ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

零成本搭建私有化RAG智能客服:Ollama+Milvus+DeepSeek完整实战

零成本搭建私有化RAG智能客服:Ollama+Milvus+DeepSeek完整实战

如果你手头有一个客服知识库,但预算不够去买大模型 API 套餐,也担心把内部资料丢到外部服务上不安全,那这套方案应该能帮到你。我最近按“尽量不花钱”的思路,把 Ollama 和 Milvus 以及 DeepSeek 搭成了一个完整的 RAG 智能客服,从环境搭建到代码落地,全程没有产生一分钱 API 费用。这篇文章把整个过程中的架构思路、实现细节、调优经验和踩坑记录完整写出来,目标是让你跟着操作也能跑通。

这个方案适合有基本 Python 基础的开发者,或者刚接触 RAG、想自己搞一个私有知识库问答系统的小团队。不需要租 GPU,不需要买向量数据库服务,一台 16G 内存的普通电脑就能跑起来。我会解释每个环节为什么这么选,也会把参数背后的逻辑讲清楚,避免你只知道复制粘贴。

1. 先理清楚:这个RAG客服到底怎么工作

1.1 RAG解决什么问题

RAG 全称是 Retrieval-Augmented Generation,中文叫检索增强生成。它的核心想法很简单:大模型不是万能的,尤其对私有领域的知识一无所知,那就先在知识库里找出和用户问题最相关的资料,再把资料一起喂给大模型,让它结合资料来回答。

你可以把 RAG 理解成“开卷考试”。大模型是考生,Milvus 是书架,Ollama 负责把考生带到考场。考生不需要把所有知识点背下来,只需要在书架上快速翻到相关章节,然后照着章节内容答题。这就解决了客服场景里最实际的问题:知识库会频繁更新,如果每次更新都去微调大模型,成本和时间都受不了。用 RAG,新增一份文档,向量化后塞进 Milvus 就行了,模型本身不用动。

为什么要自己做 RAG 而不是直接调用大模型 API?原因有两个。一是按 token 计费在客服高频场景下是持续成本,问答一旦多起来,账单很容易失控。二是客户咨询中难免涉及订单、售后、价格策略等敏感信息,员工或者用户提出问题后,这些数据如果经过外部 API,就存在泄漏风险。本地化部署意味着模型和向量库运行在自己电脑或内网服务器上,数据不出门,采购流程和合规审查都能省掉一大截。

1.2 三个组件各扮演什么角色

  • Ollama:一个本地大模型运行工具,把 DeepSeek 等开源模型下载到本地并提供 HTTP 接口。它封装了模型下载、推理调度、内存管理,你不需要手动配置复杂的 Python 推理环境。
  • Milvus:开源向量数据库,存的是“文本的数学表示”,也就是 embedding 向量。它的作用是快速找出和用户问题语义最接近的知识片段。相比用 CSV 文件暴力扫描或者简单关键词匹配,向量检索能理解近义词和口语化表达。
  • DeepSeek:这里指通过 Ollama 拉取到的 DeepSeek 系列模型,比如 deepseek-r1:7b。它是真正生成回答的“大脑”,接收检索出来的资料,组合成最终话术。

这组合最香的地方是:三个组件全都有免费可用的开源版本,不涉及按量计费,也没有限流。你只需要付出硬件电费和时间。

1.3 整体链路与选型理由

整个流程可以拆成两条链路:一条是知识入库,一条是检索问答。

知识入库就是把客服文档切分成小段,用 embedding 模型把每段文本变成向量,再把向量和原文一起写入 Milvus。

检索问答则是用户发来问题后,把问题用同一个 embedding 模型变成向量,去 Milvus 里用相似度搜索取回 Top K 条资料,最后把问题和资料拼成提示词,交给 Ollama 里的 DeepSeek 模型生成答案。

我选了 Milvus 而不是其他向量数据库,是因为它在大规模向量检索上性能很稳,而且有 Milvus Lite 这种轻量模式,开发阶段可以像用 SQLite 一样直接用文件,不用先部署一套服务。等将来数据量大了,再平滑切到 Docker 单机版或者集群版。Ollama 则是目前本地跑模型最省事的工具,一条命令拉模型,一条命令起服务,对新手相当友好。

2. 从零开始准备环境

2.1 安装Ollama并拉取模型

如果你的系统是 Linux 或者 macOS,打开终端执行:

curl -fsSL https://ollama.com/install.sh | sh

Windows 用户直接到官方仓库下载安装包,双击安装即可。装完后执行ollama -v看是否成功。

然后拉取两个模型,一个是生成回答用的 DeepSeek,一个是生成向量用的 BGE-M3:

