约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集合存储每条热线问题的向量frompymilvusimportMilvusClient clientMilvusClient(urihttp://your_milvus_host:19530,tokenyour_token,db_namedefault)# 集合结构uid(主键) Question(原文) embeddings_Q(向量, 1024维)向量用本地Ollama的bge-m3模型生成零API费用defvectorize_text(text):urlhttp://localhost:11434/api/embeddingsdata{model:bge-m3:latest,prompt:text}responserequests.post(url,jsondata)ifresponse.status_code200:returnresponse.json().get(embedding,[])returnNone去重主流程search_params{metric_type:COSINE,params:{radius:0.9}}getedlist[]fileopen(d:\\q\\问题分类.txt,w,encodingutf-8)foriinrange(1,93936):# 取第i条问题的向量和原文qsclient.get(collection_namehotline_questions,ids[i],output_fields[uid,Question,embeddings_Q])vqs[0][embeddings_Q]uidqs[0][uid]# 已归类过的跳过ifuidingetedlist:continue# 以当前问题为锚点搜索相似问题resclient.search(collection_namehotline_questions,data[v],limit10000,output_fields[uid,Question],search_paramssearch_params)# 所有命中的都标记为已归类forjinrange(0,res[0].__len__()):uid2res[0][j][entity][uid]getedlist.append(uid2)# 输出锚点uid, 命中uid, 相似度, 原文file.write(str(uid)\tstr(res[0][j][entity][uid])\tstr(res[0][j][distance])\tres[0][j][entity][Question]\n)阈值是怎么定下来的不是一次定好的是跑出来的。先给0.9跑一批看结果发现漏了不少降到0.85好多了再降到0.8试试开始出误合并。每个数据集的最佳阈值不一样没法抄别人的只能自己试。代码里把这个过程固化了以0.9起步看命中的数量——如果命中太少10条说明这个锚点表述比较特殊自动放宽到0.85再搜一轮如果还是太少5条放宽到0.8r0.9ifres[0].__len__()10:r0.85search_params[params][radius]r resclient.search(...)# 放宽再搜ifres[0].__len__()5:r0.8search_params[params][radius]r resclient.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。后续处理时每组取一条作为代表其余标记为重复。关键参数说明参数值为什么radius0.9 / 0.85 / 0.8三级递减先紧后松limit10000热门问题相似条目多limit要大metric_typeCOSINE余弦相似度语义比对的标准选择Embedding模型bge-m3中英文混合1024维本地Ollama部署零成本踩坑1. Milvus的radius不是大于是大于等于radius0.9意味着相似度≥0.9的都会返回。别理解反了。2. 已归类的必须跳过getedlist就是干这个的。94万条逐条做锚点如果不跳过已归类的同一个问题会被多次当锚点重复检索。虽然结果一样但耗时翻倍。3. limit要给够热门政策比如社保缴费相关的问题可能有几千条。limit10000确保不遗漏。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以下需要人工复核这是语义边界算法搞不定