《走进 AI Agent 第三篇:用户记忆系统》处理了一个问题:怎么让 Agent 在对话结束后仍然记住用户,记住他的偏好、习惯、账号和关系。接下来的两篇文章处理另一半:怎么让 Agent 记住知识,在某个领域里成为"专家",即读懂行业法规、公司流程、专业文档,并在需要时准确地把相关知识取回来用上。
如果没有读过第三篇,这里先补三句话作为衔接。我们讨论的是同一个框架:Agent = 决策引擎 + 信息视野 + 执行通道。其中"信息视野"包含六个层次:系统提示词、工具定义、用户记忆、领域知识、任务轨迹、当前输入。前两层相对静态,后两层随会话动态增长。中间的两层,即"用户记忆"与"领域知识",恰是跨会话的持久化信息。第三篇深入了"用户记忆",这两篇深入"领域知识"。
两者解决的是同一个问题,即跨会话的持久化知识。差别只在尺度,一个关注个体,一个关注群体。最终,两条线索会在"双层记忆架构"处汇合:用结构化记忆常驻上下文提供"概览",用上下文感知检索按需取回"细节"。两者共同支撑起最高级别的主动服务能力。
考虑到篇幅,"让 Agent 成为领域专家"拆为上下两篇。上篇(本篇)走完知识获取管道 :RAG 概述、文档分块、稠密嵌入、稀疏嵌入、混合检索与重排序、多模态信息提取,回答"知识怎么进来、怎么被检索"。下篇处理知识的组织与深度检索:RAPTOR 与 GraphRAG 的结构化索引、OpenViking 的文件系统范式、知识库的时效治理、智能体化 RAG、上下文感知检索、从结构化数据中挖掘隐性知识,最后让两条线索在双层记忆架构处汇合。
本篇不假设你有算法或数学背景。涉及的每个概念都先给直觉和类比,再给细节,示例代码统一使用 TypeScript。每个技术点都配有配套项目的核心实现代码,读完原理可以立刻对照代码加深理解。读到不懂的地方不必纠结,先往下走,很多概念会在后文反复出现,第二次见到时会豁然开朗。
文中提到的 dense-embedding、sparse-embedding、retrieval-pipeline、multimodal-agent 等项目,后续会通过开源项目的方式提供出来,欢迎关注我。文中代码为各项目核心实现的节选,为便于阅读做了简化,完整代码可按仓库说明直接运行。
一、 知识获取管道:RAG 基础
构建共享知识库的核心技术是检索增强生成(Retrieval-Augmented Generation, RAG)。其核心思想是将大型语言模型的思考和生成能力,与外部知识库的广度和时效性相结合。模型本身的训练数据有截止日期,而知识库可以随时更新。
这一部分从 RAG 的基本概念出发,依次讨论文档分块、稠密嵌入、稀疏嵌入、混合检索与重排序、多模态信息提取。这些内容构成一条完整的知识获取管道。
(一) RAG 概述:让模型利用它没见过的知识
1. 检索器 + 生成器
典型的 RAG 系统由两部分构成:检索器 负责从知识库里找出相关片段,生成器(通常是 LLM)拿到这些片段作为上下文来生成答案。
对前端同学来说,最直观的类比是:RAG 就像给组件接了一个外部数据源。组件(模型)本身一行代码不用改,只要换一份数据(知识库),渲染出来的内容(回答)就完全不同。
先通过两个例子直观感受 RAG 的工作方式。
例 1:维基百科知识库。 用户问"量子纠缠是什么?",基座模型的训练数据可能不包含最新的实验进展。RAG 的流程如下:
typescript
// 1. 用户提问
const query = "量子纠缠是什么?最新的实验进展有哪些?";
// 2. 检索:从维基百科知识库中找到最相关的片段
const results = await retriever.search(query, { topK: 3 });
// results = [
// "量子纠缠是一种量子力学现象,两个粒子的量子态相互关联...",
// "2022年诺贝尔物理学奖授予量子纠缠实验验证的三位科学家...",
// "贝尔不等式实验证明了量子纠缠的非局域性..."
// ]
// 3. 生成:将检索结果作为上下文,让 LLM 生成答案
const answer = await llm.generate({
system: "根据以下参考资料回答用户问题。如果资料不足,明确说明。",
context: results, // ← 检索到的知识片段注入上下文
question: query,
});
例 2:公司知识库。 用户问"我买的东西想退款,流程是什么?":
typescript
const query = "退款流程";
const results = await retriever.search(query, { topK: 2 });
// results = [
// "退款政策:订单签收后7天内可申请全额退款,需提供订单号。退款将在3-5个工作日内...",
// "退款操作步骤:1.进入'我的订单' 2.选择需退款的订单 3.点击'申请退款'..."
// ]
const answer = await llm.generate({
system: "你是客服助手。",
context: results,
question: query,
});
// → "您可以在签收后7天内申请全额退款。操作步骤:进入'我的订单'→选择订单→点击'申请退款'..."
两个例子的模式完全一致:检索相关片段 → 注入上下文 → LLM 基于上下文生成答案。
2. RAG 的核心价值
RAG 的核心价值在于让 LLM 能利用它训练时没见过的知识(维基百科的最新内容、公司的内部文档),而不需要重新训练模型。这背后是一个关键的工程判断:短期内提升 Agent 能力的最有效方式,不是换更强的模型,而是扩展它的接口边界。RAG 正是在信息视野这个维度上扩展接口边界,让 Agent 能"看到"训练数据之外的知识。
RAG vs 微调:有人会问,为什么不直接用新知识微调模型?原因有三:(1) 微调成本高、周期长,而 RAG 只需更新知识库;(2) 微调后的知识是"隐式"的,无法溯源和审计,而 RAG 的检索结果可以追溯到具体文档;(3) 知识会频繁更新,微调无法实时跟进。Rich Sutton 的"苦涩的教训"告诉我们通用方法终将胜出。RAG 正是一种通用的知识接入方式,不依赖特定模型的训练。

