用Ollama+Milvus+DeepSeek,零成本搭建本地RAG智能客服
做过AI客服落地的人都知道最烧钱的部分不是算法而是把企业自己的知识库“喂”给大模型这件事。之前团队内部试过直接拿商用模型API做问答效果确实不错但一问到内部产品手册、售后政策、排障文档模型就开始一本正经地胡说八道。后来把预算拉出来一看光是 embedding 和对话的 API 调用费用一个月就能吃掉小半台服务器的钱。这个项目尝试了一个更务实的路子完全用本地组件把 RAG 智能客服搭起来核心链路是 Ollama 负责本地推理和向量化Milvus 负责存储和检索知识库DeepSeek 负责最终生成回答。整套系统跑通之后除了电费和硬件折旧几乎没有额外的软件成本。文章会把从零到一的完整过程写出来包括组件选型的原因、环境怎么搭、数据怎么切、检索怎么调以及实际踩过的坑给正在做类似选型的团队一个可以直接参考的落地样本。1. 为什么是Ollama Milvus DeepSeek而不是其他组合1.1 RAG客服解决的核心痛点传统客服机器人最大的问题是“记忆力”太差。你问它产品保修期多久它答非所问你问它某个错误码什么意思它直接甩一句“请联系人工”。根本原因在于模型训练时没见过你的私有数据你再怎么调提示词它都不知道你公司的售后政策是什么。RAG的思路很简单不逼模型死记硬背而是在用户提问的时候先从知识库里检索出最相关的几段文档把这段文字拼到提示词里再让模型基于这些材料生成回答。相当于给模型开卷考试它能看到的范围就是你知识库里那部分相关内容答错的概率自然就下来了。这个项目里要做的客服系统面向的是企业内部产品问答场景知识库包含产品手册、FAQ、排障指南、售后政策四类文档总量不大但专业性强对回答的准确性和可溯源要求很高。RAG方案天然适合这种“知识库有限、但要求精准”的场景。1.2 三个组件各管哪一段先看清楚 RAG 完整链路长什么样用户提问 - 把问题转成向量 - 在向量库里找最相似的文档片段 - 把片段和问题拼成提示词 - 大模型生成回答。Ollama 在这个链路里承担两个角色一是运行 embedding 模型把文本转成向量二是可以运行生成模型作为本地备选。Milvus 是向量数据库负责存储几千条切好的文档片段和它们的向量并提供快速的相似度搜索。DeepSeek 则是最终的回答生成器通过 API 调用负责把检索到的材料组织成自然、准确的客服话术。团队也考虑过其他组合比如用 Chroma 当向量库、用纯本地模型生成回答。Chroma 在小规模原型阶段确实轻便但数据量一旦到几万条检索性能和并发能力就明显吃力了而且没有 Milvus 那种成熟的索引管理和分片机制。纯本地生成模型不是不行而是对显卡要求很高想要达到商用客服的质量至少需要一个中等偏上的推理卡这不叫零成本叫隐性成本。最终这套组合是成本和质量之间的一个平衡点。1.3 这块技术选型的避坑参考embedding 模型要选尺寸小的。客服知识库文档量级一般不大几万条以内用小模型完全够用速度快且省内存。这个项目选了 Ollama 官方仓库里的 bge-m3单条文本转向量的延迟在毫秒级效果也够稳。向量库不要一上来就上分布式。对于大多数客服场景单机版 Milvus 足以撑住千万级向量的检索没必要为了“技术先进”去搭集群白白增加运维负担。DeepSeek 走 OpenAI 兼容接口。这样封装层可以做得薄一些后续如果想换模型只需改一个 base_url不用动业务代码。这个设计在后面实际开发中帮了大忙。2. 零成本环境准备开发机配置与组件安装2.1 硬件与系统准备很多人一听“本地跑模型”就条件反射地以为需要顶配显卡。这个项目里最重的活其实是向量检索而向量库是CPU密集操作真正吃显卡的只有两个环节embedding 模型的推理很小和本地生成模型可选不用显卡也行。项目组的开发机是一台 32 核 CPU、64GB 内存、无独立显卡的普通工作站。实测下来embedding 用 CPU 跑 bge-m3 没任何压力Milvus 单机版跑 50 万条以内的向量也很稳。如果你想在纯笔记本上跑16GB 内存的机器也能跑只是 Milvus 和 Ollama 同时驻留时内存会比较紧张建议把向量数据量控制在 10 万条以内。操作系统用的 Ubuntu 22.04 LTS其他主流 Linux 发行版也兼容。Windows 用户建议直接用 WSL2别在原生 Windows 上折腾很多坑都在文件路径和共享库上。2.2 安装Ollama并搞定向量模型Ollama 安装非常直接官方脚本一行命令搞定curl -fsSL https://ollama.com/install.sh | sh装完先验证服务状态systemctl status ollama如果一切正常拉取 embedding 模型。这个项目选的是bge-m3一个对中文支持很好的多语言向量模型尺寸不大效果在开源模型里属于第一梯队ollama pull bge-m3拉完可以快速验证一下能否正常生成向量curl http://localhost:11434/api/embeddings -d { model: bge-m3, prompt: 测试一下向量效果 }返回结果里会有一个 1024 维的向量数组说明 Ollama 服务正常。顺带说一句Ollama 默认监听 11434 端口所有 API 都是 HTTP 接口后续不管是 Python 还是其他语言调用都非常方便完全不需要装 SDK。2.3 部署Milvus并初始化集合Milvus 官方推荐 Docker Compose 方式部署单机版先把项目仓库拉下来git clone https://github.com/milvus-io/milvus.git cd milvus/scripts然后启动容器docker compose up -d装完后确认三个核心组件都起来了分别是 etcd元数据存储、minio对象存储和 standaloneMilvus 主服务docker ps接着用 PyMilvus 初始化集合。集合相当于传统数据库里的表需要预先定义字段一个主键id、一个文本字段text、一个向量字段embedding另外加一个source字段用来存文档来源比如来自哪一份手册、哪个章节方便后续答案溯源。from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType connections.connect(hostlocalhost, port19530) fields [ FieldSchema(nameid, dtypeDataType.INT64, is_primaryTrue, auto_idTrue), FieldSchema(nametext, dtypeDataType.VARCHAR, max_length8192), FieldSchema(namesource, dtypeDataType.VARCHAR, max_length256), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim1024) ] schema CollectionSchema(fieldsfields, description知识库向量集合) collection Collection(nameknowledge_base, schemaschema) collection.create_index( field_nameembedding, index_params{index_type: IVF_FLAT, metric_type: COSINE, params: {nlist: 1024}} )注意dim必须和你使用的 embedding 模型输出维度一致。bge-m3 输出 1024 维如果你用了其他模型先确认维度再建集合。维度填错会直接导致插入数据失败或检索结果异常。建集合时索引选择IVF_FLAT和COSINE度量方式。IVF_FLAT 是 Milvus 最常用的索引类型检索精度高且构建速度快对于百万级以下的数据量完全够用。COSINE适合文本相似度检索它衡量的是两个向量的方向一致性比欧氏距离更贴合语义相似度这个场景。3. 知识库处理从原始文档到可检索向量3.1 数据清洗与切块策略RAG 效果好不好一半以上取决于数据切得好不好。切得太粗检索回来的片段可能包含大量无关信息切得太细语义被割裂模型理解不了完整的上下文。这个项目的知识库是混合格式的 Markdown 文档和 PDF 手册处理流程是先统一转成纯文本再做结构化解构。具体的切块逻辑有两个层次。第一层按章节结构切文档有明确的一级标题和二级标题就以二级标题为边界切块保证每个片段主题尽量单一。第二层是长度控制单块字符数控制在 500 到 800 字之间超出就从段落边界再切。这个范围是反复试出来的少于 300 字信息太碎检索回来经常缺上下文超过 1000 字向量之间的区分度明显下降模型回答时也容易抓到无关细节。用 Python 实现时直接用递归字符切分先按\n##分割再按段落边界和字符数做二次裁剪。工具上用的 LangChain 的RecursiveCharacterTextSplitter自己写也可以核心只是“先结构后长度”这个顺序from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size700, chunk_overlap80, separators[\n## , \n### , \n, 。, ] ) chunks splitter.split_text(raw_text)chunk_overlap 设置成 80 字符是为了避免切断关键句保证前后片段之间有一定语义衔接。如果文档里有一些专项属性的描述比如某个产品的保修期切块时至少保证同一条信息完整出现在一个块里。3.2 Embedding生成与入库切完块之后就是逐条转向量并写入 Milvus。这里有一个容易被忽略的问题Ollama 的 embed 接口一次只处理一条文本如果你的知识库有 5000 个片段一条条循环会非常慢。解决办法是用 Python 并发调用项目里用 ThreadPoolExecutor 开 8 个线程并发请求实测 5000 条文本的向量化从十几分钟压缩到了 3 分钟以内。import requests from concurrent.futures import ThreadPoolExecutor OLLAMA_URL http://localhost:11434/api/embeddings def embed_text(text: str): resp requests.post(OLLAMA_URL, json{model: bge-m3, prompt: text}) resp.raise_for_status() return resp.json()[embedding] with ThreadPoolExecutor(max_workers8) as executor: embedding_list list(executor.map(embed_text, chunks))写入 Milvus 时也有一点要注意一次性插入全部向量容易拿到内存建议分批量写每批 100 条左右from pymilvus import Collection collection Collection(knowledge_base) batch_size 100 for i in range(0, len(chunks), batch_size): batch_texts chunks[i:ibatch_size] batch_vectors embedding_list[i:ibatch_size] data [ batch_texts, [source_doc_xxx] * len(batch_texts), batch_vectors ] collection.insert(data) collection.flush()flush()这一步千万别省。Milvus 的数据会先进内存缓存显式 flush 才能确保数据落盘否则刷新服务或重启后数据可能丢失。3.3 检索和重排知识库入库后检索效果的验证比想象中更花时间。这里用到了一个很典型的问题单纯依赖 Milvus 的向量相似度检索返回结果的前几名经常出现“看起来相似但实际不相关”的内容。比如用户问“设备无法开机怎么办”向量检索可能返回“开机按钮位置说明”而非“无法开机排障指南”。因为“开机”两个字在两个文本中出现频率都很高向量模型把两者当成相似文本了。解决办法是加一层重排。项目里的实现方式是先用 Milvus 检索 Top 20 候选片段再用一个轻量级的 rerank 模型对候选做精排最后取 Top 5 喂给大模型。重排模型用的bge-reranker-v2-m3同样跑在 Ollama 所在的机器上CPU 推理也够用。重排之后再取 Top 5结果质量明显提升。from pymilvus import Collection import requests collection Collection(knowledge_base) collection.load() query 设备无法开机怎么办 query_vector embed_text(query) results collection.search( data[query_vector], anns_fieldembedding, param{metric_type: COSINE, params: {nprobe: 10}}, limit20, output_fields[text, source] ) candidates [hit.entity.get(text) for hit in results[0]] # 调用 rerank 接口对所有候选排序 resp requests.post(http://localhost:11434/api/rerank, json{ model: bge-reranker-v2-m3, query: query, documents: candidates }) reranked sorted(resp.json()[results], keylambda x: x[score], reverseTrue) top_texts [candidates[item[index]] for item in reranked[:5]]注意collection.load()是必须的Milvus 的集合在创建后处于 unload 状态不加载直接 search 会报错。4. RAG问答链路打通从检索到生成的完整实现4.1 查询向量化与相似度检索用户问题进来之后第一件事就是把问题转成向量然后在 Milvus 里做相似度搜索。查询向量化的模型必须和知识库入库时用的是同一个模型否则向量空间不一致检索结果会乱成一团。这个项目里统一固定用bge-m3代码里硬编码模型名不允许从配置读取就是为了防止有人改错。检索参数里有个nprobe值得说一下。它表示 IVF 索引要搜索的聚类中心数量值越大检索越精确但延迟也会升高。对于千万级以下的数据量设置为 8 到 16 之间通常就能获得比较好的平衡。项目里设的是 10实测单次检索延迟稳定在 30ms 以内。4.2 上下文组装检索回 Top 5 片段之后接下来就是组装提示词。这里最需要注意的是控制长度。DeepSeek 的上下文窗口虽然足够大但给得太多有两个坏处一是响应延迟变长二是无关上下文会干扰模型判断。项目里实测下来Top 5 片段约 3500 到 4000 字加上系统提示词和用户问题一轮对话大概 4500 字左右响应时间还在可接受范围内。上下文组装时一定要保留来源信息。每个片段带上文件名和章节名方便生成回答时引用更重要的是方便后续做答案溯源。客服场景里用户对准确性的要求极高一旦答错了至少得能查到回答依据的是哪段文档。组装模板大致如下你是一名经验丰富的客服助理。请只根据提供的参考资料回答用户问题。 如果参考资料中没有明确信息请直接回答“资料库中没有相关内容建议转人工”。 请勿编造信息。 参考资料 [1] 来源产品手册-第三章-开机与关机 内容长按电源键3秒可开机... [2] 来源排障指南-2.1 无法开机 内容如果设备无法开机请先检查电源适配器是否连接... 用户问题设备无法开机怎么办禁用编造这个规则必须写进提示词。没有这个约束模型会充分利用知识库里的只言片语“推断”出一个听起来合理但实际上错误的答案这在客服场景是致命的。4.3 调用DeepSeek生成回答DeepSeek 提供 OpenAI 兼容的 API直接用 Python requests 调用不需要额外依赖。项目里在环境变量中配置 API Key 和 Base URL代码层面保持与第三方库解耦方便后续切换模型供应商。import os import requests API_KEY os.getenv(DEEPSEEK_API_KEY, your-api-key) BASE_URL os.getenv(DEEPSEEK_BASE_URL, https://api.deepseek.com/v1) def generate_answer(system_prompt: str, user_question: str) - str: url f{BASE_URL}/chat/completions headers {Authorization: fBearer {API_KEY}} payload { model: deepseek-chat, messages: [ {role: system, content: system_prompt}, {role: user, content: user_question} ], temperature: 0.2, max_tokens: 512, stream: False } resp requests.post(url, jsonpayload, headersheaders) resp.raise_for_status() return resp.json()[choices][0][message][content]温度参数设置成 0.2 是有意为之。客服场景要求回答稳定、可靠宁可平淡也不能发挥。温度越高模型越有“创造性”在事实问答里这是灾难。之前测试时温度设为 0.7模型会把保修期从“12个月”自行脑补成“12个月但部分配件可能不同”这种不确定性完全不可接受。5. 客服场景实战多轮对话、权限与效果调优5.1 多轮对话与上下文管理真实客服场景几乎都是多轮对话。用户先问“你们设备的保修期是多久”得到回答后紧接着问“那电池呢”单轮 RAG 会把第二个问题当成独立问题去检索“电池”但用户实际想问的是同一设备下电池的保修期。多轮 RAG 最常见的做法是先把历史对话压缩成一句“当前问题”。项目里在内存会话中维护最近三轮对话记录每次新问题进来时先把历史对话和当前问题拼成一个 prompt让 DeepSeek 先做一次意图补全根据以下对话历史将用户的最新问题改写为包含上下文的完整问题。 对话历史 用户你们设备的保修期是多久 助手该设备的整机保修期为12个月... 最新问题那电池呢 改写后的完整问题这里有两个细节值得注意。一是只保留最近三轮对话来源时间过长的历史反而会导致改写后的查询偏离用户真实意图。二是改写完的问题只用作检索不直接作为最终问题的提示词内容避免用户看到被改写的问法感到奇怪。会话状态管理用的最简单的进程内字典加一个过期时间清理机制生产环境可以替换为 Redis但核心逻辑相同。5.2 提示词优化与Prompt模板提示词是 RAG 落地中收益最大、成本最低的优化点。初版模板只写了“根据参考内容回答”上线测试时用户问了几个边界问题资料里没有的信息模型开始自由发挥资料里相互矛盾的两个说法模型选择了较新一条但未说明多轮对话里援引了上一轮的资料但本轮资料已变。针对这三个问题模板迭代了三轮。最终版本的关键约束包括回答必须基于参考片段禁止推测多个参考片段内容不一致时明确列出不同说法并注明各自来源信息不足时直接说“无法从资料中确认”并建议转人工回答结尾附上引用来源标识这个模板看起来啰嗦但在客服场景里“如实承认不知道”比“硬答但答错”要体面得多。用户有不满意的地方可以转人工弥补但如果给了错误信息导致客户按错误步骤操作那就是事故级的问题了。5.3 效果评估与调优评估 RAG 效果不能只靠感觉。项目里建了一份最小评估集30 个覆盖知识库各个章节的典型问题每类问题标注了期望答案中可以出现的关键关键词。每次改动完切块参数、检索参数或提示词模板就跑一遍这 30 个问题统计命中率。命中率的判定逻辑不复杂如果模型回答文本中包含期望关键词中的任意两个算作命中否则不命中。初次评测时命中率只有 57%主要失败原因就两个方向检索失败Top 5 片段里根本没有包含正确答案的片段生成失败检索到了正确片段但模型回答时抓错了重点两个方向的调优手段不同。检索失败优先调切块逻辑、扩充候选数量、加大重排权重生成失败优先调提示词、降低温度。这一步一定要有数据记录否则就是瞎调。6. 常见问题与排查技巧实录6.1 部署类问题排查问题一Milvus 容器启动后端口不通。排查思路是先看容器状态docker ps -a如果容器一直在 Restarting看日志docker logs milvus-standalone这类问题九成是内存不够或 etcd 初始化失败。单机版 Milvus 默认预分配的内存比较大机器只有 16GB 内存时要适当调低配置在 docker-compose.yaml 里改cache相关参数否则容器会因为 OOM 反复重启。问题二Ollama 模型拉取超时。bge-m3 模型体积约 1.2GB网络差的时候很容易拉到一半断开。解决办法是设置环境变量延长超时时间或者手动到官网下载模型文件放到 Ollama 的模型目录下然后ollama create导入。后一种方式在离线环境里尤其好用。6.2 检索质量问题排查问题一检索回来的片段看起来相关但信息不完整。这种问题通常是切块策略出了问题关键信息被切断到两个块里。排查可以随便抽几个切块结果目测一遍如果块与块之间经常出现上下文不连贯就把 chunk_overlap 调大。问题二关键词精确匹配的问答效果差。比如用户问“RMA 流程是什么”知识库里的原文是“退货授权RMA申请流程”。向量相似度检索可能找得到但有时候就是匹配不上。解决办法是引入关键词混合检索Milvus 支持布尔表达式过滤可以先用关键词粗筛再结合向量排序。项目里加了一个简单的规则如果检测到问题里包含全大写的专业术语如 RMA、SDK、API先把这些词放到过滤条件里缩小范围再检索。问题三Top 5 片段相互重复。重复片段会浪费上下文空间。这是因为切块时 overlap 过大同一语义内容出现在多个相邻块里。排查时看检索结果中的 source 字段如果同一个来源的片段占了多个坑考虑减小 overlap 或者加一个简单的去重逻辑按 source 字段只保留 Top 2 个片段。6.3 避坑清单向量维度必须统一。换模型重建集合时旧集合向量数据要清空重建不能原地插入不同维度的向量。Milvus 集合要 load。开发时最容易犯的错就是忘了collection.load()直接 search 报错。DeepSeek API Key 不要写死在代码里。用环境变量管理防止误提交到公共仓库。知识库更新后别忘 flush。新增数据不调用 flush服务一重启就丢。不要把用户原始问题直接作为提示词输入。多轮场景下要先做意图补全否则模型很容易答非所问。rerank 模型和 embedding 模型是两回事。很多人一开始以为复用 embedding 模型就能做 rerank实际上两类模型训练目标完全不同混用效果会打折扣。这个项目从搭环境到能回答第一个问题大概花了一天时间。真正耗时的是后面对检索效果的反复调优前后花了一周半。核心收获是RAG 系统的瓶颈通常不在模型而在数据处理链路把切块和检索调顺了比换一个更大的模型收益要明显得多。前面说到的这些经验和参数都是基于当前项目场景的实践结果不同知识库的分布情况差异很大建议读者拿自己的数据多做几组对照测试找到适合自己的配置。