1. 为什么知识获取管道是 AI Agent 落地的第一道坎
做 AI Agent 开发的人,绕不开一个尴尬的现实:模型本身很聪明,但它不知道你公司内部的业务规则、不知道你昨天刚更新的产品文档、更不知道你私有的那几百份技术手册里写了什么。你问它一个通用问题,它答得头头是道;你问它一个只有你们团队才知道的细节,它要么一本正经地胡说八道,要么直接告诉你“我无法回答这个问题”。
这就是知识获取管道要解决的核心矛盾。所谓知识获取管道,说白了就是给 AI Agent 装上一套“外部记忆系统”,让它在回答问题之前,先去指定的知识库里把相关资料捞出来,再基于这些资料组织答案。这套机制在行业里有个更正式的名字——检索增强生成,也就是 RAG(Retrieval-Augmented Generation)。
我刚开始接触 RAG 的时候,以为它就是一个“搜索 + 拼接”的简单流程:用户提问,去数据库里搜几段相关文本,塞进提示词里,完事。真正动手搭了一套之后才发现,这里面每一步都有坑。文档怎么切分、向量怎么存、检索怎么排序、结果怎么过滤、上下文怎么组装,每一个环节的决策都会直接影响最终的回答质量。更麻烦的是,当你的知识库从几十份文档膨胀到几千份的时候,原本跑得好好的方案可能突然就不灵了。
这篇文章是“走进 AI Agent”系列的第四篇,前面几篇分别聊了 Agent 的基本架构、工具调用机制和任务规划策略。这一篇聚焦在知识获取管道上,把 RAG 的基础原理、核心组件、实操步骤和常见坑点一次性讲透。不管你是刚接触 AI Agent 开发的新手,还是已经用过一些 RAG 框架但效果不理想的开发者,都能从里面找到可以直接复用的思路和代码。
提示:RAG 不是银弹。它解决的是“模型不知道特定知识”的问题,但解决不了“模型推理能力不足”的问题。如果你的场景需要复杂的逻辑推理,光靠 RAG 是不够的。
1.1 一个真实的翻车案例:为什么“搜到了”不等于“答对了”
先讲一个我亲身经历的翻车案例。当时我们给一个内部技术文档系统搭了一套 RAG 管道,知识库里大概有 800 多份 Markdown 文档,涵盖 API 文档、部署手册、故障排查指南等。用户提问“服务启动时报端口冲突怎么处理”,系统检索到了三份文档,其中两份是相关的,一份是讲数据库连接池配置的——因为那篇文档里也提到了“端口”这个词。
问题出在哪里?出在检索环节。我们当时用的是最朴素的向量相似度检索,把用户问题转成向量,然后在向量库里找最相似的 Top-K 个文档块。那篇讲数据库连接池的文档之所以被召回,是因为它里面有一句话“数据库连接池的初始端口配置需要与连接字符串保持一致”,这句话在向量空间里跟“端口冲突”的语义距离比较近。但实际上,它跟用户的问题毫无关系。
这个案例说明了一个关键问题:检索的“相关性”和用户真正需要的“有用性”之间,存在一道鸿沟。向量相似度只能衡量语义上的接近程度,它无法判断一段文本是否真的能回答用户的问题。要弥合这道鸿沟,需要在检索之后加一层重排序,或者引入更精细的元数据过滤机制。
后来我们是怎么解决的?在向量检索之后加了一个基于交叉编码器的重排序步骤,把 Top-20 的召回结果重新打分,只保留最相关的 3-5 条。同时给每份文档打上了“文档类型”和“适用场景”的标签,检索时先按标签做一轮粗筛。这两步做完之后,那个端口冲突的问题再也没有召回无关文档了。
1.2 RAG 管道的五个核心环节
把 RAG 拆开来看,它其实是一条完整的流水线,从原始文档到最终答案,中间要经过五个关键环节。每个环节都有自己的技术选型和调优空间,任何一个环节出了问题,整条管道的效果都会打折扣。
第一个环节是文档加载与解析。你的知识可能散落在各种格式的文件里——PDF、Word、Markdown、HTML、甚至数据库里的结构化数据。第一步就是把这些异构的数据统一读进来,转成纯文本。PDF 解析是最麻烦的,尤其是那些带复杂表格和扫描件的 PDF,解析出来的文本经常是乱的。
第二个环节是文本切分。大模型有上下文窗口限制,你不可能把一整本书塞进去。所以需要把长文档切成小块,每块控制在几百到一千个字符左右。切分策略很关键,切得太碎会丢失上下文,切得太大又会引入噪声。
第三个环节是向量化与存储。把切好的文本块通过嵌入模型转成向量,然后存到向量数据库里。嵌入模型的选择直接影响检索质量,不同模型对中文、英文、代码的语义理解能力差异很大。
第四个环节是检索与重排序。用户提问时,把问题也转成向量,在向量库里做相似度搜索,召回一批候选文档块。然后通过重排序模型或者规则策略,从候选里挑出最相关的几条。
第五个环节是上下文组装与生成。把检索到的文档块和用户问题一起组装成提示词,发给大模型生成最终答案。这一步要注意控制上下文长度,避免超出模型的窗口限制。
这五个环节串起来,就是一条完整的知识获取管道。接下来我会逐个拆解每个环节的实操细节和避坑经验。
2. 文档加载与切分:管道入口的脏活累活
很多人搭 RAG 的时候,把大部分精力花在选向量数据库和调检索参数上,却忽略了最前端的文档处理。实际上,文档加载和切分是整条管道里最脏、最累、但也最重要的环节。你喂给管道的数据质量,直接决定了最终输出的质量。垃圾进,垃圾出,这句话在 RAG 场景下体现得淋漓尽致。
2.1 不同格式文档的解析策略与踩坑记录
先说 PDF。PDF 大概是所有文档格式里最难搞的一种。它本质上是一种排版格式,而不是内容格式,里面的文字位置是靠坐标定位的,没有天然的段落和阅读顺序。用 Python 的 PyPDF2 或者 pdfplumber 解析出来的文本,经常出现段落错乱、表格散架、页眉页脚混入正文的情况。
我试过好几种 PDF 解析方案,踩过的坑包括:扫描件 PDF 直接解析出来是空白(因为没有文字层,需要 OCR);双栏排版的 PDF 解析出来文字顺序是乱的(左栏和右栏的内容交错在一起);带表格的 PDF 解析出来表格结构完全丢失,变成一堆用空格分隔的数字。
目前比较靠谱的方案是:对于有文字层的 PDF,用 pdfplumber 配合自定义的版面分析规则,先识别出页眉页脚和正文区域,再按阅读顺序提取文本。对于扫描件,先用 OCR 工具做文字识别,再走同样的流程。如果 PDF 里表格很多,可以考虑用专门的表格提取工具,把表格转成 Markdown 格式再入库。
Word 文档相对好处理,python-docx 库可以比较完整地提取段落和表格。但要注意的是,Word 里的样式信息(标题级别、加粗、斜体)在转纯文本时会丢失,而这些信息对于后续的切分策略其实很有价值。我的做法是在解析时保留标题层级信息,切分时优先在标题边界处断开。
Markdown 和 HTML 是最友好的格式,因为它们本身就有结构化的标签。Markdown 的标题层级、代码块、列表项都可以直接解析出来。HTML 稍微麻烦一点,需要先去掉导航栏、侧边栏、广告这些噪声元素,只保留正文区域。可以用 readability 类的库做正文提取,效果还不错。
注意:不管用什么解析工具,解析完之后一定要人工抽查一批文档,看看提取出来的文本是否通顺、段落是否完整。我见过太多团队直接批量解析完就入库,结果检索出来的内容全是乱码或者碎片。
2.2 文本切分的三种策略与选择依据
文本切分是 RAG 管道里最容易被低估的环节。很多人直接用 LangChain 的 RecursiveCharacterTextSplitter,设一个 chunk_size 和 chunk_overlap 就完事了。但实际上,切分策略的选择需要根据你的文档类型和检索需求来定。
第一种是固定长度切分。最简单粗暴,按字符数或者 token 数硬切,每块之间留一定的重叠。这种方式的优点是实现简单、块大小均匀,缺点是经常在句子中间断开,导致语义不完整。比如一个完整的操作步骤被切成了两半,检索到前半段的时候,用户看到的是“第一步,打开配置文件”,但关键的配置内容在后半段里。
第二种是递归切分。按照优先级依次尝试不同的分隔符——先按段落切,段落太长就按句子切,句子还太长就按逗号切,最后才按字符硬切。这种方式能尽量保持语义单元的完整性,是目前最常用的策略。LangChain 的 RecursiveCharacterTextSplitter 就是这种思路。
第三种是语义切分。用嵌入模型计算相邻句子之间的语义相似度,在相似度骤降的地方断开。这种方式切出来的块语义最完整,但计算成本高,而且对于结构清晰的文档来说,效果不一定比递归切分好多少。
我的经验是:对于技术文档、API 文档这类结构清晰的内容,用递归切分就够了,分隔符优先级设为“标题 > 段落 > 句子 > 字符”。对于聊天记录、会议纪要这类口语化内容,语义切分效果更好。对于代码文件,最好按函数或类来切分,而不是按字符数。
切分块的大小也需要权衡。块太小,检索到的信息不完整,模型没法组织出好的答案;块太大,检索精度下降,而且容易超出上下文窗口。一般来说,中文文档每块 300-500 字比较合适,英文文档每块 500-1000 个 token。重叠部分设为块大小的 10%-20%,保证跨块的语义连续性。
2.3 元数据设计:让检索多一个维度
很多人在入库的时候只存文本内容和向量,忽略了元数据。这是一个巨大的浪费。元数据是检索时做过滤和排序的重要依据,设计好了能大幅提升检索精度。
最基本的元数据包括:文档来源(文件名、路径)、文档类型(API 文档、教程、FAQ)、创建时间、最后更新时间。如果文档有章节结构,还可以存章节标题和层级。对于技术文档,可以额外存编程语言、框架版本、适用平台等信息。
举个例子,用户问“React 18 里 useEffect 的执行顺序是什么”,如果你的元数据里存了“框架=React”和“版本=18”,检索时就可以先按这两个条件过滤,把范围缩小到 React 18 相关的文档块,再去算向量相似度。这样召回的结果会精准得多。
元数据的另一个用途是做权限控制。企业内部的知识库往往有权限分级,不同角色的员工能看到的文档不一样。在检索时根据用户角色过滤元数据,可以避免越权访问的问题。
3. 向量化与存储:把文本变成可检索的数字
文本切分完之后,下一步是把每个文本块转成向量,存到向量数据库里。这一步的核心是嵌入模型的选择和向量数据库的选型。这两个决策直接影响检索的速度和精度。
3.1 嵌入模型选型:中文场景下的实测对比
嵌入模型的作用是把一段文本映射到一个高维向量空间,语义相近的文本在向量空间里的距离也相近。选嵌入模型的时候,主要看三个指标:语义理解能力、推理速度、向量维度。
语义理解能力是最关键的。不同模型对中文的语义理解差异很大。有些模型在英文基准测试上得分很高,但放到中文场景下就拉胯了。我实测过几个常见的嵌入模型,在中文技术文档检索场景下的表现大致是这样的:
| 模型 | 中文语义理解 | 推理速度 | 向量维度 | 适用场景 |
|---|---|---|---|---|
| text-embedding-3-small | 良好 | 快 | 1536 | 通用场景,成本敏感 |
| text-embedding-3-large | 优秀 | 中等 | 3072 | 精度要求高的场景 |
| BGE-large-zh | 优秀 | 中等 | 1024 | 中文为主的知识库 |
| M3E-base | 良好 | 快 | 768 | 轻量级场景 |
| GTE-large | 优秀 | 中等 | 1024 | 中英文混合场景 |
如果知识库以中文为主,BGE 系列和 GTE 系列是不错的选择,它们在中文语义相似度任务上的表现很稳。如果预算充足且对精度要求极高,可以用 text-embedding-3-large。如果追求推理速度,M3E-base 或者 text-embedding-3-small 够用了。
还有一个容易被忽略的点:查询和文档要用同一个嵌入模型。我见过有人用模型 A 把文档转成向量入库,然后用模型 B 把用户问题转成向量去检索,结果检索出来的东西完全不相关。不同模型的向量空间是不兼容的,混用必然出问题。
3.2 向量数据库选型:从原型到生产的演进路径
向量数据库的选择取决于你的数据规模和部署环境。原型阶段和生产阶段的选型策略完全不同。
原型阶段,数据量小,追求快速验证。这时候用 FAISS 或者 Chroma 就够了。FAISS 是 Facebook 开源的向量检索库,性能很好,但它是库而不是服务,需要自己管理索引文件。Chroma 更友好一些,自带持久化存储和简单的 API,适合快速搭原型。
生产阶段,数据量上来了,需要考虑并发、持久化、高可用、水平扩展这些问题。这时候可以考虑 Milvus、Qdrant、Weaviate 这些专门的向量数据库。Milvus 功能最全,支持多种索引类型和分布式部署,但运维复杂度也最高。Qdrant 用 Rust 写的,性能好,API 设计简洁,部署也简单。Weaviate 自带混合检索能力,支持向量检索和关键词检索的结合。
如果团队已经在用 PostgreSQL,pgvector 扩展是一个很务实的选择。它把向量检索能力直接集成到关系型数据库里,不用额外维护一套向量数据库,事务一致性和备份恢复都能复用现有的数据库运维体系。对于中小规模的知识库(百万级向量以内),pgvector 完全够用。
提示:选向量数据库的时候,不要只看性能基准测试。要考虑团队的运维能力、现有的技术栈、以及未来的扩展需求。一个需要专职运维的分布式向量数据库,对于小团队来说可能是负担而不是助力。
3.3 索引构建与增量更新
向量入库之后,需要建立索引才能快速检索。不同的索引类型在检索速度和精度之间有不同的权衡。
最常用的是 HNSW 索引,它构建速度快,检索精度高,内存占用适中,适合大多数场景。IVF 系列索引(如 IVF_FLAT、IVF_PQ)适合超大规模数据集,通过聚类减少检索范围,但精度会有一定损失。PQ(Product Quantization)索引通过压缩向量来减少内存占用,适合内存受限的场景,但精度损失较大。
索引参数需要根据数据规模和精度要求来调。以 HNSW 为例,M 参数控制每个节点的连接数,M 越大检索精度越高但内存占用也越大;efConstruction 控制构建时的搜索范围,值越大索引质量越好但构建越慢;efSearch 控制检索时的搜索范围,值越大精度越高但速度越慢。一般从 M=16、efConstruction=200、efSearch=64 开始调,根据实际效果微调。
增量更新是另一个需要提前考虑的问题。知识库不是一成不变的,新文档会不断加入,旧文档会更新或删除。如果每次更新都重建整个索引,成本太高。好的向量数据库应该支持增量插入和删除,并且能在不重建全量索引的情况下保持检索性能。选型的时候要确认这一点。
4. 检索与重排序:决定 RAG 效果的关键一战
前面三个环节都是在做准备工作,真正决定 RAG 效果的是检索环节。用户提问之后,系统能不能从知识库里找到真正有用的信息,全看这一步。我见过太多 RAG 项目,文档处理做得很好,向量化也没问题,但检索效果就是不行,最后发现是检索策略太粗糙。
4.1 向量检索的局限性:为什么 Top-K 经常不靠谱
最基础的检索方式是向量相似度搜索:把用户问题转成向量,在向量库里找余弦相似度最高的 K 个文档块。这种方式实现简单,但有几个明显的局限。
第一个局限是语义漂移。用户的问题往往很短,几个词或者一句话,而文档块通常比较长。短文本和长文本在向量空间里的分布是不一样的,直接用短查询去匹配长文档,相似度分数可能普遍偏低,导致真正相关的内容排不到前面。
第二个局限是关键词缺失。向量检索擅长捕捉语义相似性,但对精确的关键词匹配不敏感。比如用户问“ERR_CONNECTION_REFUSED 怎么解决”,这是一个具体的错误码,向量检索可能召回一堆讲“连接失败”的文档,但真正包含这个错误码的文档反而不一定能排到最前面。
第三个局限是多样性不足。Top-K 检索返回的往往是内容高度相似的文档块,如果这些块恰好都只覆盖了问题的一个方面,那模型就只能基于片面的信息来回答。比如用户问“如何优化数据库查询性能”,Top-K 可能全是讲索引优化的,但缓存策略、查询重写、分库分表这些方面一个都没覆盖到。
4.2 混合检索:向量加关键词的双路召回
为了解决向量检索的局限性,实践中常用的是混合检索策略:同时走向量检索和关键词检索两条路,然后把两路的结果合并排序。
关键词检索可以用 BM25 算法,它是信息检索领域的经典算法,根据词频和逆文档频率来打分。BM25 对精确关键词匹配很敏感,正好弥补了向量检索的短板。很多向量数据库已经内置了混合检索能力,比如 Weaviate 的 hybrid search、Qdrant 的 sparse vector 支持。
两路检索的结果怎么合并?最简单的方法是加权求和,给向量检索和关键词检索各分配一个权重,比如 0.7 和 0.3。权重需要根据实际场景调,如果用户查询偏向自然语言描述,向量检索权重要高一些;如果查询里经常包含专有名词和错误码,关键词检索权重要高一些。
还有一种更精细的做法是 Reciprocal Rank Fusion(RRF),它不依赖具体的相似度分数,而是根据两路检索结果中的排名来计算融合分数。RRF 的好处是不需要归一化不同检索器的分数,实现简单且效果稳定。
4.3 重排序:用交叉编码器做最后一轮精选
混合检索召回了一批候选文档块之后,还需要做一轮精选,把最相关的几条挑出来。这一步通常用重排序模型来完成。
重排序模型和嵌入模型的工作方式不同。嵌入模型是双编码器结构,查询和文档分别编码成向量,然后算相似度,速度快但精度有限。重排序模型是交叉编码器结构,把查询和文档拼在一起输入模型,直接输出相关性分数,精度高但速度慢。所以典型的做法是:用嵌入模型做粗筛,召回 Top-50 到 Top-100 的候选;然后用重排序模型做精排,从候选里挑出 Top-3 到 Top-5。
常用的重排序模型有 BGE-reranker 系列、Cohere Rerank、以及一些基于交叉编码器的开源模型。BGE-reranker 在中文场景下表现不错,而且可以本地部署,不依赖外部 API。
重排序的收益是很明显的。我做过一个对比测试,在同一个知识库上,不加重排序的检索命中率大概是 65% 左右,加了重排序之后提升到了 85% 以上。当然,代价是增加了一次模型推理,延迟会上升几百毫秒。对于大多数场景来说,这个延迟是可以接受的。
4.4 检索参数调优:Top-K、阈值与上下文长度
检索环节有几个关键参数需要调优,它们直接影响最终效果。
Top-K 的选择。K 太小,可能漏掉相关文档;K 太大,会引入噪声,而且增加后续重排序和生成的负担。一般建议粗筛阶段 K 设为 20-50,精排之后保留 3-5 条。如果知识库很大且问题比较复杂,可以适当增大 K。
相似度阈值。设置一个最低相似度阈值,低于这个阈值的文档块直接丢弃。这样可以避免在知识库里没有相关内容时,强行召回一堆不相关的文档块,导致模型基于错误信息编造答案。阈值需要根据实际数据分布来定,一般设在 0.6-0.75 之间。
上下文长度控制。检索到的文档块拼接到提示词里时,要注意总长度不能超过模型的上下文窗口。如果检索到的内容太多,可以按相关性排序,优先保留最相关的,或者对长文档块做摘要压缩。
5. 上下文组装与生成:把检索结果变成靠谱的答案
检索到相关文档块之后,最后一步是把它们和用户问题组装成提示词,发给大模型生成答案。这一步看起来简单,但实际上有很多细节会影响最终输出的质量。
5.1 提示词模板的设计要点
提示词模板的核心目标是让模型基于检索到的内容来回答,而不是凭自己的记忆胡编。一个有效的模板通常包含以下几个部分:
角色设定。告诉模型它是什么角色,比如“你是一个技术文档助手,负责根据提供的资料回答用户问题”。
上下文注入。把检索到的文档块按相关性排序后拼接进去,每个块前面标注来源,方便模型引用。
回答约束。明确告诉模型“只基于提供的资料回答,如果资料中没有相关信息,就说不知道,不要编造”。
输出格式。如果需要结构化输出,可以在模板里指定格式,比如“请用分点列表的形式回答”或者“请给出具体的操作步骤”。
我踩过的一个坑是:早期版本的模板里没有加“如果资料中没有相关信息就说不知道”这条约束,结果模型在检索结果不相关的时候,会用自己的知识来编答案,而且编得煞有介事。加上这条约束之后,模型在资料不足时的表现诚实多了。
5.2 引用溯源:让答案可验证
在企业场景下,答案的可验证性非常重要。用户不仅想知道答案是什么,还想知道这个答案是从哪份文档里来的。所以在生成答案的时候,最好让模型标注引用来源。
实现方式是在拼接上下文时给每个文档块编号,然后在提示词里要求模型在回答中引用编号。比如“根据 [1] 的描述,配置文件的默认路径是 /etc/app/config.yaml”。这样用户就能顺着编号找到原始文档,验证答案的准确性。
引用溯源还有一个额外的好处:当模型给出的答案有问题时,你可以快速定位是检索环节出了问题(召回了错误的文档)还是生成环节出了问题(模型理解错了文档内容)。这对于调试和优化 RAG 管道非常有帮助。
5.3 多轮对话中的知识获取
在实际应用中,用户很少只问一个问题就结束,通常是多轮对话。多轮对话给 RAG 带来了新的挑战:用户的后续问题往往包含指代和省略,比如“那它的默认值是多少”,这里的“它”指的是上一轮讨论的某个配置项。
如果直接把“那它的默认值是多少”拿去检索,肯定什么都搜不到。所以需要在检索之前做一轮查询改写,把多轮对话中的指代消解掉,还原成一个完整的查询。可以用大模型来做这件事:把对话历史传给模型,让它把当前问题改写成一个独立的、不依赖上下文的查询。
另一个策略是把对话历史也纳入检索范围。有些向量数据库支持对对话历史做向量化,检索时同时搜索知识库和对话历史,这样即使用户的问题很短,也能结合上下文找到相关信息。
6. 从原型到生产:RAG 管道的评估与迭代
搭出一个能跑的 RAG 原型不难,难的是让它稳定地输出高质量答案。这就需要建立一套评估机制,持续监控和优化管道的各个环节。
6.1 怎么判断 RAG 系统好不好用
评估 RAG 系统需要从两个维度来看:检索质量和生成质量。
检索质量的核心指标是命中率和召回率。命中率衡量的是检索结果中是否包含真正相关的文档块,召回率衡量的是所有相关文档块中有多少被检索到了。在实际评估中,可以人工标注一批“问题-相关文档”的对应关系,然后拿系统的检索结果去比对。
生成质量的评估更复杂一些,因为答案的好坏很难用自动指标来衡量。常用的方法包括:人工评分(让标注人员对答案的准确性、完整性、流畅度打分)、自动评估(用另一个大模型来评判答案质量)、以及基于事实的评估(检查答案中的每个事实陈述是否能在检索到的文档中找到依据)。
我自己的做法是维护一个测试集,里面包含 50-100 个典型问题,每个问题都标注了标准答案和相关的文档来源。每次调整管道参数之后,跑一遍测试集,看命中率和答案质量有没有变化。这个测试集不需要很大,但覆盖面要广,要包含简单问题、复杂问题、知识库中有答案的问题、以及知识库中没有答案的问题。
6.2 常见失败模式与修复思路
RAG 系统常见的失败模式有这么几种,每种都有对应的修复思路。
检索不到相关内容。用户的问题在知识库里明明有答案,但检索就是找不到。可能的原因包括:嵌入模型对领域术语理解不够、切分粒度不合适导致关键信息被切散、查询和文档的表述差异太大。修复思路:换更适合领域的嵌入模型、调整切分策略、引入查询改写或查询扩展。
检索到了但答案不对。检索结果里有相关文档,但模型生成的答案跟文档内容不符。可能的原因是:上下文太长导致模型注意力分散、提示词约束不够强、文档块之间的信息冲突。修复思路:减少检索结果数量、加强提示词约束、对冲突信息做去重和优先级排序。
答案过于笼统。模型给出的答案正确但不够具体,没有引用文档中的细节。可能的原因是:检索到的文档块本身就不够具体、提示词没有要求模型给出细节。修复思路:优化切分策略保留更多细节、在提示词里明确要求引用具体内容。
响应太慢。整个管道的延迟太高,用户体验差。可能的原因是:嵌入模型推理慢、向量检索慢、重排序模型推理慢、大模型生成慢。修复思路:换更快的模型、优化索引参数、并行化检索和重排序、对常见问题做缓存。
6.3 持续迭代:知识库更新与管道优化
RAG 系统不是搭完就一劳永逸的。知识库会不断更新,用户的问题分布也会变化,管道需要持续迭代。
知识库更新方面,要建立一套自动化的文档同步机制。当源文档发生变化时,自动触发重新解析、切分、向量化和入库。对于删除的文档,要同步从向量库里删掉对应的向量,避免检索到过时的信息。
管道优化方面,要定期分析用户的查询日志和反馈。哪些问题检索效果好,哪些问题检索效果差,差的问题有什么共同特征。根据这些分析结果,有针对性地调整切分策略、检索参数或提示词模板。
还有一个容易被忽略的点是:知识库的质量比数量更重要。我见过一些团队,拼命往知识库里塞文档,觉得文档越多越好。但实际上,如果文档质量参差不齐,大量低质量文档会稀释检索结果,反而拉低整体效果。定期清理过时、重复、低质量的文档,比不断添加新文档更有价值。
7. 用 TypeScript 搭一个最小可用的 RAG 管道
前面讲的都是原理和策略,这一节给一个可以直接跑起来的 TypeScript 实现。选择 TypeScript 是因为它在 AI Agent 开发中越来越主流,类型系统能帮你避免很多低级错误,而且前后端可以复用同一套代码。
7.1 项目结构与依赖选择
先看项目结构。一个最小可用的 RAG 管道包含这几个模块:文档加载器、文本切分器、嵌入服务、向量存储、检索器和生成器。
// 项目结构 rag-pipeline/ ├── src/ │ ├── loaders/ // 文档加载器 │ │ └── markdown-loader.ts │ ├── splitters/ // 文本切分器 │ │ └── recursive-splitter.ts │ ├── embeddings/ // 嵌入服务 │ │ └── embedding-service.ts │ ├── stores/ // 向量存储 │ │ └── vector-store.ts │ ├── retrievers/ // 检索器 │ │ └── hybrid-retriever.ts │ ├── generators/ // 生成器 │ │ └── answer-generator.ts │ └── index.ts // 入口 ├── package.json └── tsconfig.json依赖方面,核心需要这几个包:@langchain/textsplitters做文本切分,@langchain/openai或者@langchain/community做嵌入和生成,chromadb或者faiss-node做向量存储。如果要用本地的嵌入模型,可以用@xenova/transformers在 Node.js 里跑 ONNX 模型。
注意:TypeScript 7.0 里
baseUrl和moduleResolution=node10这些选项已经弃用了。新建项目的时候直接用moduleResolution: "bundler"或者"node16",避免以后升级踩坑。
7.2 核心模块的实现细节
先看文本切分器的实现。这里用递归切分策略,分隔符优先级从高到低排列:
// src/splitters/recursive-splitter.ts export interface SplitterConfig { chunkSize: number; chunkOverlap: number; separators: string[]; } export class RecursiveSplitter { private config: SplitterConfig; constructor(config: Partial<SplitterConfig> = {}) { this.config = { chunkSize: 500, chunkOverlap: 50, separators: ['\n## ', '\n### ', '\n\n', '\n', '。', '!', '?', '.', ' ', ''], ...config, }; } split(text: string): string[] { return this.splitRecursive(text, this.config.separators); } private splitRecursive(text: string, separators: string[]): string[] { if (text.length <= this.config.chunkSize) { return [text]; } const [separator, ...restSeparators] = separators; if (separator === undefined) { // 没有更多分隔符了,硬切 return this.hardSplit(text); } const parts = text.split(separator); const chunks: string[] = []; let currentChunk = ''; for (const part of parts) { const candidate = currentChunk ? currentChunk + separator + part : part; if (candidate.length <= this.config.chunkSize) { currentChunk = candidate; } else { if (currentChunk) { chunks.push(currentChunk); } if (part.length > this.config.chunkSize) { chunks.push(...this.splitRecursive(part, restSeparators)); currentChunk = ''; } else { currentChunk = part; } } } if (currentChunk) { chunks.push(currentChunk); } return this.addOverlap(chunks); } private hardSplit(text: string): string[] { const chunks: string[] = []; for (let i = 0; i < text.length; i += this.config.chunkSize) { chunks.push(text.slice(i, i + this.config.chunkSize)); } return chunks; } private addOverlap(chunks: string[]): string[] { if (this.config.chunkOverlap === 0) return chunks; const result: string[] = []; for (let i = 0; i < chunks.length; i++) { if (i === 0) { result.push(chunks[i]); } else { const prev = chunks[i - 1]; const overlap = prev.slice(-this.config.chunkOverlap); result.push(overlap + chunks[i]); } } return result; } }再看向量存储和检索的实现。这里用内存存储做演示,生产环境换成真正的向量数据库:
// src/stores/vector-store.ts export interface VectorRecord { id: string; content: string; vector: number[]; metadata: Record<string, unknown>; } export class InMemoryVectorStore { private records: VectorRecord[] = []; async add(records: VectorRecord[]): Promise<void> { this.records.push(...records); } async search( queryVector: number[], topK: number, filter?: (meta: Record<string, unknown>) => boolean ): Promise<Array<VectorRecord & { score: number }>> { const candidates = filter ? this.records.filter((r) => filter(r.metadata)) : this.records; const scored = candidates.map((record) => ({ ...record, score: this.cosineSimilarity(queryVector, record.vector), })); scored.sort((a, b) => b.score - a.score); return scored.slice(0, topK); } private cosineSimilarity(a: number[], b: number[]): number { let dot = 0; let normA = 0; let normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } }7.3 把管道串起来:一个完整的问答流程
最后把各个模块串起来,形成一个完整的问答流程:
// src/index.ts import { RecursiveSplitter } from './splitters/recursive-splitter'; import { InMemoryVectorStore } from './stores/vector-store'; import { EmbeddingService } from './embeddings/embedding-service'; import { AnswerGenerator } from './generators/answer-generator'; async function buildPipeline(documents: string[]) { const splitter = new RecursiveSplitter({ chunkSize: 500, chunkOverlap: 50 }); const embedder = new EmbeddingService(); const store = new InMemoryVectorStore(); // 1. 切分文档 const allChunks: string[] = []; for (const doc of documents) { allChunks.push(...splitter.split(doc)); } // 2. 向量化并入库 const records = await Promise.all( allChunks.map(async (chunk, index) => ({ id: `chunk-${index}`, content: chunk, vector: await embedder.embed(chunk), metadata: { source: `doc-${index}`, index }, })) ); await store.add(records); return { embedder, store }; } async function askQuestion( question: string, pipeline: Awaited<ReturnType<typeof buildPipeline>> ) { const { embedder, store } = pipeline; // 1. 查询向量化 const queryVector = await embedder.embed(question); // 2. 检索 const results = await store.search(queryVector, 5); // 3. 组装上下文 const context = results .map((r, i) => `[${i + 1}] ${r.content}`) .join('\n\n'); // 4. 生成答案 const generator = new AnswerGenerator(); const answer = await generator.generate(question, context); return { answer, sources: results.map((r) => r.metadata.source) }; }这个实现虽然简单,但包含了 RAG 管道的所有核心环节。你可以在这个基础上逐步替换组件:把内存存储换成 Chroma 或 pgvector,把简单的向量检索换成混合检索加重排序,把基础的提示词模板换成更精细的版本。
7.4 实测中遇到的三个坑
在 TypeScript 环境下搭 RAG 管道,我踩过几个比较典型的坑,这里分享一下。
第一个坑是异步处理的顺序问题。向量化是一个异步操作,如果文档很多,并发调用嵌入 API 可能会触发限流。我的做法是用 p-limit 或者类似的库控制并发数,一般设为 5-10 比较稳妥。另外要注意,向量入库必须等所有向量都生成完之后才能做,否则检索时会漏掉部分文档。
第二个坑是向量维度的类型问题。TypeScript 里数组类型不会检查长度,如果你不小心把 1536 维的向量和 1024 维的向量混在一起,编译时不会报错,但运行时算余弦相似度会出问题。建议在类型定义里加上维度约束,或者在入库时做一次校验。
第三个坑是内存泄漏。如果用内存向量存储,随着文档不断加入,内存占用会持续增长。在 Node.js 环境下,默认的堆内存限制大概是 1.5GB 到 2GB,超过之后进程会崩溃。生产环境一定要用持久化的向量数据库,不要用内存存储。
8. 知识获取管道的下一步演进方向
RAG 基础管道搭起来之后,还有很多可以优化的方向。这里聊几个我觉得比较有价值的演进路径。
第一个方向是 Agentic RAG。传统的 RAG 是“一次检索,一次生成”的固定流程。Agentic RAG 把检索能力封装成 Agent 的一个工具,让 Agent 自己决定什么时候检索、检索什么、检索几次。比如 Agent 可以先检索一次,发现结果不够好,就改写查询再检索一次;或者把复杂问题拆成几个子问题,分别检索后再综合。这种方式更灵活,但实现复杂度也更高。
第二个方向是 GraphRAG。传统 RAG 把文档切成独立的块,块与块之间的关联信息丢失了。GraphRAG 在切分的同时构建知识图谱,把实体和关系抽取出来,检索时不仅返回相关的文本块,还返回实体之间的关系路径。这对于需要多跳推理的问题特别有用。
第三个方向是多模态 RAG。现在的 RAG 主要处理文本,但企业知识库里还有大量的图片、表格、流程图。多模态 RAG 把这些非文本内容也纳入检索范围,用多模态嵌入模型统一编码,检索时可以跨模态召回。
第四个方向是自适应检索。不是所有问题都需要检索。有些问题是常识性的,模型自己就能回答;有些问题是闲聊,根本不需要查知识库。自适应检索让系统先判断问题类型,只在需要的时候才触发检索,这样可以降低延迟和成本。
这些方向不需要一次性全上,可以根据实际需求逐步引入。我的建议是先把基础管道跑通,积累一批真实的用户查询和反馈,再根据数据来决定下一步优化什么。脱离实际数据谈优化,很容易做无用功。
最后分享一个我在实际项目中总结的小技巧:在 RAG 管道的每个环节都加上日志和埋点。记录每次查询的原始问题、改写后的查询、检索到的文档块及其分数、重排序后的结果、最终生成的答案、以及用户的反馈。这些数据是后续优化的基础。没有数据支撑的优化,都是拍脑袋。