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

6.2 · Синтетические данные для evals

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

Вручную придумать 100 хороших вопросов к базе знаний магазина — день скучной работы, и вопросы получатся однобокими: мы спросим о том, что сами помним, и теми словами, которыми написана база. Клиенты пишут иначе: с опечатками, по два вопроса сразу, с номером заказа посреди жалобы. Модель за минуты генерирует черновик, а мы как авторы базы его проверяем и чистим. Генерация синтетических данных — стандартный приём в индустрии и частая тема на собеседованиях («как вы собрали тестовый набор, когда пользователей ещё нет?»).

Идея: модель пишет черновик, человек принимает решение

Заголовок раздела «Идея: модель пишет черновик, человек принимает решение»

Сейчас у нас есть 20 вопросов в eval/golden.jsonl из недели 5 и пять вопросов из 6.1, на которых ассистент ошибается. В следующей главе, 6.3, набор вырастет до 50–100 вопросов, и часть недостающих напишет модель.

Синтетический набор — это вопросы и эталонные ответы, которые сгенерировала модель по нашим же данным, здесь — по страницам kb/. Эталонный ответ (эталон) — ответ, который мы считаем правильным и с которым потом сравниваем ответ ассистента. Схема для RAG:

случайный кусок kb/ ──► модель-генератор ──► кандидат {вопрос, ответ, цитата}
│
▼
автоматические фильтры
(дубли, копипаст, «неотвечаемые»)
│
▼
ручная проверка (мы)
│
▼
eval/golden.jsonl

Ключевое слово — черновик. Ни один синтетический вопрос не попадает в набор без нашего «да». Модель не знает, какие вопросы клиенты задают на самом деле, и регулярно ошибается в эталоне.

Почему «случайный кусок» и почему этого мало

Заголовок раздела «Почему «случайный кусок» и почему этого мало»

Начинать со случайного куска удобно: ответ заведомо лежит в нём, и у кандидата сразу есть ссылка на источник. Для метрик поиска (hit rate из 6.1) это ровно то, что нужно: мы знаем, какой кусок поиск обязан найти. Но у подхода три известных перекоса:

  1. Копирование формулировок. Модель берёт фразу из куска и превращает её в вопрос: «Подлежат ли возврату чай и кофе на развес во вскрытой упаковке?» Векторный и особенно BM25-поиск находят такой кусок почти всегда, и hit rate завышен. Реальный клиент напишет: «пачку сенчи открыли, вкус не зашёл, можно сдать?».
  2. Один кусок — один вопрос. Сравнительные («чем гарантия на „Свет“ отличается от „Свет мини“») и многошаговые вопросы так не получаются, а они самые трудные.
  3. Нет вопросов «вне базы знаний». Хороший ассистент должен уметь сказать «в базе знаний нет ответа» и передать вопрос оператору. Особый случай — «где мой заказ 1042?»: ответа в kb/ нет и быть не может. Это тоже надо проверять.

Поэтому генератор строится вокруг измерений разнообразия, а не только вокруг куска.

Измерение — это ось, по которой вопросы должны различаться: тип, сложность, кто спрашивает, как пишет. Hamel Husain в своих статьях предлагает задать несколько таких осей с конкретными значениями и генерировать вопросы по их комбинациям. Для ассистента поддержки «Полки»:

Измерение Значения
Тип вопроса factual, comparative, multi_hop, unanswerable
Сложность easy (ответ в одной фразе), medium (надо собрать из абзаца или таблицы), hard (несколько мест, условия)
Персона покупает впервые, постоянный покупатель, выбирает подарок, спрашивает о своём заказе
Стиль аккуратный, разговорный, с опечатками и без знаков препинания, очень коротко
Форма сообщения один вопрос, два вопроса сразу, вопрос внутри рассказа
Категория delivery, payment, returns, warranty, promo, orders, products

Мы выбираем комбинацию (например, comparative / hard / выбирает подарок / разговорный / один вопрос / products), подбираем подходящие куски и просим модель сгенерировать вопрос под эту комбинацию. Так распределение вопросов задаём мы, а не модель.

Правило для любого стиля: в вопросе нет слов-категорий и заголовков базы. Клиент пишет не «уточните политику возврата», а «скока доставка до Казани 3 кило». Такие вопросы и проверяют переформулировку запроса из 5.1.

