1. 为什么知识获取管道是 AI Agent 落地的第一道坎
做 AI Agent 开发的人,绕不开一个现实问题:模型本身很聪明,但它不知道你公司内部的业务规则、不知道你昨天刚更新的产品文档、更不知道你私有的那套数据表结构。你问它一个关于内部流程的问题,它要么一本正经地胡说八道,要么直接告诉你“我无法回答”。这不是模型不行,而是它的知识边界就停在那里。
知识获取管道要解决的就是这件事——把外部知识在合适的时机、以合适的形式喂给模型,让它基于真实资料来回答。而RAG(检索增强生成)就是目前工程上最成熟、性价比最高的一条路径。它的核心思路用一句话说清楚:先把知识切片存起来,用户提问时先检索出最相关的片段,再把这些片段塞进提示词里让模型生成答案。
我见过太多团队一上来就想着微调模型,结果数据准备了两周、训练跑了三天,上线后发现业务文档一天改三版,微调根本追不上变化。RAG 的好处在于知识库和模型解耦——文档更新了,重新灌一次向量库就行,模型完全不用动。这也是为什么从 2024 年到现在,几乎所有企业级 AI Agent 项目的第一期,都是先搭一条 RAG 管道。
这篇文章是“走进 AI Agent”系列的第四篇,前面几篇聊了 Agent 的整体架构、工具调用和记忆机制,这一篇专门把知识获取管道拆开讲透。我会用TypeScript作为主要实现语言,因为它在前后端通吃、类型系统能帮你在管道各环节少踩很多坑。内容会覆盖从文档加载、切片、向量化、存储、检索到重排的完整链路,也会聊到实际项目里那些文档不会告诉你的坑。不管你是刚接触 RAG 的新手,还是已经搭过一版想优化的老手,应该都能从里面找到能直接用的东西。
2. RAG 管道的五个核心环节与数据流转
2.1 从原始文档到可检索片段的完整链路
一条标准的 RAG 管道,数据要经过五个环节才能变成模型能用的知识。我用一个实际的企业知识库场景来串一遍:假设你有一批产品手册 PDF、一批 Markdown 格式的 FAQ、还有一堆 Confluence 导出的 HTML 页面。
第一个环节是加载(Loading)。不同格式的文档需要不同的解析器。PDF 要用 pdf-parse 或 pdfjs 提取文本,HTML 要用 cheerio 或 jsdom 清洗标签,Markdown 相对简单直接读就行。这一步的坑在于 PDF 里的表格和分栏排版,直接提取出来的文本顺序经常是乱的,后面切片就会切出莫名其妙的片段。
第二个环节是切片(Chunking)。把长文档切成一段段适合检索的小块。切片的粒度直接决定检索质量——切太大,一个片段里混了好几个主题,检索出来噪音多;切太小,一个完整的逻辑被切断,模型拿到手里拼不出完整意思。常见的做法是按字符数切,比如 500 到 1000 字符一块,块之间留 10% 到 20% 的重叠,防止关键信息正好卡在边界上被切掉。
第三个环节是向量化(Embedding)。把每个文本块通过嵌入模型转成一串浮点数向量。语义相近的文本,向量在空间里的距离也近。这一步决定了你的检索是“按关键词匹配”还是“按语义匹配”。比如用户问“怎么退款”,文档里写的是“申请退货流程”,关键词对不上但语义很近,向量检索就能命中。
第四个环节是存储(Storage)。向量要存进向量数据库,同时把原始文本和元数据(来源文件、页码、章节标题)一起存进去。检索出来向量后,你得能拿回对应的原文片段。常见的选型有 pgvector、Qdrant、Milvus、Chroma 等,小项目用 pgvector 就够了,不用额外维护一套数据库。
第五个环节是检索与重排(Retrieval & Rerank)。用户提问时,先把问题也向量化,然后在向量库里找最相似的 Top-K 个片段。但纯向量检索有个问题:它偏向语义相似,有时候会漏掉关键词精确匹配的结果。所以工程上通常会做混合检索——向量检索和关键词检索(BM25)各出一批结果,再用 RRF(倒数排名融合)合并。合并之后还可以加一层重排模型(Reranker),用交叉编码器对候选片段重新打分,把真正最相关的排到前面。
2.2 为什么每个环节都不能偷懒
很多人搭第一版 RAG 的时候,喜欢用现成的框架一把梭,LangChain 或者 LlamaIndex 几行代码就跑通了。跑通没问题,但上线之后效果不好,你根本不知道是哪个环节出了问题。是切片切坏了?是嵌入模型选错了?还是检索策略太单一?
我的经验是,每个环节都要能单独观测和调试。切片结果要能导出来人工看,嵌入向量要能算相似度验证,检索结果要能打印出分数和来源。这样出问题的时候,你能快速定位到具体环节,而不是对着一个黑盒干瞪眼。
还有一个容易被忽略的点:元数据的设计。很多人只存文本和向量,检索出来一个片段,不知道它来自哪个文件、哪一页。这在调试和展示引用来源时非常致命。我的做法是每个片段都带上source(文件路径)、chunkIndex(块序号)、heading(所属章节标题)这几个字段,检索结果直接能拼出“根据《产品手册》第 3 章……”这样的引用。
3. 用 TypeScript 搭一条最小可用的 RAG 管道
3.1 项目结构与依赖选型
先说一下技术选型。嵌入模型我推荐用 OpenAI 的text-embedding-3-small,1536 维,价格便宜,效果对大多数场景够用。如果你要完全本地化,可以用bge-m3或者nomic-embed-text,通过 Ollama 跑。向量库小项目直接用 pgvector,Postgres 你本来就要用,不用多维护一个组件。
TypeScript 项目结构大概长这样:
rag-pipeline/ src/ loaders/ # 各种格式的文档加载器 chunker.ts # 切片逻辑 embedder.ts # 向量化封装 store.ts # 向量库读写 retriever.ts # 检索与重排 pipeline.ts # 串联全流程 data/ # 原始文档 scripts/ ingest.ts # 灌数据脚本 query.ts # 查询测试脚本依赖方面,核心就几个:openai做嵌入,pg连 Postgres,pdf-parse解析 PDF,cheerio清洗 HTML。不需要一上来就上 LangChain,自己写反而更可控。
3.2 文档加载与清洗的实操细节
加载这一步,我踩过最大的坑是 PDF。很多 PDF 是扫描件,pdf-parse提取出来是空的,这种情况必须上 OCR,但 OCR 成本高、速度慢,能避免就避免。还有一种情况是 PDF 里有多栏排版,提取出来的文本是左右栏交错混在一起的,读起来完全不通。我的处理方式是:如果发现提取文本的换行特别频繁且句子不连贯,就标记这个文件需要人工检查。
HTML 清洗相对简单,用 cheerio 把script、style、nav、footer这些标签去掉,只留正文。但要注意有些文档站点的正文在article标签里,有些在main里,得针对性地写选择器。
Markdown 最省心,直接读文本就行,但要注意代码块和表格不要被切片逻辑破坏。我的做法是在切片前先把代码块和表格用占位符替换掉,切完再还原,这样能保证它们作为整体不被切断。
3.3 切片策略:固定长度还是语义切分
切片策略我试过三种,说说各自的适用场景。
固定长度切片最简单,按字符数切,每 800 字符一块,重叠 100 字符。优点是实现快、结果可预测。缺点是经常把一个完整段落从中间切断,检索出来的片段读起来没头没尾。
按分隔符切片是固定长度的改进版,优先在段落边界(\n\n)、句子边界(。、!、?)切,切不到再按字符数硬切。这个策略在大多数场景下效果比纯固定长度好,实现也不复杂。
语义切片最理想,用嵌入模型判断相邻句子是否属于同一主题,主题变了就切一刀。效果最好,但成本高——每个句子都要算一次嵌入。我一般只在知识库质量要求特别高的场景才用。
实际项目里,我推荐按分隔符切片 + 固定长度兜底的组合。代码大概这样:
function chunkText(text: string, maxLen = 800, overlap = 100): string[] { const paragraphs = text.split(/\n\n+/); const chunks: string[] = []; let current = ""; for (const para of paragraphs) { if ((current + para).length > maxLen && current.length > 0) { chunks.push(current.trim()); // 保留尾部重叠,防止边界信息丢失 current = current.slice(-overlap) + para; } else { current += (current ? "\n\n" : "") + para; } } if (current.trim()) chunks.push(current.trim()); return chunks; }注意:重叠部分不要设太大,否则向量库里会有大量重复内容,检索时同一段信息反复出现,反而稀释了其他相关内容的权重。10% 到 15% 的重叠比例比较合适。
3.4 向量化与入库:批量处理与错误重试
向量化这一步,核心是批量处理和错误重试。嵌入 API 一般支持一次传多个文本,比如 OpenAI 的接口一次最多传 2048 个输入。批量传能大幅减少网络往返,但要注意单批不要太大,否则一个失败整批重来。我的做法是每批 100 个片段,失败后指数退避重试三次。
入库的时候,pgvector 的表结构大概这样:
CREATE TABLE chunks ( id SERIAL PRIMARY KEY, source TEXT NOT NULL, chunk_index INT NOT NULL, heading TEXT, content TEXT NOT NULL, embedding vector(1536) ); CREATE INDEX ON chunks USING ivfflat (embedding vector_cosine_ops);ivfflat索引适合数据量中等(几万到几十万条)的场景,建索引时要注意lists参数,一般设为sqrt(行数)左右。数据量再大就得上 HNSW 索引,查询更快但建索引更慢、占内存更多。
4. 检索质量上不去的四个真实原因
4.1 嵌入模型和业务语料不匹配
这是最隐蔽也最常见的问题。通用嵌入模型是在海量互联网文本上训练的,对日常语言理解很好,但你的业务领域可能有大量专有名词、缩写、内部代号。比如你问“XX 项目的验收标准是什么”,模型不知道“XX 项目”是什么,向量化出来的结果和文档里的“XX 项目”对不上。
解决办法有两个:一是换一个在你的领域数据上微调过的嵌入模型,二是做查询改写——用户提问后,先用 LLM 把问题改写成更完整、更明确的表述,再拿去检索。比如把“XX 项目验收”改写成“XX 项目的验收标准和验收流程是什么”,检索命中率会明显提升。
4.2 切片粒度与查询粒度不匹配
用户的问题通常很短,比如“退款要多久”。但文档里的相关段落可能是一大段关于退款政策的描述,有前提条件、有例外情况、有处理时效。如果切片切得太细,检索出来的片段只有“处理时效为 3 到 5 个工作日”,缺少前提条件,模型可能给出错误答案。
我的经验是:切片粒度要略大于典型查询的粒度。用户问一句话,你检索出来的片段应该能覆盖这句话涉及的完整上下文。800 到 1200 字符是比较稳妥的范围,具体要看你的文档密度。
4.3 纯向量检索漏掉关键词精确匹配
向量检索擅长语义相似,但对精确的关键词匹配反而不敏感。比如用户问“错误码 E5021 怎么解决”,向量检索可能返回一堆关于“错误处理”的通用文档,但真正包含“E5021”的那篇反而排在后面。因为“E5021”这个 token 在嵌入模型眼里可能没什么特殊含义。
这就是为什么必须做混合检索。BM25 这类关键词检索算法对精确匹配非常敏感,把向量检索和 BM25 的结果用 RRF 融合,能同时兼顾语义和关键词。RRF 的公式很简单:每个文档的得分是1 / (k + rank)的累加,k 一般取 60。实现起来也就几十行代码。
4.4 缺少重排导致 Top-K 里混入噪音
检索出 Top-10 个片段,不代表这 10 个都相关。向量相似度高不等于真正回答了问题。这时候需要重排模型做二次筛选。重排模型(比如bge-reranker系列)会把查询和每个候选片段拼在一起,输出一个相关性分数,比单纯的向量相似度准确得多。
重排的代价是速度——交叉编码器要对每个候选片段单独推理,10 个片段就要推理 10 次。所以工程上的做法是:向量检索先召回 Top-50,重排后取 Top-5 送给模型。这样既保证了召回率,又控制了延迟。
5. 从 Demo 到生产:管道工程化的几个关键决策
5.1 增量更新与全量重建的取舍
知识库不是一次灌完就不管了。文档会更新、会新增、会删除。全量重建简单粗暴,但数据量大时耗时很长,而且重建期间检索服务不可用。增量更新复杂,但能做到近实时。
我的做法是给每个文档算一个内容哈希,灌数据时对比哈希,变了才重新切片和向量化。删除文档时,根据source字段把对应的所有片段删掉。这样每次更新只处理变化的文档,速度很快。但要注意,如果切片策略变了,所有文档的哈希都会变,相当于全量重建,这个要提前规划好。
5.2 检索结果的缓存与降级
生产环境里,同样的查询可能被反复问到。加一层查询缓存能显著降低延迟和成本。缓存的 key 是查询文本的哈希,value 是检索结果。但要注意缓存失效——知识库更新后,旧缓存要清掉。
降级策略也很重要。如果嵌入 API 挂了,检索服务不能整个挂掉。我的做法是:嵌入失败时降级到纯关键词检索(BM25),虽然效果差一些,但至少服务可用。向量库连接失败时,返回一个友好的错误提示,而不是让整个 Agent 卡死。
5.3 观测指标:命中率、延迟与成本
RAG 管道上线后,必须有一套观测指标。我最关注的三个:
命中率(Hit Rate):检索结果里是否包含真正能回答问题的片段。这个需要人工标注一批测试问题,定期跑评估。命中率低于 80% 就说明检索环节有问题,得回去查切片和嵌入。
延迟(Latency):从用户提问到返回检索结果的时间。嵌入 API 调用、向量检索、重排各占多少,要能拆开看。一般来说,嵌入 100ms 以内、向量检索 50ms 以内、重排 200ms 以内是比较理想的。
成本(Cost):每次查询消耗的嵌入 token 数和重排调用次数。嵌入成本很低,但重排如果每次都跑 Top-50,成本会上去。可以通过缓存和动态调整 Top-K 来控制。
6. 那些文档不会告诉你的踩坑记录
6.1 中文分词的隐藏陷阱
做中文 RAG 的时候,BM25 检索需要分词。很多人直接用jieba或者nodejieba,但中文分词的粒度对检索效果影响很大。比如“人工智能”如果被切成“人工”和“智能”,用户搜“人工智能”时 BM25 可能匹配不上。我的做法是维护一个领域词典,把业务专有名词加进去,保证它们不被切碎。
还有一个坑是中英文混合。文档里经常有“使用 TypeScript 开发”这样的句子,分词器可能把“TypeScript”切成“Type”和“Script”,导致搜“TypeScript”时匹配不到。解决办法是在分词前先用正则把英文单词和数字提取出来作为整体 token。
6.2 向量维度和索引参数的调优
pgvector 的ivfflat索引有个probes参数,控制查询时扫描多少个聚类。probes越大,召回率越高但速度越慢。默认值是 1,实际用的时候至少要设到sqrt(lists)左右。我一般设lists = 100、probes = 10,在召回和速度之间取平衡。
还有一个容易忽略的点:向量归一化。如果你用余弦相似度,向量必须归一化,否则内积和余弦相似度不等价。OpenAI 的嵌入接口返回的向量默认是归一化的,但如果你自己跑本地模型,一定要检查这一点。
6.3 重排模型的延迟与成本平衡
重排模型效果好,但延迟是实打实的。我实测下来,bge-reranker-base在 CPU 上跑 50 个候选片段大概要 1 到 2 秒,GPU 上能压到 200ms 以内。如果你的场景对延迟敏感,要么上 GPU,要么减少候选数量。
还有一个取巧的办法:只对 Top-20 做重排。向量检索召回 Top-50,先用一个轻量级的规则过滤掉明显不相关的(比如完全不包含查询关键词的),剩下的 20 个再送重排。这样能在几乎不损失效果的前提下把重排成本降一半。
6.4 知识库更新后的缓存一致性
前面提到查询缓存,这里展开说一个具体的坑。知识库更新后,如果缓存没清,用户会拿到旧结果。但全量清缓存又会导致缓存命中率骤降。我的做法是给缓存加一个版本号,知识库每次更新版本号加一,查询时带上版本号作为 key 的一部分。这样旧版本的缓存自然失效,新版本缓存逐步建立,平滑过渡。
7. 下一步可以往哪里走
RAG 基础管道跑通之后,有几个方向可以继续深入。一个是Agentic RAG,让 Agent 自己决定什么时候检索、检索什么、检索几次,而不是每次查询都固定走一遍管道。另一个是GraphRAG,把知识图谱和向量检索结合起来,处理多跳推理类的问题。还有就是多模态 RAG,把图片、表格也纳入检索范围。
但我的建议是,先把基础管道做扎实。切片策略调好、混合检索跑通、重排加上、观测指标建起来。这些基础工作做到位,效果已经能覆盖 80% 的场景。后面那些高级玩法,等基础管道稳定了再逐步叠加,不然出了问题你根本分不清是基础环节的锅还是高级特性的锅。
我个人在实际项目里的体会是,RAG 的效果提升往往不来自某个炫酷的新技术,而是来自把每个环节的细节抠到位。切片多调几版、嵌入模型多试几个、检索参数多跑几组对比,这些笨功夫才是真正拉开差距的地方。