1. 为什么知识获取管道是 AI Agent 的分水岭
做 AI Agent 开发的人,绕不开一个尴尬的现实:模型本身很聪明,但它不知道你公司内部的业务规则、不知道你上周刚更新的产品文档、更不知道你私有的那套运维手册里写了什么。你问它一个通用问题,它答得头头是道;你问它一个只有你们团队才知道的细节,它要么一本正经地胡说八道,要么直接告诉你“我无法回答这个问题”。
这就是知识获取管道要解决的核心矛盾。所谓知识获取管道,说白了就是给 Agent 装上一套“查资料”的能力——在它开口回答之前,先去指定的知识库里把相关材料找出来,然后基于这些材料组织答案。这套机制在行业里有个更正式的名字:RAG(Retrieval-Augmented Generation,检索增强生成)。
我接触 RAG 是从一个很具体的需求开始的。当时团队内部有一个积累了三年多的技术文档库,大概两千多篇 Markdown 文件,涵盖接口说明、部署流程、故障处理记录。新同事入职后最常问的问题就是“这个报错怎么处理”“那个配置在哪里改”,老同事被问烦了,新同事也不好意思一直问。我们想做一个内部问答机器人,让 Agent 自己去文档库里找答案。最开始想的是把文档全部塞进模型上下文,但算了一下 token 量,两千多篇文档即使每篇压缩到五百字,总量也远超模型窗口上限,而且每次提问都全量塞进去,成本和延迟都不可接受。
RAG 的思路就自然浮现出来了:不把所有知识一次性喂给模型,而是先建一个可检索的索引,用户提问时只把最相关的几段内容取出来,连同问题一起交给模型生成答案。这个思路听起来简单,但真正落地时会发现,从文档切分、向量化、存储、检索到最终生成,每一步都有大量细节需要打磨。检索不准,模型再强也白搭;切分不合理,关键信息被截断,检索出来的片段读都读不通;向量模型选错了,语义相似度计算完全偏离预期。
这篇文章面向的是正在从零搭建 AI Agent 的开发者,尤其是那些已经跑通了基础对话流程、准备接入私有知识库的人。我会用 TypeScript 作为主要实现语言,因为它在 Agent 开发中的生态越来越成熟,类型系统对复杂数据流的约束也让调试成本大幅降低。全文会围绕 RAG 的完整链路展开,从整体设计思路到每个环节的实操细节,再到我踩过的坑和排查技巧,尽量把“为什么这么做”讲清楚,而不是只给一堆代码让你抄。
2. RAG 知识获取管道的整体设计与选型思路
2.1 核心链路拆解:从文档到答案的五个阶段
RAG 的完整流程可以拆成五个阶段,每个阶段都有明确的输入和输出,理解这个链路是后续所有优化的基础。
第一阶段是文档加载与预处理。你的知识可能散落在各种地方:Markdown 文件、PDF、数据库表、网页、甚至聊天记录。这一步要做的是把这些异构数据统一成纯文本格式,同时保留必要的元信息(比如来源文件、章节标题、更新时间)。元信息在后面检索和排序时非常关键,但很多人一开始会忽略它。
第二阶段是文本切分。整篇文档太长,直接向量化会丢失细节,检索时也很难精确定位。所以需要把文档切成合适大小的片段(chunk),每个片段独立向量化。切分策略直接决定了检索质量的上限——切得太碎,语义不完整;切得太粗,噪声太多。
第三阶段是向量化与索引构建。用嵌入模型(embedding model)把每个文本片段转成一个高维向量,然后存入向量数据库。这一步的核心是选对嵌入模型和距离度量方式。不同模型对中文、英文、代码的语义捕捉能力差异很大,选错了后面怎么调都别扭。
第四阶段是检索。用户提问时,把问题也向量化,然后在向量库中找最相似的 top-k 个片段。但纯向量检索有个问题:它对关键词匹配不敏感,有时候用户问的是一个具体的错误码,向量检索反而找不到精确匹配的文档。所以实际生产中通常会做混合检索——向量检索加关键词检索,再融合排序。
第五阶段是生成。把检索到的片段和用户问题组装成提示词,交给大语言模型生成最终答案。这一步的关键是提示词设计:怎么让模型基于给定材料回答、怎么处理材料中没有答案的情况、怎么引用来源。
这五个阶段环环相扣,任何一个环节出问题都会导致最终答案质量下降。我见过太多人把精力全花在换更强的生成模型上,结果检索出来的片段根本不对,换什么模型都没用。
2.2 技术选型:为什么用 TypeScript 而不是 Python
RAG 领域的教程和开源项目大多用 Python,这很容易理解——Python 在数据处理和机器学习生态上积累深厚。但如果你正在开发的是一个面向生产的 AI Agent 应用,TypeScript 其实有它独特的优势。
首先是类型安全。RAG 链路中数据在多个阶段之间流转,从文档对象到切分片段到向量记录到检索结果,每个阶段的数据结构都不一样。用 TypeScript 把这些类型定义清楚,编译器会在你传错字段的时候直接报错,而不是等到运行时才发现某个属性是 undefined。我在用 Python 写原型时,经常遇到检索结果里某个字段名写错了,跑半天才发现问题出在一个拼写上。
其次是前后端统一。Agent 应用通常需要一个交互界面,如果后端用 Python、前端用 TypeScript,两边的数据契约要靠文档和约定来维护。全栈 TypeScript 的话,同一套类型定义可以前后端共享,接口变更时编译器会帮你找出所有需要修改的地方。
第三是异步流式处理。Agent 的响应通常是流式的,TypeScript 的 async/await 和 AsyncGenerator 在处理流式数据时非常自然。Node.js 的事件循环模型也很适合这种 I/O 密集型的场景——检索、调用模型 API、读写向量库,这些都是 I/O 操作,Node 的非阻塞特性正好发挥优势。
当然,如果你需要做模型微调或者复杂的本地推理,Python 仍然是更好的选择。但对于大多数 RAG 应用来说,你用的是云端嵌入模型和生成模型,核心工作是编排和数据处理,TypeScript 完全够用,而且在工程化方面更省心。
2.3 向量数据库选型:从原型到生产的取舍
向量数据库的选择是另一个关键决策。我的建议是分阶段来:原型阶段用最简单的方案,验证跑通后再根据数据量和性能要求升级。
原型阶段我推荐用内存向量存储,比如自己用数组实现一个简单的余弦相似度计算,或者用hnswlib-node这样的轻量库。数据量在几千条以内时,暴力计算完全够用,而且没有额外的部署成本。这个阶段的目标是验证切分策略和嵌入模型是否合适,不要过早引入基础设施复杂度。
当数据量上万、需要持久化、或者要多进程共享时,就该考虑专门的向量数据库了。常见的选择有几类:SQLite 加向量扩展适合单机小规模场景,部署简单;Qdrant和Weaviate是专门为向量检索设计的,支持过滤、混合检索等高级功能;PostgreSQL 加 pgvector适合已经有 Postgres 基础设施的团队,不用额外维护一套数据库。
我个人的经验是,如果团队已经在用 Postgres,优先考虑 pgvector,运维成本最低。如果对检索性能有极致要求,或者需要复杂的元数据过滤,再上专门的向量数据库。选型时重点看三个指标:检索延迟、过滤能力、以及是否支持你需要的距离度量方式。
3. 核心细节解析与实操要点
3.1 文本切分:RAG 质量的第一道关卡
文本切分看起来简单,实际上是最容易出问题的地方。我最初的做法是按固定字符数切分,每五百字一段,结果检索出来的片段经常从句子中间断开,读起来莫名其妙。后来改成按段落切分,又遇到有些段落特别长、有些特别短的问题。
比较稳妥的策略是递归切分:先按文档的自然结构(标题、段落)切,如果某个段落还是太长,再按句子切,最后才按字符数硬切。这样能最大程度保留语义完整性。具体实现时,我会设置一个目标片段大小(比如 500 个 token)和一个重叠区域(比如 50 个 token)。重叠的作用是防止关键信息刚好落在切分边界上被割裂。
interface ChunkOptions { targetSize: number; // 目标 token 数 overlap: number; // 重叠 token 数 separators: string[]; // 切分优先级 } function splitText(text: string, options: ChunkOptions): string[] { const { targetSize, overlap, separators } = options; // 按分隔符优先级递归切分 // 合并过短的片段,拆分过长的片段 // 在相邻片段间添加重叠内容 // ... }这里有个容易被忽略的细节:不同来源的文档要用不同的切分策略。Markdown 文档有明确的标题层级,可以按标题切分,每个小节作为一个片段,同时把标题路径作为元信息保留。代码文件要按函数或类切分,不能从函数中间断开。PDF 提取出来的文本往往没有清晰的段落结构,需要先做一轮清洗和重排。
还有一个实操心得:在片段前面加上来源信息。比如每个片段开头加上“来自《部署手册》第三章第二节”,这样即使片段本身语义不完整,嵌入模型也能捕捉到上下文。检索出来的片段交给生成模型时,模型也能知道这段内容的出处,回答时引用来源会更准确。
3.2 嵌入模型选择:中文场景下的实测对比
嵌入模型的选择直接决定了语义检索的质量。市面上常见的模型有 OpenAI 的 text-embedding 系列、Cohere 的 embed 系列、以及各种开源模型。中文场景下,我实测过几个方案,差异比想象中大。
OpenAI text-embedding-3-small在通用语义相似度上表现稳定,对中英文混合内容处理得不错,但它是云端调用,有网络延迟和成本问题,而且数据要发到外部,对隐私敏感的场景不合适。
BGE 系列(如 bge-large-zh)是中文开源嵌入模型里口碑较好的,本地部署没有数据外泄风险,中文语义捕捉能力比通用多语言模型强。缺点是模型体积大,推理需要 GPU 或者性能较好的 CPU,首次加载慢。
M3E 系列在中文短文本匹配上表现不错,模型相对轻量,适合资源受限的场景。
我的建议是:如果数据不敏感且预算允许,先用云端模型快速验证流程;如果对隐私和成本有要求,用 BGE 本地部署。选型时不要只看论文指标,一定要用你自己的数据做一轮检索测试——拿十几个真实问题,看检索出来的 top-5 片段是否包含答案,这比任何 benchmark 都靠谱。
还有一个细节:嵌入模型的维度要和向量库的配置匹配。比如 BGE-large-zh 输出 1024 维向量,建库时就要指定 1024 维。如果中途换模型,维度变了,整个库都要重建。所以选型时最好留一点余地,别选维度太小的模型。
3.3 混合检索:向量加关键词的融合策略
纯向量检索有个天然缺陷:它对精确匹配不敏感。用户问“ERR_CONNECTION_REFUSED 怎么解决”,向量检索可能会返回一堆关于“网络连接问题”的通用文档,但真正包含这个错误码的文档反而排不到前面。这时候就需要关键词检索来补位。
混合检索的做法是:同时跑向量检索和关键词检索(比如 BM25 算法),各自得到一组结果,然后用倒数排名融合(RRF)把两组结果合并。RRF 的公式很简单:每个文档的得分等于它在各个结果列表中排名的倒数之和。这样既考虑了语义相似度,又保留了关键词精确匹配的优势。
function reciprocalRankFusion( vectorResults: SearchResult[], keywordResults: SearchResult[], k: number = 60 ): SearchResult[] { const scores = new Map<string, number>(); vectorResults.forEach((result, index) => { const score = 1 / (k + index + 1); scores.set(result.id, (scores.get(result.id) || 0) + score); }); keywordResults.forEach((result, index) => { const score = 1 / (k + index + 1); scores.set(result.id, (scores.get(result.id) || 0) + score); }); return Array.from(scores.entries()) .sort((a, b) => b[1] - a[1]) .map(([id]) => findResultById(id, vectorResults, keywordResults)); }关键词检索的实现可以用minisearch或flexsearch这样的轻量库,它们支持中文分词和 BM25 排序。中文分词建议用nodejieba或segmentit,分词的粒度会影响关键词匹配的召回率。
实测下来,混合检索在技术文档场景下的命中率比纯向量检索高出不少,尤其是涉及具体错误码、配置项名称、API 路径这类查询时,优势非常明显。代价是检索延迟会增加,因为要跑两套检索再融合。如果延迟敏感,可以给关键词检索加缓存,或者只在向量检索置信度低时才触发关键词检索。
3.4 提示词组装:让模型基于材料回答而不是自由发挥
检索到相关片段后,最后一步是把它们和用户问题组装成提示词。这一步看似简单,但提示词的结构直接影响生成质量。
我的提示词模板通常包含四个部分:角色设定、材料区、问题区、回答要求。角色设定告诉模型它是什么身份,比如“你是一个技术文档助手”。材料区把检索到的片段按相关度排序后拼接,每个片段标注来源。问题区放用户原始问题。回答要求最关键,要明确告诉模型:只基于材料回答、材料中没有的信息不要编造、如果材料不足以回答就直说、回答时标注引用的来源。
function buildPrompt(question: string, chunks: RetrievedChunk[]): string { const context = chunks .map((chunk, i) => `[片段${i + 1}] 来源:${chunk.source}\n${chunk.content}`) .join('\n\n'); return `你是一个技术文档助手。请严格基于以下材料回答用户问题。 材料: ${context} 用户问题:${question} 回答要求: 1. 只使用材料中提供的信息,不要添加材料之外的知识 2. 如果材料中没有足够信息回答问题,直接说明"根据现有资料无法回答" 3. 回答时用[片段N]标注引用的来源 4. 保持回答简洁准确`; }这里有个坑:材料区不能塞太多片段。检索 top-10 全塞进去,提示词会很长,模型注意力被分散,反而容易忽略关键信息。我的经验是 top-3 到 top-5 比较合适,如果片段内容短可以适当增加。另外,片段之间要有明确的分隔标记,否则模型可能把不同片段的内容混在一起。
还有一个进阶技巧:在材料区前面加一句“以下材料按相关度从高到低排列”。这能引导模型优先关注前面的片段,减少被低相关度内容干扰的概率。
4. 实操过程与核心环节实现
4.1 项目初始化与依赖安装
先把项目骨架搭起来。我用的是 Node.js 加 TypeScript,包管理用 pnpm,构建工具用 tsx 直接跑 TypeScript 文件,省去编译步骤。
mkdir rag-pipeline && cd rag-pipeline pnpm init pnpm add openai @qdrant/js-client-rest minisearch nodejieba pnpm add -D typescript tsx @types/nodeopenai用来调用嵌入模型和生成模型,@qdrant/js-client-rest是 Qdrant 的客户端,minisearch做关键词检索,nodejieba做中文分词。如果你用本地嵌入模型,还需要装@xenova/transformers或者onnxruntime-node。
tsconfig.json的配置要注意几个点:target设成ES2022,module设成NodeNext,moduleResolution设成NodeNext。如果你看到“选项 baseUrl 已弃用”或者“选项 moduleResolution=node10 已弃用”的警告,说明配置需要更新,用NodeNext可以避免这些问题。
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist" }, "include": ["src/**/*"] }4.2 文档加载与切分实现
先定义文档和片段的数据结构。文档对象包含原始内容和元信息,片段对象包含切分后的文本、所属文档 ID、在文档中的位置、以及来源信息。
interface Document { id: string; content: string; source: string; metadata: Record<string, string>; } interface Chunk { id: string; documentId: string; content: string; source: string; position: number; metadata: Record<string, string>; }加载 Markdown 文档时,我会先按二级标题切分成大块,每个大块再按段落切分成片段。这样每个片段都带有明确的章节归属,检索时如果命中某个片段,能直接知道它来自哪一章。
function loadMarkdown(filePath: string): Document { const content = fs.readFileSync(filePath, 'utf-8'); const title = content.match(/^#\s+(.+)$/m)?.[1] || path.basename(filePath); return { id: hash(filePath), content, source: filePath, metadata: { title, type: 'markdown' } }; } function chunkDocument(doc: Document, options: ChunkOptions): Chunk[] { const sections = doc.content.split(/\n(?=##\s)/); const chunks: Chunk[] = []; sections.forEach((section, sectionIndex) => { const sectionTitle = section.match(/^##\s+(.+)$/m)?.[1] || ''; const paragraphs = section.split(/\n\n+/); let buffer = ''; paragraphs.forEach((para) => { if (buffer.length + para.length > options.targetSize && buffer.length > 0) { chunks.push(createChunk(buffer, doc, sectionTitle, chunks.length)); buffer = para; } else { buffer += (buffer ? '\n\n' : '') + para; } }); if (buffer.length > 0) { chunks.push(createChunk(buffer, doc, sectionTitle, chunks.length)); } }); return chunks; }createChunk函数会在片段内容前面加上来源标题,比如“《部署手册》- 第三章 故障处理”,这样嵌入模型能捕捉到章节上下文。
4.3 向量化与索引构建
向量化这一步,我用 OpenAI 的嵌入接口做演示,如果你用本地模型,把embed函数替换掉就行。
async function embed(texts: string[]): Promise<number[][]> { const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: texts, }); return response.data.map((item) => item.embedding); }批量向量化时要注意分批处理。一次发太多文本会被限流,我一般每批 20 到 50 条,根据接口的速率限制调整。每批之间加一点延迟,避免触发限流。
async function embedInBatches(texts: string[], batchSize = 32): Promise<number[][]> { const results: number[][] = []; for (let i = 0; i < texts.length; i += batchSize) { const batch = texts.slice(i, i + batchSize); const embeddings = await embed(batch); results.push(...embeddings); if (i + batchSize < texts.length) { await sleep(200); } } return results; }建 Qdrant 集合时,向量维度要和嵌入模型输出一致。text-embedding-3-small输出 1536 维,集合配置里就写 1536。距离度量用余弦相似度,因为嵌入向量通常做了归一化,余弦相似度比欧氏距离更合适。
async function createCollection(client: QdrantClient, name: string, dimension: number) { await client.createCollection(name, { vectors: { size: dimension, distance: 'Cosine', }, }); }写入数据时,把片段内容、来源、位置等信息放在 payload 里,检索时可以按这些字段做过滤。比如只检索某个文档的片段,或者只检索最近更新的内容。
4.4 混合检索与结果融合
检索环节我实现了两路:向量检索走 Qdrant,关键词检索走 MiniSearch。两路各取 top-10,然后用 RRF 融合,最终取 top-5 交给生成模型。
async function hybridSearch( query: string, client: QdrantClient, miniSearch: MiniSearch, topK = 5 ): Promise<RetrievedChunk[]> { const queryVector = (await embed([query]))[0]; const vectorResults = await client.search('knowledge', { vector: queryVector, limit: 10, }); const keywordResults = miniSearch.search(query, { prefix: true }).slice(0, 10); const fused = reciprocalRankFusion( vectorResults.map((r) => ({ id: r.id as string, score: r.score, payload: r.payload })), keywordResults.map((r) => ({ id: r.id, score: r.score, payload: r })) ); return fused.slice(0, topK); }MiniSearch 的索引构建要在文档切分后同步进行。中文分词用 nodejieba 的cutForSearch模式,它会把长词切成更细粒度的子词,提高召回率。
const miniSearch = new MiniSearch({ fields: ['content', 'source'], storeFields: ['content', 'source', 'position'], tokenize: (text) => nodejieba.cutForSearch(text), });4.5 生成环节与流式输出
最后一步是把检索结果组装成提示词,调用生成模型。我用流式输出,这样用户能更快看到响应。
async function generateAnswer( question: string, chunks: RetrievedChunk[] ): Promise<AsyncGenerator<string>> { const prompt = buildPrompt(question, chunks); const stream = await openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: prompt }], stream: true, temperature: 0.1, }); return (async function* () { for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; if (content) yield content; } })(); }temperature设成 0.1 是为了让回答更稳定、更贴近材料内容。如果设太高,模型容易自由发挥,编造材料里没有的信息。
5. 常见问题与排查技巧实录
5.1 检索结果不相关:从五个维度逐层排查
检索不准是最常见的问题,排查时按以下顺序逐层检查。
第一层:嵌入模型是否适合你的数据。拿几个典型问题,手动看检索出来的 top-5 片段。如果完全不相关,可能是模型对领域术语不敏感。试试换一个在你的领域数据上微调过的模型,或者在片段前面加上更多上下文信息。
第二层:切分粒度是否合理。片段太短,语义不完整;片段太长,噪声太多。我一般会打印出片段的平均长度和长度分布,如果大部分片段都在 100 token 以下,说明切太碎了;如果超过 1000 token,说明切太粗了。
第三层:查询和文档是否在同一语义空间。用户提问的口语化表达和文档的书面化表达之间可能有鸿沟。解决办法是在检索前用模型把用户问题改写成更接近文档风格的查询,或者生成多个查询变体分别检索再融合。
第四层:是否需要混合检索。如果问题包含具体错误码、配置项名称、API 路径,纯向量检索大概率找不到精确匹配。加上关键词检索后,这类查询的命中率会明显提升。
第五层:top-k 是否合适。top-k 太小,可能漏掉正确答案;太大,噪声太多。我的经验是检索阶段取 top-10 到 top-20,融合后取 top-3 到 top-5 交给生成模型。
5.2 生成答案编造内容:提示词与温度的双重约束
模型编造材料里没有的信息,通常有两个原因:提示词约束不够强,或者温度设太高。
提示词方面,除了明确说“只基于材料回答”,还可以加一个自检步骤:让模型在回答前先判断材料是否足够,如果不够就直接说无法回答。这个自检步骤能显著降低编造概率。
const prompt = `请先判断以下材料是否足以回答用户问题。 如果材料不足,直接回复"根据现有资料无法回答"。 如果材料充足,再基于材料组织答案。 材料: ${context} 问题:${question}`;温度方面,生成环节建议设在 0.1 到 0.3 之间。温度越低,模型越倾向于复制材料内容;温度越高,越容易自由发挥。RAG 场景下,忠实于材料比创造性更重要,所以温度要低。
还有一个技巧:在材料区末尾加一句“以上是全部可用材料”。这能防止模型脑补出材料之外的内容,因为它知道材料已经给完了。
5.3 性能瓶颈定位:延迟与成本的平衡
RAG 链路的延迟主要来自三个地方:嵌入模型调用、向量检索、生成模型调用。嵌入模型调用通常最快,几百毫秒;向量检索在数据量不大时也很快,几十毫秒;生成模型调用最慢,几秒到十几秒不等。
如果延迟太高,先看是不是检索阶段取了太多结果。top-20 和 top-5 的检索延迟差异不大,但生成阶段的提示词长度差异很大,直接影响生成速度。把交给生成模型的片段控制在 5 个以内,每个片段控制在 500 token 以内,能显著降低生成延迟。
成本方面,嵌入模型调用是按 token 计费的,建库时一次性成本较高,但查询时的嵌入调用很便宜。生成模型调用是大头,提示词越长、输出越长,成本越高。优化方向是精简提示词、控制输出长度、以及用更便宜的模型做初筛。
如果预算紧张,可以考虑缓存机制:对常见问题缓存检索结果和生成答案,下次同样的问题直接返回缓存。缓存键可以用问题的嵌入向量做近似匹配,相似度超过阈值就命中缓存。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 检索结果完全不相关 | 嵌入模型不适合领域数据 | 手动检查 top-5 片段 | 换模型或加领域微调 |
| 检索结果部分相关但缺关键信息 | 切分粒度不合理 | 检查片段长度分布 | 调整切分策略和重叠区域 |
| 具体错误码查不到 | 纯向量检索对精确匹配不敏感 | 对比关键词检索结果 | 启用混合检索 |
| 生成答案编造内容 | 提示词约束不够或温度太高 | 检查提示词和 temperature | 加强约束、降低温度 |
| 响应延迟高 | 检索结果太多或提示词太长 | 统计各阶段耗时 | 减少 top-k、精简提示词 |
| 中文检索效果差 | 分词或嵌入模型对中文支持不足 | 用中文问题测试 | 换中文优化模型、调整分词 |
| 新增文档后检索不到 | 索引未更新 | 检查索引构建流程 | 增量更新向量库和关键词索引 |
5.5 几个我踩过的坑
坑一:片段重叠区域设太大。我一开始把重叠设成 200 token,结果检索出来的片段大量重复,浪费了宝贵的上下文窗口。后来改成 50 token,效果反而更好。重叠的目的是防止关键信息被切断,不是越多越好。
坑二:忽略元信息过滤。早期版本我没有用元信息过滤,检索时全库搜索。后来发现很多问题其实有明确的文档范围,比如“部署问题”只需要搜部署相关文档。加上元信息过滤后,检索准确率和速度都有提升。
坑三:嵌入模型和生成模型用同一家但版本不匹配。有次我换了生成模型的版本,但嵌入模型没换,结果检索出来的片段和生成模型的语义空间有偏差,答案质量下降。嵌入模型和生成模型最好来自同一家、同一代,或者至少做过兼容性测试。
坑四:没有做检索结果去重。相邻片段内容高度重叠,检索 top-5 可能有三条来自同一段内容。去重后实际有效信息只有两条。解决办法是在融合排序后做一次去重,相似度超过阈值的片段只保留一个。
坑五:关键词索引没有持久化。MiniSearch 的索引默认在内存里,重启服务就没了。生产环境要把索引序列化到磁盘,启动时加载。或者直接用支持持久化的关键词检索方案。
6. 从基础 RAG 到 Agentic RAG 的演进方向
基础 RAG 跑通之后,你会发现它有几个天然局限:检索是一次性的,不管问题复杂不复杂都只查一轮;检索策略是固定的,不会根据问题类型调整;多个片段之间的关系没有被利用,只是简单拼接。
Agentic RAG 的思路是让 Agent 自己决定怎么检索。比如面对一个复杂问题,Agent 可以先拆解成子问题,分别检索,再综合;如果第一轮检索结果不理想,Agent 可以改写查询再试一次;如果发现某个片段提到了另一个相关概念,Agent 可以顺着线索继续检索。这种多轮、自适应的检索策略,在复杂问答场景下效果明显更好。
实现 Agentic RAG 的关键是给 Agent 提供一组检索工具,让它自己编排调用顺序。工具可以包括:向量检索、关键词检索、按文档标题检索、按时间范围检索等。Agent 根据问题类型选择合适的工具组合,而不是每次都走同一条固定链路。
另一个方向是GraphRAG,把知识片段之间的关系也建模进去。比如文档 A 引用了文档 B,或者概念 X 和概念 Y 经常一起出现,这些关系可以在检索时作为扩展线索。当用户问题涉及多个概念时,GraphRAG 能沿着关系图找到更完整的上下文。
不过这些进阶方案的前提是基础 RAG 已经跑稳了。切分策略、嵌入模型、混合检索、提示词组装这些基本功没做好,上再复杂的架构也是空中楼阁。我的建议是先把基础链路跑通,用真实数据做一轮评估,找到瓶颈之后再针对性升级。
评估 RAG 系统时,我常用两个指标:命中率和忠实度。命中率看检索结果里是否包含正确答案,忠实度看生成答案是否严格基于检索材料。这两个指标一个管“找得对不对”,一个管“答得准不准”,结合起来能比较全面地反映系统质量。评估集不用很大,二三十个真实问题就够,关键是问题要覆盖不同类型的查询场景。