检索器的质量直接决定了 RAG 的效果。如果检索不到相关片段,LLM 再强也无米之炊。本节先看文档进入知识库的第一道工序,即分块。然后重点看检索器的两大技术路线:稠密嵌入(基于语义理解)和稀疏嵌入(基于关键词匹配),以及如何将二者结合起来。
(二) 文档分块(Chunking)
图 1-1 展示的是 RAG 在查询时的核心流程:检索、增强、生成。但在能够检索之前,还有一步不可或缺的离线预处理,即分块(Chunking)。它把长文档切成适合独立检索的片段(chunk)。
1. 为什么需要分块
分块之所以必要,原因有二:
-
嵌入模型对输入长度有限制。一整篇文档只压缩成一个向量时,多个主题混在一起,向量无法精确表达其中任何一个。这与第三篇 Enhanced Notes 遇到的问题同源:段落越长,嵌入越难抓住重点。
-
检索的目标是只把相关的那部分注入上下文。片段太大,会连带大量无关内容,浪费窗口并稀释注意力。
2. 三种分块策略
固定大小切分:最简单的方法,按固定的 token 数(如 512)切分,通常在相邻块之间保留一定重叠(如 50-100 token),避免关键句子恰好在边界处被切断。这里补充一句:token 可以粗略理解为"模型眼中的字词片段",一个汉字大约占 1-2 个 token,一个英文单词大约占 1 个,后文反复出现的"token 成本""窗口长度"都以它为计量单位。固定大小切分实现简单、结果可预测,但完全无视文档结构。一个段落、一段代码、一张表格都可能被拦腰截断。
递归/结构感知切分:按文档的自然边界(章节标题、段落、句子)递归切分。先尝试按大边界切,块仍超长时再降级到更小的边界。Markdown、HTML 这类有显式结构的文档尤其适合。这是目前生产系统最常用的默认选择。
语义切分:计算相邻句子的嵌入相似度,在语义"断崖"处下刀,也就是相似度骤降的位置,使每个块内部主题尽量单一。切分质量更高,代价是需要额外的嵌入计算。
3. 块大小与重叠的权衡
块大小与重叠量的选择是一对典型权衡。块太小,单块信息不完整,脱离上下文后语义模糊。例如"该公司收入增长了 3%",读者无从知道这是哪家公司、哪个季度。块太大,一个块混杂多个主题,嵌入向量被稀释,检索精度下降,命中后还会带入更多无关内容。
实践中常见的起点是每块 256-1024 token、相邻块重叠 10%-20%,再根据检索质量实测调优。
不过,无论采用哪种策略,分块都会切断片段与其原始上下文的联系。例如"该公司"指代谁、这段话出自哪份报告,这些信息都留在了块的外面。这是分块的固有缺陷,也是下篇"上下文感知检索"一节要正面解决的问题,届时我们会看到它如何被修补。
(三) 稠密嵌入:从词汇关联到语义理解
1. 什么是嵌入(Embedding)
计算机只能处理数字,不能直接理解"苹果"和"橙子"的含义。嵌入的思路是:把每个词或句子转化成一串数字(称为"向量",比如 [0.2, -0.5, 0.8, ...]),并且让语义相近的内容转化出来的数字串也"相近"。
前端同学可以类比颜色的 RGB 表示:颜色本身是人眼的感受,但表示成 #FF5733 这样的数值后,计算机就能计算"两种颜色有多接近"。嵌入对文本做的是同样的事------语义相近的文本,就像色相相近的颜色,数值也接近。
这些向量所在的数学空间称为"向量空间",可以把它想象成一张高维地图,每个词或句子都是其中一个点。语义越接近的内容彼此就越靠近,如同北京和上海在地图上的位置反映它们的地理邻近关系。经典例子是:"国王" - "男性" + "女性" ≈ "女王",说明向量运算可以捕捉到语义关系。
"稠密"是相对于后面将介绍的"稀疏嵌入"而言:稠密向量的每个维度都有数值,稀疏向量大部分维度为零。
2. 余弦相似度
稠密嵌入用深度学习把文本映射到向量空间,语义相近的内容,向量距离也近。衡量两个向量有多"近"的常用方法是余弦相似度:它计算两个向量夹角的余弦值,值越接近 1 表示方向越一致、语义越相似。
为什么用夹角而不是距离?因为我们关心的是两个向量的方向 是否一致(语义是否相近),而不是它们的长度(文本的长度或频率)。两篇内容相同但长度不同的文档,向量长度不同但方向一致,余弦相似度能正确判断它们语义相同。
直觉上可以这样理解:两段语义相近的文本,向量夹角越小就越相似。例如养猫相关的两个表达在向量空间中几乎重合(余弦值接近 1),而养猫和股票投资则方向迥异(余弦值接近 0)。实际的嵌入模型使用 768 维甚至更高维度的向量,但判断"是否相似"的原理完全相同。
想亲手验证这个直觉的话,这里给出一个可跳过的手算示例。设在一个简化的 3 维向量空间中,"如何养猫"的嵌入向量为 A = (0.9, 0.5, 0.1)、"猫咪饲养指南"为 B = (0.8, 0.6, 0.1)、"股票投资策略"为 C = (0.1, 0.1, 0.9),余弦相似度的计算公式为:
cos(θ)=∣A∣×∣B∣A⋅B
其中 A·B 是点积(对应维度相乘再求和),|A| 是向量的模(各维度平方和的平方根)。代入计算:A 与 B 的点积 = 0.9×0.8 + 0.5×0.6 + 0.1×0.1 = 1.03,|A| ≈ 1.03,|B| ≈ 1.00,余弦值约 0.99 (非常相似);A 与 C 的点积 = 0.9×0.1 + 0.5×0.1 + 0.1×0.9 = 0.23,|C| ≈ 0.91,余弦值仅约 0.25(差异很大)。0.99 与 0.25 的对照,清晰地反映了语义距离。
3. 从 Word2Vec 到上下文感知

在稠密嵌入的早期,以 Word2Vec 为代表的技术通过分析海量文本中词汇的共现关系,为每个词生成一个固定向量。这种向量能捕捉有趣的语言规律,比如前面提到的向量运算"king - man + woman ≈ queen",证明词向量空间能以线性可计算的方式编码复杂语义关系。
然而,静态词向量存在根本局限,即无法处理一词多义 。"bank"在"river bank"(河岸)和"investment bank"(投资银行)中含义截然不同,但 Word2Vec 赋予完全相同的向量。
现代嵌入模型(如 BERT、BGE-M3)能在生成一个词的向量时充分考虑其所在的整个句子甚至段落的上下文。这得益于自注意力(Self-Attention)机制。模型在计算每个词的向量时,会同时参考句子中所有其他词的信息。因此,同一个词"苹果"在"苹果公司发布新产品"和"买了两斤苹果"中会得到不同的向量表示。
这意味着同一个词在不同语境下会拥有不同的、更精确的向量表示,实现了从"词汇级"到"语境级"语义的飞跃。此外,BGE-M3 等新一代模型还进一步支持多语言与长文本输入,BERT 这类较早的上下文模型的输入长度上限仅为 512 个 token,并不适合长文本。
4. ANN 索引算法:在海量向量中快速查找
当知识库有上百万条文档时,逐一计算相似度太慢------相当于每次查询都把整个数据库全表扫描一遍。近似最近邻(Approximate Nearest Neighbor, ANN)算法通过巧妙的索引结构实现近似但极快的查找,思路与数据库索引相同:牺牲一点点精确性,换来数量级的速度提升。

以图 1-3 展示的 HNSW 为例。它把向量组织成一张多层图。最上层稀疏,只保留少数"长程连接",如同高速公路网;最下层稠密,包含全部节点。查询从顶层开始,先靠长程连接大步逼近目标区域,再逐层向下细化,最终以 O(log N) 量级的计算完成查找。新增向量可以直接插入图中,无需推倒重建。
实际落地时该选 ANNOY 还是 HNSW,dense-embedding 项目提供了一个直观的对照:它提供 ANNOY 和 HNSW 两种可切换的后端,可以直接观察两类主流 ANN 算法在实践中的区别。表 1-1 从构建速度、内存占用、增量更新、查询精度和适用场景五个维度做了对比。选择合适的索引策略与选择嵌入模型同等重要,它直接决定了系统的性能、成本和可维护性。
表 1-1 ANNOY 与 HNSW 索引算法对比
| 特性 | ANNOY(基于树) | HNSW(基于图) |
|---|---|---|
| 构建速度 | 快 | 较慢 |
| 内存占用 | 低 | 较高 |
| 增量更新 | 不支持(需完全重建) | 支持(但长期增量插入后建议定期重建以保持查询精度) |
| 查询精度 | 较高 | 极高 |
| 适用场景 | 数据不常变的静态数据集 | 需要实时索引新信息的动态场景 |
这个差异会在下篇"知识库的时效与治理"一节产生现实后果:为频繁更新的知识库选错了索引结构,运维成本会被重建开销拖垮。
5. 动手实现:可切换后端的向量检索服务
dense-embedding 项目的重点不在于实现本身,而在于对比:同一套接口下切换 ANNOY 和 HNSW 两种后端,直接观察表 1-1 中每一行差异。核心结构如下(为便于阅读做了简化):
typescript
// dense-embedding:统一接口 + 可切换后端
interface VectorBackend {
build(vectors: Array<{ id: string; vec: number[] }>): void;
insert(id: string, vec: number[]): void;
search(query: number[], topK: number): Array<{ id: string; score: number }>;
}
// 通过配置切换两种索引结构,调用方无感知
function createBackend(name: "annoy" | "hnsw", dims: number): VectorBackend {
switch (name) {
case "annoy":
return new AnnoyBackend(dims); // 基于树:构建快、内存低,不支持增量插入
case "hnsw":
return new HnswBackend(dims); // 基于图:支持实时插入,内存开销更高
}
}
const index = createBackend("hnsw", 768);
// 写入:文档分块 → 嵌入 → 入库
for (const chunk of chunks) {
index.insert(chunk.id, await embed(chunk.text));
}
// 查询:同一接口下对比两种后端的召回质量与延迟
console.time("search");
const hits = index.search(await embed(userQuery), 10);
console.timeEnd("search");
这段代码有两个值得留意的设计。一是接口隔离 :VectorBackend 把"向量检索"抽象成 build、insert、search 三个动作,后端可以像换数据库驱动一样替换------这正是表 1-1 能做公平对比的前提。二是对比的观察点:项目内置了基准脚本,分别测量两种后端的构建耗时、内存占用、增量插入支持和 top-k 召回重合度。跑一遍就会发现,表 1-1 不是抄来的结论,而是可以亲手复现的实测数据。
(四) 稀疏嵌入:精确匹配的关键词检索
与捕捉语义相似性的稠密嵌入不同,稀疏嵌入(Sparse Embedding)根植于传统信息检索,核心是精确的关键词匹配。它将文档表示为极高维度的向量,绝大多数维度为零,只有与文档中出现的词汇对应的维度具有非零值。