Четыре типа из первой строки таблицы различаются тем, сколько мест базы нужно для ответа: factual — один факт из одного куска; comparative — сравнение фактов с двух страниц; multi_hop — ответ собирается из нескольких связанных фактов; unanswerable — ответа в kb/ нет, и правильное поведение — отказ. Каждый тип требует своего способа генерации:

  • factual: один случайный кусок → вопрос, на который он отвечает.
  • comparative: два похожих товара («Свет» и «Свет мини» с разной гарантией) или одна тема на двух страницах (порог бесплатной доставки в delivery.md и promo.md различается) → по куску на каждый → вопрос, требующий обоих. Для расходящихся страниц эталон называет оба числа, как требует промпт из 5.3.
  • multi_hop: два куска, где ответ требует связать факты («невскрытый чай вернуть можно — а доставку обратно кто оплачивает?»). Сюда же форма «два вопроса сразу»: «TEA15 со скидкой работает? и скока ехать до Новосибирска» — эталон закрывает оба.
  • unanswerable: проще всего руками. Синтетически: даём модели кусок и просим вопрос о соседней теме, которой в куске нет («гарантия на кофемашины» — их «Полка» не продаёт), затем проверяем поиском по всему индексу, что ответа действительно нет нигде. Иначе «неотвечаемый» вопрос окажется отвечаемым. Эталон — фраза отказа REFUSAL и передача оператору.
  • unanswerable от персоны «спрашивает о своём заказе»: «заказ 1187 неделю в пути, где он??», «списали дважды». Номера, имена и телефоны генератор выдумывает, из снимка базы их не берём. Тип — unanswerable, reference_answer — REFUSAL, а поле handoff (причина, по которой код передаёт вопрос оператору; подробно — в следующей главе, 6.3) задаёт маршрут: с номером — "personal_data" (contains_personal_data срабатывает до облачного вызова), без номера («где мой заказ???») — "refused".

Куски архивной returns-2025.md в генератор не идут: эталон получится по устаревшим правилам.

Кандидата мы получили; теперь по схеме из начала главы его ждут автоматические фильтры. Их три — против копипаста, дублей и «слишком простых» вопросов, — плюс проверка самого эталона. Начинаем с копипаста, первого перекоса из списка выше. Против него две защиты, и лучше обе:

  1. В промпте генератора: «Сформулируй вопрос так, как его написал бы клиент в чат поддержки, не видевший справочный центр. Не используй заголовки, термины и фразы из текста».
  2. Фильтр на пересечение: считаем, какая доля слов вопроса встречается в куске, или длину самой длинной общей подстроки. Слишком высокое пересечение — на перефразирование (второй вызов модели) или в мусор.

Простой фильтр пересечения:

import re
def tokens(text: str) -> set[str]:
# lowercase words of 3+ chars; the first 5 letters are a crude stem,
# so "возврат" and "возврата" count as the same word
return {t[:5] for t in re.findall(r"\w{3,}", text.lower())}
def overlap_ratio(question: str, chunk: str) -> float:
q = tokens(question)
if not q:
return 0.0
return len(q & tokens(chunk)) / len(q)
# rule of thumb, tune by looking at examples:
# > 0.7 means the question mostly copies the chunk

Порог не берём с потолка: смотрим на 20 кандидатов с разным overlap_ratio и решаем, где начинается копипаст.

Модель любит повторяться: из 50 кандидатов про возвраты 10 окажутся одним вопросом. Дубли ловятся эмбеддингами, которые у нас уже есть с недели 4 (4.1): если косинусное сходство двух вопросов выше порога (начинаем с 0,9 и проверяем глазами), оставляем один.

import numpy as np
def dedup(questions: list[str], vectors: np.ndarray, threshold: float = 0.9) -> list[int]:
# vectors are L2-normalized, so dot product == cosine similarity
kept: list[int] = []
for i in range(len(questions)):
if all(float(vectors[i] @ vectors[j]) < threshold for j in kept):
kept.append(i)
return kept

