45.2 相关性排序

一句话

相关性排序回答“同样是命中,谁该排前面”:TF-IDF 用“词频 × 逆文档频率”,BM25 再加“文档长度归一 + 词频饱和”——后者是工业默认,因为它在长文档与高频词上不容易跑偏。

三个因子

因子 直觉 公式里的体现
TF(词频) 出现越多越相关 tf;BM25 做了饱和:tf(k1+1)/(tf + k1(1-b+b·dl/avgdl))
IDF(逆文档频率) 越少见的词越有区分度 log((N-df+0.5)/(df+0.5)+1)
长度归一 长文档天然词多,不该占便宜 b 参数控制归一强度(0 不归一,1 完全归一)

🔑 TF-IDF 是“线性加分”,BM25 是“饱和加分”:一个词出现 100 次不该比出现 10 次相关 10 倍。k1 控制饱和速度,b 控制长度惩罚——这两个参数就是“调相关性”的主要旋钮。

代码:同一批文档,TF-IDF 与 BM25 排名不同

package main

import (
	"fmt"
	"math"
	"sort"
	"strings"
)

// 文档按“词 + 空格”预处理(真实系统里由分词器产出)
var docs = []string{
	"手机 屏幕 更换 教程",
	"手机 维修 屏幕 维修 电池 维修 主板 维修 摄像头 维修", // 关键词堆砌 + 长
	"屏幕 保护膜 手机 保护壳",
	"手机",
	"笔记本 电脑 屏幕 维修 指南 包含 拆机 步骤 与 注意 事项", // 只有“屏幕”
}

var query = "手机 屏幕"

func tokenize(s string) []string {
	return strings.Fields(s)
}