ollama pull deepseek-r1:7b ollama pull bge-m3

我为什么选 deepseek-r1:7b?因为它的体量和效果比较平衡。7B 参数量的模型经过量化后,在 CPU 上也能跑,只是速度慢一点;如果有 NVIDIA 显卡,Ollama 会默认用 GPU 加速,体验会好很多。如果你电脑内存只有 8G,也可以换更小的 deepseek-r1:1.5b,牺牲一点回答质量换速度。

BGE-M3 是专门做 embedding 的模型,输出 1024 维向量,对中文的支持比很多通用模型好。这里有个关键点:知识入库和查询提问必须用同一个 embedding 模型,否则向量的空间不一致,检索结果一定不准。

拉完模型后,Ollama 默认监听 11434 端口。可以用ollama list查看已有模型,用curl http://localhost:11434/api/tags验证服务是否正常。

2.2 安装并启动一个Milvus

Milvus 有两种跑法,我建议开发阶段用最简单的那种。

最简单的方式是安装 Milvus Lite,它可以把数据库保存在一个本地文件里,非常适合学习和测试。你先创建虚拟环境,然后安装依赖:

python -m venv rag_env source rag_env/bin/activate # Windows 下执行 rag_env\Scripts\activate pip install -U pymilvus milvus-lite requests

之后在代码里这样初始化客户端:

from pymilvus import MilvusClient # 开发模式:数据保存在本地文件 client = MilvusClient("knowledge.db")

如果用 Docker 部署正式环境,那就下载官方的单机部署文件:

wget https://github.com/milvus-io/milvus/releases/download/v2.4.16/milvus-standalone-docker-compose.yml -O docker-compose.yml docker compose up -d

Docker 模式会启动 Milvus、etcd 和 MinIO 三个服务,实际生产中建议用这种方式。连接方式改成:

# 生产模式:连接 Docker 服务 client = MilvusClient(uri="http://localhost:19530", token="root:Milvus")

要注意,Milvus 默认端口是 19530,docker-compose 启动后如果发现端口被占用,先看是不是有别的服务占着端口。开发模式和生产模式代码差别很小,这也是我用 pymilvus 的 MilvusClient 封装的原因,切换环境不用改逻辑。

2.3 准备知识库数据与切分思路

数据质量决定 RAG 质量,这句话怎么强调都不过分。我先造一个模拟的电商售后知识库,内容大概是退换货规则、物流时效、退款说明这些常见客服问题。你可以用自己的业务文档替换。

推荐的数据格式是纯文本或者 Markdown,关键是将每一条知识整理成“一问一答”或“标题+正文”的结构化形式。比如:

【退货政策】自签收之日起7天内,可申请无理由退货,退回商品需保持完好且不影响二次销售,运费由买家承担。 【换货规则】产品自收到次日起30天内,若出现非人为质量问题,可以申请换货,无需承担往返运费。 【退款时效】退货完成后,退款会在1-3个工作日内原路返回,具体到账时间取决于支付渠道。 【物流查询】商品发货后,物流单号会在24小时内更新,若超过48小时未更新,可能正在转运中。

拿到原始文档后,先做清洗:把 PDF 复制出来的断行去掉、把全角半角标点统一、把明显乱码段落删掉。清洗完再切分,切分这一步容易被忽略,但直接影响检索效果。

我推荐按段落语义切分,而不是死板地按字数硬切。可以写一个简单的按空行分段、再按最大长度合并的函数:

def split_text(text, max_len=500, overlap=50): paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] chunks = [] buffer = "" for para in paragraphs: if len(buffer) + len(para) <= max_len: buffer += "\n" + para else: if buffer: chunks.append(buffer) if len(para) > max_len: # 超长段落按句号二次切分 start = 0 while start < len(para): end = min(start + max_len, len(para)) cut = para.rfind("。", start, end) if cut > start + max_len // 2: chunks.append(para[start:cut+1]) start = cut + 1 else: chunks.append(para[start:end]) start = end buffer = "" if buffer: chunks.append(buffer) return chunks

这里设置max_len=500,是因为 BGE-M3 对长文本的编码能力虽然不错,但单段超过一定长度后,向量会被“稀释”,检索精度下降。overlap参数我其实没有在函数里写进去,如果你希望相邻片段有重叠,可以每次保留上一段末尾的 50 个字再拼到下一段开头,能避免跨段信息被截断。

3. 核心代码一:向量化与数据入库

3.1 设计集合Schema

在 Milvus 里,表叫 Collection,每条记录叫 Entity。我们要建一个叫knowledge_base的集合,字段设计如下:

字段名类型说明
idINT64主键,自增或手动指定
textVARCHAR存切分后的文本原文
embeddingFLOAT_VECTOR1024维向量,来自 BGE-M3

创建集合的代码如下:

from pymilvus import MilvusClient, DataType client = MilvusClient("knowledge.db") if client.has_collection(collection_name="knowledge_base"): client.drop_collection(collection_name="knowledge_base") schema = client.create_schema(auto_id=False, enable_dynamic_field=True) schema.add_field(field_name="id", datatype=DataType.INT64, is_primary=True) schema.add_field(field_name="text", datatype=DataType.VARCHAR, max_length=1024) schema.add_field(field_name="embedding", datatype=DataType.FLOAT_VECTOR, dim=1024) index_params = client.prepare_index_params() index_params.add_index(field_name="embedding", index_type="FLAT", metric_type="COSINE") client.create_collection( collection_name="knowledge_base", schema=schema, index_params=index_params )

选metric_type="COSINE"是因为余弦相似度更适合衡量语义向量的方向一致性,而不是绝对距离。对于文本场景,两个向量方向越接近,语义就越相关。FLAT索引是暴力计算,小数据量下没必要用高级索引,将来超过百万条再改成 IVF_FLAT 或 HNSW 不迟。

3.2 用BGE-M3生成向量并写入

调用 Ollama 的接口生成 embedding,封装一个函数:

import requests OLLAMA_URL = "http://localhost:11434" def embed_texts(texts): resp = requests.post( f"{OLLAMA_URL}/api/embed", json={"model": "bge-m3", "input": texts} ) resp.raise_for_status() return resp.json()["embeddings"]

然后把文本切分好的每一段转成向量,逐批写入 Milvus:

def ingest_docs(doc_text, start_id=0): chunks = split_text(doc_text) vectors = embed_texts(chunks) data = [] for i, (chunk, vec) in enumerate(zip(chunks, vectors)): data.append({ "id": start_id + i, "text": chunk, "embedding": vec }) client.insert(collection_name="knowledge_base", data=data) return len(data)

这里有个容易踩的坑:当你批量传入很多文本给 Ollama 时,它返回的 embeddings 顺序和输入的顺序是一致的。但如果你在做并发请求,就得自己保证顺序,否则数据会对不上号。我建议先单请求批量传,等数据量大了再拆并发,并发加 seq 字段标识顺序。

写入后可以查一下集合里的条数:

print(client.get_collection_stats(collection_name="knowledge_base"))

如果返回{"row_count": 12}之类的数据,就说明入库成功。

3.3 验证入库效果

光入库不算完,先手动检索一下,确保向量库真的能返回相关内容:

test_question = "退货需要满足什么条件" test_vec = embed_texts([test_question])[0] res = client.search( collection_name="knowledge_base", data=[test_vec], limit=2, output_fields=["text"] ) for hit in res[0]: print(hit["entity"]["text"], "score:", hit["distance"])

这一步很关键。如果相似度分数很高,说明 embedding 模型和切分策略没问题。如果分数普遍很低,或者检索结果完全无关,那一定得回头检查数据和切分逻辑,不要急着往下搭生成环节。

4. 核心代码二:召回 + 大模型生成回答

4.1 查询向量化与相似度检索

线上问答时,用户输入的问题要先转成向量,然后去 Milvus 里搜索。完整函数如下:

def search_knowledge(question, top_k=3): q_vec = embed_texts([question])[0] res = client.search( collection_name="knowledge_base", data=[q_vec], limit=top_k, output_fields=["text"] ) results = [hit["entity"]["text"] for hit in res[0]] scores = [hit["distance"] for hit in res[0]] return results, scores

top_k=3是我调下来比较稳的参数。取太少,可能漏掉关键资料;取太多,无关内容混进来反而干扰模型。实际项目中可以先设 3,后面根据准确率调整。

4.2 构造提示词模板

提示词模板是整个 RAG 生成质量的核心。我用的模板长这样:

def build_prompt(question, context): system = ( "你是某线上商城的智能客服助手。" "请依据知识库资料,用自然、简洁、专业的中文回答用户问题。\n" "规则:\n" "1. 优先使用资料原文信息回答。\n" "2. 如果资料中没有答案,不要编造,请告知用户并建议联系人工客服。\n" "3. 不要提及你是AI模型。\n" "4. 回答控制在200字以内。\n" ) user = f"知识库资料:\n{context}\n\n用户问题:{question}" return system, user

把检索出的片段用换行拼接成一个 context,如果片段太长,可以只保留开头一小段。提示词设计有一条铁律:一定要告诉模型“不知道就直说”。否则模型会用训练积累的常识脑补答案,这在客服场景里是灾难。

