Перейти к содержимому

6.3 · Набор из 50–100 вопросов с эталонами

Неделя 6 · Evals · ⏱ ~4 ч · Проект

Тестовый набор (golden dataset) — фундамент всех экспериментов проекта 3. Каждое утверждение «реранкер помог» или «чанки по заголовкам лучше» будет стоять на этом файле, и если набор кривой, все выводы тоже кривые. На собеседовании спросят «как вы собрали eval-набор и откуда уверенность в эталонах?», и ответ «70 вопросов, каждый проверен вручную, четыре типа, все ловушки базы знаний, с привязкой к источнику» звучит профессионально.

Это фиксированный, версионируемый список примеров: вход (вопрос), ожидаемый результат (эталонный ответ) и то, что нужно для автоматической проверки (где в базе знаний лежит ответ). Аналогия из обычной разработки: фикстуры в tests/, на которых гоняются тесты. Отличие в том, что ожидаемый результат — не точное значение, а смысл, который проверяют метрики и судья.

Начало у нас уже есть: eval/golden.jsonl из 5.1 · Переформулировка запроса и метаданные на 20 строк {"q", "category", "must_contain"}. Эти строки проверяют только поиск: нашёлся ли кусок с нужными словами. Теперь доводим набор до 50–100 вопросов и добавляем к каждому эталонный ответ, чтобы проверять и сам ответ ассистента. Сначала разберёмся, какие вопросы нужны (типы и покрытие), потом откуда их взять, как писать эталон и привязку к источнику, и в конце соберём всё в схему записи с валидатором.

Качество набора важнее размера. 60 точных, разнообразных, однозначных вопросов полезнее 300 сгенерированных наспех: шумный эталон даёт шумную метрику, и улучшение уже не отличить от случайности.

Вопросы различаются тем, сколько мест базы нужно для ответа и есть ли он там вообще. Эти четыре типа мы уже встречали в 6.2, когда генерировали синтетику.

Фактические (factual). Ответ — один факт из одного раздела. «Сколько дней на возврат невскрытого чая?», «сколько стоит доставка 3 кг в другой город?» (одна ячейка таблицы тарифов). Самые простые, но их большинство в реальной жизни.

Сравнительные (comparative). Требуют фактов с двух страниц. «Чем отличается гарантия на лампу „Свет“ и „Свет мини“?», «От какой суммы доставка бесплатная?» (разные цифры в delivery.md и promo.md). Для поиска это тяжело: нужно найти два куска, а векторный поиск часто приносит оба с одной страницы.

Многошаговые (multi_hop). Ответ собирается из нескольких фактов одной или разных страниц. «„Свет мини“ перестала включаться через месяц — это возврат или гарантия, и как подать претензию?»

Без ответа в базе (unanswerable). В kb/ ответа нет: не по теме («какая служба доставки самая надёжная?»), про конкретный заказ («где мой заказ 1042?»), про то, чего магазин не продаёт («гарантия на кофемашины»). Эталон: фраза отказа «В базе знаний нет ответа на этот вопрос.» и передача оператору. Без таких вопросов не узнать, галлюцинирует ли модель, когда поиск принёс нерелевантное.

Когда типы понятны, решаем, сколько вопросов каждого типа нужно. Набор должен покрывать то, что реально спрашивают покупатели, и то, где система ломается. Составляем матрицу «тип × категория» (категории — из front matter kb/) и следим за её заполнением:

Тип \ Категория delivery payment returns warranty promo orders products Всего
factual 4 3 4 3 3 3 4 24
comparative — — — — — — — 12 (пары страниц)
multi_hop 2 1 2 2 1 1 3 12
unanswerable — — — — — — — 10
Итого ~58 + синтетика

Цифры — ориентир, не закон. Главное, чтобы ни одна ячейка не была пустой и ни один тип не доминировал.

Вторая ось — ловушки базы знаний, которые мы заложили в kb/ в 4.3 и 4.4. Размечаем их тегами и тоже считаем. Эталон ловушки описывает поведение, а не только факт:

