autosnippet
Version:
Extract code patterns into a knowledge base for AI coding assistants
114 lines (113 loc) • 4.06 kB
JavaScript
/**
* HybridRetriever — 统一混合检索 (RRF 融合)
*
* 使用 Reciprocal Rank Fusion (RRF) 融合 Dense + Sparse 搜索:
* score = Σ 1/(k + rank_i)
*
* RRF 优势:
* - 不需要分数归一化 (不同检索器分数尺度无关)
* - 对异常高分 (outlier) 不敏感
* - 数学性质稳定 (有界, 单调)
* - 已被 Elasticsearch, Weaviate, Qdrant 采用为默认融合策略
*
* @module service/search/HybridRetriever
*/
export class HybridRetriever {
#vectorStore;
#rrfK;
#defaultAlpha;
/**
* @param [options.rrfK=60] RRF 常数 (k), 值越大越平滑
* @param [options.alpha=0.5] Dense 权重 (1-alpha = Sparse 权重)
*/
constructor(options = {}) {
this.#vectorStore = options.vectorStore || null;
this.#rrfK = options.rrfK || 60;
this.#defaultAlpha = options.alpha ?? 0.5;
}
/**
* RRF 融合搜索
*
* Dense: vectorStore 向量搜索 (HNSW or brute-force)
* Sparse: BM25 关键词搜索 (由外部传入结果)
*
* @param params.denseResults - 向量搜索结果
* @param params.sparseResults - 关键词搜索结果
* @param [params.alpha=0.5] Dense 权重
* @returns >}
*/
fuse({ denseResults = [], sparseResults = [], topK = 10, alpha = 0.5, }) {
const k = this.#rrfK;
const scores = new Map();
// Dense RRF 分数
denseResults.forEach((result, rank) => {
const id = result.item?.id || result.id;
if (!id) {
return;
}
const existing = scores.get(id) || {
id,
denseRank: Infinity,
sparseRank: Infinity,
rrfScore: 0,
data: result,
};
existing.denseRank = rank + 1;
existing.rrfScore += alpha * (1 / (k + rank + 1));
existing.data = result;
scores.set(id, existing);
});
// Sparse RRF 分数
sparseResults.forEach((result, rank) => {
const id = result.id;
if (!id) {
return;
}
const existing = scores.get(id) || {
id,
denseRank: Infinity,
sparseRank: Infinity,
rrfScore: 0,
data: result,
};
existing.sparseRank = rank + 1;
existing.rrfScore += (1 - alpha) * (1 / (k + rank + 1));
if (!existing.data || !existing.data.item) {
existing.data = result;
}
scores.set(id, existing);
});
// 按 RRF 分数降序排列
const fused = [...scores.values()].sort((a, b) => b.rrfScore - a.rrfScore).slice(0, topK);
// 归一化 score 到 [0, 1] 方便下游使用
const maxRrf = fused.length > 0 ? fused[0].rrfScore : 1;
for (const item of fused) {
item.score = maxRrf > 0 ? item.rrfScore / maxRrf : 0;
}
return fused;
}
/**
* 完整搜索: 同时执行 Dense + Sparse 并融合
*
* @param query 查询文本
* @param queryVector 查询向量
* @param [options.sparseSearchFn] 外部 sparse 搜索函数 (query, limit) => results[]
*/
async search(query, queryVector, options = {}) {
const { topK = 10, alpha = this.#defaultAlpha, filter = null, sparseSearchFn = null } = options;
const expandedK = topK * 3; // 每路召回更多候选以提高融合质量
// 并行执行 Dense + Sparse
const [denseResults, sparseResults] = await Promise.all([
queryVector?.length && this.#vectorStore
? this.#vectorStore.searchVector(queryVector, { topK: expandedK, filter })
: Promise.resolve([]),
sparseSearchFn ? Promise.resolve(sparseSearchFn(query, expandedK)) : Promise.resolve([]),
]);
return this.fuse({
denseResults,
sparseResults,
topK,
alpha,
});
}
}