#RAG系统混合检索
## 第一章召回问题的定位### 1.1 问题现象上线第一周抽样分析200条失败案例用户点了无用发现| 失败类型 | 占比 | 典型表现 ||---------|------|---------|| 完全未召回正确文档 | 43% | 正确答案在库里但没被检索到 || 召回但排序过低 | 31% | top10里有正确答案但排在第8-10位 || 知识库本身缺失 | 18% | 文档不全 || 生成环节错误 | 8% | 召回了对的LLM答错了 |前两项合计74%问题出在检索环节。### 1.2 单路召回测试先测纯向量召回bge-large-zh-v1.5cosine相似度测试集500条人工标注问答对k10Hit100.78Hit50.65再测纯BM25Elasticsearch默认分词tf-idf测试集同源k10Hit100.71Hit50.58差距不大但合并之后有望提升。关键在于两条路的top结果重合度向量召回top20 ∩ BM25召回top20 平均重合度 37%重合度不高说明两条路有互补性适合做混合检索。## 第二章混合检索实现### 2.1 ES配置Elasticsearch 8.11索引mappingjson{mappings: {properties: {chunk_content: {type: text,analyzer: ik_max_word,fields: {keyword: {type: keyword, ignore_above: 256}}},chunk_embedding: {type: dense_vector,dims: 1024,index: true,similarity: cosine},doc_id: {type: keyword},parent_id: {type: keyword},title: {type: text, analyzer: ik_smart},category: {type: keyword},update_time: {type: date},is_valid: {type: boolean}}}}ES的dense_vector在8.11以上版本支持HNSW索引建索引时指定json{chunk_embedding: {type: dense_vector,dims: 1024,index: true,similarity: cosine,index_options: {type: hnsw,m: 16,ef_construction: 100}}}m16ef_construction100是平衡召回率和索引速度的参数实测这个配置下索引速度约800条/秒召回率比flat检索下降不到1%。### 2.2 混合检索查询语法ES 8.11支持在同一个查询里组合dense_vector和传统querypythondef hybrid_search(query: str, embedding: list, size: int 30):es_query {size: size,query: {bool: {should: [{match: {chunk_content: {query: query,boost: 1.0}}},{script_score: {query: {match_all: {}},script: {source: cosineSimilarity(params.query_vector, chunk_embedding) 1.0,params: {query_vector: embedding}},boost: 1.0}}]}}}return es.search(bodyes_query)两个should子句各自算分ES内部做归一化后相加。这里cosineSimilarity返回值范围是[-1,1]加1.0为了对齐到[0,2]区间。**踩坑提醒**script_score方式会对全库做cosine计算数据量大了之后单次查询可能超过30秒。解决办法是给vector查询加一个knn子句做预过滤或者用ES原生的knn查询方式json{knn: {field: chunk_embedding,query_vector: embedding,k: 50,num_candidates: 200},query: {match: {chunk_content: query}}}knn方式利用HNSW索引做近似搜索num_candidates控制候选池大小实测50万条数据下查询延迟从8秒降到400ms。### 2.3 权重调优混合检索两个子句的权重需要调。做了网格搜索pythonweights [(0.3,0.7), (0.4,0.6), (0.5,0.5), (0.6,0.4), (0.7,0.3)]for w1, w2 in weights:# 设置BM25权重为w1, 向量权重为w2# 在500条测试集上计算Hit5结果| BM25权重 | 向量权重 | Hit5 ||---------|---------|-------|| 0.3 | 0.7 | 0.76 || 0.4 | 0.6 | 0.79 || 0.5 | 0.5 | 0.80 || 0.6 | 0.4 | 0.79 || 0.7 | 0.3 | 0.74 |0.5:0.5效果最好但优势不明显。最终采用动态权重对query做意图分类偏精确匹配的关键词类问题提高BM25权重到0.7偏语义类问题向量权重提到0.7。意图分类用了一个简单的规则小模型组合pythondef classify_query(query: str) - str:# 规则1包含数字编号 → 精确匹配类if re.search(r[A-Z]{2,}-\d{4,}, query):return keyword# 规则2包含怎么如何为什么 → 语义类if any(w in query for w in [怎么, 如何, 为什么]):return semantic# 其他情况用分类器return classifier.predict(query) # 用一个轻量fasttext模型分类准确率约91%剩下的9%不影响整体。混合检索动态权重后Hit5从65%提升到78%。## 第三章重排序Rerank混合检索能到78%但目标是要上85%。后面的提升来自重排序。### 3.1 Cross-Encoder重排序用bge-reranker-large把query和每个候选document拼接输入pythonfrom transformers import AutoModelForSequenceClassification, AutoTokenizerclass Reranker:def __init__(self, model_nameBAAI/bge-reranker-large):self.tokenizer AutoTokenizer.from_pretrained(model_name)self.model AutoModelForSequenceClassification.from_pretrained(model_name)self.model.eval()def rerank(self, query: str, documents: List[str], top_k: int 5):pairs [[query, doc] for doc in documents[:30]] # 只rerank前30个inputs self.tokenizer(pairs,paddingTrue,truncationTrue,max_length512,return_tensorspt)with torch.no_grad():scores self.model(**inputs).logits.squeeze().tolist()# 按分数降序排序sorted_pairs sorted(zip(documents[:30], scores),keylambda x: x[1],reverseTrue)return [doc for doc, _ in sorted_pairs[:top_k]]**关键参数**max_length512。bge-reranker-large的最大长度是512过长的document会被截断。这要求我们在检索阶段返回的候选文本本身不要超过512字符——也就是前文说的子chunk控制在200-300字在这个环节刚好够用。### 3.2 延迟优化Cross-Encoder的问题是慢。在A10上跑30对pair需要约1.2秒用户感知明显。优化方案**方案一批处理。** 单次推理改成batch推理30对一起过延迟从1.2秒降到0.4秒。PyTorch的批处理能充分利用GPU并行能力batch_size8时效率最高再往上边际收益递减。python# batch推理写法batch_size 8all_scores []for i in range(0, len(pairs), batch_size):batch pairs[i:ibatch_size]inputs tokenizer(batch, paddingTrue, truncationTrue, max_length512, return_tensorspt)scores model(**inputs).logits.squeeze()all_scores.extend(scores.tolist() if len(scores) 1 else [scores.item()])**方案二候选池缩到20。** 30个候选减少到20个rerank延迟降到0.25秒。测试显示候选池从30缩到20Hit5只降了0.5%属于可接受范围。**方案三缓存频繁query的rerank结果。** 统计发现32%的查询是重复的员工反复问同样的高频问题。加Redis缓存命中后直接返回命中率约28%。### 3.3 Rerank效果500条测试集各阶段Hit5对比| 阶段 | Hit5 | 平均延迟 ||------|-------|---------|| 纯向量 | 0.65 | 200ms || 混合检索(无rerank) | 0.78 | 350ms || 混合检索rerank | 0.87 | 600ms || 缓存后的实际平均 | 0.87 | 430ms |到0.87达到目标。## 第四章Query侧优化召回做完了还有一招**改Query不改索引。**### 4.1 Query改写用户原始query质量参差不齐。典型bad case| 原始query | 问题 | 改写后 ||----------|------|--------|| 那个机器坏了 | 指代不明 | CNC-MC2023主轴驱动故障 || 温度高 | 信息不足 | 热处理炉炉膛温度超限报警处理 || 咋弄 | 无有效信息 | 拒绝改写返回引导语 |用Qwen2-7B做改写prompt你是一个query改写助手。将用户的口语化问题改写为适合检索的规范化问句。规则1. 如果问题包含明确的设备编号或故障代码保留并突出2. 补全省略的主语和宾语3. 去除语气词和无意义的口语表达4. 如果问题信息量太少少于3个实词返回INSUFFICIENT用户输入{query}仅输出改写后的问句不要有其他内容。这个模块用vLLM部署平均推理时间80ms。上线后检索Hit5再提升4个百分点达到0.89。### 4.2 Query扩展另一种做法是生成多个变体分别检索合并结果pythondef expand_query(query: str) - List[str]:prompt f生成5个与原问题语义相似但表述不同的检索问句\n原问题{query}variants llm.generate(prompt)return [query] variants# 多个变体分别检索合并去重取top_kall_results []for q in expanded_queries:all_results.extend(search(q))# 用RRF(Reciprocal Rank Fusion)合并排序RRF公式pythondef rrf(results_lists: List[List[str]], k: int 60) - List[str]:scores {}for results in results_lists:for rank, doc_id in enumerate(results):scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank 1)return sorted(scores.keys(), keylambda x: scores[x], reverseTrue)Query扩展RRF在测试集上比单query检索再提升2个百分点但延迟增加了3倍。生产环境只对高优先级请求启用普通请求不开启。## 第五章工程化要点### 5.1 索引更新策略文档变更场景- 新增异步入库10分钟内可见- 修改先删旧embedding再插入新的用version字段做乐观锁- 删除软删除is_validfalse定期物理清理pythondef update_document(doc_id: str, new_content: str):# 1. 标记旧版本为无效es.update(indexknowledge, iddoc_id, body{doc: {is_valid: False}})# 2. 生成新embeddingnew_embedding embedding_model.encode(new_content).tolist()# 3. 插入新文档版本号1es.index(indexknowledge, body{doc_id: doc_id,chunk_content: new_content,chunk_embedding: new_embedding,version: get_current_version(doc_id) 1,is_valid: True,update_time: datetime.now()})### 5.2 监控指标生产环境必须监控三个数字| 指标 | 告警阈值 | 用途 ||------|---------|------|| Hit5(离线抽样) | 0.85 | 召回质量 || P99延迟 | 3s | 用户体验 || 空回答率 | 15%或波动5% | 幻觉/检索异常 |空回答率是一个被低估的指标。如果某天空回答率从8%突然降到2%大概率是LLM开始幻觉了需要立即检查。### 5.3 降级方案ES集群挂了怎么办准备了一个降级方案pythondef search_with_fallback(query: str):try:return es_search(query)except ElasticsearchException:# 降级到本地向量库用faiss加载备份的embeddingreturn faiss_search(query)faiss本地索引每天凌晨从ES同步一次查询延迟约100ms召回率约0.75能用。