Гибридный поиск: Когда одного векторного поиска недостаточно

07.06.2026 09:00

Мы запустили "умный" поиск по каталогу электроники. Использовали передовые эмбеддинги, векторную базу данных, настроили пайплайн. На тестах всё выглядело отлично: по запросу "телефон для бабушки" система выдавала кнопочные аппараты с большими экранами. Семантика работала. Но через неделю после релиза в поддержку посыпались жалобы. Клиент искал материнскую плату "ASUS ROG STRIX Z790-E", а поиск упорно предлагал ему "ASUS ROG STRIX Z690-E" и кучу видеокарт. Векторная модель решила, что эти товары "семантически близки". Для машины разница в одну цифру оказалась несущественной, а для покупателя — критичной.

Так мы столкнулись с главной проблемой чистого векторного поиска: он великолепно понимает смысл, но абсолютно слеп к точным совпадениям. Артикулы, специфические коды ошибок, аббревиатуры, номера моделей — всё это превращается в кашу в многомерном пространстве.

Решение кроется в гибридном поиске. Мы объединили старый добрый лексический поиск (BM25) и векторный поиск (Dense Retrieval), а финальную сборку ответа доверили LLM через RouterAPI.

Анатомия проблемы: BM25 против Dense Vectors

Лексический поиск, стандартом которого стал алгоритм BM25, опирается на точное совпадение токенов. Он вычисляет частоту термина (TF) и обратную частоту документа (IDF). Если пользователь вводит "Z790-E", BM25 найдет документы, где эта строка встречается чаще всего. Главная хитрость здесь — правильная токенизация. Если ваш анализатор разбивает "Z790-E" на "Z790" и "E", вы получите мусор в выдаче. Нам пришлось настроить кастомные edge-ngram фильтры и сохранить дефисы как часть токена для артикулов. При этом BM25 не понимает синонимов. Запрос "смартфон" не найдет документ со словом "телефон".

Векторный поиск (Dense Retrieval) работает иначе. Текст прогоняется через модель (например, text-embedding-3-small), которая превращает его в вектор из 1536 чисел. Поиск сводится к вычислению косинусного расстояния между вектором запроса и векторами документов. Он отлично справляется с синонимами, опечатками и абстрактными концепциями. Но "Z790-E" и "Z690-E" в этом пространстве находятся слишком близко друг к другу. Модель видит в них "материнские платы ASUS", игнорируя точный идентификатор поколения.

Нам нужны обе технологии. BM25 действует как скальпель, вырезая точные совпадения. Векторный поиск работает как сеть, вылавливая контекстуально релевантные результаты.

Слияние миров: Reciprocal Rank Fusion (RRF)

Главная техническая сложность гибридного поиска — объединение результатов. BM25 выдает абсолютные скоры (например, 14.5, 22.1), которые не имеют верхнего предела и зависят от длины документа. Векторный поиск выдает косинусное сходство (от -1 до 1). Складывать их напрямую нельзя. Нормализация (Min-Max) работает плохо, так как распределение скоров BM25 имеет длинный хвост.

Мы применили алгоритм Reciprocal Rank Fusion (RRF). Он игнорирует абсолютные значения и смотрит только на позицию документа в выдаче.

Формула RRF предельно проста: RRF_Score = 1 / (k + Rank_BM25) + 1 / (k + Rank_Vector)

Где k — константа сглаживания (обычно 60). Документ, который находится в топе обеих выдач, всегда получит наивысший балл. Реализация на PHP занимает буквально пару десятков строк:

/**
 * @param array<string, int> $bm25Ranks Массив [docId => rank]
 * @param array<string, int> $vectorRanks Массив [docId => rank]
 * @param int $k Константа сглаживания
 * @return array<string, float> Отсортированный массив [docId => rrfScore]
 */
public function calculateRRF(array $bm25Ranks, array $vectorRanks, int $k = 60): array
{
 $scores = [];
 $allDocIds = array_unique(array_merge(array_keys($bm25Ranks), array_keys($vectorRanks)));

 foreach ($allDocIds as $docId) {
 $score = 0.0;
 
 if (isset($bm25Ranks[$docId])) {
 $score += 1.0 / ($k + $bm25Ranks[$docId]);
 }
 
 if (isset($vectorRanks[$docId])) {
 $score += 1.0 / ($k + $vectorRanks[$docId]);
 }
 
 $scores[$docId] = $score;
 }

 arsort($scores);
 return $scores;
}

Этот подход не требует сложной калибровки весов. Мы берем топ-100 результатов из BM25, топ-100 из векторного поиска, прогоняем через RRF и оставляем топ-5 для генерации ответа.