«Слишком простой» значит одно из двух:

  • Тривиальный для поиска: вопрос почти дословно совпадает с заголовком куска («Гарантия на лампу „Свет“»). Ловится фильтром пересечения.
  • Не требует базы знаний: модель отвечает верно без контекста («Что такое промокод?»). Такой вопрос проверяет знания модели, а не наш RAG. Можно оставить пару для покрытия, но не половину набора. Проверка «с закрытой книгой» (closed-book): задаём вопрос модели без кусков базы и даём судье из 6.1 сравнить ответ с эталоном.

Ответ генератора тоже может быть неверным: модель додумала, округлила, перепутала цену за 100 г с ценой за пачку. Защита:

  • Генератор обязан вернуть цитаты (evidence): список дословных фраз из текста, по одной на каждый факт ответа. Код проверяет, что каждая цитата действительно есть в куске (после нормализации). Хоть одна не нашлась — кандидат отбраковывается. У unanswerable цитат нет.
  • При ручной проверке сверяем ответ с цитатой и при сомнении открываем страницу kb/ по ссылке.

Все требования выше собираются в одну запись. Генерируем сразу в той схеме, которую eval/golden.jsonl получит в следующей главе (6.3), чтобы при слиянии ничего не переименовывать. Основные поля: q — вопрос клиента, reference_answer — эталон, must_include — 1–3 факта, которые должен содержать ответ, evidence — цитаты из kb/, source_urls и source_chunk_keys — откуда взят кусок, плюс разметка по измерениям (type, difficulty, category, tags) и происхождение (origin, reviewed, generator). Поле must_contain из недели 5 (что содержит найденный кусок) для синтетики не обязательно: попадание поиска считается по evidence. Поле split (dev или holdout) проставит скрипт из гл. 6.3:

{"id": "syn-0042", "q": "пачку сенчи открыли, вкус не зашёл, можно сдать?",
"reference_answer": "Нет: чай и кофе на развес во вскрытой упаковке возврату не подлежат.",
"must_include": ["вскрытый чай не возвращается"],
"evidence": ["Чай и кофе на развес во вскрытой упаковке возврату не подлежат."],
"source_chunk_keys": ["3f9a…"], "source_urls": ["kb/returns.md#opened-tea-returns"],
"category": "returns", "type": "factual", "handoff": null, "difficulty": "easy",
"tags": ["colloquial"], "origin": "synthetic", "reviewed": false,
"generator": {"model": "...", "prompt_version": "gen-v1"}}

Поле evidence важнее, чем кажется: в 8.2 мы будем менять чанкинг, chunk_key (хэш от ссылки, раздела и текста) поменяются, а цитата останется, и по ней можно будет найти правильный кусок в новом индексе.

Промпт переводит правила этой главы в инструкцию для модели: комбинация измерений, запрет копировать текст, выдуманные личные данные, цитаты на каждый факт и ответ в формате JSON. Пример (иллюстративный; вызов SDK см. в главе 2.2 · Structured output: Pydantic + Instructor и в текущей документации провайдера):

GEN_PROMPT = """You create test questions for the customer support assistant of the online shop "Полка"
(home goods and gifts). The assistant answers from the shop's help center.
Below is a fragment of the help center (category: {category}, source: {url}, section: {section}).
<chunk>
{chunk}
</chunk>
Write ONE customer message in Russian of type "{qtype}" and difficulty "{difficulty}".
The customer: {persona}. Style: {style}. Message form: {form}.
Rules:
- The message must be answerable from the fragment alone.
- Write it the way a real customer types into a support chat, without having read the help center:
no headings, policy terms or category words. Typos are fine if the style asks.
- The message must make sense without seeing the fragment (no "in this section").
- Never include real personal data. If a name, phone or order number is needed, invent one.
- The answer must be short and contain the key facts (numbers, units, SKUs, promo codes) exactly as written.
- "must_include": 1-3 key facts the answer must contain.
- "evidence": a list of EXACT quotes from the fragment, one per key fact.
Return JSON only:
{{"q": "...", "reference_answer": "...", "must_include": ["..."], "evidence": ["..."]}}
"""

Для генерации подойдёт сильная модель: ошибка в эталоне стоит дороже, чем лишние центы. Конкретные модели и цены смотрим в документации провайдера на момент работы.

Скрипт связывает всё вместе: берёт куски из индекса, для каждой комбинации измерений вызывает генератор и сразу отбраковывает кандидатов с выдуманными цитатами; фильтры пересечения и дублей работают уже над всем списком.

