Я занимаюсь проектом, где нужно из свободных текстов на естественном языке вытаскивать структурированные данные. Не разово — постоянно, по мере поступления. За несколько месяцев я перепробовал регулярки, чистый 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. Руками — часы.
Ключевая идея решения
Делаем глубокий профиль, а не визитку. Храним не «Иван, Python‑разработчик», а стек с уровнями, реальные проекты, выступления, мягкие навыки. Пришёл я к этому не сразу. Сначала пытался разбирать взаимосвязи между людьми, мероприятиями и вакансиями, но потом понял, что фокус нужно сделать только на одном. На людях. Пришлось полностью всё рефакторить, что заняло у меня пару недель свободного времени.
Гибрид LLM + детерминированный код. LLM извлекает смысл, код нормализует результат. Каждый делает то, что умеет. Не зажимаем модель в узкие рамки, даём ей больше свободы — она творец! А вот алгоритмами жёстко приводим результат в нужные рамки.
Двухэтапный скоринг при поиске. Быстрый фильтр по индексу → LLM только для шорт‑листа. Делать всё моделью дорого и долго. Незачем мелким ситом просеивать гору — мелкое сито оставим для золотых крошек.
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. По сути, это такой же код — его точно так же надо версионировать и тестировать.
Сокращённый пример промпта
Ты - 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 секунд на одно описание. Четыре вещи помогают с этим справиться:
Кэширование по хешу. Если описание не менялось — не обрабатываем повторно.
Инкрементальность. Не «запусти всё заново», а «досыпь свежее».
Оптимизация промптов. Начинал с полутора страниц. Постепенно вырезал лишнее. Меньше токенов — быстрее ответ, качество, если модель не перегружать, в норме.
Есть очередь обработки, можно изменить несколько карточек и идти пить чай — до всех дойдёт очередь даже на слабом железе.
Приватность. Вся обработка локально. На этапе импорта персданные вырезаются и хранятся отдельно. Модель видит только обезличенный текст.
Итоги и выводы
AI + детерминированность. LLM хорошо понимает текст, но выдаёт нестабильный результат - значит, после неё нужен нормализационный слой.
Промпт - это код. Версионировать, тестировать, итерировать.
CardIndex - обязателен. Без единого источника правды система начинает дублировать и путаться.
Discovery - обратная связь от данных. Не выкидывайте неизвестное, накапливайте и анализируйте.
Оставляйте контроль человеком. Модерация
inferred-значений и пополнение справочников - за человеком. Полная автоматизация без контроля ведёт к мусору в данных.
Qwen 2.5 справляется достойно, но до GPT-4o по точности извлечения далеко — компенсирую более жёсткой нормализацией.
Что дальше
Планирую расширять справочники и углублять профили. А недавно внедрил экспериментальную фичу — для каждого участника генерирую персональный медиа‑отчёт: готовые темы для выступлений, форматы мастер‑классов, карточки коллабораций с конкретными людьми из базы. Первые эксперименты уже есть — именно из них получилась история с Wi‑Fi‑инженером и питерским разработчиком. Также можете лично попробовать систему заполнив форму https://debugskills.ru/articles/svyazi/.
Вопросы и опыт по схожим проектам буду рад обсудить в комментариях.
Комментарии (4)

sergeim52b20
25.04.2026 11:54"Двухэтапный скоринг при поиске" - это хороший подход, особенно если кандидатов много. Его можно развивать дальше, например после второго этапа добавить оценку качества, и в случае если оно недостаточно, откатываться на первый этап и брать больше кандидатов(для второго этапа).
У меня в похожей задаче однажды хорошо добавила качество обычная кластеризация. На первом этапе отбираем кандидатов, кластеризуем, и на второй этап идут top n кандидатов из каждого кластера. В целом, подобные пайплайны имеют почти бесконечный потенциал для оптимизации, если требуется максимум качества.

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