08 | Mem0 框架分析: BM25 的 Sigmoid 归一化

08 | BM25 的 Sigmoid 归一化------原始分数为什么不能直接加?

一场无声的灾难

假设你在做混合检索,语义相似度 0.82,BM25 原始分 15.7。你把它们加起来:0.82 + 15.7 = 16.52。

看出了什么问题吗?

语义分在 0, 1,BM25 原始分在 0, 20+。直接相加,BM25 的信号量级是语义的 20 倍。等于说,只要关键词命中了,语义匹配几乎没有任何话语权。这不是混合检索,这是关键词检索套了个语义的壳。

这还不是最坏的情况。BM25 的原始分数没有上界------在极端情况下,一个命中了大量查询词的长文档可以拿到 30 甚至 40 分。而语义分的理论最大值始终是 1.0。当 BM25 分是语义分的 30~40 倍时,语义信号在加法中已经被噪声化了------即使一个语义完全不相关的记忆,只要关键词恰好命中,也能碾压语义高度相关但关键词未命中的候选。

mem0 的混合检索管线里,三种信号要加到一起:语义分、BM25 分、实体增强分。语义分天生 0, 1,实体增强分最大 0.5,唯独 BM25 是一个无界的原始分数。如果它不归一化,整个加法体系都会崩塌。

所以问题的核心不是"要不要归一化",而是"怎么归一化"。

先破:Min-Max 归一化的致命缺陷

最直觉的方案是 Min-Max:拿当前批次的最高分和最低分做线性映射,把原始分压到 0, 1

公式很简单:

复制代码
normalized = (score - min) / (max - min)

看起来合理,但这个方案有一个致命问题:对异常值极度敏感

数值灾难:一个极端分数如何压扁所有其他结果

考虑一个具体的场景。假设 10 条候选记忆的 BM25 原始分如下:

复制代码
记忆 A:  25.3  (命中了几乎所有查询词,异常高分)
记忆 B:   7.8
记忆 C:   6.2
记忆 D:   5.5
记忆 E:   4.9
记忆 F:   4.1
记忆 G:   3.7
记忆 H:   3.2
记忆 I:   2.8
记忆 J:   2.1

Min-Max 归一化:min=2.1, max=25.3, range=23.2

复制代码
记忆 A: (25.3 - 2.1) / 23.2 = 1.000
记忆 B: ( 7.8 - 2.1) / 23.2 = 0.246
记忆 C: ( 6.2 - 2.1) / 23.2 = 0.177
记忆 D: ( 5.5 - 2.1) / 23.2 = 0.147
记忆 E: ( 4.9 - 2.1) / 23.2 = 0.121
记忆 F: ( 4.1 - 2.1) / 23.2 = 0.086
记忆 G: ( 3.7 - 2.1) / 23.2 = 0.069
记忆 H: ( 3.2 - 2.1) / 23.2 = 0.047
记忆 I: ( 2.8 - 2.1) / 23.2 = 0.030
记忆 J: ( 2.1 - 2.1) / 23.2 = 0.000

看到了吗?9 条记忆的归一化分全部被压到了 0.25 以下。记忆 B(原始分 7.8,已经算是不错的匹配)和记忆 J(原始分 2.1,几乎不匹配)在归一化后只差 0.246,而语义分 0.1 的差异就能轻松覆盖这个差距。

再来看混合评分的后果。假设记忆 B 语义分 0.91,记忆 A 语义分 0.45(关键词命中但语义不太相关):

复制代码
记忆 A 总分: 0.45 + 1.000 = 1.450
记忆 B 总分: 0.91 + 0.246 = 1.156

语义高度相关的记忆 B 输给了语义不相关但关键词全命中的记忆 A。混合检索退化为关键词检索。

没有异常值也不行:跨查询的分布不一致

Min-Max 的根本问题在于:归一化参数依赖当前批次的数据分布,而 BM25 原始分的分布在不同查询间差异巨大。

短查询"部署失败"可能只有 2 个词,所有候选分都很低(0~5)。长查询"如何在 Kubernetes 集群中配置自动扩缩容策略并设置资源限制"有 10+ 个词,候选分普遍偏高(8~20)。Min-Max 把这两种情况都拉到 0, 1,等于说短查询下 BM25 分 3 对应的归一化值,和长查询下 BM25 分 15 对应的归一化值可能相同------但前者是弱匹配,后者是强匹配。Min-Max 抹平了"绝对匹配强度"的信息。

