## 第一章:Embedding模型选型

### 1.1 为什么要自己部署

很多人直接调API,省事。但企业场景下有几个现实问题:

第一,数据不能出内网。客户的文档涉及设备参数、产线布局、质量数据,都不能传到公网API。

第二,成本问题。日均4000次查询,如果用OpenAI的text-embedding-3-small,一个月光向量化就要2000多块。自己部署一次投入,长期划算。

第三,自由度。自己部署可以随时换模型、调参、做定制化。

### 1.2 候选模型测试

我们测试了市面上主流的中文Embedding模型:

| 模型 | 参数量 | 维度 | 推理延迟(ms) | 显存占用 | MTEB中文得分 |
|------|--------|------|-------------|---------|-------------|
| text2vec-large-chinese | 326M | 768 | 45 | 2.1GB | 62.3 |
| m3e-base | 110M | 768 | 32 | 1.8GB | 64.5 |
| bge-large-zh-v1.5 | 326M | 1024 | 52 | 2.8GB | 67.8 |
| bge-m3 | 567M | 1024 | 78 | 4.2GB | 68.5 |
| Qwen-embedding | 1.5B | 1536 | 120 | 6.5GB | 69.2 |
| gte-large-zh | 326M | 1024 | 50 | 2.6GB | 68.1 |

**测试方法**:用我们自己的业务数据(2000条标注问答对)做recall测试,chunk数量50万。

**结果**:

| 模型 | Hit@10 | Hit@5 | MRR |
|------|--------|-------|-----|
| text2vec-large-chinese | 0.72 | 0.61 | 0.58 |
| m3e-base | 0.76 | 0.64 | 0.62 |
| bge-large-zh-v1.5 | 0.84 | 0.73 | 0.70 |
| bge-m3 | 0.85 | 0.74 | 0.71 |
| Qwen-embedding | 0.86 | 0.75 | 0.72 |
| gte-large-zh | 0.85 | 0.74 | 0.71 |

**结论**:bge-large-zh-v1.5性价比最高。bge-m3和Qwen-embedding效果稍好但资源消耗大太多,不划算。最终选了bge-large-zh-v1.5。

### 1.3 踩坑记录

**坑一:模型加载时的tokenizer问题**

bge-large-zh-v1.5的tokenizer默认max_length是512,但我们的chunk平均486字符(约350个token),问题不大。但有些长chunk超过512会被truncate,导致语义丢失。

解决方案:加载时调整max_length:

```python
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-large-zh-v1.5")
tokenizer.model_max_length = 1024  # 改成1024
```

但要注意,改大max_length会影响推理速度,且模型训练时用的就是512,超过512的embedding质量会下降。最终我们保持512,同时在切分时就确保chunk不超过512token。

**坑二:batch推理的显存优化**

单条推理太慢,必须batch。但batch_size设大了显存溢出。

```python
def encode_batch(texts: List[str], batch_size: int = 64) -> List[List[float]]:
    embeddings = []
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i+batch_size]
        # 对batch做padding
        inputs = tokenizer(batch, padding=True, truncation=True, max_length=512, return_tensors="pt")
        inputs = {k: v.to(device) for k, v in inputs.items()}
        
        with torch.no_grad():
            outputs = model(**inputs)
            # 取[CLS] token的embedding
            batch_embeddings = outputs.last_hidden_state[:, 0, :].cpu().numpy()
            # L2归一化
            batch_embeddings = batch_embeddings / np.linalg.norm(batch_embeddings, axis=1, keepdims=True)
            embeddings.extend(batch_embeddings.tolist())
    
    return embeddings
```

A10 24G显存下,batch_size=64刚好。设128会OOM。

**坑三:混合精度带来的精度损失**

用fp16加速能省一半显存,但召回率会下降约1.5%。我们选择用fp32,宁可慢一点也要保证召回。

## 第二章:向量索引方案选型

### 2.1 选项评估

7000万chunk(去重后),每个向量1024维,float32存储,原始大小:

```
70,000,000 × 1024 × 4 bytes = 286 GB
```

这还没算索引结构。单机扛不住,必须做索引压缩或分布式。

评估了三个方案:

| 方案 | 优点 | 缺点 | 适用场景 |
|------|------|------|---------|
| ES + HNSW | 运维统一,支持混合检索 | 索引重建慢,内存消耗大 | <1亿向量 |
| Milvus | 功能全,性能好 | 独立部署,运维成本高 | 大规模生产 |
| Faiss + Redis | 轻量,灵活 | 功能单一,需要自己写服务 | 中小规模 |