func main() {
	N := float64(len(docs))
	// 文档词频
	tfs := make([]map[string]float64, len(docs))
	dl := make([]float64, len(docs))
	df := map[string]float64{}
	avgdl := 0.0
	for i, d := range docs {
		ts := tokenize(d)
		dl[i] = float64(len(ts))
		avgdl += dl[i]
		tfs[i] = map[string]float64{}
		seen := map[string]bool{}
		for _, t := range ts {
			tfs[i][t]++
			if !seen[t] {
				seen[t] = true
				df[t]++
			}
		}
	}
	avgdl /= N
	q := tokenize(query)

	// TF-IDF
	tfidf := make([]float64, len(docs))
	for i := range docs {
		for _, t := range q {
			if tfs[i][t] > 0 {
				tfidf[i] += tfs[i][t] * math.Log(N/df[t])
			}
		}
	}

	// BM25(k1=1.2, b=0.75 是常用默认)
	const k1, b = 1.2, 0.75
	bm25 := make([]float64, len(docs))
	for i := range docs {
		for _, t := range q {
			tf := tfs[i][t]
			if tf == 0 {
				continue
			}
			idf := math.Log((N-df[t]+0.5)/(df[t]+0.5) + 1)
			denom := tf + k1*(1-b+b*dl[i]/avgdl)
			bm25[i] += idf * tf * (k1 + 1) / denom
		}
	}

	type row struct {
		idx        int
		tfidf, bm  float64
	}
	var rows []row
	for i := range docs {
		rows = append(rows, row{i, tfidf[i], bm25[i]})
	}

	fmt.Println("=== 各文档得分(查询:手机 屏幕)===")
	fmt.Printf("%-3s %-52s %-8s %-8s %-6s\n", "#", "文档", "TF-IDF", "BM25", "词数")
	for _, r := range rows {
		fmt.Printf("%-3d %-52s %-8.2f %-8.2f %-6.0f\n", r.idx, docs[r.idx], r.tfidf, r.bm, dl[r.idx])
	}

	sort.Slice(rows, func(i, j int) bool { return rows[i].tfidf > rows[j].tfidf })
	fmt.Print("\nTF-IDF 排名:")
	for _, r := range rows {
		fmt.Printf("#%d(%.2f) ", r.idx, r.tfidf)
	}
	sort.Slice(rows, func(i, j int) bool { return rows[i].bm > rows[j].bm })
	fmt.Print("\nBM25  排名:")
	for _, r := range rows {
		fmt.Printf("#%d(%.2f) ", r.idx, r.bm)
	}
	fmt.Println()

	// b 参数的影响:b=0 不惩罚长文档,b=1 完全按长度归一
	fmt.Println("\n=== b 参数对长文档(#4,词数最多)的影响 ===")
	for _, bb := range []float64{0, 0.3, 0.75, 1.0} {
		score := 0.0
		for _, t := range q {
			tf := tfs[4][t]
			if tf == 0 {
				continue
			}
			idf := math.Log((N-df[t]+0.5)/(df[t]+0.5) + 1)
			score += idf * tf * (k1 + 1) / (tf + k1*(1-bb+bb*dl[4]/avgdl))
		}
		fmt.Printf("b=%-4.2f 文档 #4 得分 %.3f\n", bb, score)
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== 各文档得分(查询:手机 屏幕)===
#   文档                                                   TF-IDF   BM25     词数    
0   手机 屏幕 更换 教程                                          0.45     0.67     4     
1   手机 维修 屏幕 维修 电池 维修 主板 维修 摄像头 维修                       0.45     0.45     10    
2   屏幕 保护膜 手机 保护壳                                        0.45     0.67     4     
3   手机                                                   0.22     0.44     1     
4   笔记本 电脑 屏幕 维修 指南 包含 拆机 步骤 与 注意 事项                     0.22     0.21     11    

TF-IDF 排名:#0(0.45) #1(0.45) #2(0.45) #3(0.22) #4(0.22) 
BM25  排名:#0(0.67) #2(0.67) #1(0.45) #3(0.44) #4(0.21) 

=== b 参数对长文档(#4,词数最多)的影响 ===
b=0.00 文档 #4 得分 0.288
b=0.30 文档 #4 得分 0.253
b=0.75 文档 #4 得分 0.215
b=1.00 文档 #4 得分 0.198

读数:

现象 含义
TF-IDF 下 #0/#1/#2 并列 0.45 线性加分且不做长度归一:堆砌的关键词只要出现,得分就和小而精准的文档一样
BM25 让 #0/#2 以 0.67 领先、#1 掉到 0.45 长度归一 + TF 饱和,长文档堆砌不再占便宜
b 从 0 调到 1,长文档 #4 从 0.288 降到 0.198(-31%) b 就是“长度惩罚”的旋钮

🔑 BM25 的两个参数怎么给:k1=1.2、b=0.75 是通用默认;短文本(标题、标签)可以调大 b(更惩罚长文档),长文本(正文)调小 b(避免长文档被过度惩罚)。调参的唯一依据是离线评估集上的 NDCG/MRR,不是“感觉”。

除了相关性,还会影响排序的因素

因素 作用 失控后果
字段权重(title > body) 标题命中更重要 全站标题堆关键词
时间衰减 新内容优先 老内容永不出现
业务权重(销量、评分) 商业化排序 相关性被完全淹没
个性化 按用户偏好调权 结果不可解释、冷启动差
去重/打散 同质内容不霸屏 首页全是同一商品的不同规格

⚠️ 相关性调参最容易犯的错是“用线上点击率直接调到满意为止”:点击受位置偏置影响(排第一的天然点击高),必须用无偏评估(随机化曝光、逆倾向加权)或离线标注集,否则越调越偏。

反模式

反模式 后果
只用 TF-IDF,不做长度归一 长文档/关键词堆砌霸榜
把 IDF 用在“每个文档都出现”的词上 IDF≈0,不起作用,白算
为了“相关性”无限加字段权重 排序不可解释,调参失控
相关性调参只看线上点击 位置偏置导致越调越偏
同义词扩展后不降权 扩展词把不相关结果拉上来
不做去重/打散 同质内容占满首页

自检

Q1 为什么关键词堆砌的文档在 TF-IDF 下排名很高,在 BM25 下会被压下去?

▸ 答案:TF-IDF 的 TF 是线性的:词出现 100 次就是 10 次的 10 倍分。BM25 的 TF 是饱和的:tf(k1+1)/(tf+k1·…) 随 tf 增长但趋于上限,堆砌收益递减;同时 BM25 用 dl/avgdl 做长度归一,长文档(往往就是堆砌的)分母更大、得分更低。两者叠加,堆砌的性价比大幅下降。

Q2 b 参数调大调小分别适合什么场景?

▸ 答案:b 控制长度归一强度。b=1 完全按长度归一,适合“短文本、长度差异大”的场景(标题、标签、商品名),避免长标题占便宜;b=0 不做长度归一,适合“长文本、长度差异小”的场景(论文、正文),否则长文档会被过度惩罚。默认 0.75 是折中。

Q3 为什么“线上点击率高”不能直接证明相关性好?

▸ 答案:因为点击受位置偏置影响:排在第一的结果天然被点得多,即使它并不比第三更相关。直接用点击训练/调参,会强化“已经排在前面的”而不是“更相关的”。正确做法是随机化部分曝光(探索)、用逆倾向加权(IPS)或离线标注评估 NDCG,把“位置效应”与“相关性”分开。

关联