☰
用Ollama+Milvus+DeepSeek,零成本搭建本地RAG智能客服
2026/10/23 3:24:21 网站建设 项目流程

做过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(对象存储)和 standalone(Milvus 主服务):

docker ps

接着用 PyMilvus 初始化集合。集合相当于传统数据库里的表,需要预先定义字段:一个主键id、一个文本字段text、一个向量字段embedding,另外加一个source字段,用来存文档来源(比如来自哪一份手册、哪个章节),方便后续答案溯源。

from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType connections.connect(host="localhost", port="19530") fields = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True), FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=8192), FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=256), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024) ] schema = CollectionSchema(fields=fields, description="知识库向量集合") collection = Collection(name="knowledge_base", schema=schema) collection.create_index( field_name="embedding", 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_size=700, chunk_overlap=80, 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_workers=8) 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:i+batch_size] batch_vectors = embedding_list[i:i+batch_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_field="embedding", param={"metric_type": "COSINE", "params": {"nprobe": 10}}, limit=20, 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"], key=lambda x: x["score"], reverse=True) 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": f"Bearer {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, json=payload, headers=headers) 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 系统的瓶颈通常不在模型而在数据处理链路,把切块和检索调顺了,比换一个更大的模型收益要明显得多。前面说到的这些经验和参数,都是基于当前项目场景的实践结果,不同知识库的分布情况差异很大,建议读者拿自己的数据多做几组对照测试,找到适合自己的配置。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询