От 0.034 до 0.791 и обратно: соревнование по Legal RAG, 17 итераций и стена масштабирования
Мне давно хотелось погрузиться в RAG, но повода не было. Я решил поучаствовать в ARLC 2026 — юридическом AI-челлендже, где нужно строить RAG-пайплайн поверх корпуса судебных решений и законов DIFC – находить нужные страницы в нужных документах, извлекать ответы и давать точные ссылки на источники. Соло, с Claude Code в качестве напарника.
До этого я работал с ML, но RAG-пайплайны не строил. За 5 дней прошёл путь от первой подачи с grounding ≈ 0 до 0.791 на первом этапе. А потом вышел в финал — и pipeline, который отлично работал на 30 документах, потерял 42% на 300.
В этой статье — архитектура, конкретный код, математика F-beta, полная таблица 17 итераций с метриками и честный разбор работы с AI-ассистентом на соревновании. Код открыт на GitHub.
Кто я такой
Я заканчивал направление NLP в НИУ ВШЭ. На работе я занимаюсь аналитикой (работал в Яндексе, Ozon, Альфа-Банке). Регулярно участвую в соревнованиях (топ-600 на Kaggle), но RAG до этого челленджа не строил. Еще я веду каналы про аналитику – Тагир Анализирует и Зарплатник Аналитика. Сейчас активно строю свой продукт — Карьерник, Duolingo для подготовки к собеседованиям на аналитика.
Что за челлендж
ARLC 2026 (Arab Region Legal Challenge) — это соревнование по Retrieval-Augmented Generation на юридических документах. Тебе дают корпус PDF-документов (судебные решения DIFC Courts, законы, указы) и набор вопросов к ним.
Задача: для каждого вопроса найти нужные страницы в нужных документах, извлечь ответ и предоставить точные ссылки на источники.
Звучит просто. Но вот нюансы.
Типы ответов
Это не просто «ответь текстом». Каждый вопрос имеет свой answer_type, и от типа зависит и стратегия ответа, и скоринг:
Тип | Формат | Скоринг |
|---|---|---|
| число (int/float) | ±1% допуск |
| true/false | точное совпадение |
| строка | normalized exact match |
| YYYY-MM-DD | точное совпадение |
| массив строк | Jaccard similarity |
| текст до 280 символов | LLM-судья, 5 критериев |
Для любого типа null — валидный ответ, означающий «информации нет в корпусе». Если и в gold-ответе null — получаешь 1 балл. Если только у тебя null — получаешь 0.
Формула скоринга
Total = (0.7 × S_det + 0.3 × S_asst) × G × T × F
Где:
S_det — точность на типизированных вопросах (number, bool, date, name, names)
S_asst — оценка LLM-судьи для free_text (5 критериев: correctness, completeness, grounding, confidence calibration, clarity)
G — grounding (F-beta, β=2.5 по page-level ссылкам)
T — telemetry factor (0.9 если телеметрия невалидна, 1.0 если ок)
F — TTFT factor (бонус/штраф за скорость)
G — это множитель. Если grounding = 0, весь скор = 0. Неважно, насколько идеальные ответы.
TTFT-бонус — бесплатные проценты
TTFT (ms) | Множитель |
|---|---|
< 1000 | 1.05 |
< 2000 | 1.02 |
< 3000 | 1.00 |
> 3000 | 0.85–0.99 |
Быстрый ответ = +5% к финальному скору. Медленный — штраф до -15%. При прочих равных — это бесплатные очки.
Warmup vs. Финал
Warmup: 30 документов, 100 вопросов, 15 попыток подачи
Финал: 300 документов, 900 вопросов, 2 попытки
У каждой фазы свой корпус — нельзя использовать документы из warmup в финале.
День первый: четыре символа, которые стоили три попытки
Первая подача — v1. Скор: 0.034.
Det | Asst | G | T | F | Total |
|---|---|---|---|---|---|
0.857 | 0.613 | 0.050 | 0.900 | 0.960 | 0.034 |
Grounding — 0.05. Из 100 вопросов система почти ни разу не сослалась на правильные страницы. При этом Det = 0.857 — ответы-то были неплохие.
Но G — множитель. 0.857 × 0.05 ≈ ничего.
Ещё две попытки (v2, v3) с мелкими правками. 0.035. 0.036. Grounding не двигался. Я перебрал всё: может, страницы нумеруются с нуля? Может, doc_id — это не имя файла? Может, ретривал совсем мимо?
А потом я открыл submission.json и увидел:
{ "doc_id": "abc123def456.pdf", "page_numbers": [3, 7] }
А в документации API:
doc_idmust be the PDF filename (SHA-like string)
Файл называется abc123def456.pdf. Имя файла — abc123def456. Без .pdf.
Три попытки из пятнадцати ушли на четыре символа.
v4 — одна строчка doc_id = filename.replace('.pdf', ''):
Det | Asst | G | T | F | Total |
|---|---|---|---|---|---|
0.857 | 0.673 | 0.550 | 0.986 | 1.005 | 0.438 |
G: 0.05 → 0.55. Скор: 0.034 → 0.438. В 13 раз.
Урок: сначала валидируй формат вывода. Потом улучшай качество. Никакой retrieval, reranking или fine-tuning не спасёт, если submission не матчится с gold по формату. Это кажется очевидным, но когда ты с нуля собираешь пайплайн — голова занята архитектурой, а не тем, есть ли .pdf в doc_id.
Архитектура: что и почему
К v14 (лучший скор) пайплайн выглядит так:

