数据库基础体系 · 第 126/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Elasticsearch 相关性调优:BM25、Boost、Function Score 和评测集

在 Elasticsearch 中,“相关性”不是一个单独的开关,而是一条完整的排序链路:

  1. 查询文本经过分析器拆分为词项;
  2. 查询子句决定哪些文档可以进入候选集;
  3. 相似度模型计算文本匹配分数,默认通常是 BM25;
  4. 查询和字段上的 boost 改变分数权重;
  5. function_score 可以把业务信号加入原始文本分数;
  6. 结果按 _score 排序;
  7. 通过评测集和离线指标判断排序是否真的改善。

如果只修改 boost,通常只能改变已有匹配结果的相对顺序;如果候选文档根本没有被查询召回,boost 和 function_score 都无法把它“变出来”。因此,相关性调优必须同时区分:

  • 召回:相关文档是否进入候选集;
  • 排序:相关文档是否排在不相关文档前面;
  • 评测:修改是否在一组稳定查询上产生真实收益。

一、先明确 Elasticsearch 中的 _score

Elasticsearch 的全文查询通常会为每个匹配文档计算 _score。这个分数用于排序,但它不是概率,也没有跨查询直接比较的统计意义。

例如,查询“数据库事务”得到的最高分是 8.2,查询“Redis”得到的最高分是 2.1,不能据此判断前一个查询的结果“相关性是后一个的四倍”。不同查询包含的词项、词频、文档频率和查询结构不同,分数尺度也会变化。

下面几类查询的评分行为不同:

{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "title": "Elasticsearch"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "status": "published"
          }
        }
      ]
    }
  }
}
  • must 中的查询负责匹配,并通常参与评分;
  • filter 只负责过滤,不计算相关性分数;
  • 如果把全文查询放入 filter,结果仍可能正确,但 _score 通常是 0,不能再依赖文本相关性排序;
  • bool 中多个评分子句的分数会按照查询结构组合。

这也是一个常见错误:

{
  "query": {
    "bool": {
      "filter": [
        {
          "match": {
            "content": "事务隔离级别"
          }
        }
      ]
    }
  },
  "sort": [
    "_score"
  ]
}

这段查询可以过滤出包含相关词项的文档,但因为全文查询在 filter 上下文中不参与评分,按 _score 排序没有实际意义。若需要按文本相关性排序,应使用 mustshould

{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "content": "事务隔离级别"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "status": "published"
          }
        }
      ]
    }
  },
  "sort": [
    {
      "_score": "desc"
    }
  ]
}

二、BM25:默认文本相关性模型

2.1 BM25 解决什么问题

BM25 是一种基于词项统计的文本相关性模型。它主要回答:

查询词在某篇文档中出现得多不多?这个词在整个索引中稀不稀有?文档长度是否造成了偏差?

BM25 的核心因素有三个:

  1. 词项频率:查询词在当前文档中出现的次数;
  2. 逆文档频率:查询词在整个索引中是否常见;
  3. 文档长度归一化:较长文档天然更容易包含查询词,需要进行校正。

Elasticsearch 的默认文本相似度通常基于 Lucene 的 BM25 实现。具体分数会受到 Lucene 和 Elasticsearch 版本、字段类型、分析器以及查询结构影响,因此不能把公式当作所有复杂查询的逐字节实现,但它准确表达了 BM25 的基本机制。


2.2 BM25 公式

对于查询中的一个词项,BM25 的典型形式为:

score(qi,d)=IDF(qi)×f(qi,d)×(k1+1)f(qi,d)+k1×(1b+b×davgdl)\text{score}(q_i,d) = \text{IDF}(q_i) \times \frac{f(q_i,d)\times(k_1+1)} {f(q_i,d)+k_1\times\left(1-b+b\times\frac{|d|}{\text{avgdl}}\right)}

其中:

  • qiq_i:查询中的第 ii 个词项;
  • dd:当前文档;
  • f(qi,d)f(q_i,d):词项 qiq_i 在文档 dd 中的词频;
  • d|d|:文档长度,通常是该字段的 token 数量;
  • avgdl\text{avgdl}:该字段在索引中的平均长度;
  • k1k_1:词频饱和参数;
  • bb:文档长度归一化参数;
  • IDF(qi)\text{IDF}(q_i):逆文档频率。