Тег Эталон Минимум
archived по returns.md, а не по returns-2025.md; если покупатель сам спрашивает про старые правила — по архиву с пометкой «правила старые» 3
table одна ячейка таблицы тарифов или сроков гарантии 5
similar_products факт именно того товара: «Свет» или «Свет мини», MUG-NORTH или MUG-SOUTH 4
conflict порог бесплатной доставки: обе цифры, delivery.md и promo.md в цитатах 2
action_request «оформите возврат», «дайте промокод»: правило из базы, без обещаний действий 3

Кроме ловушек, тегами полезно размечать дополнительные «срезы»: язык вопроса (ru/en), разговорный стиль и опечатки, точный идентификатор (LAMP-LIGHT-MINI, TEA15) или только смысл, числа (тарифы, сроки, суммы). Потом в 8.4 будет видно, что ошибки кучкуются по таким срезам.

Матрицы говорят, сколько вопросов нужно; заполняем их из четырёх источников:

  • Сама база знаний и бриф. Читаем страницы kb/ и kb-brief.md глазами покупателя и пишем его словами, а не заголовками страниц.
  • Логи проекта 2. Вопросы, которые мы сами задавали ассистенту через ask.py, app.py или бота.
  • Ошибки. Вопросы, на которых проект 2 ответил плохо (список из 6.1, строки eval/ANSWERS.md с «нет»). Обязательно включаем их: набор, где система всё делает правильно, бесполезен.
  • Синтетика, проверенная вручную в 6.2. Она уже сгенерирована в схеме этого набора (см. «Схема записи» ниже), так что при слиянии остаётся проставить split.

Настоящие вопросы клиентов (если есть свои данные) берём только переписанными в общий вопрос, без личных данных.

