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

8.5 · Проект 3: таблица экспериментов в README

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

README Проекта 3 — главный артефакт для собеседований из всего плана: он показывает умение не просто собрать RAG, а измерять и улучшать его. Фраза «подняли correctness с X% до Y%, и вот таблица, как» с честными цифрами, разбросом и выводами отличает кандидата от сотен с «чат-ботом по PDF». Ревьюер тратит на README пару минут, поэтому история должна читаться с первого экрана.

⚠️ Публичны в проекте только выдуманные вещи: база знаний kb/, набор eval/golden.jsonl и прогоны на нём. Прогон можно публиковать, только если в нём нет ни одного настоящего сообщения клиента; outputs/handoff.jsonl, логи бота и снимки базы магазина в репозиторий не попадают никогда.

Неделя 8 оставила после себя журналы experiments/EXP-*.md, прогоны в eval/runs/ и таблицу всех прогонов eval/RESULTS.md. Всё это — сырьё; README превращает его в историю, которую чужой человек прочтёт за пару минут. Начинаем с того, что этот человек ищет. Нанимающий инженер, открыв репозиторий, хочет за 1–2 минуты понять:

  1. Задача: что за система и какая проблема.
  2. Как измеряется качество: набор, метрики, судья и его проверка.
  3. Результат: откуда стартовали, куда пришли, на чём.
  4. Как думает автор: гипотезы, что не сработало, что удивило, ограничения.
  5. Можно ли это запустить: одна команда для evals.

Пункт 4 недооценивают. Таблица, где всё помогло, выглядит подозрительно; честное «реранкер большой модели ничего не дал, а маленькой дал +N п.п.» и «улучшение на 3 п.п. в пределах шума, не засчитываем» говорят о зрелости больше, чем любая цифра.

Пять вопросов читателя задают порядок разделов. Проект 3 живёт в той же папке projects/02-support-kb-rag/, что и проект 2, поэтому README один: история evals становится его первой частью, а архитектура и Quickstart из 5.5 остаются ниже — на них ссылаемся, а не дублируем.

# Shop Support Assistant: evals & experiments
One-paragraph summary: what it is + headline result
("correctness 0.6x → 0.8x on a 7x-question human-reviewed set, at N× lower cost")
## Results at a glance ← table + one chart
## The system ← short diagram, link to the Architecture section (project 2)
## How quality is measured ← dataset, metrics, judge validation, noise
## Experiments ← table with all experiments + per-experiment conclusions
## What worked, what didn't, what surprised us
## Error analysis ← distribution of failure causes, the fix
## Limitations
## How to run evals ← commands, env vars, cost of a run
## Repo layout

Заголовочный абзац и «Results at a glance» — самое важное: многие дальше не читают.

Сердце README — таблица, где каждая строка — один эксперимент недели 8. Колонки — те же метрики, что в журналах EXP-*.md: качество поиска и ответа, цена, латентность и вывод по правилу значимости:

# Эксперимент hit@5 correctness faithfulness $ / 100 q p50 / p90, с Вывод
0 Baseline: fixed-512, гибрид + переформулировка, модель L 0,71 0,64 ± 0,02 0,83 … … —
1 Chunking по заголовкам (EXP-01) 0,84 0,72 ± 0,02 0,86 … … Принято: +13 п.п. hit (11/2, p≈0,02)
2 Модель S без реранкера (EXP-02) … … … … … Отклонено: ниже порога качества
3 Модель S + реранкер (EXP-02) … … … … … Принято: качество в пределах шума от L, в N раз дешевле
4 Фильтр по товару (EXP-03, из разбора ошибок) … … … … … …
Final (holdout) … … … … … Честная цифра

(Цифры иллюстративные — у каждого будут свои.) Правила:

  • Кумулятивная или независимая? Указываем явно. Обычно строки кумулятивны (каждая добавляет изменение к предыдущей принятой), отклонённые эксперименты помечены и не переносятся дальше.
  • Разброс в формате «± половина размаха трёх прогонов» или «(min–max)»; формат объясняем под таблицей.
  • Колонка «Вывод» с правилом значимости из 8.1: принято / отклонено / тенденция и парные расхождения.
  • Holdout отдельной строкой. Если цифра на holdout ниже dev — так и пишем; это ожидаемо и честно.
  • Версия набора. Если golden.jsonl менялся (исправления после 8.4), либо перепрогоняем все строки на финальной версии (лучше, если недорого), либо указываем версию в каждой строке.