# eval/generate.py (illustrative skeleton)
import json, os, random
from anthropic import Anthropic # or openai, whichever you use in Project 2
client = Anthropic() # reads ANTHROPIC_API_KEY from env, never hardcode keys
MODEL = os.environ["GEN_MODEL"] # pick a current model from the docs
def normalize(text: str) -> str:
return " ".join(text.split()).casefold()
def load_chunks() -> list[dict]:
... # read chunks from index.db: chunk_key, text, url, section, category; skip archived
def generate(chunk: dict, combo: dict) -> dict | None:
# combo: qtype, difficulty, persona, style, form
msg = client.messages.create(
model=MODEL,
max_tokens=600,
messages=[{"role": "user", "content": GEN_PROMPT.format(
category=chunk["category"], url=chunk["url"], section=chunk["section"],
chunk=chunk["text"], **combo)}],
)
try:
data = json.loads(msg.content[0].text)
except json.JSONDecodeError:
return None
evidence = data.get("evidence") or []
if not evidence or not all(normalize(e) in normalize(chunk["text"]) for e in evidence):
return None # hallucinated evidence
return data
def main(n: int = 60, seed: int = 42):
random.seed(seed) # reproducible sampling
... # sample combinations, generate, filter overlap, dedup, write candidates

Для comparative и multi_hop в chunk идут два куска подряд, и evidence проверяется по склеенному тексту (минимум по цитате с каждой страницы); unanswerable, включая вопросы о своём заказе, генерируются отдельным промптом без цитат. Фиксируем seed, модель и версию промпта: через месяц захочется понять, откуда взялся вопрос.

Фильтры отсеивают явный брак, но не решают, хорош ли вопрос. Это последний шаг схемы, и он за нами. Для каждого кандидата отвечаем на четыре вопроса:

  1. Написал бы так реальный клиент? Если нет — переписываем или выбрасываем.
  2. Эталон верен и полон? Сверяем с цитатой и страницей kb/; в сообщении с двумя вопросами закрыты оба.
  3. Вопрос однозначен? «Какая гарантия на лампу?» без указания, «Свет» или «Свет мини», — плохой вопрос.
  4. Тип, сложность и категория размечены правильно?

Удобно проверять в цикле в терминале (y / n / e — редактировать) или в таблице через CSV. Записываем, сколько кандидатов прошло: доля брака сама по себе полезна (если отбраковано 60%, промпт генератора плохой).

Скелет выше написан на Anthropic SDK; оставляем его как вариант «для сравнения». Основной генератор у нас — DeepSeek через OpenAI SDK, эмбеддинги для фильтра дублей — Ollama, локально и бесплатно. База знаний kb/ публичная, поэтому её куски в DeepSeek отправлять можно; личные данные клиентов — нет. Факты сверены в ноябре 2026 по api-docs.deepseek.com и docs.ollama.com.

# eval/generate.py — DeepSeek variant (illustrative; check current model ids in the docs)
import json, os
from openai import OpenAI
client = OpenAI(api_key=os.environ["DEEPSEEK_API_KEY"], # from env / .env file in .gitignore
base_url="https://api.deepseek.com")
MODEL = os.environ.get("GEN_MODEL", "deepseek-v4-pro")
def generate(chunk: dict, combo: dict, retries: int = 3) -> dict | None:
prompt = GEN_PROMPT.format(category=chunk["category"], url=chunk["url"],
section=chunk["section"], chunk=chunk["text"], **combo)
for _ in range(retries):
resp = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"}, # prompt must contain the word "json" and an example
max_tokens=4000, # generous: in thinking mode reasoning also spends output tokens
)
text = (resp.choices[0].message.content or "").strip()
if not text:
continue # JSON mode may return blank content; retry
try:
data = json.loads(text)
except json.JSONDecodeError:
continue
evidence = data.get("evidence") or []
if not evidence or not all(normalize(e) in normalize(chunk["text"]) for e in evidence):
return None # hallucinated evidence
data["generator"] = {"model": resp.model, "prompt_version": "gen-v1"} # model actually served
return data
return None

