☰
AI Agent 知识获取管道:RAG 检索增强生成实战与优化
2026/9/29 11:54:23 网站建设 项目流程

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

做 AI Agent 的人,十有八九会在某个时刻撞上同一堵墙:模型本身很聪明,但你问它公司内部的报销标准、上周刚改的接口文档、某个客户的特殊约定,它要么一本正经地胡说,要么干脆说不知道。这不是模型不行,而是它的知识边界被训练数据锁死了。知识获取管道要解决的,就是把这个边界打开——让 Agent 在回答问题之前,先去外部知识源里把相关材料捞回来,再基于材料作答。这套机制的核心就是RAG(检索增强生成)。

我在实际项目里见过太多团队,模型选型纠结了两个月,Prompt 调了几十版,最后卡在"答不准"上,回头一看,问题根本不在模型,而在检索这一环。检索没做好,后面生成再强也是空中楼阁。所以我把 RAG 放在"走进 AI Agent"系列的第四篇,因为它是从"玩具 Demo"迈向"能用的产品"的关键一跳。

这篇文章适合谁看?如果你已经能跑通一个基础的 Agent 对话循环,但对"怎么让 Agent 用上自己的知识"还没有清晰路径,那这篇就是给你写的。我会用TypeScript作为主要实现语言,因为它在 Agent 工程化落地里越来越主流——类型系统能帮你在管道拼接时少踩很多坑。全文不堆概念,重点讲清楚三件事:RAG 的每个环节到底在干什么、为什么这么设计、以及我在实操中踩过的那些坑。

先说一个反直觉的结论:RAG 的瓶颈几乎从来不在"生成",而在"检索"。很多人第一次搭 RAG,把 80% 的精力花在调 Prompt 和换模型上,结果命中率死活上不去。真相是,只要检索回来的内容是对的,哪怕用中等能力的模型,答案质量也能接受;反过来,检索回来的是一堆似是而非的片段,再强的模型也只能"垃圾进垃圾出"。理解了这一点,你就知道该把力气往哪儿使了。

2. RAG 管道的四个环节:拆开看每一步在做什么

2.1 从"文档"到"可检索单元"的切分逻辑

RAG 的第一步是把原始知识源(PDF、Markdown、网页、数据库记录)变成一段段可以被检索的文本块,也就是Chunk。这一步看着简单,实则决定了整个管道的上限。我见过最粗暴的做法是按固定字数硬切,比如每 500 字一刀,结果一句话被拦腰截断,检索出来的片段语义残缺,模型读得一头雾水。

合理的切分要兼顾两个目标:语义完整性和检索粒度。语义完整性指的是尽量在段落、标题、列表项这些自然边界处切;检索粒度指的是块不能太大也不能太小——太大,一块里混了好几个主题,检索时噪声多;太小,上下文不足,模型拼不出完整答案。

我的经验值是:中文技术文档,单块控制在 300 到 800 字之间比较舒服,同时保留 10% 到 20% 的重叠(Overlap),防止关键信息正好卡在切口上。重叠的意思是相邻两块共享一部分内容,比如块 A 是 1 到 500 字,块 B 就从 400 字开始,这样跨边界的信息不会丢。

用 TypeScript 写一个带重叠的递归切分器,核心思路是先按标题层级切,再按段落切,最后才按字数兜底:

