Доверенный ИИ на практике: RAG, который ссылается на источник — или честно отказывается. gemma.. gemma. gemma4.. gemma. gemma4. llm.. gemma. gemma4. llm. Open source.. gemma. gemma4. llm. Open source. rag.. gemma. gemma4. llm. Open source. rag. искусственный интеллект.. gemma. gemma4. llm. Open source. rag. искусственный интеллект. Машинное обучение.. gemma. gemma4. llm. Open source. rag. искусственный интеллект. Машинное обучение. Тестирование IT-систем.

Поднял за выходные решение, которое давно хотел проверить руками: 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).

Что дальше

Узкое место теперь не модель и не поиск, а гранулярность цитирования и глубина/ширина корпуса — расширяем корпус авторитетными источниками, и внекорпусные отказы превращаются в ответы со ссылкой. Право — следующая вертикаль (тот же конвейер на публичный правовой корпус).

Модель — базовый товар. Воспроизводимое отличие — корпус, поиск и гард, который отказывается врать.

Автор: daniel_ivanov

Источник