Что здесь важно именно для DeepSeek:

  • JSON mode — это json_object, а не схема. Модель обязана вернуть валидный JSON, но поля никто не проверяет. Поэтому GEN_PROMPT уже содержит пример структуры и слово «JSON» (без них JSON mode DeepSeek не работает как надо), а наличие полей проверяем сами (Pydantic из главы 1.3 · Python: uv, async, Pydantic). Документация предупреждает: иногда content приходит пустым, поэтому ретрай.
  • Режим thinking включён по умолчанию. Для генерации это скорее плюс: модель «думает», прежде чем выбрать цитату, и эталон точнее. Но temperature в этом режиме не действует, так что разнообразие обеспечивают измерения (тип, персона, стиль, форма сообщения), а не температура. Чтобы управлять temperature, выключаем рассуждения: extra_body={"thinking": {"type": "disabled"}}.
  • Какая модель. Ошибка в эталоне дороже лишних центов, поэтому генератор — deepseek-v4-pro, а «дешёвые» шаги (перефразирование, проверка «отвечает без контекста») — deepseek-flash. Порядок цен на ноябрь 2026: 60 кандидатов по ~1500 входных и ~1000 выходных токенов (с рассуждением) на Pro — меньше $0,5 в часы пик и вдвое меньше вне пика. Пересчитываем по странице цен.
  • Записываем resp.model. DeepSeek переназначает имена: deepseek-flash сейчас обслуживает версия V4.1-Flash-1017, старые имена перенаправляются на новые модели. Без записи через месяц не понять, какой моделью сгенерирован вопрос.

Генератор на Ollama: бесплатно, но проверяем брак

Заголовок раздела «Генератор на Ollama: бесплатно, но проверяем брак»

Локальная модель годится как бесплатный генератор, а format с JSON-схемой в нативном API Ollama ограничивает ответ схемой надёжнее, чем json_object у DeepSeek:

# illustrative; pull the model first: ollama pull <tag> (official library at ollama.com)
import ollama
from pydantic import BaseModel
class Candidate(BaseModel):
q: str
reference_answer: str
must_include: list[str]
evidence: list[str]
resp = ollama.chat(
model="qwen3:4b", # 4B class for 8 GB RAM; 7-8B for 16 GB; 14B+ for 32 GB
messages=[{"role": "user", "content": prompt}],
format=Candidate.model_json_schema(),
options={"temperature": 0.8, "seed": 42}, # temperature and seed are honored here
)
cand = Candidate.model_validate_json(resp.message.content)

Схема гарантирует форму, но не правду. Размер модели — по памяти (см. комментарий в коде; qwen3:4b — выбор курса по умолчанию). Модель 4B заметно чаще ошибается в эталоне, копирует формулировки и пишет слишком грамотно, поэтому локальный генератор для публичной базы знаний — учебное сравнение, а не основной путь. Доля принятых кандидатов у двух генераторов на 20 штуках покажет, стоит ли экономия. Про контекст: при памяти меньше 24 ГБ Ollama по умолчанию даёт модели 4k токенов; длинный кусок (тарифная таблица delivery.md) плюс промпт может молча обрезаться. Проверяем ollama ps (колонка CONTEXT); поднимать OLLAMA_CONTEXT_LENGTH при 8 ГБ можно лишь осторожно, контекст тоже ест память (подробнее в главе 6.4 · Скрипт оценки: retrieval + faithfulness).

Эмбеддинги для фильтра дублей — только локально

Заголовок раздела «Эмбеддинги для фильтра дублей — только локально»

Эндпоинта эмбеддингов в официальной документации DeepSeek не нашлось (ноябрь 2026); у Anthropic своих эмбеддингов тоже нет. Поэтому эмбеддинги — через Ollama (или sentence-transformers, если индекс на неделе 4 строился так):

import numpy as np
import ollama
def embed(texts: list[str], model: str = "bge-m3") -> np.ndarray:
# /api/embed returns L2-normalized vectors, so dot product == cosine similarity
resp = ollama.embed(model=model, input=texts)
return np.array(resp["embeddings"])
vectors = embed([c["q"] for c in candidates])
kept = dedup([c["q"] for c in candidates], vectors, threshold=0.9)

Вопросы у нас на русском, с опечатками и вкраплениями латиницы (LAMP-LIGHT-MINI, TEA15), поэтому нужна многоязычная модель эмбеддингов: bge-m3 из библиотеки Ollama (567M параметров, выбор курса по умолчанию, помещается и в 8 ГБ). Если индекс проекта 2 построен другой моделью, для поиска дублей это не страшно, но порог подбираем заново по примерам.