Таблица генерируется скриптом из summary.json прогонов, а не вручную: ручное копирование чисел — источник ошибок. Скрипт eval/make_table.py читает список прогонов из experiments/runs.yaml (пути к папкам eval/runs/<timestamp>_<index>_<retriever>_k<k>/) и печатает markdown. eval/RESULTS.md остаётся журналом всех прогонов подряд, а таблица в README — отобранной историей.

Таблица точна, но динамику и компромиссы глаз схватывает быстрее на картинке. Один хороший график стоит половины текста. Варианты:

  • «качество против стоимости» с точками конфигураций и фронтом Парето (из 8.3);
  • «correctness по шагам» — столбцы baseline → … → final с отрезками разброса.

Подписи осей, единицы, легенда; PNG в docs/ или experiments/, вставлен в README.

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

  • Набор: сколько вопросов, типы и категории (матрица «тип × категория» из 6.3), ловушки (архивная страница, таблицы тарифов, похожие товары, противоречие порога бесплатной доставки), сколько ручных и сколько синтетических, все проверены человеком, dev/holdout. Вопросы выдуманы, база знаний тоже.
  • Метрики: hit@k, MRR, correctness, faithfulness, правильные отказы, archived_in_context — по одной строке определения.
  • Судья: бинарный, с критикой; согласие с ручной разметкой на N примерах: X% (TPR/TNR). Отказы проверяет код, а не судья.
  • Шум: разброс повторных прогонов, правило значимости.

Об этом спросят на собеседовании, поэтому формулировки здесь — заготовки для ответов.

Таблица отвечает «что изменилось», этот раздел — «чему научились»: пункт 4 из списка вопросов читателя. 3–6 пунктов, каждый с цифрой и объяснением механизма. Примеры формата (не результаты):

  • «Чанкинг по заголовкам дал самый большой прирост: таблица тарифов по весу и зоне перестала отрываться от своего заголовка».
  • «Реранкер не помог большой модели (в пределах шума), но маленькой дал +N п.п.: она хуже игнорирует лишний контекст».
  • «Удивило: 13% провалов были ошибками набора (устаревшие эталоны после правки kb/), а не системы».

Последний вопрос читателя — можно ли это запустить. Ревьюер должен суметь повторить. Минимум:

## How to run evals
uv sync # versions pinned in uv.lock
cp .env.example .env # fill in your API keys
uv run python ingest.py # builds index.db from kb/ (committed)
uv run python eval/run.py --config eval/configs/final.json # dev split
uv run python eval/compare.py eval/runs/<A> eval/runs/<B>
uv run python eval/make_table.py experiments/runs.yaml
A full run on the dev split costs about $X and takes ~Y min (prices as of <date>).

.env.example содержит имена переменных без значений. Указываем, сколько стоит прогон: это забота о читателе и показатель того, что автор считает деньги.

README готов, но публичный репозиторий показывает не только его, а всю историю коммитов. Перед push проверяем:

  • Секреты. .env в .gitignore; поиск по истории: git log -p | grep -iE "sk-|api_key|secret" (и аналогично по рабочей копии) или gitleaks из 5.5. Если ключ хоть раз попал в коммит — отзываем ключ у провайдера; удаление из истории не помогает, если репозиторий уже был публичным. После push смотрим, не пришло ли предупреждение от GitHub secret scanning.
  • Никаких данных магазина и клиентов. Проект 3 про выдуманную базу знаний и выдуманный набор. Никаких shop.db, analytics.db и путей к ../shop-live/ из проекта 1, outputs/handoff.jsonl, логов бота с вопросами клиентов, токенов Telegram. Прогоны в eval/runs/ публикуем, только если они сделаны на golden.jsonl без единого настоящего сообщения; прогон, куда хоть раз попали реальные вопросы, остаётся локально. Данные магазина в курсе выдуманы, но обращаемся с ними как с настоящими.
  • База знаний и чужой контент. kb/ и golden.jsonl коммитим: это наши выдуманные тексты, без них не собрать индекс и не повторить evals; короткие цитаты evidence в наборе — из своей же базы. Кэш raw/web/ с чужой публичной страницей (необязательный пункт 4.4) не коммитим.
  • Большие файлы. index*.db (index.db, index_ctx.db, index_fixed512.db, index_headings.db), эмбеддинги, кеш судьи — в .gitignore. Коммитим summary.json и config.json прогонов (маленькие), results.jsonl — по желанию.
  • Версии зависимостей закреплены (uv.lock или requirements.txt с точными версиями).

