Классический векторный поиск на базе плотных эмбеддингов (Dense Embeddings) произвел революцию в информационном поиске благодаря способности улавливать концептуальное и семантическое сходство текстов. Однако в производственных Enterprise-системах изолированное использование векторного поиска демонстрирует критические архитектурные слабости:
SKU-9482-X), серийные номера, имена функций (wp_set_current_user), хэши коммитов или специфические CLI-флаги теряют свою уникальность и размываются в общем семантическом шуме.tenant_id = 42 AND created_at > 2026-01-01). Если подходящих документов в топ-$k$ не оказалось, поиск возвращает пустой или неполный результат.Для преодоления этих фундаментальных ограничений промышленным стандартом 2026 года стал Гибридный поиск (Hybrid Search), объединяющий плотный векторный поиск (Dense Vector Search) и разреженный лексический поиск (Sparse Lexical Search, BM25 / SPLADE) через детерминированный алгоритм слияния рангов — Reciprocal Rank Fusion (RRF).
В основе гибридного поиска лежит параллельное выполнение двух независимых поисковых пайплайнов с последующим математическим объединением результатов в единую отранжированную выдачу.
[ User Query ]
|
+-------------------------------------------+
| |
v v
[ Dense Encoder ] [ Sparse Tokenizer ]
(e.g., text-embedding-3-large) (BM25 / SPLADE Analyzer)
| |
v (Vector: 1536d float32) v (Sparse: term -> weight)
+-----------------------------+ +-----------------------------+
| Vector DB: Dense Index | | Search Engine: Inverted Idx |
| HNSW / Cosine Similarity | | Okapi BM25 Scoring |
+-----------------------------+ +-----------------------------+
| (Ranked List 1: Top-K) | (Ranked List 2: Top-K)
| [DocA (1), DocB (2), ...] | [DocC (1), DocA (2), ...]
+-------------------------------------------+
|
v
+------------------------------------+
| Reciprocal Rank Fusion (RRF) |
| Score(d) = sum( 1 / (k + rank_i) ) |
+------------------------------------+
|
v
[ Unified Ranked Candidates (Top-N) ]
|
v
[ Payload / Metadata Filter Stage ]| Критерий | HNSW | IVFFlat | | :--- | :--- | :--- | | Алгоритмическая сложность поиска | $O(\log N)$ | $O(C \times d)$, где $C$ — число посещаемых кластеров | | Потребление оперативной памяти (RAM) | Высокое (+1.5x–2x к объему векторов за счет графа ребер) | Низкое (хранит только центроиды и списки идентификаторов) | | Скорость построения индекса (Build Time) | Медленная (требует поиска соседей для каждого узла) | Быстрая (один прогон k-means кластеризации) | | Точность поиска (Recall@k) | 95–99% без деградации при вставках | 80–90%, падает при смещении распределения данных | | Динамические вставки (Online Updates) | Поддерживает динамическое добавление без потери точности | Требует полной переиндексации при накоплении дрейфа |
При объединении списков документов из векторного индекса (где мера — косинусное сходство $[0; 1]$) и лексического индекса (где мера — оценка BM25 $[0; +\infty)$) наивная нормализация (Min-Max) приводит к нестабильности из-за аномальных выбросов (outliers) и различий в плотности распределения.
RRF устраняет зависимость от абсолютных оценок, оперируя исключительно рангом (позицией) документа в каждом списке:
RRF_Score(d ∈ D) = Σ [ 1 / (k + r_m(d)) ]где M — множество поисковых систем (Dense, Sparse), r_m(d) — порядковый номер (ранг, начиная с 1) документа d в выдаче системы m, а k — сглаживающая константа (по умолчанию k = 60). Константа k нивелирует чрезмерное доминирование первого элемента одного списка над стабильно высокими позициями другого списка.
--eviction-hard=imagefs.availableменее 15%) и статус-коды.PostgreSQL 16 в сочетании с расширением pgvector позволяет реализовать полноценный гибридный поиск в рамках единой ACID-совместимой реляционной СУБД, устраняя необходимость синхронизации данных между отдельными хранилищами.
-- 1. Подключение расширений
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 2. Создание таблицы документов с плотным вектором и полнотекстовым полем
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Вектор OpenAI text-embedding-3-small (1536 измерений)
embedding vector(1536) NOT NULL,
-- Полнотекстовый поисковый вектор (генерируемая колонка)
tsv_content tsvector GENERATED ALWAYS AS (
to_tsvector('russian', coalesce(title, '') || ' ' || coalesce(content, ''))
) STORED
);
-- 3. Создание HNSW индекса для косинусного расстояния (vector_cosine_ops)
-- m: максимальное количество соединений на узел (16 - оптимум)
-- ef_construction: размер динамического списка при построении графа (64-128)
CREATE INDEX idx_documents_embedding_hnsw
ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 4. Создание GIN индекса для полнотекстового лексического поиска
CREATE INDEX idx_documents_tsv ON documents USING gin(tsv_content);
-- 5. Индекс по метаданным для фильтрации (Single-Tenant / Multi-Tenant)
CREATE INDEX idx_documents_metadata ON documents USING gin(metadata);Выполнение слияния рангов непосредственно в PostgreSQL позволяет избежать передачи сотен сырых кандидатов по сети в приложение.
WITH
-- Параметры поиска: $1 - вектор запроса, $2 - текст запроса, $3 - k константа RRF (60), $4 - tenant_id
dense_search AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS dense_rank
FROM documents
WHERE metadata->>'tenant_id' = $4
ORDER BY embedding <=> $1::vector
LIMIT 50
),
sparse_search AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv_content, plainto_tsquery('russian', $2)) DESC) AS sparse_rank
FROM documents
WHERE
tsv_content @@ plainto_tsquery('russian', $2)
AND metadata->>'tenant_id' = $4
ORDER BY ts_rank_cd(tsv_content, plainto_tsquery('russian', $2)) DESC
LIMIT 50
)
SELECT
d.id,
d.title,
d.content,
d.metadata,
COALESCE(1.0 / ($3 + ds.dense_rank), 0.0) +
COALESCE(1.0 / ($3 + ss.sparse_rank), 0.0) AS rrf_score
FROM dense_search ds
FULL OUTER JOIN sparse_search ss ON ds.id = ss.id
JOIN documents d ON d.id = COALESCE(ds.id, ss.id)
ORDER BY rrf_score DESC
LIMIT 10;Для систем с ультра-высокими требованиями к RPS и задержке (QPS > 10 000, latency менее 15ms) выделенная векторная база данных (например, Qdrant) предоставляет аппаратную оптимизацию на Rust, скалярное квантование векторов и нативную поддержку Sparse/Dense векторов в одной коллекции.
Ниже приведена промышленная реализация гибридного ретривера с клиентом @qdrant/js-client-rest и локальной токенизацией BM25.
import { QdrantClient } from "@qdrant/js-client-rest";
import { z } from "zod";
// Zod-схемы валидации контрактов
export const SearchResultSchema = z.object({
id: z.string(),
score: z.number(),
payload: z.object({
documentId: z.string(),
content: z.string(),
tenantId: z.string(),
category: z.string(),
}),
});
export type SearchResult = z.infer<typeof SearchResultSchema>;
export interface HybridSearchOptions {
limit: number;
rrfKConstant?: number;
tenantId: string;
}
export class HybridSearchService {
private qdrant: QdrantClient;
private readonly collectionName = "enterprise_knowledge_base";
constructor(qdrantUrl: string, apiKey?: string) {
this.qdrant = new QdrantClient({
url: qdrantUrl,
apiKey: apiKey,
});
}
/**
* Слияние двух отсортированных списков идентификаторов методом RRF.
*/
private applyRRF(
denseResults: Array<{ id: string; payload: Record<string, unknown> }>,
sparseResults: Array<{ id: string; payload: Record<string, unknown> }>,
kConstant: number = 60,
limit: number = 10
): SearchResult[] {
const scores = new Map<string, { score: number; payload: Record<string, unknown> }>();
// 1. Учет рангов плотного поиска
denseResults.forEach((item, index) => {
const rank = index + 1;
const rrfContribution = 1 / (kConstant + rank);
const existing = scores.get(item.id);
if (existing) {
existing.score += rrfContribution;
} else {
scores.set(item.id, { score: rrfContribution, payload: item.payload });
}
});
// 2. Учет рангов разреженного поиска
sparseResults.forEach((item, index) => {
const rank = index + 1;
const rrfContribution = 1 / (kConstant + rank);
const existing = scores.get(item.id);
if (existing) {
existing.score += rrfContribution;
} else {
scores.set(item.id, { score: rrfContribution, payload: item.payload });
}
});
// 3. Сортировка по итоговому RRF Score и приведение к типам
const sorted = Array.from(scores.entries())
.sort((a, b) => b[1].score - a[1].score)
.slice(0, limit)
.map(([id, data]) => ({
id,
score: Number(data.score.toFixed(6)),
payload: data.payload,
}));
return z.array(SearchResultSchema).parse(sorted);
}
/**
* Выполнение гибридного запроса к Qdrant с пре-фильтрацией по метаданным
*/
public async search(
denseVector: number[],
sparseIndices: number[],
sparseValues: number[],
options: HybridSearchOptions
): Promise<SearchResult[]> {
const filter = {
must: [
{
key: "tenantId",
match: { value: options.tenantId },
},
],
};
// Параллельный запрос к Dense и Sparse векторам в Qdrant
const [denseResponse, sparseResponse] = await Promise.all([
this.qdrant.search(this.collectionName, {
vector: {
name: "dense-text",
vector: denseVector,
},
filter,
limit: 50,
with_payload: true,
}),
this.qdrant.search(this.collectionName, {
vector: {
name: "sparse-bm25",
vector: {
indices: sparseIndices,
values: sparseValues,
},
},
filter,
limit: 50,
with_payload: true,
}),
]);
const formattedDense = denseResponse.map((hit) => ({
id: String(hit.id),
payload: (hit.payload ?? {}) as Record<string, unknown>,
}));
const formattedSparse = sparseResponse.map((hit) => ({
id: String(hit.id),
payload: (hit.payload ?? {}) as Record<string, unknown>,
}));
return this.applyRRF(
formattedDense,
formattedSparse,
options.rrfKConstant ?? 60,
options.limit
);
}
}Индекс ivfflat строит центроиды методом k-means по текущему срезу данных при создании. При непрерывной вставке новых документов без выполнения полной переиндексации (REINDEX INDEX) новые векторы привязываются к неоптимальным кластерам. В результате Recall падает с 90% до 35-40% за несколько недель работы системы. В производственных сценариях с обновлениями всегда используйте hnsw.
Попытка сложить нормализованные скоры: total = 0.5 * norm(bm25) + 0.5 * cosine_sim. Значения BM25 не имеют фиксированной верхней границы и сильно зависят от длины запроса и частоты редких слов в корпусе. Единичное аномально редкое слово дает выброс (outlier), полностью подавляющий косинусное сходство плотных векторов. RRF оперирует исключительно рангами элементов, гарантируя инвариантность к масштабу метрик.
Если фильтрация по метаданным (tenant_id, дата, права доступа) выполняется после выборки $k$ ближайших векторов из графа, при жестких фильтрах (например, редкий арендатор, которому принадлежит 0.1% базы) запрос возвращает 0 документов, хотя в базе их тысячи. Векторные СУБД должны поддерживать Single-Stage Filtering (как в Qdrant или pgvector 0.7+), где проверка условий метаданных встроена непосредственно в шаг обхода графа соседей.
Использование евклидова расстояния (<-> / L2) вместо косинусного (<=>) для векторов, которые не были приведены к единичной норме ($|v| \neq 1$). Если эмбеддинги не нормализованы, евклидово расстояние будет отдавать предпочтение коротким векторам, а не векторам с совпадающим направлением, что приводит к критической деградации релевантности.
Определение схемы векторов (Dense: 1536d float32, Sparse: BPE токенизатор / BM25 словарь) и полей метаданных для жесткой фильтрации.
Калибровка параметров индекса: m = 16..32, ef_construction = 64..128 для обеспечения баланса между временем индексации и Recall@10 > 95%.
Настройка инвертированного индекса (GIN в PostgreSQL или Sparse Collection в Qdrant) с удалением стоп-слов и стеммингом.
Реализация слияния списков кандидатов с калибровкой сглаживающего коэффициента k (оптимальный диапазон 40–60).
Проведение нагрузочного тестирования под целевым RPS с замером p99 latency и проверкой изоляции тенантов.