RAG系统Embedding模型选型与向量索引

第一章: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: Liststr, batch_size: int = 64) -> ListList\[float]:

embeddings = \[\]

for i in range(0, len(texts), batch_size):

batch = textsi: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: ListDict, batch_size: int = 1000):

for i in range(0, len(chunks), batch_size):

batch = chunksi: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_poolidx.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

```