От каши к структуре: гибридная AI-система для обработки свободного текста. DevOps.. DevOps. llm.. DevOps. llm. python.. DevOps. llm. python. qwen.. DevOps. llm. python. qwen. yaml.. DevOps. llm. python. qwen. yaml. гибридная архитектура.. DevOps. llm. python. qwen. yaml. гибридная архитектура. нетворкинг.. DevOps. llm. python. qwen. yaml. гибридная архитектура. нетворкинг. нормализация.. DevOps. llm. python. qwen. yaml. гибридная архитектура. нетворкинг. нормализация. обработка естественного языка.. DevOps. llm. python. qwen. yaml. гибридная архитектура. нетворкинг. нормализация. обработка естественного языка. поиск по профилям.. DevOps. llm. python. qwen. yaml. гибридная архитектура. нетворкинг. нормализация. обработка естественного языка. поиск по профилям. структурирование данных.. DevOps. llm. python. qwen. yaml. гибридная архитектура. нетворкинг. нормализация. обработка естественного языка. поиск по профилям. структурирование данных. Управление разработкой.

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

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

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

От каши к структуре: гибридная AI-система для обработки свободного текста - 1

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

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 извлекает смысл, детерминированный код приводит результат к единому виду. Из этой идеи и выросла архитектура проекта.

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

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

От каши к структуре: гибридная AI-система для обработки свободного текста - 2

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 отсеиваются по элементарным критериям, – долго и бессмысленно. Зачем сеять гору ситом, если сначала нужно убрать камни?

От каши к структуре: гибридная AI-система для обработки свободного текста - 3

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

Недетерминированность. 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/.

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

Автор: andrey_chuyan

Источник