Что нужно Claude OpenAI DeepSeek Ollama
JSON с гарантией схемы structured outputs (JSON-схема) / tool use response_format с JSON-схемой нет: только json_object, поля проверяем сами; обходной путь — strict-режим tool calling (beta, base_url="https://api.deepseek.com/beta") да: format с JSON-схемой
Разнообразие через temperature да (с ограничениями у части моделей) да, кроме reasoning-моделей только с выключенным thinking да, плюс seed
Свой эндпоинт эмбеддингов нет да в документации не найден да, /api/embed
Качество эталонов высокое, дороже высокое, дороже высокое у Pro, центы за набор ниже, больше брака; бесплатно
Куда уходят куски kb/ облако Anthropic облако OpenAI серверы в Китае никуда

Итог для практики: генератор deepseek-v4-pro, перефразирование и closed-book проверка (вопрос без кусков базы) на deepseek-flash, дубли на эмбеддингах Ollama. Claude или OpenAI — по желанию, для сравнения доли брака на тех же 20 комбинациях.

  1. Перечитываем в Hamel Husain: Your AI Product Needs Evals раздел про генерацию тестовых случаев моделью (≈15 мин). Обращаем внимание, как он задаёт сценарии и персоны, а не просит «придумай вопросов».
  2. Открываем Hamel: A Field Guide to Rapidly Improving AI Products → раздел про синтетические данные и измерения (dimensions) (≈25 мин). Берём отсюда идею кортежей «измерение × значение».
  3. В Hamel: AI Evals FAQ находим вопросы про synthetic data (поиск по странице «synthetic») (≈20 мин). Там о том, когда синтетика вредна и как её проверять.
  4. Открываем Ragas docs → раздел про генерацию тестового набора (testset generation) (≈20 мин). Не вместо своего скрипта: смотрим, какие типы вопросов он делает (single-hop, multi-hop) и как строит граф знаний по документам.
  5. Возвращаемся к коду проекта 2 и находим, как достать из index.db все куски с метаданными: chunk_key, текст, url, section, category, archived (≈15 мин). Это вход генератора.
  6. Открываем DeepSeek: JSON Output и Thinking Mode (≈15 мин). Обращаем внимание на три требования JSON mode (слово «json», пример, достаточный max_tokens) и на пустой content.
  7. Открываем Ollama: Structured outputs и Ollama: Embeddings (≈15 мин): format с Pydantic-схемой и ollama.embed. Python-библиотеку ollama ставим с закреплённой версией.
  • Копипаст завышает метрики поиска, особенно BM25. Если hit rate на синтетике заметно выше, чем на ручных вопросах, вероятно, причина в этом. Считаем метрики отдельно по origin.
  • Генератор путает похожие товары: в куске про «Свет мини» «вспоминает» гарантию «Света». Ловит проверка цитаты.
  • Слишком грамотные клиенты. Все кандидаты начинаются с «Здравствуйте, подскажите, пожалуйста» → стиль задаём конкретнее, с выдуманными примерами в промпте.
  • Куски-мусор (оглавления, навигация, таблица без заголовка и подписи) дают бессмысленные вопросы. Отфильтровываем их до генерации: слишком короткие, без предложений, только ссылки.
  • Перекос к большим разделам. В products/ десятки страниц, а payment.md одна: при равномерном сэмплировании кусков набор перекосится к товарам. Сэмплируем по категории, потом по куску.
  • База знаний меняется. Страницы kb/ правим (updated в front matter). По source_urls находим вопросы к изменённой странице и перепроверяем эталон, иначе он «вдруг станет неверным».
  • В облако не идёт ничего, кроме публичной базы знаний. Серверы DeepSeek в Китае; правило то же для любого облачного провайдера. Настоящие сообщения клиентов, outputs/handoff.jsonl, логи с вопросами и снимок data/shop.db в генератор не попадают. Номера заказов, имена и телефоны в вопросах о своём заказе — выдуманные.
  • Синтетика не заменяет реальные вопросы. Ручные вопросы из 6.3 и eval/golden.jsonl остаются основой; синтетика их дополняет.
  • Пустой ответ в JSON mode DeepSeek при finish_reason="stop" — документированное поведение, а не баг нашего кода. Ретрай, и такие случаи не считаем «браком генератора».
  • Обрезанный кусок в Ollama. При контексте 4k по умолчанию длинный кусок обрежется без ошибки, и модель сгенерирует вопрос по куску куска, а проверка evidence отбракует хорошие кандидаты. Смотрим ollama ps.