4.3 接上DeepSeek模型输出最终答案

获取答案时,我推举用 Ollama 的/api/chat接口,因为可以分别传 system 和 user 消息,比拼接在同一个 prompt 里更规范:

def chat_with_knowledge(question): context_list, scores = search_knowledge(question, top_k=3) # 过滤低分结果 context = "\n".join(context_list) system, user = build_prompt(question, context) resp = requests.post( f"{OLLAMA_URL}/api/chat", json={ "model": "deepseek-r1:7b", "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user} ], "stream": False, "options": { "temperature": 0.3, "top_p": 0.8 } } ) resp.raise_for_status() return resp.json()["message"]["content"]

把temperature调到 0.3,是因为客服回复需要稳定性和准确性,不需要太多创造性。如果温度太高,同一个问题每次回答文案都会有明显差别,而且更容易出现胡说八道的情况。

写一个简单循环,测试跑通:

if __name__ == "__main__": while True: q = input("请输入问题(输入exit退出):") if q == "exit": break print(chat_with_knowledge(q))

实际运行效果大概是:输入“退货需要满足什么条件”,系统返回“自签收之日起7天内可申请无理由退货,商品需保持完好且不影响二次销售,运费由买家承担。” 这种结果比直接用 DeepSeek 裸奔要靠谱得多,因为每个回答都有知识库依据。

5. 调优与避坑:从“能用”到“好用”

5.1 召回质量怎么调

如果是这个方案可能被召回质量不好这个问题所困扰,多半是以下几个原因。

第一个是分块大小。块太大,向量里塞进太多无关信息,检索出来的片段就不聚焦;块太小,语义不完整,模型找不到完整上下文。经验值是在 300 到 600 字之间,客服文档偏短问答,可以适当缩到 200 字左右。

第二个是 top_k 和相似度阈值。可以看检索结果的 score,不同 embedding 模型的 score 分布不一样。BGE-M3 的余弦相似度一般 0.6 以上才算靠谱,低于 0.5 的基本可以直接丢弃。我建议先打印前 10 条检索结果,人工看一眼 score 分布,再定阈值。

第三个是查询改写。用户口语化严重时,比如“东西坏了怎么办”,可能和知识库里的“产品质量问题换货规则”匹配不太上。这时候可以先用一个轻量模型把用户问题改写成语义更完整的查询,比如“产品出现质量问题如何处理”,再去做 embedding 搜索。这个技巧很有效,但也会增加一次模型调用,只能算锦上添花。

5.2 让回答更可控的几个技巧

有几次我发现模型回答虽然正确,但太啰嗦,一个问题能扯出一大段。后来在 system prompt 里加了一条“回答不超过 200 字”,效果立竿见影。如果你想要更严格的控制,可以加 few-shot 示例,给模型一个标准问答样例,输出风格会更稳定。

另外,DeepSeek-R1 系列本身是推理模型,它会先把推理过程写在回答里。客服场景不需要展示推理链,可以在提示词里加一句“直接输出最终回答,不要输出思考过程”。否则用户会看到一大段自言自语,体验很差。实测下来这一条不发不行。

如果售后问题涉及多个知识片段,比如用户既问退货又问退款,模型可能需要综合两段资料。这时在 system prompt 里允许模型“分点作答”会更清晰,我遇到过模型把所有内容糊成一段的情况,后来提示词里明确“若涉及多个要点,请分条列出”,可读性提升很多。

5.3 性能与并发注意事项

本地部署最大的瓶颈是硬件。7B 量化模型在 CPU 上生成一个回答可能要 10 秒以上,如果业务量不大,倒也能接受。有 GPU 的话,Ollama 会自动检测显存,一般 8G 显存就能跑 7B 模型。

并发方面,Ollama 默认支持多个请求排队,但我建议在实际服务里加一个简单的互斥锁,避免用户集中问答时 OOM。生产做法是把 Ollama 的服务封装成一个 FastAPI 接口,请求进来先查缓存,再进 RAG 流程。

向量检索的性能反而是小数据量下最不用担心的。几千条知识的本地向量库,FLAT 索引的查询时间在毫秒级。真正要注意的是不要每来一个请求都往 Milvus 写数据,新增知识可以用定时任务批量入库。

5.4 数据更新策略

知识库不是静态的,每隔一段时间就要更新。最简单的更新方案是按 id 删除旧数据,再插入新数据:

client.delete( collection_name="knowledge_base", filter="id in [1, 2, 3]" ) client.upsert(collection_name="knowledge_base", data=new_data)