Та же история нужна и без экрана: на собеседовании её просят рассказать. README заодно служит сценарием устного рассказа. Репетируем вслух:

Я сделал ассистента поддержки интернет-магазина, который отвечает по базе знаний со ссылкой на раздел, и построил для него систему оценки: 7x вопросов четырёх типов, проверенных вручную, метрики поиска и ответа, LLM-судья, сверенный с моей разметкой на N примерах. Провёл четыре эксперимента. Самый сильный эффект дал чанкинг по заголовкам, +X п.п. hit rate. Разбор ошибок показал, что 30% провалов — поиск находит страницу похожего товара, например гарантию «Свет мини» вместо «Свет»; фильтр по товару убрал большую часть. В итоге correctness выросла с A до B на отложенной выборке, а замена модели на маленькую с реранкером снизила стоимость в N раз без потери качества в пределах шума.

Структура README от провайдера не зависит. Меняется то, что читатель должен узнать о моделях, чтобы цифрам можно было верить и чтобы их можно было повторить. Факты о провайдерах сверены в ноябре 2026 по api-docs.deepseek.com и docs.ollama.com.

  • Точные id и даты. deepseek-flash / deepseek-v4-pro плюс resp.model из прогонов и дата: DeepSeek переназначает имена (сейчас deepseek-flash — это V4.1-Flash-1017), и через полгода «deepseek-flash» будет другой моделью.
  • Режим рассуждений ответчика и судьи (thinking включён/выключен) — он меняет и качество, и цену, и шум.
  • Для Ollama — точный тег и железо. «qwen3:4b, ноутбук с 8 ГБ RAM, без отдельной видеокарты, контекст 8k» — без этого латентность локальной модели ничего не значит. Указываем, что теги Ollama по умолчанию квантизованы.
  • Судья и его проверка. «Judge: deepseek-v4-pro, thinking off, temperature 0; agreement with our labels X% on N answers; second opinion from <Claude/OpenAI model> on disputed cases». Если судья из того же семейства, что ответчик, пишем, как проверяли самопредпочтение.
  • Цена с оговорками. Для DeepSeek: по ценам на дату, с учётом кеша, и в каком окне (пик или вне пика) делались прогоны. Для Ollama: «$0 API, runs on the user’s device», рядом p50/p90 латентности.
  • Сравнение с Claude/OpenAI — только если оно действительно прогонялось. Одна-две строки «для сравнения» на тех же вопросах сильно усиливают README; выдуманные или «по рейтингам» цифры его убивают.
# .env.example — names only, no values
DEEPSEEK_API_KEY=
ANSWER_PROVIDER=
ANSWER_MODEL=
ANSWER_MODEL_LOCAL=
EMBED_BACKEND=
EMBED_MODEL=
RERANK_MODEL=
GEN_MODEL=
# optional, for the comparison rows
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
# optional: Langfuse tracing (only the fictional golden set goes there)
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_BASE_URL=
# local models, OpenAI-compatible form: http://localhost:11434/v1
OLLAMA_BASE_URL=

В «How to run evals» добавляем шаги для локальной части: установить Ollama по официальной инструкции для своей ОС с ollama.com (на macOS — проверить приложение codesign -dv и spctl -a -vv перед первым запуском), ollama pull <точный тег> для ответчика и ollama pull bge-m3 для эмбеддингов, и как задать контекст (OLLAMA_CONTEXT_LENGTH). Хороший жест для ревьюера: конфигурация, которая работает вообще без облачных ключей (локальный ответчик + локальные эмбеддинги, только метрики поиска и проверки отказов кодом), чтобы проект можно было запустить за 5 минут бесплатно. Указываем и стоимость полного прогона на DeepSeek (например, «≈$0.6 at peak prices, about half off-peak, as of ») — цифру берём из своих прогонов.

Ревьюер спросит: «DeepSeek — китайский провайдер, а данные?» Готовый ответ в README: «Only the public, fictional knowledge base (kb/) and the fictional eval questions are sent to DeepSeek. Real customer messages are answered by a local model only; no shop database, customer data or keys are in this repository». Строку пишем, только если это правда, и проверяем перед push (раздел «Гигиена публичного репозитория» выше).

