电商搜索的语义理解与重排序:向量检索、交叉编码器与特征融合的推理优化
电商搜索的语义理解与重排序向量检索、交叉编码器与特征融合的推理优化一、电商搜索的三阶段范式与性能瓶颈电商搜索管道分为三个阶段召回Recall、粗排Pre-rank、精排Re-rank。召回阶段从百万级候选商品中筛选出 500~1000 个要求高吞吐低延迟。传统倒排索引BM25在关键词匹配上有优势但无法理解轻薄办公本与14 寸便携笔记本电脑的语义等价关系——这就是语义检索的切入点。向量检索Dense Retrieval通过双塔模型将查询和商品编码为同空间中的稠密向量用向量内积或余弦相似度度量相关性。其瓶颈在于百万级向量的 KNN 搜索需要 O(N) 的暴力遍历——即便用 FAISS 的 IVF 索引在 IVFPQ 压缩下也需要 O(sqrt(N)) 的扫描量。双塔模型的另一个局限是查询与商品的交互仅通过向量内积完成——缺乏细粒度的词级匹配信号。交叉编码器Cross-Encoder将查询与商品拼接后送入 Transformer输出相关性分数。其精度显著高于双塔模型——NDCG10 通常提升 3%8%——但推理成本高每次评分都需要完整的前向传播。因此交叉编码器仅用于精排阶段对 50200 个候选打分。特征融合的推理优化方向是让粗排阶段的效率逼近召回让精排阶段的精度接近交叉编码器。二、向量检索与交叉编码器的协同原理向量检索的核心——FAISS IVFPQ 索引使用 K-Means 将向量空间划分为 N 个 Voronoi 单元Cell。查询时先计算查询向量与 N 个聚类中心的距离找到最近的 nprobe 个单元仅在其中暴力搜索。结合乘积量化PQ将 128 维向量压缩到 16 字节——百万级向量仅需 16MB 内存在单个索引上完成搜索。双塔粗排的优化将 N 个候选商品的向量拼接为矩阵与查询向量做单次矩阵乘法——现代 CPU 的 SIMD 指令AVX-512可以在单周期内完成 16 个 f32 的点积累加。N1000 时双塔打分仅需约 40μs——而交叉编码器需要约 200ms。交叉编码器精排将查询与每个候选拼接为[CLS] query [SEP] item [SEP]使用预训练的 BERT/RoBERTa 模型打分。为降低延迟使用模型量化INT8和操作符融合LayerNorm GeLU kernel fusion。200 个候选并行批处理总延迟控制在 20ms 以内。三、推理优化的 Rust 实现use candle_core::{Tensor, Device, DType, Module}; use candle_nn::{Linear, VarBuilder, VarMap}; use candle_transformers::models::bert::{BertModel, Config}; use tokenizers::Tokenizer; /// 双塔编码器 /// 设计原因Query 和 Item 共享编码器权重 /// 减少一半的模型参数量 struct DualEncoder { query_encoder: BertModel, item_encoder: BertModel, /// 投影层——将 BERT 输出映射到 128 维空间 query_proj: Linear, item_proj: Linear, tokenizer: Tokenizer, } impl DualEncoder { /// 编码查询——仅执行一次结果缓存在请求生命周期 /// 设计原因查询编码的高成本~2ms应摊销到所有候选 fn encode_query(self, query: str, device: Device) - ResultTensor { let tokens self.tokenizer.encode(query, true) .map_err(|e| anyhow::anyhow!(tokenize error: {}, e))?; let input_ids Tensor::new( tokens.get_ids(), device, )?.unsqueeze(0)?; let output self.query_encoder.forward(input_ids)?; // 取 [CLS] token 的表示——全局语义向量 let cls_emb output.narrow(1, 0, 1)?; self.query_proj.forward(cls_emb) // 形状: (1, 128) } /// 批量编码商品——离线完成结果存入 FAISS 索引 /// 设计原因在线推理时无需重新编码 fn encode_items(self, item_texts: [String], device: Device) - ResultTensor { let mut all_embs Vec::new(); // 批量编码——每次 64 个利用 GPU 并行 for chunk in item_texts.chunks(64) { let tokens: Vec_ chunk.iter() .map(|t| self.tokenizer.encode(t.as_str(), true)) .collect::ResultVec_, _() .map_err(|e| anyhow::anyhow!(batch tokenize error: {}, e))?; let max_len tokens.iter().map(|t| t.len()).max().unwrap_or(0); let mut input_ids Vec::new(); for t in tokens { let mut ids t.get_ids().to_vec(); ids.resize(max_len, 0); // padding input_ids.extend(ids); } let input_tensor Tensor::new( input_ids.as_slice(), device, )?.reshape((chunk.len(), max_len))?; let output self.item_encoder.forward(input_tensor)?; let cls_embs output.narrow(1, 0, 1)?; let proj self.item_proj.forward(cls_embs)?; all_embs.push(proj); } Tensor::cat(all_embs.iter().collect::Vec_(), 0) } } /// 交叉编码器精排 /// 设计原因查询 商品拼接后送入 BERT /// 获得细粒度的交互特征——精度显著高于双塔 struct CrossEncoder { model: BertModel, /// 分类头——将 [CLS] 输出映射为相关性分数 classifier: Linear, tokenizer: Tokenizer, } impl CrossEncoder { /// 批量精排 /// candidates: (query, item_text) 对列表 /// 设计原因批量推理BS32~64利用 GPU 并行 /// 避免逐个调用的 Kernel Launch 开销 fn rank(self, query: str, items: [String], device: Device) - ResultVecf32 { let mut scores Vec::with_capacity(items.len()); for chunk in items.chunks(32) { let pairs: VecString chunk.iter() .map(|item| format!([CLS] {} [SEP] {} [SEP], query, item)) .collect(); // 批量编码——所有 pair 同时前向传播 let tokens pairs.iter() .map(|p| self.tokenizer.encode(p.as_str(), true)) .collect::ResultVec_, _() .map_err(|e| anyhow::anyhow!(tokenize: {}, e))?; let max_len tokens.iter().map(|t| t.len()).max().unwrap_or(0); let mut input_ids Vec::new(); let mut attention_masks Vec::new(); for t in tokens { let len t.len(); let mut ids t.get_ids().to_vec(); let mut mask vec![1.0f32; len]; ids.resize(max_len, 0); mask.resize(max_len, 0.0); input_ids.extend(ids); attention_masks.extend(mask); } let input Tensor::new(input_ids.as_slice(), device)? .reshape((chunk.len(), max_len))?; let mask Tensor::new(attention_masks.as_slice(), device)? .reshape((chunk.len(), max_len))?; let output self.model.forward(input)?; let cls_out output.narrow(1, 0, 1)?; let batch_scores self.classifier.forward(cls_out)? .squeeze(1)?; // 将 Tensor 转为 Vecf32 let batch_scores: Vecf32 batch_scores.to_vec1()?; scores.extend(batch_scores); } Ok(scores) } }四、语义搜索的部署策略与精度权衡适用场景长尾查询较多——用户搜索词与商品标题的词汇重叠度 30%。商品语料 10 万——倒排索引的召回率下降向量检索弥补语义缺失。多语言/多模态搜索——向量空间统一表示文本与图像。需要个性化精排——交叉编码器融入用户特征提升 NDCG10 3%~8%。不适用场景精确 ID 搜索如 SKU 编码——倒排索引效率最高语义检索反而降低精度。商品语料 1 万——暴力 KNN 搜索已足够快。对延迟极端敏感P99 1ms——语义检索至少需要 5~10ms。缺乏 GPU 资源——双塔模型和交叉编码器的推理需 GPU 加速。Trade-offs向量检索的 IVFPQ 用压缩率换取速度——PQ 压缩 8x 内存但降低 2%~5% 召回率。双塔模型比交叉编码器快 1000 倍以上但 NDCG10 低 3%~8%——推荐召回用双塔、精排用交叉编码器的分层策略。特征融合增加延迟但提升精度——需在 P99 延迟约束内分配各阶段预算。五、总结召回→粗排→精排三阶段按延迟预算分配召回 10ms、粗排 1ms、精排 20ms双塔编码器共享权重减少参数量查询编码仅执行一次摊销成本交叉编码器的批量推理BS32消除逐条评分的 Kernel Launch 开销IVFPQ 索引以 2%~5% 召回率换取 8 倍内存压缩适合海量商品库分层检索策略使语义精度的提升不牺牲整体延迟——召回用向量、精排用交叉编码器