Гибридный поиск
Обычный поиск хорошо работает с точными терминами и известными названиями. Семантический помогает рассматривать свободные формулировки, но способен возвращать ближайший документ даже для неподходящего вопроса. В последнем уроке объединим подходы и сравним результат, сохраняя одну библиотеку и контрольные запросы.
Векторы документов уже подготовлены локальной моделью. Meilisearch получит их как пользовательские представления, а серверный адаптер будет вычислять совместимый вектор запроса. Браузер продолжит пользоваться прежним ограниченным API.
Два источника кандидатов
Гибридный поиск сочетает буквальные и смысловые признаки. Это не означает, что произвольные численные оценки можно просто сложить. Наша сумма полей и cosine similarity принадлежат разным шкалам.
Готовый движок предоставляет собственный способ объединения. Параметр semanticRatio управляет балансом; ноль обозначает обычный поиск, единица — семантический. Не будем описывать промежуточное значение как универсальную линейную формулу каждого score. Параметры hybrid и vector.
Для начала сравним три режима на одном индексе: 0, 0.5 и 1. Значение 0.5 — старт эксперимента, а не заранее найденный оптимум. Нужный баланс зависит от запросов и корпуса.
Точные идентификаторы и естественные вопросы должны присутствовать одновременно. Если настроить систему только на свободные фразы, можно незаметно ухудшить поиск названий языков, функций и ошибок.
Индекс с представлениями
Создадим новый версионный индекс, имя которого включает и корпус, и представления. Так изменение модели не маскируется прежним именем текстовой библиотеки.
В настройках указывается embedder tutorial с источником userProvided и размерностью 384. Движок не будет сам выбирать модель за нас. Договор конфигурации описан в Embedders API.
Создайте prepare_vectors.py. Он использует api и wait из урока 16 и проверку файла из урока 19:
from urllib.error import HTTPError
from meili import api, wait
from semantic import load_vectors
def prepare_vectors():
data = load_vectors()
uid = "articles_" + data["corpusVersion"] + "_" + data["embeddingVersion"]
try:
api("GET", "/indexes/" + uid)
except HTTPError as error:
if error.code != 404:
raise
wait(api("POST", "/indexes", {"uid": uid, "primaryKey": "id"}))
settings = {
"searchableAttributes": ["title", "headings", "text"],
"filterableAttributes": ["section", "topic"],
"displayedAttributes": ["id", "url", "title", "text", "section", "topic"],
"pagination": {"maxTotalHits": 1000},
"typoTolerance": {"disableOnWords": ["C#", "C++", ".NET"]},
"synonyms": {"js": ["javascript"], "javascript": ["js"]},
"embedders": {"tutorial": {"source": "userProvided", "dimensions": 384}},
}
wait(api("PATCH", "/indexes/" + uid + "/settings", settings))
documents = []
for doc in data["documents"]:
record = {key: value for key, value in doc.items() if key != "vector"}
record["_vectors"] = {"tutorial": doc["vector"]}
documents.append(record)
wait(api("POST", "/indexes/" + uid + "/documents", documents))
return uid
if __name__ == "__main__":
print("MEILI_INDEX=" + prepare_vectors())
Конфигурация представлений задаётся до загрузки документов. Поле _vectors связывает каждый массив с именем tutorial. Именно это имя затем указывается в запросе.
У документа сохраняются ID, URL, заголовок и текст. Вектор не создаёт отдельную карточку другой статьи. Публичная выдача по-прежнему возвращает ссылку на исходный материал.
Настройки отображаемых полей не включают векторы. Интерфейс не нуждается в сотнях чисел для каждой карточки. Вектор запроса тоже не возвращается посетителю как объяснение найденной статьи.
Преобразование запроса на сервере
Создайте semantic_adapter.py. Модель загружается до готовности этой версии сервера; повторные запросы используют один экземпляр.
from pathlib import Path
from threading import Lock
import json
import math
import os
from model_config import MODEL, REVISION, POLICY, load_model
ROOT = Path(__file__).resolve().parent
data = json.loads((ROOT / "vectors.json").read_text(encoding="utf-8"))
if (data["model"], data["revision"], data["policy"]) != (MODEL, REVISION, POLICY):
raise ValueError("Несовместимая модель")
expected = "articles_" + data["corpusVersion"] + "_" + data["embeddingVersion"]
if os.environ["MEILI_INDEX"] != expected:
raise ValueError("Выбран другой индекс")
ratio = float(os.environ.get("SEARCH_SEMANTIC_RATIO", "0.5"))
if not math.isfinite(ratio) or not 0 <= ratio <= 1:
raise ValueError("Неверная доля семантического поиска")
model = load_model() if ratio else None
lock = Lock()
def add_semantics(payload, query):
if not ratio:
return payload
with lock:
vector = model.encode(query, normalize_embeddings=True,
convert_to_numpy=True).tolist()
if len(vector) != 384 or not all(math.isfinite(x) for x in vector):
raise ValueError("Неверный вектор запроса")
return {**payload, "vector": vector,
"hybrid": {"embedder": "tutorial", "semanticRatio": ratio}}
Имя активного индекса должно совпадать с версиями локального файла. Это помогает не сравнивать новый запрос со старым корпусом или другим преобразованием. Установка одного только имени модели недостаточна.
Блокировка ограничивает одновременное использование экземпляра модели в этом учебном сервере. Она не является полноценным ограничением очереди посетителей. Для публичного сервиса нужны отдельные пределы нагрузки и время ожидания.
При нулевом ratio модель не загружается, а payload остаётся обычным. Это позволяет сравнить режимы на том же векторном индексе. Предварительно подготовленные документы при таком запросе не заставляют использовать их смысловые координаты.
Подключение к API
В server.py добавьте импорт функции add_semantics. Расширьте проверку имени индекса, чтобы допускать два версионных компонента:
if not re.fullmatch(r"articles_[a-f0-9]{16}(?:_[a-f0-9]{16})?", INDEX):
raise ValueError("Неверный индекс")
В ветке основного поиска после добавления фильтра, перед вызовом движка, вставьте строку:
payload = add_semantics(payload, query)
data = engine_search(payload)
Для подсказок сохраняется обычный поиск названий. Нет необходимости вычислять смысловой вектор после каждой промежуточной буквы, если задача списка — выбрать известное название.
Формат ответа и смысл окна выдачи остаются прежними. Интерфейс не должен показать новые внутренние настройки как дополнительные параметры, которые читатель обязан понимать для обычного поиска.
Сравнение трёх режимов
Сохраните результаты одних контрольных запросов при разных значениях SEARCH_SEMANTIC_RATIO. Смена окружения применяется к новой версии запущенного адаптера; уже работающий процесс не считывает её автоматически на каждом запросе.
Сравните ID верхних результатов, первый подходящий документ и отрицательные случаи. Затем рассмотрите вычислительную стоимость: загрузку модели, время кодирования, задержку движка и полный ответ API.
Семантический режим может улучшить один свободный вопрос и добавить лишние ответы другому. Среднее полезно для обзора, но итоговое решение требует разборов отдельных строк. Не объявляйте гибрид победителем только из-за названия подхода.
Для отрицательных запросов рассмотрите политику отказа. В движке есть возможности ограничивать результаты, но порог нужно выбирать по наблюдениям. Одно значение не гарантирует правильное отделение всех неизвестных задач.
Дальнейшее развитие
Если важные темы спрятаны в середине длинных уроков, следующим направлением будут фрагменты. Они сохраняют родительский ID и URL, а выдача группирует несколько совпадений одной статьи.
Если текст похожих материалов трудно различить, можно добавить отдельную повторную оценку кандидатов. Это увеличивает стоимость и требует собственного сравнения. Сначала установите проблему, которую такой этап действительно решает.
Автоматический ответ с цитатами является ещё одним продуктовым результатом. Наш курс заканчивается поиском и ссылками. Найденный материал не превращается автоматически в проверенное утверждение для генерации ответа.
Завершённый учебный проект
Финальная контрольная версия содержит исходные документы, обычный интерфейс, серверный адаптер, программы подготовки индекса и векторов. В ней сохранены постоянные адреса и явные версии данных.
Перед будущим применением на реальном сайте потребуется выполнить примеры, оценить качество на его корпусе и подготовить подходящее окружение обслуживания. Само написание программы не является измерением или публикацией.
Для следующего изменения сохраните небольшой отчёт сравнения: версии корпуса и модели, настройки режима, найденные ID и разобранные неудачи. Даже если средний показатель улучшился, отдельно рассмотрите точные технические названия и отрицательный запрос. Перестановка одного важного урока может иметь большее значение для читателя, чем небольшое изменение общей оценки. Такой отчёт позволяет вернуться к исходной причине выбора режима, когда библиотека вырастет или появится другая модель.
Теперь читатель может объяснить весь путь: Markdown становится документами, документы — индексом, запрос — набором условий или вектором, а результат — карточками с сохранёнными URL. Дальнейшие улучшения выбираются по задачам и наблюдениям, а не по числу подключённых технологий.