更极端的例子:如果某次查询的所有候选 BM25 分都集中在 4.0~4.5 之间(全部弱匹配),Min-Max 会把 4.5 映射为 1.0,4.0 映射为 0.0,人为制造出完全不存在的区分度。一个全局弱匹配的批次被包装成了有强有弱的分布------这是对下游加法系统的欺骗。

你需要一个归一化方案,满足三个条件:

  1. 输出值域 0, 1,与语义分可比
  2. 对异常值鲁棒,不会被单个极端分数绑架
  3. 保留"绝对匹配强度"的区分度,不受当前批次影响

追问:为什么不是其他归一化函数?

在确定 Sigmoid 之前,值得审视其他候选方案,理解为什么它们被排除。

Z-Score 归一化?

Z-Score 把分数映射为 (x - μ) / σ,输出值域无界,不满足条件 1。你可以再加一层截断到 0, 1,但截断点怎么选?又回到了和 Min-Max 同样的问题------截断参数依赖数据分布。而且 Z-Score 假设分数近似正态分布,BM25 分数明显偏态(长尾右偏),Z-Score 的理论优势在这里不成立。

Tanh?

Tanh 和 Sigmoid 是近亲:tanh(x) = 2 * sigmoid(2x) - 1,输出值域 -1, 1。需要额外线性变换才能映射到 0, 1,多一步操作,多一组参数,没有本质区别。而且 Tanh 的中点在 y=0,对 BM25 分数(始终非负)来说,负半轴完全浪费了。

Log 归一化?

normalized = log(1 + raw_score) / log(1 + max_possible)------对数压缩确实能削弱极端值,但分母 max_possible 又引入了对数据分布的依赖。如果用固定分母,那 max_possible 怎么选?本质上你仍然在做一个需要调参的映射,而且对数函数在低分区的压缩比 Sigmoid 更激进------BM25 分 1 和 3 之间的差距会被过度压缩。

百分位排名(Percentile Rank)?

把每个分数映射为它在全局分布中的百分位。理论上对异常值最鲁棒,但需要维护一个全局分数分布的统计信息,运行时开销大,且百分位是纯相对排序------完全丧失了绝对匹配强度。BM25 分 3 和 BM25 分 15 如果恰好在同一个百分位区间,它们的归一化值就相同,无论语义上天差地别。

Sigmoid 的独特优势

Sigmoid 在以上候选中脱颖而出,因为它同时满足三个条件,且不需要任何运行时数据统计:

  • 值域天然 0, 1------不需要截断、不需要额外缩放
  • 参数与数据分布解耦------midpoint 和 steepness 是预设的,不依赖当前批次
  • 非线性的压缩梯度------低分区间和高分区间的梯度天然衰减,中间区间梯度最大

这个"中间梯度大、两端梯度小"的性质,恰好对应了 BM25 分数在实际检索中的语义:中等分数区间的区分度最有价值(从"几乎不匹配"到"中等匹配"的跃迁),而极端高分区间的区分度价值递减(从"非常匹配"到"极其匹配"的差异对最终排序影响小)。

后立:Logistic Sigmoid 归一化

mem0 的选择是 Logistic Sigmoid------经典的 S 型曲线:

复制代码
normalized = 1 / (1 + exp(-steepness * (raw_score - midpoint)))

两个参数:

  • midpoint :S 曲线的中点。当 raw_score == midpoint 时,输出恰好 0.5。它定义了"什么样的 BM25 分算中等匹配"。
  • steepness:曲线的陡度。值越大,S 曲线越陡,过渡区间越窄,区分度越集中在中点附近。

Sigmoid 天然满足上面的三个条件:

值域 0, 1 ------当 raw_score → ∞ 时趋近 1,raw_score → -∞ 时趋近 0,输出永远不越界。

对异常值鲁棒------Sigmoid 的两端是饱和的。一个 BM25 分从 15 涨到 25,经过 Sigmoid 后可能只是从 0.92 变到 0.98,差异被平滑压缩。异常高分不会绑架整个归一化。

