Я занимаюсь проектом, где нужно из свободных текстов на естественном языке вытаскивать структурированные данные. Не разово - постоянно, по мере поступления. За несколько месяцев я перепробовал регулярки, чистый LLM и в итоге пришёл к гибриду. Ниже расскажу, что из этого всего вышло: архитектура, промпты, трудности и неочевидные решения.

Стек: Python 3.12, Ollama + Qwen 2.5 (всё локально), YAML как формат хранения, SHA256 для дедупликации, Jinja2 для шаблонизации промптов.

Проект называется Svyazi - система структурирования и поиска по профилям участников сообщества, которое я веду. Код закрытый, но архитектурные решения универсальны.

Мое сообщество, где делятся опытом

https://debugskills.ru/

Откуда взялась задача

В сообществе есть участники, которые хотят нетворкинг. Они представляются - кто подробно, кто в два предложения, хотя, конечно, лучше так не делать. Параллельно другие ищут: «нужен фронтендер на проект», «кто работал с Kubernetes в проде?», «ищу партнёра в финтех-стартап».

Плюс я имею крайне неприятную привычку в буре рабочей повседневности забывать полезные контакты и всегда хотел иметь умную систему контактов, в которой удобно искать спикеров, авторов лабораторок и прочих полезных участников.

Описания участников начали копиться. Стало понятно, что искать по этой куче что-то конкретное нереально.

Приведу пример, что у нас может быть в знакомстве:

Привет! Я Алексей, занимаюсь бэкендом уже лет 7. Основной стек - Go и Python, последние два года плотно сижу на k8s. До этого работал в Яндексе и одном финтех-стартапе (NDA, не могу называть). Есть опыт с ML - делал рекомендательную систему, но это скорее хобби-проект. Ищу интересные проекты, можно парт-тайм. Английский - upper intermediate.

Или так:

Дизайнер, 10 лет. Figma, немного code. Spark AR.

Попробуйте найти среди двухсот таких описаний всех Go-разработчиков с Kubernetes и хотя бы базовым ML. Руками - часы.


Ключевая идея решения

1. Делаем глубокий профиль, а не визитку. Храним не «Иван, Python-разработчик», а стек с уровнями, реальные проекты, выступления, мягкие навыки. Пришёл я к этому не сразу. Сначала пытался разбирать взаимосвязи между людьми, мероприятиями и вакансиями, но потом понял, что фокус нужно сделать только на одном. На людях. Пришлось полностью всё рефакторить, что заняло у меня пару недель свободного времени.

2. Гибрид LLM + детерминированный код. LLM извлекает смысл, код нормализует результат. Каждый делает то, что умеет. Не зажимаем модель в узкие рамки, даём ей больше свободы - она творец! А вот алгоритмами жёстко приводим результат в нужные рамки.

3. Двухэтапный скоринг при поиске. Быстрый фильтр по индексу → LLM только для шорт-листа. Делать всё моделью дорого и долго. Незачем мелким ситом просеивать гору - мелкое сито оставим для золотых крошек.

4. Privacy by design. Данные, которые хоть как-то похожи на приватные, фильтруем на входе и не пускаем в карточки. А вот уже на выходе алгоритмами объединим профиль и контакт.

Почему глубокий профиль важнее каталога

Разница между «Иван, Python-разработчик» и человеком, которого действительно знаешь, - огромная. Когда данные структурированы достаточно глубоко, появляются нетривиальные вещи.

Прикольно сработал случайный эксперимент: я попробовал покопаться в поисковом индексе и через него найти коллаборации между участниками, в итоге случайно получилась экспериментальная функция - поиск коллабораций, которая пришлась участникам сообщества по душе.

Участница с 15-летним опытом в Wi-Fi-инженерии получила карточку коллаборации с разработчиком из Петербурга. Система предложила им совместный open-source проект по радиопланированию Wi-Fi-сетей - с описанием ролей и дорожной картой. Ни один из них не знал о существовании другого до этого момента. С плоским списком навыков такое не работает.

Путь был тернист...

Регулярки

Первое, самое простое, что может быть. Пишем паттерны, вытаскиваем навыки, годы опыта, компании.