interface Chunk { id: string; text: string; metadata: { source: string; heading?: string; index: number }; } function splitByHeadings(text: string): string[] { // 按 Markdown 标题切分,保留标题作为上下文 const sections = text.split(/\n(?=#{1,3}\s)/); return sections.filter((s) => s.trim().length > 0); } function splitWithOverlap(text: string, maxLen = 600, overlap = 100): string[] { if (text.length <= maxLen) return [text]; const chunks: string[] = []; let start = 0; while (start < text.length) { const end = Math.min(start + maxLen, text.length); chunks.push(text.slice(start, end)); if (end === text.length) break; start = end - overlap; } return chunks; }

这里有个细节值得说:metadata 比文本本身还重要。每个 Chunk 都要带上来源文件、所属标题、在原文中的位置。为什么?因为检索回来之后,你需要给模型提供"这段话出自哪里"的线索,模型才能判断可信度;同时前端展示引用来源时也靠它。我早期偷懒没存 metadata,后来做引用溯源时全部返工,血的教训。

2.2 向量化:把语义变成可计算的坐标

切好块之后,要把每块文本转成向量(Embedding),也就是一串浮点数。这串数字的妙处在于:语义相近的文本,向量距离也近。这样"检索"就变成了"找距离最近的几个向量",数学上非常干净。

选 Embedding 模型时,别只盯着排行榜。我实际用下来,要综合考虑三点:语言支持(中文场景必须选中文语料训练充分的)、维度成本(维度越高存储和计算越贵,768 或 1024 维通常是性价比甜点)、是否支持本地部署(涉及敏感数据时这是硬要求)。热词里提到的"本地知识库"场景,基本都要求 Embedding 能在内网跑,这时候开源模型就是首选。

向量化本身没什么玄机,但有个坑必须提醒:查询和文档必须用同一个模型。我见过有人文档用 A 模型编码,查询时手滑换成了 B 模型,结果检索结果乱七八糟,排查了半天才发现是模型不一致。向量空间都不一样,距离计算毫无意义。

async function embedBatch(texts: string[]): Promise<number[][]> { // 批量编码,注意控制单次请求的文本数量,避免超限 const BATCH_SIZE = 32; const results: number[][] = []; for (let i = 0; i < texts.length; i += BATCH_SIZE) { const batch = texts.slice(i, i + BATCH_SIZE); const vectors = await embeddingClient.encode(batch); results.push(...vectors); } return results; }

批量处理时要注意单次请求的 token 上限。我一般把批大小设在 16 到 64 之间,太大容易触发限流,太小则吞吐上不去。这个值没有标准答案,得根据你用的服务实测调整。

2.3 向量库选型:别一上来就上重型武器

存向量需要一个向量数据库。市面上的选择从轻到重排一排:内存数组、SQLite 扩展、FAISS、Milvus、Qdrant、Pinecone 等等。新手最容易犯的错是一上来就部署一套分布式向量库,结果数据量才几千条,纯属杀鸡用牛刀。

我的建议是按数据规模分档:

数据规模推荐方案理由
1 万条以内内存 + 余弦相似度零依赖,启动快,够用
1 万到 100 万FAISS 或 SQLite 向量扩展单机性能强,运维简单
100 万以上Qdrant / Milvus支持分布式、过滤、持久化

对于大多数内部知识库场景,数据量其实就在几万条这个量级,用 FAISS 单机跑完全没问题。等真的到了百万级再考虑上分布式,别提前优化。

用 TypeScript 做内存检索其实很简单,核心就是算余弦相似度:

function cosineSimilarity(a: number[], b: number[]): number { let dot = 0, normA = 0, 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)); } function topK(query: number[], vectors: number[][], k = 5) { return vectors .map((v, i) => ({ index: i, score: cosineSimilarity(query, v) })) .sort((x, y) => y.score - x.score) .slice(0, k); }

这段代码在几千条数据下毫秒级返回,完全够用。等数据涨到十万条以上,再换成 FAISS 的 WASM 版本或者外部服务。

2.4 检索与重排:把"差不多"变成"就是它"

最朴素的检索就是"向量最近邻 Top-K",但实际用起来你会发现,Top-5 里经常混进一两条不相关的。这时候就需要重排(Rerank)。重排的思路是:先用向量检索快速召回一批候选(比如 20 条),再用一个更精细的模型对候选逐条打分,挑出真正相关的几条。

为什么两阶段?因为向量检索快但粗,重排模型准但慢。先用快的把范围缩小,再用准的精选,是典型的工程折中。热词里提到的"RAG 命中率"(hit rate),很大程度上就靠重排来提升。

除了重排,还有两个提升命中率的实用手段:混合检索和查询改写。混合检索是把向量检索和关键词检索(BM25)的结果融合,因为有些查询靠关键词匹配更准,比如查一个具体的错误码。查询改写则是让模型先把用户的口语化问题改写成更适合检索的形式,比如把"报销咋弄"改写成"差旅报销流程和标准"。