我们选了**ES + HNSW**,原因很简单:团队对ES最熟,而且ES的dense_vector从8.11开始支持HNSW索引,同时还能做BM25关键词检索,混合检索天然支持。

### 2.2 ES索引配置

完整索引mapping:

```json
{
  "settings": {
    "number_of_shards": 6,
    "number_of_replicas": 1,
    "refresh_interval": "60s",
    "analysis": {
      "analyzer": {
        "ik_max_analyzer": {
          "type": "ik_max_word"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "chunk_content": {
        "type": "text",
        "analyzer": "ik_max_analyzer",
        "fields": {
          "keyword": {"type": "keyword", "ignore_above": 512}
        }
      },
      "chunk_embedding": {
        "type": "dense_vector",
        "dims": 1024,
        "index": true,
        "similarity": "cosine",
        "index_options": {
          "type": "hnsw",
          "m": 16,
          "ef_construction": 100
        }
      },
      "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"}
    }
  }
}
```

**分片数为什么是6**:数据量约7000万条,单个分片建议不超过5000万,6个分片每个约1167万,合理。分片数=节点数×1.5,我们4个节点,6个分片正好。

**m和ef_construction参数**:
- m=16:每个节点连接数,越大召回率越高但内存越大
- ef_construction=100:构建时搜索宽度,越大索引质量越高但构建越慢

我们测试了不同参数组合:

| m | ef_construction | 召回率@10 | 索引大小 | 构建时间 |
|---|----------------|----------|---------|---------|
| 8 | 50 | 0.92 | 340GB | 6h |
| 16 | 100 | 0.95 | 410GB | 12h |
| 32 | 200 | 0.96 | 520GB | 24h |

选了m=16, ef_construction=100,平衡各方面。

### 2.3 索引构建优化

7000万向量构建索引是个大工程。几个优化点:

**第一,批量写入**:

```python
def bulk_index(chunks: List[Dict], batch_size: int = 1000):
    for i in range(0, len(chunks), batch_size):
        batch = chunks[i:i+batch_size]
        actions = []
        for chunk in batch:
            actions.append({
                "_index": "knowledge_base",
                "_id": chunk['id'],
                "_source": {
                    "chunk_content": chunk['content'],
                    "chunk_embedding": chunk['embedding'],
                    "doc_id": chunk['doc_id'],
                    "category": chunk['category'],
                    "update_time": datetime.now().isoformat()
                }
            })
        
        # 批量写入
        helpers.bulk(es_client, actions, request_timeout=60)
```

**第二,关闭refresh和translog**:

索引构建时,refresh间隔调大,translog改成异步:

```json
{
  "settings": {
    "refresh_interval": "-1",
    "translog.durability": "async",
    "translog.sync_interval": "60s"
  }
}
```

构建完再改回来。这个调整让写入速度从每秒500条提升到3000条。

**第三,分段构建**:

7000万条一次性建索引风险太大。我们按文档类型分批,每批1000万条,建完再合并。中间某个批次失败了不会影响全局。

### 2.4 查询优化

线上查询的优化点:

**使用knn查询替代script_score**:

旧方案:

```json
{
  "script_score": {
    "script": {
      "source": "cosineSimilarity(params.query_vector, 'chunk_embedding')"
    }
  }
}
```

这个方式会遍历全库计算cosine,7000万条数据下耗时8-10秒。

新方案用原生knn:

```json
{
  "knn": {
    "field": "chunk_embedding",
    "query_vector": query_embedding,
    "k": 50,
    "num_candidates": 200
  }
}
```

num_candidates控制候选池大小。200意味着先找200个近似候选,再从中取50个。比全库计算快几十倍。

测试不同num_candidates的召回率:

| num_candidates | 查询延迟 | Hit@10召回率 |
|---------------|---------|------------|
| 100 | 80ms | 0.91 |
| 200 | 120ms | 0.94 |
| 500 | 250ms | 0.95 |

200是最佳平衡点。

**混合检索的查询写法**:

```json
{
  "size": 30,
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "chunk_content": {
              "query": user_query,
              "boost": 0.5
            }
          }
        }
      ]
    }
  },
  "knn": {
    "field": "chunk_embedding",
    "query_vector": query_embedding,
    "k": 30,
    "num_candidates": 200,
    "boost": 0.5
  },
  "rank": {
    "rrf": {
      "window_size": 30,
      "rank_constant": 60
    }
  }
}
```

ES 8.11支持在knn查询里直接设置boost,用RRF做结果融合。

## 第三章:冷热数据分离

### 3.1 数据分层

我们观察到一个规律:用户查询集中在最近2年的文档上。3年前的老文档只有极少数情况被问到。

因此做了冷热分离:

| 层级 | 数据范围 | 查询占比 | 存储方式 |
|------|---------|---------|---------|
| 热数据 | 近2年文档 | 85% | ES主索引,SSD |
| 温数据 | 2-5年文档 | 12% | ES索引,HDD |
| 冷数据 | 5年以上 | 3% | 离线存储,不建向量索引,仅保留原始文档 |

热数据放在SSD节点,温数据放在HDD节点。查询时先查热索引,没结果再查温索引。

### 3.2 索引别名切换

用ES的index alias做无缝切换:

```python
def switch_to_new_index(new_index: str):
    # 原子操作:移除旧别名,添加新别名
    actions = [
        {"remove": {"index": "knowledge_v1", "alias": "knowledge"}},
        {"add": {"index": new_index, "alias": "knowledge"}}
    ]
    es_client.indices.update_aliases({"actions": actions})
```

重建索引时,先建新索引,切换别名,再删旧索引。对业务无感知。

## 第四章:性能优化实战

### 4.1 延迟分布

分析10000次查询的延迟构成:

| 阶段 | 平均耗时 | 占比 |
|------|---------|------|
| Query改写 | 80ms | 14% |
| 向量化 | 45ms | 8% |
| ES检索(knn) | 120ms | 21% |
| ES检索(BM25) | 80ms | 14% |
| 重排序 | 250ms | 43% |
| LLM生成 | 因流式不统计 | - |
| 其他 | 5ms | 1% |
| **总计** | **580ms** | **100%** |

重排序占了大头。优化措施:

**方案一:候选池缩减**

重排序的输入从30个减到20个,延迟从250ms降到180ms,Hit@5下降0.5%。

**方案二:缓存高频query的rerank结果**

用Redis缓存,key是query的hash,value是rerank后的top5文档ID。缓存命中率约25%,命中时直接跳过rerank。

```python
def search_with_cache(query: str):
    cache_key = hashlib.md5(query.encode()).hexdigest()
    cached = redis.get(cache_key)
    if cached:
        return json.loads(cached)
    
    result = full_pipeline(query)
    redis.setex(cache_key, 3600, json.dumps(result))  # 缓存1小时
    return result
```

### 4.2 并发处理

高并发场景下ES容易扛不住。在ES前面加了一层查询路由:

```python
class QueryRouter:
    def __init__(self):
        self.es_pool = [
            Elasticsearch(["es-node1:9200"]),
            Elasticsearch(["es-node2:9200"]),
            Elasticsearch(["es-node3:9200"]),
        ]
        self.counter = 0
    
    def route(self, query_body: dict) -> dict:
        # 轮询
        idx = self.counter % len(self.es_pool)
        self.counter += 1
        return self.es_pool[idx].search(index="knowledge", body=query_body)
```

简单轮询,没有复杂的负载均衡策略。实际测试QPS从150提升到400。

## 第五章:监控与告警

### 5.1 ES集群监控

必须监控的指标:

| 指标 | 含义 | 告警阈值 |
|------|------|---------|
| 查询延迟(P95) | 检索耗时 | >500ms |
| 查询拒绝率 | 被拒绝的请求占比 | >1% |
| 节点内存使用率 | JVM内存 | >85% |
| 磁盘使用率 | 存储空间 | >80% |
| 索引队列长度 | 写入堆积 | >1000 |

### 5.2 向量检索质量监控

每天抽样100条query,记录:
- 返回结果数量(是不是经常少于10条?)
- 平均cosine相似度(是不是越来越低?)
- 与上周的查询结果重合度(如果大幅下降,可能索引有问题)

```python
def daily_health_check():
    sample_queries = get_daily_samples(100)
    stats = {
        'avg_hits': 0,
        'avg_similarity': 0,
        'result_overlap': 0
    }
    
    for query in sample_queries:
        result = search(query)
        stats['avg_hits'] += len(result['hits'])
        stats['avg_similarity'] += result['avg_score']
        # 与上周结果比较
        last_week = get_cached_result(query)
        if last_week:
            overlap = len(set(result['ids']) & set(last_week['ids']))
            stats['result_overlap'] += overlap / len(result['ids'])
    
    # 归一化
    stats['avg_hits'] /= 100
    stats['avg_similarity'] /= 100
    stats['result_overlap'] /= 100
    
    # 告警
    if stats['avg_hits'] < 8:
        alert("检索结果数量异常偏低")
    if stats['avg_similarity'] < 0.6:
        alert("平均相似度异常偏低")
    if stats['result_overlap'] < 0.7:
        alert("结果与上周差异过大,可能是索引重建问题")
    
    return stats
```

Logo

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

更多推荐