Код и данные: projects/02-support-kb-rag/eval/.

  • Создаём eval/generate.py по скелету выше. Ключ API — из переменной окружения или .env, который есть в .gitignore.

  • Функция load_chunks(): читает куски из index.db проекта 2 (chunk_key, текст, url, section, category), архивные пропускает. Отфильтровываем куски короче 200 символов и состоящие в основном из ссылок.

  • Определяем измерения: тип (factual, multi_hop, unanswerable; comparative — опционально, через пару кусков), сложность, персона (включая «спрашивает о своём заказе»), стиль, форма сообщения, категория. Сэмплируем комбинации так, чтобы ни один тип не занимал больше 40%, а unanswerable — не меньше 15%.

  • Реализуем проверку «каждая цитата evidence есть в куске» и отбраковку.

  • Реализуем overlap_ratio и смотрим распределение на первых 20 кандидатах; выбираем порог. Кандидатов выше порога отправляем на перефразирование вторым вызовом (промпт: «перепиши вопрос словами клиента в чате поддержки, не меняя смысла»).

  • Реализуем dedup на эмбеддингах.

  • (Опционально) Проверка «отвечает без контекста»: задаём вопрос модели без документов, помечаем кандидатов, где ответ совпал с эталоном, флагом closed_book_ok: true.

  • Генерируем ~60 кандидатов → eval/synthetic_candidates.jsonl.

  • Пишем eval/review.py: показывает кандидата (вопрос, эталон, цитата, url), принимает y/n/e, пишет принятые в eval/synthetic_reviewed.jsonl с reviewed: true.

  • Проверяем вручную минимум 30. Записываем в notes/evals.md: сколько принято, сколько отклонено и по каким причинам (копипаст, неверный эталон, неоднозначный, бессмысленный, «не похоже на клиента»).

  • Прогоняем принятые вопросы о своём заказе через ask(): ни один с номером не дошёл до облака, причина в outputs/handoff.jsonl совпадает с полем handoff (personal_data или refused).

  • Основной генератор — DeepSeek: клиент OpenAI SDK с base_url="https://api.deepseek.com", ключ DEEPSEEK_API_KEY из окружения, JSON mode, ретрай на пустой content, запись resp.model в поле generator.

  • Эмбеддинги для dedup через Ollama (ollama pull bge-m3 или другая многоязычная модель из официальной библиотеки); версию библиотеки ollama закрепляем в pyproject.toml.

  • (Опционально) Генерируем те же 20 комбинаций локальной моделью через format=Candidate.model_json_schema() и сравниваем долю брака с DeepSeek. Записываем в notes/evals.md.

  • (Опционально, для сравнения) Прогоняем исходный скелет на Anthropic SDK или OpenAI на тех же 20 комбинациях: доля брака и цена против DeepSeek.

Ожидаемо: из 60 кандидатов после фильтров остаётся ~40–50, после ручной проверки ~25–35. Если принято меньше половины, улучшаем промпт генератора и повторяем на 20 штуках.

  • eval/generate.py генерирует кандидатов в схеме eval/golden.jsonl (тип, сложность, категория, must_include, цитаты evidence, источник kb/<file>.md#<anchor>), воспроизводимо (seed, модель, версия промпта записаны).
  • Работают фильтры: цитата, пересечение, дубли; архивные куски в генератор не попадают.
  • В наборе есть разговорные вопросы, сообщения с двумя вопросами, вопросы о конкретном заказе и без ответа в базе.
  • Минимум 30 кандидатов проверены вручную, принятые лежат в eval/synthetic_reviewed.jsonl и готовы к слиянию в eval/golden.jsonl.
  • В заметках есть доля брака и её причины.

Вопрос 1

Почему синтетический вопрос, почти дословно повторяющий кусок базы знаний, вреден для evals?

Поиск (особенно лексический) находит такой кусок почти всегда, поэтому hit rate завышен и не отражает реальных клиентов, которые пишут иначе.

Вопрос 2

Зачем генератор возвращает поле evidence с дословными цитатами? Какие две причины?

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

Вопрос 3

Что такое «измерения» (dimensions) при генерации синтетики и зачем они?

Оси разнообразия (тип, сложность, персона, стиль и т. д.), по комбинациям которых генерируются вопросы. Они задают распределение набора сознательно, вместо того чтобы модель генерировала однотипное.

Вопрос 4

Какой тип вопросов хуже всего получается из одного случайного куска?

  • a) factual
  • b) comparative
  • c) easy
  • d) с опечаткой.

