Ассистент отвечает на вопросы клиентской поддержки по русскоязычной базе
знаний конкретного тенанта. Задача — не давать ответов про коды ошибок,
гарантию или биллинг компании, для которых в базе нет подтверждающих
пассажей: каждый ответ опирается на найденные фрагменты, и до десяти
извлечённых утверждений перепроверяются по ним до того, как ответ покинет
граф, — если включена проверка фактов и контекст поиска непустой.
Первый скриншот — чат-UI ассистента с живым ответом на E20 (тот же
кейс, который разобран ниже в «Реальный вопрос, end-to-end»). Это статический
UI-shell static/chat.html, отрендеренный через production-функцию
addMessage на том самом JSON, что вернул реальный прогон графа /api/ask
(sources + citations + badges Качество/Маршрут), снятый на изолированном
демо-тенанте, чтобы не задеть корпус владельца. Layout, citation-pill,
disclosure «Источники» — реальный DOM, заполненный этим живым ответом.
Остальные три скриншота — с самого docs-хаба: лэндинг, секция
«Measured baseline» и автогенерируемая схема LangGraph.
Чат-UI — E20 walkthrough через production-функцию addMessage на живом ответе /api/ask (external-mistral, снят 2026-06-16; citation, бейджи, sources disclosure — реальный DOM).Лэндинг — заголовок, три кнопки навигации, секция Proof points.Страница оценки — измеренные агрегаты + per-category числа из benchmark_offline_with_docs.py.Архитектура / LangGraph — flowchart Mermaid, сгенерированный из agent/graph.py.
В репозитории лежит набор из 12 фикстурных вопросов
evaluation/test_cases.json по шести темам: коды ошибок, сброс пароля,
гарантия, установка, биллинг, общее. Демо-загрузчик
(demo/seed_docs.py) кладёт исходные файлы в каталог
для последующей индексации обычным каналом загрузки в базу знаний; сам
загрузчик векторный индекс не строит. Ниже один из этих вопросов
прогнан через полный конвейер LangGraph на этом корпусе после индексации.
Демо-корпус: гарантия, возвраты и ошибки E10-E30 (errors_e10_e30.md).
Конвейер — это конечный автомат LangGraph. Он живёт в
agent/graph.py;
полный flowchart со всеми узлами и условными переходами генерируется
автоматически на странице
LangGraph state machine.
Разбор ниже называет каждый узел в том порядке, в котором их реально
проходит граф.
Retrieve 1-3: classify_complexity — классифицирует вопрос как simple, complex
или unknown. Метка управляет выбором модели (быстрая для простых,
сильная для сложных), а не путём через граф: любой вопрос дальше уходит
в transform_query.
Retrieve 1-3: transform_query — переписывает вопрос в одну строку search_query.
Если есть история переписки, уточняющие вопросы сначала переписываются
в самодостаточный запрос. Если включён HyDE (hypothetical-document
embedding — модель пишет правдоподобный ответ-черновик, который потом
эмбеддится и идёт как пробный запрос для поиска), сгенерированный
гипотетический ответ сохраняется как hyde_query, и при удачном HyDE
узел retrieve берёт его вместо search_query.
Retrieve 1-3: retrieve — гибридный поиск по коллекции ChromaDB,
выделенной под тенанта. Изоляция тенантов сделана через разные
коллекции: tenant_id берётся из JWT на входе в API и используется
для выбора отдельной Chroma-коллекции
({prefix}_{sanitize(tenant_id)} — см. vectordb/manager.py).
Санитайзер приводит запрещённые в имени коллекции символы к _ и
обрезает строку по лимиту 63 символа, так что изоляция держится,
пока сырые tenant_id остаются различимыми после нормализации.
Запросы к Postgres фильтруются по tenant_id на уровне ORM.
Generate 4-5: grade_docs (Corrective RAG — модель сама пере-оценивает каждый
найденный фрагмент по схеме YES/NO на фактическую релевантность,
а не доверяет ранжированию по векторному сходству) — фрагменты с
меткой NO отбрасываются до генерации, при этом топ-1 векторное
совпадение сохраняется как запасной вариант, если хоть что-то ещё
прошло. Это формирует то, что увидит generate, но само по себе
повтор не запускает — решение о повторе принимает route_or_retry
уже по оценкам само-проверки.
Generate 4-5: generate — QA-промпт получает только отграждированные фрагменты
плюс вопрос, каждый фрагмент подаётся в виде
[Документ N | source=<filename>]. Модель выбирается активным
профилем маршрутизации (по умолчанию gracekelly-primary, запасной
путь на Ollama при сбое провайдера — см.
Provider routing).
Verify & Decide 6-8: verify_facts — когда проверка фактов включена и контекст поиска
непустой, из черновика ответа извлекаются до десяти утверждений, и
каждое перепроверяется против найденных фрагментов. Каждое
проверенное утверждение помечается supported / unsupported в
трейсе, а в factuality_score пишется процент подтверждённых
утверждений; на пропущенных путях / без утверждений пишется 100.
Эта разметка независима от само-оценки следующего шага.
Verify & Decide 6-8: evaluate — запускает само-оценку моделью против контекста
поиска и выдаёт quality_score (1–100) и relevance_score
(выводится как quality_score / 100, округлённый до трёх знаков —
обычно 0.01–1.0, потому что quality_score зажат в 1–100). Эти
оценки читает дальше route_or_retry.
Verify & Decide 6-8: route_or_retry — выставляет вердикт из двух оценок и
оставшегося бюджета итераций. Для auto должны пройти ОБА порога:
auto (quality ≥ min_qualityи relevance ≥ min_relevance,
по умолчанию 80 и 0.8) → suggest_questions → log
retry (любой из порогов не пройден, итерации ещё есть) →
rewrite_query → retrieve → … (Self-RAG — цикл, который
переписывает запрос и снова ищет, когда само-оценка слишком
низкая; ограничен max_iterations)
human (любой из порогов не пройден, итерации исчерпаны) → log
напрямую, без подсказок
error (состояние несёт ошибку из более раннего узла) →
handle_error → END
Finalize 9-10: suggest_questions — генерирует три уточняющих вопроса из
контекста ответа, чтобы засеять следующий шаг диалога. Запускается
только при вердикте auto.
Finalize 9-10: log — хранилище трейсов сохраняет порядок узлов, снимки
состояния, ссылки на найденные документы и цитаты, поставщика и
модель, расход токенов и оценку стоимости. Длительность каждого
узла и интервалы LLM-вызовов уносят Langfuse и OpenTelemetry, когда
соответствующие экспортёры настроены.
QA-промпт подаёт каждый найденный фрагмент с тегом source=<filename>
внутренне (см. agent/prompts.py) и просит модель ставить нумерованную
цитату [N], когда опирается на конкретный факт. Имена источников,
на которые эти цитаты ссылаются, возвращаются отдельно в полях sources
и citations JSON-ответа /api/ask. Ответ с опорой на источники для
комплектного кейса E20 выглядит примерно так:
Ошибка E20 связана с проблемой слива воды. Возможные причины:
засорённый сливной фильтр, перегиб сливного шланга или неисправность
сливного насоса [1].
Возможно, вас также интересует:
• Что делать, если фильтр чистый, а ошибка остаётся?
• Где найти инструкцию по разборке сливного узла?
• Какие коды ошибок ещё связаны с водой?
{
"sources": [
{ "source": "errors_e10_e30.md", "page_content": "E20 — проблема со сливом воды …" }
Точные значения полей меняются от запуска к запуску, но форма закреплена:
sources — список объектов {source, page_content}, citations — список
объектов {index, doc_id, title, excerpt} — см. Pydantic-модели
AskResponse, SourceInfo и Citation в api/routers/conversation.py.
Тело ответа ограничено тем, что реально написано в errors_e10_e30.md
про E20 (засорённый фильтр, перегиб сливного шланга, неисправность
сливного насоса).
expected_keywords этого кейса (ошибка, проверьте, устройство) НЕ
сверяются с текстом ответа — benchmark_runner подаёт их в
context_recall, которое проверяет, появляются ли ключевые слова в
контексте, найденном на этапе retrieve. Поле expected_answer
потребляется отдельно как фикстура для офлайн-метрик качества ответа.
Любой вопрос проходит один и тот же скелет:
classify → transform → retrieve → grade → generate → verify → evaluate → route_or_retry. Различия между темами — в том, какие оценки обычно
выдаёт evaluate, и какой вердикт из-за этого выберет route_or_retry.
Таблица ниже называет, что чаще всего может запустить повтор по теме;
это качественная оценка по комплектному набору фикстур, не измеренное
число.
Категория
Пример
Что может запустить повтор
error_codes
Что означает ошибка E401?
Вопрос про код, которого нет в индексе (E401 в наборе фикстур есть, но в errors_e10_e30.md лежат только E10/E20/E25/E30) — поиск возвращает нерелевантные фрагменты, evaluate падает, route_or_retry переписывает запрос. Разбор выше (E20) в базе есть и приземляется чисто.
reset_password
Как сбросить пароль?
Короткий фактический; в демо-базе нет документа про сброс пароля, поэтому поиск вернёт пусто или нерелевантное, и evaluate упадёт.
warranty
Сколько длится гарантия?
Короткий фактический; документ про гарантию в демо-базе есть, поиск приземляется чисто.
installation
Как установить приложение?
В демо-базе нет документа про установку — поиск вернёт пусто или нерелевантное, evaluate упадёт, route_or_retry уйдёт в retry или human.
billing
Как отменить подписку?
В демо-базе нет документа про биллинг — тот же путь, что у installation.
general
Как связаться с поддержкой?
Короткий фактический; если ответ выводится из комплектных документов — evaluate пройдёт, иначе тот же путь с пустым результатом поиска.
Из финального ответа извлекается до десяти утверждений; каждое сверяется с найденными фрагментами в verify_facts, который ставит каждому supported / unsupported и пишет factuality_score как процент подтверждённых. evaluate отдельно прогоняет само-оценку против найденного контекста, и решение «вернуть ответ или сделать повтор» принимает route_or_retry именно по этой оценке, а не по меткам verify_facts.
Самокорректирующийся поиск
Corrective RAG (grade_docs) выкидывает нерелевантные фрагменты до генерации. Цикл Self-RAG (route_or_retry → rewrite_query → retrieve → …) запускается оценками evaluate, а не количеством фрагментов, и ограничен max_iterations.
Мульти-тенант по построению
tenant_id берётся из JWT на входе в API и адресует каждый запрос к векторной базе в выделенную под тенанта коллекцию ChromaDB ({prefix}_{sanitize(tenant_id)}). Запросы к Postgres фильтруются по tenant_id на уровне ORM. Тенанты попадают в разные коллекции, пока их сырые id остаются различимыми после санитизации и обрезки по 63-символьному лимиту имени коллекции в Chroma.
Прозрачность от начала до конца
Хранилище трейсов пишет порядок узлов, снимки состояния, ссылки на найденные документы, поставщика и модель, расход токенов и стоимость — любой ответ из прода можно воспроизвести офлайн. Длительность LLM-вызовов и спаны несут Langfuse и OpenTelemetry, когда настроены.