爱回收
75.46M · 2026-04-05
在 AI 搜索和知识库场景中,混合检索(Hybrid Search)是当前最优解:
单独使用稠密向量会导致专有名词召回不准确,而仅使用稀疏向量则无法理解语义相近的不同表述。混合检索能够同时规避这两个问题。
混合检索方案特别适用于以下前端场景:
仅使用稠密向量检索时的问题:
仅使用 BM25/关键词检索时的问题:
flowchart LR
A["文档内容"] --> B["生成稠密向量<br/>Embedding"]
A --> C["生成稀疏向量<br/>分词+词频"]
B --> D["写入向量库(dense)"]
C --> E["写入向量库(sparse)"]
Q["用户Query"] --> Q1["Query Embedding"]
Q --> Q2["Query Sparse Vector"]
Q1 --> S1["Dense Search"]
Q2 --> S2["Sparse Search"]
S1 --> F["RRF 融合排序"]
S2 --> F
F --> R["TopK 返回前端"]
示例文本:我是好学生,每天8点起床
分词后:
["我", "是", "好", "学生", "每天", "8", "点", "起床"]
稀疏结构(示意):
{
indices: [102, 1552, 30091],
values: [1, 1, 1]
}
text-embedding-3-small)以下代码采用通用写法,不依赖特定项目结构,可直接迁移到任意 TypeScript 项目中
async function addDocument(content: string, metadata?: Record<string, any>) {
const dense = await embedText(content) // number[]
const sparse = textToSparseVector(content) // { indices, values }
await qdrant.upsert('documents', {
points: [
{
id: crypto.randomUUID(),
vector: {
dense,
bm25: sparse,
},
payload: { content, metadata },
},
],
})
}
import { createRequire } from 'node:module'
import { Jieba } from '@node-rs/jieba'
type SparseVector = { indices: number[]; values: number[] }
const require = createRequire(import.meta.url)
const { dict } = require('@node-rs/jieba/dict') as { dict: Uint8Array }
const jieba = Jieba.withDict(dict)
function fnv1aHash(str: string): number {
let hash = 0x811c9dc5
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return hash >>> 0
}
function textToSparseVector(text: string): SparseVector {
const tokens = jieba
.cutForSearch(text, true)
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
.filter((t) => !/^[p{P}p{S}p{Z}]+$/u.test(t))
const tf = new Map<number, number>()
for (const token of tokens) {
const idx = fnv1aHash(token)
tf.set(idx, (tf.get(idx) ?? 0) + 1)
}
const entries = [...tf.entries()].sort((a, b) => a[0] - b[0])
return {
indices: entries.map(([i]) => i),
values: entries.map(([, v]) => v),
}
}
await qdrant.createCollection('documents', {
vectors: {
dense: { size: 512, distance: 'Cosine' },
},
sparse_vectors: {
bm25: { modifier: 'idf' },
},
})
说明:
modifier: 'idf')type SearchMode = 'dense' | 'sparse' | 'hybrid'
async function search(query: string, topK = 5, mode: SearchMode = 'hybrid') {
const querySparse = textToSparseVector(query)
const queryDense = mode === 'sparse' ? null : await embedText(query)
if (mode === 'dense') return searchDense(queryDense!, topK)
if (mode === 'sparse') return searchSparse(querySparse, topK)
const [denseRes, sparseRes] = await Promise.all([
searchDense(queryDense!, topK),
searchSparse(querySparse, topK),
])
return fuseByRRF(denseRes, sparseRes, topK)
}
const RRF_K = 60
const rrf = (rank: number) => 1 / (RRF_K + rank + 1)
RRF(Reciprocal Rank Fusion)算法的核心思想是基于排名而非分数进行融合。当同一文档在稠密检索和稀疏检索的结果中排名都靠前时,其最终融合分数会更高。
相比于传统的加权融合方法,RRF 的优势在于:
基于上述技术方案,完整的实施流程包括以下步骤:
vectors 和 sparse_vectors 的集合hybrid 模式sparse 模式)当查询文本全是标点符号或停用词时,稀疏向量可能为空。此时应返回空数组或降级到纯稠密检索,避免出现异常。
在稠密检索分支中,必须确保 embedding 已成功生成。空向量应直接抛出错误,而非静默返回不可靠的结果。
当 embedding 模型的向量维度发生变化(如从 512 维升级到 1536 维)时,现有的向量集合通常无法直接复用,需要重新生成所有文档的向量并迁移数据。
业务专有术语如果未被包含在分词词典中,会显著影响稀疏向量的召回效果。建议根据业务场景定制分词词典,加入领域特定术语。
混合检索方案将向量检索技术从算法研究转化为可落地的搜索体验工程实践: