本文是 AgentScope 2.0 源码解析系列 (RAG 模块)。
关注追更,后续会持续更新 Workspace、Skill、formatter 等模块。
读完这篇你会带走这些源码层面的结论:
-
整个 RAG 模块被抽象成一条单向流水线:
Parser → Section → Chunker → Chunk → KnowledgeBase → VectorStore,每个阶段有自己的数据载体和边界,前一阶段不知道后一阶段的存在;分块(chunking)被独立成单独一阶段,不藏在解析或存储里 -
Section是硬边界——ChunkerBase源码注释里写死「never combine content across two Sections」,PDF 的一页、PPT 的一张幻灯片永远是一个完整切分单元,跨页拼接这种脏数据从结构上就不可能产生 -
KnowledgeBase把 embedding model 和 vector store 配成一对,对外只露 4 个方法(search/insert_document/delete_document/list_documents),全部async,collection 是首次调用时懒创建并 memoized -
metadata_filter是一道纵深防御的租户隔离:构造时设一次,search/list永远带上它过滤,insert则把它的 key 强制覆盖到每个 chunk 的 metadata 上——恶意 parser 也无法把记录塞进别人的 scope -
Chunk故意不带score也不带embedding字段:分数塞进VectorSearchResult包装类、向量塞进VectorRecord包装类,让Chunk在流水线里语义保持稳定 -
三家向量库(Milvus Lite / Qdrant / MongoDB)实现的是同一个
VectorStoreBase抽象,差异全压在各自后端,KnowledgeBase对底层是哪家一无所知
适合谁读:在写或要改 RAG / 知识库功能、想给自己的 Agent 接外部知识、或对「怎么设计一个能换后端、能多租户隔离、还能跨格式」的检索系统感兴趣的 Python 开发者。
预计阅读:主线约 16 分钟(5 张字段表分散在各小节,深度查阅另需约 5 分钟)。
🎯 如果只记一件事
RAG 模块把「索引」这件事拆成 5 个阶段、每阶段一个数据载体 + 一个抽象基类,并且用 Section 这个硬边界从结构上杜绝跨结构单元拼接。这套「流水线分阶段 + 每阶段一个不变量数据类型 + 硬边界防越界」的思路,搬到任何要把异构输入加工成可检索单元的系统(日志检索、代码搜索、多模态库)都成立——宁可多设计一层 Section,也不要让下游 chunker 自己去判断「这里能不能断开」。
一、这个模块到底在解决什么问题?
读完前几篇你会发现一个反差:Agent 的 ReAct 循环、Middleware 的 6 个 Hook、Tool 的工具调用——这些机制都假设 Agent「脑子里已经有知识」。但真实业务里,Agent 需要的知识往往躺在几百份 PDF、PPT、Markdown、图片里。把这些异构文件变成 Agent 能「查」到的东西,就是 RAG 模块要解决的问题。
如果没这个模块会怎样?你得自己写:怎么读 PDF 抽页、怎么把 PPT 的表格转成文字、图片要不要做多模态 embedding、文本按什么粒度切、切完存哪、查的时候怎么去重、多个租户共用一个库怎么不串数据……这些零碎活全压到业务代码里,最后一定长成一个谁也不敢动的「上帝函数」。
AgentScope 选的方案,是把整件事拆成一条单向流水线,每一阶段只干一件事、只产出一个固定形状的数据结构,后一阶段只认前一阶段的输出形状,不关心它怎么来的。一句话理解这个模块:它把「把文件变成可检索的向量」这件又杂又长的事,拆成了 5 个能各自独立测试、独立替换的阶段 。
二、这个模块在整个框架中的位置
先看数据在流水线里怎么流。输入是一个文件(bytes 或路径),最终落进一个向量数据库的 collection。

图 1 · RAG 五阶段索引流水线:文件 → Parser → Section(玫红粗线是硬边界)→ Chunker → Chunk → KnowledgeBase → VectorStore
这条链上有两个角色是「业务侧调用者」而非模块内部组件:
-
Parser(4 家):吃原始文件,吐
Section列表。按 IANA media type 路由(application/pdf→PDFParser等)。 -
VectorStore(3 家):物理存储和 ANN 检索。
MilvusLiteStore/QdrantStore/MongoDBStore都实现同一个VectorStoreBase。
依赖方向:KnowledgeBase 依赖 embedding 模块(要算向量)和 message 模块(TextBlock/DataBlock 是内容载体);它不依赖agent / tool / model(LLM 那个 model)。反过来也没有模块依赖 RAG——RAG 是被上层「知识库管理器」和 Agent 工具调用的叶子模块。这个单向、可插拔的位置,是它能支持「换后端、换 parser、换 embedding」而不动其它代码的前提。
三、为什么这样设计?
这是理解整个模块的关键。先讲设计,再讲代码。
3.1 为什么是流水线,而不是一个大 index(file) 方法
最容易想到的写法是一个 KnowledgeBase.index(file_path),内部一把梭:读文件 → 切块 → 嵌入 → 存库。简单,但有三个问题:
-
不可测试。想单独测「PDF 解析对不对」必须连向量库一起起。
-
不可替换。想换 chunk 策略(按 token / 按语义),得改
index内部。 -
不可复用。Parser 产出的
Section想给「全文摘要」用、给「关键词抽取」用,都得重新读一遍文件。
AgentScope 把它拆成 5 个阶段,每个阶段一个数据载体(Section / Chunk / VectorRecord / VectorSearchResult / DocumentSummary),阶段之间用这些固定形状的对象交接。Parser 完全不知道 Chunker 存在;Chunker 也不知道有向量库。这样每一段都能单独 mock 测试,也都能被流水线之外的逻辑复用。
3.2 为什么 Section 是硬边界
这是整个模块最该停下来想一想的约束。ChunkerBase 的基类注释里白纸黑字写着三条不变量:
-
No cross-Section merging: every output
Chunkis derived from exactly one inputSection. -
DataBlock pass-through: a Section whose content is a
DataBlockbecomes a single Chunk with the same content; multimodal data is never sliced. -
Continuous indexing:
chunk_indexruns from0tototal_chunks - 1across the entire output list.
为什么这条约束这么重要?因为 RAG 检索质量的一个常见杀手是上下文断裂:把 PDF 第 3 页的尾巴和第 4 页的开头拼进同一个 chunk,检索时这块文本既不属于任何一页,引用时也无法标页码,用户根本无从核对。Section 把「自然边界」(一页 / 一张幻灯片 / 一张图)固定下来,Chunker 只能在边界内切,从结构上消灭了这种脏数据。这是比「靠 chunker 自己小心」更稳的防线——你不可能在结构层面就禁止掉一个错误。
3.3 为什么 Chunk 不带 score 也不带 embedding
读源码时一个反直觉的细节:Chunk 是流水线的核心数据结构,但它只有content / source / chunk_index / total_chunks / metadata 五个字段。检索的分数在哪?插入的向量在哪?
作者把这两样东西分别塞进了包装类:
-
插入时:
VectorRecord(vector=..., document_id=..., chunk=...)——向量是「要插进库」时才裹上去的壳。 -
检索时:
VectorSearchResult(score=..., document_id=..., chunk=...)——分数是「成了查询命中」时才裹上去的壳。
_vector_store.py 的注释点破了意图:"Chunk is intentionally not extended with an embedding field so its semantics stay stable across the indexing pipeline"。换句话说,如果给 Chunk 加 score,那流水线生产阶段(insert)也得扛着一个永远为空的 score 字段;如果加 embedding,消费阶段(search)也得扛着一个永远为空的向量。包装类把这种「这个字段在这个阶段才有意义」的副作用隔离掉了。把数据模型的生命周期语义做进类型系统,比靠注释提醒「这个字段这里别用」可靠得多。
3.4 为什么 collection 是懒创建的
KnowledgeBase.ensure_collection(_knowledge.py:160)只在第一次真正要操作时才检查 collection 存不存在、不存在就用 embedding 维度建一个,建完立刻 self._collection_ready = True 并 memoize,之后所有调用就是一个 if 直接返回。
这个设计解决的是「冷启动体验」:用户新建一个知识库时不需要先跑一遍 setup,第一次 insert_document 或 search 自然就建好了。代价是首次调用多一次 round-trip,但 memoize 之后归零。对一个「库可能建了很久但没人用」的系统,懒创建比「构造即建」省资源。
四、跟着我阅读源码
第一次读只需精读 Chunk 字段表 一张,其余字段表按需查阅,不影响理解主线。
模块源码一共 18 个文件、约 3270 行,但真正承载设计的是少数几个。按重要程度排:
-
_document.py(102 行)——必读。定义Section和Chunk,是整条流水线的「数据契约」。看懂这两个,就懂了模块的骨架。 -
_knowledge.py(384 行)——必读。KnowledgeBase是唯一对外暴露的运行时句柄,4 个 async 方法串起检索 + 增删查。 -
_vdb/_vector_store.py(292 行)——必读。定义VectorStoreBase抽象 + 三个数据包装类。这是「换后端」的根。 -
_chunker/_base.py+_approx_token_chunker.py——重要。前者定义三条不变量,后者是唯一的 chunker 实现(近似 token 切分)。 -
_parser/_base.py+ 四个 parser——按需。理解supported_media_types路由机制时读_base.py,想看多模态怎么处理时读_image.py/_ppt.py。 -
三个 vector store 实现——按需查阅。挑一个最熟的后端读对照,理解抽象怎么落地即可。
字段表 1:Chunk(核心,必读)
_document.py:71。这是流水线最终产物、向量库每一条记录的业务载荷。
|
字段 |
类型 |
含义 |
|---|---|---|
content | TextBlock \| DataBlock |
chunk 内容。文本切片是 |
source | str |
源文件名,从父 |
chunk_index | int |
在整个文档内的 0 基索引,用于检索时「找相邻 chunk 扩展上下文」 |
total_chunks | int |
同源文件切出的 chunk 总数,和 |
metadata | dict[str, Any] |
从父 |
重点理解 chunk_index + total_chunks 这对组合:它们不是给 chunker 自己用的,而是给检索时的上下文扩展用的。命中第 5 个 chunk 时,可以靠 chunk_index 去把第 4、6 个也捞回来,这是 RAG 里「扩大命中窗口」的标准操作。source 和 metadata 一起构成了「这条命中来自哪、具体在哪个位置」的引用链。
字段表 2:Section(按需查阅)
_document.py:31。Parser 的产物,Chunker 的输入。
|
字段 |
类型 |
含义 |
|---|---|---|
content | TextBlock \| DataBlock |
section 内容,文本用 |
source | str |
源文件名,向下游传递到每个 |
metadata | dict[str, Any] |
parser 写入的格式特有元信息(PDF 的 |
Section 比 Chunk 少了 chunk_index / total_chunks——因为它是「自然边界」的载体,不需要定位编号;它比 Chunk 多承担了「格式特有元信息来源」的角色。
字段表 3:VectorRecord / VectorSearchResult(按需查阅)
_vdb/_vector_store.py:28 / :53。两个包装类,作用见 3.3 节。
|
类 |
字段 |
含义 |
|---|---|---|
VectorRecord
(插入壳) | vector: list[float] |
密集嵌入向量 |
document_id: str |
源文档 ID,后端必须持久化, | |
chunk: Chunk |
业务载荷 | |
VectorSearchResult
(命中壳) | score: float |
相似度分数,cosine/dot 越大越相似,L2 越小越相似 |
document_id: str |
命中所属源文档 | |
chunk: Chunk |
命中的业务载荷 |
字段表 4:DocumentSummary(按需查阅)
_vdb/_vector_store.py:76。list_documents 的返回元素,向量库是「库里有哪些文档」的唯一真相源。
|
字段 |
类型 |
含义 |
|---|---|---|
document_id | str |
源文档 ID |
source | str |
原始文件名(取自遇到的第一个 chunk) |
chunk_count | int |
该文档索引的 chunk 总数 |
metadata | dict[str, Any] |
文档级元信息(媒体类型、大小、上传时间等,取自遇到的第一个 chunk) |
KnowledgeBase 构造参数(必读)
_knowledge.py:74。这是业务侧会直接 new 的类,参数决定运行时行为。
|
参数 |
类型 |
含义 |
|---|---|---|
name | str |
面向 Agent 的知识库名,露给 LLM(工具描述里)和前端 |
description | str |
面向 Agent 的描述,LLM 据此判断「要不要查这个库」 |
embedding_model | EmbeddingModelBase |
嵌入模型,索引和检索必须同一模型否则向量不可比 |
vector_store | VectorStoreBase |
共享的向量库连接,必须以 |
collection | str |
物理集合名,懒创建 |
metadata_filter | dict \| None |
纵深防御的 payload 过滤器,见下文 |
五、代码到底是怎么运行起来的?
跟着一次完整检索走一遍。假设业务侧已经建好了一个 KnowledgeBase,现在调 await kb.search(["年假多少天?"])。
第 1 步:空查询短路(_knowledge.py:228)。
if not queries:
return []
进来的 queries 是 ["年假多少天?"],非空,继续。
第 2 步:多模态降级(_knowledge.py:231)。
if not self._embedding_model.supports_multimodal:
queries = [q for q in queries if not isinstance(q, DataBlock)]
如果绑定的 embedding 模型不支持多模态,把 DataBlock(图片查询)静默丢弃。设计意图:调用方可以传混合 list 而不用为每个 KB 判断能力。这里查询是字符串,不受影响。
第 3 步:懒建 collection(_knowledge.py:236 → ensure_collection)。
await self.ensure_collection()
首次调用会走 has_collection 检查,不存在就用 embedding_model.dimensions 建;之后 _collection_ready=True,这里就是个 if 返回。
第 4 步:批量嵌入(_knowledge.py:237)。
response = await self._embedding_model(queries)
所有查询一次性批量嵌入(不是一个一个嵌)。这一步是整条链里最贵的,批量能显著省 LLM 调用成本。
第 5 步:并发检索(_knowledge.py:239)。
results_per_query = await asyncio.gather(
*(self._vector_store.search(
collection=self._collection,
query_vector=vector,
top_k=top_k,
metadata_filter=self._metadata_filter,
) for vector in response.embeddings)
)
每个查询向量并发打向量库。注意 metadata_filter每次都带上——这是租户隔离的执行点。
第 6 步:去重 + 阈值 + 排序 + 截断(_knowledge.py:251-272)。
key = (result.document_id, result.chunk.chunk_index)
if key not in best or result.score > best[key].score:
best[key] = result
这是去重的关键:用 (document_id, chunk_index) 作为 chunk 的稳定身份。注释点明它「survives reindex (block UUIDs do not)」——向量库内部的主键 UUID 在重建索引时会变,但「哪个文档的第几个 chunk」是逻辑身份,稳定。多个查询命中同一个 chunk 时保留分数最高的那个。最后按分数降序排,截到 top_k。
插入路径(insert_document,_knowledge.py:278)走的是镜像逻辑,但有一个关键安全细节:metadata 合并时 metadata_filter 的优先级最高(见下文扩展建议里的「千万不要改」)。
六、如何开始调试源码
第一次:在 _knowledge.py:237(response = await self._embedding_model(queries))下断点。观察 queries 进来时长什么样、response.embeddings 出去时维度对不对。这一步能确认 embedding 模型配置正确。
第二次:在 _knowledge.py:245(metadata_filter=self._metadata_filter)下断点。观察传给向量库的 filter 是不是你设的租户隔离值。多租户 bug 经常就出在这一行 filter 没传或传错。
第三次:在 _knowledge.py:263(key = (result.document_id, result.chunk.chunk_index))下断点。观察多查询去重是否如预期——尤其是当你怀疑「明明该出来的 chunk 没出来」时,看 best 字典里它的 score 是不是被另一个查询的低分覆盖了(不会,因为取 max,但值得确认)。
插入调试:在 _knowledge.py:329(metadata 合并那个 for 循环)下断点,看 chunk.metadata 最终长什么样——这是排查「为什么检索时按租户过滤把这个文档过滤掉了」的关键,因为 metadata_filter 的 key 在这里被强制覆盖上去。
七、如何扩展这个模块
✅ 应该改(推荐路径)
加一种文件格式:继承 ParserBase,声明 supported_media_types,实现 parse 返回 list[Section]。这是模块设计的主扩展点,路由靠 MIME 类型自动完成,无需改任何其它代码。参考 _pdf.py(92 行)是最简单的范本。
换 chunk 策略:继承 ChunkerBase 实现 chunk。比如想做「按 Markdown 标题切」或「按句子切」,新写一个 chunker 即可。务必遵守三条不变量(不跨 Section 合并、DataBlock 透传、连续索引)。
加一个向量库后端:继承 VectorStoreBase 实现 7 个抽象方法。KnowledgeBase 对后端无感知,换库零改动。
⚠️ 不应该改(有更优替代)
不要直接改 Chunk 加字段。如果新功能需要「带分数的 chunk」,用包装类(参考 VectorSearchResult),而不是污染 Chunk 的语义。
不要在 KnowledgeBase 里硬编码某个后端的 filter 语法。metadata_filter 是 flat dict 抽象,后端各自翻译(Milvus 翻成 expr、Qdrant 翻成 Filter、MongoDB 翻成 $and)。在 KnowledgeBase 层写死某种语法会破坏可插拔性。
🚫 千万不要改(动了会破坏不变量)
ChunkerBase 的「不跨 Section 合并」约束。这是模块防脏数据的结构防线,一旦放开,跨页拼接这类问题会悄无声息地混进检索结果。
KnowledgeBase.insert_document 里 metadata 合并时 metadata_filter 的最高优先级(_knowledge.py:330-334)。这个顺序是纵深防御的核心——改了它,恶意或 buggy 的 parser 就能把记录塞进别人的租户 scope。
八、本模块最值得学习的设计
第一,流水线 + 每阶段一个数据类型。Section / Chunk / VectorRecord / VectorSearchResult / DocumentSummary,五种类型一一对应五个阶段。这比「一个万能 Document 类扛全流程」清晰得多——每个类型的字段都只在它有意义的阶段出现。这种「让数据类型的生命周期语义显式化」的做法,在任何多阶段处理系统里都值得照搬。
第二,硬边界防越界。Section 不是性能优化、不是代码风格,而是一道结构化的不变量:它让「跨页拼接」这种错误在类型层面就不可表达,而不是靠每个 chunker 作者自觉。这是把「容易出错的判断」从运行时挪到编译/结构时的高级手法。
第三,包装类隔离副作用。Chunk 不带 score / embedding,靠 VectorRecord / VectorSearchResult 包装。这解决了「同一个数据结构在不同阶段有不同可用字段」的污染问题。凡是遇到「这个字段有时有值有时没值」,先想包装类,别想 Optional。
第四,纵深防御的租户隔离。metadata_filter 在 search/list 端过滤、在 insert 端强制覆盖,两端都设防。这不是「信不过调用方」,而是承认「parser 可能 buggy、上游可能传错」——纵深防御的价值就在这里:任何一层失守都还有下一层兜底。
九、阅读建议
先读 _document.py(102 行,半小时)——Section 和 Chunk 是契约,看懂这两个字段表,整条流水线就清晰了。
再读 _knowledge.py 的 search 方法(_knowledge.py:190)——跟着一次查询走完整调用链(第五节那条),这是理解运行时行为的最快路径。
然后读 _chunker/_base.py 的不变量声明 + _approx_token_chunker.py 的 _split_text(_approx_token_chunker.py:106)——看近似 token 切分怎么用 bisect_right 在 UTF-8 字节偏移上做窗口滑动,是个干净的小算法实现。
最后挑一个后端对照读——如果你熟 Milvus 读 _milvus_lite.py,熟 Qdrant 读 _qdrant.py。重点看它怎么把抽象方法落地、怎么把 metadata_filter 翻译成原生 filter 语法。三个都读会发现:差异全在后端 SDK 语法,业务逻辑零差异——这正是抽象基类成功的证据。
可以跳过的:三个后端里挑一个读就够,另外两个对照看 filter 翻译差异即可,不必逐行。
十、阅读完成以后
读完这个模块,你应该能回答:为什么 RAG 被拆成 5 个阶段而不是一个大方法?因为每阶段独立可测、可替换、可复用。为什么 Section 是硬边界?因为它从结构上消灭跨结构单元拼接这种脏数据。代码怎么跑的?search 是批量嵌入 + 并发检索 + (document_id, chunk_index) 去重 + 阈值过滤 + 排序截断。要改从哪改?加格式继承 ParserBase、换策略继承 ChunkerBase、换后端继承 VectorStoreBase——三条扩展路径互不影响。
真正应该带走的,是这套「流水线分阶段 + 每阶段一个不变量类型 + 硬边界防越界 + 包装类隔离副作用 + 纵深防御隔离」的组合拳。它不是为了优雅而优雅,而是每一条都在解决一个具体的工程问题(可测试性 / 脏数据 / 字段污染 / 租户串数据)。把这些思路迁移到你下一个要处理「异构输入 → 可检索单元」的系统里,比记住某个 API 签名有价值得多。
聊聊
💬 留两个问题
1. 低门槛:你做 RAG 时踩过哪些「chunk 切得不对导致检索垃圾」的坑?是按字符切断在词中间,还是把两页不相干的内容拼进了一个 chunk?AgentScope 这种「Section 硬边界 + Chunker 只在边界内切」的结构,能帮你避开其中哪种?
2. 进阶:如果你要做一个多租户的知识库平台,让 A 公司的查询永远查不到 B 公司的数据,你会把隔离做在哪一层?是靠 metadata_filter(像 AgentScope 这样在应用层每次过滤),还是靠物理分 collection,还是两者都要?为什么?
305



被折叠的 条评论
为什么被折叠?



