6.2 · Синтетические данные для evals
Неделя 6 · Evals · ⏱ ~4 ч · Практика
Зачем это AI-инженеру
Заголовок раздела «Зачем это AI-инженеру»Вручную придумать 100 хороших вопросов к базе знаний магазина — день скучной работы, и вопросы получатся однобокими: мы спросим о том, что сами помним, и теми словами, которыми написана база. Клиенты пишут иначе: с опечатками, по два вопроса сразу, с номером заказа посреди жалобы. Модель за минуты генерирует черновик, а мы как авторы базы его проверяем и чистим. Генерация синтетических данных — стандартный приём в индустрии и частая тема на собеседованиях («как вы собрали тестовый набор, когда пользователей ещё нет?»).
Идея: модель пишет черновик, человек принимает решение
Заголовок раздела «Идея: модель пишет черновик, человек принимает решение»Сейчас у нас есть 20 вопросов в eval/golden.jsonl из недели 5 и пять вопросов из 6.1, на которых ассистент ошибается. В следующей главе, 6.3, набор вырастет до 50–100 вопросов, и часть недостающих напишет модель.
Синтетический набор — это вопросы и эталонные ответы, которые сгенерировала модель по нашим же данным, здесь — по страницам kb/. Эталонный ответ (эталон) — ответ, который мы считаем правильным и с которым потом сравниваем ответ ассистента. Схема для RAG:
случайный кусок kb/ ──► модель-генератор ──► кандидат {вопрос, ответ, цитата} │ ▼ автоматические фильтры (дубли, копипаст, «неотвечаемые») │ ▼ ручная проверка (мы) │ ▼ eval/golden.jsonlКлючевое слово — черновик. Ни один синтетический вопрос не попадает в набор без нашего «да». Модель не знает, какие вопросы клиенты задают на самом деле, и регулярно ошибается в эталоне.
Почему «случайный кусок» и почему этого мало
Заголовок раздела «Почему «случайный кусок» и почему этого мало»Начинать со случайного куска удобно: ответ заведомо лежит в нём, и у кандидата сразу есть ссылка на источник. Для метрик поиска (hit rate из 6.1) это ровно то, что нужно: мы знаем, какой кусок поиск обязан найти. Но у подхода три известных перекоса:
- Копирование формулировок. Модель берёт фразу из куска и превращает её в вопрос: «Подлежат ли возврату чай и кофе на развес во вскрытой упаковке?» Векторный и особенно BM25-поиск находят такой кусок почти всегда, и hit rate завышен. Реальный клиент напишет: «пачку сенчи открыли, вкус не зашёл, можно сдать?».
- Один кусок — один вопрос. Сравнительные («чем гарантия на „Свет“ отличается от „Свет мини“») и многошаговые вопросы так не получаются, а они самые трудные.
- Нет вопросов «вне базы знаний». Хороший ассистент должен уметь сказать «в базе знаний нет ответа» и передать вопрос оператору. Особый случай — «где мой заказ 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 в генератор не идут: эталон получится по устаревшим правилам.
Перефразирование против копипаста
Заголовок раздела «Перефразирование против копипаста»Кандидата мы получили; теперь по схеме из начала главы его ждут автоматические фильтры. Их три — против копипаста, дублей и «слишком простых» вопросов, — плюс проверка самого эталона. Начинаем с копипаста, первого перекоса из списка выше. Против него две защиты, и лучше обе:
- В промпте генератора: «Сформулируй вопрос так, как его написал бы клиент в чат поддержки, не видевший справочный центр. Не используй заголовки, термины и фразы из текста».
- Фильтр на пересечение: считаем, какая доля слов вопроса встречается в куске, или длину самой длинной общей подстроки. Слишком высокое пересечение — на перефразирование (второй вызов модели) или в мусор.
Простой фильтр пересечения:
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, randomfrom anthropic import Anthropic # or openai, whichever you use in Project 2
client = Anthropic() # reads ANTHROPIC_API_KEY from env, never hardcode keysMODEL = 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, модель и версию промпта: через месяц захочется понять, откуда взялся вопрос.
Ручная проверка
Заголовок раздела «Ручная проверка»Фильтры отсеивают явный брак, но не решают, хорош ли вопрос. Это последний шаг схемы, и он за нами. Для каждого кандидата отвечаем на четыре вопроса:
- Написал бы так реальный клиент? Если нет — переписываем или выбрасываем.
- Эталон верен и полон? Сверяем с цитатой и страницей
kb/; в сообщении с двумя вопросами закрыты оба. - Вопрос однозначен? «Какая гарантия на лампу?» без указания, «Свет» или «Свет мини», — плохой вопрос.
- Тип, сложность и категория размечены правильно?
Удобно проверять в цикле в терминале (y / n / e — редактировать) или в таблице через CSV. Записываем, сколько кандидатов прошло: доля брака сама по себе полезна (если отбраковано 60%, промпт генератора плохой).
DeepSeek и Ollama: как это сделать у нас
Заголовок раздела «DeepSeek и Ollama: как это сделать у нас»Скелет выше написан на Anthropic SDK; оставляем его как вариант «для сравнения». Основной генератор у нас — DeepSeek через OpenAI SDK, эмбеддинги для фильтра дублей — Ollama, локально и бесплатно. База знаний kb/ публичная, поэтому её куски в DeepSeek отправлять можно; личные данные клиентов — нет. Факты сверены в ноябре 2026 по api-docs.deepseek.com и docs.ollama.com.
Генератор на DeepSeek
Заголовок раздела «Генератор на DeepSeek»# eval/generate.py — DeepSeek variant (illustrative; check current model ids in the docs)import json, osfrom 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 ollamafrom 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 npimport 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 комбинациях.
Маршрут изучения
Заголовок раздела «Маршрут изучения»- Перечитываем в Hamel Husain: Your AI Product Needs Evals раздел про генерацию тестовых случаев моделью (≈15 мин). Обращаем внимание, как он задаёт сценарии и персоны, а не просит «придумай вопросов».
- Открываем Hamel: A Field Guide to Rapidly Improving AI Products → раздел про синтетические данные и измерения (dimensions) (≈25 мин). Берём отсюда идею кортежей «измерение × значение».
- В Hamel: AI Evals FAQ находим вопросы про synthetic data (поиск по странице «synthetic») (≈20 мин). Там о том, когда синтетика вредна и как её проверять.
- Открываем Ragas docs → раздел про генерацию тестового набора (testset generation) (≈20 мин). Не вместо своего скрипта: смотрим, какие типы вопросов он делает (single-hop, multi-hop) и как строит граф знаний по документам.
- Возвращаемся к коду проекта 2 и находим, как достать из
index.dbвсе куски с метаданными:chunk_key, текст,url,section,category,archived(≈15 мин). Это вход генератора. - Открываем DeepSeek: JSON Output и Thinking Mode (≈15 мин). Обращаем внимание на три требования JSON mode (слово «json», пример, достаточный
max_tokens) и на пустойcontent. - Открываем 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
Какой тип вопросов хуже всего получается из одного случайного куска?
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, который станет основой всех экспериментов.