逆文档频率常写作:

IDF(qi)=ln(1+Ndf(qi)+0.5df(qi)+0.5)\text{IDF}(q_i) = \ln\left( 1+\frac{N-\text{df}(q_i)+0.5} {\text{df}(q_i)+0.5} \right)

其中:

  • NN:参与统计的文档数;
  • df(qi)\text{df}(q_i):包含词项 qiq_i 的文档数。

多个查询词项的分数通常相加:

score(q,d)=qiqscore(qi,d)\text{score}(q,d) = \sum_{q_i\in q} \text{score}(q_i,d)

实际 Elasticsearch 查询可能还会引入查询 boost、字段权重、布尔组合方式等额外因素。


2.3 词频为什么会“饱和”

BM25 不会让词项出现次数越多,分数无限线性增长。

取:

  • k1=1.2k_1=1.2
  • b=0.75b=0.75
  • 文档长度 d=100|d|=100
  • 平均长度 avgdl=80\text{avgdl}=80
  • 词频 f(qi,d)=2f(q_i,d)=2

长度归一化项为:

1b+b×davgdl=10.75+0.75×10080=1.18751-b+b\times\frac{|d|}{\text{avgdl}} = 1-0.75+0.75\times\frac{100}{80} = 1.1875

词频部分为:

2×(1.2+1)2+1.2×1.1875=4.43.4251.285\frac{2\times(1.2+1)} {2+1.2\times1.1875} = \frac{4.4}{3.425} \approx1.285

如果词频从 2 增加到 20

20×2.220+1.2×1.1875=4421.4252.054\frac{20\times2.2} {20+1.2\times1.1875} = \frac{44}{21.425} \approx2.054

词频增加了十倍,但词频因子只从约 1.285 增加到约 2.054。这就是饱和效应:词出现几次通常很有帮助,但重复几十次不会被认为是几十倍相关。

这能抑制关键词堆砌,但不能完全防止垃圾文本。若业务允许用户输入大量重复词项,还应在分析、内容清洗和召回策略层面处理。


2.4 逆文档频率为什么重要

假设索引中有 1000 篇文档,查询词在 10 篇文档中出现:

IDF=ln(1+100010+0.510+0.5)4.56\text{IDF} = \ln\left( 1+\frac{1000-10+0.5}{10+0.5} \right) \approx4.56

如果另一个词出现在 900 篇文档中:

IDF=ln(1+1000900+0.5900+0.5)0.11\text{IDF} = \ln\left( 1+\frac{1000-900+0.5}{900+0.5} \right) \approx0.11

前一个词更能区分文档,因此贡献更大的分数。常见词并非没有价值,但它们对区分相关文档的帮助更小。

这也解释了一个反直觉现象:

文档中出现了更多查询词,不一定意味着它得分更高;词项是否稀有、出现位置所在字段以及文档长度同样重要。


2.5 文档长度归一化与长文档偏差

在其他条件相同的情况下,长文档更容易偶然包含查询词。BM25 使用 bb 对此进行归一化:

  • b=0b=0:不进行长度归一化;
  • b=1b=1:完全按照文档长度进行归一化;
  • 常见默认值约为 0.75,但这不是所有领域的最佳值。

例如:

  • title 通常很短,长度差异小;
  • content 可能从几百字到几十万字,长度差异大;
  • tags 字段长度几乎固定,长度归一化的收益可能有限。

因此,不能只因为某个字段的默认 BM25 表现不好,就直接调整全局参数。更常见的原因可能是:

  • 查询词被错误分析;
  • 字段内容混入了不应参与检索的文本;
  • 标题和正文没有分开建模;
  • 业务要求精确匹配,但使用了宽松的全文查询;
  • 文档长度包含了模板、导航、重复内容。

2.6 修改 BM25 参数

可以在创建索引时定义自定义相似度:

curl -X PUT 'http://localhost:9200/articles' \
  -H 'Content-Type: application/json' \
  -d '
{
  "settings": {
    "index": {
      "similarity": {
        "article_bm25": {
          "type": "BM25",
          "k1": 1.2,
          "b": 0.5
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "similarity": "article_bm25"
      },
      "content": {
        "type": "text",
        "similarity": "article_bm25"
      }
    }
  }
}
'

这里将 b 从常见默认值调低到 0.5,表示降低文档长度对分数的影响。

这不是“文档越长越好”,而是假设:

在这个业务中,较长的文章不应因为长度被过度惩罚。

是否成立必须通过评测集验证。一个适合新闻标题的参数,未必适合法律条款、代码搜索或商品描述。

修改相似度后,应明确验证:

  1. 新旧索引的映射和相似度配置;
  2. 同一查询下 _score 和排序变化;
  3. 评测集指标变化;
  4. 长文档和短文档的边界案例;
  5. 重建索引或重新写入后的线上结果。

不要只观察单条查询的分数。BM25 的参数改变通常会改变大量文档之间的相对顺序。


三、Boost:改变查询结构中的相对权重

3.1 Boost 的含义

Boost 是一个权重因子,用于提高或降低某个查询子句、字段或查询整体对最终分数的影响。

例如:

{
  "query": {
    "multi_match": {
      "query": "Elasticsearch 相关性",
      "fields": [
        "title^3",
        "summary^2",
        "content"
      ]
    }
  }
}

这里:

  • title^3 表示标题字段的权重为 3;
  • summary^2 表示摘要字段的权重为 2;
  • content 使用默认权重 1。

它表达的是业务假设:

在其他条件相近时,标题中的匹配比正文中的匹配更重要。

Boost 不是概率,也不是百分制。^3 不保证标题匹配分数一定是正文匹配的三倍,因为不同字段的词频、文档频率、长度和查询结构都可能不同。


3.2 Boost 只影响已匹配的文档

下面两个文档:

文档 A:title = "Elasticsearch 相关性调优"
文档 B:title = "Redis 缓存设计"

查询:

{
  "query": {
    "match": {
      "title": {
        "query": "Elasticsearch",
        "boost": 5
      }
    }
  }
}

文档 A 会获得较高分,文档 B 不会因为 boost 变成匹配文档。Boost 不能突破查询的匹配条件。

如果想让“标题匹配”成为强约束,可以使用 must;如果只是额外加分,可以使用 should

{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "content": "事务"
          }
        }
      ],
      "should": [
        {
          "match": {
            "title": {
              "query": "事务",
              "boost": 3
            }
          }
        }
      ]
    }
  }
}

语义是:

  • 正文必须匹配“事务”;
  • 标题也匹配“事务”的文档获得额外分数;
  • 标题不匹配的文档仍可能进入结果。

这与下面的查询不同:

{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "title": {
              "query": "事务",
              "boost": 3
            }
          }
        }
      ]
    }
  }
}

第二种写法要求标题必须匹配,boost 不再只是排序偏好,而同时参与候选集约束。


3.3 Boost 的组合方式

对于不同字段,multi_match 的类型会影响分数如何组合。常见类型包括:

  • best_fields:倾向于使用单个字段中得分最高的匹配;
  • most_fields:多个字段的匹配分数可以累加;
  • cross_fields:尝试把多个字段视作一个组合字段;
  • phrasephrase_prefix:改用短语匹配逻辑。

例如,文档标题中有“数据库”,正文中有“事务”,查询为“数据库事务”:

  • best_fields 可能偏向某一个字段的最佳匹配;
  • most_fields 可能让多个字段共同贡献分数;
  • cross_fields 更适合姓名、地址等被拆分到多个字段但逻辑上属于同一字段的场景。

因此,调整 title^3 之前,必须先确认查询类型和字段组合方式。否则可能误以为 boost 没生效,实际上是分数组合规则与预期不同。


3.4 Boost 的常见失败表现

失败一:把 boost 当成绝对规则

"title^10"

