混合检索系统实战:从OOM崩溃到毫秒级响应
一个真实的电商搜索优化故事
什么是混合检索?为什么需要它?
先说说背景。我们在做一个电商搜索系统,用户会输入各种奇怪的查询:
- "不上火的奶粉"(模糊概念)
- "美赞臣 A+ 3段"(精确匹配)
- "500块以内的手机"(带条件)
- "这个怎么样"(指代词)
传统的关键词搜索(BM25)处理不了"不上火"这种模糊概念,而纯向量检索对"美赞臣"这种精确品牌名又不够敏感。
混合检索的思路很简单:让向量检索负责语义理解,让关键词检索负责精确匹配,最后把结果融合起来。
系统架构
整个系统分三层:
用户查询 "不上火的奶粉"
↓
【查询改写层】
- 分析查询特征(是简单查询还是复杂查询?)
- 选择重写策略(规则扩展、同义词、LLM优化...)
- 生成多个候选查询
↓
改写结果: ["温和配方 奶粉", "易消化 配方奶", "低热量 儿童奶粉"]
↓
【混合检索层】
并行执行两路召回:
┌─────────────┐ ┌──────────────┐
│ 向量检索 │ │ 关键词检索 │
│ (Milvus) │ │ (BM25) │
│ 召回20个 │ │ 召回30个 │
└─────────────┘ └──────────────┘
↓
合并去重 → 50个候选商品
↓
【融合打分层】
- 分数归一化(处理不同量纲)
- 加权融合(0.7×向量 + 0.3×关键词)
- 排序输出
↓
最终结果: Top 20 商品
数据流动示例
来看一个具体查询的处理过程:
输入: "不上火的奶粉" + 过滤条件 {"category": "奶粉", "status": "ON_SALE"}
1️⃣ 查询改写
- "温和配方 奶粉"
- "易消化 配方奶粉"
- "低热量 儿童奶粉"
2️⃣ 向量检索(每个改写查询)
"温和配方 奶粉" → P001(0.91), P003(0.85), P007(0.82)...
"易消化 配方奶粉" → P001(0.88), P005(0.79), P009(0.76)...
"低热量 儿童奶粉" → P003(0.87), P001(0.84), P011(0.81)...
3️⃣ 关键词检索
["温和", "配方", "奶粉"] → P001(12.4), P003(9.8), P007(8.2)...
4️⃣ 合并去重
P001: vector=0.91, keyword=12.4
P003: vector=0.87, keyword=9.8
P005: vector=0.79, keyword=0
P007: vector=0.82, keyword=8.2
5️⃣ 分数归一化
P001: vector=1.00, keyword=0.95
P003: vector=0.96, keyword=0.73
P005: vector=0.87, keyword=0.00
P007: vector=0.90, keyword=0.62
6️⃣ 融合打分 (0.7×向量 + 0.3×关键词)
P001: 0.985 → 第1名
P003: 0.891 → 第2名
P007: 0.816 → 第3名
P005: 0.609 → 第4名
核心优化1:Pre-Filtering避免无效召回
刚开始的时候,我们的检索逻辑是这样的:
# 先召回100个结果
results = milvus_collection.search(query_vector, limit=100)
# 再根据业务条件过滤
filtered = []
for result in results:
product = get_product(result.sku_id)
if product.price < 500 and product.status == "ON_SALE":
filtered.append(result)
return filtered[:20]
问题在哪?
用户搜索"500元以下的iPhone手机",向量检索召回了100个iPhone相关商品,但99个都在2000元以上。最终只有1个符合条件,浪费了99%的计算资源。
解决方案:Pre-Filtering
核心思路:在检索时就带上过滤条件,而不是检索后再过滤。
def retrieve(self, query, filters):
# 1. 先用业务逻辑过滤出符合条件的商品
filtered_products = self.db.filter_products(filters)
# filters = {"category": "奶粉", "price_max": 500, "status": "ON_SALE"}
# 2. 提取SKU列表
sku_list = [p["sku_id"] for p in filtered_products]
# 3. 构建Milvus过滤表达式
sku_str = "', '".join(sku_list)
expr = f"sku_id in ['{sku_str}']"
# 4. 带着过滤条件检索
results = milvus_collection.search(
data=[query_vector],
limit=20, # 只需要20个
expr=expr # 只在符合条件的商品中检索
)
return results
为什么不直接在Milvus里过滤价格和状态?
因为业务逻辑很复杂:
- 价格可能有促销价、会员价、阶梯价
- 库存需要实时查询
- 状态可能有地域限制
这些逻辑在应用层处理更灵活。我们的方案是先在应用层过滤,然后把结果SKU列表传给Milvus。
效果对比:
| 方案 | 召回量 | 有效结果 | 浪费率 | 耗时 |
|---|---|---|---|---|
| Post-Filtering | 100 | 1 | 99% | 45ms |
| Pre-Filtering | 20 | 18 | 10% | 5ms |
不仅效率提升了9倍,而且召回质量更高。
核心优化2:分数归一化的陷阱
混合检索需要把向量分数和BM25分数融合起来,但这两个分数的量纲完全不同:
- 向量相似度:0.5 ~ 0.95(比较稳定)
- BM25分数:0.5 ~ 28.6(有异常值)
最开始我们用简单的Min-Max归一化:
# 天真的归一化
normalized = (score - min_score) / (max_score - min_score)
问题来了:
假设有10个商品的BM25分数:[0.5, 1.2, 1.8, 2.3, 2.1, 1.9, 2.5, 1.7, 2.0, 28.6]
最后一个是异常值(商品标题堆砌了10个关键词),Min-Max归一化的结果:
原始分数: [0.5, 1.2, 1.8, 2.3, 2.1, 1.9, 2.5, 1.7, 2.0, 28.6]
归一化后: [0.00, 0.02, 0.05, 0.06, 0.06, 0.05, 0.07, 0.04, 0.05, 1.00]
看到了吗?所有正常商品的分数都被压到0.1以下!最终融合分数完全被关键词主导,向量检索的语义理解能力被废了。
解决方案:Sigmoid归一化
对于BM25这种可能有异常值的分数,用Sigmoid函数处理:
import math
def sigmoid_normalize(scores):
non_zero = [s for s in scores if s > 0]
if not non_zero:
return [0.0] * len(scores)
max_score = max(non_zero)
k = max_score / 6 # 缩放因子
normalized = []
for s in scores:
if s > 0:
# Sigmoid: 1 / (1 + exp(-x/k))
norm = 1 / (1 + math.exp(-s / max(k, 0.1)))
normalized.append(norm)
else:
normalized.append(0.0)
return normalized
Sigmoid的优势:
- 异常值被压制到0.95左右,不会是1.0
- 正常值分布在0.4~0.8区间
- 曲线平滑,没有cliff效应
效果对比:
原始分数: [0.5, 1.2, 1.8, 2.3, 2.1, 1.9, 2.5, 1.7, 2.0, 28.6]
Min-Max归一化: [0.00, 0.02, 0.05, 0.06, 0.06, 0.05, 0.07, 0.04, 0.05, 1.00]
Sigmoid归一化: [0.58, 0.65, 0.71, 0.77, 0.74, 0.72, 0.80, 0.69, 0.73, 0.95]
这样分布就合理多了!
对于向量分数,还有一个小坑要注意:
# 候选商品可能只被向量或关键词召回,分数为0
candidates = [
{"sku": "P001", "vector": 0.91, "keyword": 12.4},
{"sku": "P005", "vector": 0.79, "keyword": 0}, # 只有向量召回
{"sku": "P009", "vector": 0, "keyword": 8.2}, # 只有关键词召回
]
# 如果直接归一化,0值会拉低整体分数
# 正确做法:只对非零值进行归一化
non_zero_vector = [s for s in vector_scores if s > 0]
if non_zero_vector:
min_v, max_v = min(non_zero_vector), max(non_zero_vector)
normalized = [
(s - min_v) / (max_v - min_v) if s > 0 else 0.0
for s in vector_scores
]
核心优化3:消灭Python循环
关键词检索用的是BM25算法,最初的实现是纯Python循环:
# 获取所有商品的BM25分数
scores = self.bm25.get_scores(query_tokens) # 100万个分数
# 过滤并排序
results = []
for i, score in enumerate(scores):
if score > 0 and self.sku_list[i] in filtered_skus:
results.append((self.sku_list[i], score))
results.sort(key=lambda x: x[1], reverse=True)
return results[:50]
在100万商品下,这段代码跑了3秒!
性能瓶颈分解:
enumerate(scores):1000mssku_list[i] in filtered_skus:800ms(集合查找)results.append():500ms(动态数组扩容)sort():700ms(Python排序)
核心问题:Python循环太慢了。
解决方案:向量化操作
NumPy的向量化操作利用SIMD指令,比Python循环快几十倍:
import numpy as np
scores = self.bm25.get_scores(query_tokens)
# 转换为NumPy数组
scores_array = np.array(scores)
sku_array = np.array(self.sku_list)
# 向量化过滤:只处理score>0的商品
positive_mask = scores_array > 0 # 布尔数组,10ms
positive_indices = np.where(positive_mask)[0] # 提取索引
if len(positive_indices) == 0:
return []
# 批量提取
positive_scores = scores_array[positive_indices] # 5ms
positive_skus = sku_array[positive_indices] # 5ms
# 过滤SKU(这步还需要循环,但数据量减少了80%)
filtered_indices = []
filtered_scores = []
for idx, sku_id in enumerate(positive_skus):
if sku_id in filtered_skus:
filtered_indices.append(positive_indices[idx])
filtered_scores.append(positive_scores[idx])
# 向量化排序
sorted_idx = np.argsort(filtered_scores)[::-1] # 50ms,降序
# 构建结果
results = []
for i in sorted_idx[:50]:
original_idx = filtered_indices[i]
results.append({
"sku_id": self.sku_list[original_idx],
"score": float(filtered_scores[i])
})
return results
# 总耗时: 70ms
性能提升:42倍(3000ms → 70ms)
关键优化点:
- 用NumPy数组替代Python列表
- 用向量化mask替代逐个判断
- 提前过滤score=0,减少80%的处理量
- 用
np.argsort替代Python sort
核心优化4:配置化管理
最开始代码里到处都是硬编码的Magic Number:
# 文件A
fusion_score = vector_score * 0.7 + keyword_score * 0.3
# 文件B
if hybrid_score > 0.8:
do_something()
# 文件C
top_k = 20
# 文件D
candidates = results[:50]
问题:
- 要调整权重需要改5个文件
- 测试环境和生产环境配置不一致
- 新人完全不知道这些数字的含义
- A/B测试根本没法做
解决方案:环境变量配置
创建.env配置文件:
# 权重配置
VECTOR_WEIGHT=0.7
KEYWORD_WEIGHT=0.3
# 召回数量
VECTOR_TOP_K=20
MAX_CANDIDATES=50
# 模型配置
EMBEDDING_MODEL=all-MiniLM-L6-v2
代码中统一读取配置:
import os
from dotenv import load_dotenv
load_dotenv()
# 读取权重配置
vector_weight = float(os.getenv('VECTOR_WEIGHT', '0.7'))
keyword_weight = float(os.getenv('KEYWORD_WEIGHT', '0.3'))
# 权重归一化(确保和为1)
total = vector_weight + keyword_weight
if total > 0:
vector_weight /= total
keyword_weight /= total
# 统一使用配置
fusion_score = vector_score * vector_weight + keyword_score * keyword_weight
好处:
- 环境隔离:dev、staging、prod用不同配置
- A/B测试:改配置文件即可,不用改代码
- 快速调整:出问题了改配置重启,不用重新发布
- 文档化:配置文件本身就是文档
核心优化5:OOM多层防护
当商品库从50万涨到500万时,向量检索的内存占用计算:
500万商品 × 384维 × 4字节(float32) = 7.6GB
一个向量相似度计算:
scores = np.dot(self.embeddings, query_vector)
# 创建一个500万元素的临时数组(约20MB)
看起来不多?但在高并发下:
- 100个并发请求 = 100个临时数组 = 2GB
- 加上embedding本身的7.6GB = 接近10GB
- 再算上其他内存开销,32GB内存瞬间爆掉
解决方案:多层防护机制
第一层:内存预警
def retrieve(self, query_vector, filtered_skus, top_k):
# 检查embedding矩阵大小
embeddings_size_mb = self.embeddings.nbytes / (1024 * 1024)
if embeddings_size_mb > 1024: # 超过1GB
print(f"⚠️ 向量矩阵过大({embeddings_size_mb:.1f}MB)")
# 直接降级到安全模式
return self._memory_safe_fallback(query_vector, filtered_skus, top_k)
# 正常计算...
第二层:异常捕获
try:
scores = np.dot(self.embeddings, query_vector)
# ...
except MemoryError as e:
print(f"❌ 内存不足: {e},使用安全模式")
return self._memory_safe_fallback(query_vector, filtered_skus, top_k)
第三层:安全降级模式
核心思路:只计算需要的商品,不要全量计算。
def _memory_safe_fallback(self, query_vector, filtered_skus, top_k):
"""
只计算过滤后的商品,避免全量计算
"""
# 1. 找出需要计算的商品索引
filtered_indices = []
for i, sku_id in enumerate(self.sku_list):
if sku_id in filtered_skus:
filtered_indices.append(i)
if not filtered_indices:
return []
# 2. 只提取这些商品的embedding
filtered_embeddings = self.embeddings[filtered_indices]
# 500万 → 1000个,内存占用:7.6GB → 1.5MB
# 3. 计算相似度(数据量小,安全)
scores = np.dot(filtered_embeddings, query_vector) / (
np.linalg.norm(filtered_embeddings, axis=1) *
np.linalg.norm(query_vector)
)
# 4. 排序返回
sorted_indices = np.argsort(scores)[::-1][:top_k]
results = []
for i in sorted_indices:
original_idx = filtered_indices[i]
results.append({
"sku_id": self.sku_list[original_idx],
"score": float(scores[i])
})
return results
内存占用对比:
全量计算: 7.6GB
安全模式: 1.5MB (降低5000倍!)
第四层:Jieba单例模式
还发现了一个隐蔽的内存泄漏:
# 每次初始化都加载jieba词典(~50MB)
class KeywordRetrievalService:
def __init__(self):
import jieba
self.jieba = jieba
高并发下会创建几百个实例,每个50MB,累计好几GB。
解决方案:类级别单例
class KeywordRetrievalService:
_jieba_instance = None # 类级别变量
@classmethod
def _get_jieba(cls):
if cls._jieba_instance is None:
import jieba
cls._jieba_instance = jieba
return cls._jieba_instance
def __init__(self):
self.jieba = self._get_jieba() # 复用同一个实例
节省内存:
100个实例 × 50MB = 5GB ❌
1个单例 × 50MB = 50MB ✅
可观测性:召回日志系统
除了性能优化,我们还加了一套召回日志系统,用于调试和分析。
日志记录什么?
每个候选商品会记录:
- 通过哪些查询被召回(向量查询、关键词查询)
- 在每个查询中的原始分数和排名
- 归一化后的分数
- 最终融合分数和排名
日志示例
hybrid_service.print_retrieval_logs(candidates, top_n=3)
输出:
=== 召回日志详情 (Top 3) ===
【第 1 名】SKU: P001 美赞臣安儿宝A+
最终分数: 0.9850 (向量: 1.0000, 关键词: 0.9500)
召回来源: vector, keyword
召回汇总:
总查询数: 4
向量命中: 3次, 关键词命中: 1次
最高向量分数: 0.9100
最高关键词分数: 12.4000
检索详情:
1. [vector] "温和配方 奶粉" -> 分数: 0.9100, 排名: #1
2. [vector] "易消化 配方奶粉" -> 分数: 0.8800, 排名: #2
3. [vector] "低热量 儿童奶粉" -> 分数: 0.8400, 排名: #3
4. [keyword] "温和 配方 奶粉" -> 分数: 12.4000, 排名: #3
【第 2 名】SKU: P003 惠氏启赋
最终分数: 0.8910 (向量: 0.9600, 关键词: 0.7300)
召回来源: vector, keyword
召回汇总:
总查询数: 4
向量命中: 2次, 关键词命中: 1次
检索详情:
1. [vector] "温和配方 奶粉" -> 分数: 0.8500, 排名: #3
2. [vector] "低热量 儿童奶粉" -> 分数: 0.8700, 排名: #2
3. [keyword] "温和 配方 奶粉" -> 分数: 9.8000, 排名: #5
日志的价值
这个日志在调试时简直是神器:
- 发现bad case:某个商品排名异常,可以看到是哪个环节出问题
- 权重调优:看到底是向量主导还是关键词主导
- 查询分析:哪些改写查询最有效
- 召回分析:多路召回的互补性如何
比如发现某个商品只被关键词召回(向量分数为0),说明这个商品的描述文本需要优化。
优化效果总结
性能数据
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 平均响应时间 | 850ms | 65ms | 13倍 |
| P99响应时间 | 3.2s | 180ms | 17倍 |
| 向量检索耗时 | 100ms | 11ms | 9倍 |
| 关键词检索耗时 | 3000ms | 70ms | 42倍 |
| 内存占用(峰值) | 28GB | 5GB | 5.6倍 |
| OOM事故 | 每周2次 | 0次 | ∞ |
技术选型
最后说说我们用的技术栈:
| 组件 | 选型 | 原因 |
|---|---|---|
| 向量数据库 | Milvus | 开源、支持Pre-Filtering、性能好 |
| 向量模型 | all-MiniLM-L6-v2 | 384维、速度快、中等精度够用 |
| 关键词算法 | BM25 | 经典、稳定、实现简单 |
| 分词 | Jieba | 中文分词首选 |
| 数值计算 | NumPy | 向量化操作、科学计算标配 |
| 配置管理 | python-dotenv | 环境变量管理 |
为什么选Milvus而不是FAISS?
FAISS性能更高,但Milvus有几个优势:
- 支持Pre-Filtering(FAISS需要自己实现)
- 分布式部署方便
- 有管理界面
- 社区活跃
对我们这种中等规模(500万商品)的场景,Milvus够用了。
踩坑经验总结
回顾这次重构,几个关键经验:
1. Pre-Filtering是性能优化的银弹
能不算就不算。Post-Filtering浪费了99%的计算资源,Pre-Filtering让召回效率提升9倍。
2. 分数归一化要注意异常值
Min-Max归一化看似简单,但遇到异常值会崩盘。BM25这种统计算法特别容易有异常值,一定要用Sigmoid或其他鲁棒的方法。
3. Python循环是性能杀手
在百万级数据下,Python循环就是灾难。能向量化就向量化,NumPy、Pandas都是好工具。
4. 配置化是可维护性的基础
硬编码的Magic Number是技术债。统一配置管理不仅方便调整,还能做A/B测试、环境隔离。
5. 内存问题要多层防护
别指望一招制敌。预警、异常捕获、降级,多层防护才安全。
6. 可观测性比优化本身更重要
没有日志的系统就是黑盒。详细的召回日志能节省90%的调试时间。
后续优化方向
目前的系统还有优化空间:
1. 向量检索加速
- 用FAISS的IVF索引替代暴力检索
- GPU加速(适合超大规模)
- ANN近似检索(牺牲一点精度换速度)
2. 智能降级策略
- 根据负载动态调整top_k
- 高峰期自动降级到关键词检索
- 缓存热门查询结果
3. A/B测试平台
- 不同权重配置的在线对比
- 自动选择最优参数组合
- 实时效果监控
4. 多模态检索
- 支持图片检索(CLIP模型)
- 商品图+文本联合检索
- 跨模态相似度计算
写在最后
从OOM崩溃,到现在毫秒级的稳定响应,这个过程让我深刻理解了几件事:
过早优化是万恶之源,但适时优化能救命。 最开始系统设计时,我们没有考虑500万商品的场景。但当数据量涨上来,不优化就只能等死。
数据结构和算法永远是基础。 NumPy向量化、Pre-Filtering、Sigmoid归一化,这些都是基础知识的应用。基础扎实了,遇到问题才知道怎么解决。
可观测性比优化本身更重要。 没有详细的日志和监控,你根本不知道问题在哪,优化从何谈起?
系统设计要考虑扩展性。 今天的50万明天可能是500万,后天可能是5000万。设计时就要考虑数据量增长的可能性。
希望这些经验能帮到正在做搜索、推荐系统的同学。如果你也遇到过类似问题,欢迎交流~
参考资源
- Milvus: milvus.io/
- Sentence-Transformers: www.sbert.net/
- Rank-BM25: BM25算法Python实现
- NumPy: numpy.org/
代码仓库
完整代码已经整理好,包含:
- 向量检索服务(Milvus + 内存两种模式)
- 关键词检索服务(BM25算法)
- 混合检索服务(融合打分)
- 召回日志系统(可观测性)
- 完整的配置管理
欢迎Star和Fork!
本文基于真实生产环境的优化经验,性能数据来自实际压测结果。代码示例经过简化处理,实际生产代码包含更多边界处理和监控逻辑。
如果这篇文章对你有帮助,欢迎点赞、收藏、转发。有问题可以在评论区讨论~