ADR 0080 — Гибридный поиск: веса, порог и что такое цитата
- Статус: принят
- Дата: 2026-08-16
- Предшествующий контекст: ADR 0079
(таблица
chunks, с вектором иtsvectorв одной строке — фундамент без конвейера и поиска), ADR 0075 (LLMProvider.embed?, capability доказана только против Ollama)
Контекст
ADR 0079 оставил таблицу готовой и пустой: ни один конвейер в неё не пишет, ни один поиск из неё не читает. Этой волне (ПРОГРАММА 28, Волна 4, направление G2) нужно было решить четыре открытых вопроса, и все четыре структурные — продуктовое решение, а не деталь реализации:
- Откуда берётся текст, становящийся chunk, для трёх честных областей
ADR 0079 (
docs,adr,session)? - Как нарезать документ/сообщение на куски — размер, перекрытие — без произвольного числа?
- Что делать, когда provider embedding не отвечает? Конвейер не может притворяться, что проиндексировал полностью, когда проиндексировал только лексическую половину.
- Как объединить векторный сигнал (pgvector) с лексическим (
tsvector) в одном поиске, с каким весом и каким срезом, — и что возвращать как цитату, раз именно этот контракт будет потреблять Chat RAG (Волна 5, экран ещё не построен), не имея возможности угадывать форму.
Решение
1. Источник текста: репозиторий САМОГО проекта и event log сессии
docs/adr индексируются из GIT-репозитория индексируемого проекта — через
ReadProjectCodeUseCase, ту же поверхность, что использует вкладка Code (то
же разрешение учётных данных владельца, RN-058/082; тот же затвор
контейнера, RN-105; та же проверка пути, RN-095; тот же кэш). Это не
документация Brabo как продукта: это соглашение docs//docs/adr/, которое
может быть у каждого УПРАВЛЯЕМОГО проекта в собственном репозитории.
Переиндексация без дублирования обхода дерева была важнее, чем разделение двух
областей на два use case, — IndexProjectDocsUseCase покрывает обе, различая
по ПРЕФИКСУ пути (docs/adr/ → adr, остальное → docs).
session индексируется из ДВУХ типов событий, составляющих разговор:
chat.message (человек) и agent.response (агент). Остальная часть event log
(tool.call, agent.status, tool.result, события gate) остаётся вне — это
механизм, а не цитируемое знание. Индексация tool.call заставила бы поиск
возвращать JSON payload так, будто это проза; индексация agent.error
цитировала бы отказ так, будто это предмет сессии. Каждый chunk сессии хранит
metadata.sourceRef с id ИСХОДНОГО события — тот же id, который уже разрешает
GetSessionEventUseCase, — чтобы цитата могла вернуться к точному месту
разговора.
2. Chunking: 1200 символов, 150 перекрытия, по АБЗАЦУ/разрыву
CHUNK_TARGET_CHARS = 1200 (~300 токенов на португальском) нацелен на кусок,
достаточно большой, чтобы нести целую идею, — то, что нужно вектору
embedding, чтобы не размывать смысл между несвязанными темами, — и
достаточно маленький, чтобы стать цитатой, читаемой за секунды, а не целым
документом. CHUNK_OVERLAP_CHARS = 150 (12,5% цели) существует потому, что
точный разрез посреди предложения заставляет следующий кусок потерять его
антецедент.
Оба числа — регулируемая отправная точка, а не наука: пока не существует корпуса реальных вопросов, прогнанного против этого индекса, чтобы откалибровать оптимальный размер chunk против качества извлечения, — эта программа таких данных не произвела, и выдумывать их значило бы изображать точность, которой нет (тот же класс ошибки, который ADR 0042 отвергает для оценки моделей).
Разрез предпочитает ближайший к цели разрыв АБЗАЦА (\n\n), затем СЛОВА
(пробел), и режет посреди слова, только если ни того, ни другого нет в окне из
200 символов — Markdown, разрезанный посреди таблицы, всё равно лучше цитаты,
обрезающей предложение пополам. Документы Markdown (docs/adr) сначала
делятся по ЗАГОЛОВКУ (сохраняя след headingPath, часть «раздел» в цитате
«файл + раздел»), и только затем нарезаются по размеру ВНУТРИ каждого
раздела.
Подсчёт ТОКЕНОВ вместо символов потребовал бы токенизатора самой модели
embedding, который nomic-embed-text локально не раскрывает (в отличие от
GptTokenizerEstimator, который уже использует chat, откалиброванного для
моделей CHAT). Символы с предпочтением чистого разрыва — честное
приближение.
3. Отказ provider: индексировать лексическое, объявить пробел — никогда не притворяться полным
Модель/provider embedding ФИКСИРОВАНЫ константой
(RAG_EMBEDDING_MODEL = 'nomic-embed-text', RAG_EMBEDDING_PROVIDER = 'ollama'), а не разрешаются по каталогу: chunks.embedding — это
vector(768), реальная и ДОКУМЕНТИРОВАННАЯ размерность этой модели (RN-222),
и пока нет сохраняемой колонки, говорящей «какая модель — embedding», — ADR
0075 оставил это как будущую работу, и у этой волны не было слота миграции,
чтобы это решить (единственная миграция этой волны, 0046, принадлежит
направлению F1, для project_containers).
Когда ollama не отвечает — демон упал, таймаут, модель не скачана, —
RagEmbeddingService не бросает исключение вызывающему, чтобы тот
обрабатывал chunk за chunk: возвращает available: false и null для каждого
запрошенного вектора. Конвейер индексации всё РАВНО записывает chunks, с
embedding: null — search_vector это GENERATED ALWAYS AS и не зависит ни
от какого provider (ADR 0079), так что лексическая половина остаётся
доступной даже при недоступной семантической. Альтернатива — провалить всю
индексацию из-за того, что половины сигнала не хватило, — выбросила бы
сработавшую половину. Отчёт индексации
(embedding: { available, embedded, skipped, reason }) объявляет пробел; ничто
в ответе не говорит «индексация полная», когда она не была такой.
Та же деградация действует в ПОИСКЕ: если запрос нельзя векторизовать, поиск
выполняется только по лексическому сигналу, и vectorAvailable: false
предупреждает — никогда не притворяясь, что выполнил полный гибрид.
4. Гибридный поиск: два независимых запроса, слияние взвешенной суммой
Вектор и лексика — это два отдельных запроса против chunks
(ChunkRepository.searchByVector/searchByLexicalQuery), а не один с JOIN, —
каждый пользуется индексом, сделанным для него (HNSW для косинуса, GIN для
ts_rank), — та же проектная причина, по которой ADR 0079 поместил обе колонки
в ОДНУ строку (чтобы не потерять слияние), но не обязывает к ОДНОМУ запросу
(чтобы не потерять правильный индекс). Порт возвращает сырых кандидатов; use
case (HybridSearchUseCase) сливает, взвешивает и срезает — та же граница,
что уже отделяет ChunkRepository (хранит данные) от того, кто решает, что с
ними делать (RN-226).
Веса: 0.6 векторный, 0.4 лексический. Нормализованный ts_rank (бит 32,
rank/(rank+1)) редко превышает ~0.3 даже при сильном совпадении, тогда как
косинусное сходство действительно релевантной пары обычно лежит между 0.5 и
0.85 — две шкалы по природе НЕ сравнимы. Больший вес векторной стороны
признаёт это, не стирая лексику: chunk только с лексикой ещё может пройти порог
сам (0.4 * 0.3 = 0.12, ниже порога — так что на практике очень сильное
лексическое совпадение в одиночку всё ещё недостаточно, и это намеренно:
текст, совпадающий одним общим словом, не должен становиться цитатой без
какой-либо опоры на смысл).
Порог: 0.2. Ниже него «нашли что-то слабое» и «ничего не нашли» становятся неразличимыми для читающего ответ — слабая цитата, поданная как сильная, хуже, чем никакой цитаты.
Ни одно из четырёх чисел (веса, порог, размер chunk, перекрытие) не взято из калибровки на реальных данных качества поиска. Пока нет корпуса реальных вопросов, прогнанного против этого индекса, — сам Chat RAG (Волна 5) ещё не существует как экран. Это отправные точки, задокументированные как таковые, с доводом каждого выбора, записанным, чтобы их можно было пересмотреть с данными позже, а не пересчитывать в уме.
Что считается цитатой
Контракт возврата (HybridSearchHit): chunkId, content, комбинированный
score, vectorScore/lexicalScore (каждый null, когда этот сигнал не
нашёл chunk, — не ноль, который путал бы «не нашёл» с «нашёл, и сходство
нулевое»), scope и origin — дискриминированное объединение по kind:
{ kind: 'file', sourcePath, headingPath?, title? } для docs/adr или
{ kind: 'session', sessionId, eventId?, title? } для session.
Дискриминация существует, чтобы потребителю никогда не приходилось проверять
два необязательных поля, чтобы узнать, какое null, — tsc делает проверку
исчерпывающей.
Последствия
«Покрытие индекса» (панель, которую просит handoff) отвечает тем, на что
сегодня можно ответить ЧЕСТНО: GetRagCoverageUseCase считает реальные
файлы .md в репозитории против того, у скольких есть chunk, и сессии проекта
против того, у скольких есть chunk. Не включает «переиндексировано 12 мин
назад» — колонки метки времени индексации по области нет, и угаданное число
лгало бы.
Переиндексация всегда РУЧНАЯ (POST .../rag/reindex, role:maintainer,
полная идемпотентная пересборка через deleteByScope/deleteBySession с
последующим воссозданием). Нет watcher по push или по закрытию сессии —
решение, уже зафиксированное в ADR 0079 («переиндексация — ответственность
того, кто напишет конвейер»), и эта волна написала конвейер ПО ТРЕБОВАНИЮ, а
не реактивный. Исходный код и Pull Requests остаются вне индекса по той же
причине, что в ADR 0079.
Метеринг embedding остаётся вне (тот же срез, что в ADR 0075):
фиксированный provider локальный и бесплатный (ollama), так что сегодня
нечего регистрировать; в тот день, когда войдёт платный provider embedding,
этот пробел нужно будет закрыть раньше.
HTTP уже в этой волне, до экрана (Волна 5). POST .../rag/search,
POST .../rag/reindex и GET .../rag/coverage существуют, потому что экран
Chat RAG зависит от контракта поиска и цитаты, чтобы быть построенным без
угадывания формы, — handoff уже предполагает «busca híbrida · embeddings +
BM25 · limiar X» и панель покрытия, и у обоих реальные данные появляются
только после этих трёх маршрутов.