
1. 为什么知识获取管道是 AI Agent 落地的第一道坎做 AI Agent 开发的人绕不开一个尴尬的现实模型本身很聪明但它不知道你公司内部的业务规则、不知道你昨天刚更新的产品文档、更不知道你私有的那几百份技术手册里写了什么。你问它一个通用问题它答得头头是道你问它一个只有你们团队才知道的细节它要么一本正经地胡说八道要么直接告诉你“我无法回答这个问题”。这就是知识获取管道要解决的核心矛盾。所谓知识获取管道说白了就是给 AI Agent 装上一套“外部记忆系统”让它在回答问题之前先去指定的知识库里把相关资料捞出来再基于这些资料组织答案。这套机制在行业里有个更正式的名字——检索增强生成也就是 RAGRetrieval-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 自带混合检索能力支持向量检索和关键词检索的结合。如果团队已经在用 PostgreSQLpgvector 扩展是一个很务实的选择。它把向量检索能力直接集成到关系型数据库里不用额外维护一套向量数据库事务一致性和备份恢复都能复用现有的数据库运维体系。对于中小规模的知识库百万级向量以内pgvector 完全够用。提示选向量数据库的时候不要只看性能基准测试。要考虑团队的运维能力、现有的技术栈、以及未来的扩展需求。一个需要专职运维的分布式向量数据库对于小团队来说可能是负担而不是助力。3.3 索引构建与增量更新向量入库之后需要建立索引才能快速检索。不同的索引类型在检索速度和精度之间有不同的权衡。最常用的是 HNSW 索引它构建速度快检索精度高内存占用适中适合大多数场景。IVF 系列索引如 IVF_FLAT、IVF_PQ适合超大规模数据集通过聚类减少检索范围但精度会有一定损失。PQProduct Quantization索引通过压缩向量来减少内存占用适合内存受限的场景但精度损失较大。索引参数需要根据数据规模和精度要求来调。以 HNSW 为例M 参数控制每个节点的连接数M 越大检索精度越高但内存占用也越大efConstruction 控制构建时的搜索范围值越大索引质量越好但构建越慢efSearch 控制检索时的搜索范围值越大精度越高但速度越慢。一般从 M16、efConstruction200、efSearch64 开始调根据实际效果微调。增量更新是另一个需要提前考虑的问题。知识库不是一成不变的新文档会不断加入旧文档会更新或删除。如果每次更新都重建整个索引成本太高。好的向量数据库应该支持增量插入和删除并且能在不重建全量索引的情况下保持检索性能。选型的时候要确认这一点。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 FusionRRF它不依赖具体的相似度分数而是根据两路检索结果中的排名来计算融合分数。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和moduleResolutionnode10这些选项已经弃用了。新建项目的时候直接用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: PartialSplitterConfig {}) { 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: Recordstring, unknown; } export class InMemoryVectorStore { private records: VectorRecord[] []; async add(records: VectorRecord[]): Promisevoid { this.records.push(...records); } async search( queryVector: number[], topK: number, filter?: (meta: Recordstring, unknown) boolean ): PromiseArrayVectorRecord { 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: AwaitedReturnTypetypeof 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 管道的每个环节都加上日志和埋点。记录每次查询的原始问题、改写后的查询、检索到的文档块及其分数、重排序后的结果、最终生成的答案、以及用户的反馈。这些数据是后续优化的基础。没有数据支撑的优化都是拍脑袋。