К каждому вопросу пишем эталон: с ним судья в 6.4 будет сравнивать ответ ассистента. Эталон должен быть проверяемым. Плохо: «Доставка в другой город стоит недорого и зависит от веса». Хорошо: «Курьером в зону 2 посылка 1–3 кг стоит 470 ₽ [kb/delivery.md#rates-by-weight-and-zone]». Правила:

  1. Короткий: 1–3 предложения с ключевым фактом.
  2. Числа с единицами (₽, кг, дни), артикулы, промокоды и названия служб доставки — точно как в kb/.
  3. Если ответ зависит от условий (вес, зона, способ оплаты, вскрыта ли упаковка) — указываем условие.
  4. Добавляем поле must_include: 1–3 ключевых факта, без которых ответ неверен. Судья в 6.4 будет проверять именно их, это делает его строже и стабильнее.

Не путаем: must_contain — что содержит найденный кусок (поиск), must_include — что содержит ответ (генерация).

Привязка к источнику, устойчивая к чанкингу

Заголовок раздела «Привязка к источнику, устойчивая к чанкингу»

Эталон проверяет ответ; осталось записать, где в базе этот ответ лежит. Для метрик поиска нужно знать, какой кусок правильный. Наивный вариант — хранить ключ чанка chunk_key. Проблема: он считается как sha256(url + section + text), и в 8.2 при смене стратегии чанкинга все ключи станут другими. Поэтому храним:

  • source_urls — разделы базы знаний в том же виде, что url чанка: kb/<file>.md#<anchor>;
  • evidence — список дословных цитат из kb/ (по одной на каждый нужный факт; у сравнительного вопроса — минимум по одной с каждой страницы);
  • source_chunk_keys — ключи в текущем index.db, как удобство.

Кусок считается релевантным, если он содержит цитату (после нормализации пробелов и регистра). Тогда при любом чанкинге правильные чанки пересчитываются автоматически. must_contain так не умеет: он требует все строки в одном куске, а у вопроса на две страницы их два. Нюанс: если цитату разрезало границей чанков, ни один чанк её целиком не содержит. Держим цитаты короткими (одна фраза с фактом) и считаем попаданием чанк, содержащий большую часть цитаты, например 80% её символов подряд. У таблиц цитата — строка таблицы целиком: «| 1–3 | 350 ₽ | 470 ₽ | 590 ₽ |».

Теперь всё, о чём шла речь выше, собираем в одну запись. eval/golden.jsonl, одна строка — один JSON-объект. Поля недели 5 остаются, чтобы её скрипты продолжали работать; остальные добавляем:

{
"id": "q-017",
"q": "скок будет стоить отправить 3 кило в другой город курьером?",
"category": "delivery",
"must_contain": ["1–3", "470"],
"reference_answer": "Курьерская доставка в зону 2 (другие города) для посылки 1–3 кг стоит 470 ₽.",
"must_include": ["470 ₽", "зона 2"],
"evidence": ["| 1–3 | 350 ₽ | 470 ₽ | 590 ₽ |"],
"source_urls": ["kb/delivery.md#rates-by-weight-and-zone"],
"source_chunk_keys": ["3f9a…"],
"type": "factual",
"handoff": null,
"difficulty": "medium",
"tags": ["table", "colloquial", "numbers", "ru"],
"origin": "manual",
"reviewed": true,
"split": "dev",
"notes": ""
}

Важно: эталон в примере — иллюстрация формата. Тарифы, пороги и якоря у каждого свои, эталоны пишем по своей kb/.

Для unanswerable: reference_answer — ровно фраза отказа REFUSAL, evidence, source_* и must_contain пустые. Новое поле handoff — причина, с которой код передаёт вопрос оператору: "refused" (модель отказала) или "personal_data" (сработала contains_personal_data, облачная модель не вызывалась — так с «где мой заказ 1042?»). У отвечаемых вопросов handoff: null.

Делим набор на две части:

  • dev (~70%) — на нём смотрим ошибки, крутим промпты, выбираем конфигурацию.
  • holdout (~30%) — не смотрим вообще, прогоняем только в конце, для итоговой цифры в README.

Зачем: если десять раз подряд подбирать конфигурацию под одни и те же 60 вопросов, система подгонится под них, и цифра станет оптимистичной. Holdout показывает честную цифру. Подробнее в 8.1. Делим стратифицированно: в обеих частях должны быть все типы и ловушки. 13 вопросов, на которых в 5.3 проверяли ответы (eval/ANSWERS.md), мы уже видели много раз — они идут в dev.

Набор живёт в git рядом с кодом. Правила:

  • Набор меняется только коммитом с понятным сообщением («golden v3: fixed reference for q-031, +8 comparative»).
  • В отчёте каждого прогона записана версия набора (хеш файла или git-коммит). Метрики на разных версиях набора не сравниваются напрямую.
  • Нашли неверный эталон в 8.4 — исправляем, поднимаем версию, перепрогоняем базовую конфигурацию.

JSONL легко сломать руками. Пишем валидатор и запускаем его перед каждым прогоном (это assertion первого уровня из 6.1, только для данных):

# eval/validate_golden.py (sketch)
import sys
from typing import Literal
from pydantic import BaseModel, field_validator
from rag.answer import REFUSAL
from rag.privacy import contains_personal_data
Category = Literal["delivery", "payment", "returns", "warranty", "promo", "orders", "products"]
class Item(BaseModel):
id: str
q: str
category: Category | None
must_contain: list[str] = []
reference_answer: str
must_include: list[str] = []
evidence: list[str] = []
source_urls: list[str] = []
source_chunk_keys: list[str] = []
type: Literal["factual", "comparative", "multi_hop", "unanswerable"]
handoff: Literal["refused", "personal_data"] | None = None
difficulty: Literal["easy", "medium", "hard"]
tags: list[str] = []
origin: Literal["manual", "synthetic"]
reviewed: bool
split: Literal["dev", "holdout"]
notes: str = ""
@field_validator("reviewed")
@classmethod
def must_be_reviewed(cls, v: bool) -> bool:
if not v:
raise ValueError("every item must be reviewed by a human")
return v
def main(path: str = "eval/golden.jsonl") -> None:
seen: set[str] = set()
for n, line in enumerate(open(path, encoding="utf-8"), 1):
item = Item.model_validate_json(line)
assert item.id not in seen, f"line {n}: duplicate id {item.id}"
seen.add(item.id)
if item.type == "unanswerable":
assert item.reference_answer == REFUSAL, f"{item.id}: reference must be REFUSAL"
assert item.handoff, f"{item.id}: unanswerable needs a handoff reason"
else:
assert item.evidence and item.source_urls, f"{item.id}: no evidence/source"
# invented personal data is allowed only where we test the routing
assert contains_personal_data(item.q) == (item.handoff == "personal_data"), \
f"{item.id}: personal data check disagrees with handoff"
print(f"OK: {len(seen)} items")
if __name__ == "__main__":
main(*sys.argv[1:])

Дополняем: проверку, что каждая цитата evidence находится в каком-то чанке текущего index.db (иначе цитата с опечаткой или страница kb/ изменилась) и не взята из архива (кроме вопросов с тегом archived), и печать обеих матриц покрытия.

Набор от провайдера не зависит: golden.jsonl, схема, валидатор и split одинаковы для Claude, OpenAI, DeepSeek и Ollama. Именно поэтому на нём потом можно честно сравнивать провайдеров. Меняется немного (проверено в ноябре 2026 по api-docs.deepseek.com):

  • Записываем, какой моделью сделана синтетика, и не только имя из конфига, а resp.model и дату (поле generator из главы 6.2 · Синтетические данные для evals). DeepSeek переназначает имена: deepseek-flash сейчас обслуживает V4.1-Flash-1017, а старые имена перенаправляются на новые модели.
  • Помощник по однозначности. deepseek-flash с выключенными рассуждениями может дёшево (центы за весь набор) выписать для каждого вопроса возможные трактовки: «гарантия на какую лампу — „Свет“ или „Свет мини“?». Это подсказка, а не судья: решение и эталон всё равно наши. Вопросы выдуманы и про публичную базу знаний, поэтому отправлять их можно.
  • Каждый вопрос набора будет уходить в DeepSeek при каждом прогоне. Поэтому в наборе нет настоящих обращений клиентов, только общие вопросы. Серверы DeepSeek в Китае, но правило касается любого облачного провайдера. Вопросы с handoff: "personal_data" содержат выдуманные данные и проверяют маршрут: в ask() их перехватывает contains_personal_data до облака, а вспомогательные скрипты их пропускают.
  • Проверка цитат по индексу работает с тем индексом, что есть; эмбеддинги считает Ollama (bge-m3), так что всё локально и бесплатно.
Claude OpenAI DeepSeek Ollama
Влияет на формат набора нет нет нет нет
Чем помогает при сборке проверка однозначности (для сравнения) то же дешёвая проверка однозначности, генерация синтетики эмбеддинги для проверки цитат и дублей; черновики по настоящим обращениям
Что нельзя отправлять данные клиентов данные клиентов данные клиентов; серверы в Китае можно всё, данные не покидают компьютер
  1. Перечитываем свой конспект notes/evals.md из 6.1 и eval/ANSWERS.md из 5.3 (≈10 мин): какие типы ошибок ожидаем от проекта 2.
  2. Открываем Hamel: AI Evals FAQ → вопросы про размер набора и про то, как его собирать (поиск по «dataset») (≈20 мин). Обращаем внимание: начинать с небольшого набора и расширять по мере обнаружения ошибок нормально.
  3. Открываем Anthropic: Define success criteria and build evaluations → разделы про построение тестовых случаев и краевые случаи (≈20 мин). Берём отсюда список видов краевых случаев (неоднозначный ввод, нерелевантный ввод, длинный ввод).
  4. Открываем Pydantic: Models → model_validate_json и Literal-поля (≈15 мин), если с ними ещё не работали в неделях 1–2.
  5. Дальше работа руками: сама сборка набора — основное время задачи.
  6. Открываем DeepSeek: Models & Pricing и раздел новостей на api-docs.deepseek.com (≈10 мин): смотрим, как переименовываются и переназначаются модели. Отсюда правило записывать resp.model и дату.
  • Неоднозначный вопрос — источник вечного шума. «Сколько стоит доставка?» — какой службой, какой вес, какая зона? «Какая гарантия на лампу?» — «Свет» или «Свет мини»? Судья будет ставить случайные оценки. Переписываем или удаляем.
  • Эталон по памяти, а не по базе. Свою kb/ мы писали сами, но страницы меняются: тариф обновили, порог подвинули. Каждый эталон сверяем с текущей страницей и записываем цитату.
  • Набор только из простых вопросов даёт 95% и ощущение, что всё готово. Добавляем сравнительные, ловушки и вопросы без ответа в базе.
  • Перекос по категориям. Если 40 из 60 вопросов про доставку, общая цифра описывает доставку.
  • Утечка holdout. Посмотрели ошибки на holdout и поправили промпт — holdout перестал быть holdout. Тогда честно переносим эти вопросы в dev и собираем новый holdout.
  • Данные клиентов — не сюда. Ничего из data/shop.db, outputs/handoff.jsonl и настоящих обращений; выдуманные телефоны и номера заказов — только в вопросах с handoff: "personal_data".
  • Нельзя «чинить» эталон под ответ системы, если система неправа. Исправляем эталон, только когда он сам ошибочен, и записываем в notes почему.

Работаем в projects/02-support-kb-rag/eval/.

  • Рисуем матрицу покрытия (тип × категория) и таблицу ловушек в notes/evals.md с целевыми числами.

  • Переводим 20 строк из 5.1 на новую схему и добавляем 3 вопроса без ответа в базе из 5.3 (два с handoff: "refused", «где мой заказ 1042?» — с "personal_data").

  • Дописываем вручную до 30–50 вопросов: сначала те, на которых проект 2 ошибался, затем доводим обе матрицы. Не меньше 8 comparative и 8 unanswerable, ловушки — не меньше минимума из таблицы.

  • Для каждого: находим ответ в kb/, записываем reference_answer, must_include, дословные цитаты evidence, source_urls с якорями.

  • Добавляем 20–50 проверенных синтетических вопросов из eval/synthetic_reviewed.jsonl (6.2), приведя их к общей схеме.

  • Пишем eval/validate_golden.py (схема, уникальность ID, цитаты есть в index.db и не из архива, handoff согласован с contains_personal_data, печать обеих матриц).

  • Скрипт eval/split.py: стратифицированно по type проставляет split (dev ~70%, holdout ~30%) с фиксированным seed. После этого holdout не открываем.

  • Прогоняем валидатор и run_retrieval_eval.py из недели 5, исправляем ошибки.

  • Коммитим eval/golden.jsonl (версия v1) и записываем в notes/evals.md его состав: сколько вопросов по типам, категориям, ловушкам, origin.

  • На следующий день перечитываем 10 случайных вопросов свежим взглядом: однозначны ли они?

  • (Опционально) Скрипт eval/ambiguity_check.py: deepseek-flash (thinking выключен, JSON mode) для каждого вопроса возвращает {"ambiguous": bool, "interpretations": [...]}. Вопросы с handoff: "personal_data" пропускает. Просматриваем все ambiguous: true и сами решаем, переписывать ли вопрос.

  • Проверяем, что в вопросах и эталонах нет настоящих данных клиентов: набор целиком будет уходить в облачную модель при каждом прогоне.

Ожидаемый результат: файл на 50–100 строк, валидатор печатает OK и матрицы без пустых ячеек.

  • eval/golden.jsonl содержит 50–100 вопросов, у каждого reviewed: true, эталон, must_include, цитаты и ссылки kb/<file>.md#<anchor> (кроме unanswerable, у которых эталон — фраза отказа и задан handoff).
  • Представлены все четыре типа, все семь категорий и все пять ловушек; есть split на dev/holdout.
  • Валидатор проходит, скрипты недели 5 работают на новом файле, набор закоммичен с версией.

Вопрос 1

Почему качество набора важнее его размера?

Шумные или неверные эталоны дают шумную метрику: улучшение невозможно отличить от случайности, а ошибки эталона маскируются под ошибки системы.

Вопрос 2

Зачем в наборе вопросы без ответа в базе и какой у них эталон?

Чтобы проверить, что система не галлюцинирует, когда ответа нет. Эталон — точная фраза отказа «В базе знаний нет ответа на этот вопрос.», а поле handoff задаёт причину передачи оператору (refused или personal_data).

Вопрос 3

Почему не стоит опираться только на chunk_key как на указатель правильного куска?

chunk_key считается из текста и раздела чанка и меняется при смене чанкинга. Цитата evidence позволяет пересчитать правильные чанки для любого индекса.

Вопрос 4

Для чего нужен holdout, если есть dev?

  • a) чтобы набор был больше
  • b) чтобы получить честную оценку, не подогнанную под набор
  • c) чтобы проверять судью
  • d) для синтетических вопросов.

