跳转到内容

03-Mem0 的多信号混合检索管道

Mem0 的多信号混合检索管道

学习目标

本章分析 Mem0 如何将语义搜索、BM25 关键词匹配和实体提升三种信号融合为一个统一的检索管道。你将了解:

  • _search_vector_store() 的 9 步检索流程
  • BM25 Sigmoid 归一化的查询长度自适应策略
  • 实体提升的 spread-attenuation 衰减机制
  • over-fetch 策略如何确保结果数量充足

前置知识

本章涉及多信号混合检索的通用原理,建议先阅读:

下文假设你已理解混合检索的基本概念,直接聚焦 Mem0 的具体实现。

项目实践

检索管道总览

Memory.search() 方法是检索入口,核心工作由 _search_vector_store() 完成。整个管道包含 9 个步骤:

关键实现分析

Step 1: 查询预处理

# 伪代码
query_lemmatized = lemmatize_for_bm25(query) # "dogs running" → "dog run"
query_entities = extract_entities(query) # [("PERSON", "John"), ("ORG", "Shopify")]

词形还原使用 spaCy 模型(en_core_web_sm),将查询词还原为词根形式,提高 BM25 匹配的召回率。

Step 3: over-fetch 策略

internal_limit = max(limit * 4, 60) # top_k=20 → 80, top_k=5 → 60
semantic_results = self.vector_store.search(query=..., top_k=internal_limit)

语义搜索召回 top_k × 4 或至少 60 条结果。原因:后续经过阈值过滤和 BM25/实体加分后重排,结果集可能缩小。过度检索确保最终返回足够的结果。

Step 5: BM25 Sigmoid 归一化

midpoint, steepness = get_bm25_params(query, lemmatized=query_lemmatized)
for mem in keyword_results:
bm25_scores[mem.id] = normalize_bm25(mem.score, midpoint, steepness)

get_bm25_params() 根据查询词数选择参数:

查询词数midpointsteepness
≤ 35.00.7
4-67.00.6
7-99.00.5
10-1510.00.5
> 1512.00.5

normalize_bm25() 使用 Sigmoid 函数:1 / (1 + exp(-steepness * (raw_score - midpoint)))

设计亮点:参数选择逻辑在 utils/scoring.py 中独立实现,与检索管道解耦,便于单独调优。

Step 6: 实体提升

entity_boosts = self._compute_entity_boosts(query_entities, filters)

_compute_entity_boosts() 的实现:

  1. 去重查询实体(最多 8 个)
  2. 每个实体 Embedding 后在 Entity Store 中搜索(阈值 ≥ 0.5)
  3. 计算提升权重:boost = similarity × 0.5 × memory_count_weight
  4. memory_count_weight = 1 / (1 + 0.001 × (num_linked - 1)²) —— spread-attenuation

Spread-Attenuation 的效果

  • 实体关联 1 条记忆:weight = 1.0(完整提升)
  • 实体关联 10 条记忆:weight = 1 / (1 + 0.001 × 81) ≈ 0.92
  • 实体关联 100 条记忆:weight = 1 / (1 + 0.001 × 9801) ≈ 0.09

关联越多权重衰减越厉害,防止”大众实体”(如常见人名)提升过多记忆。

Step 8: 融合评分

scored_results = score_and_rank(
semantic_results=candidates,
bm25_scores=bm25_scores,
entity_boosts=entity_boosts,
threshold=threshold,
top_k=limit,
)

score_and_rank()utils/scoring.py 中实现:

# 伪代码
max_possible = 1.0
if has_bm25: max_possible += 1.0
if has_entity: max_possible += 0.5
for result in semantic_results:
if result.score < threshold:
continue # 语义分数低于阈值,直接排除
combined = (semantic + bm25 + entity) / max_possible

关键设计:阈值在融合前应用于语义分数——即使 BM25 或实体分数很高,语义不相关的记忆也会被排除。

可选 Reranker

搜索支持 rerank=True 参数,启用后在融合评分后追加 reranker 精排:

if rerank and self.reranker:
reranked_memories = self.reranker.rerank(query, original_memories, limit)

支持的 Reranker:Cohere、Sentence Transformer、Zero Entropy、LLM-based、HuggingFace。

问题与规避

问题对策
向量存储不支持 keyword_search返回 None,BM25 信号自动缺失,评分公式自适应
Entity Store 未初始化_compute_entity_boosts 捕获异常,返回空提升字典
Reranker 调用失败logger.warning 捕获,使用原始结果
over-fetch 导致检索变慢max(limit × 4, 60) 限制上限,避免过度检索

设计取舍

为什么选加性融合而非学习排序(Learning to Rank)?

优势

  • 加性融合(semantic + bm25 + entity)简单透明,每个信号的贡献可解释
  • max_possible 分母自适应,无需重新训练模型
  • 新增信号只需加入公式,不需要重新标注训练数据

代价

  • 信号权重固定(语义 1.0、BM25 1.0、实体 0.5),无法根据查询类型动态调整
  • 不如 LTR 模型(如 LambdaMART)精准

为什么阈值只应用于语义分数?

Mem0 的设计认为语义相关性是基础门槛——如果一个记忆与查询语义不相关,即使关键词匹配或实体关联也不应该返回。这防止了”关键词匹配但语义无关”的噪音结果。

参考来源

  • Mem0 论文:arXiv:2504.19413
  • Okapi BM25 论文:Robertson et al., 2009