b. Сравнительному вопросу нужны два куска: два похожих товара («Свет» и «Свет мини») или две страницы на одну тему.

Вопрос 5

По куску warranty.md сгенерирован «неотвечаемый» вопрос. Что обязательно проверить, прежде чем принять его?

Что ответа действительно нет нигде в индексе (поиском и глазами), иначе вопрос окажется отвечаемым и эталон-отказ будет неверен.

Вопрос 6

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

Посчитать эмбеддинги вопросов и выкинуть те, у которых косинусное сходство с уже принятым выше порога (порог подобрать по примерам).

Вопрос 7

Hit rate на синтетических вопросах 0,95, на ручных 0,70. Самая вероятная причина?

Синтетические вопросы копируют формулировки кусков, поэтому поиск находит их легко. Метрики считают отдельно по origin, а копипаст перефразируют словами клиента.

Вопрос 8

Что не так в этом промпте?

Here is a section of the shop help center: {chunk}
Generate 20 diverse questions with answers.

20 вопросов за раз дают повторы и деградацию к концу; нет типа, сложности, персоны и стиля клиента; нет требования к формату (JSON) и цитате; нет запрета копировать текст и заголовки; «diverse» без измерений ничего не гарантирует.

Вопрос 9

Зачем фиксировать seed, модель и версию промпта генератора?

Для воспроизводимости и разбора: понять, откуда взялся вопрос, и перегенерировать набор тем же способом или сравнить версии промпта.

Вопрос 10

Почему куски сэмплируют сначала по категории, а потом внутри категории?

Иначе категория с большим числом страниц (products/, по странице на товар) займёт большую часть набора, и метрика будет описывать в основном её.

Вопрос 11

Модель без контекста отвечает на синтетический вопрос верно. Что это говорит о вопросе?

Он проверяет знания модели (типичные правила магазинов), а не поиск по базе знаний. Пара таких вопросов допустима, но они не говорят о качестве RAG.

Вопрос 12

DeepSeek в JSON mode вернул finish_reason="stop" и пустой content. Что произошло и что делать?

Документированное поведение JSON mode: если модель не смогла выдать валидный JSON, API может вернуть пустое содержимое. Сделать ретрай (и проверить, что в промпте есть слово «json», пример и хватает max_tokens).

Вопрос 13

Чем response_format={"type": "json_object"} у DeepSeek отличается от format=<JSON-схема> в Ollama и что из этого следует для кода генератора?

json_object гарантирует только валидный JSON, а не нужные поля; JSON-схема в Ollama ограничивает ответ схемой. Поэтому на DeepSeek наличие и типы полей проверяются в коде (Pydantic), а пример структуры кладётся в промпт.

Вопрос 14

Чем считать эмбеддинги для фильтра дублей, если основной провайдер — DeepSeek, и почему?

Локальной моделью эмбеддингов в Ollama (или sentence-transformers): эндпоинта эмбеддингов в документации DeepSeek нет, а локально это бесплатно и приватно. Для русских вопросов с опечатками и латинскими артикулами — многоязычная модель (bge-m3).

Вопрос 15

Синтетический вопрос: «заказ 1187 неделю в пути, где он??». Какие у него type, эталон и handoff в наборе и что он проверяет?

type: "unanswerable", reference_answer — фраза REFUSAL, handoff: "personal_data": статуса заказа в kb/ нет, придумывать его нельзя, а номер ловит contains_personal_data до облачного вызова. Вопрос проверяет маршрутизацию личных данных и отказ; номер выдуман, поэтому генерировать его облачной моделью можно.

В 6.3 · Набор из 50–100 вопросов с эталонами добавляем проверенную синтетику к ручным вопросам в eval/golden.jsonl, который станет основой всех экспериментов.