保留绝对强度------midpoint 和 steepness 是根据查询特征预设的,不依赖当前批次的数据分布。BM25 分 8 在短查询下和长查询下会被归一化到不同的值,这恰恰是正确的------短查询下 8 分已经是强匹配,长查询下 8 分只是普通。

Sigmoid 的数学性质:为什么它能平滑压缩极端值同时保留中间区分度

理解 Sigmoid 的关键在于它的导数------梯度决定了函数在每个点的"区分度"。

Sigmoid 的导数为:f'(x) = steepness * f(x) * (1 - f(x))

当 f(x) = 0.5 时(即 raw_score == midpoint),导数最大:f'(midpoint) = steepness / 4。这是 Sigmoid 区分度最高的点。

当 f(x) 趋近 0 或 1 时,导数趋近 0------这就是饱和区,区分度被压缩。

用一个具体数值来感受。取 midpoint=7.0, steepness=0.6:

复制代码
raw_score =  0  →  normalized = 0.014   (几乎为0,弱匹配被判为弱匹配 ✓)
raw_score =  3  →  normalized = 0.119   (低分区间,梯度开始上升)
raw_score =  5  →  normalized = 0.310   (中低区间,区分度显著)
raw_score =  7  →  normalized = 0.500   (中点,最大区分度)
raw_score =  9  →  normalized = 0.690   (中高区间,区分度依然显著)
raw_score = 11  →  normalized = 0.845   (高分区间,梯度开始衰减)
raw_score = 15  →  normalized = 0.960   (高饱和区,区分度被压缩)
raw_score = 25  →  normalized = 0.998   (极高分数,与15分几乎无差异)

对比之前 Min-Max 的灾难性结果:同一个 25 分的异常值,Min-Max 给出 1.000,Sigmoid 给出 0.998,差异不大。但关键区别在于------Min-Max 让其他 9 条记忆全部被压到 0.25 以下,而 Sigmoid 让 7 分的记忆获得 0.500,9 分的记忆获得 0.690。中间区间的区分度被完整保留。

这正是 Sigmoid 的核心价值:它不是简单地把极端值砍掉,而是用数学结构本身保证了"中间区分度最大化、两端区分度递减"的优先级分布。这个优先级分布与 BM25 分数在检索场景中的语义价值完美对齐。

查询长度自适应:为什么短查询和长查询不能用同一组参数?

BM25 有一个众所周知的特性:查询越长,原始分越高。因为 BM25 的评分是各查询词得分的加总,5 个词的查询天然比 2 个词的查询得分高。

这意味着,如果所有查询共用一组 midpoint 和 steepness,短查询的 BM25 分会被压到 Sigmoid 的左尾(输出接近 0),长查询的分会被推到右尾(输出接近 1)。两种情况下区分度都会丧失。

用一个极端例子来说明:假设 midpoint 固定为 7.0。

短查询"部署失败"只有 2 个有效词,候选记忆的 BM25 分通常在 0~5 之间。所有分数都落在 midpoint 左侧,Sigmoid 输出全部在 0.31 以下------9 条记忆挤在 0.01, 0.31 的窄区间里,区分度极差。

长查询"如何在 Kubernetes 集群中配置自动扩缩容策略并设置资源限制"有 10+ 个有效词,候选分通常在 8~20 之间。所有分数都落在 midpoint 右侧,Sigmoid 输出全部在 0.69 以上------同样区分度极差。

两种情况都让 Sigmoid 退化为"几乎全 0"或"几乎全 1"的常函数,失去了归一化的意义。

mem0 的解法:get_bm25_params

mem0 的解法是 get_bm25_params------根据查询的词元数量,动态选择 sigmoid 参数:

python 复制代码
def get_bm25_params(query: str, *, lemmatized: Optional[str] = None) -> tuple:
    if lemmatized is None:
        from mem0.utils.lemmatization import lemmatize_for_bm25
        lemmatized = lemmatize_for_bm25(query)
    num_terms = len(lemmatized.split()) if lemmatized else 1

    if num_terms <= 3:
        return 5.0, 0.7
    elif num_terms <= 6:
        return 7.0, 0.6
    elif num_terms <= 9:
        return 9.0, 0.5
    elif num_terms <= 15:
        return 10.0, 0.5
    else:
        return 12.0, 0.5