1. 词袋模型:只看词频,不管顺序
理论基石是经典的词袋模型(Bag of Words, BoW)。它把一段文本看作一个"装满词的袋子",只关心哪些词出现了、出现了几次,完全忽略词序。例如"猫追狗"和"狗追猫"在词袋模型中是完全相同的。在此基础上,逐步演进出更复杂的概率排序算法。
2. 从 TF-IDF 到 BM25
先用一个具体例子建立直觉。假设知识库有 100 篇技术文章,用户搜索"模型蒸馏"。"模型"这个词在 60 篇文章中都出现了(太常见,区分度低),而"蒸馏"只在 3 篇文章中出现(很稀有,区分度高)。一个好的检索算法应该给"蒸馏"这个词更高的权重,因为包含"蒸馏"的文章更可能是用户真正想找的。这就是 TF-IDF 和 BM25 的核心思想。
TF-IDF 基于一个简单的直觉:一个词在文档中出现的频率(TF,词频)越高,包含这个词的文档数(DF,文档频率)越少,也就是逆文档频率(IDF)越高,这个词就越重要。在上面的例子中,"模型"的 df/N = 60%,IDF 值低;"蒸馏"的 df/N = 3%,IDF 值高。所以"蒸馏"对排序的贡献远大于"模型"。
然而 TF-IDF 有两个缺陷:没有考虑文档长度(长文档天然具有更高词频),且词频增长是线性的(一个词出现 10 次的重要性真的是 5 次的 2 倍吗?)。BM25 引入两个关键参数来修正这些问题:
-
k1控制词频"饱和度":一篇文章提到"蒸馏"20 次和 10 次,它与"蒸馏"的相关程度并不真的差一倍。k1让词频的贡献随着增加而逐渐趋于平缓,避免长文档因词频堆砌而不公平地占优。 -
b控制文档长度归一化,使算法能更公平地处理不同长度的文档。
可以把 k1 和 b 理解成两个调参旋钮,就像防抖函数里的等待时长:不改变逻辑本身,只改变手感。绝大多数场景用默认值(k1=1.5、b=0.75)就够了。
这使 BM25 成为更加鲁棒有效的排序函数,至今仍是各大搜索引擎中不可或缺的核心组件。
为了看清这套机制究竟如何运作,sparse-embedding 项目从零实现了一遍。它的核心价值不在于性能的极致优化,而在于过程的完全透明化:通过丰富的日志和可视化接口,可以清晰观察文档索引的全过程,包括文本预处理(分词,并去除"的""了"这类几乎不携带检索价值的停用词)、构建倒排索引、计算 TF 和 IDF 值。
倒排索引 (Inverted Index)就是一个从词到文档的反向映射表。普通索引是"给定文档,列出它包含的词",倒排索引则反过来,"给定一个词,立刻找到所有包含它的文档"。用 JavaScript 的眼光看,它的本质就是一张 Map<关键词, 文档ID[]>。这好比一本书后面的术语索引页:你查"TCP",它告诉你第 45、112、203 页提到了这个词。
查询时日志详细展示 BM25 的每步计算。仍以查询"模型蒸馏"为例,但这次是在项目自带的一个小型示例语料(共 N=10 篇文档,与上面 100 篇的直觉例子是两份不同语料)上运行。固定 BM25 参数 k1=1.5、b=0.75,平均文档长度 avgdl=250 词,IDF 采用标准形式 IDF=ln((N−df+0.5)/(df+0.5)),其中 df 为包含该词的文档数:
typescript
查询分词:["模型", "蒸馏"]
词 "模型" → 倒排索引命中 3 篇文档 (df=3, IDF=0.76):
doc_1: TF=5, 文档长度=200词, BM25贡献=1.52
doc_3: TF=2, 文档长度=500词, BM25贡献=0.82
doc_7: TF=8, 文档长度=150词, BM25贡献=1.68
词 "蒸馏" → 倒排索引命中 2 篇文档 (df=2, IDF=1.22, 比"模型"更稀有):
doc_1: TF=3, 文档长度=200词, BM25贡献=2.15 ← "蒸馏"更稀有,单次出现的贡献更大
doc_5: TF=1, 文档长度=250词, BM25贡献=1.22
最终排序:doc_1 (3.67) > doc_7 (1.68) > doc_5 (1.22) > doc_3 (0.82)
可以看到,在 doc_1 中"蒸馏"的词频(TF=3)低于"模型"(TF=5),但因为 IDF 值更高(在文档集合中更稀有),它对 doc_1 得分的贡献(2.15)反而超过"模型"(1.52)。这正是 BM25 的核心逻辑。此外,doc_1 同时命中两个查询词,总分 3.67 遥遥领先,也印证了多词命中对排序的叠加效应。
这个小示例同时揭示了稀疏检索的一长一短:它凭精确的关键词匹配在技术代码、人名等查询上表现极佳,却读不懂同义表达,查一个词只能匹配到字面相同的文档。正是这组对照,为下一节引入混合检索埋下了伏笔。
3. 动手实现:从零写一个 BM25 搜索引擎
上面日志里的每个数字,对应的都是下面这段不足 50 行的实现(节选,为便于阅读做了简化)。整个搜索引擎只有两个动作:建索引和打分。
typescript
// sparse-embedding:BM25 搜索引擎核心
const K1 = 1.5, B = 0.75;
interface Posting { docId: string; tf: number }
class BM25Index {
private inverted = new Map<string, Posting[]>(); // 倒排索引:词 → 命中文档列表
private docLen = new Map<string, number>();
private avgdl = 0; // 平均文档长度
private N = 0; // 文档总数
addDocument(docId: string, text: string) {
const terms = tokenize(text); // 分词,并去除"的""了"等停用词
const tf = new Map<string, number>();
for (const t of terms) tf.set(t, (tf.get(t) ?? 0) + 1);
for (const [term, freq] of tf) {
const postings = this.inverted.get(term) ?? [];
postings.push({ docId, tf: freq });
this.inverted.set(term, postings);
}
this.docLen.set(docId, terms.length);
this.N++;
this.avgdl = [...this.docLen.values()].reduce((a, b) => a + b, 0) / this.N;
}
private idf(df: number) {
// 词越稀有(df 越小),IDF 越高
return Math.log((this.N - df + 0.5) / (df + 0.5));
}
search(query: string, topK = 10) {
const scores = new Map<string, number>();
for (const term of tokenize(query)) {
const postings = this.inverted.get(term) ?? [];
const idf = this.idf(postings.length); // df = 命中文档数
for (const { docId, tf } of postings) {
const dl = this.docLen.get(docId)!;
// k1 让词频贡献饱和,b 做长度归一化
const norm = (tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * dl) / this.avgdl));
scores.set(docId, (scores.get(docId) ?? 0) + idf * norm);
}
}
return [...scores.entries()].sort((a, b) => b[1] - a[1]).slice(0, topK);
}
}
对照前面的日志逐行看:addDocument 负责构建那张 Map<关键词, 文档ID[]> 倒排索引;search 里的 idf * norm 就是日志中每个"BM25贡献"的由来------idf 体现"蒸馏比模型稀有",norm 体现 k1 的饱和效应和 b 的长度归一化。把示例语料喂进去,输出的排序与日志中的 doc_1 (3.67) > doc_7 (1.68) > doc_5 (1.22) > doc_3 (0.82) 完全一致。BM25 没有黑箱,每个分数都能用手指着算出来。
4. 学习型稀疏检索
本篇以经典的 BM25 作为稀疏检索的代表,因为它无需训练、透明可复算,最适合讲清稀疏检索的原理。但需要指出,稀疏检索本身已经进入"学习型"阶段。以 SPLADE 为代表的一类模型,以及 BGE-M3 的稀疏输出分支,用神经网络为每个词项打权重。它不再是 BM25 那样只按词频和文档频率算分,而是让模型判断"这个词在这段文本里到底有多重要",甚至为原文没出现、但语义相关的词项补上非零权重(术语扩展)。这样得到的仍是一个大部分维度为零的稀疏向量,既保留了词法层面的可解释性和精确匹配能力,又借神经网络获得了一定的语义泛化。可以把它看作稀疏与稠密两条路线在中间地带的一次融合。
(五) 混合检索:两全其美的艺术
1. 两种检索的互补
两种方法各有盲区:稠密检索懂语义但可能漏掉关键词(搜"HTTP-403"可能返回"服务器错误"的泛泛讨论),稀疏检索精确匹配但读不懂同义词(搜"kitty"找不到只写了"cat"的文档)。
混合检索的思路很简单:两个引擎都跑,结果合并。难点在于如何把分布迥异的两组得分整合成一个有意义的排序。