Проблема: люди не пишут по шаблону. «5 лет опыта в Python» - регулярка справится. «Работаю с питоном с 2019» - нужно считать от текущей даты. «Делал кучу всего на пайтоне, потом перешёл на Go» - непонятно ни сколько лет, ни на каком уровне.

Слишком много мусора, слишком много пропущенного. Сейчас в эпоху LLM смысла в это нет, могло быть актуально лет 19 назад, но не сейчас.

Чистый LLM

Логичный следующий шаг: отдать текст модели, попросить вернуть JSON.

На практике три проблемы:

- Консистентность. Одна технология возвращается как golang, Go, Golang и Go (Golang). Для человека одно и то же, для фильтрации по базе - четыре разных навыка.

- Галлюцинации. Модель увидела «делал проект на Spark» → записала Apache Spark. Человек имел в виду Spark AR. Контекст был целиком про дизайн - не помогло.

- Нестабильный формат. Иногда ответ в ``json``, иногда с комментариями внутри (а в JSON комментариев нет), иногда с текстом до и после. Каждый такой случай - упавший парсер.

Гибрид

LLM извлекает смысл, детерминированный код приводит результат к единому виду. Из этой идеи и выросла архитектура проекта.

Архитектура: 6 слоёв

Началось всё с одного приложения, со временем код усложнился и поддерживать всё это стало слишком сложно. Поэтому на помощь пришло разделение конвейера на независимые модули - слои. Всего их шесть. Каждый делает одну вещь. Если сломался третий - первые два переделывать не надо.

YAML → [Import] → [AI Processing] → [Normalization] → [Indexing] → [Пред-скоринг] → [Семантический поиск]

Слой

Что делает

1 - Import

Парсинг YAML, отсечение персданных, SHA256-дедупликация, создание карточки pending

2 - AI Processing

Отправка в LLM, получение JSON, retry при ошибках, dead letter queue

3 - Normalization

Синонимы → канон, классификация по достоверности, стандартизация форматов

4 - Indexing

Раскладка по индексам: навыки, роли, общий

5 — Пред-скоринг

Детерминированная фильтрация, пороговые значения, формирование шорт-листа

6 — Семантический поиск

LLM-скоринг по шорт-листу, ранжирование, результаты

Сквозной пример

Слой 1. Текст Алексея попадает в систему. Вырезаем идентификаторы: телеграм, ссылки, email - они хранятся отдельно и в AI-конвейер не попадают. Считаем SHA256:

def normalize_for_hash(text: str) -> str:
return re.sub(r'\s+', ' ', text.strip().lower())
def compute_hash(text: str) -> str:
return hashlib.sha256(normalize_for_hash(text).encode()).hexdigest()

Если хеш уже есть - пропускаем. Без этого одно описание, скопированное с разным форматированием, порождает два профиля. Было пару раз на ранних этапах.

Слой 2. Обезличенный текст уходит в Qwen 2.5. Возвращается:

{
"skills": [
    {"name": "Go",     "level": "senior", "years": 7},
    {"name": "Python", "level": "senior", "years": 7},
    {"name": "k8s",    "level": "middle", "years": 2},
    {"name": "ML",     "level": "junior", "years": null}
  ],
"companies": ["Яндекс"],
"work_format": "part-time"
}

Проиллюстрировал косяки: k8s вместо kubernetes, years: 7 для Go - это она взяла из «занимаюсь бэкендом лет 7» и приписала к конкретному языку. В тексте нигде не сказано, что именно на Go человек работает семь лет. Это работа для слоя 3.

Слой 3. Детерминированный код причёсывает JSON:

k8s → kubernetes (справочник синонимов)

Go → go (lowercase-каноника)

years: 7 → null (помечено как inferred)

ML level → hobby (из контекста "хобби-проект")

Слои 4–5 - подробнее в разделе про поиск ниже.

---

СardIndex — сердце системы

В центре всей архитектуры находится CardIndex. Это не отдельный слой, а сквозной компонент, с которым работают все слои пайплайна. CardIndex — это единственный источник правды о состоянии каждой карточки.