注意,这里统计词元数量用的是 lemmatize 后的结果,不是原始查询。这很合理------"I am attending meetings" 经过去停用词和词形还原后只剩 "attend meeting"(2 个词),而不是 4 个。

参数的演进规律:

词元数 midpoint steepness 直觉含义
≤3 5.0 0.7 短查询分低,中点前移,曲线更陡
4~6 7.0 0.6 中点右移,陡度略降
7~9 9.0 0.5 中点继续右移,陡度进一步降低
10~15 10.0 0.5 长查询,中点继续推高
>15 12.0 0.5 超长查询,高分常态,中点最高

midpoint 随查询长度递增------长查询的 BM25 原始分天然偏高,需要更高的中点才能让 Sigmoid 输出有意义的中等值。如果 15 个词的查询仍然用 midpoint=5.0,几乎所有结果都会被压到 Sigmoid 右尾(接近 1.0),BM25 的区分功能名存实亡。

steepness 随查询长度递减------短查询的候选少、分数集中,需要更陡的曲线来拉开区分度。长查询的候选多、分数分散,较平缓的曲线能保留更宽的有效区间。这个设计暗含一个假设:短查询的命中/未命中更二元化(要么精确匹配要么不匹配),长查询的匹配程度更连续。

k=1.0 和 x0=6.0 的参数含义

在 Sigmoid 的标准数学形式中,参数通常写作 k(陡度)和 x0(中点)。mem0 的代码中对应关系是:steepness = k, midpoint = x0

如果固定 k=1.0 和 x0=6.0 作为基准线,Sigmoid 在 raw_score=6 时输出 0.5,在 raw_score=2 时输出 0.018,在 raw_score=10 时输出 0.982。有效区分区间大约在 2, 10,覆盖了中等查询长度下 BM25 分数的典型分布。

但 mem0 没有使用固定参数------它的 steepness 范围是 0.5~0.7,midpoint 范围是 5.0~12.0。这意味着它比标准 Sigmoid 更平缓、更宽容。steepness=0.5 时,从 0.1 到 0.9 的过渡区间需要 raw_score 跨越约 9 个单位(对比 steepness=1.0 只需约 4.5 个单位)。更宽的过渡区间意味着更少的结果被压到极端的 0 或 1 附近,更多结果保留在有效区分区间内。

这是一个保守的设计选择:宁可牺牲一点区分度精度,也不让任何一组查询的候选被挤到 Sigmoid 的饱和区。在工程实践中,"宁可区分度稍弱,也不让整组查询失效"是更安全的策略。

前置工序:lemmatize_for_bm25 的预处理

在调用 get_bm25_params 之前,查询先经过 lemmatize_for_bm25 预处理。这个函数承担两项职责,而且这两项职责的交互对 BM25 的召回率有深远影响。

第一项:词形还原

用 spaCy 的 lemmatizer 把 "attending" 归为 "attend","memories" 归为 "memory","older" 归为 "old"。这保证 BM25 的关键词匹配不受词形变化干扰。

词形还原对 BM25 的贡献是本质性的。BM25 是精确词元匹配------如果索引中存储的是 "running",而查询词是 "ran",BM25 不会给任何分数。词形还原把两者统一为 "run",消除了这个召回缺口。

考虑一个实际场景:用户存储了"我每天跑步五公里",索引中的词元是 "run 5 kilometer"。后来用户查询"跑步习惯",查询词元是 "run habit"。"run" 精确匹配,BM25 给出分数。如果没有词形还原,查询词 "running" 无法匹配索引中的 "run",这条记忆就丢失了。

这不是小概率事件------英语中动词形态变化极为普遍(run/running/ran,attend/attending/attended,meet/meeting/met),名词复数和比较级同样常见。在 mem0 的场景中,用户存储记忆时的措辞和查询时的措辞几乎必然存在词形差异。词形还原是 BM25 召回率的基石。

第二项:-ing 形式的双保留

这是一个精巧的 hack:

python 复制代码
# Also add original if it ends in -ing and differs from lemma.
# This handles noun/verb ambiguity (meeting/meet, attending/attend).
if token.text.endswith("ing") and token.text != lemma and token.text.isalnum():
    tokens.append(token.text)