并不意味着标题匹配必然压过正文匹配。若正文中的稀有词项具有很高 IDF,仍可能获得更高分。

若业务规则是“标题精确匹配必须排在所有正文匹配前”,单纯提高 BM25 boost 不一定足够。可以考虑:

  • 分层 bool should
  • dis_max
  • 使用 constant_score 表示明确的规则分;
  • 在排序阶段使用显式的业务字段;
  • 通过 function_score 或二次排序表达硬规则。

失败二:在分词不符合预期时盲目加 boost

查询“PostgreSQL事务”,如果分析器把它拆成了意外的词项,给字段增加十倍 boost 只会放大错误匹配。应该先检查:

curl -X GET 'http://localhost:9200/articles/_analyze' \
  -H 'Content-Type: application/json' \
  -d '
{
  "field": "content",
  "text": "PostgreSQL事务"
}
'

预期结果应是实际使用的分析器输出的 token 列表。只有知道查询被拆成了什么,才能解释 BM25 和 boost 的结果。

失败三:用 boost 解决召回缺失

如果同义词、拼写变体、中文分词或字段映射导致文档没有匹配,boost 没有作用。应先通过 _analyze_explain 和查询改写确认召回链路。


四、用 _explain 拆解一条具体分数

对于某个文档,可以使用:

curl -X GET 'http://localhost:9200/articles/_explain/1' \
  -H 'Content-Type: application/json' \
  -d '
{
  "query": {
    "multi_match": {
      "query": "Elasticsearch 相关性",
      "fields": [
        "title^3",
        "content"
      ]
    }
  }
}
'

响应中的 explanation 会以树形结构说明:

  • 文档是否匹配;
  • 哪个查询子句贡献了分数;
  • 词项频率是多少;
  • 文档频率是多少;
  • 字段长度如何影响 BM25;
  • boost 乘在了哪一层;
  • 布尔查询如何组合子分数。

诊断时应从叶子节点向上阅读:

  1. 查询词是否真的被分析出来;
  2. 该词是否出现在目标字段;
  3. term frequency 是否符合预期;
  4. document frequency 是否异常;
  5. 字段 boost 是否应用;
  6. 父级布尔或 multi_match 如何合并。

_explain 适合单文档、单查询的原因分析,不适合直接对生产流量中的所有结果启用。批量解释会增加 CPU 和响应开销。


五、Function Score:把业务信号加入文本相关性

5.1 为什么需要 Function Score

BM25 主要使用文本统计信息,但业务排序通常还需要:

  • 文档新鲜度;
  • 点击量或热度;
  • 商品销量;
  • 内容质量分;
  • 用户或租户权限之外的业务偏好;
  • 地理距离;
  • 人工配置的权重。

function_score 允许先执行一个查询,再使用函数修改查询分数。

它的概念结构是:

原始查询分数 query_score
        ↓
若干评分函数 function_i
        ↓
score_mode 合并函数结果
        ↓
boost_mode 与原始查询分数组合
        ↓
最终 _score

这里必须区分两个参数:

  • score_mode:多个函数之间如何合并;
  • boost_mode:函数结果与原始查询分数如何合并。

5.2 一个可执行示例

假设索引包含:

{
  "title": "Elasticsearch 相关性调优",
  "content": "介绍 BM25、Boost 和评测集",
  "popularity": 128,
  "published_at": "2025-01-10T00:00:00Z"
}

查询希望:

  1. 先根据标题和正文的文本相关性召回;
  2. 热度较高的文档获得一定加分;
  3. 最近发布的文档更有优势;
  4. 函数结果与 BM25 分数相乘;
  5. 避免函数分数无限放大。
{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "Elasticsearch 相关性",
          "fields": [
            "title^3",
            "content"
          ]
        }
      },
      "functions": [
        {
          "field_value_factor": {
            "field": "popularity",
            "modifier": "log1p",
            "missing": 0
          },
          "weight": 0.2
        },
        {
          "gauss": {
            "published_at": {
              "origin": "now",
              "scale": "30d",
              "offset": "7d",
              "decay": 0.5
            }
          },
          "weight": 1.0
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply",
      "max_boost": 10
    }
  }
}

