RAG по базе знаний службы поддержки. На вопрос пользователя система находит релевантные страницы справки, а затем решает, что с ними делать: ответить по найденному контексту, задать уточняющий вопрос или признать, что ответа в базе нет. Вырос из задачи хакатона Alfa Hack и переработан с нуля самостоятельно. Работает полностью локально: эмбеддинги через sentence-transformers, поиск через FAISS, генерация через Ollama.
Пайплайн от предметной области не зависит и запускается на любом csv с колонками
web_id, title, url, text — см. «Свои данные». К домену привязан только слой
guardrails.py: это словарь русских маркеров банковской поддержки, на других данных
его нужно переписывать.
Индексация: текст страницы склеивается с её заголовком, пробелы нормализуются,
результат режется RecursiveCharacterTextSplitter на куски по 900 символов
с перекрытием 150 и кодируется в 384-мерные векторы.
запрос
├─> dense retrieval по чанкам (multilingual-e5-small + FAISS IndexFlatIP)
├─> агрегация чанков в документы (max score на web_id)
├─> [опционально] CrossEncoder-реранк
├─> guardrails: разметка запроса по признакам (intent, маркеры продукта,
│ процесса, персональных данных, overlap с найденным)
├─> decision layer: правила по этим признакам -> ok / need_clarify / no_answer
└─> ветвление:
ok -> генерация ответа строго по найденным источникам (Ollama)
need_clarify -> уточняющий вопрос: ближайший пример из размеченного
банка уточнений, при необходимости переформулированный LLM
no_answer -> отказ с предложением переформулировать
Decision layer нужен, чтобы не отвечать любой ценой. Обычный RAG всегда что-то генерирует, а на клиентских обращениях это даёт уверенные ответы на вопросы, которые без уточнения ответа не имеют вовсе — вроде «у меня там оставались деньги на счёте». Отсюда три класса вместо простого порога по score.
Данные хакатона: 1937 страниц справки, 18 965 чанков по 900 символов с перекрытием 150.
Разметка — 150 обращений: 41 ok, 105 need_clarify, 4 no_answer.
Считается на 41 запросе с меткой ok, метрика на уровне документов. Релевантные документы
выбирались из топ-20 этой же системы, поэтому числа отражают качество ранжирования
внутри найденного, а не полноту поиска (см. «Ограничения»). В скобках — 95% доверительный
интервал, бутстрап по запросам.
| режим | recall@1 | recall@5 | MRR@10 |
|---|---|---|---|
| полный пайплайн, dense + guardrails | 0.610 [0.463, 0.756] | 0.878 [0.780, 0.976] | 0.740 [0.634, 0.840] |
| чистый dense, без guardrails | 0.585 [0.439, 0.732] | 0.878 [0.780, 0.976] | 0.725 [0.620, 0.825] |
| dense + CrossEncoder-реранк | 0.463 | 0.805 | — |
Разница между первыми двумя строками — фильтрация служебных страниц в guardrails: капча, формы входа, документы без заголовка. На выборке в 41 запрос она укладывается в доверительный интервал, поэтому как прибавка не заявляется.
CrossEncoder-реранк выключен по умолчанию (USE_RERANK=0): на этих данных он снижает
recall@1 с 0.585 до 0.463. Подъём страницы контактов по CALLCENTER_PIN_WEB_ID выключен
там же — на метрики он не влияет.
Перебирались размер чанка, место заголовка, способ агрегации чанков в документ, четыре других энкодера, BM25 и гибрид с ним, два реранкера. 900 символов оптимальны, заголовок в начале документа даёт +0.15 к recall@1, повторять его в каждом чанке вредно. Всё, что меняет модель поиска, этой разметкой корректно не измеряется: от 28% до 59% выдачи таких систем в разметку не попадала.
Классы несбалансированы: 109 обращений из 150 требуют уточнения, поэтому основная метрика —
macro-F1. Класс no_answer слит с need_clarify: в разметке его 4 примера и он
не предсказывается ни разу.
| macro-F1 | accuracy | правильных ok |
|
|---|---|---|---|
| правила | 0.742 | 0.773 | 32 из 41 |
| константа «всегда need_clarify» | 0.421 | 0.727 | 0 из 41 |
| класс | precision | recall | f1 | support |
|---|---|---|---|---|
| ok | 0.561 | 0.780 | 0.653 | 41 |
| need_clarify | 0.903 | 0.771 | 0.832 | 109 |
По accuracy разрыв с константой копеечный, но константа переспрашивает всегда и не отвечает ни на один вопрос из 41. По macro-F1 разрыв двукратный.
Разметка проверялась вслепую на 74 обращениях, из них 21 расхождение пересмотрено с выдачей ретривера перед глазами: исходная метка подтвердилась в 17 случаях из 21. Итоговое согласие с разметкой около 95%.
Абляция признаков, разбор расхождений и трёхклассовый вариант — в docs/experiments.md.
Числа воспроизводятся, если положить в data/ файлы хакатона websites.csv
и gold_labels.csv:
python scripts/make_chunks_parquet.py --inp data/websites.csv --out data/chunks_websites.parquet
python scripts/evaluate.py --chunks chunks_websites.parquet --gold data/gold_labels.csv --with-rerankДанные хакатона в репозиторий не выкладываются. Вместо них есть синтетический демо-корпус из 16 документов вымышленного банка — на нём проект запускается сразу после клонирования, но метрики выше на нём не воспроизводятся, он нужен только для проверки работоспособности.
git clone https://github.com/belkovskyy/alfa-rag-demo.git
cd alfa-rag-demo
python -m venv .venv
.venv\Scripts\activate # Linux/macOS: source .venv/bin/activate
pip install -e ".[app,dev]"
python scripts/make_demo_corpus.py
python scripts/run_demo.py "Где посмотреть все мои счета?"Первый запуск скачивает intfloat/multilingual-e5-small (~470 МБ) и строит FAISS-индекс;
дальше индекс берётся из кэша в папке с данными.
Веб-интерфейс — python -m streamlit run apps/streamlit_app.py, тесты — pytest.
Генерация ответов опциональна и требует локальной Ollama
с любой моделью (ollama pull qwen2.5:3b). Без неё пайплайн отрабатывает полностью,
но вместо сгенерированного ответа печатается список найденных источников.
Нужен csv с колонками web_id, title, url, text:
python scripts/make_chunks_parquet.py --inp data/mydocs.csv --out data/chunks.parquet
python scripts/run_demo.py --chunks chunks.parquet "запрос"Для замера метрик нужна ещё разметка q_id, query, label_status, gold_web_ids, clarify_note,
где label_status — одно из ok / need_clarify / no_answer. Образец формата —
data/gold_labels_sample.csv.
Через переменные окружения: USE_RERANK, RERANK_MODEL, E5_MODEL, OLLAMA_URL,
OLLAMA_MODEL, K_CHUNKS, K_DOCS, DATA_DIR, CHUNKS_PATH, GOLD_PATH,
CALLCENTER_PIN_WEB_ID.
Последняя задаёт документ, который поднимается в начало выдачи, когда запрос похож
на просьбу позвонить в поддержку. По умолчанию выключено; на данных хакатона это была
страница с контактами, CALLCENTER_PIN_WEB_ID=862.
src/alfa_rag/
retrieval.py dense-поиск по чанкам, агрегация в документы, кэш индекса
rerank.py CrossEncoder-реранк (выключен по умолчанию)
guardrails.py признаки запроса и найденной выдачи для decision layer
decision.py правила ok/need_clarify/no_answer и опциональный ML-gate
clarify.py банк уточнений и подбор ближайших примеров
llm.py генерация через Ollama, фолбэки при её отсутствии
service.py сборка пайплайна
eval.py метрики retrieval и прогон по размеченному набору
labeling.py интерактивная разметка gold-набора
scripts/
make_demo_corpus.py синтетический корпус для запуска без данных хакатона
make_chunks_parquet.py нарезка своего csv на чанки
run_demo.py один запрос через весь пайплайн
evaluate.py метрики из раздела «Результаты»
ablate_features.py вклад каждого признака guardrails в решение
apps/streamlit_app.py веб-интерфейс
notebooks/RAG.ipynb исходный ноутбук хакатона
tests/ тесты на guardrails, decision layer, чанкинг и утилиты
data/gold_labels_sample.csv 30 строк разметки как образец формата
Gold-набор размечался по выдаче системы: показывался топ-20, релевантные документы выбирались из него. Поэтому recall@1/3/5 меряют качество ранжирования внутри найденного, а не полноту поиска, а сравнивать между собой корректно только конфигурации с одной моделью (разбор — в docs/experiments.md). Выборка небольшая, 41 запрос для retrieval, поэтому доверительные интервалы приведены рядом с каждой метрикой.
Ретривер, схема ok/need_clarify/no_answer, банк уточнений и генерация по контексту
переносятся на любую базу знаний; к домену привязан только словарь маркеров в guardrails.py.
MIT, см. LICENSE. Данные хакатона под лицензию не подпадают и в репозитории не распространяются.