为什么要双保留?因为 spaCy 的词形还原依赖上下文。"meeting" 作为名词时,spaCy 可能保留 "meeting";作为动词时,还原为 "meet"。但 BM25 是无上下文的关键词匹配,如果索引中存储的是名词形态 "meeting",而查询被还原成了 "meet",就永远匹配不上。

反过来更危险:如果存储时 spaCy 把 "meeting" 还原为 "meet"(因为它在存储上下文中被判定为动词),而查询时 "meeting" 被保留为名词形态(因为在查询上下文中被判定为名词),同样匹配失败。

这是一个经典的 NLP 歧义问题在信息检索中的投影。spaCy 的词形还原器在理想情况下能正确区分 "meeting" 的名词和动词用法,但在短文本、无上下文的记忆片段中,它的判断不可靠。

解决方案简单粗暴但有效:-ing 结尾的词,lemma 和原形都保留。"meeting" 同时索引 "meet" 和 "meeting",两边都能命中。代价是索引膨胀(每个 -ing 词多一个 token),但在 mem0 的场景中(记忆数量有限、单个记忆文本短),这个代价可以忽略。

完整的预处理流程

python 复制代码
def lemmatize_for_bm25(text: str) -> str:
    nlp = get_nlp_lemma()
    if nlp is None:
        return text

    doc = nlp(text.lower())
    tokens = []
    for token in doc:
        if token.is_punct or token.is_stop:
            continue
        lemma = token.lemma_
        if lemma.isalnum():
            tokens.append(lemma)
        if token.text.endswith("ing") and token.text != lemma and token.text.isalnum():
            tokens.append(token.text)

    return " ".join(tokens)

逐步拆解这个函数的每一步:

  1. 获取 spaCy 模型get_nlp_lemma() 懒加载一个轻量级 spaCy pipeline(只做 tokenization 和 lemmatization,不做 NER 和 parser,节省资源)。如果 spaCy 不可用,直接返回原文------降级策略保证功能不中断。

  2. 转小写text.lower()------BM25 是大小写敏感的精确匹配,统一小写避免 "Deploy" 和 "deploy" 不匹配。

  3. spaCy 分词nlp(text.lower()) 生成 Doc 对象,每个 token 带有词性标注和词形还原结果。

  4. 过滤停用词和标点token.is_punct or token.is_stop------"the", "is", "a" 这类词对 BM25 匹配无贡献,反而增加噪声。注意 BM25 本身有 IDF 机制会降低高频词的权重,但停用词的 IDF 极低,几乎不贡献分数,不如直接过滤以减少计算量。

  5. 词形还原token.lemma_------"attending" → "attend","older" → "old"。

  6. -ing 双保留 :如果原词以 "-ing" 结尾且与 lemma 不同,追加原词。条件 token.text.isalnum() 过滤掉 "something" 这类伪 -ing 词。

  7. 空格拼接:返回空格分隔的词元序列。

最终输出是一个空格分隔的词元序列,既用于 BM25 关键词搜索的查询,也用于 get_bm25_params 中统计词元数量。

Qdrant 内置 BM25 vs mem0 自建 BM25:为什么不能直接用 Qdrant 的分数?

mem0 使用 Qdrant 作为默认向量存储。Qdrant 本身支持 BM25 稀疏向量搜索------在创建 collection 时注册一个 bm25 命名的稀疏向量槽位,使用 SparseVectorParams(modifier=Modifier.IDF) 配置,查询时通过 query_points(using="bm25") 执行稀疏向量搜索。

但 Qdrant 返回的 BM25 分数是原始分------没有归一化,没有上界。这就是 mem0 必须自建归一化层的原因。

具体来说,Qdrant 的 BM25 实现路径是:

  1. 写入时 :用 fastembed 的 SparseTextEmbedding(model_name="Qdrant/bm25") 把文本编码为 BM25 稀疏向量(词元 → 稀疏索引,权重由 BM25 公式计算)。稀疏向量与稠密向量一起存入同一个 Qdrant point。

  2. 查询时:查询文本同样被编码为 BM25 稀疏向量,然后在 Qdrant 中执行稀疏向量相似度搜索,返回的 score 是原始 BM25 分。

  3. IDF 修饰器Modifier.IDF 让 Qdrant 在计算稀疏向量相似度时自动考虑逆文档频率,等价于 BM25 的 IDF 组成部分。