b. Holdout не участвует в подборе конфигурации, поэтому его цифра не подогнана.

Вопрос 5

Что такое must_include, чем оно отличается от must_contain и как помогает судье?

Список ключевых фактов, без которых ответ неверен. must_contain описывает найденный кусок (поиск), must_include — ответ (генерацию). Судья проверяет конкретные пункты, а не «похоже ли в целом», поэтому он строже и стабильнее.

Вопрос 6

Что не так с этой записью?

{"id": "q-5", "q": "Какая гарантия на лампу?", "reference_answer": "Зависит от лампы",
"type": "factual", "category": null, "reviewed": false}

Вопрос неоднозначный («Свет» или «Свет мини»), эталон непроверяем, тип не тот (сравнение двух ламп — это comparative), category пустая, хотя вопрос явно про гарантию, нет цитат и ссылок, reviewed: false, нет must_contain, must_include, handoff, difficulty, origin, split.

Вопрос 7

Исправлены эталоны у 5 вопросов. Можно ли сравнивать новые метрики со старыми прогонами напрямую?

Нет. Набор другой версии — другая задача. Нужно перепрогнать базовую конфигурацию на новой версии и сравнивать уже с ней.

Вопрос 8

Какой тип вопросов особенно трудный для векторного поиска и почему?