async function hybridRetrieve(query: string, k = 5) { const [vectorHits, keywordHits] = await Promise.all([ vectorSearch(query, k * 4), bm25Search(query, k * 4), ]); // 用 RRF(倒数排名融合)合并两路结果 const scores = new Map<string, number>(); const RRF_K = 60; vectorHits.forEach((hit, rank) => { scores.set(hit.id, (scores.get(hit.id) ?? 0) + 1 / (RRF_K + rank)); }); keywordHits.forEach((hit, rank) => { scores.set(hit.id, (scores.get(hit.id) ?? 0) + 1 / (RRF_K + rank)); }); return [...scores.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, k); }

RRF 这个融合算法我很推荐,它不需要调权重,对两路结果的分数尺度不敏感,实测下来比手动加权省心得多。

3. 把管道接进 Agent:检索结果怎么喂给模型

3.1 上下文拼装:不是塞得越多越好

检索回来 5 条 Chunk,怎么塞进 Prompt?新手常见的做法是全塞进去,觉得信息越多越好。但上下文窗口是有限资源,塞太多无关内容反而会稀释关键信息,让模型抓不住重点。而且 token 是要花钱的,塞一堆废话纯属浪费。

我的做法是:按相关度排序,取前 3 到 5 条,每条前面标注来源,让模型知道这是外部知识而非它自己的记忆。Prompt 模板大致长这样:

你是一个基于知识库回答问题的助手。请严格依据下面提供的资料作答, 如果资料中没有相关信息,直接说"资料中未提及",不要编造。 【资料 1】来源:员工手册.pdf 差旅报销标准:一线城市住宿每晚不超过 500 元…… 【资料 2】来源:财务制度.md 报销需在出差结束后 15 个工作日内提交…… 用户问题:出差去上海住宿能报多少?

这个模板里有两个关键约束:"严格依据资料"和"没有就说没有"。前者防止模型自由发挥,后者是抑制幻觉的关键。我实测下来,加上"资料中未提及"这个兜底话术后,编造答案的比例明显下降。

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

一个成熟的 RAG 系统,答案里应该带上引用标记,比如"根据《员工手册》第 3 章……"。这不仅是体验问题,更是信任问题——用户能点开来源核对,才敢把系统用在正经业务上。

实现上,就是在拼装上下文时给每条资料编号,然后要求模型在答案里用[1][2]这样的标记引用。解析答案时把标记映射回原始来源即可。这一步不难,但很多 Demo 都省了,导致系统看起来"能答",实际没人敢用。

3.3 多轮对话下的检索策略

单轮问答好办,多轮对话就麻烦了。用户第二句说"那北京呢",你拿"那北京呢"去检索,啥也搜不到。这时候需要查询改写:把当前问题和历史对话一起喂给模型,让它改写出一个自包含的检索查询,比如"出差去北京的住宿报销标准是多少"。

async function rewriteQuery(history: Message[], current: string): Promise<string> { if (history.length === 0) return current; const prompt = `根据以下对话历史,把用户的最新问题改写成一个独立完整的检索查询, 只输出改写后的查询,不要解释。\n\n历史:\n${history.map(m => `${m.role}: ${m.content}`).join('\n')}\n\n最新问题:${current}`; return await llm.complete(prompt); }

这个改写步骤在多轮场景下几乎是必需的,否则检索质量会断崖式下跌。代价是多一次模型调用,延迟增加几百毫秒,但换来的是命中率的大幅提升,非常值。

4. 实操中那些文档不会告诉你的坑

4.1 切分粒度调优:从"能跑"到"好用"的必经之路

前面说了切分的重要性,但具体切多大,没有万能公式。我的做法是先跑一版基线,再用真实问题测命中率,然后针对性调整。具体操作:准备 20 到 30 个真实用户问题,人工标注每个问题的正确答案出自哪个文档的哪一段,然后跑检索看 Top-5 里有没有命中。命中率低于 70% 就说明切分或检索有问题。

调优时优先动这几个参数:块大小、重叠比例、Top-K 数量。我遇到过一种情况,块设成 1000 字时命中率只有 60%,改成 400 字后跳到 85%——原因是原文里一段话讲了三个不同主题,块太大导致检索时主题被平均掉了。这个教训是:块大小要匹配你文档的"主题密度",主题密集的文档要切小,主题松散的可以切大。

4.2 元数据过滤:被低估的检索利器

纯向量检索有个盲区:它只看语义相似,不看"该不该看"。比如用户问"2024 年的政策",向量检索可能把 2022 年的相似条款也捞回来。这时候元数据过滤就派上用场了——在检索前先按年份、部门、文档类型过滤,把范围缩小到该看的子集。

实现上,向量库基本都支持带过滤条件的检索。即使是内存方案,也可以在算相似度前先筛一遍:

function filteredSearch(query: number[], chunks: Chunk[], vectors: number[][], filter: (c: Chunk) => boolean, k = 5) { const candidates = chunks .map((c, i) => ({ chunk: c, vector: vectors[i] })) .filter(({ chunk }) => filter(chunk)); return candidates .map(({ chunk, vector }) => ({ chunk, score: cosineSimilarity(query, vector) })) .sort((a, b) => b.score - a.score) .slice(0, k); }

元数据设计得好,检索质量能上一个台阶。我在项目里给每个 Chunk 都打了source、heading、updatedAt、category四个字段,过滤时灵活组合,效果立竿见影。

4.3 增量更新:知识库不是一次性的

知识库会变。文档会更新,新文件会加进来。如果每次改动都全量重建向量,数据量一大就受不了。正确做法是增量更新:给每个 Chunk 一个稳定的 ID(比如文件路径 + 块序号的哈希),更新时只处理变化的文件,删掉旧 Chunk、插入新 Chunk。

这里有个容易忽略的点:删除要彻底。我见过有人更新文档时只插入新块,忘了删旧块,结果同一个问题检索出新旧两个版本的答案,模型直接精神分裂。所以更新逻辑必须是"先删后插",或者用文档 ID 做覆盖写。

async function upsertDocument(docId: string, content: string) { const chunks = splitDocument(content); const vectors = await embedBatch(chunks.map(c => c.text)); // 先删除该文档的所有旧块 await store.deleteByMetadata({ source: docId }); // 再插入新块 await store.insert(chunks.map((c, i) => ({ ...c, vector: vectors[i] }))); }

4.4 评估:没有度量就没有优化

最后说一个最容易被跳过、但最重要的环节:评估。没有评估,你根本不知道改动是变好了还是变坏了,全靠感觉调参,纯属瞎蒙。

我的评估方案很朴素:维护一个测试集,每条包含"问题 + 期望命中的文档片段",每次改动后跑一遍,看命中率和答案质量。命中率是客观指标,答案质量可以人工打分或用一个强模型当裁判。这个测试集不用很大,20 到 50 条就能反映趋势。关键是每次改动都跑,形成闭环。

热词里反复出现的"RAG 瓶颈""RAG 命中率",本质上都是评估缺失导致的——你不知道瓶颈在哪,自然无从优化。把评估做起来,瓶颈会自己浮出水面。

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

基础 RAG 跑通之后,你会发现它有几个天然局限:检索是一次性的,不管问题多复杂都只查一轮;检索策略是固定的,不会根据问题类型调整。这就是Agentic RAG要解决的问题——让 Agent 自己决定"要不要检索""检索几次""用什么策略检索"。

举个具体例子:用户问"对比一下 A 产品和 B 产品的退款政策"。基础 RAG 会把这个当成一个查询去检索,结果可能只捞到其中一个产品的政策。而 Agentic RAG 会先拆解成两个子查询,分别检索,再综合对比。这种"检索规划"能力,是基础 RAG 给不了的。

实现上,Agentic RAG 通常把检索封装成一个工具(Tool),让 Agent 通过工具调用来触发检索,并且可以多轮调用。这正好衔接了本系列前面讲的 Agent 工具调用机制。你可以先让 Agent 判断问题是否需要检索,需要的话生成查询、调用检索工具、评估结果是否充分,不充分就换个查询再检一次。

const retrievalTool = { name: "search_knowledge", description: "在内部知识库中检索相关信息,输入检索查询,返回相关文档片段", parameters: { query: "string" }, execute: async ({ query }: { query: string }) => { const hits = await hybridRetrieve(query, 5); return hits.map(h => `【${h.chunk.metadata.source}】${h.chunk.text}`).join("\n\n"); }, };

把检索做成工具之后,Agent 的自主性就上来了。它可以根据第一次检索的结果决定下一步,而不是被动地接受一次检索的结果。这是从"RAG 管道"到"RAG 智能体"的关键转变,也是当前这个方向最值得投入的地方。

我个人在实际项目里的体会是:先把基础 RAG 的每个环节做扎实,再考虑 Agentic 化。基础没打好就上 Agentic,只会把问题放大——检索本身就不准,让 Agent 多检几轮也只是多捞几遍垃圾。顺序不能反。

最后分享一个小技巧:调试 RAG 时,把每次检索的查询、召回的 Chunk、最终拼装的 Prompt 全部打日志。出问题时一眼就能看出是检索没召回、还是召回了但没用好。这个日志我在每个 RAG 项目里都会加,省下的排查时间难以估量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询