card_id: "a1b2c3d4"
content_hash: "sha256:9f86d08..."
status: "processed"       # pending | processed | error | updated
created_at: "2024-11-15"
processed_at: "2024-11-15"
version: 3
previous_hashes:
  - "sha256:3c7a2b..."    # v1
  - "sha256:8e4f1d..."    # v2

Отслеживание состояний, дедупликация, очередь обработки, история изменений - всё через него. Первые варианты были без CardIndex, и через некоторое время начинались ошибки дублирования.

Промпт-инжиниринг

Промпт для извлечения данных - не фиксированная строка. Он менялся раз двенадцать и будет меняться ещё много раз. Постоянно размышляю над тем, как сделать механизм обратной связи для периодического самоулучшения промпта. Уверен, что дойду до этого, хотя лучшее, что я пока придумал, - это показатель качества карточки, сформированный LLM на основе полноты и проверяемости данных. Промпт храню в отдельных .md-файлах, переменные подставляю через Jinja2. По сути, это такой же код - его точно так же надо версионировать и тестировать.

<details>

<summary>Сокращённый пример промпта</summary>

Ты - AI-ассистент системы Svyazi. Извлеки информацию из текста
и верни ВАЛИДНЫЙ JSON.

## Формат ответа
{
  "name": "ФИО",
  "professional_roles": ["роль1", "роль2"],
  "technical_skills": [
    {"name": "навык", "level": 3, "verification_status": "claimed"}
  ],
  "experience_years": 5
}

## Уровни навыков
- 0–1: Новичок - базовое знакомство
- 2–3: Промежуточный - использование в проектах
- 4–5: Эксперт - глубокие знания, обучение других

## verification_status
- "verified"  - есть подтверждение (сертификат, проект)
- "claimed"   - указано самим человеком
- "inferred"  - выведено из контекста

## Правила
1. Используй ТОЛЬКО явную информацию из текста
2. Не придумывай данные, которых нет в источнике
3. Если уровень не указан явно - ставь 1, статус "inferred"
4. Не путай «упомянул технологию» и «владеет технологией»
5. Не приписывай общий стаж к конкретному навыку

## Что НЕ делать
- Не добавляй текст до или после JSON
- Не вставляй комментарии в JSON

Вылезают три конкретных проблемы:

Модель «додумывает». Без явного правила о null - ставит уровень на основании каких-то своих представлений. Senior, middle. Откуда - непонятно.

Модель путает «упомянул» и «работает». «На прошлой работе коллеги использовали Terraform, но я в это не лез» → {"name": "Terraform", "level": "junior"}. Нет. Не навык.

JSON-мусор. Комментарии внутри JSON (в JSON их нет), текст до и после, случайные markdown-обёртки. Есть отдельный слой очистки - некрасивый, но необходимый.

Нормализация

После AI - три детерминированных этапа.

Синонимы → канон. Справочник (skills_synonyms.yml):

kubernetes:
  aliases: ["k8s", "kube", "кубер", "кубернетес"]
  category: "devops"

go:
  aliases: ["golang", "го", "go lang"]
  category: "programming_language"

Поначалу было строк тридцать, сейчас под сто и постоянно растёт. Отдельный AI-агент периодически сканирует Discovery-файл и дополняет справочник на основе новых обнаруженных значений.

Классификация по достоверности:

- verified - навык однозначно распознан по справочнику

- claimed - заявлен человеком, в справочнике отсутствует

- inferred - додуман моделью из контекста; идёт на ручную модерацию

Стандартизация форматов. «Yandex», «Яндекс», «яндекс» - одна компания. Даты в ISO.

Discovery

Когда встречается навык, которого нет в справочнике, - не выкидываем, складываем:

# unknown_values.yml
- value: "bun"
  context: "перешли с node на bun в продакшене"
  occurrences: 3
  first_seen: "2024-10-22"

- value: "cursor"
  context: "пишу код в cursor, очень удобно"
  occurrences: 7
  first_seen: "2024-11-01"

