В базе знаний был точный ответ про ошибку E1047. Семантический поиск отдавал общую статью про авторизацию: смысл похож, кода ошибки в ней нет. Полнотекстовый поиск находил E1047 сразу. После объединения результатов нужный фрагмент стал первым. Так обычно и выясняется, что embeddings не заменяют поиск.
Причина не в том, что векторный поиск плох. Просто он работает с похожестью смысла, а E1047 ничего не значит: это имя. Спрашивать у семантики точное совпадение — всё равно что искать человека по описанию «примерно такого роста, приятной наружности», имея на руках его паспорт.
Чанк — это единица доказательства
Чанк должен сохранять законченную мысль и адрес в исходнике. Резать каждые 500 токенов удобно только загрузчику. Для Markdown разумнее идти по заголовкам, затем делить слишком длинные секции с небольшим перекрытием. Таблицу, подпись и условия применения нельзя разнести по разным кускам.
from dataclasses import dataclass
@dataclass
class Chunk:
document_id: str
heading: str
text: str
ordinal: int
version: str
# tokenizer зависит от embedding-модели
def split_section(tokens, size=450, overlap=60):
step = size - overlap
for start in range(0, len(tokens), step):
yield tokens[start:start + size]Проверять нарезку стоит одним вопросом: если показать этот фрагмент человеку отдельно от документа, он сможет по нему ответить? Кусок «…не применяется к корпоративным тарифам» без начала предложения не сможет. Более того, он опасен: модель прочитает его как отрицание чего-то и приделает к ответу.
Отсюда практические следствия:
- заголовок секции добавляется к тексту чанка, а не только в метаданные;
- таблица режется по строкам вместе с шапкой или не режется вовсе;
- условия, исключения и сноски остаются рядом с тем, к чему относятся;
- перекрытие делается небольшим: оно спасает пограничные предложения, но при 50% overlap вы получаете корпус, где каждый факт лежит трижды и трижды вытесняет из контекста другие документы.
Embeddings: одна модель, одно пространство
Embedding должен строиться одной зафиксированной моделью. При смене размерности или модели создают новую колонку либо индекс и переиндексируют корпус. Смешивать в одном пространстве векторы разных моделей нельзя.
Это не педантизм. Векторы разных моделей — это координаты в разных системах отсчёта; сравнивать их косинусом можно ровно с тем же успехом, с каким можно складывать градусы Цельсия с километрами: арифметика не возражает, число получается, смысла нет.
Отсюда правило эксплуатации: имя и версия embedding-модели хранятся вместе с вектором. Как только их нет в схеме, миграция превращается в археологию (глава 21).
Запрос и документ должны кодироваться одинаково: та же модель, тот же префикс (если модель их использует), та же нормализация. Половина странных провалов recall объясняется тем, что документы индексировались с одним префиксом, а запросы уходили без него.
Hybrid search
Векторный поиск ловит перефразирование, лексический сохраняет номера договоров, имена методов и артикулы. Результаты удобно объединять Reciprocal Rank Fusion, которому не требуется сравнивать несопоставимые score:
def rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
scores = {}
for ranking in rankings:
for rank, item_id in enumerate(ranking, start=1):
scores[item_id] = scores.get(item_id, 0) + 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)RRF хорош тем, что не требует калибровки: он смотрит на позиции, а не на значения. Попытка вместо него «сложить score с весами 0.7 и 0.3» выглядит научнее и работает хуже, потому что распределения у двух движков разные, а веса подобраны на пяти примерах в пятницу вечером.
Reranking
После фильтров и fusion остаётся 20–50 кандидатов. Reranker оценивает пару «вопрос, фрагмент» точнее embedding-поиска и возвращает несколько источников в prompt. Это отдельная задержка, поэтому его не запускают на всём корпусе.
У него жёсткая граница: reranker переставляет, но не находит. Если нужного документа нет среди кандидатов, никакая переоценка его не воскресит. Поэтому порядок настройки всегда один: сначала recall на этапе кандидатов, потом точность на этапе переранжирования. Команды, которые начинают с reranker, получают идеально упорядоченный список неправильных документов.
Метрики по слоям
Каждый слой измеряется своей цифрой, иначе улучшения одного маскируют деградацию другого:
| Слой | Метрика | Что она говорит |
|---|---|---|
| Chunking | доля вопросов, где ответ целиком лежит в одном чанке | не режем ли мы мысль пополам |
| Кандидаты (hybrid) | recall@20 | есть ли нужный документ в принципе |
| Reranking | nDCG@5, precision@5 | правильные ли пять уходят в prompt |
| Генерация | точность ответа на идеальном контексте | виноват ли пересказ |
Настроечное правило, которое экономит недели: один компонент за итерацию. Поменяли нарезку — прогнали набор. Добавили BM25 — прогнали набор. Заменили reranker — прогнали набор. Три изменения сразу дают одну цифру и ноль знаний о том, какое из них помогло.
Failure modes
Слишком мелкие чанки теряют условия и исключения. Слишком крупные дают высокий шум и дорогой prompt. Большое overlap плодит дубликаты. ANN-индекс с агрессивными параметрами пропускает редкий точный ответ. Reranker меняет порядок, но не может вернуть документ, который retrieval не нашёл. Метаданные после обновления документа расходятся с текстом, если индексация не атомарна.
Отдельный жанр — «поиск стал хуже после того, как мы залили ещё документов». Это не деградация модели, а конкуренция: корпус вырос, дубликаты размножились, и старые хорошие ответы вытеснены новыми похожими. Лечится дедупликацией и лимитом фрагментов на документ, а не сменой embedding-модели.
Проверять надо на именованных запросах: точный код, разговорная формулировка, вопрос с двумя условиями, документ без ответа. Среднее впечатление от пяти демонстраций ничего не говорит о recall.
Правило главы
Chunking задаёт единицу доказательства, hybrid search собирает кандидатов, reranker выбирает пригодные. Настраивать их следует по отдельным метрикам, сохраняя путь до исходного документа.
Чеклист главы
Практикум
Задача 20.1 — нарезка против нарезки. ⭑⭑
Подготовьте 40 вопросов и отметьте релевантные секции. Сравните фиксированную нарезку по токенам с нарезкой по заголовкам.
Критерии приёмки:
Подсказки:
- Разметку «вопрос → релевантная секция» делайте руками: сорок вопросов — это один вечер и единственный способ получить честный recall.
- Смотрите не только на среднее, но и на худшие случаи: решение принимается по ним.
Задача 20.2 — hybrid search и RRF. ⭑⭑
Добавьте к векторному поиску лексический и объедините результаты через RRF.
Критерии приёмки:
Подсказки:
- BM25 в PostgreSQL доступен через полнотекстовый поиск; отдельный движок для начала не нужен.
- Не пытайтесь нормализовать и складывать score двух движков: это тот случай, когда простое ранговое слияние выигрывает у изобретательности.
Задача 20.3 — reranker и разбор промахов. ⭑⭑⭑
Подключите reranker поверх кандидатов и разберите вручную пять промахов.
Критерии приёмки:
Подсказки:
- Ведите журнал итераций прямо в репозитории: дата, что изменили, какие метрики получили. Через две недели он будет ценнее самих метрик.
- Промах «вопрос сформулирован так, что на него нет ответа в корпусе» — не промах поиска. Такие случаи выносите в отдельную категорию, иначе они будут вечно портить статистику.