Qdrant 做了 BM25 的评分计算,但没有做归一化------这是存储引擎的合理边界。Qdrant 作为一个通用向量数据库,不应该对用户的分数分布做假设。归一化策略是应用层的决策,取决于下游如何使用这些分数。

mem0 不能直接用 Qdrant 的原始分,因为:

  • 混合加法需要可比量级------Qdrant 返回的原始 BM25 分与语义分不在同一量级。
  • 查询长度影响------不同查询长度的 BM25 分分布不同,Qdrant 不感知查询上下文,无法自适应调整。
  • 多存储引擎一致性------mem0 支持多种向量存储(Qdrant、Milvus、Pinecone、PGVector...),归一化必须在应用层统一,不能依赖特定存储引擎的行为。

所以 mem0 的架构选择是:Qdrant 负责 BM25 的评分计算和存储,mem0 负责 BM25 的归一化和融合。各司其职,边界清晰。

这个架构也解释了为什么 keyword_search 返回 None 是合法的------如果 Qdrant collection 是 v3 之前创建的(没有 bm25 稀疏槽位),或者 fastembed 未安装,BM25 搜索会被静默禁用,检索退化为纯语义搜索。这不是错误,而是降级策略。

源码解读:从原始分数到归一化分数的完整链路

现在把所有拼图放在一起,追踪一次搜索请求中 BM25 分数的完整生命周期。

Step 1: 查询预处理

python 复制代码
# main.py, search()
query_lemmatized = lemmatize_for_bm25(query)

用户输入 "I am attending meetings about deployment"。

经过 lemmatize_for_bm25

  • "i" → 停用词,过滤
  • "am" → 停用词,过滤
  • "attending" → lemma "attend",-ing 双保留 → tokens: "attend", "attending"
  • "meetings" → lemma "meeting",-ing 双保留 → tokens: "meeting", "meetings"
  • "about" → 停用词,过滤
  • "deployment" → lemma "deployment" → tokens: "deployment"

输出:"attend attending meeting meetings deployment",共 5 个词元。

Step 2: 获取 Sigmoid 参数

python 复制代码
midpoint, steepness = get_bm25_params(query, lemmatized=query_lemmatized)

num_terms = len("attend attending meeting meetings deployment".split()) = 5,落入 4~6 档位,返回 (7.0, 0.6)

Step 3: 执行 BM25 关键词搜索

python 复制代码
keyword_results = self.vector_store.keyword_search(
    query=query_lemmatized, top_k=internal_limit, filters=filters
)

Qdrant 把 query_lemmatized 编码为 BM25 稀疏向量,执行稀疏搜索,返回原始分数的结果列表。

Step 4: 逐条归一化

python 复制代码
bm25_scores = {}
if keyword_results is not None:
    midpoint, steepness = get_bm25_params(query, lemmatized=query_lemmatized)
    for mem in keyword_results:
        mem_id = str(mem.id) if hasattr(mem, 'id') else str(mem.get('id', ''))
        raw_score = mem.score if hasattr(mem, 'score') else mem.get('score', 0)
        if raw_score and raw_score > 0:
            bm25_scores[mem_id] = normalize_bm25(raw_score, midpoint, steepness)

对每条结果,调用 normalize_bm25(raw_score, 7.0, 0.6)

python 复制代码
def normalize_bm25(raw_score: float, midpoint: float, steepness: float) -> float:
    return 1.0 / (1.0 + math.exp(-steepness * (raw_score - midpoint)))

完整计算过程,以 raw_score=8.3 为例:

  1. 计算偏移量:raw_score - midpoint = 8.3 - 7.0 = 1.3
  2. 乘以陡度:steepness * offset = 0.6 * 1.3 = 0.78
  3. 取负:-0.78
  4. 指数运算:exp(-0.78) = 2.718^(-0.78) ≈ 0.458
  5. 加 1:1 + 0.458 = 1.458
  6. 取倒数:1 / 1.458 ≈ 0.686