Раз в какое-то время открываю этот файл: bun - рантайм, добавляю в справочник. cursor - IDE, не навык. Когда occurrences растёт - тренд, надо разобраться побыстрее. Система сама подсказывает, чего ей не хватает.

Двухэтапный поиск (слои 5–6)

Вернёмся к слоям 5 и 6 из архитектуры. Приходит запрос «Go-разработчик, senior, Kubernetes, удалёнка». В поиск можно передавать как текст, так и файл вакансии или события.

Этап 1 - пре-фильтр (детерминированный). Быстрая проверка по индексу: go есть, kubernetes есть, формат совместим. Алексей попадает в шорт-лист. Работает мгновенно, ничего не стоит.

Этап 2 - семантический скоринг. Только для шорт-листа - LLM делает глубокую оценку с учётом нюансов. «Хобби-ML» может быть плюсом, если вакансия в ML-продукте. Или нерелевантен, если ищут чистого бэкендера.

Зачем два этапа? Прогонять через LLM все 200 профилей, когда 170 отсеиваются по элементарным критериям, - долго и бессмысленно. Зачем сеять гору ситом, если сначала нужно убрать камни?

Грабли на которые наступал

Недетерминированность. temperature=0 не гарантирует одинаковый результат. Что делаю: few-shot примеры в промпте, валидация ответа по JSON-схеме, retry до трёх попыток. После трёх - карточка уходит в error, для разбора вручную.

Галлюцинации. «Руководил командой из 5 человек» про волонтёрский проект → team_lead как профессиональный навык. Поэтому: обязательное поле confidence и правило - всё с меткой inferred проходит модерацию перед попаданием в индекс. Создаёт ручную работу, зато нет мусора в данных.

Скорость. Qwen 2.5 на моём железе - 120–200 секунд на одно описание. Четыре вещи помогают с этим справиться:

- Кэширование по хешу. Если описание не менялось - не обрабатываем повторно.

- Инкрементальность. Не «запусти всё заново», а «досыпь свежее».

- Оптимизация промптов. Начинал с полутора страниц. Постепенно вырезал лишнее. Меньше токенов - быстрее ответ, качество, если модель не перегружать, в норме.

- Есть очередь обработки, можно изменить несколько карточек и идти пить чай - до всех дойдёт очередь даже на слабом железе.

Приватность. Вся обработка локально. На этапе импорта персданные вырезаются и хранятся отдельно. Модель видит только обезличенный текст.

Итоги и выводы

1. AI + детерминированность. LLM хорошо понимает текст, но выдаёт нестабильный результат - значит, после неё нужен нормализационный слой.

2. Промпт - это код. Версионировать, тестировать, итерировать.

3. CardIndex - обязателен. Без единого источника правды система начинает дублировать и путаться.

4. Discovery - обратная связь от данных. Не выкидывайте неизвестное, накапливайте и анализируйте.

5. Оставляйте контроль человеком. Модерация inferred-значений и пополнение справочников - за человеком. Полная автоматизация без контроля ведёт к мусору в данных.

Qwen 2.5 справляется достойно, но до GPT-4o по точности извлечения далеко - компенсирую более жёсткой нормализацией.

Что дальше

Планирую расширять справочники и углублять профили. А недавно внедрил экспериментальную фичу - для каждого участника генерирую персональный медиа-отчёт: готовые темы для выступлений, форматы мастер-классов, карточки коллабораций с конкретными людьми из базы. Первые эксперименты уже есть - именно из них получилась история с Wi-Fi-инженером и питерским разработчиком. Также можете лично попробовать систему заполнив форму https://debugskills.ru/articles/svyazi/.

Вопросы и опыт по схожим проектам буду рад обсудить в комментариях.

Комментарии (2)


  1. onyxmaster
    25.04.2026 11:54

    Дополните пожалуйста конкретикой по размеру используемой модели. Всё-таки Qwen 2.5 бывает от 0.5 до 72млрд параметров.


    1. andrey_chuyan Автор
      25.04.2026 11:54

      Использую Qwen 2.5 7B, хотя этого мало, коплю на видеокарту помощнее)

      В идеале нужно обрабатывать на Qwen 2.5 14B и иметь 12GB VRAM