Milvus 2.4 之后的 pymilvus 客户端支持 upsert 操作,按主键更新。实际项目中我会在文档里加一个source字段,在更新时先把某个来源的所有记录删掉,再重新插入,这样不会把不同文档的旧版本残留在库里。

我在处理过一份更新频率高的 FAQ 表格后意识到,与其频繁全量重灌,不如把知识库分成几个集合,比如售后规则一个集合、商品信息一个集合,检索时分开查再合并结果。这样更新某个模块时互不影响,也能降低整体故障风险。

6. 常见问题与排查实录

6.1 Milvus连接失败

症状是代码报RuntimeError: fail to connect to server。原因基本就是三种:Milvus 服务没启动、URI 写错、端口被占用。

如果是本地文件模式,检查knowledge.db路径是否有写权限;如果是 Docker 模式,先执行docker compose ps看 Milvus 是否在运行。还有一点容易被忽略:Milvus Lite 模式不能用http://localhost连接,必须用本地文件路径,反过来也一样。

这种连接问题在我自己的部署中出现的频率很低,但每次切换环境都容易踩。建议在入口处做一次连通性测试,失败就打印明确提示,不要等查询时报一堆底层异常。

6.2 Ollama模型加载慢或内存不足

症状是第一次请求等很久,然后报library cuda错误,或者直接 OOM。Ollama 第一次加载模型确实会慢,因为它要把权重读入内存,之后会缓存在内存中,速度就快很多。

如果是 CPU 机器,内存至少得有 16G,建议用 7B 量化版但预留充足的内存。如果爆内存,换deepseek-r1:1.5b或者清理掉不用的模型。

显存不够时,可以设置 OLLAMA_MAX_LOADED_MODELS=1,让 Ollama 同时只加载一个模型。这个参数在系统环境变量里配置,不会影响多模型支持,只是减少内存占用。

6.3 检索结果为空或相关度低

检索结果为空,先确认集合里有没有数据,再看查询向量维度是否和集合维度一致。维度不一致时,Milvus 会直接报错,这属于低级问题,但也容易在切换 embedding 模型后发生。

相关度低的话,先降低分块大小,再检查是不是原始文档本身表述和用户问法差异太大。最有效的排查手段是打印检索出的原文,人工判断是不是知识库压根没有覆盖这个知识点。如果知识库里真的没有,那 RAG 是救不回来的,任何优化都于事无补,只能补充文档。

6.4 回答明显错误或胡编乱造

如果系统给出的答案看起来内容正确,但实际上是没有根据的,那就是典型的模型幻觉。出现这种情况,说明两点:要么检索的上下文不够,要么系统提示词里没有明确禁止模型自由发挥。

我的修正思路是,先加一条“如果资料没有相关内容,只能说不知道,不能推测”。然后把temperature降到 0.1 再试。如果还不行,就得看看是不是检索到了不相关的片段。因为模型不会区分哪段资料可信,你把噪声片段也塞进 context,它就会一本正经地用错误资料作答。

最后加一个兜底分支:相似度最高的一条如果分数低于阈值,直接返回“未找到相关知识,建议人工介入”,不进入生成阶段。这样虽然回答“死板”,但至少不会误导用户。

6.5 向量维度设置错误

BGE-M3 的输出维度是 1024,这是它的模型结构决定的,不能随意改。有次我在创建集合时写错成 768,报错信息非常隐晦,查了半天才发现是维度不匹配。建议写代码时把维度写成配置项,并和 embedding 模型名称绑定,这样别人接手或者自己日后调整模型时不容易改错。

一个小技巧是,在创建集合前先跑一下len(embed_texts(["测试"])),打印真实维度,然后再拿去建表。这比背数字靠谱得多,尤其是换不同 embedding 模型的时候。

个人体会与扩展建议

这套系统搭完之后,我的直观感受是:RAG 项目的复杂度不在模型,而在检索质量。DeepSeek 和 Ollama 都只是执行工具,真正决定客服答得好不好的,是知识库怎么清洗、怎么切分、怎么检索。把数据这块磨好,哪怕是 7B 的小模型,也能做出像样的问答效果。

如果后续想把系统做得更完整,我建议朝三个方向扩展:一是给每个请求接一个日志系统,记录用户问题和回答,方便抽检质量;二是做一个简单后台管理页,运营人员可以直接在网页上添加和修改知识片段,不用改代码;三是把 Ollama 推理改成流式返回,用户打字时能实时看到回复过程,体验会更接近商业客服系统。零成本只是起点,把流程跑顺之后,这套架构完全有能力撑起一个小型团队的日常客服需求。

返回列表