归一化结果:0.686。这个值在 (0, 1) 区间内,表示"中等偏上的匹配",与直觉一致------8.3 分略高于中点 7.0,归一化值略高于 0.5。

注意过滤条件 if raw_score and raw_score > 0:零分和负分的结果直接跳过,不进入归一化。这避免了 exp(0) = 1 带来的边界情况(raw_score=0 时,如果 midpoint=7.0,归一化值约 0.014,接近 0 但不等于 0,可能在下游加法中产生微弱的噪声)。

Step 5: 三路信号融合

python 复制代码
# score_and_rank() in scoring.py
raw_combined = semantic_score + bm25_score + entity_boost
combined = min(raw_combined / max_possible, 1.0)

max_possible 根据活跃信号动态调整:

  • 只有语义分:1.0
  • 语义 + BM25:2.0
  • 语义 + BM25 + 实体增强:2.5
  • 语义 + 实体增强(无 BM25):1.5

除以 max_possible 把总分归一化到 0, 1,确保不同配置下的分数可比。

此时三路信号都在可比的量级:语义分 0, 1、BM25 分 (0, 1)、实体增强分 0, 0.5。加法终于有了意义。

完整调用链路图

复制代码
search() / asearch()
  │
  ├─ Step 1: query_lemmatized = lemmatize_for_bm25(query)
  │     ├─ 转小写
  │     ├─ spaCy 分词
  │     ├─ 过滤停用词和标点
  │     ├─ 词形还原 (attending → attend)
  │     └─ -ing 双保留 (attend + attending)
  │
  ├─ Step 2: query_entities = extract_entities(query)
  │
  ├─ Step 3: embeddings = embedding_model.embed(query, "search")
  │
  ├─ Step 4: semantic_results = vector_store.search(query, embeddings, top_k)
  │
  ├─ Step 5: keyword_results = vector_store.keyword_search(query_lemmatized, top_k)
  │     ├─ fastembed 编码为 BM25 稀疏向量
  │     ├─ Qdrant 稀疏向量搜索 (using="bm25", modifier=IDF)
  │     └─ 返回原始 BM25 分数
  │
  ├─ Step 6: BM25 归一化
  │     ├─ midpoint, steepness = get_bm25_params(query, lemmatized=query_lemmatized)
  │     │     └─ 根据词元数选择参数 (5个词 → midpoint=7.0, steepness=0.6)
  │     └─ for each keyword_result:
  │          bm25_scores[mem_id] = normalize_bm25(raw_score, midpoint, steepness)
  │               └─ 1 / (1 + exp(-steepness * (raw_score - midpoint)))
  │
  ├─ Step 7: entity_boosts = _compute_entity_boosts(query_entities, filters)
  │
  └─ Step 8: score_and_rank(semantic_results, bm25_scores, entity_boosts, threshold, top_k)
       ├─ 过滤语义分低于 threshold 的候选
       ├─ raw_combined = semantic + bm25 + entity_boost
       ├─ combined = min(raw_combined / max_possible, 1.0)
       └─ 按 combined 降序排序,返回 top_k

Sigmoid 的平滑过渡 vs 极端分数的区分度损失

Sigmoid 不是没有代价。它的核心 Trade-off 在于:两端饱和区间的区分度损失

当 BM25 原始分远高于 midpoint 时,Sigmoid 输出趋近 1。一个 15 分的结果和一个 25 分的结果,归一化后可能是 0.93 和 0.99------看起来差不多,但原始分差距是 10 分。

这不是 bug,而是设计意图。mem0 的混合检索中,语义分和 BM25 分应该是互补而非替代的关系。如果一个记忆的 BM25 分已经 15 了,说明关键词匹配极其充分,此时继续放大它与 25 分结果的差距,对最终排序几乎没有帮助------因为语义分才是区分"关键词都命中但语义相关度不同"的候选的关键。

但这个 Trade-off 在某些边界情况下会暴露问题。考虑两个记忆:

  • 记忆 X:BM25 分 25,语义分 0.30(关键词全命中,但语义不相关)
  • 记忆 Y:BM25 分 15,语义分 0.50(部分关键词命中,语义相关度中等)

