#RAG系统混合检索
## 第一章:召回问题的定位
### 1.1 问题现象
上线第一周,抽样分析200条失败案例(用户点了"无用"),发现:
| 失败类型 | 占比 | 典型表现 |
|---------|------|---------|
| 完全未召回正确文档 | 43% | 正确答案在库里,但没被检索到 |
| 召回但排序过低 | 31% | top10里有正确答案,但排在第8-10位 |
| 知识库本身缺失 | 18% | 文档不全 |
| 生成环节错误 | 8% | 召回了对的,LLM答错了 |
前两项合计74%,问题出在检索环节。
### 1.2 单路召回测试
先测纯向量召回(bge-large-zh-v1.5,cosine相似度):
```
测试集:500条人工标注问答对
k=10,Hit@10=0.78,Hit@5=0.65
```
再测纯BM25(Elasticsearch默认分词+tf-idf):
```
测试集同源
k=10,Hit@10=0.71,Hit@5=0.58
```
差距不大,但合并之后有望提升。关键在于两条路的top结果重合度:
```
向量召回top20 ∩ BM25召回top20 平均重合度 = 37%
```
重合度不高,说明两条路有互补性,适合做混合检索。
## 第二章:混合检索实现
### 2.1 ES配置
Elasticsearch 8.11,索引mapping:
```json
{
"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
}
}
}
```
m=16,ef_construction=100是平衡召回率和索引速度的参数,实测这个配置下索引速度约800条/秒,召回率比flat检索下降不到1%。
### 2.2 混合检索查询语法
ES 8.11支持在同一个查询里组合dense_vector和传统query:
```python
def 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(body=es_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 权重调优
混合检索两个子句的权重需要调。做了网格搜索:
```python
weights = [(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条测试集上计算Hit@5
```
结果:
| BM25权重 | 向量权重 | Hit@5 |
|---------|---------|-------|
| 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。
意图分类用了一个简单的规则+小模型组合:
```python
def 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%不影响整体。
混合检索+动态权重后,Hit@5从65%提升到78%。
## 第三章:重排序(Rerank)
混合检索能到78%,但目标是要上85%。后面的提升来自重排序。
### 3.1 Cross-Encoder重排序
用bge-reranker-large,把query和每个候选document拼接输入:
```python
from transformers import AutoModelForSequenceClassification, AutoTokenizer
class Reranker:
def __init__(self, model_name="BAAI/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,
padding=True,
truncation=True,
max_length=512,
return_tensors="pt"
)
with torch.no_grad():
scores = self.model(**inputs).logits.squeeze().tolist()
# 按分数降序排序
sorted_pairs = sorted(
zip(documents[:30], scores),
key=lambda x: x[1],
reverse=True
)
return [doc for doc, _ in sorted_pairs[:top_k]]
```
**关键参数**:max_length=512。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_size=8时效率最高,再往上边际收益递减。
```python
# batch推理写法
batch_size = 8
all_scores = []
for i in range(0, len(pairs), batch_size):
batch = pairs[i:i+batch_size]
inputs = tokenizer(batch, padding=True, truncation=True, max_length=512, return_tensors="pt")
scores = model(**inputs).logits.squeeze()
all_scores.extend(scores.tolist() if len(scores) > 1 else [scores.item()])
```
**方案二:候选池缩到20。** 30个候选减少到20个,rerank延迟降到0.25秒。测试显示候选池从30缩到20,Hit@5只降了0.5%,属于可接受范围。
**方案三:缓存频繁query的rerank结果。** 统计发现32%的查询是重复的(员工反复问同样的高频问题)。加Redis缓存,命中后直接返回,命中率约28%。
### 3.3 Rerank效果
500条测试集,各阶段Hit@5对比:
| 阶段 | Hit@5 | 平均延迟 |
|------|-------|---------|
| 纯向量 | 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。上线后检索Hit@5再提升4个百分点,达到0.89。
### 4.2 Query扩展
另一种做法是生成多个变体分别检索,合并结果:
```python
def expand_query(query: str) -> List[str]:
prompt = f"生成5个与原问题语义相似但表述不同的检索问句:\n原问题:{query}"
variants = llm.generate(prompt)
return [query] + variants
# 多个变体分别检索,合并去重,取top_k
all_results = []
for q in expanded_queries:
all_results.extend(search(q))
# 用RRF(Reciprocal Rank Fusion)合并排序
```
RRF公式:
```python
def 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(), key=lambda x: scores[x], reverse=True)
```
Query扩展+RRF在测试集上比单query检索再提升2个百分点,但延迟增加了3倍。生产环境只对高优先级请求启用,普通请求不开启。
## 第五章:工程化要点
### 5.1 索引更新策略
文档变更场景:
- 新增:异步入库,10分钟内可见
- 修改:先删旧embedding,再插入新的,用version字段做乐观锁
- 删除:软删除(is_valid=false),定期物理清理
```python
def update_document(doc_id: str, new_content: str):
# 1. 标记旧版本为无效
es.update(index="knowledge", id=doc_id, body={"doc": {"is_valid": False}})
# 2. 生成新embedding
new_embedding = embedding_model.encode(new_content).tolist()
# 3. 插入新文档,版本号+1
es.index(index="knowledge", 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 监控指标
生产环境必须监控三个数字:
| 指标 | 告警阈值 | 用途 |
|------|---------|------|
| Hit@5(离线抽样) | <0.85 | 召回质量 |
| P99延迟 | >3s | 用户体验 |
| 空回答率 | >15%或波动>5% | 幻觉/检索异常 |
空回答率是一个被低估的指标。如果某天空回答率从8%突然降到2%,大概率是LLM开始幻觉了,需要立即检查。
### 5.3 降级方案
ES集群挂了怎么办?准备了一个降级方案:
```python
def search_with_fallback(query: str):
try:
return es_search(query)
except ElasticsearchException:
# 降级到本地向量库(用faiss加载备份的embedding)
return faiss_search(query)
```
faiss本地索引每天凌晨从ES同步一次,查询延迟约100ms,召回率约0.75,能用。
更多推荐
所有评论(0)