电商搜索的语义理解与重排序:向量检索、交叉编码器与特征融合的推理优化
一、电商搜索的三阶段范式与性能瓶颈
电商搜索管道分为三个阶段:召回(Recall)、粗排(Pre-rank)、精排(Re-rank)。召回阶段从百万级候选商品中筛选出 500~1000 个,要求高吞吐低延迟。传统倒排索引(BM25)在关键词匹配上有优势,但无法理解"轻薄办公本"与"14 寸便携笔记本电脑"的语义等价关系——这就是语义检索的切入点。
向量检索(Dense Retrieval)通过双塔模型将查询和商品编码为同空间中的稠密向量,用向量内积(或余弦相似度)度量相关性。其瓶颈在于:百万级向量的 KNN 搜索需要 O(N) 的暴力遍历——即便用 FAISS 的 IVF 索引,在 IVFPQ 压缩下也需要 O(sqrt(N)) 的扫描量。双塔模型的另一个局限是:查询与商品的交互仅通过向量内积完成——缺乏细粒度的词级匹配信号。
交叉编码器(Cross-Encoder)将查询与商品拼接后送入 Transformer,输出相关性分数。其精度显著高于双塔模型——NDCG@10 通常提升 3%8%——但推理成本高:每次评分都需要完整的前向传播。因此交叉编码器仅用于精排阶段,对 50200 个候选打分。特征融合的推理优化方向是:让粗排阶段的效率逼近召回,让精排阶段的精度接近交叉编码器。
二、向量检索与交叉编码器的协同原理
向量检索的核心——FAISS IVFPQ 索引:使用 K-Means 将向量空间划分为 N 个 Voronoi 单元(Cell)。查询时,先计算查询向量与 N 个聚类中心的距离,找到最近的 nprobe 个单元,仅在其中暴力搜索。结合乘积量化(PQ)将 128 维向量压缩到 16 字节——百万级向量仅需 16MB 内存,在单个索引上完成搜索。
双塔粗排的优化:将 N 个候选商品的向量拼接为矩阵,与查询向量做单次矩阵乘法——现代 CPU 的 SIMD 指令(AVX-512)可以在单周期内完成 16 个 f32 的点积累加。N=1000 时,双塔打分仅需约 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) -> Result<Tensor> { 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) -> Result<Tensor> { 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::<Result<Vec<_>, _>>() .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) 对列表 /// 设计原因:批量推理(BS=32~64)利用 GPU 并行 /// 避免逐个调用的 Kernel Launch 开销 fn rank(&self, query: &str, items: &[String], device: &Device) -> Result<Vec<f32>> { let mut scores = Vec::with_capacity(items.len()); for chunk in items.chunks(32) { let pairs: Vec<String> = 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::<Result<Vec<_>, _>>() .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 转为 Vec<f32> let batch_scores: Vec<f32> = batch_scores.to_vec1()?; scores.extend(batch_scores); } Ok(scores) } }四、语义搜索的部署策略与精度权衡
适用场景:长尾查询较多——用户搜索词与商品标题的词汇重叠度 < 30%。商品语料 > 10 万——倒排索引的召回率下降,向量检索弥补语义缺失。多语言/多模态搜索——向量空间统一表示文本与图像。需要个性化精排——交叉编码器融入用户特征,提升 NDCG@10 3%~8%。
不适用场景:精确 ID 搜索(如 SKU 编码)——倒排索引效率最高,语义检索反而降低精度。商品语料 < 1 万——暴力 KNN 搜索已足够快。对延迟极端敏感(P99 < 1ms)——语义检索至少需要 5~10ms。缺乏 GPU 资源——双塔模型和交叉编码器的推理需 GPU 加速。
Trade-offs:向量检索的 IVFPQ 用压缩率换取速度——PQ 压缩 8x 内存但降低 2%~5% 召回率。双塔模型比交叉编码器快 1000 倍以上,但 NDCG@10 低 3%~8%——推荐召回用双塔、精排用交叉编码器的分层策略。特征融合增加延迟但提升精度——需在 P99 延迟约束内分配各阶段预算。
五、总结
- 召回→粗排→精排三阶段按延迟预算分配:召回 10ms、粗排 1ms、精排 20ms
- 双塔编码器共享权重减少参数量,查询编码仅执行一次摊销成本
- 交叉编码器的批量推理(BS=32)消除逐条评分的 Kernel Launch 开销
- IVFPQ 索引以 2%~5% 召回率换取 8 倍内存压缩,适合海量商品库
- 分层检索策略使语义精度的提升不牺牲整体延迟——召回用向量、精排用交叉编码器