在搜索这个领域里,BM25 几乎是个默认选项。简单、稳定、可解释,用 ES 做搜索的团队基本都在用。但随着业务越跑越久,几乎所有人都会碰到同一个尴尬的场景:搜出来的十条结果一眼看过去都算相关,可用户心里真正想要的那一条,偏偏卡在第二页第三条。
这其实揭示了一个核心问题——"最相关的"和"用户最想要的",往往不是一回事。
Learning to Rank(以下简称 LTR)正是为了解决这"最后一公里"而生。从 Elastic Stack 8.15 起,它作为正式特性发布,到 9.1 版本已经相当成熟。腾讯云 Elasticsearch 在 9.1.3 上完整支持了 LTR rescorer。下面我们以一个电影搜索场景为例,基于腾讯云 Elasticsearch 9.1.3,把整条链路完整跑一遍:从特征定义、特征抽取、XGBoost 模型训练,到模型推送至集群作为 rescorer 使用。
需要提前说明的是,环境前提是:腾讯云 Elasticsearch 9.1.3 版本集群(白金版或 AI 增强版均包含 ML 能力),客户端使用对应的 elasticsearch-py 9.x 与 eland 9.x。
一、整体思路
LTR 的工作模式可以拆成两个阶段。
第一阶段是粗筛,行业内叫"召回"——用 BM25 或者向量检索,从千万级文档里先捞几十到几百条候选出来。这一步追求的是速度和覆盖面,不能漏掉任何可能相关的结果。
第二阶段是细排,业内叫"精排"——拿一个训练好的模型,结合更多信号(BM25 分数、字段是否命中、文档本身的热度、时效性等等)给这几百条候选重新打一遍分。这一步追求的是精准,要把最想要的结果推到最前面。
训练这个模型,需要喂两样东西进去:判定列表(judgment list) 和 特征。判定列表说白了就是人工打的标签——某条 query 下,某条文档到底算不算用户想要的那个;特征则是 query 和文档之间一切可以被量化的信号,BM25 分数、字段是否命中、文档热度都算在内。把这两部分拼到一起,就是喂给 XGBoost 的训练数据。
整条链路用一张图来表示,大概是这样:
左边是离线训练,跑一次就能得到一个模型;右边是线上每次搜索都要走的两步。下面我们的主线,就是从左到右把它跑通。
二、专业术语解释:特征定义、特征抽取、模型训练
这三个词后面会反复出现,这里先用一个通俗的例子帮助理解。
其实可以把它想象成媒婆给你介绍对象的过程。
媒婆手上有十几份资料,需要挑几个合适的推给你。她不会随便挑,而是会看对方的身高、长相、工作、性格合不合、离家远不远。重点看哪些方面,她心里有自己的标准。LTR 里管这个叫特征定义。判断一条文档配不配得上用户这次搜索的 query,到底该看哪几样?BM25 打多少分算一样,标题是否命中算一样,片子热不热门、新不新,都算。把这些要看的列出来,说清楚每条怎么计算,定义就完成了。
光列出来还不够,得真把数据整理出来。她翻小张的资料:165cm、长相清秀、老师、性格温和、家就在隔壁区;翻小李的:160cm、一般、互联网、外向、在外地。一份资料填一行,十几份就是十几行。LTR 里这一步叫特征抽取。每一条"query + 文档"组合,按照前面定的那几样逐个算出数值,拼成一行。一万条样本,就是一万行。模型后面要学习的,就是这堆数据。
媒婆做了几年这行,手里攒了不少案例:哪些人推给你之后处下来了,哪些人见一面就没下文了。这些案例在她脑子里反复过,慢慢就形成了直觉:身高其实没那么要紧,性格合不合最关键,工作过得去就行,住得近不近看人。下次再来新资料,她不用多想,扫一眼就知道该不该推给你。LTR 里这一步叫XGBoost 模型训练。把"历史样本 + 每行的特征值 + 这条到底算不算用户想要的"丢给 XGBoost,它会自己从这些数据里学出一套打分逻辑。线上来了新的候选,就套用这套逻辑打分,得分高的排前面。
这三件事有明确的先后顺序:先确定要评估哪些方面,再把每个候选的这几项数据填好,最后从历史数据里学出一套打分逻辑。
三、准备工作
客户端版本需要和服务端大版本对齐,9.1.3 集群对应 9.x 系列的 SDK:
pip install -U "elasticsearch>=9.0,<10" "eland>=9.0,<10" "eland[scikit-learn]" xgboost tqdm
然后连接腾讯云 ES 集群。实例创建好后,控制台会提供内网访问地址和初始账号 elastic,密码自己设置。
from getpass import getpass
from elasticsearch import Elasticsearch
# 腾讯云 ES 控制台「实例详情 - 基本信息」中的访问地址
ES_ENDPOINT = "https://10.10.10.10:9200"
ES_USERNAME = "elastic"
ES_PASSWORD = getpass("Elasticsearch Password: ")
es_client = Elasticsearch(
hosts=[ES_ENDPOINT],
basic_auth=(ES_USERNAME, ES_PASSWORD),
request_timeout=60,
)
# 连通性确认
print(es_client.info()["version"]["number"]) # 期望输出 9.1.3
如果集群开启了 HTTPS,把地址换成 https://...,并按要求配置 verify_certs 与 ca_certs。生产环境建议优先走内网 VPC 地址,公网链路抖动会直接拖慢后续的特征抽取。
四、数据集介绍
示例数据来自 MSRD(Movie Search Ranking Dataset),由 Elastic 整理后放在了 elasticsearch-labs 仓库,包含三份文件:
- movies-corpus.jsonl.gz:电影正排数据,待索引
- movies-judgments.tsv.gz:判定列表
- movies-index-settings.json:索引 settings 与 mapping
from urllib.parse import urljoin
DATASET_BASE_URL = "https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/notebooks/search/sample_data/learning-to-rank/"
CORPUS_URL = urljoin(DATASET_BASE_URL, "movies-corpus.jsonl.gz")
JUDGEMENTS_FILE_URL = urljoin(DATASET_BASE_URL, "movies-judgments.tsv.gz")
INDEX_SETTINGS_URL = urljoin(DATASET_BASE_URL, "movies-index-settings.json")
电影文档的字段如下:
| 字段名 | 含义 |
|---|---|
| id | 文档 ID |
| title | 电影名 |
| overview | 剧情简介 |
| actors | 演员列表 |
| director | 导演 |
| characters | 角色列表 |
| genres | 类型 |
| year | 上映年份 |
| budget | 预算(美元) |
| votes | 投票数 |
| rating | 平均评分 |
| popularity | 热度 |
| tags | 标签 |
五、写入语料
先创建索引,再用 bulk 把语料灌进去。
import json
import elasticsearch.helpers as es_helpers
import pandas as pd
from urllib.request import urlopen
MOVIE_INDEX = "movies"
es_client.options(ignore_status=[400, 404]).indices.delete(index=MOVIE_INDEX)
index_settings = json.load(urlopen(INDEX_SETTINGS_URL))
es_client.indices.create(index=MOVIE_INDEX, **index_settings)
corpus_df = pd.read_json(CORPUS_URL, lines=True)
bulk_result = es_helpers.bulk(
es_client,
actions=[
{"_id": movie["id"], "_index": MOVIE_INDEX, **movie}
for movie in corpus_df.to_dict("records")
],
)
print(f"Indexed {bulk_result[0]} documents into {MOVIE_INDEX}")
六、判定列表
判定列表就是告诉模型对错的那份数据:对于某条 query,哪条文档是好结果、哪条不是。每行包含四列:
| 列名 | 含义 |
|---|---|
| query_id | 同一条 query 的样本归到一组,按 query 分组训练 |
| query | 实际查询文本 |
| doc_id | 文档 ID |
| grade | 这条文档对这条 query 来说,相关度有多高 |
这里的 grade 只分相关和不相关两档。在真实业务场景中,更常见的是 0~4 这种分级标注,因为给模型的信息量更丰富。
judgments_df = pd.read_csv(JUDGEMENTS_FILE_URL, delimiter="\t")
judgments_df
七、定义特征
LTR 这套机制里,特征不是直接写成一个 numpy 数组喂给模型的,而是写成一段 ES 自己的查询语句——训练时计算特征用这段查询,线上运行的时候也是这段查询。
eland 提供了 LTRModelConfig 和 QueryFeatureExtractor 这两个工具类,用来描述特征具体长什么样:
from eland.ml.ltr import LTRModelConfig, QueryFeatureExtractor
ltr_config = LTRModelConfig(
feature_extractors=[
# title 字段的 BM25 分数
QueryFeatureExtractor(
feature_name="title_bm25",
query={"match": {"title": "{{query}}"}}
),
# actors 字段的 BM25 分数
QueryFeatureExtractor(
feature_name="actors_bm25",
query={"match": {"actors": "{{query}}"}}
),
# 更严格的匹配:所有 term 都必须命中
QueryFeatureExtractor(
feature_name="title_all_terms_bm25",
query={
"match": {
"title": {"query": "{{query}}", "minimum_should_match": "100%"}
}
},
),
QueryFeatureExtractor(
feature_name="actors_all_terms_bm25",
query={
"match": {
"actors": {"query": "{{query}}", "minimum_should_match": "100%"}
}
},
),
# 用 script_score 直接读取字段值,把热度作为特征
QueryFeatureExtractor(
feature_name="popularity",
query={
"script_score": {
"query": {"exists": {"field": "popularity"}},
"script": {"source": "return doc['popularity'].value;"},
}
},
),
]
)
特征设计是 LTR 项目里最值得反复打磨的部分。BM25 的全字段命中、title 是否完全匹配、热度、时效性、点击率统计……这些信号都可以用相同的形式塞进 feature_extractors。
八、构造训练样本
特征定义好之后,需要把判定列表里的每个
整个过程可以直观地理解为:
换句话说就是:人工标注的对错 + ES 算出的分数 = 能喂给 XGBoost 的训练数据。
import numpy
from eland.ml.ltr import FeatureLogger
feature_logger = FeatureLogger(es_client, MOVIE_INDEX, ltr_config)
def _extract_query_features(query_judgements_group):
doc_ids = query_judgements_group["doc_id"].astype("str").to_list()
query_params = {"query": query_judgements_group["query"].iloc[0]}
doc_features = feature_logger.extract_features(query_params, doc_ids)
for feature_index, feature_name in enumerate(ltr_config.feature_names):
query_judgements_group[feature_name] = numpy.array(
[doc_features[doc_id][feature_index] for doc_id in doc_ids]
)
return query_judgements_group
judgments_with_features = judgments_df.groupby(
"query_id", group_keys=False
).progress_apply(_extract_query_features)
这一步的耗时与网络状况密切相关。如果网络正常,示例数据集基本上 2 分钟内就能跑完。
九、训练 XGBoost 模型
ES 的 LTR rescorer 只认 XGBRanker 训练出来的模型。训练目标用 NDCG(一种常见的排序质量评估指标,分值越高表示排序越符合预期),评估指标用 NDCG@10,再加入早停机制(如果效果连续几轮没有提升就自动停止)。
from xgboost import XGBRanker
from sklearn.model_selection import GroupShuffleSplit
ranker = XGBRanker(
objective="rank:ndcg",
eval_metric=["ndcg@10"],
early_stopping_rounds=20,
)
X = judgments_with_features[ltr_config.feature_names]
y = judgments_with_features["grade"]
groups = judgments_with_features["query_id"]
# 按 query_id 分组划分训练/验证集,避免同一 query 的样本被切到两边
group_preserving_splitter = GroupShuffleSplit(n_splits=1, train_size=0.7).split(
X, y, groups
)
train_idx, eval_idx = next(group_preserving_splitter)
train_features, eval_features = X.loc[train_idx], X.loc[eval_idx]
train_target, eval_target = y.loc[train_idx], y.loc[eval_idx]
train_query_groups, eval_query_groups = groups.loc[train_idx], groups.loc[eval_idx]
ranker.fit(
X=train_features,
y=train_target,
group=train_query_groups.value_counts().sort_index().values,
eval_set=[(eval_features, eval_target)],
eval_group=[eval_query_groups.value_counts().sort_index().values],
verbose=True,
)
训练完成后,可以画一下特征重要度,对模型的表现有个直观判断:
from xgboost import plot_importance
plot_importance(ranker, importance_type="weight")