Что показать Claude OpenAI DeepSeek Ollama
Идентификатор модели id из доки (с датой снапшота) id из доки id + resp.model + дата точный тег + квантизация
Цена по странице цен на дату по странице цен на дату по странице цен, кеш, пик/вне пика $0 API + железо и латентность
Ключ в .env.example ANTHROPIC_API_KEY (опционально) OPENAI_API_KEY (опционально) DEEPSEEK_API_KEY ключ не нужен
Вопрос ревьюера «почему не только Claude?» то же «а данные?» «насколько хуже и на каком железе?»
  1. GitHub Docs: About READMEs (≈10 мин): где GitHub показывает README, относительные ссылки, изображения.
  2. GitHub Docs: Basic writing and formatting syntax → таблицы, изображения, сворачиваемые блоки (≈15 мин).
  3. Anthropic: Contextual Retrieval ещё раз, уже как пример подачи (≈10 мин): как коротко представлены эксперименты и цифры. Берём оттуда манеру «изменение → эффект в процентах → цена».
  4. Перечитываем свой README Проекта 2 (5.5) (≈10 мин): Проект 3 на него ссылается, а не дублирует.
  5. Перечитываем журналы experiments/EXP-*.md (≈20 мин) и выписываем для каждого одну фразу вывода.
  6. DeepSeek: Models & Pricing в день публикации README (≈5 мин): сверяем id и цены в таблице с текущими, ставим дату.
  7. Страница своей локальной модели на ollama.com/library (≈5 мин): точный тег и размер — в раздел «How to run evals».
  • Цифры без разброса и размера набора выглядят как маркетинг. Всегда «на N вопросах», «± разброс».
  • Только dev-цифры. Финальный результат — holdout; если его нет, читатель-инженер это заметит.
  • Ручное копирование чисел в таблицу — опечатки и расхождения с прогонами. Таблицу генерирует скрипт.
  • Несравнимые строки: разные версии набора без пометки.
  • Скрытые неудачи. Отклонённые эксперименты — тоже результат, их показывают.
  • README, который нельзя запустить: нет .env.example, kb/ не закоммичена, не закреплены версии.
  • Секрет в истории git — отзывать ключ, а не только чистить историю.
  • Прогон с настоящими вопросами клиентов в eval/runs/. Публиковать можно только прогоны на выдуманном наборе.
  • Стена текста. Первый экран: абзац итога + таблица + график.
  • «deepseek-flash» без даты и resp.model — через полгода цифры будет невозможно ни повторить, ни объяснить.
  • Латентность Ollama без железа («p90 9 s») ничего не говорит: на ноутбуке с 8 ГБ и на сервере с GPU это разные числа.
  • Строки «Claude/OpenAI для сравнения» без реальных прогонов. Только то, что прогнали сами на том же наборе.
  • Создаём experiments/runs.yaml: список строк таблицы (имя эксперимента, путь к папке прогона в eval/runs/, кумулятивно ли, вывод).
  • Пишем eval/make_table.py: читает summary.json указанных прогонов, печатает markdown-таблицу с hit@5, correctness (с разбросом), faithfulness, $/100q, p50/p90, выводом.
  • Если версия набора менялась, перепрогоняем ключевые конфигурации (baseline и принятые шаги) на финальной версии, если позволяет бюджет; иначе помечаем версии в таблице.
  • Строим один график (качество против цены или correctness по шагам) → experiments/*.png.
  • Пишем README по структуре выше: итоговый абзац, таблица, график, как измеряется качество, эксперименты, «что помогло / нет / удивило», разбор ошибок, ограничения, «Как запустить evals», структура репо; разделы проекта 2 (архитектура, Quickstart) остаются ниже.
  • В разделе «How quality is measured» указываем модели ответчика и судьи с точными id, resp.model, режимом thinking и датой; для локальной модели — тег, железо, контекст.
  • Обновляем .env.example: DEEPSEEK_API_KEY, переменные моделей, опциональные ключи для сравнения и Langfuse, OLLAMA_BASE_URL — только имена. Проверяем .gitignore (.env, index*.db*, кеши судьи, raw/, outputs/, data/).
  • Делаем конфигурацию eval/configs/local_only.json, которая запускается без облачных ключей (локальный ответчик, только метрики поиска и проверки отказов кодом).
  • Проверяем историю и рабочую копию на секреты; убеждаемся, что нет *.db, outputs/handoff.jsonl и прогонов с настоящими сообщениями клиентов (git ls-files, git log --all --stat -- '*.db' 'outputs/*'). Только после этой проверки добавляем в README строку о приватности.
  • Проходим «Как запустить evals» с нуля в чистой папке (новый clone, новое окружение), включая local_only.json. Исправляем всё, что не сработало.
  • Push на GitHub. Открываем README в браузере: таблица и график отображаются, ссылки работают.
  • Репетируем вслух питч на 60 секунд, записываем его в notes/pitch.md.
  • README рассказывает историю улучшений в цифрах: старт → финиш на holdout, каждый эксперимент с выводом, разброс и размер набора указаны.
  • Есть разделы «что помогло / не помогло / удивило», «Ограничения», «Как запустить evals».
  • Evals запускаются по README в чистом клоне.
  • Репозиторий на GitHub без секретов, без данных магазина и без настоящих сообщений клиентов; kb/ и eval/golden.jsonl на месте.

Вопрос 1

Что должно быть на первом экране README Проекта 3?

Абзац с задачей и главным результатом (старт → финиш, на каком наборе, цена), таблица результатов и график.

Вопрос 2

Зачем показывать отклонённые эксперименты?

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

Вопрос 3

Почему итоговую цифру берут с holdout, а не с dev?

Dev использовался для выбора конфигураций и разбора ошибок, поэтому его цифры оптимистичны; holdout не участвовал в выборе.

Вопрос 4

Что не так с такой строкой в README?

Добавили реранкер — качество выросло с 74% до 77%!!!

Нет размера набора и разброса; +3 п.п. скорее всего в пределах шума (нет парного сравнения); не сказано, что зафиксировано, сколько стоило и как изменилась латентность; восклицательные знаки вместо вывода.

Вопрос 5

Зачем генерировать таблицу скриптом из summary.json?

Чтобы цифры в README точно совпадали с прогонами и таблицу можно было пересобрать после новых экспериментов без ручных ошибок.

Вопрос 6

В истории git нашёлся коммит с API-ключом, репозиторий уже публичный. Что делать в первую очередь?

  • a) git rebase и force push
  • b) отозвать ключ у провайдера
  • c) сделать репозиторий приватным
  • d) ничего, никто не заметит.

b. Ключ уже мог быть скопирован; сначала отзыв, затем чистка истории по желанию.

Вопрос 7

Почему kb/ и eval/golden.jsonl коммитят, а outputs/handoff.jsonl и прогон, в котором были настоящие вопросы клиентов, — нет?

kb/ и набор выдуманы и принадлежат автору, без них не собрать индекс и не повторить evals. handoff.jsonl и такой прогон содержат сообщения клиентов — персональные данные, которые не публикуют (и не отправляют в облако), даже если данные магазина в курсе выдуманы: обращаемся с ними как с настоящими.

Вопрос 8

Что должно быть в .env.example?

Имена нужных переменных окружения (без значений), чтобы читатель знал, что заполнить.

Вопрос 9

Строки таблицы посчитаны на разных версиях набора. Как поступить?

Перепрогнать ключевые конфигурации на финальной версии набора; если это слишком дорого — явно пометить версию набора в каждой строке и не сравнивать несравнимое.

Вопрос 10

Почему стоит указать в README стоимость одного прогона evals?

Читатель понимает, во что обойдётся повторить, и видит, что автор считает деньги — это часть инженерной работы.

Вопрос 11

Почему в README мало написать «answer model: deepseek-flash»? Что добавить?

Имя у DeepSeek может указывать на разные версии во времени. Нужны resp.model из прогонов, дата, режим thinking и цены на дату (с учётом кеша и окна пик/вне пика).

Вопрос 12

Ревьюер спрашивает: «Вы отправляли данные в DeepSeek, это же Китай?» Что должно быть правдой в проекте, чтобы ответить коротко, и где это написано?

В DeepSeek уходили только публичная выдуманная база знаний и выдуманные вопросы набора; настоящие сообщения клиентов обрабатывает только локальная модель, а данных магазина, клиентов и ключей в репозитории нет нигде. Это одна строка в README (раздел о приватности) и проверка репозитория перед push.

Проект 3 завершён. Следующий этап — агенты: начинаем с 9.1 · Building effective agents + OWASP LLM Top 10; навык evals вернётся в 10.3 · Проект 4: evals агента на 20 случаях.