1. 联网问答 Agent 到底在解决什么问题
企业里做 AI 助手,最尴尬的场景不是模型不够聪明,而是它一本正经地胡说八道。你问它"我们产品最新版本号是多少",它给你编一个;你问"上周发布的定价策略调整了哪些条目",它把三个月前的旧文档翻出来当答案。这类问题在纯离线大模型上几乎无解,因为模型的训练数据有截止日期,而企业的业务信息每天都在变。
联网问答 Agent要干的事情很直接:让 AI 助手在回答之前,先去该去的地方把最新信息捞回来,再基于真实数据组织答案。它不是一个单纯的"聊天机器人加个搜索框",而是一套完整的检索—理解—生成—溯源链路。核心关键词就三个:实时性、准确性、可追溯。
这套东西适合谁?我梳理了一下,大致是三类人:一是企业内部的 IT 或数字化团队,想给业务部门搭一个能查内部知识库的问答入口;二是做 SaaS 产品的团队,需要在产品里嵌入一个能回答用户问题的智能客服;三是独立开发者或小团队,想用较低成本验证"AI 助手 + 实时数据"这个方向。不管你属于哪一类,底层的架构思路是相通的。
我见过太多团队一上来就纠结用哪个大模型,结果模型换了好几轮,问答质量还是上不去。问题根本不在模型,在于检索环节没做对。模型再强,你喂给它的上下文是错的、过时的、不完整的,它只能编。所以这篇内容我会把重心放在联网问答 Agent 的架构设计、检索策略、实操落地和踩坑经验上,模型选型反而放在次要位置讲。
2. 整体架构设计与方案选型思路
2.1 为什么不能只靠"模型 + 搜索 API"硬拼
很多人第一版方案是这样的:用户提问 → 调用搜索接口 → 把搜索结果拼进 Prompt → 丢给大模型 → 输出答案。跑个 Demo 没问题,一上生产就崩。原因有几个:
第一,搜索结果噪音太大。搜索引擎返回的是一堆网页摘要,里面夹杂着广告、导航栏、无关段落,直接塞给模型,模型会被干扰,答案质量断崖式下降。第二,多轮对话场景下,用户的问题往往有上下文依赖,比如先问"你们有哪些产品线",再问"第二个的定价是多少",这时候"第二个"指代什么,搜索接口根本不知道。第三,没有溯源。企业场景下,答案必须能指回原文出处,否则业务方不敢用。
所以联网问答 Agent 的架构,本质上是在"模型"和"数据源"之间加了一层智能调度和加工层。这层要干的事包括:意图识别、查询改写、多源检索、结果重排、上下文组装、答案生成、引用标注。
2.2 分层架构拆解
我习惯把这套系统拆成四层,从下往上说:
数据接入层负责对接各种数据源。企业内部常见的有:文档管理系统、工单系统、产品数据库、FAQ 库、内部 Wiki。外部数据源就是公开网页、行业资讯等。这一层的关键是统一数据格式,不管原始数据是 PDF、HTML 还是数据库记录,最终都要转成带元数据的文本块(chunk),元数据至少包含来源、更新时间、权限标签。
检索层是核心。它要支持两种检索模式:向量检索(语义相似)和关键词检索(精确匹配)。企业场景下两者缺一不可。比如用户问"报销流程",向量检索能召回语义相关的内容,但如果用户问的是具体单号"报销单 BX-20240301",那就必须靠关键词精确匹配。我通常用混合检索,两路结果做加权融合。
编排层负责把用户问题变成检索指令,再把检索结果组装成模型能吃的上下文。这里面涉及查询改写、意图分类、多轮对话状态管理。这一层做得好不好,直接决定最终答案质量。
生成层就是大模型出场的地方。但注意,模型在这里的角色是"基于给定上下文组织答案",而不是"凭记忆回答问题"。Prompt 里要明确约束:只允许使用提供的上下文,找不到就说找不到,不许编。
2.3 方案选型的关键取舍
选型上有几个岔路口,我一个个说我的判断。
自建检索还是用现成服务?如果数据全在企业内部,自建是必须的,因为外部服务碰不到你的内网数据。如果主要是公开数据,可以用现成的搜索 API 做召回,但重排和加工还是得自己做。
向量数据库选哪个?这个没有标准答案,看数据规模和团队熟悉度。数据量在百万级以下,很多轻量方案都能扛;上了千万级,就要考虑分布式和索引性能。我的建议是先用小规模验证效果,别一上来就上重型方案。
大模型用哪个?我的经验是:检索质量决定下限,模型能力决定上限。检索做得好,中等模型也能给出不错的答案;检索做得烂,顶级模型也救不回来。所以预算有限的话,先把钱花在检索优化上。
要不要做微调?大多数企业问答场景不需要微调。微调适合固定格式输出或特定领域术语理解,但联网问答的核心矛盾是"信息时效性",微调解决不了这个问题。RAG(检索增强生成)才是正路。
3. 核心细节解析与实操要点
3.1 文档切分:最容易被低估的环节
文档切分(chunking)看起来简单,实际上是最影响检索质量的环节之一。切得太碎,语义不完整,检索出来的片段答非所问;切得太粗,噪音太多,模型抓不住重点。
我的实操经验是这样的:按语义边界切,而不是按固定字数切。具体做法是先用标题层级做一级切分,把文档拆成章节;章节内再按段落切,段落超过一定长度(比如 500 字)再按句子边界二次切分。每个 chunk 控制在 200 到 500 字之间,这个区间在多数场景下效果比较稳。
还有一个细节:chunk 要带上下文头。什么意思?就是每个切分出来的片段,前面要附上它所属的章节标题和文档标题。比如一个片段本身只写了"价格为 199 元",单独看不知道是什么的价格,但如果前面加上"产品 A 标准版定价说明",语义就完整了。这个技巧实测能明显提升检索命中率。
注意:切分参数没有万能值,一定要用你自己的数据做小规模测试。我一般会准备 20 到 30 个典型问题,跑一遍检索,看 Top5 结果里有没有正确答案,据此调整切分粒度。
3.2 查询改写:让用户的问题"说人话"
用户提问往往很随意,比如"那个新出的东西多少钱",直接拿这句话去检索,效果很差。查询改写要做几件事:
指代消解。多轮对话里,"那个""它""第二个"这些指代词,要结合历史对话还原成具体实体。这个通常靠维护一个对话状态,把上一轮提到的实体记下来,改写时替换进去。
口语转书面。"多少钱"改成"价格","咋弄"改成"操作步骤","能不能"改成"是否支持"。这一步可以用小模型做,也可以用规则加词典,成本不高但收益明显。
查询扩展。用户问"退款",可能相关文档里写的是"退货退款""售后退款""订单取消退款",改写时可以生成几个同义变体,一起检索,提高召回率。
意图分类。有些问题根本不需要检索,比如"你好""谢谢",直接走闲聊通道;有些问题需要查数据库而不是查文档,比如"今天有多少订单",这类要路由到结构化查询。意图分类做准了,能省掉大量无效检索。
3.3 混合检索的权重调优
混合检索的核心是向量检索和关键词检索的结果怎么融合。常见做法是分别取 TopN,然后用加权分数合并。权重怎么定?我的经验是:
| 场景类型 | 向量权重 | 关键词权重 | 说明 |
|---|---|---|---|
| 语义问答为主 | 0.7 | 0.3 | 用户问概念、流程类问题 |
| 精确查询为主 | 0.3 | 0.7 | 用户问编号、名称、参数 |
| 混合场景 | 0.5 | 0.5 | 大多数企业问答的默认值 |
这个权重不是拍脑袋定的,要用测试集跑评估。评估指标看两个:召回率(正确答案有没有被检索到)和准确率(检索结果里有多少是相关的)。两个指标要平衡,不能只看一个。
还有一个提效技巧:加一层重排(rerank)。混合检索召回 Top20 之后,用一个重排模型对这 20 条做精排,取 Top5 送给大模型。重排模型比向量检索更精细,能显著提升送入模型的上下文质量。这一步增加了一点延迟,但答案质量的提升通常值得。
3.4 Prompt 设计:约束比指令更重要
生成层的 Prompt 设计,很多人写成一长串"请你扮演一个专业的客服...",其实作用有限。真正重要的是约束条件。我常用的约束有这么几条:
- 只允许使用下方提供的参考资料回答问题,不得使用你自己的知识。
- 如果参考资料中没有相关信息,直接回答"根据现有资料无法回答该问题",不要猜测。
- 回答时标注引用来源,格式为 [来源编号]。
- 如果多个来源信息冲突,指出冲突并说明各自出处。
这几条约束能挡掉大部分幻觉。特别是"找不到就说找不到"这一条,很多团队不敢加,怕用户体验差,但实际上,一个诚实的"不知道"比一个自信的错误答案价值高得多,尤其在 enterprise 场景。
另外,上下文组装时要注意顺序和分隔。把最相关的资料放在最前面和最后面,中间放次要的,这是利用了模型对首尾信息更敏感的特性。每条资料之间用明确的分隔符隔开,并标注来源编号,方便模型引用。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
假设我们用 Python 技术栈来搭这套系统,基础环境需要这些东西:Python 3.10 以上、一个向量数据库(这里以轻量方案为例)、一个嵌入模型、一个大模型接口、一个 Web 框架。
pip install fastapi uvicorn pip install sentence-transformers pip install chromadb pip install rank-bm25 pip install openai这里说明一下选型理由。sentence-transformers用来做文本向量化,本地跑,不依赖外部服务,适合数据敏感场景。chromadb是轻量向量库,适合中小规模数据快速验证。rank-bm25做关键词检索,纯 Python 实现,够用。大模型接口按你实际用的平台替换。
4.2 数据入库:从原始文档到可检索块
第一步是把文档处理成 chunk 并入库。核心代码如下:
import hashlib from datetime import datetime def split_document(doc_title, sections, max_len=500): chunks = [] for sec_title, content in sections: paragraphs = content.split("\n\n") buffer = "" for para in paragraphs: if len(buffer) + len(para) > max_len and buffer: chunks.append(build_chunk(doc_title, sec_title, buffer)) buffer = para else: buffer += "\n" + para if buffer else para if buffer: chunks.append(build_chunk(doc_title, sec_title, buffer)) return chunks def build_chunk(doc_title, sec_title, content): header = f"文档:{doc_title} | 章节:{sec_title}" full_text = header + "\n" + content return { "id": hashlib.md5(full_text.encode()).hexdigest(), "text": full_text, "doc_title": doc_title, "section": sec_title, "updated_at": datetime.now().isoformat() }这段代码的关键点在build_chunk里:每个 chunk 都带上了文档标题和章节标题作为上下文头。这就是前面说的"上下文头"技巧的落地。别小看这一行,它让检索时的语义匹配准确率提升很明显。
入库时,向量和原文一起存:
from sentence_transformers import SentenceTransformer import chromadb model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") client = chromadb.Client() collection = client.create_collection("enterprise_qa") def index_chunks(chunks): texts = [c["text"] for c in chunks] embeddings = model.encode(texts).tolist() collection.add( ids=[c["id"] for c in chunks], embeddings=embeddings, documents=texts, metadatas=[{"doc": c["doc_title"], "section": c["section"]} for c in chunks] )4.3 混合检索的实现
检索环节把向量检索和 BM25 关键词检索的结果融合。先建 BM25 索引:
from rank_bm25 import BM25Okapi import jieba def build_bm25(chunks): corpus = [list(jieba.cut(c["text"])) for c in chunks] return BM25Okapi(corpus), chunks bm25, all_chunks = build_bm25(chunks)检索函数:
def hybrid_search(query, top_k=5, vec_weight=0.5): # 向量检索 q_emb = model.encode([query]).tolist() vec_results = collection.query(query_embeddings=q_emb, n_results=20) vec_ids = vec_results["ids"][0] vec_scores = {id_: 1 - d for id_, d in zip(vec_ids, vec_results["distances"][0])} # 关键词检索 tokens = list(jieba.cut(query)) bm25_scores_raw = bm25.get_scores(tokens) bm25_ranked = sorted(range(len(bm25_scores_raw)), key=lambda i: bm25_scores_raw[i], reverse=True)[:20] bm25_scores = {} for i in bm25_ranked: cid = all_chunks[i]["id"] bm25_scores[cid] = bm25_scores_raw[i] # 归一化后融合 all_ids = set(vec_scores) | set(bm25_scores) max_bm25 = max(bm25_scores.values()) if bm25_scores else 1 fused = {} for cid in all_ids: v = vec_scores.get(cid, 0) b = bm25_scores.get(cid, 0) / max_bm25 fused[cid] = vec_weight * v + (1 - vec_weight) * b ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True)[:top_k] return ranked这里有个细节:BM25 分数和向量相似度不在一个量纲上,直接加权会出问题。所以我对 BM25 分数做了最大值归一化,把它压到 0 到 1 之间,再和向量分数融合。这个处理不做的话,融合结果会被 BM25 的大数值主导。
4.4 生成与引用标注
最后一步是把检索结果组装成 Prompt,调用大模型:
def build_prompt(query, retrieved_chunks): context_parts = [] for i, (cid, score) in enumerate(retrieved_chunks, 1): chunk = next(c for c in all_chunks if c["id"] == cid) context_parts.append(f"[{i}] {chunk['text']}") context = "\n\n".join(context_parts) prompt = f"""你是一个企业知识问答助手。请严格根据下方参考资料回答问题。 规则: 1. 只使用参考资料中的信息,不得使用你自己的知识。 2. 如果参考资料无法回答该问题,直接说"根据现有资料无法回答"。 3. 回答中标注引用来源编号,如 [1][2]。 4. 多个来源冲突时,指出冲突并说明出处。 参考资料: {context} 用户问题:{query} """ return prompt调用模型后,把返回的答案和引用来源一起返回给前端。前端展示时,引用编号可以做成可点击的,点开显示原文片段,这样业务方就能核对答案是否靠谱。
4.5 多轮对话状态管理
单轮问答跑通后,多轮是必须补上的。核心是维护一个会话状态,记录最近几轮的实体和意图:
class ConversationState: def __init__(self): self.history = [] self.entities = {} def update(self, query, answer, new_entities): self.history.append({"q": query, "a": answer}) self.entities.update(new_entities) if len(self.history) > 5: self.history.pop(0) def rewrite_query(self, query): for alias, real in self.entities.items(): if alias in query: query = query.replace(alias, real) return query实体抽取可以用规则,也可以用一个小模型。我一般先用规则跑,覆盖常见指代场景,后面再逐步加模型。别一上来就上重型方案,迭代速度会拖慢。
5. 常见问题与排查技巧实录
5.1 检索到了正确内容,但模型还是答错
这是最常见的问题。排查思路分三步:先看检索结果里到底有没有正确答案,如果没有,问题在检索层;如果有但模型没用,问题在 Prompt 或上下文组装;如果模型用了但理解错了,问题在模型能力或上下文表述。
我遇到过一个典型案例:检索结果里明明有"标准版 199 元",模型却回答"价格未提及"。后来发现是上下文太长,这条信息被淹没在中间位置。解决办法是把最相关的 chunk 排到最前面,并且在 Prompt 里显式提示"请重点关注 [1] 号资料"。这个技巧实测有效。
5.2 答案时效性不对,用了旧数据
企业文档经常有多个版本,检索时可能召回旧版本。解决办法是在元数据里加更新时间,检索后按时间做二次排序,优先用新数据。如果新旧版本内容冲突,Prompt 里要明确让模型指出冲突。
还有一种情况是文档更新了但索引没更新。这就要做增量索引,文档变更时触发重新切分和入库。别用全量重建,数据量大了扛不住。
5.3 多轮对话中上下文丢失
用户问"第二个产品的价格",系统不知道"第二个"指什么。排查时先看对话状态有没有正确记录上一轮的实体。常见错误是实体抽取规则太窄,比如只抽了产品名,没抽产品列表的顺序信息。解决办法是在回答列表类问题时,把列表项和序号一起存进状态。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 答非所问 | 检索结果不相关 | 检查 Top5 召回内容 | 调整切分粒度、优化查询改写 |
| 答案编造 | Prompt 约束不足 | 检查是否有"不得使用自身知识"约束 | 加强约束、加引用标注 |
| 答案过时 | 索引未更新 | 检查文档更新时间 | 增量索引、时间加权排序 |
| 多轮指代错误 | 状态管理缺失 | 检查对话状态记录 | 补实体抽取、指代消解 |
| 响应太慢 | 检索链路太长 | 分段计时 | 缓存、并行检索、减少重排量 |
| 引用编号错乱 | 上下文组装顺序问题 | 检查 Prompt 拼接逻辑 | 固定编号与 chunk 的映射 |
5.5 几个我踩过的坑
坑一:嵌入模型选太大。一开始用了个大参数量的嵌入模型,效果确实好一点,但检索延迟高得离谱。后来换成轻量多语言模型,效果差距不大,速度提升好几倍。嵌入模型不是越大越好,要看你的延迟预算。
坑二:忽略权限控制。企业数据有权限分级,检索时必须带上用户权限过滤,否则 A 部门的人能查到 B 部门的机密文档。这个要在元数据里加权限标签,检索时做过滤,不能等生成后再过滤,那样已经晚了。
坑三:测试集太随意。早期我用几个自己编的问题测,效果很好,上线后用户问法千奇百怪,效果一落千丈。后来老老实实收集真实用户问题,建了一个 100 条以上的测试集,每次改动都跑一遍,才稳下来。测试集的质量决定你优化的方向对不对。
坑四:Prompt 里塞太多资料。一开始觉得资料越多越好,塞了 10 条 chunk 进去,结果模型反而抓不住重点。后来压到 3 到 5 条,答案质量反而提升。上下文不是越多越好,精准比数量重要。
6. 效果评估与持续迭代
6.1 怎么衡量这套系统好不好
评估要分两个层面:检索层和生成层。
检索层看召回率和准确率。召回率是"正确答案在不在 TopK 里",准确率是"TopK 里有多少是相关的"。这两个指标要一起看,召回率高但准确率低,说明噪音多;准确率高但召回率低,说明漏掉了正确答案。
生成层看答案的正确性、完整性、引用准确性。正确性靠人工抽检,完整性看是否覆盖了问题的所有方面,引用准确性看标注的来源是否真的支持答案。我一般每周抽 50 条真实问答做人工评估,记录问题类型和错误原因,形成迭代清单。
6.2 迭代的优先级
迭代不要眉毛胡子一把抓,按影响面排序:
第一优先是检索召回失败的问题,因为这是根子上的问题,检索不到,后面全白搭。第二优先是Prompt 约束失效导致的幻觉,这个改 Prompt 就能解决,成本低见效快。第三优先是多轮对话体验,这个影响面相对小,但做好了用户感知明显。第四优先是响应速度,除非慢到不可用,否则可以往后放。
6.3 一个实用的迭代方法
我习惯用错误归因表来驱动迭代。每次发现一个 bad case,就记录:用户问题、期望答案、实际答案、错误环节(检索/改写/生成/状态)、根因、修复方案。积累到一定数量后,按错误环节统计分布,哪个环节问题最多就先修哪个。这个方法比拍脑袋优化靠谱得多。
7. 一些实操心得
联网问答 Agent 这个方向,技术栈其实不复杂,难的是细节打磨。同样的架构,切分粒度差一点、权重调一点、Prompt 改一句,效果可能差出一大截。所以别指望一次搭好就完事,要留出持续迭代的预算。
另外,别追求 100% 准确。企业问答场景下,能做到 80% 的问题准确回答、20% 的问题诚实说"不知道",就已经很有价值了。追求最后那 20% 的成本是指数级上升的,不如把精力放在扩大覆盖面上。
最后说一个我最近在试的思路:把用户反馈闭环接进来。答案旁边放个"有用/没用"按钮,用户点了"没用",就把这条 case 自动进迭代队列。这样系统会越用越准,而不是靠你一个人拍脑袋想优化点。这个机制跑起来之后,迭代效率提升很明显。