基于 Milvus + Ollama(BGE-M3) + DeepSeek 的智能客服 RAG 实战
一、项目背景
在社保、医保、就业等公共服务领域,每天都有大量群众拨打热线咨询相似问题。传统人工客服成本高、效率低,而基于关键词匹配的机器人又难以理解用户的真实意图。
本项目基于 RAG(Retrieval-Augmented Generation,检索增强生成) 架构,搭建了一套智能客服系统,实现:
- 用户问题语义理解(非关键词匹配)
- 从知识库中精准检索相关问答
- 基于 LLM 生成自然语言回答
- 自动推荐相关问题引导用户继续咨询
- 支持语音输入和语音播报
二、整体架构
用户(语音/文本输入)
│
▼
┌─────────────┐
│ Flask服务 │ ← HTTPS部署
│ (后端API) │
└──────┬──────┘
│
▼
┌──────────────────┐ ┌──────────────────┐
│ Ollama本地服务 │ │ Milvus向量数据库 │
│ (bge-m3 向量化) │────▶│ (知识库语义检索) │
└──────────────────┘ └────────┬─────────┘
│ Top-20 相似问答
▼
┌──────────────────┐
│ DeepSeek API │
│ (LLM推理生成回答) │
└────────┬─────────┘
│
▼
回答 + 推荐问题
(SSE流式 / 普通返回)
三、技术栈
| 组件 | 技术选型 | 说明 |
|---|---|---|
| Embedding模型 | Ollama + bge-m3 | 本地部署,免费,支持中英文混合 |
| 向量数据库 | Milvus | 开源高性能,支持COSINE相似度 |
| 大语言模型 | DeepSeek API (deepseek-chat) | 推理能力强,性价比高 |
| 后端服务 | Flask + flask_cors | 轻量级,支持HTTPS |
| 前端 | HTML + JS + jQuery | 支持语音输入(讯飞) + Markdown渲染 |
四、环境准备
4.1 安装 Ollama 并拉取模型
bash
# 安装Ollama(参考官方文档 https://ollama.com)
# 拉取中文Embedding模型
ollama pull bge-m3
验证模型是否可用:
bash
curl http://localhost:11434/api/embeddings -d '{
"model": "bge-m3:latest",
"prompt": "社保缴费年限不够怎么办"
}'
返回结果中包含 embedding 字段即为成功。
4.2 部署 Milvus
推荐使用 Docker 部署:
bash
# 下载docker-compose.yml
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
4.3 安装Python依赖
bash
pip install flask flask-cors pymilvus requests chardet
五、知识库构建
5.1 创建Milvus集合
python
from pymilvus import (
MilvusClient, FieldSchema, CollectionSchema, DataType
)
client = MilvusClient(
uri="http://your_milvus_host:19530",
token="your_token",
db_name="default"
)
fields = [
FieldSchema(name="uid", dtype=DataType.INT64, is_primary=True, auto_id=False, max_length=100),
FieldSchema(name="Question", dtype=DataType.VARCHAR, max_length=5000),
FieldSchema(name="Answer", dtype=DataType.VARCHAR, max_length=5000),
FieldSchema(name="Vector", dtype=DataType.FLOAT_VECTOR, dim=1024),
]
schema = CollectionSchema(fields, "社保知识库向量存储")
client.create_collection(
collection_name="SI_knowledge",
schema=schema,
dimension=1024
)
注意 :bge-m3 模型输出的向量维度为 1024 ,创建集合时
dim必须一致。
5.2 创建向量索引
python
def create_index(client: MilvusClient, collection_name: str):
index_para = client.prepare_index_params()
index_para.add_index(
field_name="Vector",
index_type="IVF_FLAT",
metric_type="COSINE",
params={"nlist": 1024}
)
client.create_index(collection_name=collection_name, index_params=index_para)
5.3 导入知识库数据
将业务问答数据向量化并存入Milvus。核心逻辑:先检索是否已存在语义相似的问答(去重),再插入新数据。
python
def vectorize_text(text):
url = "http://localhost:11434/api/embeddings"
headers = {"Content-Type": "application/json", "Accept": "application/json"}
data = {
"model": "bge-m3:latest",
"prompt": text
}
response = requests.post(url, json=data, headers=headers)
if response.status_code == 200:
return response.json().get('embedding', [])
return None
def import_knowledge(client, question, answer, uid):
vector = vectorize_text(question)
# 去重:先检索是否存在语义高度相似的问题
search_params = {
"metric_type": "COSINE",
"params": {"radius": 0.87}
}
res = client.search(
collection_name="SI_knowledge",
data=[vector],
limit=10,
output_fields=["uid", "Question"],
search_params=search_params
)
if len(res[0]) > 0:
print(f"已存在相似问题: {res[0][0]['entity']['Question']}")
return
# 插入新数据
data = {"uid": uid, "Question": question, "Answer": answer, "Vector": vector}
client.insert(collection_name="SI_knowledge", data=data)
print(f"插入成功: uid={uid}")
六、核心服务实现
6.1 Flask服务主框架
python
from flask import Flask, request, render_template, Response, stream_with_context
from pymilvus import MilvusClient
from flask_cors import CORS
import requests, json, time
app = Flask(__name__)
CORS(app)
# Milvus连接配置
client = MilvusClient(
uri="your_milvus_host:19530",
token="your_token",
db_name="default"
)
# DeepSeek API配置
DEEPSEEK_URL = "https://api.deepseek.com/chat/completions"
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY") # 建议使用环境变量
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {DEEPSEEK_API_KEY}"
}
# 向量检索参数
search_params = {
"metric_type": "COSINE",
"params": {"radius": 0.5}
}
6.2 本地Embedding向量化
python
def vectorize_text(text):
"""调用本地Ollama的bge-m3模型进行文本向量化"""
url = "http://localhost:11434/api/embeddings"
headers = {"Content-Type": "application/json", "Accept": "application/json"}
data = {"model": "bge-m3:latest", "prompt": text}
response = requests.post(url, json=data, headers=headers)
if response.status_code == 200:
return response.json().get('embedding', [])
return None
为什么用本地Ollama做Embedding?
- 免费调用,无API费用
- 数据不出内网,安全可控
- bge-m3 对中文语义理解效果优秀
6.3 普通问答接口(同步返回)
python
@app.route('/getAnser', methods=['GET'])
def get_answer():
question = request.args.get('q')
# 1. 问题向量化
vector = vectorize_text(question)
# 2. Milvus语义检索,获取Top-20相关问答
res = client.search(
collection_name="SI_knowledge",
data=[vector],
limit=20,
output_fields=["uid", "Question", "Answer"],
search_params=search_params
)
# 3. 构建DeepSeek的请求
data = {
"model": "deepseek-chat",
"messages": [],
"stream": False,
"temperature": 0.3,
"max_tokens": 8192
}
# 用户问题(带Prompt约束)
data["messages"].append({
"role": "user",
"content": (
question
+ "?只能使用提供的上下文进行逻辑推理,不要提示根据提供的上下文。"
"回答问题后,推荐相关的3个问题且不含回答及序号,"
"且答案与推荐的问题之间用<separate>分开且问题的答案在前面"
)
})
# 将检索到的知识库答案作为System上下文注入
for item in res[0]:
data["messages"].append({
"role": "system",
"content": item["entity"]["Answer"]
})
# 4. 调用DeepSeek生成回答
start_time = time.time()
response = requests.post(DEEPSEEK_URL, json=data, headers=headers)
print(f"耗时: {time.time() - start_time:.2f}s")
if response.status_code == 200:
result = response.json()
content = result["choices"][0]["message"]["content"]
# 5. 解析回答和推荐问题
answer, recommend = parse_answer_and_recommend(content)
return answer + "<separate>" + recommend
return ""
6.4 流式问答接口(SSE实时返回)
python
def safe_decode(byte_data: bytes, encoding='utf-8') -> str:
"""兼容多种编码格式的字节解码"""
try:
encoding = requests.compat.chardet.detect(byte_data)['encoding']
return byte_data.decode(encoding)
except UnicodeDecodeError:
return byte_data.decode(encoding, errors='replace')
@app.route('/getAnserStream', methods=['GET'])
def get_answer_stream():
question = request.args.get('q')
# 1. 向量化 + 检索(同上)
vector = vectorize_text(question)
res = client.search(
collection_name="SI_knowledge",
data=[vector],
limit=20,
output_fields=["uid", "Question", "Answer"],
search_params=search_params
)
# 2. 构建请求(stream=True)
data = {
"model": "deepseek-chat",
"messages": [],
"stream": True,
"temperature": 0.3,
"max_tokens": 8192
}
data["messages"].append({
"role": "user",
"content": (
question
+ "?只能使用提供的上下文进行逻辑推理,不要提示根据提供的上下文,"
"如果发现是问了多个问题,拆分问题。"
"回答问题后,从上下文中推荐相关的3个问题且不含回答及序号,"
"且答案与推荐的问题之间用<separate>分开且问题的答案在前面"
)
})
for item in res[0]:
data["messages"].append({
"role": "system",
"content": f"问:{item['entity']['Question']}?答:{item['entity']['Answer']}"
})
# 3. 流式调用DeepSeek
response = requests.post(DEEPSEEK_URL, json=data, headers=headers, stream=True)
if response.status_code == 200:
def generate():
for line in response.iter_lines():
if line:
text = safe_decode(line).replace("data: ", "")
try:
chunk = json.loads(text)
content = chunk["choices"][0]["delta"]["content"]
yield content
except Exception:
pass
return Response(stream_with_context(generate()), mimetype='text/plain')
return ""
6.5 解析回答和推荐问题
python
def parse_answer_and_recommend(raw_content: str):
"""从LLM输出中分离答案和推荐问题"""
# 处理DeepSeek-R1的思考标记
char_think = ">\u25b8"
position = raw_content.find(char_think)
start = position + 8 if position > 0 else 0
content = raw_content[start:]
# 分离答案和推荐问题
sep = "<separate>"
sep_pos = content.find(sep)
if sep_pos > 0:
answer = content[:sep_pos]
recommend_text = content[sep_pos + len(sep):]
# 清洗推荐问题文本
lines = recommend_text.split('\n')
cleaned = []
for line in lines:
for noise in ["相关推荐问题", "相关问题推荐", "推荐相关问题", "*", ":", ":"]:
line = line.replace(noise, "")
if len(line) >= 4:
cleaned.append(line)
recommend = "<separate>".join(cleaned)
return answer, recommend
return content, ""
七、前端集成
7.1 流式请求与Markdown渲染
javascript
function askllm(question) {
var allResult = '';
fetch('/getAnserStream?q=' + encodeURIComponent(question))
.then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
function readChunk() {
return reader.read().then(({ done, value }) => {
if (done) {
// 流结束,解析推荐问题
const sepIndex = allResult.indexOf("<separate>");
const answer = allResult.slice(0, sepIndex);
// 使用marked.js渲染Markdown
document.getElementById("result").innerHTML =
marked(allResult.substring(0, sepIndex));
// 解析并渲染推荐问题...
return;
}
const text = decoder.decode(value);
allResult += text;
document.getElementById('result').innerHTML += text;
readChunk();
});
}
readChunk();
});
}
7.2 语音输入(讯飞语音识别)
前端集成了讯飞实时语音转写(iat),用户可以通过点击麦克风按钮直接语音提问,语音识别结果自动发送给后端进行问答。
7.3 语音播报(讯飞TTS)
回答生成完成后,自动调用讯飞TTS进行语音播报,实现完整的语音交互闭环。
八、HTTPS部署
python
if __name__ == '__main__':
app.run(
host='0.0.0.0',
port=443,
ssl_context=('path/to/server.crt', 'path/to/server.key')
)
生产环境建议使用 Nginx 反向代理 + Let's Encrypt 证书,而非直接在Flask中启用HTTPS。
九、关键技术细节
9.1 Prompt工程
本项目的Prompt设计核心思路:
用户问题 + 约束条件 → 要求LLM:
1. 只能使用提供的上下文推理(防止幻觉)
2. 不提示"根据提供的上下文"(更自然)
3. 拆分多问题场景
4. 输出答案 + 推荐问题(用特殊分隔符分隔)
将检索到的知识库答案以 system 角色注入,格式为 问:xxx?答:xxx,让LLM理解上下文结构。
9.2 相似度阈值调优
| 参数 | 值 | 说明 |
|---|---|---|
radius |
0.5 | 检索阈值,越高越严格 |
limit |
20 | 检索返回条数 |
metric_type |
COSINE | 余弦相似度 |
temperature |
0.3 | LLM温度值,偏低以保持准确 |
知识库去重时使用 radius=0.87,确保高语义相似的问题不会重复入库。
9.3 选择bge-m3的原因
- 中英文混合支持:bge-m3 多语言效果好
- 维度适中:1024维,Milvus检索效率高
- 本地部署零成本:通过Ollama本地运行,无需API调用
十、效果展示
用户提问示例:
用户: "职工缴费年限不够,怎么办"
系统回答:
- 从知识库检索到Top-20相关问题及答案
- DeepSeek基于上下文生成结构化回答
- 自动推荐3个相关问题:如"养老保险缴费标准是什么"、"灵活就业人员如何参保"等
十一、优化方向
- Prompt输出结构化 :当前使用
<separate>分隔符解析推荐问题,可改为JSON格式输出更稳定 - Rerank重排序:在Milvus检索后增加Rerank模型(如bge-reranker),提升Top-K准确率
- 对话历史管理:当前为单轮对话,可增加session管理实现多轮上下文
- 缓存层:高频问题可加Redis缓存,减少重复的Embedding和LLM调用
- 知识库更新机制:定时从业务系统同步最新问答数据
十二、总结
本项目通过 Ollama本地Embedding + Milvus向量检索 + DeepSeek推理 的RAG架构,实现了:
- 零Embedding API成本的语义检索
- 基于知识库的准确回答(减少幻觉)
- 流式输出 + 语音交互的良好用户体验
整套方案技术栈开源、部署简单,适合政府、企业等有私有知识库的智能客服场景。
相关技术文档
- Milvus官方文档:https://milvus.io/docs
- Ollama官方文档:https://ollama.com
- DeepSeek API文档:https://platform.deepseek.com/api-docs
- bge-m3模型:https://huggingface.co/BAAI/bge-m3