## 第一章:召回问题的定位

### 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,能用。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