Skip to content

检索操作相关技术名词part1 ​

接下来我们来看一下和检索操作相关的技术名词,以及具体的落地方针、还有相关的面试题。

由于内容比较多,这里拆解成上下两篇。

和检索相关的技术名词有:

  • Keyword Search
  • Vector Search
  • Metadata Filtering
  • Small-to-Big Retrieval
  • Hypothetical Retrieval

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)的默认核心算法之一。

当用户输入一个查询:

笔记本 蓝屏 怎么 修

搜索引擎会做两件事:

  1. 找出包含这些词的文档
  2. 用 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);

Vector Search 中文一般叫 “向量检索”。

它的核心意思是:不是看你和文档里有没有出现一模一样的词,而是看“意思像不像”。

你可以把它理解成:

把用户的问题,和知识库里的每一段内容,都先“翻译”成一串机器更容易比较的数字;然后再去看,谁和这个问题在“语义空间”里更接近。

所以它找的不是“字面相同”,而是“语义相近”。比如,用户问:

笔记本电脑很烫怎么办

知识库里未必正好写着“很烫怎么办”,它可能写的是:

笔记本电脑散热不良的常见原因与处理方法

虽然两句话字面不一样,但表达的意思很接近。这时候,Vector Search 往往就能把它找出来。这就是它和关键词检索最大的区别:

  • Keyword Search 更看重有没有出现同样的词
  • Vector Search 更看重表达的意思是否接近

Vector Search 特别适合处理这些场景:

  • 用户说法比较口语化
  • 用户和文档用词不一致
  • 文档里是规范表述,用户问法很随意
  • 想提高“语义召回”能力

不过它也不是万能的。

它的优势是:

  • 更懂“意思”
  • 能解决同义词、近义表达、口语化表达的问题

它的弱点是:

  • 对特别精确的关键词条件,有时不如关键词检索稳定
  • 可能会召回“意思有点像,但其实不完全对”的内容

所以在真实系统里,常见做法不是只用它,而是:

  • Vector Search 负责按语义召回
  • Keyword Search 负责按关键词兜底

两者结合效果通常更好。

另外关于向量检索,常见的方式有:

  1. 余弦相似度
  2. 欧氏距离
  3. 点积

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 种做法。

  1. 先不设死阈值,用 TopK:这种最常见。比如先取最相似的前 5 条或前 10 条,这是很多 RAG 系统的默认做法,比死阈值更稳。

  2. TopK + 最低阈值:更实用。比如先取 Top 10,再过滤掉分数低于 0.75 的内容,这样可以避免把明显不相关的内容也塞进去。

  3. 用验证集调阈值:最靠谱。做法是准备一批真实 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);
}

真实系统里,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,返回结果可能是:

  1. 文档A:iPhone 15 续航评测
  2. 文档B:苹果手机电池容量对比
  3. 文档C:iPhone 15 Pro 发热测试

另一路是 Vector Search,返回结果可能是:

  1. 文档C:iPhone 15 Pro 发热测试
  2. 文档A:iPhone 15 续航评测
  3. 文档D:手机续航排行榜

你会发现,这两路结果并不一样。

  • Keyword Search 更容易把“字面上匹配得比较准”的内容找出来
  • Vector Search 更容易把“语义上相关”的内容找出来

这时候,如果只是简单把两份结果直接拼在一起,其实不太合理。因为两边的分数体系本来就不一样,没法直接硬比。所以这时候就会用 RRF 来做融合。

它的思路很简单,不怎么盯着“原始分数”,而是更关心“你排第几”。

比如上面这组结果里:

  • 文档A 在关键词检索里排第 1,在向量检索里排第 2
  • 文档C 在关键词检索里排第 3,在向量检索里排第 1
  • 文档B 只在关键词检索里比较靠前
  • 文档D 只在向量检索里出现

那融合的时候,RRF 会更偏向那些在多路结果里都排得不错的文档。

所以最后融合出来的候选结果,往往会更像这样:

  1. 文档A
  2. 文档C
  3. 文档B
  4. 文档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

请说明:

  1. 两种检索如何组合(流程)
  2. 是否需要使用 RRF?为什么?
  3. 是否需要 Rerank?放在哪一步?为什么?

题目分析

这题是一个典型的 RAG 检索链路设计题,核心考察三点:

  1. 是否理解多路召回(Hybrid Search),不是只会讲 BM25 是啥,Vector Search 是啥

而是要能说清:为什么一定要“两路一起用”

关键点:

  • Keyword → 精确匹配(型号 / 错误码 / 专有名词)
  • Vector → 语义召回(同义表达 / 口语 query)

面试官在看你有没有“真实系统思维”,而不是背概念。

  1. 是否理解“融合层”(RRF)

重点不是记公式,而是理解:为什么不能直接 merge 或拼接?

核心点:BM25 和 Vector 的 score 不可比,所以不能直接加权,RRF 用 rank 融合 → 工程上更稳定

这一点很多人答不出来,是区分度点

  1. 是否理解“召回 vs 精排”(Rerank)

这题一定要说清:

阶段作用
多路召回尽量别漏(Recall)
RRF合并候选
Rerank排得更准(Precision)
  1. 是否有工程链路意识

这非常关键,面试官真正想听的是这一整条链路:

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-