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,把“位置效应”与“相关性”分开。