Полнотекстовый поиск и объединение рангов
Векторный поиск уже возвращает активные разрешённые фрагменты. Добавим второй способ поиска — по словам в PostgreSQL. Он полезен для точных терминов вроде ETag и известных формулировок. Два механизма могут находить разные кандидаты; задача этого урока — соединить их результаты, не объявляя одно числовое значение универсальной оценкой релевантности.
В lesson-07 появляется hybrid.py. Он вызывает два запроса в одном снимке базы и объединяет позиции через Reciprocal Rank Fusion, сокращённо RRF. Векторы и база при подготовке не запускались. Описанная выдача остаётся ожидаемым результатом, а превосходство гибридного поиска над отдельными механизмами пока не измерено.
Поиск по текстовому представлению
Поле search_text мы создали вместе со схемой. Оно содержит нормализованное текстовое представление заголовка и тела. Функция websearch_to_tsquery() превращает пользовательскую строку в запрос для выбранной языковой конфигурации. Это другой договор, чем совпадение подстроки: PostgreSQL анализирует слова и особенности языка.
Для учебной библиотеки используется конфигурация russian. Она должна совпадать с конфигурацией при построении search_text. Если индексировать одним способом, а разбирать вопрос другим, одинаковый видимый текст может давать неожиданные результаты. Язык поиска — часть воспроизводимого поведения, а не оформление интерфейса.
SQL выбирает только фрагменты, соответствующие текстовому запросу. Функция ts_rank_cd() даёт числовую оценку их текстового соответствия. Более высокое значение сортируется раньше. Его нельзя напрямую складывать с косинусным расстоянием: разные механизмы имеют разные шкалы и даже разные направления предпочтения.
from db import connect
from embeddings import get_embedder
from retrieve import vector_candidates,allowed_role
def lexical_search(conn,query,role,space,limit=12):
allowed_role(role)
sql='''SELECT c.doc_id,c.revision,c.space,c.part,c.heading,c.body,d.url,d.title,
ts_rank_cd(c.search_text,websearch_to_tsquery('russian',%(query)s)) AS lexical_score
FROM chunks c JOIN documents d ON c.doc_id=d.doc_id
WHERE NOT d.deleted AND c.revision=d.active_revision
AND c.space=d.active_space AND c.space=%(space)s
AND (d.visibility='public' OR %(role)s='editor')
AND c.search_text @@ websearch_to_tsquery('russian',%(query)s)
ORDER BY lexical_score DESC,c.doc_id,c.part LIMIT %(limit)s'''
return conn.execute(sql,{'query':query,'space':space,'role':role,'limit':limit}).fetchall()
def hybrid_search(query,role='reader',limit=6):
allowed_role(role)
if not isinstance(query,str) or not query.strip() or len(query)>1000:raise ValueError('invalid query')
if not 1<=limit<=20:raise ValueError('invalid limit')
embedder=get_embedder();vec=embedder.embed([query])[0]
with connect() as conn:
conn.execute('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ')
rankings=[vector_candidates(conn,vec,embedder.space,role,12),lexical_search(conn,query,role,embedder.space,12)]
merged={}
for ranking in rankings:
for rank,row in enumerate(ranking,1):
key=(row['doc_id'],row['revision'],row['space'],row['part'])
if key not in merged:merged[key]={**row,'rrf':0.0}
merged[key]['rrf']+=1/(60+rank)
return sorted(merged.values(),key=lambda r:(-r['rrf'],r['doc_id'],r['part']))[:limit]
Снимок корпуса для двух выдач
Представьте, что векторный запрос прочитал старую ревизию urls, затем импорт активировал новую, а текстовый запрос уже прочитал обновлённые фрагменты. Объединение внешне похожих результатов могло бы скрыть смесь двух состояний. Особенно опасно, если в обновлении изменили публичность документа.
Поэтому обе SQL-выдачи выполняются внутри одной транзакции с уровнем REPEATABLE READ. Это обеспечивает согласованный снимок для их чтения. Вектор вопроса получаем заранее, чтобы не удерживать транзакцию во время работы модели. Общий снимок данных не отменяет возможного изменения модели, поэтому пространство также остаётся отдельным условием.
Выбранный уровень не заставляет базу остановить все обновления. Другой импорт может завершиться параллельно; текущая транзакция продолжит видеть своё состояние. Новый пользовательский запрос может получить уже новую ревизию. Такое различие нормально: важно объяснять ответ через конкретный набор прочитанных источников, а не обещать всем запросам одновременно самый последний текст.
Здесь нет многопользовательского отзыва доступа к уже сформированному ответу. Если роль процесса разрешала источник при чтении, приложение может успеть показать его после изменения политики. Производственная система должна отдельно определить допустимое окно и порядок повторной проверки перед выдачей чувствительных данных.
Объединение через позиции
Для каждой выдачи присваиваем позицию, начиная с единицы. Вклад кандидата вычисляется как 1 / (60 + rank). Число шестьдесят — выбранный параметр лаборатории. Оно сглаживает разницу между соседними местами и позволяет сложить вклады кандидата из двух списков без непосредственного сравнения исходных расстояний.
Если фрагмент встретился в обеих выдачах, он получает два вклада. Если только в одной — один. Например, источник на первом месте двух списков будет иметь сумму 2 / 61. Это арифметический пример устройства RRF, а не опубликованный замер качества нашей модели.
Ключ объединения состоит из документа, ревизии, пространства и номера фрагмента. Одного doc_id недостаточно: полезными могут оказаться разные разделы одной статьи. Одной пары документа и номера тоже мало для хранения результата во времени. Версия входит в предмет сравнения даже тогда, когда интерфейс показывает короткое название.
Лимиты кандидатов и итоговой выдачи
Каждый механизм запрашивает до двенадцати фрагментов, после объединения оставляем до шести. Эти числа задают бюджет текущей лаборатории. Если нужный источник не попал в первые двенадцать ни одного списка, RRF его не восстановит: алгоритм работает только с полученными кандидатами.
Увеличение количества кандидатов может повысить полноту, но добавляет стоимость SQL, передачи текста и дальнейшей обработки. Нельзя оптимизировать только последнее limit, не рассматривая предыдущий отбор. Вопрос «почему не найден источник» часто требует изучить промежуточные списки, а не итоговую красивую карточку.
Текстовый поиск может вернуть пустой список, а векторный — несколько строк. Тогда итог построится только по второму списку. Это не ошибка объединения. Но такой результат по-прежнему не подтверждает, что источник отвечает на вопрос. Для неизвестной темы числовая близость может остаться случайной.
При равных итоговых вкладах используем идентификатор документа и номер фрагмента как дополнительный порядок. Это делает отображение одного полученного набора предсказуемым. Сами промежуточные векторные позиции при равном расстоянии могут зависеть от плана запроса; для строгого исследования таких случаев нужен отдельно определённый договор ничьих.
Проверяем причину изменения результата
Для самостоятельного разбора сравните вопрос с точным термином ETag и вопрос о проверке изменения документа без этого термина. Сохраните оба промежуточных списка и итог. Если новый механизм поднял нужный фрагмент, отметьте, откуда пришёл дополнительный вклад. Не ограничивайтесь утверждением «гибридный поиск лучше»: оно не объясняет причину.
Не выбирайте коэффициенты по двум удачным вопросам и не называйте их окончательными. Нужны независимые случаи, неизвестные темы и разные роли. В одиннадцатом уроке подготовим такой набор и отдельно опишем показатели поиска и ответа. До его выполнения параметр шестьдесят остаётся исходной настройкой.
После слияния у строки появляется оценка rrf, но отдельная численная характеристика может остаться только от того пути, который первым добавил строку. У кандидата, найденного исключительно полнотекстовым запросом, не обязано быть поле косинусного расстояния. Интерфейс должен учитывать это различие и не печатать отсутствующее значение как ноль. Для подробной диагностики можно сохранить обе позиции и происхождение кандидата отдельными полями. Мы пока не усложняем формат, потому что учимся объединять порядок. Количество кандидатов каждой исходной выдачи тоже влияет на результат: источник вне первых двенадцати строк не попадёт в слияние вообще. Изменение этой глубины является отдельным параметром будущего сравнения.
Следующий шаг исследует HNSW. Он может уменьшить стоимость сравнения большого числа векторов, но меняет договор получения кандидатов. Согласованные метаданные и права доступа останутся обязательными условиями даже при переходе от полного перебора к приближённому поиску.
Исходники и документация
Оглавление серии · Все состояния проекта. Для этого урока откройте папку lesson-07.
pgvector, транзакции Psycopg, Ollama Embed, структурированные ответы.
Примеры подготовлены как исходники. Их выполнение, замеры, тесты и публикация при написании не проводились. Описанные результаты ожидаемые.