结果基本都长一个样:title_bm25 和 popularity 这两根柱子明显比别的长。仔细想想也不奇怪——搜电影的时候,标题命中的、大家都在看的,确实应该排在前面。模型只是把这些我们平时凭感觉做的判断,给量化出来了。
十、把模型推到 ES
eland 的 MLModel.import_ltr_model 一行代码就能搞定。需要注意的是,ltr_model_config 必须带上,因为 ES 在线推理时也要按照这份配置去计算特征。
from eland.ml import MLModel
LEARNING_TO_RANK_MODEL_ID = "ltr-model-xgboost"
MLModel.import_ltr_model(
es_client=es_client,
model=ranker,
model_id=LEARNING_TO_RANK_MODEL_ID,
ltr_model_config=ltr_config,
es_if_exists="replace",
)
十一、把模型当 rescorer 用
模型上线后,调用方式和普通的 rescore 完全一致——在 _search 接口里加一段 rescore 即可。window_size 控制需要被精排的候选条数,一般取 50~200,值越大结果越准但代价也越高。
GET /movies/_search
{
"query": {
"multi_match": {
"query": "star wars",
"fields": ["title", "overview", "actors", "director", "tags", "characters"]
}
},
"rescore": {
"window_size": 50,
"learning_to_rank": {
"model_id": "ltr-model-xgboost",
"params": {
"query": "star wars"
}
}
}
}
十二、效果对比
用同一条 query "star wars" 做对比。先看纯 BM25 的结果:
query = "star wars"
search_fields = ["title", "overview", "actors", "director", "tags", "characters"]
bm25_query = {"multi_match": {"query": query, "fields": search_fields}}
bm25_search_response = es_client.search(index=MOVIE_INDEX, query=bm25_query)
[
(movie["_source"]["title"], movie["_score"], movie["_id"])
for movie in bm25_search_response["hits"]["hits"]
]
再看叠加 LTR rescorer 之后的结果:
ltr_rescorer = {
"learning_to_rank": {
"model_id": LEARNING_TO_RANK_MODEL_ID,
"params": {"query": query},
},
"window_size": 100,
}
rescored_search_response = es_client.search(
index=MOVIE_INDEX, query=bm25_query, rescore=ltr_rescorer
)
[
(movie["_source"]["title"], movie["_score"], movie["_id"])
for movie in rescored_search_response["hits"]["hits"]
]
两份结果摆在一起,差异就很直观了:
上面是示意性结果,实际名次以运行结果为准。
小结
LTR 在业内已经折腾好些年了,只是早些年需要自己搭建一套训练、部署、线上调用的完整链路,门槛不低。现在 Elastic 把它接进了 _search 的 rescore 之后,整件事就变得轻量很多:训练交给 XGBoost,部署一行 eland 搞定,线上调用还是普通的 search 请求。再加上腾讯云 ES 9.1.3 这种托管形态,ML 节点、Kibana、模型管理这些都是现成的,基本无需自己额外搭建什么设施。
对于已经在使用 Elasticsearch 做搜索的团队来说,这条路投入不大,效果也很容易看见。如果你手头的搜索业务正好卡在那种"BM25 已经调到不能再调"的阶段,不妨找个空闲的下午,拿自己的数据跑一遍——说不定那个一直排不上去的结果,这次真的能出现在第一页了。
