混合检索系统实战:从OOM崩溃到毫秒级响应

38 阅读15分钟

混合检索系统实战:从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-Filtering100199%45ms
Pre-Filtering201810%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):1000ms
  • sku_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)

关键优化点:

  1. 用NumPy数组替代Python列表
  2. 用向量化mask替代逐个判断
  3. 提前过滤score=0,减少80%的处理量
  4. 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

好处:

  1. 环境隔离:dev、staging、prod用不同配置
  2. A/B测试:改配置文件即可,不用改代码
  3. 快速调整:出问题了改配置重启,不用重新发布
  4. 文档化:配置文件本身就是文档

核心优化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

日志的价值

这个日志在调试时简直是神器:

  1. 发现bad case:某个商品排名异常,可以看到是哪个环节出问题
  2. 权重调优:看到底是向量主导还是关键词主导
  3. 查询分析:哪些改写查询最有效
  4. 召回分析:多路召回的互补性如何

比如发现某个商品只被关键词召回(向量分数为0),说明这个商品的描述文本需要优化。

优化效果总结

性能数据

指标优化前优化后提升
平均响应时间850ms65ms13倍
P99响应时间3.2s180ms17倍
向量检索耗时100ms11ms9倍
关键词检索耗时3000ms70ms42倍
内存占用(峰值)28GB5GB5.6倍
OOM事故每周2次0次

技术选型

最后说说我们用的技术栈:

组件选型原因
向量数据库Milvus开源、支持Pre-Filtering、性能好
向量模型all-MiniLM-L6-v2384维、速度快、中等精度够用
关键词算法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 + 内存两种模式)
  • 关键词检索服务(BM25算法)
  • 混合检索服务(融合打分)
  • 召回日志系统(可观测性)
  • 完整的配置管理

欢迎Star和Fork!


本文基于真实生产环境的优化经验,性能数据来自实际压测结果。代码示例经过简化处理,实际生产代码包含更多边界处理和监控逻辑。

如果这篇文章对你有帮助,欢迎点赞、收藏、转发。有问题可以在评论区讨论~