基于Milvus的智能客服知识库搭建实战:从关键词到向量检索 📅 发布时间:2026/9/18 22:40:26 👁 浏览次数: 上个月一个做运营的朋友跟我诉苦说他们花了一整周把几百条客服FAQ整理成了一张工整的Excel表结果用户问东西什么时候能到在后台一搜商品配送时效说明那几个关键词毛都匹配不上。这个场景我太熟了——关键词搜索并不理解用户想问什么它只负责找出长得像的文本。也正是这个痛点让我去年在做智能客服问答系统选型时果断放弃了传统的ES分词检索转向了以Milvus为核心的向量检索方案。这篇博客就把整个过程完整记录下来包括为什么选Milvus、知识库怎么建、问答链路怎么串、上线踩了哪些坑给正在做类似项目的朋友一个参考。所谓基于阿里云Milvus知识库服务简单说就是把知识库的存储和检索交给Milvus先用Embedding模型把清洗后的文档切片转成向量存进去用户提问时再用同样的方式把问题转成向量按语义相似度找回最相关的片段最后交给大模型组织成自然的客服回复。这个方案不需要自研底层的向量引擎也不用训练模型业务方没有专职算法团队也能落地。整个系统我从部署到跑通大概用了两周适合那些手里已经有一堆FAQ、想快速上线一个不丢人客服机器人的团队参考。1. 传统客服检索方式的局限为什么FAQ做得再规范用户也问不出来1.1 关键词匹配的天然瓶颈以前很多客服系统的知识库查询底层其实就是一个非常大的LIKE匹配或者ES分词倒排索引。它有个致命问题用户口语化表达和文档书面语之间存在巨大鸿沟。用户不会照着FAQ标题去问他只会按照自己脑子里那套说法提问。比如知识库里写着退换货政策四个字用户的问法是我买的东西不想要了怎么办这两者之间几乎没有共同的关键词。即便你用ES做了分词和同义词扩展也只会把退换货和退货这种近义词关联起来很难理解不想要了这种意图层面的相似。我见过太多客服系统知识库内容本身是齐全的但用户搜不到最后用户只能去转人工知识库沦为一个摆设。1.2 向量检索到底解决了什么向量检索的思路完全换了一个维度。它先把文本扔进一个Embedding模型让模型把这句话编码成一个几百维甚至上千维的向量。这个向量不是随便生成的模型在训练过程中学到了词语的上下文语义含义相近的句子在向量空间里距离就近。所以东西什么时候能到和商品配送时效说明即便没有一个字相同它们的向量距离依然非常接近。这个差异在实际客服场景里是降维打击。去年我们测试过一批真实用户问题大概400条其中有超过30%的问题如果只用关键词搜索是完全搜不到正确答案的但用向量召回这套思路能把这部分里的大多数捞回来。这也是我为什么说做智能客服先别提上不上大模型先把用户的问题到底能不能从知识库里搜出来这件事做好向量检索就是那一块最重要的地基。1.3 为什么具体选了Milvus而不是其他向量数据库市面上向量数据库不少有开源的Milvus、Qdrant、Chroma也有各云厂商的托管服务。我当时评估了一圈选Milvus有几点比较实际的原因。Milvus的生态和中文资料更成熟遇到问题能搜到答案的概率高很多。做项目最怕的不是技术难而是掉进一个没人掉过的坑里出不来。它支持标量过滤和向量检索混合查询比如可以按来源操作手册过滤后再做相似度搜索这个在客服场景很实用后面讲多路召回时会详细说。部署形态灵活阿里云上有托管的Milvus知识库服务可以用也可以自己在ECS上起Docker Compose跑社区版。我们当时为了赶进度两种方式都验证过。支持HNSW、IVF_FLAT多种索引单机版在百万级向量下也能扛住客服场景的知识库量级基本在几十万条以内完全够用不需要一上来就上分布式集群。2. 系统整体流转从Excel表到一条能回答问题的完整链路2.1 离线知识库构建流程整个系统分两条链路一条是离线灌库一条是在线问答。离线链路负责把原始文档变成Milvus里的向量数据收集各种来源的知识文档包括FAQ Excel表、操作手册Word文档、售后政策PDF等。清洗数据去掉空行、无意义的换行、表格的横线符号把问答对整理成结构化字段。选择切片策略FAQ按问答对切分操作手册按二级标题切分形成一条条独立的文本块。调用Embedding模型把每个切片转成向量。连同原文、来源等标量字段一起写入Milvus的Collection建索引加载到内存。2.2 在线问答链路在线问答链路处理用户的每一条提问用户输入问题服务端把问题交给同一个Embedding模型转成向量。拿这个向量去Milvus做近似最近邻检索取回Top-K个候选片段。候选片段经过重排模型精排过滤掉低相关结果。如果最高分低于阈值说明知识库里没有相关内容直接触发转人工话术。如果命中了把命中的候选片段连同用户问题一起组装成Prompt发给大模型。大模型基于检索结果生成回复通过SSE流式返回给前端对话界面。2.3 技术栈版本选择我们的技术栈选型算不上激进但每一样都是验证过能稳定跑的组件选型说明向量数据库Milvus 2.4.x 社区版使用Standalone模式部署Python SDKpymilvus官方客户端API设计清晰Embedding模型bge-large-zh1024维中文场景效果好重排模型bge-reranker-base交叉编码精排过滤噪声服务框架FastAPI异步接口SSE流式输出配合好大模型通过API接入保留切换空间不绑定某一家这套组合让我比较满意的点是召回、重排、生成三个阶段各管各的坏了哪个换哪个不用推翻整个系统。比如后来我们觉得bge-large-zh响应不够快就换了一个更轻量的Embedding模型只需要重新灌库问答链路其他部分完全不用动。3. Milvus部署实录本机Docker和阿里云ECS两条路径的差异3.1 一条命令起一个单机版MilvusMilvus部署是我这次项目里最顺利的环节比想象中简单。官方提供了Standalone模式的Docker Compose编排文件里面一次性定义了Milvus、etcd、MinIO三个组件。etcd负责存元数据MinIO负责存向量数据文件Milvus主服务负责查询和写入。你可以把它理解成一个数据库的进程分工Milvus是计算层etcd是元数据存储层MinIO是数据文件层。部署步骤mkdir -p /opt/milvus cd /opt/milvus # 下载standalone部署编排文件 wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose.yml # 启动全部组件 docker compose up -d # 查看运行状态 docker compose ps等所有容器都变成Up状态后Milvus默认监听19530端口。你可以用pymilvus连上来验证一下from pymilvus import connections connections.connect(aliasdefault, hostlocalhost, port19530) print(Milvus连接成功)3.2 本机跑和阿里云ECS跑的差别本机Docker部署基本不会出问题但一旦挪到阿里云ECS上事情就多了起来。我第一次在ECS上执行同样的命令容器是起来了但外网访问死活不通。最开始还以为程序写错了排查了一圈发现是阿里云安全组没有放行19530端口。这里必须再次强调云上端口访问的三层概念安全组规则、实例防火墙、容器端口映射。缺一个都连不上。阿里云ECS上需要打开的端口按需放行端口用途是否建议公网暴露19530Milvus gRPC服务不建议仅内网访问9091Milvus监控指标不建议2379etcd客户端不暴露9000/9001MinIO API与管理界面不暴露我踩过的坑是为了省事把19530暴露到公网结果被扫描盯上半夜CPU飙到100%一查是有人在暴力破解。后来老老实实把端口收敛到内网前置一台带鉴权的网关或者直接用阿里云托管服务。3.3 部署过程中的常见问题定位清单这部分记录一下我们部署时遇到的实际问题以及排查思路给后来人省点时间。etcd容器不停重启多半是挂载目录权限问题。Docker挂载的目录默认属主是rootetcd进程没有权限写数据。解决办法是给数据目录授权或者干脆清空旧数据卷重新初始化。安全生产经验就是数据目录手动预先建好并给足权限不要在容器运行时报错了再去找原因。Milvus容器日志报fail to connect etcd注意启动顺序。三个容器同时拉起时etcd可能还没就绪Milvus会一直重试。这一步一般等几十秒就好了或者可以在编排文件里加一个healthcheck和depends_on的condition。阿里云ECS磁盘被日志占满Docker容器默认日志驱动如果不做轮转几个月就能把磁盘写满。Milvus本身会写大量查询日志加上Gunicorn和Nginx的访问日志磁盘爆掉只是个时间问题。建议在docker-compose.yml里加上日志限制logging: driver: json-file options: max-size: 100m max-file: 34. 知识库灌库全流程清洗、切片、Embedding与入库的那些细节4.1 源文档的清洗比想象中更重要灌库之前数据清洗直接决定检索效果的上限。我们拿到的FAQ原始表格长这样有合并单元格有空行有把多个问题堆在同一个单元格里的还有答案里套着另一个问题的情况。如果一股脑全倒进向量库里检索质量会非常差。我的做法是先把Excel导成CSV再用pandas清洗核心规则是合并单元格拆开后自动填充上级内容保证每行都有完整的问和答。去掉完全重复的行保留来源最早的版本。答案里如果包含明显的换行说明是由多个段落拼的需要归一化成连续文本。不同类别的FAQ打上不同的标签比如物流类售后类产品咨询类方便后续做标量过滤。这个环节我跟运营一起核了三天虽然枯燥但很值得。数据干净了后面所有步骤都不需要返工。4.2 切片策略怎么定客服FAQ和操作手册完全不同切片是RAG里一个经常被低估的环节。我在做这个项目之前也默认固定512字符切割、重叠64字符就能用但实际跑完后发现效果一般原因在于FAQ数据按固定长度切会把完整的问答对拦腰切断。用户问到一半的内容检索出来的片段根本解释不了上下文。针对不同类型的文档我用了两套不同的切片策略FAQ类文档以每条问答对作为最小切片不额外切分。每条FAQ的question字段会连着answer一起进入向量库召回时直接返回完整答案。这样设计的好处是用户问题匹配到的就是一个有头有尾的答案块不需要大模型再去拼接理解。操作手册类文档按Markdown或Word的标题结构切分默认取二级标题以下的内容作为一个片段保留标题作为片段的上下文前缀。如果某个标题下内容过长再按段落进一步拆细。切出来的片段末尾要保留一个指向原文的source字段方便溯源。切片大小没有一个绝对正确的答案核心判断标准是这个片段单独拿出来能不能让一个不看原文的人看懂它在讲什么。如果能这个切片就是合格的。4.3 Embedding模型选择为什么是bge-large-zh中文场景做EmbeddingBGE系列是目前开源模型里综合表现最稳的。我们选bge-large-zh的原因是它在C-MTEB中文基准上排名靠前而且模型体积不算大单张T4卡甚至纯CPU推理也能扛住低并发场景。相比之下一些更小的模型推理速度更快但在语义区分度上会明显拉开差距尤其是那些表述相似但意图不同的FAQ小模型很容易把它们编码到很近的位置导致召回误伤。需要提醒一点上传到Milvus的向量维度和模型输出的维度必须完全一致。bge-large-zh输出1024维向量如果你中途换了模型用了不同的维度要么重新建Collection要么在插入前做维度对齐没有捷径。4.4 入库代码实现下面这段是入库过程的核心骨架包含建Collection、建索引、批量插入。from pymilvus import ( connections, CollectionSchema, FieldSchema, DataType, Collection ) connections.connect(aliasdefault, hostlocalhost, port19530) fields [ FieldSchema(nameid, dtypeDataType.INT64, is_primaryTrue, auto_idTrue), FieldSchema(namequestion, dtypeDataType.VARCHAR, max_length512), FieldSchema(nameanswer, dtypeDataType.VARCHAR, max_length4096), FieldSchema(namecategory, dtypeDataType.VARCHAR, max_length64), FieldSchema(namesource, dtypeDataType.VARCHAR, max_length256), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim1024) ] schema CollectionSchema(fieldsfields, descriptionfaq_knowledge_base) collection Collection(namefaq_kb, schemaschema) index_params { index_type: HNSW, metric_type: COSINE, params: {M: 16, efConstruction: 200} } collection.create_index(field_nameembedding, index_paramsindex_params) # 假设已经通过模型把每一条FAQ转成了embeddings数组 questions [商品多久发货, 如何申请退款, 配送范围是什么] answers [付款后48小时内发货, 在订单页面提交申请, 目前支持全国配送] categories [物流, 售后, 物流] sources [faq_2024.xlsx, faq_2024.xlsx, faq_2024.xlsx] embeddings [...] # 每个元素是1024维float列表 collection.insert([questions, answers, categories, sources, embeddings]) collection.flush() collection.load()有两点需要单独说明。第一Collection的load操作是把索引加载到内存加载之后检索性能才有保证。灌库阶段最后一定要执行load否则search会报collection not loaded错误。第二HNSW索引的M和efConstruction参数不需要调得过大M16、efConstruction200对百万级以下的数据量是性价比最高的选择再往上提升不明显内存占用却会涨得很快。5. 问答链路串联召回、重排、Prompt与大模型输出的完整实现5.1 召回参数怎么调Top-K和阈值都不是拍脑袋定的用户问题向量化之后去Milvus检索这里需要决定两个参数取回多少条候选相似度阈值定多少。我们线上取Top-K5。这个值不是随便拍的测过Top-K3时召回不足有些正确答案排在第四五位直接被截掉了Top-K10虽然召回更全但会把大量噪声一起捞上来重排阶段的压力变大响应时间也变长。相似度阈值需要结合Embedding模型的分布特性来看BGE模型用余弦相似度计算出来的分数整体偏高。我们统计过一批命中样本和不命中样本的得分分布发现0.45到0.55之间是一个合理区间。线上定在0.5低于0.5直接认定知识库里没有相关内容。这个阈值上线前一定要用一批真实用户问题去验证最好再留一点余量避免误伤。5.2 为什么加了重排这一道工序向量检索本质是近似最近邻它的优点是快但代价是精度有上限。为了不让大模型拿噪声当依据去一本正经地胡说八道我在Milvus召回之后加了一个bge-reranker-base重排模型。重排模型的工作方式是对用户问题候选文档这对组合做交叉编码比向量检索时那种两边分别编码再算相似度的双塔方式要精细得多。跑起来的效果也确实如此很多排在第一位的答案在重排后会掉到后面去因为它和用户问题的字面重叠多但语义不相关重排模型能抓住这层区别。5.3 Prompt组装与输出控制Prompt设计是整个问答效果的最后一公里。我见过很多RAG项目召回没问题最后栽在Prompt上大模型把检索到的内容重新加工时添油加醋反而误导用户。我们的System Prompt控制了两个点一是身份二是纪律。原文大概是这样的你是一名在线客服助手只能基于下方提供的知识库内容回答用户问题。 如果知识库内容不足以回答问题请直接回复未找到相关知识建议转人工不要自行编造答案。 回答时不要臆测不要补充知识库中没有的信息。实际效果立竿见影编造率降到了很低的水平。有些人可能觉得这样会让回答太生硬但客服场景最重要的不是花哨而是准确。5.4 完整的问答接口实现下面是一个简化版的问答服务用FastAPI实现from fastapi import FastAPI from fastapi.responses import StreamingResponse from pymilvus import connections, Collection from sentence_transformers import SentenceTransformer app FastAPI() connections.connect(aliasdefault, hostlocalhost, port19530) collection Collection(faq_kb) embed_model SentenceTransformer(BAAI/bge-large-zh) def search_docs(query: str, top_k: int 5): query_vec embed_model.encode(query).tolist() results collection.search( data[query_vec], anns_fieldembedding, param{metric_type: COSINE, params: {ef: 64}}, limittop_k, output_fields[question, answer, source, category] ) hits [] for hit in results[0]: if hit.score 0.5: hits.append({ question: hit.entity.get(question), answer: hit.entity.get(answer), source: hit.entity.get(source), score: hit.score }) return hits def build_prompt(query: str, hits: list) - str: context \n\n.join( f问题{h[question]}\n答案{h[answer]} for h in hits ) return f你是一名在线客服助手只能基于下方提供的知识库内容回答用户问题。 如果知识库内容不足以回答问题请直接回复未找到相关知识建议转人工。 知识库内容 {context} 用户问题{query} app.post(/chat) async def chat(query: str): hits search_docs(query) if not hits: return {answer: 未找到相关知识建议转人工, hits: []} prompt build_prompt(query, hits) # 这里把prompt交给大模型API按自身偏好选择流式或非流式 ...有个小细节值得注意命中结果为空和命中结果相似度低在用户侧看到的都是未找到知识但排查时需要区分开。所以接口返回里我始终带着hits列表方便出问题时定位到底是召回没召回到还是召回到了但被阈值拦住了。6. 上线压测后暴露的问题资源瓶颈、检索质量与数据更新的应对6.1 资源瓶颈比预想来得更快系统联调完成后我们做了一轮并发压测。测试场景是200个用户同时提问模拟高频客服时段。压测数据跑出来后Milvus本身的查询延迟表现非常稳P99延迟在50ms以内QPS在数百级别。真正被卡住的地方是Embedding模型推理和大模型API的响应速度。Embedding模型我们一开始是直接用CPU推理的单条向量化需要200ms上下一旦并发上来CPU直接打满问答链路整体响应时间飙升到秒级。优化方案是给推理服务挂上GPU实例并把Embedding服务独立部署成一个进程和FastAPI应用分离。这样应用服务器负责调度推理服务只干向量化一件事互不抢占资源。内存方面Milvus的HNSW索引会整体加载到内存加上缓存和写入内存单机版Milvus占用经常在3GB以上etcd和MinIO再加一部分。我们ECS最后选了16GB内存的规格压力测试跑完还能留出余量。6.2 检索质量出现问题时的排查路径上线第一周运营就反馈有些问题搜索出来的答案不对。走到这一步核心排查顺序是先看Milvus召回分数和Top-K里命中了什么。如果排序第一的候选和用户问题字面上很相似但语义不对问题出在切片或Embedding模型上。切片是否把两个不同意图的话题塞进了同一个片段。这种情况在操作手册类文档里很常见解决办法是把大段落再拆细。召回环节没问题但大模型回答错了。这种情况多半是Prompt约束不够。我们在System Prompt里明确要求大模型只能基于知识库内容回答效果立竿见影。确认重排模型是否生效。如果重排模型没接上或者被跳过噪声片段很容易混进大模型的上下文里。6.3 数据更新策略不重建Collection的增量同步客服知识库不是一成不变的每周都会有新FAQ进来也会有旧政策下线。一开始我们图省事每次更新都是全量重建Collection后来发现有问题重建过程中查询不可用而且全量Embedding一次耗时较长。改成增量同步后流程是每天凌晨从业务库抽取当日新增和变更的FAQ转成向量通过Milvus的upsert接口更新。Milvus支持按主键更新向量数据只要在插入时带上主键相同主键的记录就会被覆盖。删除的操作一样by主键删掉就行。需要注意增量更新后的索引不会立刻优化到最佳状态建议在低峰期手动执行一次compact合并段否则查询性能会有缓慢劣化。7. 项目做完后的几点实在体会这套系统从部署到上线前前后后一个多月有几件事给我的印象特别深。第一向量数据库不是银弹。Milvus只是把找得到这件事从不可能变成了可能但到底找得准不准取决于上游的文档清洗和切片策略。我甚至可以说这个项目80%的价值是数据治理贡献的Milvus是那个把数据价值兑现出来的底座。第二先跑通最小闭环再谈优化。网上攻略太多了很容易陷入选型纠结。实际上Milvus用Docker部署半天就能跑通先把一条FAQ灌进去再写一个最简单的检索脚本整个过程不到两天。有了这个闭环后面所有优化才有参照系。第三一定要给大模型划定知识边界。客服场景里大模型最大的风险不是答不上来而是答错了还自信满满。我们最终靠的是两层约束一层是检索阶段的相似度阈值兜底不相关就是不相关另一层是Prompt里明确禁止编造。这比任何调参都重要。第四人工兜底机制要预留。智能客服的目标不是100%替代人工而是把重复性问题滤掉让人工集中处理真正复杂的问题。我们把未命中、相似度偏低、用户主动要求转人工这三种情况统一接入人工客服工单系统同时沉淀这些新问题定期反哺知识库。整个系统跑起来后人工工作量大约下降了四成这个数据比机器人自己的指标更有说服力。对正在评估Milvus做知识库服务选型的朋友我的建议很直接不用纠结太多先用你手头真实的一百条FAQ跑一遍端到端效果好不好当场就能看出来。数据量几万条以内单机版完全够用等规模涨了再迁移到托管版也不迟。