Appearance
检索操作相关技术名词part1
接下来我们来看一下和检索操作相关的技术名词,以及具体的落地方针、还有相关的面试题。
由于内容比较多,这里拆解成上下两篇。
和检索相关的技术名词有:
- Keyword Search
- Vector Search
- Metadata Filtering
- Small-to-Big Retrieval
- Hypothetical Retrieval
1. Keyword Search
Keyword Search 中文一般叫 “关键词检索”。
它的核心意思是:不是去理解整句话的语义,而是看文本里有没有出现你要找的关键词。
你可以把它理解成用户给出几个词,系统就去知识库里“按词找内容”。所以它更像是在做:
- 词是否出现
- 词出现了几次
- 词是不是比较重要
- 这些词在文档里匹配得好不好
比如,用户问:
苹果15续航咋样如果知识库里刚好有这些词,或者有比较接近的关键词,比如:
苹果 15 续航测试
iPhone 15 电池续航那关键词检索就有机会把它找出来。
但如果文档里写的是:
iPhone 15 的电池表现如何虽然意思接近,但因为没有完全对上的关键词,Keyword Search 就不如向量检索那么敏感。
所以它特别适合这些场景:
- 用户问题里有明确关键词
- 文档里也用了同样或非常接近的词
- 需要精确命中某些术语、产品名、报错信息、编号、专有名词
比如这些场景就很适合:
- 错误码检索:404、ERR_CONNECTION_RESET
- 产品型号检索:iPhone 15 Pro
- 专有术语检索:React Hooks、JWT
- 文档编号、合同编号、工单号检索
它的优势是:
- 命中明确关键词时很准
- 对专有名词、编号、术语很有效
- 实现成熟,速度通常也很快
它的弱点是:
- 不太擅长理解“换一种说法”
- 遇到同义词、近义词、口语化表达时,可能召回不到
- 更依赖 query 和文档在字面上能对上
1-1. Key Search 示例代码
ts
这段代码体现了 Keyword Search 的核心思想:
用户问题 -> 分词 -> 找关键词 -> 命中文档 -> 计算匹配分数 -> 排序返回在真实的 RAG 系统中,Keyword Search 通常不会从零实现,而是直接使用成熟的全文检索引擎。
例如:
- Elasticsearch
- OpenSearch
- Meilisearch
- Typesense
- 数据库全文索引(MySQL / PostgreSQL FTS)
这些系统内部通常基于倒排索引构建高效检索能力,并结合 BM25 等相关性算法,对关键词匹配结果进行打分和排序。
因此,Keyword Search 的本质并不是简单的字符串匹配,而是一套完整的“关键词相关性排序系统”。
1-1. BM25相关性算法
BM25 理解为:一种用于“关键词检索排序”的评分函数,用来判断“这篇文档和查询到底有多相关”。
它是现代搜索引擎(如 Elasticsearch、OpenSearch)的默认核心算法之一。
当用户输入一个查询:
笔记本 蓝屏 怎么 修搜索引擎会做两件事:
- 找出包含这些词的文档
- 用 BM25 给每个文档打分 → 谁更相关排前面
BM25 解决的核心问题是:“包含关键词 ≠ 更相关,如何衡量‘更相关’?”
简化版核心公式: $$ score(D, Q) = \sum_{t \in Q} IDF(t) \cdot \frac{f(t, D) \cdot (k_1 + 1)} {f(t, D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{avgdl} \right)} $$
不用被公式吓到,可以拆成三个直觉。
词出现越多 → 分数越高(但有上限)
f(t, D):词在文档中出现次数
直觉:
- 出现 1 次 → 有点相关
- 出现 5 次 → 更相关
- 出现 100 次 → 不会无限加分
BM25 做了“边际递减”控制(避免刷词)
稀有词更重要(IDF)
IDF(t):词的稀有程度(Inverse Document Frequency)
直觉:
| 词 | 重要性 |
|---|---|
| “的”、“是” | 很低 |
| “蓝屏” | 很高 |
稀有词区分度更强 → 权重更高
文档越长 → 要“降权”
|D| / avgdl:文档长度 vs 平均长度b:长度惩罚系数
直觉:
- 长文档更容易“包含关键词”(天然占便宜)
- 所以要适当压分
防止“水文”排前面。
因此总结起来,BM25 本质是在计算:
一个文档中“重要关键词”出现得“够多”,但又不是靠堆词刷出来,同时文档长度合理 → 分数更高
1-2. 倒排索引
另外还有一个关键词,叫做倒排索引。所谓倒排索引,指的是从“词 → 文档”的映射关系。
假设有三篇文档:
Doc1: 笔记本 蓝屏 修复
Doc2: 蓝屏 错误码 查询
Doc3: 笔记本 推荐 2024正排索引(Forward Index):
Doc1 → [笔记本, 蓝屏, 修复]
Doc2 → [蓝屏, 错误码, 查询]
Doc3 → [笔记本, 推荐, 2024]问题:如果用户搜索“蓝屏”,你必须扫所有文档,非常的慢
倒排索引(Inverted Index):把关系“反过来”:
笔记本 → [Doc1, Doc3]
蓝屏 → [Doc1, Doc2]
修复 → [Doc1]
错误码 → [Doc2]
推荐 → [Doc3]现在:搜“蓝屏” → 直接拿到 Doc1、Doc2
1-3. Key Search示例代码
ts
type KnowledgeDocument = {
id: number;
content: string;
};
type InvertedIndexItem = {
docId: number;
count: number;
};
type InvertedIndex = Map<string, InvertedIndexItem[]>;
type SearchResult = KnowledgeDocument & {
score: number;
};
// 示例知识库文档
const documents: KnowledgeDocument[] = [
{
id: 1,
content: "iPhone 15 电池续航测试表现不错,视频播放时间较长",
},
{
id: 2,
content: "React Hooks 是 React 中用于状态管理和副作用处理的 API",
},
{
id: 3,
content: "ERR_CONNECTION_RESET 通常表示网络连接被重置",
},
{
id: 4,
content: "iPhone 15 Pro 使用 A17 Pro 芯片,性能更强",
},
];
// 简单分词函数
//
// 注意:这个函数只是为了演示 keyword search 的整体流程,不是真正的中文分词器。
// 它主要依赖空格来切分文本,所以更适合处理英文、数字,或者已经用空格分好的中文词。
//
// 例如:
// "iPhone 15 电池 续航" 会被切成 ["iphone", "15", "电池", "续航"]
// 但 "iPhone 15 电池续航测试表现不错" 里的 "电池续航测试表现不错"
// 会被当成一个整体词,查询 "续航" 时并不能直接匹配到它。
//
// 真实项目中,中文一般会使用 jieba、nodejieba、分词服务等工具,
// 或者结合搜索引擎自身的中文 analyzer 来完成分词。
function tokenize(text: string): string[] {
return text
.toLowerCase()
.replace(/[^\w\u4e00-\u9fa5\s]/g, " ")
.split(/\s+/)
.filter(Boolean);
}
// 构建倒排索引
//
// 倒排索引的作用是:
// 从 “文档 -> 包含哪些词”
// 转换成 “词 -> 出现在哪些文档里”
//
// 有了倒排索引后,查询某个关键词时就不需要逐篇扫描所有文档,
// 可以直接找到包含该关键词的文档列表。
//
// 这里的 “词” 由上面的 tokenize 函数决定。
// 因为 tokenize 是一个简化实现,所以本示例重点演示倒排索引思想,
// 不是演示完整的中文搜索效果。
// invertedIndex 结构:
// {
// "iphone": [{ docId: 1, count: 1 }, { docId: 4, count: 1 }],
// "15": [{ docId: 1, count: 1 }, { docId: 4, count: 1 }],
// "react": [{ docId: 2, count: 2 }]
// }
function buildInvertedIndex(docs: KnowledgeDocument[]): InvertedIndex {
const invertedIndex: InvertedIndex = new Map();
for (const doc of docs) {
const words = tokenize(doc.content);
// 统计当前文档中每个词出现的次数
const wordCount = new Map<string, number>();
for (const word of words) {
wordCount.set(word, (wordCount.get(word) || 0) + 1);
}
// 把词和文档的关系写入倒排索引
for (const [word, count] of wordCount) {
if (!invertedIndex.has(word)) {
invertedIndex.set(word, []);
}
const matchedDocs = invertedIndex.get(word);
if (!matchedDocs) continue;
matchedDocs.push({
docId: doc.id,
count,
});
}
}
return invertedIndex;
}
// 关键词检索核心逻辑
//
// 基本流程:
// 1. 把用户 query 切成关键词
// 2. 在倒排索引中找到包含这些关键词的文档
// 3. 用一个简化版 TF-IDF 公式给文档打分
// 4. 按分数从高到低返回结果
function keywordSearch(
query: string,
docs: KnowledgeDocument[],
invertedIndex: InvertedIndex,
): SearchResult[] {
const queryWords = tokenize(query);
// 用于记录每篇文档的得分
const scores = new Map<number, number>();
for (const word of queryWords) {
const matchedDocs = invertedIndex.get(word);
// 如果知识库中没有这个关键词,直接跳过
if (!matchedDocs) continue;
// 包含该关键词的文档数量
const documentFrequency = matchedDocs.length;
// IDF:词越少见,说明它越重要
// 例如 ERR_CONNECTION_RESET 比 “的”、“是” 更有区分度
const idf = Math.log(docs.length / documentFrequency);
for (const item of matchedDocs) {
const { docId, count } = item;
// TF:关键词在当前文档中出现次数越多,相关性越高
const tf = count;
// 简化版 TF-IDF 分数
const score = tf * idf;
scores.set(docId, (scores.get(docId) || 0) + score);
}
}
// 按分数从高到低排序,返回匹配到的文档
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.map(([docId, score]) => {
const doc = docs.find((item) => item.id === docId);
if (!doc) {
throw new Error(`Document ${docId} not found`);
}
return {
...doc,
score,
};
});
}
// 使用示例
//
// 这个查询里,"iPhone" 和 "15" 可以匹配到相关文档;
// 但由于本示例没有真正做中文分词,"续航" 不一定能匹配到
// "电池续航测试表现不错" 这种未用空格切开的中文片段。
//
// 如果想让中文关键词在这个简化示例中稳定命中,
// 可以把文档内容预先写成用空格分隔的形式,例如:
// "iPhone 15 电池 续航 测试 表现 不错"
const invertedIndex = buildInvertedIndex(documents);
const result = keywordSearch("iPhone 15 续航", documents, invertedIndex);
console.log(result);2. Vector Search
Vector Search 中文一般叫 “向量检索”。
它的核心意思是:不是看你和文档里有没有出现一模一样的词,而是看“意思像不像”。
你可以把它理解成:
把用户的问题,和知识库里的每一段内容,都先“翻译”成一串机器更容易比较的数字;然后再去看,谁和这个问题在“语义空间”里更接近。
所以它找的不是“字面相同”,而是“语义相近”。比如,用户问:
笔记本电脑很烫怎么办知识库里未必正好写着“很烫怎么办”,它可能写的是:
笔记本电脑散热不良的常见原因与处理方法虽然两句话字面不一样,但表达的意思很接近。这时候,Vector Search 往往就能把它找出来。这就是它和关键词检索最大的区别:
Keyword Search更看重有没有出现同样的词Vector Search更看重表达的意思是否接近
Vector Search 特别适合处理这些场景:
- 用户说法比较口语化
- 用户和文档用词不一致
- 文档里是规范表述,用户问法很随意
- 想提高“语义召回”能力
不过它也不是万能的。
它的优势是:
- 更懂“意思”
- 能解决同义词、近义表达、口语化表达的问题
它的弱点是:
- 对特别精确的关键词条件,有时不如关键词检索稳定
- 可能会召回“意思有点像,但其实不完全对”的内容
所以在真实系统里,常见做法不是只用它,而是:
Vector Search负责按语义召回Keyword Search负责按关键词兜底
两者结合效果通常更好。
另外关于向量检索,常见的方式有:
- 余弦相似度
- 欧氏距离
- 点积
2-1. 余弦相似度
可以先复习一下数学里面余弦的知识。以前学习余弦是在学习三角函数时期学习的,也就是说,余弦是和 角度 强相关的。
在数学里,一个向量可以想成一根箭头。
- 箭头朝哪边,就是它的方向
- 两根箭头之间形成一个夹角
这时候:
- 夹角越小,说明方向越接近
- 夹角越大,说明方向差得越多
- 如果夹角是 0°,几乎就是同方向
- 如果夹角是 90°,说明它们基本没什么方向关系
- 如果夹角是 180°,说明它们方向完全相反
而余弦恰好就是用来描述这个夹角的。
它有一个很方便的性质:
- cos(0°) = 1
- cos(90°) = 0
- cos(180°) = -1
所以我们就可以用余弦值,直接判断两个向量的方向关系。
因为“方向”本质上就是在看 两个向量夹角有多大。
- 越接近 1,方向越一致
- 越接近 0,方向关联越弱
- 越接近 -1,方向越相反
回到余弦相似度的话题,进行向量检索的时候,一段文本会变成一个向量,一个 query 也会变成一个向量,然后看这两个向量夹角大不大。
- 如果两根箭头朝向几乎一致,那么夹角就会比较小,说明它们表达的意思很接近
- 如果两根箭头方向差很多,那么夹角就会比较大,说明它们意思差得比较远
使用余弦相似度,就能够从语义上去进行搜索
举个通俗例子:
- 苹果15续航咋样
- iPhone 15 的电池续航表现如何
这两句话字面不同,但表达方向很一致,都在问同一件事。
通过余弦相似度,我们可以聚焦到“意思像不像”,而不是文本长一点还是短一点、力度大一点还是小一点。
余弦相似度计算示例代码:
js
/**
* 余弦相似度计算
* @param {*} vecA
* @param {*} vecB
* @returns
*/
function cosineSimilarity(vecA, vecB) {
const dot = vecA.reduce((sum, val, i) => sum + val * vecB[i], 0);
const normA = Math.sqrt(vecA.reduce((sum, val) => sum + val * val, 0));
const normB = Math.sqrt(vecB.reduce((sum, val) => sum + val * val, 0));
return dot / (normA * normB);
}🤔思考:余弦相似度来搜索的时候,算作相似的阀值是多少?
没有固定统一的阈值,在实际项目里,阈值通常是 结合 embedding 模型、数据集分布、业务目标,通过实验调出来的。
余弦相似度常见范围是:
- 1:方向完全一致
- 0:基本无关
- -1:方向完全相反
如果换成角度理解:
- 越接近 0°,越相似
- 越接近 90°,越不相似
- 超过 90°,通常就明显不相关了
在向量检索里,通常不用角度阈值来配,而是直接用余弦相似度分数阈值,因为系统里算出来的就是这个分数。
一个比较实用的经验是:
- > 0.9:通常非常相似
- 0.8 ~ 0.9:通常比较相似
- 0.7 ~ 0.8:可能相关,但要看场景
- < 0.7:很多场景下就开始不太稳了
但这只是经验感受,不是通用标准。因为不同模型的分数分布差异很大,有的 embedding 模型,相关文本普遍能到 0.85 以上,有的模型,相关文本可能 0.65 就已经不错了。
所以阈值到底怎么定,通常有 3 种做法。
先不设死阈值,用 TopK:这种最常见。比如先取最相似的前 5 条或前 10 条,这是很多 RAG 系统的默认做法,比死阈值更稳。
TopK + 最低阈值:更实用。比如先取 Top 10,再过滤掉分数低于 0.75 的内容,这样可以避免把明显不相关的内容也塞进去。
用验证集调阈值:最靠谱。做法是准备一批真实 query,标注哪些召回结果算相关,哪些不相关,分别测试 0.6 / 0.7 / 0.75 / 0.8 / 0.85,看召回率和准确率的平衡。
这样才能找到自己业务里的最佳值。
因此总结一下:工程上一般直接使用相似度分数,而不是角度值,并结合 TopK、验证集和业务效果来确定阈值。
2-2. 欧氏距离
欧氏距离的核心是:直接看两个点在空间里离得有多远。这个概念其实最直观,就是我们平时说的“直线距离”。
比如地图上有两个位置:
- 距离近,说明更相似
- 距离远,说明差异更大
放到向量检索里也是一样:把 query 和文档都变成空间里的点,然后直接量这两个点之间的距离。
- 距离越小,通常说明越相似
- 距离越大,通常说明越不相似
它和余弦相似度的区别是:
- 余弦相似度更看方向,看“你们是不是朝同一个方向走”
- 欧氏距离更看“你们俩现在到底隔了多远”
欧式距离计算示例代码:
js
/**
* 欧氏距离计算
* @param {*} vecA
* @param {*} vecB
* @returns
*/
function euclideanDistance(vecA, vecB) {
const sum = vecA.reduce((acc, val, i) => acc + Math.pow(val - vecB[i], 2), 0);
return Math.sqrt(sum);
}2-3. 点积
点积可以理解成:既看方向是不是一致,也会把向量本身的大小考虑进去。
你可以这么理解,还是想象成两根箭头。
- 如果两根箭头方向一致,而且都比较“长”,那点积会比较大
- 如果方向不一致,点积会变小
- 如果方向相反,点积甚至可能变成负的
所以它不像余弦相似度那样只关心方向,它还会把“长度”也算进去。
为什么有人会用点积?
因为在某些模型里,向量长度本身也可能带有信息。这时候,保留“长度”这个因素,可能有帮助。
但也正因为它把长度算进去了,所以有时候会带来一个现象:有些向量不一定方向最像,但因为它“长度更大”,点积结果也可能很高
总结起来点积就是方向 + 长度一起看。
点积计算示例代码:
js
/**
* 点积计算
* @param {*} vecA
* @param {*} vecB
* @returns
*/
function dotProduct(vecA, vecB) {
return vecA.reduce((sum, val, i) => sum + val * vecB[i], 0);
}3. Hybrid Search
真实系统里,Keyword Search 和 Vector Search 很少二选一,更常见的是混合检索(Hybrid Search)。一个负责“按字找”,一个负责“按意思找”,两边一起找,再把结果合并。
这样做的原因很简单,因为各自擅长的东西不同:
Keyword Search 擅长:
- 产品名
- 型号
- 报错码
- 人名、地名、编号
- 专有术语
- 精确匹配
Vector Search 擅长:
- 同义表达
- 口语化提问
- 语义接近但字面不同
- 问法和文档写法不一致
因此只用 Keyword Search:容易漏掉“换一种说法”的内容,只用 Vector Search:容易召回“意思有点像,但关键词不准”的内容。
这里有一个词,叫做“关键词稀释”,比如用户搜“iPhone 15”,向量模型可能因为“iPhone”这个词太泛,把“iPhone 13”的文档排在前面。这时候 Keyword Search 的硬匹配权重就能起到“纠偏”作用。
举个例子,用户问:
笔记本蓝屏怎么修知识库里可能有两类内容:
- 文档 A:笔记本电脑蓝屏故障排查
- 文档 B:Windows 系统崩溃后的恢复方法
这时:
- Keyword Search 很容易命中 A,因为有“蓝屏”
- Vector Search 可能把 B 也找出来,因为“蓝屏”和“系统崩溃恢复”语义接近
通过两边结合,召回会更全面。
最常见的基本流程
一个 query 进来后,系统会同时做两路检索:
- 一路走 Keyword Search
- 一路走 Vector Search
比如用户问:
苹果15续航咋样真实的 RAG 系统会同时做:
- Keyword Search:找包含 苹果、15、续航、电池 等词的文档
- Vector Search:找语义上和“iPhone 15 电池表现”接近的文档
两路召回结束后,系统通常会拿到两份候选结果,一份来自 Keyword Search,另一份来自 Vector Search。
这时候一般不会简单地把两份结果直接拼接起来,而是会做一次结果融合。
在很多 RAG 系统里,常见的做法是使用 RRF,全称是 Reciprocal Rank Fusion。
3-1. RRF
RRF 可以理解为一种“按名次融合多路召回结果”的方法。
它不直接关心不同检索器给出的原始分数。因为:
- 关键词检索的分数体系和向量检索的分数体系往往不一样
- 两边分数通常不能直接比较
- 直接混排容易失真
所以,RRF 的思路不是比较分数,而是比较排名。
如果某个文档在多路检索结果里都排得比较靠前,那么它最终的融合得分就会更高,也就更有机会进入后续阶段。
可以理解为:
- 在 keyword 结果里排得越靠前,得分越高
- 在 vector 结果里排得越靠前,得分也越高
- 如果同一个文档同时在两边都靠前,它会被优先保留下来
比如还是这个问题:
text
苹果15续航咋样系统会同时做两路召回。
一路是 Keyword Search,返回结果可能是:
文档A:iPhone 15 续航评测文档B:苹果手机电池容量对比文档C:iPhone 15 Pro 发热测试
另一路是 Vector Search,返回结果可能是:
文档C:iPhone 15 Pro 发热测试文档A:iPhone 15 续航评测文档D:手机续航排行榜
你会发现,这两路结果并不一样。
Keyword Search更容易把“字面上匹配得比较准”的内容找出来Vector Search更容易把“语义上相关”的内容找出来
这时候,如果只是简单把两份结果直接拼在一起,其实不太合理。因为两边的分数体系本来就不一样,没法直接硬比。所以这时候就会用 RRF 来做融合。
它的思路很简单,不怎么盯着“原始分数”,而是更关心“你排第几”。
比如上面这组结果里:
文档A在关键词检索里排第 1,在向量检索里排第 2文档C在关键词检索里排第 3,在向量检索里排第 1文档B只在关键词检索里比较靠前文档D只在向量检索里出现
那融合的时候,RRF 会更偏向那些在多路结果里都排得不错的文档。
所以最后融合出来的候选结果,往往会更像这样:
文档A文档C文档B文档D
原因也很直观:
文档A两边都靠前,说明它既有关键词命中,也有语义相关性文档C也是类似,虽然关键词名次没那么高,但在向量检索里排得很前文档B只在关键词检索里表现好文档D只在向量检索里表现好
你可以把 RRF 理解成一种“综合排名”机制,不是看谁只在某一路特别强,而是看谁在多路召回里都表现不错。
这样融合出来的结果,通常会比只看单一路检索更稳一些。
这里顺手补一句,别把 RRF 和 Rerank 混在一起了。虽然它们看起来都像是在“处理检索结果”,但它们干的其实不是一回事。
RRF 做的是多路召回结果的融合。也就是 keyword 找回来一批,vector 找回来一批,然后想办法把这几路结果合成一份更合理的候选集。所以它更适合放在检索阶段内部去理解,因为它本质上还是在处理“召回”这件事。
而 Rerank 做的则是进一步精排。也就是前面已经有了一批候选结果,然后再用更强一点的方式,重新判断一下谁应该排在前面。
你可以简单理解成:
- RRF 解决的是:多路结果怎么合并
- Rerank 解决的是:合并完以后,谁应该排得更靠前
所以这两个步骤虽然经常前后挨着出现,但它们不是同一个东西。如果你非要粗暴地记:
- RRF 更偏向“检索阶段里的融合”
- Rerank 更偏向“检索之后的精排”
这样理解,通常就不容易混了。
3-2. 示例代码
概念清楚了,接下来就看一下相关的示例代码:
ts
type Document = {
id: string;
text: string;
};
type SearchResult = {
id: string;
text: string;
score: number;
};
const documents: Document[] = [
{ id: "doc1", text: "iPhone 15 续航评测:日常使用可坚持一整天" },
{ id: "doc2", text: "苹果手机电池容量对比:iPhone 14 vs iPhone 15" },
{ id: "doc3", text: "iPhone 15 Pro 发热测试与游戏表现" },
{ id: "doc4", text: "2024 年热门手机续航排行榜" },
];
const query = "苹果15续航咋样";
/**
* 1. Keyword Search
* 这里只做一个非常简化的关键词匹配示例,方便教学。
* 真正项目里通常会用 BM25 / 全文检索引擎。
*/
function keywordSearch(query: string, docs: Document[]): SearchResult[] {
const keywords = ["苹果", "15", "续航", "电池", "iPhone"];
return docs
.map((doc) => {
let score = 0;
for (const kw of keywords) {
if (query.includes(kw) || doc.text.includes(kw)) {
if (doc.text.includes(kw)) {
score += 1;
}
}
}
return {
id: doc.id,
text: doc.text,
score,
};
})
.sort((a, b) => b.score - a.score);
}
/**
* 2. Vector Search
* 这里不用真实 embedding,直接手写一个“模拟语义分数”,目的是让课堂上更容易看懂流程。
*
* 真正项目里这里通常是:
* - 调 embedding 模型
* - 把 query 转成向量
* - 到向量库做相似度检索
*/
function vectorSearch(query: string, docs: Document[]): SearchResult[] {
const semanticScores: Record<string, number> = {
doc1: 0.91,
doc2: 0.72,
doc3: 0.88,
doc4: 0.79,
};
return docs
.map((doc) => ({
id: doc.id,
text: doc.text,
score: semanticScores[doc.id] ?? 0,
}))
.sort((a, b) => b.score - a.score);
}
/**
* 3. RRF 融合
* RRF = Reciprocal Rank Fusion
*
* 它不直接比较不同检索器的原始分数,而是根据“排名”来融合结果。
*/
function rrfFusion(rankLists: SearchResult[][], k = 60): SearchResult[] {
const rrfScores = new Map<string, number>();
const docMap = new Map<string, string>();
for (const rankList of rankLists) {
rankList.forEach((item, index) => {
const rank = index + 1;
const prevScore = rrfScores.get(item.id) ?? 0;
const addScore = 1 / (k + rank);
rrfScores.set(item.id, prevScore + addScore);
docMap.set(item.id, item.text);
});
}
return Array.from(rrfScores.entries())
.map(([id, score]) => ({
id,
text: docMap.get(id) ?? "",
score,
}))
.sort((a, b) => b.score - a.score);
}
// 执行流程
const keywordResults = keywordSearch(query, documents);
const vectorResults = vectorSearch(query, documents);
const hybridResults = rrfFusion([keywordResults, vectorResults]);
console.log("=== Keyword Search ===");
console.table(keywordResults);
console.log("=== Vector Search ===");
console.table(vectorResults);
console.log("=== Hybrid Search with RRF ===");
console.table(hybridResults);
// 最终只取前 topK 个候选结果
const topK = 3;
const finalCandidates = hybridResults.slice(0, topK);
console.log("=== Final Candidates ===");
console.table(finalCandidates);4. metadata filtering
metadata filtering 严格来讲是位于正式召回之前或召回过程中,不过它并不是做 query 改写,而是对检索范围施加约束。因此这里我把它归到“检索阶段”更合适。
比如,一个知识库里的每条文档,除了正文内容之外,往往还会带一些附加信息,这些附加信息就是 metadata。
常见的 metadata 有:
- 文档标题
- 作者
- 来源
- 时间
- 部门
- 标签
- 文档类型
- 产品线
- 权限范围
这些信息本身未必是正文内容,但可以帮助系统更精准地控制检索范围。例如用户问:
报销流程怎么走如果系统不加限制,可能会搜出很多结果:
- 行政报销流程
- 差旅报销流程
- 采购报销流程
- 去年的旧制度
- 其他部门的流程文档
这时如果加上 metadata filtering,比如:
- 部门 = 销售部
- 文档类型 = 制度
- 年份 = 2025
- 状态 = 生效中
那么系统就不是在全库里乱搜了,而是先筛出“符合这些条件的文档”,再在这批文档里做检索。这样召回结果通常会更准。
所以你可以把它理解成:metadata filtering 不是在改写问题,也不是直接算语义相似度,而是在检索时先加一层过滤条件,避免搜得太散、太乱。
它特别适合这些场景:
- 知识库很大,内容来源很多
- 同一个问题在不同部门、不同产品线下答案不同
- 需要限定时间范围
- 需要做权限控制
- 需要只在某类文档中搜索
比如:
- 只搜产品文档
- 只搜最近 1 年
- 只搜某个知识库
- 只搜当前用户有权限看的内容
它的好处很明显:
- 缩小检索范围
- 降低噪声
- 提高结果准确率
- 让召回更符合业务上下文
不过它也有一个前提:前提是你的文档在入库时,已经把这些元数据整理好了。如果文档没有打标签、没有时间字段、没有来源字段,那你就没法很好地做 metadata filtering。
来看一段示例代码:
ts
type Doc = {
id: string;
text: string;
metadata: {
department: string;
docType: string;
year: number;
status: "active" | "inactive";
knowledgeBase: string;
allowedRoles: string[];
};
};
const documents: Doc[] = [
{
id: "doc1",
text: "销售部差旅报销流程:员工提交申请后,由主管审批,再进入财务复核。",
metadata: {
department: "sales",
docType: "policy",
year: 2025,
status: "active",
knowledgeBase: "company-policy",
allowedRoles: ["sales", "finance", "admin"],
},
},
{
id: "doc2",
text: "采购部报销制度:采购相关费用需要附上合同、发票和验收记录。",
metadata: {
department: "procurement",
docType: "policy",
year: 2025,
status: "active",
knowledgeBase: "company-policy",
allowedRoles: ["procurement", "finance", "admin"],
},
},
{
id: "doc3",
text: "销售部 2024 年旧版报销制度:历史流程存档,仅供参考。",
metadata: {
department: "sales",
docType: "policy",
year: 2024,
status: "inactive",
knowledgeBase: "company-policy",
allowedRoles: ["sales", "finance", "admin"],
},
},
{
id: "doc4",
text: "销售部产品报价审批流程:适用于大客户折扣申请,不属于报销流程。",
metadata: {
department: "sales",
docType: "workflow",
year: 2025,
status: "active",
knowledgeBase: "sales-handbook",
allowedRoles: ["sales", "admin"],
},
},
];
const query = "报销流程怎么走";
// 当前用户的业务上下文
const currentUser = {
department: "sales",
role: "sales",
};
/**
* 用 metadata 先过滤文档范围
* 这一步不是在改写 query,而是在限制“去哪里搜”
*/
function metadataFilter(docs: Doc[], user: typeof currentUser): Doc[] {
return docs.filter((doc) => {
// 只看当前部门
if (doc.metadata.department !== user.department) {
return false;
}
// 只看制度类文档
if (doc.metadata.docType !== "policy") {
return false;
}
// 只看 2025 年
if (doc.metadata.year !== 2025) {
return false;
}
// 只看生效中的文档
if (doc.metadata.status !== "active") {
return false;
}
// 权限控制:当前用户必须有权访问
if (!doc.metadata.allowedRoles.includes(user.role)) {
return false;
}
return true;
});
}
/**
* 一个非常简化的关键词检索
* 这里只是为了演示“过滤之后再检索”的流程
*/
function keywordSearch(query: string, docs: Doc[]) {
const keywords = ["报销", "流程", "审批", "财务"];
return docs
.map((doc) => {
let score = 0;
for (const keyword of keywords) {
if (query.includes(keyword) && doc.text.includes(keyword)) {
score += 1;
}
}
return {
id: doc.id,
text: doc.text,
metadata: doc.metadata,
score,
};
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score);
}
// 1. 先做 metadata filtering
const filteredDocs = metadataFilter(documents, currentUser);
// 2. 再在过滤后的文档范围里做检索
const results = keywordSearch(query, filteredDocs);
console.log("=== 过滤前的文档数量 ===");
console.log(documents.length);
console.log("=== metadata filtering 后的文档数量 ===");
console.log(filteredDocs.length);
console.log("=== 最终检索结果 ===");
console.table(results);5. 相关面试题
题目1
题干
为什么很多 RAG 系统不会只用 Vector Search,而是会做 Hybrid Search?
题目分析
理想回答通常会涉及:
- Vector Search 擅长语义召回,但对型号、错误码、专有名词、精确关键词不稳定
- Keyword Search 擅长精确匹配,但不擅长同义词、近义表达、口语化表达
- Hybrid Search 的本质是多路召回:BM25 / Keyword + Vector
- 融合阶段通常会提到 RRF、加权融合、去重、再排序
- 工程上常见链路是:双路召回 -> 融合 -> rerank -> 送给 LLM
- 如果只停留在“因为 hybrid 更好”,通常不够,需要说明“为什么更好、在哪些 query 上更好”
这题很常见,因为它同时考察你对 检索原理 和 生产落地经验 的理解。
参考答案
如果只用 Vector Search,它确实很适合做语义召回,比如用户问得很口语化、和文档表达不一致的时候,它通常比关键词检索强。但问题是,它对一些特别精确的信息不一定稳定,比如产品型号、错误码、专有名词、接口名这种内容,有时候关键词检索反而更准。
所以真实系统里更常见的是 Hybrid Search,也就是关键词检索和向量检索一起做。前者负责“按字面精确命中”,后者负责“按语义补召回”。这样一来,既能保证召回更全,也能避免一些关键术语漏掉。
工程上一般就是两路召回之后再做融合,比如用 RRF、加权排序,或者再接一层 rerank。我的理解是,Hybrid Search 本质上不是为了炫技,而是因为真实 query 既有语义成分,也有精确匹配成分,只靠单一路线通常不够稳。
题目2
题干
你需要设计一个 RAG 检索模块,要求同时支持:
- Keyword Search(BM25)
- Vector Search(embedding)
- 最终返回 TopK 结果给 LLM
请说明:
- 两种检索如何组合(流程)
- 是否需要使用 RRF?为什么?
- 是否需要 Rerank?放在哪一步?为什么?
题目分析
这题是一个典型的 RAG 检索链路设计题,核心考察三点:
- 是否理解多路召回(Hybrid Search),不是只会讲 BM25 是啥,Vector Search 是啥
而是要能说清:为什么一定要“两路一起用”
关键点:
- Keyword → 精确匹配(型号 / 错误码 / 专有名词)
- Vector → 语义召回(同义表达 / 口语 query)
面试官在看你有没有“真实系统思维”,而不是背概念。
- 是否理解“融合层”(RRF)
重点不是记公式,而是理解:为什么不能直接 merge 或拼接?
核心点:BM25 和 Vector 的 score 不可比,所以不能直接加权,RRF 用 rank 融合 → 工程上更稳定
这一点很多人答不出来,是区分度点
- 是否理解“召回 vs 精排”(Rerank)
这题一定要说清:
| 阶段 | 作用 |
|---|---|
| 多路召回 | 尽量别漏(Recall) |
| RRF | 合并候选 |
| Rerank | 排得更准(Precision) |
- 是否有工程链路意识
这非常关键,面试官真正想听的是这一整条链路:
Query
↓
BM25 + Vector(并行)
↓
RRF 融合
↓
Rerank(可选)
↓
TopK → LLM如果你能主动说出这一整条链路,基本是加分项。
参考答案
一般做法是这样的:首先在检索阶段会同时走两路,一路是 BM25 做关键词检索,负责精确匹配,比如型号、错误码、专有名词;另一路是 Vector Search,负责语义召回,解决用户表达和文档不一致的问题。
这两路会各自返回一个 TopK,然后进入融合阶段。
这里通常不会直接用 score 合并,因为 BM25 和向量检索的分数是不可比的,所以工程上更常用的是 RRF,通过 rank 来融合结果,保证多路都命中的文档优先。
融合之后,一般还会加一层 Rerank,用更强的模型对候选结果做语义级精排,这一步主要是提升排序准确性。但 Rerank 成本比较高,所以通常只对融合后的 TopK 做。
最终再选 TopN 的结果送给 LLM。
总结一下就是:
BM25 + Vector 做多路召回保证不漏,RRF 做融合,Rerank 做精排,这是目前比较标准的一条 RAG 检索链路。
题目3
题干
metadata filtering 在 RAG 检索里有什么作用?它应该放在检索前、检索中还是检索后?
题目分析
理想回答通常会涉及:
- metadata filtering 不是 query rewrite,也不是相似度算法
- 它的核心作用是:对检索范围施加约束,减少噪声
- 常见过滤条件包括:
- source
- time
- doc_type
- department
- author
- permission scope
- 严格说它可以发生在检索前或检索过程中
- 需要说明它依赖索引阶段先把 metadata 准备好
- 如果候选人能补充“权限控制、时效性过滤、租户隔离”这些业务场景,会加分
这题很能区分只懂概念的人和真正做过系统的人,因为它考察的是 结构化约束能力。
参考答案
我理解 metadata filtering 的核心作用,不是去判断“这段内容和 query 像不像”,而是先限制“应该在哪个范围里搜”。比如只搜某个知识库、某个时间范围、某类文档、某个部门,甚至某个用户有权限看到的数据。
所以它本质上是一种检索约束手段,目的是减少噪声、提高结果准确率。比如用户问报销流程,如果不做过滤,可能会把不同部门、不同年份、甚至已失效的制度都召回出来,那结果就会很乱。
至于它放在哪,我觉得严格说它既可能发生在正式召回之前,也可能嵌在检索过程中一起执行。但如果是做系统分层,我会更倾向把它归到“检索阶段”,因为它本质上不是 query 改写,也不是生成后处理,而是检索执行时的约束条件。前提当然是索引阶段已经把 metadata 准备好了。
-EOF-