Разберём каждый компонент.
Ingestion: парсинг PDF
Юридические PDF бывают двух видов: цифровые (текст копируется) и сканированные (картинка). Нужно обрабатывать оба.
def extract_pages(pdf_path, min_chars=50, ocr_dpi=300): pages = [] doc = pdfplumber.open(pdf_path) for i, page in enumerate(doc.pages): text = page.extract_text() or "" if len(text.strip()) < min_chars: # Сканированный документ — fallback на OCR text = ocr_page(pdf_path, i + 1, dpi=ocr_dpi) pages.append({ "doc_id": pdf_path.stem, # без .pdf! "page_number": i + 1, # 1-based "text": text }) return pages
Ключевое решение: page-level, а не чанки. Grounding считается по (doc_id, page_number) — значит и индексировать нужно по страницам. Чанки (куски по 500 токенов) создают проблему маппинга обратно на страницы: чанк может пересекать границу страниц, и непонятно, на какую страницу ссылаться.
Минус page-level: страница может быть длинной (1500+ символов), и контекст для LLM раздувается. Решение — context distillation перед вызовом модели (об этом ниже).
Гибридный поиск: BM25 + embeddings + RRF
Чистый BM25 хорошо находит точные совпадения — номер статьи, имя судьи, номер дела. Но плохо ловит переформулировки: вопрос «What is the penalty?» не матчится с текстом «shall be liable to a fine».
Чистые embeddings наоборот — ловят семантику, но теряют точные термины.
Решение — гибридный поиск с Reciprocal Rank Fusion:
def hybrid_search(query, bm25_index, embedding_index, pages, top_k=20, rrf_k=60): # BM25 ранжирование bm25_scores = bm25_index.get_scores(tokenize(query)) bm25_ranked = sorted(range(len(pages)), key=lambda i: -bm25_scores[i]) # Embedding ранжирование q_emb = embed_model.encode([query[:512]], normalize_embeddings=True) sim_scores = (embedding_index @ q_emb.T).flatten() emb_ranked = sorted(range(len(pages)), key=lambda i: -sim_scores[i]) # RRF fusion: score(d) = Σ 1/(k + rank(d)) combined = {} for rank, idx in enumerate(bm25_ranked[:top_k * 3]): combined[idx] = combined.get(idx, 0) + 1.0 / (rrf_k + rank) for rank, idx in enumerate(emb_ranked[:top_k * 3]): combined[idx] = combined.get(idx, 0) + 1.0 / (rrf_k + rank) # Сортировка по combined score return sorted(combined, key=lambda i: -combined[i])[:top_k]
RRF с k=60 — стандартная формула. Она не требует нормализации скоров (BM25 и cosine similarity имеют разные шкалы), а просто использует ранги. Страница, которая в топ-5 по обоим методам, получит высокий combined score.
Модель для embeddings: all-MiniLM-L6-v2 — 22M параметров, 90MB, работает локально. Для 30 документов warmup этого хватает с запасом. Для финала (300 документов) тоже — индексация занимает ~10 секунд.
Document routing: comparison-вопросы
Comparison-вопросы типа «В каком из дел CFI 001/2020 и CFI 002/2021 судья был назначен раньше?» требуют контекста из двух документов. Обычный retrieval может найти страницы только из одного.
Решение — отдельный routing-индекс, построенный по первым двум страницам каждого документа:
def build_doc_routing_index(pages): case_to_doc = {} # "CFI 010/2024" → "abc123def" law_to_doc = {} # "DIFC Law No. 5 of 2007" → "xyz789abc" for p in pages: if p["page_number"] > 2: # Только титульные страницы continue # Паттерн номера дела: "CFI 010/2024", "DIFC ARB 123/2020" for case in re.findall(r"\b([A-Z]{2,5}\s+\d{3}/\d{4})\b", p["text"]): case_to_doc[case.upper()] = p["doc_id"] return case_to_doc, law_to_doc
При обнаружении comparison-вопроса (два номера дел в тексте) делаем dual query — отдельный BM25-запрос для каждого дела, результаты чередуем round-robin:
Query A (case 1): [page1_A, page3_A, page7_A, ...] Query B (case 2): [page2_B, page5_B, page9_B, ...] Interleaved: [page1_A, page2_B, page3_A, page5_B, ...]
Зачем чередование: если все результаты case A идут первыми, а max_pages маленький (4) — case B может не попасть в контекст совсем. Чередование гарантирует представительство обоих дел.
Cross-encoder reranking
BM25 + embeddings дают грубую сортировку. Cross-encoder (ms-marco-MiniLM-L-6-v2) пересортировывает топ-30 по настоящей релевантности:
def rerank(pages, query, top_k=5): HARD_CAP = 30 # O(N²) scaling protection # Priority pages (article index, routing) не могут быть выброшены priority = [p for p in pages if p.get("_priority")] non_priority = pages[:HARD_CAP - len(priority)] all_pages = priority + non_priority pairs = [(query, p["text"][:512]) for p in all_pages] scores = cross_encoder.predict(pairs) # Priority pages получают score = 1000 (всегда в топе) for i, p in enumerate(all_pages): if p.get("_priority"): scores[i] = 1000.0 ranked = sorted(zip(scores, all_pages), key=lambda x: -x[0]) return [p for _, p in ranked[:top_k]]
Hard cap 30 — не прихоть, а необходимость. Cross-encoder делает forward pass для каждой пары (query, page). 30 пар — ~50ms. 100 пар — ~500ms. 1500 пар (весь корпус) — несколько секунд, и TTFT улетает за 3000ms → штраф к скору.
Priority tagging — самый важный паттерн. Некоторые страницы должны быть в контексте всегда (определение цитируемой статьи, титульная страница дела). Cross-encoder может их опустить (например, если вопрос сформулирован иначе, чем текст статьи). Флаг _priority=True гарантирует, что эти страницы не выпадут.
Типизированные ответы: когда LLM не нужен
Одно из ключевых решений — детерминированные fast-paths для типизированных вопросов. Зачем тратить 700ms на вызов Haiku, если можно за 5ms извлечь ответ регулярным выражением?
Извлечение номера закона
if answer_type == "number" and "law number" in question.lower(): for page in context_pages: m = re.search(r"(?:DIFC\s+)?Law\s+No\.?\s*(\d+)", page["text"]) if m: return int(m.group(1)), [page] # Ответ + grounding
Сравнение дат
if answer_type == "name" and is_comparison and "earlier" in question.lower(): dates = {} for case_ref, doc_id in case_routing.items(): for pn in (1, 2): # Дата выдачи — на первых двух страницах page = get_page(doc_id, pn) m = re.search(r"Date of Issue[:\s]+(\w+ \d+,?\s+\d{4})", page["text"]) if m: dates[case_ref] = parse_date(m.group(1)) # Возвращаем case с более ранней датой earlier = min(dates, key=dates.get) return earlier, [pages_used]
Сравнение денежных сумм
if answer_type == "name" and "higher monetary claim" in question.lower(): amounts = {} for case_ref, doc_id in case_routing.items(): for pn in range(1, 6): for m in re.finditer(r"AED\s+([\d,]+(?:\.\d+)?)", page_text): amount = float(m.group(1).replace(",", "")) # "4 months of salary at AED 50,000" → 200,000 months_m = re.search(r"(\d+)\s*months?\s+of\s+salary", pre_context) if months_m: amount *= int(months_m.group(1)) amounts[case_ref] = max(amounts.get(case_ref, 0), amount) return max(amounts, key=amounts.get), [pages_used]
Эти fast-paths покрывают ~30-40% типизированных вопросов. Выигрыш: -700ms на TTFT (бонус к F) и 0 токенов на API.
Adversarial detection
В корпусе DIFC есть вопросы-ловушки — про институты, которых в DIFC-праве просто нет:
_ADVERSARIAL_KEYWORDS = { "jury", # В DIFC нет суда присяжных "plea bargain", # Институт common law, не DIFC "miranda rights", # Американское право "parole", # Система условного освобождения, не DIFC "bail bond", # Залоговая система другого типа } def is_adversarial(question): q_lower = question.lower() return any(kw in q_lower for kw in _ADVERSARIAL_KEYWORDS)
Если вопрос adversarial, ответ — null для типизированных и стандартный fallback для free_text:
“There is no information on this question in the provided documents.”
v15 показал, что менять этот fallback нельзя. Я попробовал заменить его на конкретные DIFC-объяснения («DIFC Courts do not use jury trials…»). Gold ожидал generic fallback. Asst упал с 0.720 до 0.640.
Context distillation: сжимаем контекст для LLM
Страница юридического документа — это ~1500 символов. Если в контексте 5 страниц — это 7500 символов, а большая часть нерелевантна.
Context distillation выбирает только релевантные абзацы:
def distill_page(text, question, max_chars=1200): paragraphs = re.split(r"\n\s*\n|\n(?=[A-Z0-9\(\[])", text) q_words = extract_keywords(question) scored = [] for i, para in enumerate(paragraphs): score = sum(1 for w in q_words if w in para.lower()) # Бонус: упоминание нужной статьи if "Article 14" in question and re.search(r"\bArticle\s+14\b", para): score += 5 # Бонус: judge/party info в заголовочных секциях if "judge" in question.lower() and re.search(r"BETWEEN|Judge|BEFORE", para): score += 5 # Первый абзац (заголовок) — всегда оставляем if i == 0: score += 1 scored.append((score, para)) scored.sort(key=lambda x: -x[0]) result, total = [], 0 for score, para in scored: if total + len(para) > max_chars: break result.append(para) total += len(para) return "\n\n".join(result)
Результат: ~40% сжатие (1500 → 900 символов) с сохранением ключевой информации. Экономит ~200 input-токенов на вопрос.
Бюджеты по типам ответов:
Тип | Max pages | Chars/page | Max tokens (output) |
|---|---|---|---|
boolean | 4 | 1200 | 30 |
number | 3 | 900 | 30 |
date | 3 | 900 | 30 |
name | 3 | 900 | 60 |
free_text | 5 | 1200 | 250 |
Для boolean — больше страниц (comparison требует контекст из двух документов), но маленький output. Для free_text — больше всего и контекста, и output.
LLM: промпты, модели, парсинг цитат
Выбор модели
if answer_type == "free_text": model = "claude-sonnet-4-6" # Лучше рассуждает, +0.10 Asst else: model = "claude-haiku-4-5-20251001" # Быстрее, хватает для извлечения
Почему не Sonnet для всего: +300ms TTFT на каждый вопрос. При 30% free_text вопросов это снижает средний F на ~0.009. Sonnet даёт +0.10 на Asst, что в формуле = +0.03 × G. При G=0.86 это +0.026. Чистый выигрыш: +0.026 - 0.009 = +0.017 — стоит того.
Type-specific промпты
Для каждого типа — отдельная инструкция. Вот пример для number:
Return ONLY a single numeric value (integer or decimal). IMPORTANT: The answer is the VALUE stated in the text, NOT the article number. Example: Article 19(4) says 'within six months' → answer is 6, NOT 19. Word-numbers: 'one'=1, 'six'=6, 'twelve'=12. Accounting parentheses: (5,000) = -5000. If not found: null End with CITE: followed by 0-based block numbers (e.g. CITE:0,2).
«NOT the article number» — результат конкретного бага. LLM получает контекст про Article 19, видит вопрос «What is the time limit under Article 19?» и отвечает… 19. Вместо 6 (месяцев). Эта строчка в промпте — хардкод-фикс одной из самых частых ошибок.
Comparison boolean: CoT
Для сравнительных boolean-вопросов добавлен Chain-of-Thought:
Compare the two entities/cases. Extract the fact from each: E1:[fact] E2:[fact] ANSWER:true/false CITE:0,1
Вместо прямого «true/false» модель сначала извлекает факты, потом сравнивает. Это снижает ошибки сравнения на ~15%.
Парсинг цитат из ответа LLM
LLM возвращает что-то вроде:
The time limit is 6 months. CITE:0,2
Парсинг:
def parse_citations(raw_response, num_context_pages): m = re.search(r"\bCITE:\s*([\d,\s]+)\s*$", raw_response) if m: indices = [int(i) for i in re.findall(r"\d+", m.group(1)) if int(i) < num_context_pages] answer_text = raw_response[:m.start()].strip() return answer_text, indices return raw_response, []
Эти индексы — 0-based номера «блоков» контекста (страниц), которые модель считает релевантными. Но модель может цитировать неточно — поэтому дальше идёт verification.
Post-LLM verification: ловим галлюцинации
LLM иногда ошибается — возвращает число из другого контекста, путает артикул с его значением, или отвечает generic фразой вместо конкретного имени. Post-LLM verification это ловит.
Проверка «ответ есть в тексте»
def verify_in_text(answer, answer_type, pages): """Проверяет, что ответ LLM действительно встречается в контексте.""" if answer_type == "number": variants = number_search_variants(answer) # [6, "six", "six (6)"] for page in pages: for v in variants: if re.search(re.escape(str(v)), page["text"], re.IGNORECASE): return True return False if answer_type == "name": return any(str(answer).lower() in p["text"].lower() for p in pages) if answer_type == "date": variants = date_search_variants(answer) # ["2024-03-15", "15 March 2024", "March 15, 2024"] for page in pages: for v in variants: if v in page["text"]: return True return False return True # bool и free_text не проверяем
Если ответ не найден в тексте — значит, LLM галлюцинирует. Fallback на детерминированное извлечение:
llm_answer = call_llm(question, context_pages) if not verify_in_text(llm_answer, answer_type, context_pages): # LLM соврал — пробуем regex det_answer = deterministic_extract(answer_type, context_pages, question) if det_answer is not None: return det_answer
Article-number confusion
Одна из самых коварных ошибок:
if answer_type == "number": article_ref = re.search(r"Article\s+(\d+)", question) if article_ref and int(article_ref.group(1)) == llm_answer: # LLM вернул номер статьи вместо значения из неё det_answer = deterministic_extract("number", pages, question) if det_answer is not None and det_answer != llm_answer: return det_answer # Правильный ответ
Пример: «What is the notice period under Article 14?» → LLM отвечает 14 вместо 30 (дней).
Territorial scope check
DIFC-законы действуют только в DIFC. Если вопрос спрашивает «Does DIFC Law apply in London?» — ответ false, даже если LLM говорит true:
if answer_type == "boolean" and llm_answer is True: foreign_m = re.search(r"apply.*\b(?:in|to)\s+(.{5,80}?)(?:\?|$)", question) if foreign_m: jurisdiction = foreign_m.group(1).lower() is_foreign = any(city in jurisdiction for city in ["london", "new york", "paris", "singapore"]) is_difc = "difc" in jurisdiction or "dubai" in jurisdiction if is_foreign and not is_difc: return False # DIFC law не применяется в другой юрисдикции
Evidence-based grounding: главный компонент
Grounding — множитель в формуле. Ему посвящено больше всего кода и итераций. Система выбора страниц для цитирования — трёхуровневая:
Уровень 1: Article index pages
Для вопросов про статьи закона — сначала ищем страницу, где статья определена (заголовок «Article 14»), а не просто упоминается:
def build_article_index(pages): """Индекс: (doc_id, article_num) → [pages where article heading appears]""" definitions = {} for p in pages: # Паттерн заголовка: "Article 14" в начале строки for m in re.finditer(r"(?:^|\n)\s*Article\s+(\d+)\b", p["text"]): key = (p["doc_id"], m.group(1)) definitions.setdefault(key, []).append(p["page_number"]) # Паттерн sub-clause: "14(" — тоже определение for m in re.finditer(r"(?:^|\n)\s*(\d+)\(", p["text"]): num = int(m.group(1)) if 1 <= num <= 200: key = (p["doc_id"], str(num)) definitions.setdefault(key, []).append(p["page_number"]) return definitions
Почему definitions, а не all mentions: страница, где написано «see Article 14» в тексте другой статьи — это не gold-страница. Gold — это страница, где Article 14 определена и содержит ответ.
Это изменение (article index → CITE ordering) дало +0.065 к G (v12→v13).
Уровень 2: LLM citations (CITE)
Если article index не помог — используем цитаты из ответа LLM:
if cited_indices: # [0, 2] — LLM считает релевантными блоки 0 и 2 for i in cited_indices: candidate_pages.append(context_pages[i])
Уровень 3: Evidence verification
Финальная проверка: содержит ли кандидат-страница реальное доказательство ответа?
def verify_evidence(answer, answer_type, candidate_pages): if answer_type == "number": # Ищем страницу, где есть И значение И ссылка на статью for page in candidate_pages: has_value = any(v in page["text"] for v in number_variants(answer)) has_article = re.search(r"Article\s+\d+", page["text"]) if has_value and has_article: return [page] # Идеальный кандидат # Fallback: хотя бы значение for page in candidate_pages: if any(v in page["text"] for v in number_variants(answer)): return [page] return candidate_pages[:1] # Крайний fallback
Ключевая идея: страница, содержащая и ответ, и ссылку на нужную статью — почти наверняка gold. Страница только со ссылкой или только со значением — менее вероятна.
Smart article continuation
Статьи часто разбиты на две страницы. Наивный подход — всегда добавлять следующую страницу. Проблема: следующая страница может быть Article 15, а нам нужна 14.
def should_add_next_page(current_page, target_article, page_lookup): next_key = (current_page["doc_id"], current_page["page_number"] + 1) next_page = page_lookup.get(next_key) if not next_page: return False # Проверяем: начинается ли следующая страница с ДРУГОЙ статьи? next_text = next_page["text"][:200] new_article = re.search(r"\bArticle\s+(\d+)\b", next_text) if new_article and new_article.group(1) != target_article: return False # Следующая страница — другая статья, не добавляем return True # Та же статья продолжается — добавляем
Это изменение дало +0.037 к G (v13→v14).
Page caps по типам
Тип вопроса | Max pages в grounding | Почему |
|---|---|---|
boolean (не comparison) | 2 | Gold обычно 1 страница |
number | 2 | Одно число — одна страница |
date | 2 | Одна дата — одна страница |
name (не comparison) | 2 | Одно имя — одна страница |
comparison | 4 | Минимум по 1 странице на каждое дело |
free_text | 5 | Синтез из нескольких источников |
v11 показал: cap 3 → 4 для non-comparison = +43% страниц, но G упал на 0.059. v14 показал: cap 3 → 2 для non-comparison = -13% страниц, G вырос на 0.037.
Математика grounding: β=2.5 под микроскопом
Это самый важный раздел для тех, кто строит RAG с grounding-скорингом. β=2.5 кажется очевидным — «recall важнее, добавляй больше страниц». На практике всё сложнее.

Формула F-beta
F_β = (1 + β²) × precision × recall / (β² × precision + recall)
При β=2.5: β²=6.25. Recall взвешен в 6.25 раз тяжелее precision.
Сценарий 1: Пропущенная золотая страница
Gold = 2 страницы. Ты цитируешь 1 из 2:
precision = 1/1 = 1.0, recall = 1/2 = 0.5 F = 7.25 × 1.0 × 0.5 / (6.25 × 1.0 + 0.5) = 3.625 / 6.75 = 0.537
Ты цитируешь 2 из 2 + 1 лишнюю:
precision = 2/3 = 0.667, recall = 2/2 = 1.0 F = 7.25 × 0.667 × 1.0 / (6.25 × 0.667 + 1.0) = 4.836 / 5.167 = 0.936
+74%. Одна правильная страница (даже с шумом) радикально поднимает G.
Сценарий 2: Лишняя страница
Gold = 1 страница. Ты цитируешь её:
precision = 1/1 = 1.0, recall = 1/1 = 1.0 F = 1.0 (идеально)
Ты цитируешь её + 1 лишнюю:
precision = 1/2 = 0.5, recall = 1/1 = 1.0 F = 7.25 × 0.5 × 1.0 / (6.25 × 0.5 + 1.0) = 3.625 / 4.125 = 0.879
-12%. Каждая лишняя страница — это реальная потеря.
Сценарий 3: Две лишних
Gold = 1. Цитируешь 1 + 2 лишних:
precision = 1/3 = 0.333, recall = 1/1 = 1.0 F = 7.25 × 0.333 / (6.25 × 0.333 + 1.0) = 2.414 / 3.083 = 0.783
-22%. Две лишних страницы = минус пятая часть grounding.
Практический вывод
Формула наказывает за лишние страницы сильнее, чем кажется при β=2.5. Оптимальная стратегия:
Всегда включай все golden pages (recall = 1.0 критичен)
Минимизируй шум (каждая лишняя страница стоит 10-22%)
Лучше недоцитировать, чем перецитировать — если не уверен, что страница gold, не добавляй
Вот как это проявилось в экспериментах:
Версия | Avg pages/q | G score | Комментарий |
|---|---|---|---|
v10 | 2.08 | 0.781 | baseline |
v11 | 2.97 | 0.722 | +43% страниц, G упал |
v14 | ~1.8 | 0.862 | -13% страниц, G вырос |
Precision > volume. Это контринтуитивно, когда β=2.5 кричит «recall!», но формула не врёт.
История итераций: 15 версий, 3 провала, 1 финальный скор

Полная таблица:
Ver | Det | Asst | G | T | F | Total | Ключевое изменение |
|---|---|---|---|---|---|---|---|
v1 | 0.857 | 0.613 | 0.050 | 0.900 | 0.960 | 0.034 | Первая подача |
v2 | 0.857 | 0.613 | 0.050 | 0.900 | 0.990 | 0.035 | TTFT fix |
v3 | 0.857 | 0.640 | 0.050 | 0.900 | 1.002 | 0.036 | Asst tweak |
v4 | 0.857 | 0.673 | 0.550 | 0.986 | 1.005 | 0.438 |
|
v5 | 0.886 | 0.647 | 0.521 | 0.990 | 1.005 | 0.422 | Embeddings, Det up |
v6 | 0.800 | 0.607 | 0.610 | 0.981 | 1.040 | 0.462 | Hybrid search, TTFT halved |
v7 | 0.929 | 0.593 | 0.659 | 0.991 | 1.035 | 0.559 | Adversarial detection |
v8 | 0.929 | 0.713 | 0.788 | 0.996 | 1.038 | 0.704 | Page expansion + β math |
v9 | 0.957 | 0.680 | 0.690 | 0.996 | 1.041 | 0.626 | REGRESSION: adj pages |
v10 | 0.971 | 0.687 | 0.781 | 0.996 | 1.040 | 0.716 | Evidence overhaul + hardcodes |
v11 | 0.971 | 0.713 | 0.722 | 0.996 | 1.037 | 0.667 | REGRESSION: more pages |
v12 | 0.971 | 0.687 | 0.760 | 0.996 | 1.039 | 0.696 | Partial revert |
v13 | 0.971 | 0.707 | 0.825 | 0.996 | 1.031 | 0.756 | Article index first |
v14 | 0.971 | 0.720 | 0.862 | 0.996 | 1.029 | 0.791 | Smart continuation + caps |
v15 | 0.929 | 0.640 | 0.862 | 0.996 | 1.037 | 0.749 | REGRESSION: judge override |
v1 ██ 0.034 v2 ██ 0.035 v3 ██ 0.036 v4 ██████████████████████ 0.438 v5 █████████████████████ 0.422 v6 ███████████████████████ 0.462 v7 ████████████████████████████ 0.559 v8 ███████████████████████████████████ 0.704 v9 ███████████████████████████████ 0.626 ↓ v10 ████████████████████████████████████ 0.716 v11 █████████████████████████████████ 0.667 ↓ v12 ███████████████████████████████████ 0.696 v13 ██████████████████████████████████████ 0.756 v14 ████████████████████████████████████████ 0.791 v15 █████████████████████████████████████ 0.749 ↓
Три регрессии — три урока
v9 (0.626, -0.078): «Если статья длинная, соседняя страница тоже релевантна». Нет. Соседняя страница часто содержит другую статью. Adjacent page retention без проверки содержимого — антипаттерн.
v11 (0.667, -0.049): «β=2.5 значит больше страниц = лучше». Нет. +43% страниц = -7.5% G. Дополнительные страницы были шумом. Precision matters даже при recall-ориентированной метрике.
v15 (0.749, -0.042): «Мы можем улучшить Det, добавив domain-специфичные overrides». Нет. «Assistant Registrar» — не судья в gold-ответах. Промпт для free_text — калиброван, менять формулировки опасно. Два изменения — два провала.
Что работает, а что нет: сводка для строителей RAG
Что работает
Подход | Влияние | Почему |
|---|---|---|
Page-level retrieval (не чанки) | Фундаментальное | Grounding считается по страницам |
Гибридный BM25 + embeddings | +0.089 G (v4→v6) | BM25 для точных терминов, embeddings для семантики |
Article index first, CITE second | +0.065 G (v12→v13) | Определение статьи > случайное упоминание |
Evidence verify (answer + article ref) | +0.044 G (v12→v13) | Страница с И ответом И артикулом — почти наверняка gold |
Smart article continuation | +0.037 G (v13→v14) | Только если следующая страница — та же статья |
Adversarial detection | +0.129 Det (v6→v7) | null для невозможных вопросов |
Post-LLM text verification | +0.042 Det (v9→v10) | Ловит галлюцинированные числа и имена |
Type-specific page caps | +0.037 G (v13→v14) | 2 для non-comparison, 4 для comparison |
Deterministic fast-paths | +0.03 F factor | -700ms TTFT, 0 API-токенов |
Context distillation | ~0 скор, -0.05/run | Экономит токены без потери качества |
Что НЕ работает (проверено экспериментами)
Антипаттерн | Результат | Почему |
|---|---|---|
Больше страниц в grounding | G -0.059 (v11) | Шум > сигнал, precision penalty |
Adjacent page retention | G -0.098 (v9) | Следующая страница ≠ та же статья |
Domain-specific fallback для adversarial | Asst -0.080 (v15) | Gold ожидает generic fallback |
Post-LLM boolean overrides | Det -0.042 (v15) | «Assistant Registrar» ≠ judge |
Изменение формулировок free_text промпта | Asst falls | Промпт калиброван, любое изменение — риск |
Sonnet для boolean вопросов | 11/19 null | Sonnet «рассуждает» и сомневается, Haiku извлекает |
Batch-изменения (17 за раз в v8) | Непонятно, что помогло | Невозможно сделать ablation |
Разработка с Claude Code: честный разбор
Весь пайплайн написан через Claude Code — ~3000 строк в 7 модулях за 5 дней.
Типичная итерация
Я: «В v13 мы переставили article index перед CITE. Теперь нужно добавить проверку: если следующая страница начинается с Article N+1, не добавляй её. Только если продолжается та же статья.»
Claude Code: читает retrieve.py и llm.py → находит функцию article continuation → добавляет regex-проверку → обновляет caps → прогоняет build_submission.py → показывает diff.
Три минуты. Без Claude Code — час.
Что Claude Code делает хорошо
Скорость итераций. 17 версий за 5 дней — больше 3 в день. Каждая версия включает изменения в 3-5 файлах, 50-200 строк кода, и прогон submission. Руками я бы сделал 3-5 итераций за это время.
Рефакторинг. Когда архитектура менялась (а она менялась 15 раз), Claude перестраивал зависимости, обновлял типы, правил вызовы.
Память и история. Claude Code хранит контекст между сессиями в .claude/memory/. Закрыл ноутбук, открыл через 4 часа — он помнит, что v11 сломал grounding и почему. Плюс каждое изменение коммитится в git с осмысленным описанием, а CHANGELOG.md ведётся автоматически. Когда работаешь с AI-агентом, версионирование — must have: без него невозможно откатиться или понять, какое изменение что сломало.
Код-ревью своих же изменений. Перед каждой подачей я просил: «проверь, не сломали ли мы что-нибудь». Claude читал diff и иногда находил проблемы до submission.
Исследование подходов. Claude сам искал современные техники (RRF fusion, cross-encoder reranking, context distillation), объяснял trade-offs и предлагал, что стоит попробовать следующим. Не просто исполнитель — активный участник в выборе направления оптимизации.
Где нужен человек
Приоритизация. «Что оптимизировать дальше: Det или G?» — вопрос, на который модель отвечает «и то, и другое». А тебе нужно выбрать одно, потому что осталось 5 submission.
Знание evaluation protocol. Что Assistant Registrar — не судья в gold-ответах, что generic fallback — это ожидаемый gold для adversarial вопросов. Эти нюансы оценки узнаёшь только из результатов подач и Discord-чата с организаторами.
Интерпретация провалов. Когда v11 сломал G, Claude предложил «увеличить recall floor ещё больше». Правильное решение было противоположным — уменьшить число страниц. Для этого нужно было понять почему упало, а не просто «что-то упало, давай крутанём ручку дальше».
Главный компромисс
Claude Code ускоряет итерации в 5-10 раз, но снижает глубину понимания кода. Я хуже знаю свой codebase, чем если бы писал всё руками. Баг с .pdf мог бы всплыть раньше, если бы я сам строчка за строчкой писал парсер. Но без Claude Code за 5 дней было бы 3-5 итераций вместо 17 — и скор остался бы где-то в районе 0.4.
Результаты warmup
Метрика | v1 (старт) | v14 (лучший) | Изменение |
|---|---|---|---|
Det | 0.857 | 0.971 | +13% |
Asst | 0.613 | 0.720 | +17% |
G | 0.050 | 0.862 | x17 |
T | 0.900 | 0.996 | +11% |
Total | 0.034 | 0.791 | x23 |
Лидер warmup — CPBD с 0.959 (Det=1.0, G=0.976). Основной гэп — grounding (0.862 vs 0.976) и Asst (0.720 vs 0.840).
Финал: стена масштабирования
30 → 300 документов
Финальная фаза: 303 документа, 4244 страницы, 900 вопросов, 2 попытки подачи. Код зафиксирован на v14.
Первая проблема обнаружилась до подачи: кеш warmup перезаписал данные финала. Пайплайн радостно проиндексировал 30 warmup-документов вместо 303 финальных. 37 null-ответов из 900. Очистили кеш, переиндексировали — nulls упали до 4. Банальный баг, но при 2 попытках — опасный.

F-v1: 0.457
Det | Asst | G | T | F | Total |
|---|---|---|---|---|---|
0.696 | 0.637 | 0.647 | 1.000 | 1.042 | 0.457 |
Падение по всем метрикам:
Метрика | Warmup v14 | Final v1 | Падение |
|---|---|---|---|
Det | 0.971 | 0.696 | −28% |
Asst | 0.720 | 0.637 | −12% |
G | 0.862 | 0.647 | −25% |
Total | 0.791 | 0.457 | −42% |
Диагностика: почему всё сломалось
Параллельная диагностика тремя агентами вскрыла корневые причины:
1. Retrieval dilution. С 30 документами BM25 почти всегда находит правильный. С 303 — один документ в 537 страниц (DIFC Courts Rules) загрязняет результаты для любого запроса с юридической лексикой. Запрос про «Employment Law Article 19» возвращает страницы из Courts Rules, Companies Law и Operating Law — до Employment Law.
2. Disambiguation failure. 53 consultation papers пронумерованы от 1 до 8, но из разных лет и на разные темы. «Consultation Paper No. 3» матчится с 7 документами. BM25 выбирает самый «плотный» по терминам, а не правильный.
3. Law number regex ловит fee/fine вопросы. Регулярка difc\s+law\s+no для определения «какой номер закона?» матчилась с любым вопросом, содержащим «DIFC Law No. 1 of 2019» — включая вопросы про штрафы. Вместо суммы штрафа возвращался номер закона. ~27 неправильных ответов.
4. 93 zero-page ответа. 89 из них — adversarial free_text с fallback-текстом и пустыми страницами. Это 10% всех ответов с G=0.
5. Case number leakage. LLM извлекает номер из case reference вместо фактического ответа: «How many claimants in case CFI 070/2018?» → 70 (номер дела, не количество сторон).
6. Два пустых документа. Сканированные PDF без OCR — 19 невидимых страниц.
F-v2: 8 фиксов, -0.008 к скору
Для второй (и последней) попытки применили 8 целевых исправлений:
Law number guard — исключить вопросы со словами fine/fee/penalty
Free_text zero-page — цитировать top-1 страницу для adversarial/fallback
Case number leakage — post-LLM проверка: ответ = номер дела?
Consultation paper disambiguation — matching по quoted title
Document diversity — cap 5 страниц на документ в retrieval
OCR — pytesseract для пустых документов
Party count boost — max_pages=5, Sonnet для party/names
CP routing — consultation paper titles в doc routing index
Результат:
Метрика | F-v1 | F-v2 | Дельта |
|---|---|---|---|
Det | 0.696 | 0.709 | +0.013 |
Asst | 0.637 | 0.644 | +0.007 |
G | 0.647 | 0.631 | −0.016 |
F | 1.042 | 1.033 | −0.009 |
Total | 0.457 | 0.449 | −0.008 |
Det и Asst чуть выросли. Но G упал. Причина: мы добавили страницы к adversarial-ответам, предполагая, что оценщик ожидает цитаты. Он не ожидает. Когда gold = пустые страницы, любая цитата = шум → precision падает → G падает.
Одно неверное предположение об evaluation protocol стоило дороже, чем все 7 правильных фиксов вместе.
Полная таблица: от warmup к финалу
WARMUP: v1 ██ 0.034 v4 ██████████████████████ 0.438 v7 ████████████████████████████ 0.559 v8 ███████████████████████████████████ 0.704 v10 ████████████████████████████████████ 0.716 v14 ████████████████████████████████████████ 0.791 ← best FINAL: F-v1 ███████████████████████ 0.457 F-v2 ██████████████████████ 0.449
Уроки: что бы я сделал иначе
Для warmup
1. Начал бы с валидации формата. Три подачи на обнаружение .pdf в doc_id — это три подачи, которых больше не будет. Первый шаг в любом соревновании: unit-тест на формат submission.
2. Одно изменение = одна подача. В v8 я сделал 17 изменений за раз. Скор вырос. Но какие из 17 помогли? Без ablation testing (отключаешь по одному компоненту и смотришь, как меняется метрика) — не узнаешь.
3. Собрал бы eval-сет. Без ground truth каждый эксперимент = подача. Даже 10 вопросов с известными ответами сэкономили бы 3-4 submission.
Для финала
4. Подал бы v14 as-is первым. Мы обнаружили баг кеша во время v1, потратив baseline-подачу на отладку. Правильно: сначала чистый v14 как baseline → потом фиксы.
5. Иерархический retrieval. Flat BM25 не масштабируется с 30 до 300 документов. Нужно: сначала определить документ (title match, type classification), потом искать страницы внутри него. Document-level pre-filter вместо page-level search по всему корпусу.
6. Протестировал бы evaluation protocol. Предположение «add pages = better G» стоило нам v2. Один тест с пустыми страницами в warmup показал бы, что оценщик даёт G=1.0 за пустой citation list когда gold тоже пустой.
7. Синтетический scaling test. Дублировать warmup-документы, добавить шум → проверить, как pipeline деградирует при 100+ документах. Это выявило бы retrieval dilution до финала.
Главные выводы
1. Grounding определяет всё. G — множитель в формуле скоринга. 25% падение grounding уничтожает весь скор, даже если ответы идеальные. Первый приоритет — всегда правильные страницы, а не правильный ответ.
2. Precision > recall, даже при β=2.5. Контринтуитивно, но доказано тремя экспериментами. Каждая лишняя страница стоит 10-22%. +43% страниц = −7.5% G.
3. Domain guardrails бьют general intelligence. «Assistant Registrar» ≠ judge в gold-ответах. Generic fallback — это gold answer для adversarial вопросов. Эти правила нельзя вывести из промпта — только из результатов подач. Они дают +0.13 Det.
4. Prompt engineering хрупок. Изменение формулировки free_text промпта: Asst −0.080. «Если работает — не трогай» — легитимный инженерный принцип.
5. Масштаб всё меняет. Pipeline, оптимизированный на 30 документах, теряет 42% при 300. Retrieval precision — фундамент, и при масштабировании он трескается первым.
6. Evaluation protocol — часть задачи. Неверное предположение о том, как оценщик считает G для edge cases, стоило дороже, чем 7 правильных багфиксов.
Расходы
Claude Code: 100 USD/мес (Max подписка, используется не только для этого проекта)
API (Haiku + Sonnet): 87.95 USD за всё соревнование — warmup + финал + отладка. Финал: 13.38 USD (Sonnet 9.36 + Haiku 4.02). Остальные ~75 USD — warmup и дебаг.
Embeddings + reranking: локальные модели, бесплатно
Время: 5 активных дней (Mar 11-13 warmup, Mar 19 + 21 финал)
Финальные результаты
Фаза | Версия | Det | Asst | G | T | F | Total |
|---|---|---|---|---|---|---|---|
Warmup | v1 (старт) | 0.857 | 0.613 | 0.050 | 0.900 | 0.960 | 0.034 |
Warmup | v14 (лучший) | 0.971 | 0.720 | 0.862 | 0.996 | 1.029 | 0.791 |
Final | v1 | 0.696 | 0.637 | 0.647 | 1.000 | 1.042 | 0.457 |
Final | v2 | 0.709 | 0.644 | 0.631 | 1.000 | 1.033 | 0.449 |
Что забрать с собой
Если вы строите RAG — неважно, для соревнования или продакшена — вот что я бы хотел знать до старта:
Сначала формат, потом качество. Прежде чем оптимизировать retrieval и промпты — убедитесь, что ваш output матчится с ожидаемым форматом. Напишите валидатор. Это сэкономит больше времени, чем любая архитектурная оптимизация.
Считайте математику своей метрики. Не полагайтесь на интуицию про «recall важнее». Возьмите формулу, подставьте конкретные сценарии, посчитайте. В моём случае математика F-beta с β=2.5 показала, что каждая лишняя страница стоит 10-22% — и это перевернуло всю стратегию.
Одно изменение за раз, метрики на каждое. Без этого вы не знаете, что работает. Ведите changelog, коммитьте каждую итерацию, записывайте скоры. Когда нужно откатиться — а это будет нужно — вы скажете себе спасибо.
Проверяйте на масштабе. Pipeline, идеально работающий на тестовом наборе, может потерять 42% на реальном. Если ваш eval-сет в 10 раз меньше прода — вы не тестируете retrieval, вы тестируете удачу.
AI-ассистент ускоряет, но не заменяет. Claude Code позволил мне сделать 17 итераций за 5 дней вместо 3-5. Но каждый раз, когда нужно было решить что оптимизировать, как интерпретировать провал и стоит ли рисковать последнюю подачу — это оставалось на мне.
Сейчас идёт закрытая оценка финальной фазы — результаты объявят позже. Я не питаю иллюзий: в warmup-лидерборде я был далеко от топа (0.791 vs лидер 0.959), и 42% падение на финале вряд ли это исправит. Но 5 дней, 88 USD и путь от нуля до работающего пайплайна — это опыт, ради которого стоило участвовать.