Интеграция RouterAPI для генерации ответа

Найти правильные документы — половина дела. Пользователю нужен конкретный ответ, а не список ссылок. Мы используем RouterAPI (наш внутренний шлюз) для финальной суммаризации. RouterAPI позволяет прозрачно переключаться между провайдерами (резервный провайдер, резервный канал RouterAPI) и моделями, обеспечивая отказоустойчивость и контроль над расходами.

Ниже приведен пример реализации сервиса, который собирает контекст из гибридного поиска и отправляет его в модель. Мы используем anthropic/claude-3.5-sonnet как основную модель, так как она превосходно работает с объемным контекстом и строго следует инструкциям.

declare(strict_types=1);

namespace App\Services\Search;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;

readonly class HybridSearchSummarizer
{
 public function __construct(
 private string $routerApiUrl,
 private string $apiKey
 ) {}

 /**
 * @param string $query Запрос пользователя
 * @param array<int, array{id: string, title: string, content: string}> $documents Топ-5 документов после RRF
 */
 public function summarize(string $query, array $documents): string
 {
 $context = $this->buildContext($documents);

 $systemPrompt = "Ты — технический эксперт. Ответь на вопрос пользователя, используя ТОЛЬКО предоставленный контекст. "
 . "Если в контексте нет ответа, скажи: 'Информации недостаточно'. Не выдумывай характеристики. "
 . "Обязательно указывай точные артикулы из контекста.";

 try {
 $response = Http::withToken($this->apiKey)
 ->timeout(15)
 ->post("{$this->routerApiUrl}/v1/chat/completions", [
 'model' => 'anthropic/claude-3.5-sonnet',
 'messages' => [
 ['role' => 'system', 'content' => $systemPrompt],
 ['role' => 'user', 'content' => "Контекст:\n{$context}\n\nВопрос: {$query}"]
 ],
 'temperature' => 0.0,
 'max_tokens' => 500,
 ]);

 if (! $response->successful) {
 Log::error('RouterAPI error', ['status' => $response->status, 'body' => $response->body]);
 throw new RuntimeException('Ошибка генерации ответа.');
 }

 return $response->json('choices.0.message.content') ?? 'Ответ не получен.';
 
 } catch (\Exception $e) {
 Log::error('Search summarization failed', ['exception' => $e->getMessage]);
 return 'Произошла ошибка при обработке запроса. Пожалуйста, уточните артикул.';
 }
 }

 private function buildContext(array $documents): string
 {
 $contextParts = [];
 foreach ($documents as $index => $doc) {
 $contextParts[] = sprintf(
 "[Документ %s]\nЗаголовок: %s\nТекст: %s",
 $doc['id'],
 $doc['title'],
 $doc['content']
 );
 }
 return implode("\n\n", $contextParts);
 }
}

Обратите внимание на temperature = 0.0. В задачах RAG (Retrieval-Augmented Generation) модель должна работать как строгий синтезатор, а не как креативный писатель. Любая галлюцинация в артикуле или спецификации приведет к возврату товара и негативу клиента.

Выводы из продакшена

Переход на гибридный поиск решил проблему с артикулами. Конверсия из поиска выросла на 14%, а количество жалоб на нерелевантную выдачу упало практически до нуля.

Но мы извлекли несколько жестких уроков:

  1. Задержка (Latency) растет. Векторный поиск медленнее лексического. Параллельное выполнение BM25 и k-NN спасает ситуацию, но генерация эмбеддингов для пользовательского запроса перед походом в базу добавляет 50-100 мс. Плюс 1-2 секунды на ответ от RouterAPI. Мы внедрили стриминг ответа на фронтенд, чтобы пользователь видел текст по мере генерации, скрадывая ожидание.
  2. Размер индекса бьет по кошельку. Хранение векторов на 1536 размерностей (float32) требует значительно больше оперативной памяти, чем инвертированный индекс BM25. Нам пришлось шардировать кластер раньше, чем мы планировали, и перейти на скалярное квантование (int8) для векторов, пожертвовав 1% точности ради двукратного снижения потребления RAM.
  3. LLM не исправит плохой поиск. Если нужный документ не попал в топ-5 после RRF, RouterAPI сгенерирует ответ "Информации недостаточно". И это правильное поведение. LLM — это процессор, а не база знаний. Качество ответа жестко ограничено качеством контекста.

Векторный поиск — мощный инструмент, но он не серебряная пуля. Только комбинация грубой силы лексического совпадения, математической грации эмбеддингов и аналитических способностей LLM дает пользователю тот опыт, которого он ожидает. Гибридный поиск — это не компромисс, это единственный рабочий паттерн для сложных предметных областей.

Теги

Ещё по теме