Косинусный поиск и доступ к источникам
Импорт сохраняет полные ревизии документов и указывает активное пространство векторов. Теперь построим запрос, который превращает вопрос читателя в список ближайших разрешённых фрагментов. В этом шаге появится числовое расстояние, но ещё не текстовый ответ: сначала нужно увидеть и объяснить саму выдачу.
В состоянии lesson-06 добавлен retrieve.py. Он проверяет вопрос, получает его вектор и выполняет SQL с ограничением доступа, ревизии и пространства. Роль остаётся доверенной настройкой локального оператора. Настоящие пользователи и вход не реализованы, а запросы к PostgreSQL и модели при написании не выполнялись.
Расстояние между направлениями
Косинусное расстояние сравнивает направление двух ненулевых векторов. В pgvector соответствующий оператор записывается как <=>. Меньшее расстояние означает более близкие направления в выбранном пространстве. Это числовое отношение представлений; оно не доказывает правильность источника и не измеряет уверенность будущего ответа.
В режиме fixture координаты построены из корзин слов. Поэтому близость отражает особенности этого алгоритма: совпадения форм слов и возможные хеш-коллизии. В режиме Ollama механизм представления сложнее, но необходимость оценить релевантность остаётся. Нельзя назначить универсальный порог «правильного документа» без наблюдений на конкретной модели и корпусе.
Параметры запроса передаём через psycopg. Вектор сериализуется отдельно, затем SQL приводит его к типу vector. Значения вопроса, роли и лимита не склеиваются с текстом оператора. Это сохраняет границу между программой SQL и данными пользователя даже при необычных символах во входе.
from db import connect
from embeddings import get_embedder,vector_literal
def allowed_role(role):
if role not in {'reader','editor'}:raise ValueError('invalid local operator role')
return role
def vector_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:
return vector_candidates(conn,vec,embedder.space,role,limit)
def vector_candidates(conn,vec,space,role,limit):
allowed_role(role)
sql='''SELECT c.doc_id,c.revision,c.space,c.part,c.heading,c.body,d.url,d.title,
c.embedding <=> %(vector)s::vector AS distance
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')
ORDER BY c.embedding <=> %(vector)s::vector LIMIT %(limit)s'''
return conn.execute(sql,{'vector':vector_literal(vec),'space':space,'role':role,'limit':limit}).fetchall()
Какие строки допускаются к сравнению
Условие NOT d.deleted исключает скрытый документ. Затем проверяем совпадение фрагмента с активной ревизией и активным пространством родителя. Старые фрагменты могут оставаться в таблице, но не должны автоматически участвовать в текущем поиске. Возможность хранить историю не означает возможность смешивать историю в одной выдаче.
Дополнительное сравнение c.space с пространством адаптера связывает запрос с индексом. Если оператор сменил модель и ещё не переимпортировал документы, старые записи не становятся допустимыми кандидатами. Ожидаемым результатом может оказаться пустой список. Это полезнее красивой, но несогласованной выдачи из векторов разных моделей.
В проверке доступа публичный документ разрешён обеим ролям, служебный — только editor. Значение роли проверяется до запроса. Строка из браузера не является свидетельством личности; веб-этап позднее использует роль процесса. Если приложение начнёт доверять role=editor из тела запроса, SQL-параметризация не спасёт от нарушения авторизации.
Логически ограничение доступа действует до выдачи результата и его LIMIT. При использовании приближённого индекса внутренний поиск кандидатов устроен сложнее, и фильтрация может уменьшить число найденных строк. Это будет разбираться в уроке HNSW. Такая особенность не разрешает показывать приватный текст для заполнения свободных мест.
Читаем поля результата
Каждая строка содержит идентификатор документа, ревизию, пространство, номер фрагмента, заголовок, текст и адрес. Эти значения нужны для разного: идентификаторы помогают объединять результаты, текст формирует цитату, адрес даёт путь к источнику. Дистанция описывает конкретный поисковый механизм и не должна заменять всю остальную информацию.
Полный набор идентификаторов особенно важен при обновлении корпуса. Два результата с одинаковыми doc_id и part могут относиться к разным ревизиям. Если сохранить только пару коротких значений, позднее невозможно объяснить, какой текст участвовал в ответе. Поэтому в гибридном поиске будем объединять строки с учётом версии.
В запросе сортировка начинается непосредственно с оператора расстояния и содержит LIMIT. Такая форма важна для возможного использования векторного индекса. Преобразование расстояния в выражение «сходство» и сортировка по произвольному вычислению могут изменить доступный план. Красивое имя столбца не определяет, какой индекс выберет PostgreSQL.
Что означает пустой список
Пустая выдача имеет несколько возможных причин: нет активных документов нужного пространства, все доступные источники скрыты, корпус не импортирован или выбранная роль ничего не разрешает. Ошибка подключения — другой результат: код выбросит исключение. Не следует превращать любую техническую ошибку в утверждение «по теме нет информации».
Напротив, непустой список ещё не означает, что вопрос имеет ответ. Точный векторный поиск способен вернуть ближайшие строки даже для совершенно неизвестной темы. В нашем небольшом корпусе вопрос о прогнозе рынка тоже имеет математически ближайшую заметку. Поэтому дальнейший интерфейс честно покажет найденные источники, а качество связи с вопросом будет оцениваться отдельно.
Ограничение limit задаёт максимальное количество строк, а не требуемый минимум. При меньшем числе доступных фрагментов результат будет короче. Не нужно дублировать один документ или снимать ограничения доступа ради ровно шести карточек. Количество кандидатов относится к бюджету последующей обработки, а не к обещанию полноты знаний.
Сравнение ролей на одном вопросе
Для самостоятельного разбора возьмите вопрос о служебной заметке. В роли reader документ editor не должен появляться среди возвращаемых строк. В роли editor он может стать допустимым кандидатом. Сравнивайте именно наличие приватного источника, а не только его место: изменение модели или текста может изменить расстояния.
Текущий SQL-фильтр не заменяет ограничения доступа к самой базе. Пользователь с прямыми полномочиями читать таблицу способен обойти функцию приложения. Для production нужны отдельные роли базы, проверенная идентичность и при необходимости RLS. Лаборатория исследует один контролируемый маршрут чтения, который нельзя автоматически объявлять полной системой защиты.
У нескольких фрагментов может получиться одинаковое расстояние. В текущей сортировке нет дополнительного полного порядка для таких совпадений, поэтому нельзя обещать неизменное первое место при любой перестройке плана. Для редакционного сравнения сохраните идентификаторы и сам состав выдачи, а не только номер строки. Если нужен воспроизводимый порядок при равных значениях, определите его отдельно и изучите влияние изменения запроса на использование индекса. Приближённый поиск добавляет ещё одно различие: сам состав кандидатов может отличаться от полного перебора. Таким образом, одинаковое расстояние, одинаковый порядок и одинаковый набор источников являются тремя разными свойствами. Их нужно различать при разборе наблюдений, вместо общего заявления «поиск изменился».
Следующий урок добавит полнотекстовую выдачу. Она поможет находить точные термины и идентификаторы, которые векторное представление может ранжировать иначе. Объединим результаты по позициям, сохранив единый снимок базы и те же ограничения источников.
Исходники и документация
Оглавление серии · Все состояния проекта. Для этого урока откройте папку lesson-06.
pgvector, транзакции Psycopg, Ollama Embed, структурированные ответы.
Примеры подготовлены как исходники. Их выполнение, замеры, тесты и публикация при написании не проводились. Описанные результаты ожидаемые.