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

📅 2026/6/29 18:01:29
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万。**结果**| 模型 | Hit10 | Hit5 | 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_lengthpythonfrom transformers import AutoTokenizertokenizer 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设大了显存溢出。pythondef 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:ibatch_size]# 对batch做paddinginputs tokenizer(batch, paddingTrue, truncationTrue, max_length512, return_tensorspt)inputs {k: v.to(device) for k, v in inputs.items()}with torch.no_grad():outputs model(**inputs)# 取[CLS] token的embeddingbatch_embeddings outputs.last_hidden_state[:, 0, :].cpu().numpy()# L2归一化batch_embeddings batch_embeddings / np.linalg.norm(batch_embeddings, axis1, keepdimsTrue)embeddings.extend(batch_embeddings.tolist())return embeddingsA10 24G显存下batch_size64刚好。设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索引配置完整索引mappingjson{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参数**- m16每个节点连接数越大召回率越高但内存越大- ef_construction100构建时搜索宽度越大索引质量越高但构建越慢我们测试了不同参数组合| m | ef_construction | 召回率10 | 索引大小 | 构建时间 ||---|----------------|----------|---------|---------|| 8 | 50 | 0.92 | 340GB | 6h || 16 | 100 | 0.95 | 410GB | 12h || 32 | 200 | 0.96 | 520GB | 24h |选了m16, ef_construction100平衡各方面。### 2.3 索引构建优化7000万向量构建索引是个大工程。几个优化点**第一批量写入**pythondef bulk_index(chunks: List[Dict], batch_size: int 1000):for i in range(0, len(chunks), batch_size):batch chunks[i:ibatch_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_timeout60)**第二关闭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)}}}这个方式会遍历全库计算cosine7000万条数据下耗时8-10秒。新方案用原生knnjson{knn: {field: chunk_embedding,query_vector: query_embedding,k: 50,num_candidates: 200}}num_candidates控制候选池大小。200意味着先找200个近似候选再从中取50个。比全库计算快几十倍。测试不同num_candidates的召回率| num_candidates | 查询延迟 | Hit10召回率 ||---------------|---------|------------|| 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做无缝切换pythondef 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降到180msHit5下降0.5%。**方案二缓存高频query的rerank结果**用Redis缓存key是query的hashvalue是rerank后的top5文档ID。缓存命中率约25%命中时直接跳过rerank。pythondef 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前面加了一层查询路由pythonclass QueryRouter:def __init__(self):self.es_pool [Elasticsearch([es-node1:9200]),Elasticsearch([es-node2:9200]),Elasticsearch([es-node3:9200]),]self.counter 0def route(self, query_body: dict) - dict:# 轮询idx self.counter % len(self.es_pool)self.counter 1return self.es_pool[idx].search(indexknowledge, bodyquery_body)简单轮询没有复杂的负载均衡策略。实际测试QPS从150提升到400。## 第五章监控与告警### 5.1 ES集群监控必须监控的指标| 指标 | 含义 | 告警阈值 ||------|------|---------|| 查询延迟(P95) | 检索耗时 | 500ms || 查询拒绝率 | 被拒绝的请求占比 | 1% || 节点内存使用率 | JVM内存 | 85% || 磁盘使用率 | 存储空间 | 80% || 索引队列长度 | 写入堆积 | 1000 |### 5.2 向量检索质量监控每天抽样100条query记录- 返回结果数量是不是经常少于10条- 平均cosine相似度是不是越来越低- 与上周的查询结果重合度如果大幅下降可能索引有问题pythondef 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] / 100stats[avg_similarity] / 100stats[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