2. 三阶段流水线
典型的混合检索流水线包含三个阶段,三者各司其职、层层递进。
第一阶段:并行检索。 系统同时向稠密和稀疏两个引擎发送查询,各自召回一部分候选文档。
第二阶段:结果融合。 负责把两路结果合成一个统一的候选池。难点在于两路得分不可直接比较:稠密检索的相似度得分(如余弦相似度,理论范围 −1 到 1,归一化文本嵌入实践中通常落在 0 到 1)和稀疏检索的 BM25 得分(可能是 0 到几十的任意值),尺度和分布完全不同。
常用的融合方法有两种:
-
加权归一化:把各路得分分别归一化后加权求和。保留了原始得分信息,但两路尺度对齐本身不好调。
-
倒数排名融合(Reciprocal Rank Fusion, RRF):完全抛开原始得分、只看排名,每个文档的综合得分是它在各路结果中排名的平滑倒数之和,计算公式为 得分 = Σ 1/(k + rank)。其中 k 是平滑常数(常取 60),用于压低排名最靠前几个位置之间的得分差距。这就像评选时不看各位评委打的具体分数、只看名次来投票,避免某一路打分尺度偏大而带偏结果。RRF 简单鲁棒,但只利用了排名信息,丢失了原始得分中蕴含的丰富相关性信号。
第三阶段:神经重排序 (Neural Reranking)。注意重排序并不是为了"补救 RRF 丢掉的得分"才存在的:无论前一步用哪种方式融合,重排序都值得加,因为它换用了一种更强的匹配范式 。它让跨编码器对查询和文档做深度交互匹配,精度远高于检索阶段双编码器各自独立编码、再靠向量运算比相似度的做法。具体做法是对融合产生的候选池中排名靠前的 N 个候选(如前 50 个)逐一精细打分,产生最终排序。需要强调:重排序并不替代融合。融合负责从两路结果中产生统一的候选池,重排序负责在这个候选池上精排。没有前者,后者甚至不知道该对哪些文档打分。
3. 双编码器 vs 跨编码器
打个比方:求职者把简历交给猎头快速筛选,是双编码器;面试官与每位候选人深谈,是跨编码器。前者依靠预先抽取的特征做大规模初筛,后者则让查询和候选文档"面对面"逐字斟酌。
双编码器(Bi-Encoder)为查询和文档独立生成向量,通过向量运算计算相似度。文档向量可以离线提前算好并缓存,查询时只做一次向量比对,因此速度极快,但无法捕捉深层的匹配关系,适合从海量数据中做初步筛选。
跨编码器 (Cross-Encoder)则把查询和候选文档拼接成一段完整的文字送入模型,让模型逐词比对、输出一个综合的相关性得分。它慢得多,但判断更准确。常用的重排序模型如 BAAI/bge-reranker-v2-m3 就采用这种架构。
这种"共同关注"机制使跨编码器能捕捉到双编码器无法感知的细微语义关联,输出远比单一检索方法更准确的最终排序。
4. 如何度量检索质量
调优这样一条多阶段流水线,需要客观的度量指标,最核心的有三个(均在带标注答案的测试查询集上计算):
表 1-2 检索质量的三个核心指标
| 指标 | 直觉解释 |
|---|---|
| recall @k(召回率 @k) | 包含正确答案的文档出现在前 k 个检索结果中的查询比例,回答"该找的找到了吗",是最贴近 RAG 需求的指标:只要相关文档进入上下文,LLM 就有机会利用它 |
| MRR(平均倒数排名) | 每个查询取第一个相关文档排名的倒数,再对所有查询取平均,回答"找到得够不够靠前":排第 1 得 1 分,排第 10 只得 0.1 分 |
| nDCG(归一化折损累积增益) | 综合考虑所有相关文档的排名与相关程度,排名越靠后的相关文档得分折扣越大,回答"整个排序列表的质量如何" |
工业界的报告中还常见"检索失败率"的说法。例如下篇将引用的 Anthropic 数据中,检索失败率指正确信息未出现在 top-20 检索结果中的查询比例,本质上就是 1 − recall @20。看到这类数字时,先弄清它对应哪个指标、k 取多少,才能做有意义的横向比较。
这套流水线实际跑起来效果如何?retrieval-pipeline 项目把三个环节都做成了可观察的模块,并用一组测试案例加以检验。每个案例都旨在突出一种特定的信息检索挑战,包括语义相似(如"kitty"对"feline/cat")、精确名称、多语言查询和技术代码,可以直接观察稠密与稀疏两路在每类查询下各自的胜负。
最引人注目的是重排序器在提升最终结果质量上的作用。系统不仅返回重排序列表,还详细展示每个文档在原始稠密和稀疏检索中的排名,以及重排序后的变化。通过分析这些"排名变化"统计,可以清晰看到神经重排序器如何把被单一方法低估、但实际高度相关的文档提升到顶端。
这些观察清楚地说明了一个问题:没有哪种单一检索策略在所有场景下都可靠。把稠密、稀疏和重排序组合起来,才是构建生产级 RAG 系统的正确做法。
5. 动手实现:搭一条三阶段混合检索流水线
retrieval-pipeline 项目的主流程,就是把上面三个阶段翻译成代码(节选,为便于阅读做了简化):
typescript
// retrieval-pipeline:三阶段混合检索
async function hybridSearch(query: string, topK = 10) {
// 第一阶段:稠密、稀疏两路并行召回
const [denseHits, sparseHits] = await Promise.all([
vectorIndex.search(await embed(query), 50), // 语义召回
bm25.search(query, 50), // 关键词召回
]);
// 第二阶段:RRF 融合------只看排名,不看原始得分
const rrfScore = new Map<string, number>();
const K = 60; // 平滑常数,压低头部名次的得分差距
for (const hits of [denseHits, sparseHits]) {
hits.forEach((hit, rank) => {
rrfScore.set(hit.id, (rrfScore.get(hit.id) ?? 0) + 1 / (K + rank + 1));
});
}
const candidates = [...rrfScore.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 50)
.map(([id]) => docs.get(id)!);
// 第三阶段:跨编码器对候选逐一精排
const reranked = await reranker.score(query, candidates);
return reranked.slice(0, topK);
}
三个阶段的职责划分在代码里一目了然:并行召回解决"找得全",RRF 融合解决"两路得分不可比",重排序解决"排得准"。注意第二阶段的融合只产生候选池,真正的排序决策在第三阶段------这正对应前文强调的"重排序不替代融合"。
项目的测试案例(语义相似、精确名称、多语言、技术代码)会逐类打印两路召回名单、RRF 排名和重排序后的最终排名。改一行代码就能做消融:把 reranker.score 换成直接返回 candidates,就能亲眼看到召回率不变、但头部精度明显下滑------重排序的价值立刻显现。
到目前为止,我们的检索对象都是纯文本。但现实中的知识载体远不止于此。
(六) 多模态信息提取:超越文本的界限
在整条知识库流水线里,多模态信息提取属于最前端的摄取与索引阶段。它决定了非文本内容以什么形态进入知识库,进而决定后续分块、嵌入和检索能利用到多少信息。
现实中知识不只存在于文字里。图表、PDF 版式、语音等非文本形式的信息同样需要处理。架构上有三条路,核心取舍在于保真度与成本之间的平衡。
1. 原生多模态处理:统一的语义空间
原生多模态处理 的核心技术突破在于,通过专门的编码器将不同类型的数据全部映射到统一的高维语义空间。以图像为例,架构公开的多模态模型(如 Qwen-VL、LLaVA)通常集成了基于 Vision Transformer(ViT)的视觉编码器。简单理解,ViT 就是"把图像切成一个个小方块当作'视觉单词',再交给 Transformer 处理"------可以类比 CSS Grid:把一张图划分成网格,每个格子当作一个 token 参与计算。
具体来说,ViT 将图像分割为固定大小的图像块(Patches),像处理句子中的单词一样将每个块序列化为向量,与文本词向量共存于共享的多模态嵌入空间。Transformer 的自注意力机制能同等对待文本和图像 Tokens,计算任意跨模态关联。这种端到端联合处理提供了无与伦比的上下文保真度。模型直接"看到"PDF 的页面布局、图表和文字时,能理解图文之间的空间和语义关系。这种方式尤其适合版式复杂、信息密度高的文档。
2. 提取为文本:低成本方案
提取为文本(Extract to Text)是两阶段过程:先通过专门工具(如 OCR 服务、音频转录服务)将非文本内容转为纯文本,再输入语言模型。这种方式代表了模块化和成本效益的设计哲学,可以将任何多模态任务转化为纯文本任务,兼容所有语言模型,提取出的文本可缓存和复用。但代价是上下文信息的损失。所有版式、图表、图像信息都在提取过程中被丢弃。
3. 工具化分析:按需深入方案
将多模态分析作为工具 是一种混合方法。它以文本提取为起点,为 Agent 提供初步文本摘要,同时赋予 Agent 可对原始文件深入分析的工具(如 analyze_image、analyze_pdf)。这种"按需深入"的策略兼顾了低成本初步处理和高保真深度分析,也是"摘要常驻、按需取全文"这一设计哲学的又一次体现。它的做法是让 Agent 先只看到轻量的元信息,确有需要时再逐层拉取完整内容,把 token 花在刀刃上。下篇介绍 OpenViking 时,我们还会看到这一设计哲学被推向极致。
三条路线的取舍,用同一份文件并排跑一遍就能看清。multimodal-agent 项目在统一框架内对三种策略做了系统比较。它通过 demo.py 将同一多模态文件(如含图表的 PDF 报告)和同一问题分别交给三种模式处理,差异立刻显现。
原生多模态模式 凭借对视觉和空间信息的深刻理解,在分析图表、理解文档布局等任务上表现最佳。提取为文本模式 处理纯文本占主导的文档时成本效益最高,但完全无法处理需要视觉信息的查询。带工具模式在交互式场景中展现灵活性,能以较低成本处理大多数初步查询,并在需要时通过调用工具进行高成本深度分析,但在需要一次性端到端深度理解的场景下表现不如原生模式。
三种策略各有胜场,没有万能答案。这类对照实现的价值,正在于让取舍过程可以直接测量,而不是靠猜。
4. 动手实现:一个文件,三种处理方式
multimodal-agent 项目的三种模式,本质上是对同一份输入的三条处理路径(节选,为便于阅读做了简化):
typescript
// multimodal-agent:同一份文件,三种处理模式
type Mode = "native" | "extract" | "tools";
async function answer(file: UploadedFile, question: string, mode: Mode) {
switch (mode) {
case "native":
// 原生多模态:文件直接进入模型,图文联合理解
return llm.generate({
input: [await file.toBase64(), question],
});
case "extract": {
// 提取为文本:先 OCR/转录,再走纯文本链路
const text = await extractText(file); // 版式与图像信息在此丢失
return llm.generate({ input: [text, question] });
}
case "tools": {
// 工具化:先给轻量摘要,模型按需调用深入分析工具
const summary = await extractSummary(file);
return agent.run({
input: [summary, question],
tools: [analyzeImage, analyzePdf], // 确有需要时才付出高成本
});
}
}
}
三种模式的分野就在信息进入模型之前:native 什么也不丢但最贵,extract 最便宜但丢掉全部视觉信息,tools 用轻量摘要做第一道过滤、把高成本分析变成可选项。用项目自带的 PDF 报告分别跑三次,对比回答质量和 token 账单,「保真度与成本的平衡」就从一句口号变成了两行可比较的数字。
结语(上篇):知识进来了,但还只是碎片
这一篇走完了知识获取管道的全部工序:文档分块划定检索单元,稠密嵌入捕捉语义,稀疏嵌入做关键词匹配,结果融合汇成候选池,神经重排序作最终精排,多模态把感知范围扩展到图表和文档版式。四段配套代码把这条管道从流程图变成了可以亲手运行的实现:可切换的 ANN 后端、50 行的 BM25、三阶段混合检索、三种多模态模式。
如果只能记住一个结论,那就是:没有哪种单一检索策略在所有场景下都可靠。稠密检索懂语义但漏关键词,稀疏检索精确但不理解同义词,重排序用跨编码器做深度匹配补齐两者的短板。把三者组合起来,才是构建生产级 RAG 系统的正确做法。
但到目前为止,我们讨论的都是"给定一堆文本块,怎么找到最相关的几个"。一个更根本的问题还没有回答:**这些文本块本身该怎么组织?**简单的切块方式会丢失知识的内在结构和跨文档的关联------试图通过阅读一本字典的随机词条来理解一部小说,结果可想而知。
下篇《走进 AI Agent 第四篇(下):知识的组织与深度检索》将回答这个问题:用 RAPTOR 和 GraphRAG 给知识建立结构,用 OpenViking 的文件系统范式做轻量管理,讨论知识库的时效治理与权限隔离,再让 Agent 从被动的检索管道升级为主动的探索者。最后,知识库这条线索会与第三篇的用户记忆在"双层记忆架构"处正式汇合。