Я занимаюсь проектом, где нужно из свободных текстов на естественном языке вытаскивать структурированные данные. Не разово - постоянно, по мере поступления. За несколько месяцев я перепробовал регулярки, чистый LLM и в итоге пришёл к гибриду. Ниже расскажу, что из этого всего вышло: архитектура, промпты, трудности и неочевидные решения.
Стек: Python 3.12, Ollama + Qwen 2.5 (всё локально), YAML как формат хранения, SHA256 для дедупликации, Jinja2 для шаблонизации промптов.
Проект называется Svyazi - система структурирования и поиска по профилям участников сообщества, которое я веду. Код закрытый, но архитектурные решения универсальны.
Откуда взялась задача
В сообществе есть участники, которые хотят нетворкинг. Они представляются - кто подробно, кто в два предложения, хотя, конечно, лучше так не делать. Параллельно другие ищут: «нужен фронтендер на проект», «кто работал с 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-дедупликация, создание карточки |
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/.
Вопросы и опыт по схожим проектам буду рад обсудить в комментариях.

onyxmaster
Дополните пожалуйста конкретикой по размеру используемой модели. Всё-таки Qwen 2.5 бывает от 0.5 до 72млрд параметров.
andrey_chuyan Автор
Использую Qwen 2.5 7B, хотя этого мало, коплю на видеокарту помощнее)
В идеале нужно обрабатывать на Qwen 2.5 14B и иметь 12GB VRAM