拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI Agent知识获取管道:RAG检索增强生成全流程与TypeScript实战

AI Agent知识获取管道:RAG检索增强生成全流程与TypeScript实战

1. 为什么知识获取管道是 AI Agent 的分水岭

做 AI Agent 的人迟早会撞上一堵墙:模型本身很聪明,但你问它公司内部的报销流程、上周刚更新的接口文档、某个客户的特殊约定,它要么一本正经地胡说,要么干脆承认不知道。这不是模型不行,而是它的知识被冻结在训练截止那一刻,而你的业务知识每天都在变。

知识获取管道就是解决这个问题的核心组件。它的职责很明确:在 Agent 需要知识的时候,把正确、最新、相关的信息从外部知识源里捞出来,塞进模型的上下文窗口,让模型基于真实材料回答。这套机制最主流的实现就是RAG(检索增强生成,Retrieval-Augmented Generation)。

我见过太多团队在 Agent 项目上翻车,不是因为模型选错了,而是因为知识管道做得稀烂。检索回来的东西驴唇不对马嘴,模型再强也只能基于垃圾输入产出垃圾输出。所以我把 RAG 放在这个系列第四篇来讲,因为它是从"玩具 Demo"跨到"能上生产"的关键一步。

这篇文章面向的是已经动手搭过 Agent、想认真把知识管道做扎实的开发者。我会用TypeScript作为主要实现语言,因为它在 Agent 工程化里越来越主流,类型系统能帮你在管道这种多环节串联的场景里少踩很多坑。全文围绕一个核心问题展开:一条能用的 RAG 管道,到底由哪些环节组成,每个环节的坑在哪里,怎么用代码落地。

先给一个整体认知:RAG 不是"向量数据库 + 大模型"这么简单。它是一条流水线,从文档进来,到答案出去,中间至少经过切分、向量化、存储、检索、重排、拼装、生成七个环节。任何一个环节偷懒,整条管道的效果都会塌方。下面我按数据流动的顺序,把每个环节拆开讲。

2. 文档切分:决定检索质量的第一道关卡

2.1 切分粒度为什么比你想的更重要

很多人做 RAG 的第一步是找个库把 PDF 读成文本,然后按固定字数一切,扔进向量库就完事了。这是最典型的翻车起点。切分粒度直接决定了检索的"最小语义单元",切得太碎,一个完整的意思被拆散,检索出来的是半句话;切得太粗,一个块里混了好几个主题,向量被平均化,检索精度暴跌。

我一般把切分策略分成三个层次来考虑。固定长度切分最简单,按 token 数或字符数硬切,适合结构松散的纯文本,但几乎必然切断语义。递归字符切分是折中方案,按段落、句子、标点的优先级递归尝试,尽量在自然边界断开,这是大多数场景的默认选择。语义切分最贵也最准,用嵌入模型判断相邻句子的语义相似度,在语义转折处断开,适合对精度要求极高的场景。

实际项目里我的经验是:先看文档结构,再决定切分策略。如果原始文档本身有清晰的标题层级(Markdown、HTML、带书签的 PDF),那就顺着结构切,一个二级标题下的内容作为一个块,这比任何算法都靠谱。结构信息是免费的语义信号,不用白不用。

2.2 用 TypeScript 实现一个带重叠的递归切分器

切分时有个关键参数叫重叠(overlap),就是相邻两个块之间保留一部分重复内容。为什么需要它?因为无论怎么切,边界处的语义都可能被割裂,重叠能让被切断的信息至少在一个块里是完整的。经验值一般是块大小的 10% 到 20%。

下面是一个我常用的递归切分实现,核心思路是按分隔符优先级逐层尝试,切出来的块如果还太大就继续往下切:

interface ChunkOptions { chunkSize: number; // 目标块大小(字符数) chunkOverlap: number; // 重叠大小 separators: string[]; // 分隔符优先级,从粗到细 } const DEFAULT_SEPARATORS = ["\n\n", "\n", "。", "!", "?", ".", " ", ""]; function recursiveSplit(text: string, options: ChunkOptions): string[] { const { chunkSize, chunkOverlap, separators } = options; // 找到当前层级能用的分隔符 const separator = separators.find((s) => text.includes(s)) ?? ""; const parts = separator ? text.split(separator) : [text]; const chunks: string[] = []; let current = ""; for (const part of parts) { const candidate = current ? current + separator + part : part; if (candidate.length <= chunkSize) { current = candidate; } else { if (current) chunks.push(current); // 单个 part 就超长,需要递归用更细的分隔符切 if (part.length > chunkSize) { const nextSeparators = separators.slice(separators.indexOf(separator) + 1); chunks.push(...recursiveSplit(part, { ...options, separators: nextSeparators })); current = ""; } else { current = part; } } } if (current) chunks.push(current); // 加上重叠:把上一个块的尾部拼到当前块前面 return applyOverlap(chunks, chunkOverlap); } function applyOverlap(chunks: string[], overlap: number): string[] { if (overlap <= 0) return chunks; return chunks.map((chunk, i) => { if (i === 0) return chunk; const prev = chunks[i - 1]; const tail = prev.slice(Math.max(0, prev.length - overlap)); return tail + chunk; }); }

这段代码有几个细节值得说。分隔符数组的顺序就是优先级,从段落分隔符\n\n一路降到空字符串,空字符串意味着最后兜底按字符硬切。applyOverlap是在切分完成后统一加重叠,比在切分过程中处理要清晰得多。

注意:重叠不是越大越好。重叠太大会导致向量库里充满重复内容,检索时返回一堆高度相似的块,浪费上下文窗口。我实测下来 10% 到 15% 是个甜点区,超过 30% 基本是负收益。

2.3 元数据:被 90% 的人忽略的检索利器

切分的时候千万别只存文本内容,一定要把元数据一起存下来。元数据包括来源文件名、章节标题、页码、更新时间、文档类型等等。为什么重要?因为检索阶段你可以用元数据做过滤,比如"只在最近三个月更新的文档里搜"、"只搜产品手册不搜会议纪要"。纯向量检索做不到这种精确控制,元数据过滤能把召回范围先缩小一圈,精度立刻上一个台阶。

我的做法是给每个块生成一个结构化的对象,而不是裸字符串:

interface DocumentChunk { id: string; content: string; metadata: { source: string; // 来源文件 section?: string; // 所属章节 page?: number; // 页码 updatedAt: string; // 更新时间 docType: string; // 文档类型 }; }

这个结构在后续的检索、重排、引用溯源环节都会反复用到。尤其是引用溯源,当 Agent 给出答案时,你能告诉用户"这个结论来自《XX手册》第 3 章",可信度完全不一样。

3. 向量化与存储:把语义变成可计算的距离

3.1 嵌入模型选型:不是越贵越好

向量化的本质是把文本映射到一个高维空间,语义相近的文本在空间里距离也近。这个映射由**嵌入模型(Embedding Model)**完成。选型时我关注三个维度:维度、语言支持、成本。

维度决定了向量库的存储和检索开销。常见的有 768 维、1024 维、1536 维。维度越高表达能力越强,但检索越慢、存储越贵。对于中文为主的场景,我一般优先选对中文优化过的模型,因为很多英文模型在中文语义上的表现会明显打折。

这里有个很多人踩的坑:嵌入模型和生成模型是两回事。你可以用 A 模型做嵌入,用 B 模型做生成,它们互不干扰。嵌入模型只负责把文本变成向量,生成模型负责根据检索结果写答案。选嵌入模型时看的是检索准确率,选生成模型时看的是语言组织和推理能力,别混为一谈。

还有一个隐蔽的坑:换了嵌入模型,整个向量库必须重建。因为不同模型生成的向量空间不兼容,旧向量和新向量放在一起检索,结果完全是乱的。所以嵌入模型一旦定了,尽量别中途换;如果非要换,做好全量重嵌入的准备。

3.2 向量库的选型逻辑

向量库这块选择很多,我不列一堆产品名,而是给你一套选型逻辑。核心问自己三个问题:

  • 数据量多大?几万条以内,用内存向量库甚至本地文件就够了,别上重型方案。百万级以上才需要考虑分布式向量库。
  • 要不要持久化?生产环境必须持久化,而且要支持增量更新,不能每次全量重建。
  • 要不要混合检索?如果需要关键词和向量的混合检索,得选支持这种能力的库。

对于大多数中小型 Agent 项目,我的建议是先用最简单的方案跑通,别一上来就上分布式。我见过太多项目在选型上纠结两周,结果数据量才几千条,用什么都绰绰有余。工程上有个原则叫 YAGNI(You Aren't Gonna Need It),在向量库选型上尤其适用。

存储的时候,向量和元数据要一起存。检索时先按向量相似度召回候选,再用元数据过滤,最后返回带元数据的完整块。下面是一个存储层的抽象接口,方便你替换底层实现:

interface VectorStore { upsert(chunks: DocumentChunk[], vectors: number[][]): Promise<void>; search( queryVector: number[], topK: number, filter?: Record<string, unknown> ): Promise<Array<{ chunk: DocumentChunk; score: number }>>; }

这个接口把"存"和"查"两件事抽象出来,底层换成任何向量库都不影响上层逻辑。这是我在多个项目里验证过的做法,前期多花十分钟设计接口,后期换库能省好几天。

3.3 批量嵌入与限流处理

调用嵌入接口时,别一条一条调,要批量。大多数嵌入服务都支持一次传多条文本,批量调用能把网络往返开销摊薄,速度提升非常明显。但批量也有上限,一般单次几十到上百条,超了会被拒绝。

批量调用必须配限流和重试。嵌入接口通常有速率限制,你一股脑发几千条请求,会被限流甚至封禁。我的做法是用一个带并发控制的队列,比如限制同时最多 5 个请求在飞,失败的请求指数退避重试:

async function embedWithRetry( texts: string[], embedFn: (batch: string[]) => Promise<number[][]>, batchSize = 64, maxRetries = 3 ): Promise<number[][]> { const results: number[][] = []; for (let i = 0; i < texts.length; i += batchSize) { const batch = texts.slice(i, i + batchSize); let attempt = 0; while (true) { try { results.push(...(await embedFn(batch))); break; } catch (err) { attempt++; if (attempt > maxRetries) throw err; // 指数退避:1s, 2s, 4s... await new Promise((r) => setTimeout(r, 1000 * 2 ** (attempt - 1))); } } } return results; }

这段代码看起来简单,但它是生产环境能不能稳定跑完的关键。我踩过的坑是:本地测试几十条数据一切正常,上线跑几万条文档时因为没做限流,被服务端限流后大量请求失败,整个索引任务卡死。加上重试和退避之后,同样的任务稳稳跑完。

4. 检索策略:从"能搜到"到"搜得准"

4.1 纯向量检索的天花板在哪里

向量检索擅长语义匹配,你问"怎么报销差旅费",它能召回"出差费用申请流程"这种字面不同但意思相近的内容。这是它的强项。但它也有明显短板:对精确关键词不敏感。

举个例子,你搜一个产品型号"XR-2000",向量检索可能给你返回一堆"XX-2000"、"XR-3000"的相似内容,因为它关注的是整体语义,而不是精确的字符匹配。再比如搜一个专有名词、错误码、人名,向量检索经常抓瞎。

这就是为什么**混合检索(Hybrid Search)**成了标配。混合检索同时跑两路:一路向量检索负责语义召回,一路关键词检索(比如 BM25)负责精确匹配,然后把两路结果融合。融合算法常用 RRF(Reciprocal Rank Fusion,倒数排名融合),它不需要两路分数可比,只看排名,简单又稳。

4.2 用 RRF 融合向量与关键词结果

RRF 的核心思想很朴素:一个文档如果在多路检索里都排得靠前,那它大概率真的相关。公式是每个文档的得分等于它在各路检索中排名的倒数之和:

function reciprocalRankFusion( resultLists: Array<Array<{ id: string; score: number }>>, k = 60 ): Array<{ id: string; score: number }> { const scoreMap = new Map<string, number>(); for (const list of resultLists) { list.forEach((item, rank) => { const rrfScore = 1 / (k + rank + 1); scoreMap.set(item.id, (scoreMap.get(item.id) ?? 0) + rrfScore); }); } return [...scoreMap.entries()] .map(([id, score]) => ({ id, score })) .sort((a, b) => b.score - a.score); }

k这个常数一般取 60,作用是平滑排名靠前文档的权重差距,避免第一名一家独大。这个算法我在多个项目里用过,比手动调两路分数的权重靠谱得多,因为向量相似度和 BM25 分数根本不在一个量纲上,硬加权很容易调崩。

4.3 重排:把最相关的顶到最前面

检索召回之后,还有一步能显著提升精度:重排(Rerank)。检索阶段为了快,用的是向量点积或近似最近邻,精度有限。重排阶段用一个更精细的模型(通常是交叉编码器)对召回的候选逐一打分,把真正相关的排到最前面。

为什么重排有效?因为检索阶段是"粗筛",它把查询和文档分别编码成向量再算距离,两者没有交互。而重排模型把查询和文档拼在一起送进模型,能捕捉到细粒度的交互信息,精度高得多。代价是慢,所以只对召回的 Top N(比如 20 条)做重排,再取 Top K(比如 5 条)给生成模型。

我的标准流程是:混合检索召回 20 到 50 条,重排后取 3 到 5 条。这个比例是实测出来的,召回太少会漏,召回太多重排又慢。重排这一步是"性价比最高的精度提升手段",如果你的 RAG 效果不理想,先别急着换模型,加个重排试试。

4.4 查询改写:让用户的烂问题也能搜到好东西

用户的问题往往很烂。可能是一句话里混了好几个意图,可能是口语化表达,可能省略了关键信息。直接拿这种问题去检索,效果自然差。**查询改写(Query Rewriting)**就是在检索前,用模型把用户问题改写成更适合检索的形式。

常见的改写策略有几种。多查询生成:把一个问题改写成几个不同角度的查询,分别检索再合并结果,覆盖更全面。假设文档生成(HyDE):让模型先"编"一个理想答案,再用这个答案去检索,因为答案和文档的语义更接近。指代消解:把"它"、"这个"这类代词替换成具体指代的对象,这在多轮对话里特别重要。

我一般会在 Agent 里加一个轻量的查询改写步骤,成本很低但收益明显。尤其是多轮对话场景,用户第二句经常是"那它的价格呢",不消解指代根本没法检索。

5. 上下文拼装与生成:最后一步决定用户体验

5.1 上下文窗口是稀缺资源,要精打细算

检索回来的块怎么塞进提示词,这里面有讲究。上下文窗口是有限的,塞太多无关内容不仅浪费,还会稀释真正相关的信息,让模型抓不住重点。这就是所谓的"迷失在中间"现象:模型对上下文开头和结尾的信息更敏感,中间的内容容易被忽略。

我的拼装策略是:按相关性排序,最相关的放最前面和最后面。具体做法是把重排后的块按分数排好,然后交错放置,把最高分的放开头,次高分的放结尾,中间的按顺序填。这样能最大化利用模型对首尾位置的注意力偏好。

每个块前面要加来源标注,比如[来源:产品手册 第3章]。这有两个好处:一是模型能引用来源,二是当多个块内容冲突时,模型能根据来源判断可信度。拼装的模板大概长这样:

function buildContext(chunks: Array<{ content: string; metadata: any }>): string { return chunks .map((c, i) => `[片段${i + 1} | 来源:${c.metadata.source}]\n${c.content}`) .join("\n\n---\n\n"); } function buildPrompt(question: string, context: string): string { return `你是一个严谨的助手。请仅根据下面提供的资料回答问题。 如果资料中没有相关信息,请明确说明"资料中未提及",不要编造。 资料: ${context} 问题:${question} 回答:`; }

注意提示词里那句"仅根据资料回答"和"没有就说没有",这是抑制幻觉的关键。不加这句,模型很容易把检索到的片段和自己的先验知识混在一起,编出看似合理实则错误的内容。

5.2 引用溯源:让答案可验证

生产环境的 RAG 必须支持引用溯源。用户看到答案后,能点开看到这个结论来自哪个文档的哪一段。这不仅是信任问题,也是排错手段:当答案错了,你能快速定位是检索错了还是生成错了。

实现上,就是在拼装上下文时给每个块编号,然后要求模型在回答时标注引用了哪个片段。解析模型输出时把编号映射回原始文档的元数据。这个功能做起来不难,但对产品可信度的提升是巨大的。我在项目里加了这个功能后,用户对 Agent 的信任度明显上升,因为他们能自己验证。

5.3 流式输出与首字延迟

生成阶段还有个体验问题:首字延迟。如果等模型把整个答案生成完再返回,用户要盯着空白屏幕好几秒。解决办法是流式输出,模型生成一个 token 就推一个 token 给前端,用户能立刻看到内容在往外冒,感知延迟大大降低。

流式输出在 TypeScript 里通常用异步迭代器实现,逐块读取响应流并推送给前端。这块要注意的是,流式输出和引用溯源要配合好:引用信息一般在流结束后统一返回,或者用特殊标记在流中插入。

6. 评估与调优:没有度量就没有优化

6.1 建立一套能跑的评估集

RAG 最怕的是"感觉还行"。你觉得效果不错,但到底哪里好哪里差,说不清楚。所以必须建立评估集:一批有标准答案的问题,每次改动管道后跑一遍,看指标变化。

评估集不用很大,几十到上百条就够,但要有代表性,覆盖各种问题类型:事实查询、多跳推理、否定问题、无答案问题。无答案问题特别重要,用来测试模型会不会在资料里没有答案时老实说"不知道",而不是硬编。

评估指标我关注两个层面。检索层面看召回率(相关文档有没有被召回)和精确率(召回的有多少是相关的)。生成层面看答案正确性和忠实度(答案是否忠于检索到的资料,有没有编造)。检索和生成要分开评估,否则出了问题你不知道是哪一环的锅。

6.2 定位瓶颈:是检索的锅还是生成的锅

调优的第一步是定位瓶颈。方法很简单:拿评估集里答错的案例,人工看检索回来的内容对不对。如果检索回来的内容里根本没有正确答案,那是检索的问题;如果检索回来了但模型没用上或者用错了,那是生成的问题。

检索问题通常出在切分和检索策略上:块切得不好、嵌入模型不合适、没做混合检索、没做重排。生成问题通常出在提示词和上下文拼装上:提示词没约束好、上下文塞太多、块顺序不对。

我踩过的一个典型坑:有段时间答案质量突然下降,排查半天发现是新导入的一批文档切分粒度不对,块特别大,导致检索时一个块里混了好几个主题,向量被平均化,召回精度暴跌。重新切分后立刻恢复。这个案例说明数据质量比模型调参重要得多,别一上来就想着换模型。

6.3 增量更新与知识保鲜

知识是活的,文档每天都在变。RAG 管道必须支持增量更新:新文档进来能加进去,旧文档改了能更新,删了的能移除。全量重建索引在小数据量下还行,数据一多就扛不住。

增量更新的关键是给每个块一个稳定的 ID,通常用"文档路径 + 块序号"或者内容哈希。文档更新时,先删掉这个文档的所有旧块,再插入新块。内容哈希的好处是内容没变的块可以跳过重新嵌入,省成本。

提示:增量更新一定要处理"删除"场景。很多团队只做了新增和更新,忘了删除,结果旧版本的文档一直留在库里,检索时新旧内容混在一起,模型无所适从。删除逻辑必须在设计阶段就考虑进去。

7. 我在实际项目里踩过的几个真坑

第一个坑是过度依赖向量检索。早期我做的管道只有向量检索,遇到精确匹配的场景就翻车。后来加了关键词检索和 RRF 融合,效果立竿见影。教训是:向量检索不是银弹,混合检索才是生产标配。

第二个坑是忽略元数据过滤。有次用户反馈搜出来的内容全是过期的旧文档,排查发现是没做时间过滤,旧文档和新文档一起参与检索,旧文档因为内容更详细反而排名更高。加上时间过滤和版本标记后解决。元数据不是可选项,是必需品。

第三个坑是提示词没约束好导致幻觉。模型很"聪明",当检索内容不完整时,它会自动用自己的知识补全,补出来的东西看着合理但是错的。后来在提示词里加了严格的约束,并且要求标注引用,幻觉率明显下降。记住:模型不会主动告诉你它不知道,你得逼它承认。

第四个坑是评估缺失导致优化靠猜。没有评估集的时候,每次调参都是凭感觉,改完不知道是变好了还是变坏了。建立评估集之后,所有优化都有了依据,效率提升不止一倍。这是我最想强调的一点:先建评估,再谈优化。

8. 从基础 RAG 到 Agentic RAG 的演进方向

基础 RAG 是一条固定流水线:检索一次,生成一次。但真实场景往往更复杂,一次检索不够,需要多轮检索、多步推理。这就是Agentic RAG的方向:把检索能力交给 Agent 自主调度,Agent 根据问题决定检索什么、检索几次、要不要换个角度再检索。

比如一个复杂问题"对比 A 产品和 B 产品的差异",基础 RAG 可能一次检索就返回一堆混杂内容。而 Agentic RAG 会先检索 A 的信息,再检索 B 的信息,然后对比。这种自主调度能力是基础 RAG 不具备的。

从工程角度看,Agentic RAG 对知识管道的要求更高:检索接口要能被 Agent 灵活调用,返回结果要结构化,要支持多轮检索的状态管理。但底层的东西没变,还是切分、嵌入、检索、重排这一套。所以把基础 RAG 做扎实,是通往 Agentic RAG 的必经之路。

我个人在实际操作中的体会是:别一上来就追求 Agentic RAG 的花哨,先把基础管道的每个环节做对。我见过太多项目在基础检索都没做好的情况下,急着上多轮检索和自主调度,结果就是花架子,效果还不如老老实实的单轮 RAG。基础打牢了,往上叠能力是水到渠成的事。

最后分享一个我常用的调试技巧:把每次检索的中间结果都打日志——召回了哪些块、重排后排序如何、最终塞进上下文的是哪几条。出问题时翻日志,一眼就能看出是哪一环掉了链子。这个习惯帮我省了无数排查时间,比任何调试工具都管用。

返回列表