基于LangGraph与Milvus的RAG客服问答系统实战:原理、实现与优化
发布时间:2026/10/31 15:45:00 锦皓数字建站

客服机器人最让人头疼的地方不是它答不上来而是它一本正经地胡说八道。你问它退货政策它给你编一个“30天无理由退换需提供DNA样本”你问它产品参数它把A型号的电池容量安到B型号头上。这种“幻觉”在通用大模型上几乎是必然的——它本质是个概率续写机器不知道“不知道”只会顺着你的话往下编。RAG检索增强生成就是给这个话痨套上缰绳先查资料再开口说话。我最近用LangGraph加Milvus搭了一套客服问答系统实测下来只要检索环节不出岔子回答的准确率能从“随机抽奖”拉到“基本可信”。这篇文章就把整套原理和工程实现拆开讲包括Embedding选型、Milvus部署、LangGraph编排以及我踩过的那些坑。1. 为什么客服场景必须上RAG1.1 通用大模型在客服场景的三个致命伤先说清楚问题才知道RAG到底在解决什么。我拿同一个问题“你们支持货到付款吗”分别问过纯大模型和RAG系统差距非常明显。第一个致命伤是知识截止。通用大模型的训练数据有截止日期而客服知识是天天在变的——今天新增了某个地区的配送限制明天调整了退换货规则。模型不可能知道这些。你问它最新的运费标准它只能根据训练时见过的“一般电商运费规则”来编编得还挺像那么回事。第二个致命伤是私有知识缺失。你公司的产品手册、内部FAQ、工单记录这些东西从来没出现在公开训练数据里。模型对你家业务一无所知但你问它它不会说“我不知道”它会用通用常识硬答。比如你问“XX型号支持几路继电器输出”它可能回你“通常支持2到4路”听起来很专业但完全是猜的。第三个致命伤是幻觉的自信表达。这是最要命的。模型胡说八道的时候语气和说真话时一模一样用户根本分辨不出来。我见过它把“7天无理由退货”说成“15天”还附带一段看起来非常合理的解释。客服场景里这种错误直接导致客诉和纠纷。RAG的思路很朴素既然模型不知道那就别让它凭记忆答先去知识库里查把查到的原文塞给它让它基于原文回答。这就像开卷考试答案就在书上你照着抄总比瞎蒙强。1.2 RAG到底是怎么让模型“闭嘴查资料”的RAG的全称是Retrieval-Augmented Generation检索增强生成。拆开看就三步检索、增强、生成。检索是把用户的问题转成向量去向量数据库里找最相似的文档片段。增强是把找到的文档片段和用户问题拼成一个新的提示词。生成是把这个提示词交给大模型让它基于文档内容回答。关键在于“增强”这一步的提示词设计。你不能只是把文档丢给模型就完事得明确告诉它只允许根据以下资料回答资料里没有的就说不确定不要自己发挥。我用的提示词模板大概是这样你是一个客服助手。请严格根据以下参考资料回答用户问题。 如果参考资料中没有相关信息直接回答“抱歉我暂时没有找到相关信息建议您联系人工客服”。 不要编造任何参考资料之外的内容。 参考资料 {context} 用户问题{question}这个模板看起来简单但“不要编造”这句话必须写而且要写在显眼位置。我试过省略这句话模型该编还是编。1.3 什么场景适合RAG什么场景不适合RAG不是万能药。它适合知识密集型、答案有明确出处、知识更新频繁的场景。客服问答、产品文档查询、内部制度咨询这些都很合适。不适合的场景也很明确。需要复杂推理的数学题、需要实时计算的场景、创意写作这些RAG帮不上忙。你问“帮我写一首诗”检索一堆文档塞给模型反而干扰它发挥。还有一个容易被忽略的点知识库的质量决定RAG的上限。如果知识库本身就有错误、过时、自相矛盾的内容RAG只会把这些错误更“自信”地传递给用户。所以上线前一定要清洗知识库这一步偷懒后面全是坑。2. 核心组件选型与原理拆解2.1 Embedding模型把文字变成坐标Embedding是整个RAG的地基。它的作用是把一段文字映射成一个高维向量语义相近的文字在向量空间里距离也相近。比如“怎么退货”和“退换货流程”这两个句子字面不一样但Embedding之后向量很接近检索时就能匹配上。选Embedding模型主要看三个指标检索准确率、推理速度、向量维度。维度越高表达能力越强但存储和计算成本也越高。我实测过几个主流模型对比数据如下模型维度中文检索表现推理速度适用场景text-embedding-3-small1536良好快通用场景成本敏感text-embedding-3-large3072优秀中等精度要求高的场景BGE-M31024优秀中等中文为主支持多语言m3e-base768良好快轻量级本地部署我最后选了BGE-M3原因是它对中文的支持确实好而且支持多语言以后要加英文知识库也不用换模型。维度1024在精度和成本之间比较平衡。这里有个坑要提醒Embedding模型换了整个知识库必须重新向量化。不同模型生成的向量空间完全不兼容混用会导致检索结果乱七八糟。我一开始图省事用A模型建库后来换B模型查询检索出来的东西驴唇不对马嘴排查了半天才发现是模型不匹配。2.2 Milvus向量数据库的安装与配置Milvus是专门存向量、做相似度检索的数据库。传统数据库用B树索引适合精确匹配Milvus用HNSW、IVF这类近似最近邻算法适合在高维空间里找“最像”的向量。安装方式我推荐Docker干净、可复现。在Mac上装Milvus Standalone的步骤如下# 下载Milvus的docker-compose配置文件 wget https://github.com/milvus-io/milvus/releases/download/v2.4.0/milvus-standalone-docker-compose.yml -O docker-compose.yml # 启动 docker-compose up -d # 检查状态 docker-compose ps启动后Milvus默认监听19530端口。连接测试from pymilvus import connections, utility connections.connect(hostlocalhost, port19530) print(utility.get_server_version())如果输出类似v2.4.0的版本号说明连接成功。Milvus的核心概念是Collection相当于关系数据库里的表。创建Collection时要定义字段和索引from pymilvus import CollectionSchema, FieldSchema, DataType, Collection fields [ FieldSchema(nameid, dtypeDataType.INT64, is_primaryTrue, auto_idTrue), FieldSchema(nametext, dtypeDataType.VARCHAR, max_length2000), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim1024), ] schema CollectionSchema(fields, description客服知识库) collection Collection(customer_service_kb, schema) # 创建索引 index_params { metric_type: COSINE, index_type: HNSW, params: {M: 16, efConstruction: 200} } collection.create_index(field_nameembedding, index_paramsindex_params)这里解释几个关键参数。metric_type选COSINE因为余弦相似度衡量的是向量方向的一致性对文本语义匹配更合适。欧氏距离也可以但对文本长度变化更敏感。HNSW的M参数控制每个节点的连接数M越大检索越准但内存占用越高16是个常用值。efConstruction影响建索引时的搜索范围200在精度和速度之间比较平衡。注意Milvus的Collection创建后字段类型和维度不能改。如果Embedding模型换了维度必须删掉重建。我建议在Collection命名里带上模型标识比如kb_bge_m3_1024避免以后搞混。2.3 LangGraph把RAG流程编排成图LangGraph是LangChain团队出的编排框架核心思想是把整个流程建模成状态图。节点是处理步骤边是流转条件。相比传统的链式调用LangGraph的优势在于支持循环和条件分支这对RAG很重要。为什么需要循环因为检索可能失败。第一次检索没找到相关文档可以改写查询再试一次。这种“重试”逻辑用链式调用很难写用图就很自然。我的RAG图结构是这样的from langgraph.graph import StateGraph, END from typing import TypedDict, List class RAGState(TypedDict): question: str rewritten_question: str documents: List[str] answer: str retry_count: int def rewrite_query(state: RAGState): # 用LLM改写查询提升检索命中率 ... def retrieve(state: RAGState): # 向量检索 ... def generate(state: RAGState): # 生成回答 ... def should_retry(state: RAGState): if not state[documents] and state[retry_count] 2: return rewrite return generate graph StateGraph(RAGState) graph.add_node(rewrite, rewrite_query) graph.add_node(retrieve, retrieve) graph.add_node(generate, generate) graph.set_entry_point(rewrite) graph.add_edge(rewrite, retrieve) graph.add_conditional_edges(retrieve, should_retry, { rewrite: rewrite, generate: generate }) graph.add_edge(generate, END) app graph.compile()这个图里should_retry是条件边根据检索结果决定是重试还是生成。retry_count防止无限循环。实测下来查询改写能把检索命中率提升15%到20%尤其是用户问题比较口语化的时候。3. 从零搭建RAG客服机器人的完整流程3.1 知识库准备文档清洗与分块策略知识库的质量直接决定最终效果。我拿到的原始资料是一堆Word文档和PDF里面夹杂着页眉页脚、目录、无关的广告语。直接扔进去检索出来的全是噪音。清洗步骤我做了这几件事去掉页眉页脚和页码删除目录页把表格转成结构化文本统一标点符号。这些用Python脚本批量处理大概花了半天时间。分块Chunking是另一个关键决策。块太大检索出来的内容包含太多无关信息干扰模型块太小上下文不完整模型理解不了。我试过几种策略分块策略块大小重叠效果固定长度500字50字一般容易切断句子按段落不定无较好但长段落仍会超限递归分割500字100字最佳优先按段落再按句子最后用的是递归分割优先按段落切段落太长再按句子切还长就硬切。重叠100字是为了防止关键信息刚好落在切割边界上。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap100, separators[\n\n, \n, 。, , , , ] ) chunks splitter.split_text(document_text)实操心得分块大小没有标准答案得根据你的文档特点调。FAQ类文档适合小块200-300字产品手册适合大块500-800字。我建议先用100条真实问题测试看检索出来的块能不能覆盖答案再反过来调块大小。3.2 向量化入库批量处理与增量更新文档分好块之后逐块调Embedding模型生成向量然后写入Milvus。这里要注意批量处理一条一条调太慢。BGE-M3支持批量推理一次传32条效率最高。from pymilvus import Collection from sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-m3) collection Collection(customer_service_kb) def batch_insert(texts, batch_size32): for i in range(0, len(texts), batch_size): batch texts[i:ibatch_size] embeddings model.encode(batch, normalize_embeddingsTrue) collection.insert([batch, embeddings.tolist()]) collection.flush()normalize_embeddingsTrue很重要它把向量归一化到单位长度这样余弦相似度计算就等价于点积检索更快。增量更新是实际运营中必须考虑的。知识库不是建一次就完事每天都有新文档进来。我的做法是给每个块算一个内容哈希入库前先查哈希是否存在存在就跳过不存在才插入。删除旧文档时根据文档ID批量删除对应的块。3.3 检索环节相似度阈值与Top-K选择检索时有两个关键参数Top-K和相似度阈值。Top-K是返回最相似的K个块阈值是低于这个相似度的块直接丢弃。Top-K设太小可能漏掉相关文档设太大无关文档混进来干扰模型。我实测下来K5是个不错的起点。阈值我设的0.6低于0.6的基本都是不相关的。def retrieve(question, top_k5, threshold0.6): query_vec model.encode([question], normalize_embeddingsTrue) results collection.search( dataquery_vec.tolist(), anns_fieldembedding, param{metric_type: COSINE, params: {ef: 64}}, limittop_k, output_fields[text] ) docs [] for hits in results: for hit in hits: if hit.score threshold: docs.append(hit.entity.get(text)) return docsef是HNSW的检索参数越大越准但越慢。64在大多数场景够用了。这里有个细节用户问题往往很短直接检索效果不好。比如用户问“退货”这两个字检索出来的东西很泛。我的做法是在检索前先用LLM把问题改写成更完整的查询比如“退货”改写成“退货流程和退货条件是什么”。这一步在LangGraph里就是rewrite_query节点做的事。3.4 生成环节提示词模板与防幻觉约束生成环节的核心是提示词。我前面给了一个基础模板实际用的版本更细你是一个专业的客服助手名字叫小助手。 回答规则 1. 只根据【参考资料】中的内容回答不要使用你自己的知识。 2. 如果参考资料中没有相关信息回答“抱歉这个问题我暂时无法回答建议您联系人工客服”。 3. 回答要简洁直接给出答案不要重复问题。 4. 如果参考资料中有多个相关条目综合它们给出完整答案。 5. 不要编造任何数字、日期、政策条款。 【参考资料】 {context} 【用户问题】 {question} 【回答】规则5特别重要。我遇到过模型把参考资料里的“7天”改成“10天”的情况加了这条之后就没再出现。生成时还要控制temperature参数。客服场景要的是稳定和准确不是创意。我把temperature设成0.1基本就是确定性输出。设成0.7的话同一个问题问两次答案可能不一样这在客服场景是灾难。4. 实测中踩过的坑与排查技巧4.1 检索不准的四种典型情况和排查路径检索不准是RAG最常见的问题。我整理了一个排查表现象可能原因排查方法解决方案检索结果完全不相关Embedding模型不匹配检查建库和查询是否用同一模型统一模型重建索引相关文档排在后几位Top-K太小或阈值太高打印所有检索结果的score增大K降低阈值检索结果重复分块重叠过大检查chunk_overlap设置减小重叠去重短问题检索效果差查询太短语义信息不足对比改写前后的检索结果加查询改写节点我遇到最坑的一次是检索结果时好时坏。排查了半天发现是Milvus的索引没建好数据插入后没有flush部分数据还在内存里没落盘。加了collection.flush()之后稳定了。4.2 模型仍然胡编乱造怎么办即使有RAG模型偶尔还是会编。我总结了几个原因和对策。原因一参考资料里确实没有答案但模型不甘心说“不知道”。对策是在提示词里把“不知道”的回复模板写死让它照抄。我甚至试过在few-shot里加一个“资料中没有答案”的示例效果更好。原因二参考资料里有相似但不完全匹配的内容模型自行“推理”了。比如资料说“A产品保修1年”用户问B产品模型可能套用A的答案。对策是在提示词里强调“不要跨产品套用信息”。原因三检索到的块不完整关键信息被切掉了。这个要靠调分块策略解决。我的经验是如果某个问题的答案总是缺一半就去检查对应的块是不是被切断了适当增大块大小或重叠。4.3 性能优化从3秒到300毫秒的响应提速第一版系统响应要3秒多用户体验很差。我做了几项优化把响应压到了300毫秒左右。优化一Embedding模型本地化。一开始调的是远程API网络延迟就占了大头。换成本地部署的BGE-M3之后向量化时间从800毫秒降到50毫秒。优化二Milvus索引调优。把ef从默认的128降到64检索速度提升明显精度损失很小。另外把Collection加载到内存collection.load()避免每次检索都读磁盘。优化三生成环节流式输出。用户不需要等完整答案生成完才看到内容流式输出让首字响应时间降到200毫秒以内。虽然总时间没变但体感快了很多。优化四缓存高频问题。客服场景里Top 50的问题占了80%的咨询量。我把这些问题的答案缓存起来命中缓存直接返回连检索都省了。4.4 知识库更新的工程化处理知识库更新不是手动跑脚本就完事得工程化。我的方案是做一个简单的管理接口运营人员上传新文档后系统自动完成清洗、分块、向量化、入库的全流程。关键是要处理文档版本。同一份文档更新后旧版本的块要删掉否则检索时新旧内容混在一起模型可能给出过时答案。我的做法是给每个块打上doc_id和version标签更新时先按doc_id删除旧块再插入新块。def update_document(doc_id, new_text): # 删除旧版本 collection.delete(fdoc_id {doc_id}) # 插入新版本 chunks splitter.split_text(new_text) embeddings model.encode(chunks, normalize_embeddingsTrue) collection.insert([chunks, embeddings.tolist(), [doc_id]*len(chunks)]) collection.flush()删除操作在Milvus里是标记删除实际空间不会立即释放。定期做compact操作可以回收空间。5. 几个值得关注的进阶方向5.1 混合检索向量加关键词的双保险纯向量检索有个短板对专有名词、型号编码不敏感。比如用户问“XX-2000的电池”向量检索可能把“XX-3000”的文档也召回来因为语义上都是“电池相关”。这时候加上关键词检索BM25做混合效果会好很多。混合检索的思路是两路并行向量检索一路BM25一路然后做结果融合。融合算法我用的RRFReciprocal Rank Fusion简单有效不需要调权重。5.2 重排序让最相关的文档排到最前面检索返回Top-K之后可以用一个重排序模型Reranker对这K个结果重新打分排序。Reranker比Embedding模型更重但精度更高适合在候选集不大比如20个的时候用。我用的BGE-Reranker-base对Top-20的结果重排把最相关的3个送给生成模型。实测准确率比不重排提升了约10%。5.3 多轮对话中的指代消解客服场景经常有多轮对话。用户先问“你们支持退货吗”得到肯定回答后接着问“那运费谁出”。这个“那”指代的是退货场景但单独把“那运费谁出”拿去检索什么都查不到。解决方案是在检索前做指代消解把“那运费谁出”改写成“退货的运费谁出”。这需要把对话历史传给LLM让它结合上下文改写当前问题。LangGraph的状态里要维护一个history字段每次改写时带上最近几轮对话。这套系统我跑了三个月日均处理两千多条咨询人工客服的介入率从60%降到了25%左右。剩下的25%主要是情绪化投诉和复杂纠纷这些确实不是RAG能解决的。我的体会是RAG的价值不在于完全替代人工而在于把人工从重复的、有标准答案的问题里解放出来让他们专注处理真正需要人的场景。知识库的维护是个持续活儿我每周都会看一遍检索失败的case把缺失的知识补进去把分块不合理的文档重新处理。这个迭代过程没有终点但每轮迭代都能看到准确率往上走一点。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。