各部分含义如下。

文本查询

"multi_match": {
  "query": "Elasticsearch 相关性",
  "fields": ["title^3", "content"]
}

先由 BM25 计算文本分数。标题中的匹配相对正文更重要。

热度函数

"field_value_factor": {
  "field": "popularity",
  "modifier": "log1p",
  "missing": 0
}

如果 popularity=128log1p 会使用对数形式压缩数值差异,避免 100000 次点击的文档直接把文本相关性完全压倒。

missing: 0 用于处理字段缺失。若不处理缺失值,可能出现查询失败或分数行为与预期不同,具体应以所用版本的函数语义和字段映射为准。

新鲜度函数

gauss 根据日期距离产生衰减分数:

  • offset 范围内基本不衰减;
  • 超出后按 scale 衰减;
  • decay: 0.5 表示达到指定尺度时,函数值大约衰减到一半。

新鲜度并不是“越新越相关”的证明,而是业务偏好。对于永久有效的技术文档,过强的新鲜度权重可能导致旧但正确的文档被压下去。

函数之间的组合

"score_mode": "sum"

表示热度函数和新鲜度函数的结果先相加。

函数与文本分数的组合

"boost_mode": "multiply"

表示最终分数大致遵循:

final_score=text_score×combined_function_score\text{final\_score} = \text{text\_score} \times \text{combined\_function\_score}

随后受到 max_boost 的限制。


5.3 score_modeboost_mode 的区别

假设:

原始文本分数 = 4
函数 A = 2
函数 B = 0.5

score_mode: sum

F=2+0.5=2.5F=2+0.5=2.5

如果 boost_mode: multiply

S=4×2.5=10S=4\times2.5=10

score_mode: max

F=max(2,0.5)=2F=\max(2,0.5)=2

再乘以文本分数:

S=4×2=8S=4\times2=8

boost_mode: sum

若函数合并结果为 2.5

S=4+2.5=6.5S=4+2.5=6.5

boost_mode: replace

最终分数只使用函数结果:

S=2.5S=2.5

这会完全丢弃 BM25 分数,只有在业务规则明确要求如此时才适合使用。


5.4 使用过滤条件限制函数作用范围

不同文档可以使用不同评分函数:

