Я разрабатываю систему под названием Svyazi — инструмент для структурирования и поиска по профилям участников сообщества. Основная задача — из свободных текстов извлекать структурированные данные и делать это непрерывно, по мере поступления. За несколько месяцев я перепробовал регулярные выражения, чистый LLM и пришёл к гибридному решению. Ниже — архитектура, промпты, трудности и неочевидные решения.
Стек: Python 3.12, Ollama + Qwen 2.5 (всё локально), YAML для хранения, SHA256 для дедупликации, Jinja2 для шаблонизации промптов.
Откуда взялась задача
В моём сообществе участники часто ищут коллег: «нужен фронтендер», «кто работал с 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, такой подход бессмысленен.
Чистый LLM: три главные проблемы
- Неконсистентность: одна технология возвращается как golang, Go, Golang, Go (Golang).
- Галлюцинации: «делал проект на Spark» → Apache Spark, хотя имелся в виду Spark AR.
- Нестабильный формат: ответы с комментариями, текстом до и после, markdown-обёртками — всё это ломает парсер.
Вывод: 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: текст Алексея попадает в систему. Вырезаются персональные данные — Telegram, email, ссылки. Считается SHA256. Если хеш уже есть — профиль пропускается. Это предотвращает дубли.
Слой 2: обезличенный текст отправляется в Qwen 2.5. Модель возвращает JSON. Пример проблемы: указывает k8s вместо kubernetes, а 7 лет опыта на Go — потому что человек 7 лет в бэкенде, но не сказано, что именно на Go.
Слой 3: детерминированный код нормализует данные:
- k8s → kubernetes (по справочнику синонимов)
- Go → go (канонизация в lowercase)
- years: 7 → null (помечено как inferred)
- ML level → hobby (по контексту «хобби-проект»)
CardIndex — сердце системы
CardIndex — единый источник правды. Он отслеживает состояние всех карточек: статус, дедупликацию, очередь, историю изменений. Без него система начинает плодить дубли и терять согласованность.
Промпт-инжиниринг
Промпт не статичен. Он менялся более десяти раз и будет меняться дальше. Хранится в .md-файлах, подставляется через Jinja2. Это код — его нужно версионировать и тестировать.
Три основные проблемы с промптами:
- «Додумывание»: модель ставит уровень senior или middle без явных оснований.
- Путаница «упомянул» и «работает»: «коллеги использовали Terraform, но я в это не лез» → навык Terraform с уровнем junior.
- JSON-мусор: комментарии, текст вокруг JSON, markdown. Требуется отдельный слой очистки.
Нормализация: три детерминированных этапа
- Синонимы → канон: справочник skills_synonyms.yml вырос с 30 до почти 100 строк. AI-агент периодически дополняет его.
- Классификация по достоверности:
- verified — навык точно подтверждён
- claimed — заявлен, но нет в справочнике
- inferred — додуман моделью, требует модерации
- Стандартизация форматов: «Yandex», «Яндекс» → единый канон. Даты — в ISO.
Новые навыки не удаляются, а накапливаются. Когда частота ростёт — это сигнал: возможно, появился тренд.
Двухэтапный поиск
Запрос: «Go-разработчик, senior, Kubernetes, удалёнка».
Этап 1 — пре-фильтр: детерминированная проверка по индексу. Быстро, дёшево. Алексей попадает в шорт-лист.
Этап 2 — семантический скоринг: LLM оценивает нюансы. Например, «хобби-ML» может быть плюсом для ML-вакансии, но не нужен чистому бэкендеру.
Зачем два этапа? Зачем прогонять 200 профилей через LLM, если 170 отсеиваются по базовым критериям?
Грабли, на которые наступал
- Недетерминированность: даже при temperature=0 результаты могут отличаться. Решение: few-shot примеры, валидация по JSON-схеме, retry до трёх раз. После — ручной разбор.
- Галлюцинации: «руководил волонтёрским проектом» → team_lead. Решение: поле confidence, модерация всех inferred-значений.
- Скорость: Qwen 2.5 обрабатывает профиль за 120–200 секунд. Помогают:
- Кэширование по хешу
- Инкрементальная обработка
- Оптимизация промптов (сократил с полутора страниц)
- Очередь — можно идти пить чай, система сама дойдёт до всех
- Приватность: всё локально. Персданные вырезаются на этапе импорта. Модель видит только обезличенный текст.
Итоги и выводы
- AI + детерминированность: LLM понимает текст, но требует нормализации после себя.
- Промпт — это код: версионировать, тестировать, итерировать.
- CardIndex обязателен: без единого источника правды система теряет согласованность.
- Discovery — обратная связь от данных: не выкидывайте неизвестное, анализируйте накопленное.
- Контроль остаётся за человеком: модерация inferred-навыков и пополнение справочников — ручная работа, но она предотвращает мусор в данных.
Qwen 2.5 справляется достойно, но уступает GPT-4o по точности. Компенсирую это более жёсткой нормализацией.
Что дальше
Планирую расширять справочники и углублять профили. Недавно внедрил экспериментальную функцию — персональные медиа-отчёты для участников: темы выступлений, форматы мастер-классов, карточки коллабораций. Первые результаты уже есть — включая историю с Wi-Fi-инженером и питерским разработчиком.