Grounding fix v1 — закрытый список источников + реквизиты из реестра
Фикс уверенной галлюцинации Люми на запросах про конкретные НПА РК. Дата: 17 апреля 2026.
Проблема
Тестовый диалог с Камалом показал три пересекающиеся дыры:
- Уверенная фабрикация номеров. На запрос «сан требования к пункту питания» Люми родила «Приказ МЗ РК №26 от 20.02.2023 о водоисточниках» — номер и дата выдуманы, документ реально существует, но с другим номером/датой. Только под пушбэком призналась.
- Мид-сентенс суррогаты от citation_verifier. Префиксы вида «(соответствующий приказ РК)» появлялись мусорной вставкой посреди предложения, ломая смысл.
- COVID-времён требования всплывали как текущие («разметка 1.5м», «бесконтактные термометры»).
На вопрос «это со всеми моделями или мы что-то не так сделали?» — ответ: это структурный архитектурный пробел, не только LLM. У нас было 90% нужных данных (388 НПА РК + ID V2300031934 у документа id=154), но retrieval их не доставал из-за синонимов (водоснабжение vs водоисточники) и реестр не показывал LLM реквизитов.
Решение (три слоя)
Слой 1 — Closed-world grounding hint
В retriever.py::build_retrieval_sources_hint() заголовок блока с «собрано из...» (открытый мир, рекомендация) перевёрнут в ЗАКРЫТЫЙ СПИСОК ИСТОЧНИКОВ (закрытый мир, жёсткое правило):
[ЗАКРЫТЫЙ СПИСОК ИСТОЧНИКОВ] Это единственные документы, на которые
ты имеешь право ссылаться по номеру, дате или статье в этом ответе:
• «Санитарные правила ...» — Әділет V2300031934, утв. 21.04.2025 [РК, НПА]
• ...
ЖЁСТКОЕ ПРАВИЛО:
— Номера приказов, даты, статьи называй ТОЛЬКО из этого списка или контекста ниже.
— «Әділет V23...» и «утв. DD.MM.YYYY» — официальный идентификатор, можешь
ссылаться: «... (Әділет V2300031934, утв. 21.04.2025)». Не подменяй выдумкой.
— Если пользователь просит номер, которого нет в списке — честно: «точного
номера сейчас нет, могу описать требования по сути».
— Внутри списка текст документов у тебя есть целиком — не говори «базы нет».
Слой 2 — Citation stripping вместо substitution
В bot.py::_strip_citation() три стратегии удаления, а не подстановки суррогата:
- Parenthetical
(Приказ X)→ удалить скобку целиком. - Connector before («согласно X», «в соответствии с X») → удалить коннектор + референс.
- Fallback
_neutral_qualifier()— короткая нейтральная форма без служебных скобок.
Постобработка: _cleanup_after_strip убирает многопробелы, пустые скобки, висящую пунктуацию, двойные запятые. Логируется Citation unverified: ... [stripped|kept].
Слой 3 — Registry enrichment с реквизитами
document_registry.py::parse_file_metadata() — парсит поле file в registry.json:
v2300031934.21-04-2025.rus → (adilet_id="V2300031934", date_approved="21.04.2025")
Формат Әділет: <adilet_id>.<dd-mm-yyyy>.<lang>. Regex: ^([\w\-]+)\.(\d{2})-(\d{2})-(\d{4})\.(\w{2,5})$.
DocumentRegistry.__init__ при загрузке итерирует all_docs, вызывает парсер, пишет adilet_id и date_approved в каждый док. Логирует enriched_count/total.
DocumentRegistry._by_title — параллельный O(1) индекс по нормализованному заголовку для быстрого lookup из retriever.
DocumentRegistry.get_by_title(title) — точное совпадение + fallback по prefix (на случай обрезанных заголовков в метаданных ChromaDB).
DocumentRegistry.format_requisites(doc) → "Әділет V2300031934, утв. 21.04.2025". Пусто для международных (у них формат file другой).
retriever.py::build_retrieval_sources_hint — для каждого документа из ranked вызывает get_by_title() и, если есть, подставляет реквизиты в строку источника.
Volume mount для registry_v2.json
Отдельная структурная проблема, вскрытая в процессе деплоя.
Симптом: после каждой пересборки Docker реестр «отваливался». В логе циклами WARNING → docker cp руками → INFO → пересборка → снова WARNING.
Причина: registry_v2.json — это производное (сгенерировано сканом ChromaDB), а не исходник. В Dockerfile никогда не было COPY registry_v2.json — файл жил только на хосте в /opt/lyumi/. Подкидывался вручную через docker cp, что переживало только текущий инстанс контейнера.
Фикс: добавили volume mounts в docker run:
-v /opt/lyumi/registry_v2.json:/app/registry_v2.json:ro \
-v /opt/lyumi/registry.json:/app/registry.json:ro \
Эффекты:
- Пересборка образа больше не ломает реестр — файл идёт мимо образа.
- Обновление реестра (новый скан ChromaDB) = один docker restart, без билда.
- Единственный способ снова сломать — запустить docker run без флага.
Стандартная команда деплоя теперь 5 volume'ов, не 3. Рекомендуется добавить алиас lyumi-run в ~/.bashrc на сервере.
Файлы
hse_copilot/document_registry.py—parse_file_metadata, enrichment в__init__,_by_title,get_by_title,format_requisiteshse_copilot/retriever.py—build_retrieval_sources_hintс реестром + жёсткое правилоhse_copilot/bot.py—_strip_citation,_cleanup_after_strip, обновлённаяverify_citationshse_copilot/llm.py— блок «Номера документов и нормативка» переписан с «не придумывай» на «только из закрытого списка» + негативный пример
Тесты (smoke)
- «сан требования к удалённому пункту питания с шашлыком» — ожидание: либо реальный ҚР ДСМ-16, либо honest fallback «точного номера нет, опишу по сути».
- «найди приказ про водоснабжение на производстве» — ожидание:
Әділет V2300031934, утв. 21.04.2025вместо фантомного «№26 от 20.02.2023».
Что дальше
- Парсинг настоящего номера приказа из первого чанка каждого документа (сейчас
titleв реестре без номера). - Synonym expansion для retrieval (водоснабжение ↔ водоисточники, питание ↔ общепит).
- Phase 2 — tool calling:
lookup_npa(query)как отдельный инструмент для LLM. - Phase 3 — structured writing: PLAN → WRITE → VALIDATE → ASSEMBLE с whitelist-проверкой каждой ссылки на НПА.
Связанные
- [[lyumi/bot_v3]] — общая архитектура
- [[lyumi/kz_monitor_spider]] — источник обновлений реестра