Сравнительные: нужны куски с двух страниц, а векторный поиск по одному запросу часто приносит похожие куски одной страницы (особенно у похожих ламп). Multi-hop тоже: второй кусок может быть непохож на вопрос.

Вопрос 9

Как ведёт себя валидатор, если на странице kb/delivery.md поменяли тариф и цитата больше не находится?

Он должен упасть или предупредить: цитата устарела, значит надо обновить эталон по новой странице и поднять версию набора (или переиндексировать, если устарел index.db).

Вопрос 10

Ошибки на holdout просматривались 10 раз, пока улучшался промпт. Что делать?

Holdout скомпрометирован. Перенести эти вопросы в dev и собрать новый holdout из новых вопросов, которые не просматривались.

Вопрос 11

Почему стоит включать в набор вопросы, на которых система уже ошибается?

Набор, где всё проходит, ничего не измеряет: улучшения не будет видно. Известные ошибки — главное, что хочется починить и отслеживать как регрессии.

Вопрос 12

Почему в записи синтетического вопроса стоит хранить resp.model, а не только "deepseek-flash" из конфига?

Имя модели у DeepSeek может указывать на разные версии во времени (сейчас deepseek-flash — V4.1-Flash-1017, старые имена перенаправлены). resp.model и дата показывают, чем на самом деле сделан вопрос, и объясняют изменения при перегенерации.

Вопрос 13

Хочется добавить в набор настоящее обращение: «я Петрова, +7 912 …, по заказу 1187 оплатила картой 2 340 ₽, вернула лампу, где деньги?». Что не так и как переписать?

Это личные данные настоящего клиента: набор лежит в git, вопросы уходят в облачную модель (DeepSeek — серверы в Китае) при каждом прогоне, а contains_personal_data на регулярных выражениях ловит не всё (имя «Петрова» она не заметит). Да и проверяет такой вопрос один случай, а не базу знаний. Переписать как общий вопрос к базе знаний: «Через сколько дней возвращаются деньги за возвращённый товар, если заказ оплачен картой?»

В 6.4 · Скрипт оценки: retrieval + faithfulness пишем eval/run.py, который прогоняет этот набор через ask() и считает hit rate, MRR, correctness и стоимость одной командой.