Поднял за выходные решение, которое давно хотел проверить руками: RAG, который отвечает строго по корпусу и к каждому утверждению ставит ссылку на пункт правил — или честно пишет «В корпусе нет основания для ответа». Корпус — приказ Минспорта России №834, «Правила вида спорта “волейбол”» (плюс немного про баскетбол). Модель — Gemma-4, локально, через Ollama (сделано нарочно на Ollama, знаю про vLLM / SGLang, здесь было целью — проверить гипотезу быстро и дешево). На слое инференса ни одного внешнего вызова: можно физически отключить сеть — оно продолжает работать.
Это не «ещё один чат с PDF». Цель была узкая и проверяемая: измерить, насколько ИИ врёт со ссылками, и построить механизм, чтобы он либо ссылался на проверяемый источник, либо отказывался. Для государства, права, медицины уверенная выдумка ссылки на норму — это не «галлюцинация», это дисквалификация и полный стоп в переговорах. Поэтому я не верю демкам — я верю замерам. Ниже — стек, баги (с них и начну, честно) и цифры.
Стек
→ Эмбеддинги: BGE-M3 через Ollama (1024-dim dense). Сознательно без torch — чтобы один и тот же код жил и на Mac, и на Linux-GPU. → Векторная база: pgvector в Docker, HNSW + cosine. → Реранк: кросс-энкодер bge-reranker-v2-m3. → Генерация: Gemma-4 (gemma4:12b на сервере, gemma4:e4b на устройстве) через Ollama. → API/демо: FastAPI + одна статическая страница.
Конвейер: вопрос → BGE-M3 → pgvector top-40 → реранк → top-4 → Gemma-4 с инструкцией ставить [п. N.N] → программный гард, который проверяет каждую цитату.
Баги, на которые я наступил (мне все больше нравится с этого начинать)
1. Генерация заняла 93 секунды. Виновата не GPU — «thinking» токены. Первый замер: prompt-eval 2257 ток/с, generation 80 ток/с — GPU в порядке. Но модель сгенерировала 3152 токена, а поле response пришло пустым. Gemma-4 — reasoning-модель: по умолчанию она «думает», и весь бюджет ушёл в reasoning, а до ответа дело не дошло. Плюс Ollama по умолчанию подняла окно контекста на 262144 токена (огромный KV-кэш). Лечение:
"think": False, # для grounded-извлечения думать не нужно — нужен прямой ответ"options": {"temperature": 0, "num_ctx": 4096}
93 с → 2–5 с. Параметр reasoning: {enabled: false} через OpenRouter, кстати, отдавал 400 — убрал, решил проще.
2. operator does not exist: vector <=> double precision[]. Эмбеддинг-запрос уходил в pgvector как массив float8, а не как вектор — потому что в выражении embedding <=> %s у psycopg нет типового контекста (на INSERT он есть, тип колонки подсказывает, на SELECT — нет). Лечение: передавать запрос как numpy-массив, тогда срабатывает векторный дампер. Заодно поймал классику — порядок позиционных параметров должен совпадать с порядком плейсхолдеров в SQL слева направо, а не «WHERE-первым».
3. Поиск не находил ответ, который точно есть в корпусе. Ответ про размер лежал в чанке, который не попадал даже в top-40. Покопал: чанки по 1600 символов размывали эмбеддинг. Изолированное предложение давало cos 0.72 к запросу, оно же внутри большого окна — 0.59, чуть выше совсем нерелевантного (0.51). Чанки по 600 символов починили recall.
4. Я неправильно поставил диагноз — и замер меня поправил. Первый прогон по 50 вопросам бенчмарка дал 60–85% отказов. Фиксирую: «retrieval — узкое место». Оказалось — нет. Когда посмотрел, где был отказ, увидел: вопросы про лимит легионеров РФБ-Суперлиги, видеопросмотр по статье ФИБА 46.2.2, нейтральный статус на Лиге чемпионов CEV. Этого нет в базовом приказе — и система корректно отказывалась. Это не провал поиска, это ровно то поведение, которого я и добивался. Просто набор вопросов был не из моего корпуса.
Вывод болезненный, но полезный: обычный бенчмарк (closed-book проверка знаний модели) не годится для оценки RAG над конкретным корпусом. Нужен другой замер.
Замер, которому можно верить: corpus-faithful eval
Сгенерировал вопросы из самого корпуса: для каждого чанка модель-судья пишет вопрос, на который можно ответить только этим чанком, плюс эталонный ответ. Теперь у каждого вопроса есть «золотой» чанк — и можно развести три вещи, которые раньше не удавалось разделить:
→ context recall — нашёл ли поиск золотой чанк? (чистое качество поиска) → oracle quality — даём модели золотой чанк, оцениваем ответ (чистое качество модели) → e2e + over-refusal — реальный конвейер целиком
60 вопросов (по 10 на 6 видов спорта — взял приказы по волейболу, баскетболу, футболу, лёгкой атлетике, гимнастике, плаванию, 4766 чанков). Судья — Gemini-3.1-pro по рубрике. Результат:
oracle/10 e2e/10 over-refuse% guard(oracle)gemma4:31b 10.00 9.45 3.3 98%gemma4:12b 9.67 9.00 1.7 95%gemma4:e4b 9.50 8.40 1.7 85%context recall: 95% (57/60 золотых чанков найдены)
Что это показало мне:
→ Поиск — не узкое место. 95% recall на отвечаемых вопросах. Те самые «60–85% отказов» были корректными отказами на внекорпусных вопросах. → 31B не лучше 12B. 10.0 против 9.67 при вдвое большей задержке и на четверть большем over-refusal. Платить за 31B нечем. Беру 12B, это было одно из самых важных решений для меня. → Качество — в корпусе и поиске, не в размере модели. Ровно то, что я и хотел доказать себе цифрами.
Гард валил правильные ответы — и это была моя вина, не модели
Самое интересное. Гард помечал ~25% ответов как «непроверяемые». Я пошел смотреть — а это ответы на 10/10 по содержанию, просто их исходный чанк не содержал чистого номера пункта (N.N). Модели физически нечего было цитировать, она ставила что-то приблизительное — гард срабатывал. То есть при включённом enforce система отказывалась бы от правильных ответов.
Сам гард — простой и в этом вся суть «доверенности»: цитата считается валидной, только если номер пункта буквально присутствует в извлечённом тексте (топорно, ниже починим).
def guard_citations(text, context_text, valid_sections=frozenset()): bad = set() for c in extract_citations(text): # [п. ...] if not RULENUM_RE.match(c): # «[п. СЕНТЯБРЬ 2024]» — мусор, ловим bad.add(c) elif not re.search(r"(?<![\d.])" + re.escape(c) + r"(?![\d])", context_text): bad.add(c) # номера нет в контексте for s in extract_sections(text): # [р. II] if s not in valid_sections: bad.add(f"р. {s}") return (len(bad) == 0, bad)
Чиним — двухуровневая цитата: [п. N.N], если в контексте есть конкретный номер; иначе фолбэк на раздел [р. II] (раздел знает каждый чанк). Теперь корректный ответ всегда может сослаться на проверяемое место. Результат на тех же 60 вопросах: oracle-guard 12B 75→95%, e4b 67→85%, e2e e4b 73→87% — при сохранённом качестве. Для права это обобщается напрямую: номер статьи, если есть, иначе глава/раздел.
Та же модель — на устройстве. QAT держит качество
Отдельно проверил on-device. Google выпустил QAT-чекпойнты Gemma-4 (quantization-aware training — квантизация заложена в обучение). Замерил gemma4:e4b-it-qat (6.1 ГБ) против полной e4b (9.6 ГБ) на том же oracle:
→ качество держится: 9.47 против 9.50 при на треть меньшем размере; → цитирует лучше: guard 85 → 93% (то самое слабое место e4b закрылось само); → e2b-it-qat (4.3 ГБ) — 9.05, для совсем слабых устройств.
То есть 4B модель при квантизации даёт grounded-качество около флагманского — и работает офлайн на одном рабочем месте или телефоне. Это и есть приватный/офлайн контур: данные не покидают устройство.
Закрытый контур — не фича, а свойство архитектуры
На инференсе конвейер не делает ни одного внешнего вызова: Ollama + pgvector + реранкер + FastAPI — всё локально. Модель и корпус загружаются на этапе установки, после чего сеть можно отключить полностью — оно работает. Облачные ИИ-API так не умеют естественно. Единственный внешний вызов у меня — судья на этапе оценки (разработка), не в боевом контуре; для air-gap его меняют на локального судью.
Развёртывание — Ansible (драйвер → Ollama → модели → pgvector → подтянуть код по git → загрузить корпус → systemd-сервис), идемпотентно, развернуть с нуля занимает ~30 минут на голой Ubuntu. Кейс прогонял на RTX PRO 6000 (96 ГБ) — это было чисто как удобство, не требование: 12B спокойно обслуживает одного клиента на 24 ГБ (QAT-вариант — на 16).
Что дальше
Узкое место теперь не модель и не поиск, а гранулярность цитирования и глубина/ширина корпуса — расширяем корпус авторитетными источниками, и внекорпусные отказы превращаются в ответы со ссылкой. Право — следующая вертикаль (тот же конвейер на публичный правовой корпус).
Модель — базовый товар. Воспроизводимое отличие — корпус, поиск и гард, который отказывается врать.
ссылка на оригинал статьи https://habr.com/ru/articles/1044752/