第一次把REA检索增强智能体项目演示给业务同事看的时候我盯着后台日志里一条格外扎眼的记录有人连续问了三次“客户退货流程里审批到哪个节点了”系统前两次回答都引用了过期的流程文档第三次才答对。这条日志让REA从“能跑起来的Demo”彻底变成了“敢上生产环境的答案引擎”。REA是我近期做的一个企业知识库问答项目全称Retrieval-Enhanced Agent解决的是最朴素的痛点组织里几百号人、几万份文档散落在不同系统新员工找不到答案老员工被重复问题淹没。REA做的事情也很简单——把文档索引起来用户问一句系统先检索最相关的片段再交给大模型生成带引用来源的回答。如果你想做一个企业内部问答助手、或者想搞明白RAG和Agent怎么落地这篇会把我踩过的坑和验证过的路径全交代清楚。1. 项目思路与整体设计1.1 需求是从一条客服留言开始的做REA之前我在某公司内部IT支持部门蹲了两天工单系统。发现每天将近三分之一的工单是重复的员工离职流程怎么走、报销单卡在哪个审批人手上、会议室设备连不上怎么处理。运维同学大部分时间不是在解决故障而是在复制粘贴标准答案。当时内部不是没有知识库问题恰恰是知识库太多——Wiki一套、OA一套、共享盘里还有一堆陈年Word。每个系统搜出来的结果都不一样员工根本不知道该信谁。这就引出了REA的第一个设计目标把分散文档统一索引通过一个入口回答问题并且回答必须标注来源让提问者能自己点进去核对。这个概念听起来也不新鲜市面上早就有各种“智能问答”产品。但真正落地时你会发现企业内部问答和通用聊天完全是两码事。通用场景可以容忍模型自由发挥因为答错了没有严重后果企业内部问答要求的是“精确、可追溯、更新及时”这三个词几乎决定了后面所有技术选型。1.2 为什么选RAG而不是微调模型这是REA立项时第一个被反复追问的问题。当时团队里有同事提出既然内部文档格式固定、专业词汇多干脆微调一个专用模型效果应该更准。我把两种方案放在一起做过对比结论非常明确这个场景根本不适合微调。先看训练成本。微调一套内部模型至少需要几千条高质量问答对还得请业务专家一条条标注这个人力成本往往被严重低估。更麻烦的是知识更新——企业流程一个月能改三次“售后响应时效从24小时改成8小时”这种事微调之后要重新准备数据集、重新训练、重新评估整套流程下来至少两周而RAG这边只需要替换一个文档片段全量刷新索引用不了半小时。再看回答质量。微调模型擅长学“说话风格”和“推理模式”但不擅长记忆大量事实细节尤其是那些带有部门名、审批节点、时效数字的内容。RAG刚好相反每次回答都先把相关文档片段“抓”过来让模型基于证据作答相当于开卷考试。它不依赖模型记住知识只依赖模型读懂检索到的内容。还有一个经常被忽略的点可追溯性。RAG可以在回答里带上来源文档路径出了问题能直接定位是哪份文档写错了还是检索没召回。微调模型像一口黑锅你不知道它从训练数据里“背”了哪句话出了问题连甩锅对象都没有。企业经营场景出一次错就可能引发流程风险所以REA从第一天起就确定了RAG路线开卷考试带证据出题。1.3 系统分成三个层次REA的整体结构没有做得很复杂按功能切成三层每一层可以独立替换、独立测试。第一层是数据接入层。负责把散落的文档收集起来做格式解析、清洗、切块然后向量化写入索引库。这一层的设计原则是“格式多样性优先”——Word、PDF、Markdown、HTML、表格文件都可能成为知识源每一类格式都需要独立的解析策略。第二层是检索层。接收用户的自然语言问题做查询理解、混合检索、重排序最终从索引库挑出最相关的Top-K个文档片段。这一层是整个项目的核心直接决定模型“看到什么”也就直接决定回答质量。第三层是生成层也就是智能体层。把检索到的片段、用户原始问题、系统提示词、可选的工具调用结果一起交给大模型由模型组织语言生成最终答案同时声明自己引用了哪些片段。这三层有个重要特性各自独立。这意味着我可以先不管生成层单独把检索质量调到80分再回头折腾Prompt也可以在生成层引入更多工具能力而无须改动索引结构。做项目最怕牵一发动全身这种分层方式让我在后期迭代时省了大量返工时间。2. 核心链路拆解检索增强到底怎么工作2.1 文档切分最基础也最容易翻车的一步很多第一次做RAG的人以为检索是模型的事结果发现最影响效果的是“切分”。你可以把文档切分想象成把一本书裁成便签纸——每张便签纸上写的内容必须足够自洽读者拿到任何一张都能大致看懂但又不能太碎导致丢了上下文。REA最初用的切分参数是固定长度每500个字符一块相邻块重叠50个字符。跑了一轮测试后发现大量文档的语义结构被切断尤其是“流程步骤”和“注意事项”这类内容经常被硬生生切成两半检索时只召回了后半截模型自然答得云里雾里。后来换成了按结构切分。首先按文档的结构化标题切出章节再针对长章节做递归切块。递归切块是分治思路优先按段落符切段落太长再按句号切句子还太长才按长度硬切。这样切出来的块基本保留了独立语义。现有代码里核心逻辑是这样def split_documents(docs, chunk_size500, chunk_overlap50): splitter RecursiveCharacterTextSplitter( chunk_sizechunk_size, chunk_overlapchunk_overlap, separators[\n\n, \n, 。, , , , , , ], ) chunks [] for doc in docs: # 先按照文档原有的二级标题分节再递归切分 sections split_by_heading(doc) for section in sections: chunks.extend(splitter.split_text(section)) return chunks切分时必须保留一个信息块来源的文档路径和章节标题。这个元信息在后面做引用溯源时是命根子。如果切分时丢了标题那模型回答一句“根据规范文档”用户根本没法定位。REA里我要求所有切块都携带source和heading两个字段检索结果里也一直带着这两个字段走。2.2 向量检索和关键词检索到底该信谁纯向量检索是目前RAG最主流的方案因为语义搜索能解决“用户没说关键词”的问题。比如员工问“电脑连不上网怎么办”如果文档里写的是“网络连接异常排查”两个句子一个字都对不上但语义上高度相关向量检索可以把它们匹配起来。但相当一段时间的排障经验告诉我向量检索也有明显的短板尤其是处理精确匹配的场景。问“报销单编号CP-2024-0123的状态”这种检索里核心是一个分类编号向量模型对这种短token并不敏感召回的排名经常不如一个朴素的关键词倒排索引。还有那些内部黑话系统里叫“主数据”文档里写“基础数据”向量能跨过语义障碍但是会召回一批评分很接近的片段排名混乱的概率就上来了。REA的解法是混合检索不把鸡蛋放一个篮子里。一条查询同时走向量检索和BM25关键词检索然后用RRF算法把两边的Top-K结果合并。RRF的逻辑很有意思它不直接比较两种检索出的分数因为向量分数和BM25分数根本不在一个量纲上而是看每条文档在两个结果列表里的排名位置用排名倒数作为融合得分。def rrf_fusion(vector_hits, bm25_hits, k60): fused {} for rank, hit in enumerate(vector_hits): fused[hit.id] fused.get(hit.id, 0) 1.0 / (k rank) for rank, hit in enumerate(bm25_hits): fused[hit.id] fused.get(hit.id, 0) 1.0 / (k rank) return sorted(fused.items(), keylambda x: x[1], reverseTrue)这套混合方案上线后召回的精确率提升非常明显尤其是带工单号、产品型号等精确标识的查询命中率直接上一个台阶。如果你正在做RAG但还没上混合检索我建议你优先加这一步成本低、见效快。2.3 重排序让最相关的片段挤到前排混合检索能保证“相关内容不会被漏掉”但Top-10里仍然可能混着两三条与问题关系不大的重叠片段。如果直接把Top-10全部塞给大模型会让模型读太多无关内容反而削弱对真正关键信息的把握——这就是为什么需要重排序步骤。REA用的是Cross-Encoder方案做重排序。这里的区别在于普通向量检索是分别把问题和文档编码成向量再算相似度重排序则是把问题和文档拼接成一个句子对直接扔给模型打分。因为看到了问题和文档的交互信息重排序分数比向量相似度可靠得多。代价也明显就是速度慢所以REA里只对混合检索出来的Top-30做重排取前5名作为最终上下文。def rerank(query, candidates, top_n5): # candidates是混合检索后的文档片段列表 pairs [(query, c.content) for c in candidates] scores reranker.predict(pairs) ranked sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) return [c for c, s in ranked[:top_n]]优化后的链路就是向量BM25粗召回Top-30重排序精排Top-5再交给大模型生成。整套流程走下来上下文里的垃圾信息大量减少模型“找不到重点”的毛病基本消失。这一步做完检索层的评价指标命中率、相关性大概能到85分以上后面优化Prompt的效果才真正显现得出来。3. 智能体层让模型从“问答机”变成“办事员”3.1 工具调用知识库回答不了的问题怎么办RAG只能回答“文档里写着答案”的问题。但在企业内部大量提问需要联动实时数据用户问“我的工单到哪一步了”答案不在任何文档里而在工单系统的数据库里。如果REA只会RAG这类问题就只能让用户自己去查体验感瞬间跌回传统搜索时代。解决方案是给REA加上工具调用能力。当模型发现当前问题需要实时数据时它会生成一个调用指令比如调用一个查询工单的函数把返回结果作为补充上下文再生成回答。这就像给一个博学但没手机的助手配了一部电话他能自己打去业务系统核实信息而不是只能凭记忆说话。在实现层面关键工作是为模型提供工具描述。每定义一个工具都要写清楚“这个工具是干什么的”“参数长什么样”“什么场景下用”。REA里一个典型的工具定义是这个样子{ type: function, function: { name: get_ticket_status, description: 根据工单号查询当前处理状态和审批节点, parameters: { type: object, properties: { ticket_id: { type: string, description: 用户提供的完整工单号 } }, required: [ticket_id] } } }模型会根据用户问题判断“该不该调工具、调哪个工具、参数是什么”。这段逻辑不需要写各种硬编码规则模型自己能完成但前提是你的工具描述必须写得足够清晰。描述含糊的话模型会乱调用用户只问一句“谁能帮我改邮箱密码”它可能就去调了重置密码工具的接口预想不到。3.2 多轮对话记住刚才聊过什么单独的RAG系统每一轮都是“独立问答”因为检索只依赖当前这一句话。但实际使用时用户很少会一次性把问题描述完整更多是展开式发问“售后流程怎么走”“审批要多久”“加急呢”最后一个问题如果脱离了前文语境根本不知道是在问什么。所以REA需要一个会话层负责维护每一轮回答以前的多轮对话上下文。实现方式不复杂每轮请求模型时把历史消息一起带上让模型根据对话历史理解当前问题的真实意图再做检索和生成。但这里有个坑上下文长度有限历史消息全部塞进请求很快会顶爆窗口。REA的做法是压缩历史系统定期把早期对话摘要成一段话只保留最近三轮完整对话加摘要既保住语境又控制长度。def build_messages(user_question, history, summary): messages [ {role: system, content: SYSTEM_PROMPT}, {role: assistant, content: f会话摘要{summary}}, ] for item in history[-3:]: # 只保留最近三轮 messages.append({role: user, content: item.question}) messages.append({role: assistant, content: item.answer}) messages.append({role: user, content: user_question}) return messages会话摘要这一步看起来不起眼实际解决了一个很烦人的问题当用户说“跟刚才那个流程一样换成销售部门”模型能理解“那个流程”指代的是什么而不是两眼一黑。3.3 Prompt设计用提示词把模型“框住”REA生成层的Prompt经历了至少五轮迭代。每一轮都对应一类线上反馈的错误。系统提示词本质上是在做三件事界定职责边界、规定回答格式、强制引用溯源。第一版Prompt里我只写了一句“你是企业知识库助手请根据上下文回答用户问题”。结果模型经常自由发挥上下文里没有的信息它也一本正经编出来。后来我改成“你只能依据提供的知识片段回答若知识片段不足以回答请明确回复‘当前知识库中未找到相关信息’并告知用户联系IT支持获取进一步帮助”。这句话立竿见影模型开始学着诚实了。第二个问题是格式混乱。用户问“退货涉及哪些部门”模型回答洋洋洒洒写了五百字核心信息反而淹没在段落里。我在Prompt里强制要求“尽量用条款列表或步骤编号逐条输出关键节点需标出涉及部门、时限、操作动作”回答结构立刻工整起来。第三个问题是溯源。业务侧在验收时点名要“每个回答都要有出处”否则无法追踪责任。Prompt里我加了很硬性的一条“回答末尾必须以引用列表形式列出提供依据的文档名称和章节标题缺少依据的表述不得出现在回答正文中”。配合切分时保留的元信息这条规则的执行效果很好。最终版的系统Prompt如下你是内置知识库问答助手。回答问题时必须遵循以下规则仅基于用户提供的上下文片段作答不得使用片段之外的知识。如果上下文不足以支撑回答直接说明“知识库中暂无相关信息”不得编造。回答需分点或分步骤输出先给结论再给依据。在回答末尾列出引用来源文档名章节无来源引用的内容禁止出现。这套Prompt帮助REA把幻觉率从肉眼可见的频繁压到了个别偶发。4. 实操过程从0到1跑通REA的全流程4.1 环境准备与依赖选型REA的后端我选用Python生态因为RAG相关的工具链最成熟出问题也好在社区查。Python版本用了3.10主要是为了类型标注和最新异步特性。跑起来之前先把虚拟环境建好避免全局包污染。python -m venv venv source venv/bin/activate pip install fastapi uvicorn pip install 向量计算库 文档解析库 嵌入式模型库Embedding模型的选型是这里最重要的决定。REA用的Embedding模型要支持中英文混合原因是企业内部文档夹杂着中文描述和英文系统名。如果Embedding模型对中文支持不好检索质量会断崖式下降。选型建议是先拿200条真实问题做小规模评测看召回的Top-5里有多少相关的别光看模型榜单分数。向量数据库我这边选用的是原生支持向量索引的开源社区版数据量在百万级块以下完全够用。如果你处理的文档量很小甚至可以用内存型向量索引先跑起来架构上把这部分独立出来后期再无缝切到真正的向量库。4.2 数据接入把一堆文件变成长得整整齐齐的向量REA项目里第一份要接入的数据集是某个部门的SOP文档集三十多份Word、十几张Excel表格、还有一些PDF扫描件。这个阶段最能体现“文档解析是个脏活累活”这句话。Word文档有目录、正文、页眉页脚、表格嵌套解析时不处理页眉页脚索引里就会混进一堆重复的部门名称。Excel表格更麻烦因为信息藏在单元格结构里直接按文本提取会丢失行列关系。我们的处理方式是先统一转成结构化文本再对每一类格式写独立的解析规则。所有文档解析完统一进切分、向量化、写入向量库的流程。索引构建的核心代码长这样def build_index(doc_paths): raw_docs parse_documents(doc_paths) # 各类格式解析成纯文本 chunks split_documents(raw_docs) # 语义切分 embeddings embed_model.encode(chunks) # 批量向量化 vector_db.add( ids[c.id for c in chunks], vectorsembeddings, metadatas[c.metadata for c in chunks] # 存储来源和章节信息 )批量向量化是容易被忽略的坑。千万别写个for循环一条条编码那是真的会等到天荒地老。用批量接口一次编码几百条性能提升是数量级的。实测下来同样一万个片段循环编码要一个多小时批量编码不到十分钟。4.3 查询接口用几个简单函数把整条链路串起来索引建好以后查询链路的实现其实非常直白。我在FastAPI里暴露了一个/ask接口内部依次调用查询理解、混合检索、重排序、上下文组装、模型生成这五个环节。app.post(/ask) async def ask(question: str, session_id: str ): history get_history(session_id) query rewrite_query(question, history) # 多轮改写 candidates hybrid_search(query, top_k30) # 混合召回 top_docs rerank(query, candidates, top_n5) # 精排 context build_context(top_docs) # 组装上下文 answer generate_answer(query, context, history) save_history(session_id, question, answer) return {answer: answer, sources: top_docs}这里rewrite_query就是利用历史消息把“加急呢”这种指代不清的问题改写成“售后审批流程加急怎么处理”。这相当于给检索层配了一个翻译官极大提升多轮对话场景下的召回效果。整个接口实现完前后端一接REA的第一个可用版本就上线了。从立项到跑通第一个端到端流程前后大概三周时间其中一半时间都花在文档解析和切分调参上。5. 常见问题与排查实录5.1 为什么答案总说不相关按我个人的排障习惯第一个排查的不是模型而是检索。因为模型看到什么内容直接决定它说什么话。手上出现“召回片段跟问题对不上”的反馈时我会先做一个固定的诊断动作把系统实际召回的Top-5片段导出来人肉看一眼到底哪一步丢了正确内容。这里我把问题按现象分成三类处理方式完全不同做成了一张速查表现象根因处理方案Top-5里有一条相关但排名太靠后混合检索权重不合理或重排序模型不够强调整向量/BM25融合参数换规模更大的重排序模型Top-5里完全没有相关内容切分粒度太粗或Embedding模型不合适按章节递归切块重新评测Embedding模型相关片段存在但模型没用上上下文太长关键信息被淹没了压缩片段数量强制在Prompt里要求优先引用靠前片段这三类问题里最坑的是第三类。有段时间检索质量已经很好了Top-5里前两条就是标准答案但模型答非所问。后来发现是因为一个片段里有大量表格数据模型读到一半就把前面的重点忘了。解决办法是限制每个片段最长内容量、表格单独转成文字描述模型这才开始认真读重点内容。5.2 模型一本正经地胡说八道幻觉问题是所有RAG系统上线时都会被业务方反复拷问的。REA面对的“胡说八道”分两种一种是完全无中生有另一种是引用了不合适的片段还强行作答。第二种当时占比高得多因为有些文档本身讲的是旧流程模型也不知道它已经过时照样把旧规则写得理直气壮。处理无中生有靠Prompt约束“只许用提供的片段否则说不知道”这个前面已经讲过了。处理过时文档则牵扯到一个核心机制给文档片段打上有效期。REA在元数据里增加了valid_from和valid_to字段检索时自动过滤不在有效期内的片段。比如“报销时限2023版”文档过了截止日期就被排除模型自然没有机会读到过期内容。还有一个细节当模型判断片段不够充分时我允许它部分退化成“传统答案机”。具体做法是在Prompt里加上一条“如果片段信息过时或不完整以最接近当前日期的片段为准并在答案中注明‘该信息可能存在版本差异’”。这种处理方式在业务上比一句冷冰冰的“不知道”更受用。5.3 系统越来越慢、知识更新不及时怎么办REA跑了一段时间后检索链路的速度瓶颈开始浮现。向量检索本身是毫秒级响应真正耗时的是两个环节一是混合检索里BM25倒排索引是纯内存构建文档库一大会占用大量内存二是模型生成阶段即使采用流式返回用户可感知的整体延迟还是偏高。针对检索速度我做了一个很简单的缓存层用查询摘要的哈希值做键把常见问题的检索结果缓存下来。同一个人反复问“密码重置多久生效”这类问题第二次开始直接从缓存取Top-5片段不重新走一次检索链路。实测命中缓存后检索耗时从几百毫秒降到了十几毫秒。针对知识更新我原本用的是定时重建索引的笨方案每天晚上把全部文档重新解析、重新向量化。但每次全量重建要跑一个多小时太折磨。后来改成增量更新向量库支持按文档集合删除再插入新增或修改一份文档只重新切分这一份删掉旧块、插入新块几分钟完成。这个改动让REA的知识新鲜度从“T1天”提高到了“T0小时”运维负担反而更小了。注意增量更新的前提是切分块必须携带稳定的文档ID和版本号。如果ID设计不好一份文档改一个字索引里就会残留大量旧块回答质量悄悄劣化。结尾的小经验把REA从零搭到能稳定服务我自己的几条经验是第一切分和元信息设计占了整个项目收益的一半请把时间花在前面第二混合检索加重排序是一个低调但回报极高的组合强烈建议直接放进第一版第三业务方验收时最在意的不是模型多聪明而是每个结论能不能追到出处所以引用溯源从第一天就必须进入数据结构。如果你也想做企业内部知识问答或者正在研究RAG和Agent的工程落地我的建议是从一个很小的文档集开始二十份真实文档、一百条真实问题先把检索链路调到让人舒服的程度再去追逐花哨的工具调用和多Agent编排。这些复杂能力都是锦上添花检索质量才是这套系统的地基。地基不牢上面盖什么都白搭。
阅读完成 · 觉得有帮助?