约94万条热线问题怎么去重?动态相似度阈值+Milvus,不用LLM一毛钱
背景
政务热线每天的来电都会转成文字记录。积累了几年下来,问题库动辄几十万条。拿来做RAG知识库之前,必须先去重------不然"养老保险怎么交"和"养老金怎么缴纳"是同一条知识,存两份就浪费,检索时还可能把两个相似但略有不同的答案都捞出来,LLM拿到矛盾的上下文就开始胡编。
问题来了:94万条问题,怎么去重?
方案对比
| 方案 | 思路 | 问题 |
|---|---|---|
| 关键词去重 | 分词后比关键词重叠度 | "社保卡丢了怎么办"和"社会保障卡遗失如何补办"零重叠,但语义完全一样 |
| 编辑距离 | 字符串相似度 | 94万×94万的比对矩阵,算到明年 |
| LLM分类 | 让大模型判断两条是否重复 | 94万条,每条调一次API,费用上千,速度还慢 |
| 向量相似度聚类 | Embedding + Milvus余弦相似度 | 本地部署零成本,毫秒级检索 |
选第四条路。
核心思路:给个值,跑跑看,效果不够再降
94万条问题不可能一开始就定好阈值。实际过程是这样的:
第一次跑,给了0.9。 结果出来一看,"社保卡丢了怎么办"和"社保卡丢了咋办"确实合并了,但"社保卡丢失如何补办"没进来------表述差异大了一点点,相似度掉到0.88,被0.9的阈值挡住了。
降到0.85再跑。 这回好多了,表述差异大的也进来了。但又发现新的问题------有些命中的条目看一眼就知道不是同一个意思,但相似度偏偏过了0.85。这说明0.85在这个数据集上是临界点:往上安全但漏得多,往下能捞到更多但开始混入误合并。
再降到0.8试了试。 误合并明显增多,"社保卡丢了"和"医保卡丢了"开始混在一起。这俩在热线里是不同的业务,不能合并。
所以最终结论不是"设计了三级阈值",是试出来的:
- 0.9:安全但保守,漏掉不少
- 0.85:最佳平衡点,大部分重复能抓到,误合并少
- 0.8:再往下就危险了,必须人工复核
代码里的三级递减不是一开始就写好的,是先跑0.9,看命中的条目数------如果太少(<10条),说明这个锚点的问题比较特殊,表述差异大,自动放宽到0.85再搜;如果还是太少(<5条),放宽到0.8,但这批结果要人工看。
实现
数据结构
Milvus集合存储每条热线问题的向量:
python
from pymilvus import MilvusClient
client = MilvusClient(
uri="http://your_milvus_host:19530",
token="your_token",
db_name="default"
)
# 集合结构:uid(主键) + Question(原文) + embeddings_Q(向量, 1024维)
向量用本地Ollama的bge-m3模型生成,零API费用:
python
def vectorize_text(text):
url = "http://localhost:11434/api/embeddings"
data = {"model": "bge-m3:latest", "prompt": text}
response = requests.post(url, json=data)
if response.status_code == 200:
return response.json().get('embedding', [])
return None
去重主流程
python
search_params = {"metric_type": "COSINE", "params": {"radius": 0.9}}
getedlist = []
file = open("d:\\q\\问题分类.txt", "w", encoding="utf-8")
for i in range(1, 93936):
# 取第i条问题的向量和原文
qs = client.get(
collection_name="hotline_questions",
ids=[i],
output_fields=["uid", "Question", "embeddings_Q"]
)
v = qs[0]["embeddings_Q"]
uid = qs[0]["uid"]
# 已归类过的跳过
if uid in getedlist:
continue
# 以当前问题为锚点,搜索相似问题
res = client.search(
collection_name="hotline_questions",
data=[v],
limit=10000,
output_fields=["uid", "Question"],
search_params=search_params
)
# 所有命中的都标记为已归类
for j in range(0, res[0].__len__()):
uid2 = res[0][j]["entity"]["uid"]
getedlist.append(uid2)
# 输出:锚点uid, 命中uid, 相似度, 原文
file.write(
str(uid) + "\t" +
str(res[0][j]["entity"]["uid"]) + "\t" +
str(res[0][j]["distance"]) + "\t" +
res[0][j]["entity"]["Question"] + "\n"
)
阈值是怎么定下来的
不是一次定好的,是跑出来的。先给0.9,跑一批看结果,发现漏了不少;降到0.85,好多了;再降到0.8试试,开始出误合并。每个数据集的最佳阈值不一样,没法抄别人的,只能自己试。
代码里把这个过程固化了:以0.9起步,看命中的数量------如果命中太少(<10条),说明这个锚点表述比较特殊,自动放宽到0.85再搜一轮;如果还是太少(<5条),放宽到0.8:
python
r = 0.9
if res[0].__len__() < 10:
r = 0.85
search_params["params"]["radius"] = r
res = client.search(...) # 放宽再搜
if res[0].__len__() < 5:
r = 0.8
search_params["params"]["radius"] = r
res = client.search(...) # 再放宽
<10和<5这两个触发条件,也是试出来的。先跑了一批数据看分布,大部分问题在0.9能命中几十条,少数冷门问题才不到10条。所以拿10作为分界线。
实际跑下来,94万条问题中:
- 0.9档吃掉了大部分重复------换个说法的同一问题
- 0.85档吃掉了表述差异更大的重复------口语化vs书面化的区别
- 0.8档需要人工复核------这个区间里开始混入语义相近但实际不同的问题
输出格式
每行四列:锚点uid\t命中uid\t相似度\t原文
1 1 1.000 养老保险怎么交
1 5234 0.943 养老保险如何缴纳
1 18762 0.912 养老保险缴费方式
1 45021 0.901 怎么交养老保险
同一组的都指向同一个锚点uid。后续处理时,每组取一条作为代表,其余标记为重复。
关键参数说明
| 参数 | 值 | 为什么 |
|---|---|---|
radius |
0.9 / 0.85 / 0.8 | 三级递减,先紧后松 |
limit |
10000 | 热门问题相似条目多,limit要大 |
metric_type |
COSINE | 余弦相似度,语义比对的标准选择 |
| Embedding模型 | bge-m3 | 中英文混合,1024维,本地Ollama部署零成本 |
踩坑
1. Milvus的radius不是"大于",是"大于等于"
radius=0.9意味着相似度≥0.9的都会返回。别理解反了。
2. 已归类的必须跳过
getedlist就是干这个的。94万条逐条做锚点,如果不跳过已归类的,同一个问题会被多次当锚点,重复检索。虽然结果一样,但耗时翻倍。
3. limit要给够
热门政策(比如社保缴费)相关的问题可能有几千条。limit=10000确保不遗漏。Milvus的IVF_FLAT索引在这个量级下性能没问题。
4. 0.8档必须人工复核
相似度0.8是个分水岭。低于0.8,"社保卡丢了"和"医保卡丢了"可能被合并------这俩在热线里是不同的业务。高于0.85基本安全。0.8-0.85之间是灰色地带,必须人工看。
费用
- Embedding:本地Ollama + bge-m3,零费用
- Milvus检索:自部署,零费用
- LLM调用:零------全程不用大模型,纯向量相似度搞定
94万条问题去重,唯一成本就是一台跑Ollama的机器(8G显存够了)和Milvus服务器。
总结
- 94万条热线问题去重,核心策略是"先紧后松"的动态相似度阈值
- 0.9→0.85→0.8三级递减,大部分重复在前两级就解决了
- 全程不用LLM,纯向量相似度,零API费用
- 0.8以下需要人工复核,这是语义边界,算法搞不定