归一化后:X 的 BM25 分 ≈ 0.99,Y 的 BM25 分 ≈ 0.93。差值只有 0.06,而语义差值是 0.20。最终 X 的总分(0.30 + 0.99 = 1.29)仍然高于 Y(0.50 + 0.93 = 1.43)------等等,Y 赢了?

是的,在这个例子中 Y 赢了。这正是 Sigmoid 归一化设计意图的体现------当 BM25 分数都进入饱和区时,语义分接管了排序权。但如果你把 Y 的语义分换成 0.20,X 就会赢(1.29 vs 1.13)。这个边界地带的排序行为取决于语义分和 BM25 分的相对大小,Sigmoid 保证的是两者在量级上可比,但不保证每种情况下谁胜谁负------那是语义分和 BM25 分各自的信号质量决定的。

另一个隐含的 Trade-off:get_bm25_params 的分段参数是硬编码的。5 个档位、5 组参数,全凭经验设定。这意味着它的最优性依赖于 mem0 的典型工作负载------如果 BM25 底层实现换了,或者文档语料的长度分布剧变,这些参数可能需要重新校准。硬编码的好处是零运行时开销,坏处是无法自适应。

还有一个容易被忽略的 Trade-off:词元计数的准确性get_bm25_paramslemmatize_for_bm25 的输出统计词元数,而 lemmatize 的 -ing 双保留策略会让词元数虚高。"attending" 贡献了 2 个 token("attend" 和 "attending"),但它本质上是一个语义单元。这意味着词元数可能被高估,导致选择了更激进的 midpoint(更偏右),Sigmoid 的中点被不必要地推高。在实践中,-ing 双保留只影响少数词元,偏差有限,但这是一个理论上的不完美之处。

小结

BM25 原始分不能直接加到语义分上,因为量级差异会让混合检索退化为关键词检索。Min-Max 归一化依赖批次数据分布,会被异常值绑架------一个 25 分的异常值能让其他 9 条记忆全部被压到 0.25 以下。其他候选方案(Z-Score、Tanh、Log、百分位)各有缺陷,无法同时满足值域有界、对异常值鲁棒、保留绝对匹配强度三个条件。

Sigmoid 归一化用预设的 midpoint 和 steepness,将 BM25 分映射到 0, 1,对异常值鲁棒,同时保留绝对匹配强度信息。它的数学结构天然保证了中间区间区分度最大、两端饱和------这与 BM25 分数在检索场景中的语义价值对齐。

查询长度自适应参数是整个设计的点睛之笔------它让 Sigmoid 的中点随查询词元数动态调整,确保短查询和长查询都能在 Sigmoid 的有效区间内获得有意义的区分度。词形还原(lemmatization)是 BM25 召回率的基石,而 -ing 双保留策略解决了 spaCy 上下文依赖的词形还原在短文本场景下的歧义问题。

代价是极端高分区间的区分度损失,硬编码参数的经验性,以及 -ing 双保留对词元计数的轻微膨胀。但在"量级可比"和"区分度保留"之间,这是 mem0 做出的务实选择。Sigmoid 不是最优雅的方案,但它是约束条件下的最优解------在工程中,这比优雅更重要。

至此,语义分、BM25 分、实体增强分三路信号终于可以在同一个量级上相加,初始排序的管线算是闭环了。但一个问题随之浮现:初始排序搞定了,为什么还需要二次精排?

相关推荐
IT邦德1 小时前
Oracle 26ai RAC 通过gold image RU打补丁
数据库·oracle
DogDaoDao1 小时前
【第 04 篇】列表与元组 —— 序列类型核心详解
人工智能·python·深度学习·神经网络·机器学习·conda·numpy
dongf20191 小时前
R 语言随机森林算法
算法·随机森林·r语言
idingzhi1 小时前
A股量化策略日报(2026年06月07日)
python
xingpanvip1 小时前
使用 Webwright 在 CSDN 自动发文:Python 浏览器自动化实践
开发语言·python·自动化
armwind1 小时前
openISP学习7-CCM — Color Correction Matrix(色彩校正矩阵)
python·学习·矩阵
C137的本贾尼1 小时前
MySQL 整体架构与存储引擎对比
数据库·mysql·架构
艺杯羹1 小时前
零成本!3步设置Windows动态壁纸,免费无广告
python
AZaLEan__1 小时前
图论:拓扑排序
算法·深度优先