去年我在做一个内部知识库问答系统的时候,被一个问题折磨了很久:模型回答得头头是道,结果一查原文,引用来源根本对不上。那时候我才意识到,光有模型能力远远不够,真正让AI"懂"你的私有数据、还敢把答案拿出去用,必须走一条系统化的技术路线。这就是RAG(检索增强生成)要做的事。如果你也在搞AI Agent,手头有一堆文档、工单、FAQ想让大模型基于它们回答问题,这篇文章就是给你准备的。我会从向量检索讲起,一路通到最终的AI智能回答,全程带着代码、参数和踩坑记录,尽量还原我实际搭系统时的思路。
1. 为什么必须搭一套完整RAG,而不是直接调接口
很多人上来就问:我直接把文档内容贴进Prompt里,让大模型回答不就完了?小规模演示可以,一上真实业务就会露馅。我一开始也这么试过,结果在文档超过几百页之后,Prompt拼到爆,模型开始"选择性地"忽略中间段落,而且你根本没法回答"这个结论出自哪一段"这种基础问题。RAG解决的核心问题,就是把"模型知识"和"业务事实"分开:模型负责理解与组织语言,检索系统负责从你的文档里找出真正相关的片段,再把两者拼起来生成回答。
1.1 直接调接口做问答的三个硬伤
硬伤之一是知识时效性。大模型训练数据是有截止日期的,它对你们部门上周刚更新的流程一无所知。硬伤之二是幻觉风险。如果没有事实约束,模型会在不知道答案时一本正经地编一个。我见过最离谱的回答是,它把完全不存在的接口文档描述得和真的一模一样。硬伤之三是数据和权限边界。内部文档往往涉及敏感范围,直接丢给通用模型不仅没边界概念,而且你很难控制谁能看到什么。
1.2 微调为什么不适合当"知识加载"手段
偶尔有人提:那我微调一个模型,把知识融进参数里行不行?我的观点是:微调适合改变模型的"行为风格"或者"输出格式",但不适合塞进高频变动的业务知识。每次文档更新都要重新训练一轮,成本高、周期长,而且模型很容易把"学到了格式"和"记错了内容"混在一起。RAG的思路恰恰相反——知识放在外部索引里,更新文档只需要重跑切片和向量化,模型参数完全不动。这也是RAG在工程上更被青睐的根本原因:知识变了,索引跟着变,模型不用变。
1.3 RAG在Agent架构里的位置
把RAG放进整个AI Agent的框架里看,它通常是Agent的"记忆组件"或者"工具调用后端"。Agent决定"需要查资料"时,会调用检索工具,拿到候选片段后交给大模型作为上下文。这个定位决定了RAG必须输出结构化的、可验证的中间结果,而不只是给一段文本。所以你在设计RAG的时候,脑子里始终要有一条线:从文档进来,到片段被检索,再到引用被生成,每一环都要留下可追溯的信息。
2. 文档切分与索引构建:RAG系统能否答好的地基
我在拆解RAG链路的时候发现,大部分人一开始最关心向量数据库选哪个,但其实决定问答质量的上限,是文档预处理做得干不干净。向量检索再强,喂给它一堆乱七八糟的碎片,它也召不回什么好东西。我的实操顺序是:先做文档清洗,再做切分策略设计,最后才考虑嵌入和索引。
2.1 文档清洗:哪些脏数据必须先处理
这一步最容易被忽略,但几乎决定了后面所有环节的体验。我遇到过的典型脏数据包括:PDF里的页眉页脚、表格转换后的断裂文本、Markdown源码残留、扫描件OCR出来的乱码字符。清洗方案不要写得过于复杂,我建议至少做三件事:一是统一编码转UTF-8,避免生僻字符导致向量化时出现错乱;二是去除页眉页脚和重复导航信息,用规则正则就够;三是对明显乱码的段落做异常检测,比如统计非正常字符率,超阈值就直接标记为低质量块,不要让它参与检索。
2.2 切分策略:固定长度、递归分割还是语义切分
整个索引构建里最需要花心思的就是切分。固定长度切分最简单,按字符数硬切,但极易切断句子和段落,导致语义不完整。递归分割是我目前更常用的方式,它按段落、句子、子句逐级往下切,尽量保持语言单元完整。而语义切分(embedding切分)成本偏高,适合内容结构比较松散的长文。我的经验是:先按文档结构定边界,再按token上限做二次截断。比如一篇操作手册,先按"##"标题拆成章节,每个章节内部再用递归分割,控制每块在300到500个token之间。
2.3 嵌入模型选择与向量化细节
嵌入模型的任务是把文本变成长度固定的向量。选型上我只关注两个指标:检索质量(可以用一个小的标注集跑召回率)和向量维度(维度越大存储成本越高)。很多新同学会忽视一个细节:切分出来的片段不能直接用原始文本向量化,最好在块首拼接一个"标题提示"。比如"第3章 故障排查 > 3.2 数据库连接失败",这样能让嵌入模型更好地感知上下文。我自己测过,拼接标题之后,在工单类数据上的召回效果有明显提升。
2.4 元数据设计:不然后面做过滤检索会想哭
索引里除了文本块内容和向量,还要存元数据。至少包括:来源文档ID、章节路径、页码(如果是PDF)、更新时间、权限标签。为什么要这些?因为真实业务检索绝不是纯语义相似度就够的。比如用户只看得到A类文档,那么检索时就必须按照权限标签过滤。再比如某个答案是依据旧版本文档生成的,出了问题你要能快速溯源。元数据字段越早设计好,后面写过滤条件就越省事。
# 伪代码示例:构建文档块并写入索引 for doc in loaded_documents: blocks = split_document_by_structure(doc) # 先按标题结构切 for block in blocks: block.content = add_title_prefix(block) # 拼接标题上下文 block.vector = embed_model.encode(block.content) block.meta = { "source_id": doc.id, "chapter": block.chapter_path, "page": block.page_number, "permission": doc.permission_level } index.add(block)3. 向量检索层的核心参数与数据库选型
索引建好之后,真正面对用户请求的是检索层。很多人以为向量检索就是把query变成向量然后算相似度,实际工程里还要考虑召回数量、相似度阈值、召回后的重排,以及不同类型的查询该走哪条检索路径。检索层直接决定了生成阶段"有没有料可用",所以我会把它单独拿出来仔细讲。
3.1 向量数据库选型时到底在选什么
市面上的向量数据库或者带向量能力的数据库很多,我不推荐纠结"哪个最好",而是看几个硬指标。第一,索引类型是否支持动态更新。内部文档随时会改,如果每次更新都要重建整个索引,运维成本不可接受。第二,是否支持标量过滤与向量检索的混合查询。也就是我在2.4里说的权限过滤、时间过滤,最好能在一次查询里完成。第三,召回延迟。我自己在业务场景里定的预期是:纯检索阶段要稳定在100毫秒以下,否则对问答体验拖累明显。第四,部署运维成本。如果你的团队没有专职DBA,尽量选部署简单、有清晰监控指标的产品。
3.2 相似度算法:内积、余弦还是L2距离
不少向量数据库默认支持好几种相似度度量,但它们并不等价。余弦相似度看的是方向一致性,跟向量长短无关,适合文本语义匹配;内积更偏向量长度大的结果占优,如果嵌入模型做了归一化,内积和余弦是等价的;L2距离衡量的是绝对距离,值越小越相似,但它对向量模长敏感,文本领域用得相对少。我的建议很简单:如果嵌入模型输出的是归一化向量,直接选内积,因为计算最快;如果不确定是否归一化,选余弦最稳妥。
3.3 top-k、score阈值和混合检索的取舍
top-k决定了后续生成环节能看到多少个候选片段。太小了召回不全,太大了又会把不相关内容塞进上下文,反而干扰模型回答。我习惯把候选召回数量设置在10到20之间,但在进入生成之前,会做一轮更严格的筛选。score阈值经常会误导人,因为不同嵌入模型的分数分布差异很大,不能拿一个固定值套在所有场景上。更可靠的做法是:先在测试集上跑一遍,观察真实相关片段和不相关片段的score分布,再找一个分水岭。
另外,纯语义检索对"精确关键词"场景其实很弱。比如用户搜"QPS-2024-03"这种编号,语义向量可能完全不知道它和哪个文档相关。所以我在系统里加了一条混合检索路径:先用BM25或者关键词倒排索引召回一轮,再和向量检索结果做合并去重。关键词保精确,向量保语义,两者是互补关系。
3.4 召回之后别急着生成,先做一次重排
重排(rerank)是我非常推荐的性能杠杆。初次召回阶段为了速度,往往只做粗筛,但粗筛结果里可能混着一些措辞相似但实质无关的内容。重排的思路是:对召回的10到20个片段,用一个更强的模型(或者同一个模型但更精细的指令)重新计算相关性,只保留最相关的3到5个片段。这一步让上下文更精炼,生成质量会明显更稳,也减少了幻觉空间。代价是多了一次模型推理,但这个开销通常值得。
# 检索链路示意 candidates = vector_search(query_vec, top_k=20, filter=permission_filter) if use_keyword_search: keyword_hits = bm25_search(query, top_k=20) candidates = merge_and_deduplicate(candidates, keyword_hits) reranked = rerank_model(query, candidates) final_blocks = reranked[:3]4. 生成阶段的问答质量:上下文组装与提示词设计
当检索链路交上来3到5个高相关片段,接下来的工作就是组装Prompt、约束模型回答。我见过不少人在这时候把检索片段一股脑塞进去就完事,结果模型回答了但引用混乱。这里的关键不是片段多,而是要让模型明确"回答的唯一依据是什么、引用怎么标、不知道时怎么办"。
4.1 提示词的上下文结构:怎么摆放片段和指令
提示词里的顺序会影响模型的注意力分配,这一点很多资料都没讲透。我的建议是:把身份指令放在最前面,中间放检索到的资料片段,最后放用户问题。这样模型会先"进入状态",然后看到资料,最后带着资料去回答问题。片段之间要加上清晰的编号和来源标记,比如"[片段1](来源:操作手册-第3章-数据库连接失败)",让模型知道引用时要对着编号和来源写。
4.2 控制模型"只基于资料回答"的边界设置
想让模型不胡编,最有效的手段不是反复强调"不许幻觉",而是在提示词里做硬性约束。我会明确写:只有资料中存在明确依据时才能回答;如果资料不足,直接说明"根据现有资料无法回答",并列出已检索到的相关关键词。这里有一个容易被忽视的点:你还要告诉模型"资料片段可能包含冲突信息",让它以更新时间和来源可信度作为判断依据。很多模型在拿到多个互相矛盾的片段时,会自己脑补一个"合理"答案,这是最危险的。
4.3 结构化输出设计:让引用可以被程序校验
做Agent系统的时候,生成结果不能只是一段给人看的文本,最好是结构化数据,方便上层逻辑做校验和后处理。我在提示词里要求模型输出JSON格式,字段至少包括:answer(最终回答)、references(引用的来源列表)、confidence(对答案的确信程度)。这样程序可以检查references里的每个条目是否真的对应检索结果里的某个片段,如果模型编造了一个来源,就可以当场拦截。
# 生成阶段的Prompt模板 SYSTEM_PROMPT = """ 你是一个严谨的问答助手。回答时只允许依据给定的资料片段,不得使用个人常识或编造事实。 每个结论后需标注来源编号,格式为[片段编号]。 如果资料不足以回答,必须输出"根据现有资料无法回答"。 请以JSON格式输出,包含:answer, references, confidence。 """4.4 查询改写:用户的问题往往不适合直接检索
用户问"这个功能怎么搞",在语义上并不一定和文档里的"功能配置"直接匹配。这也是RAG系统里一个见效很快的优化点:在进入检索之前,先用大模型对用户问题进行改写或扩展。改写方向包括:把口语变书面、补充同义词、把模糊指代明确化、拆解多意图问题。我会让模型一次输出多个改写候选,分别去检索,再把结果合并。代价是增加了一次模型调用,但换取的是召回率大幅提升。
5. 线上遇到的检索质量问题:一次完整的排查链条
即使做了这么多设计,系统上线后依然会出问题。我从一个真实的故障开始讲:某天用户反馈,回答内容挺准确,但点开引用来源,发现来源页码指向的段落跟答案毫无关系。表面上这是"引用错乱",实际上往往是检索召回阶段混入了高相似度但语义不相关的切片,或者是元数据过滤规则写错了。
5.1 先拆链路:把问题定位到检索还是生成
排查时我的第一步永远是把链路拆开,分别看中间结果。我会跑同一个query,分别检查:切片后的检索片段是什么、重排后的最终候选是什么、模型生成的引用标了什么。对照这三层,很快就能知道问题出在哪个环节。如果检索片段里本身就是无关内容,那是召回的问题;如果片段是相关的但生成答案没用到,那是Prompt或模型的问题;如果引用的编号超出了实际提供的片段范围,那就是提示词约束失效。
5.2 典型案例:元数据过滤条件把正确文档误杀了
那次故障的根因很有意思:检索召回率在单元测试里一切正常,但线上答案一直不完整。后来发现是权限标签维护混乱,部分文档的真实权限是"全员",但索引里的标签被写成了"管理员"。用户在检索时带上了"permission=staff"的过滤条件,直接把本该召回的文档给过滤掉了。这类问题很难通过调算法解决,必须要靠索引元数据的校验机制,比如定时任务检测标签异常、对比源系统权限表和索引表的一致性。
5.3 修复动作和验证方法
修复那次故障一共做了三件事:一是修正有问题的权限标签并重建受影响文档的索引;二是在检索接口加了一个诊断参数,支持返回"过滤条件命中的文档数",方便线上快速判断是否误过滤;三是补了一批含权限边界的回归测试用例,覆盖普通员工、管理员、跨部门用户几种身份。验证方法也很直接:用固定测试集对比修复前后的召回率和最终答案正确率,两个指标都有回升。
# 诊断思路:对比不同过滤条件下的召回数 with_filter = search(query, filter={"permission": "staff"}) without_filter = search(query, filter=None) print(f"过滤后召回: {len(with_filter)} 条, 未过滤召回: {len(without_filter)} 条") # 如果两者差距极大,优先怀疑元数据过滤条件写错或标签缺失6. 上线之后还要盯的事:评测、反馈回流与性能开销
系统稳定跑起来之后,真正拉开差距的是持续迭代。RAG不是搭完就能放着不管的东西,它最需要的是源源不断的评测反馈和性能监控。我现在维护这套系统时,日常核心工作就三块:评测集更新、反馈数据回流、延迟监控。
6.1 评测集怎么搭:不能只看"看起来答得对"
很多团队评测RAG的方式是人眼随机看几条,然后觉得效果不错就上线。这不够严谨,因为个例表现和分布表现可能是两回事。我更推荐搭一个几十条的小规模评测集,每条包含:用户问题、标准答案要点、应该召回的文档ID、允许的引用范围。评测分两个层面:检索层看recall@k(正确的文档是否在召回名单里),生成层看答案是否覆盖标准答案要点、引用是否合法。这个小评测集会成为你后续所有优化的标尺。
6.2 用户反馈回流:把"答错了"变成训练数据
每次用户点击"回答有用/无用"、每次有人手动纠正答案,都是免费的标注信号。我在系统里加了一个轻量记录表,存下问题、片段、答案、用户反馈,每周抽出那些"回答无帮助"的记录,看是召回问题(正确文档没被找到)还是生成问题(文档对了但答偏了)。这个问题分类会让你后续优化有方向,而不是每次都拍脑袋调参数。
6.3 性能开销:延迟瓶颈往往不在模型推理
一个常见误解是:RAG系统的延迟瓶颈在生成阶段的大模型推理。但在我的实测里,往往一次性把20个片段全塞进上下文,才会导致生成时间和成本暴涨。解决办法我在3.4说过:用重排控制在3到5个片段。此外,向量检索的延迟也不能忽视,如果索引规模增长后延迟变差,优先检查是否需要调整索引参数,比如是否走了暴力扫描而不是近似最近邻索引。
6.4 小规模RAG的落地优先级,我个人的建议
如果你刚开始尝试做RAG,我建议把精力按这个顺序分配:先是文档清洗和切分(占30%),这是最容易取得效果也最容易被忽视的地方;然后是评测集建设(占25%),没有评测后面都不知道改对了没;接着是检索与重排链路(占25%);最后才是各种花式Prompt和模型调参(占20%)。很多人把顺序倒过来,上来就调Prompt,最后发现召回内容不对,Prompt写得再漂亮也无济于事。
我在实际搭建这套系统的过程中的体会是:RAG真正的门槛不在任何一个单独的算法或工具,而在于你能不能把"文档进、答案出"这条链路里的每个环节都做扎实,并且随时能拿出数据说明"改对了还是改错了"。如果这篇文章能帮你少走几步弯路,那就值了。后面有条件的话,我还会继续分享Query改写、多路召回和重排模型的具体实践,欢迎持续关注。