1. 从零理解多引擎同步优化 Agent 到底在做什么
1.1 一个真实需求场景的拆解
先说说我为什么会折腾这套东西。去年下半年,我手上有一个企业知识服务的项目,客户是做工业设备运维的,内部沉淀了大概十几万份文档——设备手册、维修工单、故障报告、培训材料,格式从 PDF、Word 到扫描件、Excel 表格都有。他们最初的想法很简单:搞一个能问答的机器人,让一线工程师遇到问题直接问,不用再翻手册。
我一开始也觉得这事不难,接个大语言模型,把文档塞进向量数据库,做个 RAG 检索增强,前端套个对话框就完事了。结果真跑起来才发现,问题远比想象中复杂。工程师问“XX型号泵在高温工况下振动超标怎么处理”,系统检索出来的却是三年前一份不相关的采购合同;问“上次那个轴承异响最后怎么解决的”,系统完全不知道“上次”指的是哪一次。更麻烦的是,客户希望这个 Agent 不仅能问答,还要能自动生成维修建议、自动归档工单、自动推送预警——这就不是单纯一个 RAG 能扛住的了。
这就是“多引擎同步优化 Agent”要解决的核心问题。所谓多引擎,指的是一个 Agent 系统里同时跑着好几套能力引擎:有负责语义检索的向量引擎,有负责关键词精确匹配的全文引擎,有负责结构化数据查询的 SQL 引擎,还有负责调用外部工具的 Function Calling 引擎。同步优化,指的是这些引擎不是各干各的,而是要在一次用户请求里协同工作、结果融合、互相校正。这跟传统单路 RAG 的区别,就像一个人看病,单路 RAG 是只让你做一项检查,多引擎 Agent 是让你同时做血常规、CT、B超,然后综合判断。
1.2 为什么单路 RAG 在企业场景里经常翻车
我踩过的坑里,最典型的就是检索命中率(hit rate)上不去。很多人做 RAG 教程,拿几篇博客文章当语料,检索效果看着挺好,一到企业真实数据就崩。原因有几个层面。
第一是语义漂移。向量检索本质是把文本映射到高维空间算余弦相似度,但企业文档里有大量专业术语、型号编码、缩写,这些词在通用嵌入模型里根本没有好的表示。比如“DN80-PN16”这种管道规格,嵌入模型可能把它和“DN100-PN25”算得很近,但工程上这俩完全不能混用。
第二是长文档切分失真。一份 50 页的设备手册,你按 512 token 切块,切完之后每块都失去了上下文。用户问的是整台设备的启动流程,检索出来的却是某个中间步骤的片段,答非所问。
第三是结构化信息丢失。企业里大量关键信息在表格里、在工单系统的数据库里,纯文本 RAG 根本够不着。工程师问“上个月哪台设备故障率最高”,这需要查数据库聚合,不是检索文档能解决的。
第四是多轮对话记忆缺失。用户第一句问“3号泵怎么了”,第二句问“那它上次维修是什么时候”,单路 RAG 每次都是独立检索,根本不知道“它”指什么。
多引擎同步优化的思路,就是针对这四类问题分别下药:向量引擎解决语义泛化,全文引擎(BM25 之类)解决精确匹配,SQL 引擎解决结构化查询,记忆模块解决多轮上下文。关键在于“同步”——不是简单地把几路结果拼起来,而是要有融合排序、互相验证的机制。
1.3 这套方案适合谁,不适合谁
先说适合的。如果你手上有企业级知识库,文档量大、格式杂、专业性强,而且业务方对准确率有硬要求(比如运维、医疗、法律、金融这类容错率低的场景),那多引擎方案值得投入。如果你要做的是AI Agent 而不是单纯问答,需要 Agent 能调工具、能查库、能多步推理,那这套架构基本是绕不开的。
不适合的情况也得说清楚。如果你只是个人玩玩,想搭个本地知识库问答,语料就几百篇 Markdown,那单路 RAG 加个好点的嵌入模型足够了,上多引擎纯属杀鸡用牛刀,运维成本还高。如果你对延迟极度敏感(比如要求 200ms 内出结果),多引擎并行检索加融合排序的开销也要仔细权衡。
我个人的判断标准是:当你的单路 RAG 在真实业务数据上 hit rate 低于 70%,或者业务方开始抱怨“答得不对”超过三次,就该考虑上多引擎了。
2. 多引擎 Agent 的整体架构与选型逻辑
2.1 四层架构:接入层、编排层、引擎层、数据层
我把整套系统拆成四层来理解,这样排查问题的时候能快速定位是哪一层出了毛病。
接入层负责和用户交互,包括对话界面、API 网关、鉴权、限流。这一层看着简单,但企业场景里往往是最容易被低估的——你要处理并发、要记录审计日志、要做敏感词过滤,这些都得在这一层落地。
编排层是整个 Agent 的大脑,负责意图识别、任务规划、引擎调度、结果融合。用户一句话进来,编排层要先判断这是哪类问题:是纯知识问答,还是要查数据库,还是要调外部工具?判断完了再决定调哪几个引擎、怎么合并结果。这一层我强烈建议用成熟的 Agent 框架来做,比如 LangChain、LlamaIndex,或者国内常用的 LangChain4j(Java 技术栈的话)。自己从零写编排逻辑,前期爽,后期维护会哭。
引擎层就是前面说的多引擎:向量检索引擎、全文检索引擎、SQL 查询引擎、工具调用引擎。每个引擎独立部署、独立扩缩容,通过统一接口暴露给编排层。
数据层包括向量数据库、关系型数据库、文档存储、缓存。这里有个关键设计:同一份原始数据要同时进入多个引擎的索引。文档既要做向量化存进向量库,也要做分词存进全文索引,结构化字段还要抽出来进 SQL 库。数据同步是这套架构里最容易出问题的地方,后面会专门讲。
2.2 向量数据库选型:别只看 benchmark
向量数据库选型是问得最多的问题。我列个实际用过的对比表,都是生产环境跑过的,不是看文档抄的。
| 数据库 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| Milvus | 生态成熟,分布式能力强,支持多种索引 | 运维复杂,资源占用高 | 千万级以上向量,团队有运维能力 |
| Qdrant | 部署简单,过滤查询强,Rust 性能好 | 社区相对小,分布式方案较新 | 百万级向量,需要复杂元数据过滤 |
| Weaviate | 内置混合检索,模块化好 | 内存占用偏高 | 想开箱即用混合检索的团队 |
| pgvector | 和 PostgreSQL 一体,事务友好 | 亿级向量性能下降明显 | 已有 PG 技术栈,向量规模中等 |
| Chroma | 极简,适合原型 | 生产特性弱 | 本地开发、Demo |
我的经验是:如果你团队已经有 PostgreSQL 且向量规模在百万级以内,pgvector 是最省心的选择,因为不用额外维护一套数据库,事务一致性也好保证。超过千万级再考虑 Milvus 或 Qdrant。别一上来就上分布式向量库,运维成本会吃掉你所有精力。
2.3 嵌入模型:本地部署还是调 API
这是绕不开的决策。调 API 的好处是省事、效果好、不用管算力;坏处是数据出域、成本随量线性增长、有网络延迟。本地部署的好处是数据不出门、成本固定、延迟可控;坏处是要买卡、要调优、效果可能不如顶级 API 模型。
我的建议分两种情况。如果数据敏感度不高、预算充足、追求快速上线,先用 API 跑通流程,把精力放在编排和融合上。如果数据敏感或者量大到 API 成本扛不住,再考虑本地部署。本地部署的话,嵌入模型我实测下来,BGE 系列(BAAI 出的)在中文场景性价比很高,M3E 也不错。部署用 Ollama 最省事,一条命令拉起来,配合简单的本地 RAG 知识库完全够用。
这里插一句关于“本地部署大语言模型”的常见误区。很多人以为本地部署就是装个 Ollama 拉个模型就完事,其实真正的难点在推理服务的并发和显存管理。7B 模型单卡能跑,但并发一上来就排队。生产环境要么用 vLLM 这类推理框架做批处理,要么就接受低并发。算力约束下,资源配置建模比模型选型更重要——你得算清楚 QPS 目标、单请求 token 数、显存占用,反推需要几张卡。
2.4 全文检索引擎:被低估的精确匹配利器
向量检索火了之后,很多人把 BM25 这类全文检索当成过时技术。这是大错特错。在企业场景里,型号、编码、人名、专有名词的精确匹配,BM25 吊打向量检索。
我做过一个对比测试,语料是 5 万份设备文档,测试集是 200 个真实工程师提问。纯向量检索 hit rate 是 68%,纯 BM25 是 61%,但两者融合之后到了 89%。这个提升幅度说明什么?说明两路检索抓的是不同的信息,融合才有价值。
全文引擎选型上,Elasticsearch 是标配,功能全但重;OpenSearch 是它的开源分支,差不多;如果规模不大,Meilisearch 或 Typesense 更轻量,部署体验好很多。我最近几个项目用的是 OpenSearch,主要是生态成熟、中文分词插件多。
3. 核心细节:检索融合、记忆管理与工具调用
3.1 混合检索的融合排序怎么做
多引擎检索出来一堆结果,怎么合并成一个排序列表,这是核心技术点。常见做法有三种。
第一种是 RRF(Reciprocal Rank Fusion,倒数排名融合)。原理很简单:每个结果根据它在各自引擎里的排名算一个分数,公式是 score = Σ 1/(k + rank),k 一般取 60。这个方法的优点是不需要归一化分数,因为不同引擎的分数尺度完全不一样(向量是余弦相似度 0-1,BM25 是任意正数),直接加权平均会出问题。RRF 只看排名,鲁棒性好,我大部分项目都用这个。
第二种是加权分数融合。把每路分数归一化到 0-1,然后加权求和。难点在归一化,而且权重很难调。除非你有大量标注数据做调优,否则不推荐。
第三种是学习排序(Learning to Rank)。用一个小模型,输入是各路检索的特征(排名、分数、文档长度等),输出最终排序。效果最好,但需要标注数据训练,成本高。企业场景里如果有历史点击日志,可以试试。
我实操下来,RRF 是性价比最高的选择,代码就十几行,效果稳定。具体实现时,k 值可以调,k 越大越平滑,一般 60 是经验值。另外要注意,融合之前每路检索的 top-k 要设合理,太小了融合没意义,太大了噪声多。我一般每路取 top 20,融合后取 top 5 送给大模型。
3.2 重排序模型:融合之后的第二道关
融合排序之后,还有一步能显著提升效果:用重排序模型(Reranker)对 top 结果重新打分。Reranker 和嵌入模型的区别在于,嵌入模型是双塔结构,query 和 doc 分别编码再算相似度,快但精度有限;Reranker 是交叉编码,query 和 doc 拼在一起过模型,慢但精度高。
流程是这样的:向量检索和全文检索各取 top 20,RRF 融合后取 top 20,然后这 20 个结果过 Reranker,重新排序取 top 5。这样既保证了召回,又保证了精度。
Reranker 模型我推荐 BGE-Reranker 系列,中文效果好,本地部署也方便。注意 Reranker 是计算密集型的,20 个候选过一遍可能就要几百毫秒,所以候选数量不能太多。如果延迟敏感,可以只对 top 10 做重排。
3.3 Agent 记忆管理:短期、长期、工作记忆
Agent 要能多轮对话,记忆管理是必须的。我把记忆分三类。
短期记忆就是当前对话的上下文,直接塞进 prompt 里。但要注意 token 限制,对话长了要截断或摘要。我的做法是保留最近 5 轮完整对话,更早的做摘要压缩。
长期记忆是跨会话的,比如用户偏好、历史问题。这个要存到数据库里,需要的时候检索出来。实现上可以用向量库存用户的历史问答,新问题来了先检索相关历史。
工作记忆是 Agent 执行任务过程中的中间状态,比如它查了数据库、调了工具,这些结果要暂存起来供后续步骤用。这个用内存或 Redis 都行,任务结束就清掉。
这里有个坑:记忆检索本身也会引入噪声。如果用户历史问答质量不高,检索出来反而干扰当前回答。我的经验是,长期记忆只在明确需要的时候才检索,不要每轮都查。
3.4 工具调用与 Function Calling 的工程细节
Agent 要调外部工具,这块的工程细节很多。首先是工具描述要写清楚,大模型是根据描述决定调不调、怎么调的。描述里要包含:工具干什么、参数是什么、什么情况下用。写得含糊,模型就乱调。
其次是参数校验。模型生成的参数不一定合法,比如要整数给了字符串,要日期格式给了自然语言。调用之前必须校验,不合法就返回错误让模型重试。
第三是超时和重试。外部工具可能挂掉或变慢,必须有超时机制,超时了要么降级要么告诉用户。重试要幂等,不然会重复执行。
第四是权限控制。不是所有用户都能调所有工具,比如删除类操作要限制。这个在编排层做,根据用户角色过滤可用工具列表。
我踩过最坑的一次是:模型调了一个查询工具,参数里带了个 SQL 注入的字符串,直接把测试库删了。从那以后,所有工具的参数都做了严格校验,SQL 查询只允许 SELECT,而且要用参数化查询。
4. 实操过程:从环境搭建到跑通全流程
4.1 环境准备与依赖安装
我以一套最小可跑的系统为例,技术栈选:Python + FastAPI 做服务,LangChain 做编排,Qdrant 做向量库,OpenSearch 做全文检索,Ollama 跑本地嵌入模型,PostgreSQL 存结构化数据。这套组合的好处是全部可以本地部署,不依赖外部 API。
先装依赖:
pip install fastapi uvicorn langchain langchain-community qdrant-client opensearch-py psycopg2-binary ollama sentence-transformersOllama 单独装,然后拉嵌入模型:
ollama pull bge-m3Qdrant 和 OpenSearch 用 Docker 起:
docker run -d -p 6333:6333 qdrant/qdrant docker run -d -p 9200:9200 -e "discovery.type=single-node" opensearchproject/opensearch:latestPostgreSQL 用现成的就行,建个库存工单数据。
4.2 数据入库:一份数据进三个引擎
这是最容易出问题的环节。我的做法是写一个统一的入库管道,原始文档进来之后,走三个分支。
第一个分支做向量化。文档先切块,切块策略很关键。我一般用递归切分,先按标题切,再按段落切,最后按 token 数兜底。块大小 512 token,重叠 50 token。切完用 BGE-M3 编码,存进 Qdrant,payload 里带上原文、来源、页码等元数据。
第二个分支做全文索引。同样的块,用中文分词器(IK 或 jieba)分词,存进 OpenSearch。这里要注意,分词器要和查询时用的一致,不然匹配不上。
第三个分支做结构化抽取。用大模型从文档里抽结构化字段,比如设备型号、故障类型、日期,存进 PostgreSQL。这一步可以用规则+模型混合,纯模型成本高。
三个分支要保证数据一致性。我的做法是给每份文档一个唯一 ID,三个引擎都用这个 ID,任何一路失败就整体回滚。同步用消息队列做,文档进来先入队,三个消费者分别处理,都成功了才标记完成。
4.3 检索编排:一次请求的完整链路
用户提问进来,编排层按这个流程走:
第一步,意图识别。用一个小模型或规则判断问题类型。是知识问答、数据查询、还是工具调用?我一般用大模型做 few-shot 分类,准确率够用。
第二步,查询改写。用户的问题往往口语化,要改写成适合检索的形式。比如“那个泵咋回事”要结合上下文改写成“XX型号泵的故障原因”。这一步用大模型做,把历史对话一起塞进去。
第三步,多路检索。改写后的 query 同时发给向量引擎和全文引擎,各取 top 20。如果是数据查询类,还要生成 SQL 查数据库。
第四步,融合重排。RRF 融合,Reranker 重排,取 top 5。
第五步,生成回答。把 top 5 文档、用户问题、历史对话一起塞给大模型,生成回答。prompt 里要明确要求“只根据提供的资料回答,不知道就说不知道”,减少幻觉。
第六步,引用标注。回答里要标出每句话来自哪份文档,方便用户核实。这个在企业场景里很重要,业务方要能追溯。
4.4 关键参数的计算与调优
几个关键参数我列一下我的经验值,但强调一下,这些都要根据你的数据实测调整,不能照搬。
切块大小:512 token 是通用值,但技术文档可以小一点(256-384),因为技术文档信息密度高;叙述性文档可以大一点(768-1024)。
检索 top-k:每路取 20,融合后取 20,重排后取 5。如果延迟敏感,每路取 10。
RRF 的 k 值:60 是默认,我试过 30 到 100,差异不大,60 够用。
Reranker 候选数:10-20,超过 20 延迟明显上升。
大模型温度:问答场景设 0.1-0.3,要稳定不要创意。
并发控制:向量检索和全文检索可以并行,用 asyncio 或线程池。大模型生成是瓶颈,要限流。
4.5 一个完整的代码骨架
import asyncio from langchain.embeddings import OllamaEmbeddings from qdrant_client import QdrantClient from opensearchpy import OpenSearch class MultiEngineRetriever: def __init__(self): self.embedder = OllamaEmbeddings(model="bge-m3") self.qdrant = QdrantClient(host="localhost", port=6333) self.os_client = OpenSearch(hosts=[{"host": "localhost", "port": 9200}]) async def vector_search(self, query, top_k=20): vec = self.embedder.embed_query(query) results = self.qdrant.search( collection_name="docs", query_vector=vec, limit=top_k ) return [(r.id, r.score, r.payload) for r in results] async def bm25_search(self, query, top_k=20): body = { "query": {"match": {"content": query}}, "size": top_k } results = self.os_client.search(index="docs", body=body) return [(r["_id"], r["_score"], r["_source"]) for r in results["hits"]["hits"]] def rrf_fusion(self, vector_results, bm25_results, k=60): scores = {} for rank, (doc_id, _, payload) in enumerate(vector_results): scores[doc_id] = scores.get(doc_id, {"score": 0, "payload": payload}) scores[doc_id]["score"] += 1 / (k + rank + 1) for rank, (doc_id, _, payload) in enumerate(bm25_results): scores[doc_id] = scores.get(doc_id, {"score": 0, "payload": payload}) scores[doc_id]["score"] += 1 / (k + rank + 1) sorted_docs = sorted(scores.items(), key=lambda x: x[1]["score"], reverse=True) return [(doc_id, info["score"], info["payload"]) for doc_id, info in sorted_docs] async def retrieve(self, query): vector_task = self.vector_search(query) bm25_task = self.bm25_search(query) vector_results, bm25_results = await asyncio.gather(vector_task, bm25_task) fused = self.rrf_fusion(vector_results, bm25_results) return fused[:5]这个骨架跑起来之后,再往上加 Reranker、加 SQL 引擎、加工具调用,就是完整的 Agent 了。
5. 常见问题与排查技巧实录
5.1 检索命中率上不去的排查清单
hit rate 低是最常见的问题,我整理了一个排查顺序,从易到难。
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| 切块策略 | 看切出来的块是否语义完整 | 块太小丢上下文,太大噪声多 |
| 嵌入模型 | 拿几个 query 手动算相似度 | 模型不适配领域,换领域模型 |
| 分词器 | 看 query 分词结果 | 中文没分词,专有名词被切碎 |
| 元数据过滤 | 检查是否该过滤的没过滤 | 检索到其他部门/其他设备的文档 |
| 查询改写 | 看改写后的 query 是否合理 | 改写过度,偏离原意 |
| 融合权重 | 对比单路和融合的效果 | 某一路噪声大拖累整体 |
我的经验是,80% 的 hit rate 问题出在切块和嵌入模型上。先换嵌入模型试试,不行再调切块。
5.2 大模型幻觉的抑制手段
企业场景最怕大模型胡说。抑制幻觉有几个手段,我按有效性排序。
第一,prompt 里明确约束。写清楚“只根据以下资料回答,资料里没有的信息不要编造,不知道就说不知道”。这个最简单,但有效。
第二,提供引用。让模型在回答里标注每句话的来源,用户能核实。模型知道要标来源,编造的概率会降低。
第三,降低温度。温度设 0.1 甚至 0,输出更确定。
第四,后置校验。生成完回答后,用另一个模型或规则检查回答里的关键信息是否在检索结果里,不在就标记可疑。
第五,拒答机制。如果检索结果的相关性分数都低于阈值,直接告诉用户“没有找到相关资料”,不要硬答。
我实测下来,前三条组合起来能把幻觉率压到可接受范围。后两条成本高,看场景用。
5.3 并发扛不住的优化路径
Agent 系统并发一上来,瓶颈通常在三个地方:大模型推理、向量检索、Reranker。
大模型推理是最大瓶颈。优化路径:用 vLLM 做批处理,把多个请求合并成一个 batch 推理,吞吐能提升好几倍;或者用更小的模型,7B 不够就上 3B,效果差一点但快很多;再不行就加卡,横向扩展。
向量检索优化:选对索引类型,HNSW 比 IVF 快但内存占用高;开量化,把 float32 降到 int8,内存省一半,精度损失很小;分片,数据量大就分多个 collection。
Reranker 优化:减少候选数,20 降到 10;用更小的 Reranker 模型;或者干脆异步做,先返回粗排结果,重排完再更新。
我踩过的坑是:一开始没做限流,用户一多直接把大模型服务打挂。后来加了令牌桶限流,超过阈值的请求排队或降级,系统才稳。
5.4 数据同步不一致的处理
前面说过,一份数据要进三个引擎,任何一路失败都会导致不一致。我的处理方案是:
用消息队列(RabbitMQ 或 Kafka)做异步同步,文档进来先入队,三个消费者分别处理。每个消费者处理完发一个 ack,三个 ack 都收到才标记文档同步完成。如果某个消费者失败,消息重回队列重试,重试超过三次进死信队列,人工介入。
另外要定期做一致性校验。写个脚本,随机抽样文档,检查三个引擎里是否都有,内容是否一致。发现不一致就触发重新同步。
这个机制看着麻烦,但企业场景里数据一致性是底线,不能省。
5.5 几个独家避坑技巧
最后分享几个我踩坑踩出来的经验,文档里不会写。
技巧一:给检索结果加时间衰减。企业文档有新旧之分,用户通常想要最新的。在融合排序时,给新文档加个小的加权,比如按时间排序加 5% 的分数。这个改动很小,但效果明显。
技巧二:query 改写要保留原 query。改写后的 query 用于检索,但原 query 也要保留,两路都检索,结果一起融合。因为改写可能丢信息,原 query 能兜底。
技巧三:Reranker 之前先去重。向量检索和全文检索经常召回同一篇文档的不同块,融合后要去重,不然 Reranker 浪费算力在重复内容上。
技巧四:给大模型的 prompt 里加 few-shot 示例。尤其是回答格式,给一两个示例,模型输出会规范很多,省去后处理。
技巧五:监控检索的 hit rate 和回答的采纳率。hit rate 是检索层面的,采纳率是用户层面的(用户是否满意、是否追问)。两个指标一起看,才能定位问题在检索还是在生成。
这套系统我从零搭到跑通,前后花了大概两个月,中间踩了无数坑。现在回头看,最难的不是技术选型,而是理解业务场景的真实需求。技术方案再漂亮,解决不了业务问题就是白搭。所以我的建议是,动手之前先花时间搞清楚:用户到底会问什么、数据到底长什么样、准确率要求到底多高。这三个问题想清楚了,技术方案自然就出来了。