{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "content": "Elasticsearch"
        }
      },
      "functions": [
        {
          "filter": {
            "term": {
              "category": "official"
            }
          },
          "weight": 1.5
        },
        {
          "filter": {
            "term": {
              "category": "community"
            }
          },
          "weight": 1.0
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}

这里的 filter 只决定某个函数是否应用,不等同于外层查询的文档过滤条件。外层查询仍然决定文档是否进入候选集。


5.5 Function Score 的风险

业务分数没有归一化

popularity 取值从 0 到数百万,直接使用:

{
  "field_value_factor": {
    "field": "popularity"
  }
}

可能让热度完全压过文本相关性。对数、平方根、截断、分桶或预计算归一化字段通常更可控,但具体选择必须结合数据分布和评测结果。

乘法放大极端值

boost_mode: multiply 会放大原始分数差异。一个文本分数很高、函数分数也很高的文档可能迅速领先;函数值接近零时,也可能把原本高度相关的文档压到很后面。

script_score 成本高且容易失控

自定义脚本可以表达更复杂的逻辑,但会带来:

  • 更高的 CPU 消耗;
  • 脚本执行次数随候选文档数增长;
  • 字段缺失、类型转换和异常处理风险;
  • 分片之间分数分布不一致时的调试困难。

脚本应尽量使用 doc values 可直接访问的字段,避免在脚本中进行昂贵计算或访问外部系统。不能把 script_score 当成普通业务代码执行环境。

random_score 不适合稳定相关性评测

随机排序可用于探索或打散,但会导致同一查询的结果不稳定。若要评测排序改动,必须固定随机种子,或在评测阶段不要使用随机函数。


六、相关性评测集:从“感觉变好了”变成可重复实验

6.1 评测集的组成

一个基本的相关性评测集至少包含:

查询 query
候选文档 document
人工相关性等级 rating

可以表示为:

{
  "query_id": "q-001",
  "query": "Elasticsearch 相关性调优",
  "ratings": [
    {
      "doc_id": "doc-101",
      "rating": 3
    },
    {
      "doc_id": "doc-205",
      "rating": 2
    },
    {
      "doc_id": "doc-309",
      "rating": 0
    }
  ]
}

相关性等级应先定义清楚。例如:

  • 0:不相关;
  • 1:部分相关,但不能解决问题;
  • 2:基本相关;
  • 3:高度相关,直接满足意图。

等级不是越多越好。若标注人员无法稳定区分 23,增加等级只会增加噪声。


6.2 标注边界必须先于模型调参

以下问题需要在标注规范中明确:

  • 文档只要包含查询词,是否算相关?
  • 内容正确但版本过旧,是否降低等级?
  • 同一主题的概览文档和深入文档是否同等级?
  • 查询有多个意图时,一个文档满足其中一个意图算什么?
  • 结果重复、软删除或无权限文档如何处理?
  • 新鲜度是相关性的一部分,还是单独业务排序因素?

如果标注标准不断变化,那么模型指标的变化无法解释。一次评测可能实际上比较的是两套标注规则,而不是两套排序方案。


6.3 常用离线指标

设查询结果前 kk 个文档为:

d1,d2,,dkd_1,d_2,\ldots,d_k

Precision@k

kk 个结果中相关文档的比例:

P@k=前 k 个结果中的相关文档数kP@k = \frac{\text{前 }k\text{ 个结果中的相关文档数}}{k}

它适合衡量用户看到的前几条结果是否大多有用。

Recall@k

已知相关文档中,有多少出现在前 kk 个结果:

R@k=前 k 个结果中的相关文档数该查询全部已知相关文档数R@k = \frac{\text{前 }k\text{ 个结果中的相关文档数}} {\text{该查询全部已知相关文档数}}

Recall 依赖“全部相关文档”这一分母。如果评测集只标注了少量文档,Recall 可能被低估。

MRR

对于每个查询,只关注第一个相关结果的位置:

RR=1第一个相关结果的排名RR=\frac{1}{\text{第一个相关结果的排名}}

所有查询的平均值为 MRR。它适合问答、导航、单一最佳结果等场景,但不充分反映前十条中有多个高质量结果的情况。

DCG 与 NDCG

对于有等级相关性的结果,DCG 常写为:

DCG@k=i=1k2reli1log2(i+1)DCG@k = \sum_{i=1}^{k} \frac{2^{rel_i}-1}{\log_2(i+1)}

其中 relirel_i 是第 ii 个结果的相关性等级。位置越靠前,折损越小;相关性等级越高,收益越大。

NDCG 用理想排序的 DCG 进行归一化:

NDCG@k=DCG@kIDCG@kNDCG@k = \frac{DCG@k}{IDCG@k}

它适合比较不同查询之间的排序质量。


6.4 一个 NDCG@3 算例

假设某查询的理想相关性等级为:

[3, 2, 1]

某次排序得到:

[1, 3, 0]

使用:

gain(rel)=2rel1gain(rel)=2^{rel}-1

则实际 DCG 为:

DCG@3=211log2(2)+231log2(3)+201log2(4)DCG@3 = \frac{2^1-1}{\log_2(2)} + \frac{2^3-1}{\log_2(3)} + \frac{2^0-1}{\log_2(4)}

=1+71.585+05.416=1+\frac{7}{1.585}+0 \approx5.416

理想 DCG 为:

IDCG@3=71+31.585+129.393IDCG@3 = \frac{7}{1} + \frac{3}{1.585} + \frac{1}{2} \approx9.393

因此:

NDCG@35.4169.3930.576NDCG@3 \approx \frac{5.416}{9.393} \approx0.576

这个结果说明:虽然前 3 个结果中有高相关文档,但等级为 3 的文档排在第二位,排序仍有明显损失。


七、使用 Elasticsearch _rank_eval 进行离线评测

Elasticsearch 提供了 Rank Evaluation API,可对多个查询及其人工评级进行评估。

示例:

curl -X POST 'http://localhost:9200/articles/_rank_eval' \
  -H 'Content-Type: application/json' \
  -d '
{
  "requests": [
    {
      "id": "q-001",
      "request": {
        "query": {
          "multi_match": {
            "query": "Elasticsearch 相关性",
            "fields": [
              "title^3",
              "content"
            ]
          }
        }
      },
      "ratings": [
        {
          "_index": "articles",
          "_id": "doc-101",
          "rating": 3
        },
        {
          "_index": "articles",
          "_id": "doc-205",
          "rating": 2
        },
        {
          "_index": "articles",
          "_id": "doc-309",
          "rating": 0
        }
      ]
    }
  ],
  "metric": {
    "dcg": {
      "k": 10,
      "normalize": true
    }
  }
}
'

请求中的关键部分是:

  • requests:每个查询一个评测请求;
  • request:实际执行的 Query DSL;
  • ratings:已知文档及其人工相关性等级;
  • metric:评测指标,这里使用归一化的 DCG@10,即 NDCG 风格的评估。

评测返回通常包括:

  • 总体指标;
  • 每个查询的指标;
  • 查询实际返回的文档;
  • 评级文档是否被召回;
  • 未评级结果等诊断信息。

评测 API 的请求结构和可用指标应以当前部署版本的官方文档为准。不同版本在指标参数名和细节上可能存在差异,升级后应重新验证评测脚本,而不要把评测请求当作永久不变的协议。


八、正确的调优实验顺序

一次可靠的实验应只改变一个主要变量。

8.1 固定索引和运行环境

比较两个查询版本时,至少固定:

  • 相同的索引快照或相同数据版本;
  • 相同的分片数量;
  • 相同的分析器和映射;
  • 相同的过滤条件;
  • 相同的分页窗口;
  • 相同的评测集;
  • 相同的相关性等级定义。

如果一边重建了索引,一边修改了查询,就无法判断收益来自 BM25、数据变化还是倒排结构变化。

8.2 先检查召回,再调排序

对每个评测查询,记录:

已知相关文档总数
前 10、前 20、前 100 的召回数
第一个相关结果的位置
相关文档的排序位置

如果相关文档在前 100 名都没有出现,优先检查:

  • 分词;
  • 同义词;
  • minimum_should_match
  • 字段是否选错;
  • mustfilter 是否使用错误;
  • 时间和权限过滤是否过严;
  • 查询是否被 operator: and 过度收紧。

不要先加 title^10

8.3 再调整字段 boost

建议使用一个小范围实验,例如:

title^1, title^2, title^3, title^5

而不是直接尝试 title^100。观察:

  • NDCG@10;
  • MRR;
  • 长文档与短文档的分布;
  • 不同查询意图下的结果;
  • 标题误匹配是否增加。

8.4 最后加入业务函数

Function Score 应在文本基线稳定后加入。实验至少应包含:

BM25 基线
BM25 + 字段 boost
BM25 + 热度
BM25 + 新鲜度
BM25 + 热度 + 新鲜度

否则多个因素同时变化,无法知道是哪一项造成回归或收益。


九、线上诊断:分数、查询和分页

9.1 不要把 _score 作为跨查询业务分值

同一查询内比较 _score 通常是有意义的;跨查询比较则通常没有意义。若需要跨查询统一排序,应使用显式业务分、归一化模型或后处理策略。

9.2 深分页会改变评测和线上成本

相关性排序通常需要协调各分片的候选结果。使用:

{
  "from": 10000,
  "size": 20
}

会增加深分页成本,并可能受到 index.max_result_window 限制。对于大规模遍历,应使用 search_after;如果需要稳定排序,通常需要 _score 加一个唯一且稳定的次排序字段:

{
  "sort": [
    {
      "_score": "desc"
    },
    {
      "_id": "asc"
    }
  ],
  "search_after": [
    12.345,
    "doc-101"
  ],
  "size": 20
}

需要注意,_score 可能受到索引分片、刷新时机和查询执行环境影响。离线评测应尽量固定数据和排序条件,线上分页也应设计稳定的 tie-breaker。

9.3 使用 Profile 分析查询执行

profile 可以帮助观察查询各阶段的执行时间:

{
  "profile": true,
  "query": {
    "function_score": {
      "query": {
        "match": {
          "content": "BM25"
        }
      },
      "functions": [
        {
          "field_value_factor": {
            "field": "popularity",
            "modifier": "log1p",
            "missing": 0
          }
        }
      ]
    }
  }
}

它适合测试和诊断,不应默认用于所有生产请求,因为响应会变大并增加额外开销。Profile 主要告诉你“哪里耗时”,不直接告诉你“排序是否相关”。


十、与混合检索和 RAG 的关系

在混合检索中,BM25 往往承担词法召回,向量检索承担语义召回。两者的分数通常不在同一尺度上:

  • BM25 分数取决于词频、文档频率和字段长度;
  • 向量相似度取决于向量模型和相似度函数;
  • 直接把两种原始分数相加,通常没有稳定统计依据。

常见流程是:

BM25 召回候选
向量召回候选
融合候选集
二次重排
截取 Top K
生成 RAG 上下文

无论使用何种融合方法,都应评测:

  • 词法精确查询;
  • 语义改写查询;
  • 专有名词和数字;
  • 长尾查询;
  • 无答案查询;
  • 文档版本和时效性;
  • 引用文档是否真的支持生成结论。

对于 RAG,检索相关性不等于生成正确性。至少应分开评估:

  1. 相关文档是否被召回;
  2. 相关文档是否排在上下文窗口前部;
  3. 生成答案是否忠实于检索结果;
  4. 引用是否指向支持答案的具体内容。

BM25 boost 和 Function Score 可以改善候选排序,但不能替代向量召回、重排模型或引用验证。


十一、常见误解与对应诊断

误解一:分数高就代表业务上最相关

BM25 分数是模型内部排序信号,不是人工相关性标签。高分文档可能只是:

  • 包含稀有词;
  • 文本较短;
  • 查询词重复较多;
  • 命中了高 boost 字段;
  • 获得了过强的业务函数加分。

应使用评测集验证,而不是只看 _score

误解二:提高 boost 就能解决所有排序问题

Boost 只能改变匹配文档的相对权重。召回缺失、分析器错误、过滤过严的问题必须从查询和索引层修复。

误解三:热门文档一定应该排前

热度是业务偏好,不是文本相关性。若热门文档与查询意图无关,Function Score 可能造成明显回归。应使用候选门槛、函数上限、对数压缩或分层排序限制其影响。

误解四:离线指标提高就可以直接上线

离线评测集可能存在:

  • 查询分布过窄;
  • 标注样本不足;
  • 热门查询过多;
  • 评测文档泄漏;
  • 与线上过滤条件不一致;
  • 只评估 Top 10,却线上展示 Top 50。

因此应同时保留回归集和探索集,并在上线后观察点击、停留、改写、无结果率等线上信号。但点击数据本身也有位置偏差,不能不加校正地当作人工相关性。


十二、生产中的取舍

一个可解释的相关性系统,通常应遵循这样的分层:

索引与分析器:决定词项和基本召回能力
BM25:提供文本统计相关性
Boost:表达字段和查询结构的相对偏好
Function Score:加入有限、可解释的业务信号
评测集:验证修改是否改善目标查询
线上监控:验证离线收益是否在真实流量中成立

其中每层的责任不同:

  • 分词错误,不应靠 boost 修复;
  • 召回不足,不应靠 Function Score 修复;
  • 业务热度过大,不应靠提高 BM25 参数解决;
  • 单条查询看起来变好,不足以证明整体排序改善;
  • 评测指标提高,也不代表所有查询类型都没有回归。

最终应把每次调优记录为可重放的实验:

数据版本
索引映射与相似度配置
查询 DSL
参数变化
评测集版本
指标变化
回归查询
线上灰度结果

这样,BM25 参数、字段 boost 和函数评分才不会变成只能凭经验反复试错的黑箱。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。