作者:来自 Elastic Max Jakob
如今,用户已经开始期待根据个人兴趣定制搜索结果。如果我们听的所有歌曲都是摇滚歌曲,那么在搜索 "Crazy" 时,我们会期望 Aerosmith 的歌曲排在搜索结果的首位,而不是 Gnarls Barkley 的歌曲。在本文中,我们将介绍个性化搜索的方法,然后以音乐偏好为例,深入探讨如何使用学习排名 (learning-to-rank: LTR) 来实现个性化搜索的具体方法。
排名因素
首先,让我们回顾一下一般搜索排名中哪些因素很重要。给定一个用户查询,相关性函数可以考虑以下一个或多个因素:
- 文本相似度可以用多种方法测量,包括 BM25、密集向量相似度、稀疏向量相似度或通过交叉编码器模型。我们可以计算查询字符串与文档中多个字段(标题、描述、标签等)的相似度分数,以确定输入查询与文档的匹配程度。
- 查询属性可以从查询本身推断出来,例如语言、命名实体或用户意图。领域将影响这些属性中的哪一个最有助于提高相关性。
- 文档属性与文档本身有关,例如其受欢迎程度或文档所代表产品的价格。如果应用正确的权重,这些属性通常会对相关性产生很大影响。
- 用户和上下文属性是指与查询或文档无关但与搜索请求上下文相关的数据,例如用户的位置、过去的搜索行为或用户偏好。这些信号将帮助我们个性化搜索。
个性化结果
当查看最后一类因素,即用户和上下文属性时,我们可以区分三种类型的系统:
- "General -常规" 搜索不考虑任何用户属性。只有查询输入和文档属性决定搜索结果的相关性。输入相同查询的两个用户会看到相同的结果。当你启动 Elasticsearch 时,你就会拥有这样一个开箱即用的系统。
- 个性化搜索将用户属性添加到组合中。输入查询仍然很重要,但现在由用户和/或上下文属性补充。在此设置中,用户可以针对同一查询获得不同的结果,并且希望结果与个人更相关。
- 推荐更进一步,专注于文档、用户和上下文属性。这些系统没有主动提供的用户查询。许多平台在主页上推荐针对用户帐户量身定制的内容,例如基于购物历史或之前观看的电影。
如果我们将个性化视为一个范围,个性化搜索位于中间。用户输入和用户偏好都是相关性方程的一部分。这也意味着搜索中的个性化应该谨慎应用。如果我们过于看重过去的用户行为而忽视当前的搜索意图,那么当用户专门搜索其他内容时,他们可能会对自己最喜欢的文档感到失望。也许你也有过这样的经历:观看朋友发布的一段民间舞蹈视频,然后在搜索舞蹈音乐时找到了更多这样的视频。这里的教训是,确保用户拥有足够多的历史数据,以便自信地将搜索结果偏向某个方向,这一点很重要。还请记住,个性化主要会对模糊的用户输入和探索性查询产生影响。你的一般搜索机制应该已经涵盖了明确的导航查询。
个性化有很多方法。有基于规则的启发式方法,开发人员手动将用户属性匹配到特定文档集上,例如手动提升新用户的入职文档。也有低技术含量的方法从一般和个人结果列表中抽样结果。许多更有原则的方法使用基于项目相似性或协同过滤技术(例如 "客户也购买了")训练的向量表示。你可以在网上找到很多关于这些方法的文章。在这篇文章中,我们将重点介绍如何使用学习排名。
使用 LTR 进行个性化
学习排序 (Learning-to-rank: LTR) 是创建相关性排序统计模型的过程。你可以将其视为自动调整不同相关性因素的权重。我们不是手动提出结构化查询和所有文本相似性、查询属性和文档属性的权重,而是训练一个模型,该模型在给定一些数据的情况下找到最佳权衡。数据以判断列表的形式出现。在这里,我们将研究使用 LTR 的基于行为的个性化,这意味着我们将利用过去的用户行为来提取将用于我们的 LTR 训练过程的用户属性。
重要的是要注意,为了取得成功,你应该在开始个性化之前已经在 LTR 之旅中取得了良好的进展:
- 你应该已经拥有 LTR。如果你想将 LTR 引入你的搜索,最好先优化你的通用(非个性化)搜索。那里可能会有一些唾手可得的成果,这将使你有机会在增加复杂性之前建立坚实的技术基础。处理用户相关数据意味着你在训练和评估期间需要更多数据,这变得更加棘手。我们建议你等到你的整体 LTR 设置处于稳定状态后再进行个性化。
- 你应该已经在收集使用数据。没有它,你将没有足够的数据来对你的相关性进行有意义的改进:冷启动问题。同样重要的是,你要对使用跟踪数据的正确性有高度的信心。错误发送的跟踪事件和错误的数据管道通常不会被检测到,因为它们不会引发任何错误,但最终的数据会歪曲实际的用户行为。随后基于这些数据的个性化项目可能不会成功。
- 你应该已经根据使用数据创建了判断列表。这个过程也称为点击建模,它既是一门科学,也是一门艺术。在这里,你无需手动标记搜索结果中的相关和不相关文档,而是使用点击信号(点击搜索结果、添加到购物车、购买、收听整首歌曲等)来估计用户在过去的搜索结果中看到的文档的相关性。你可能需要进行多次实验才能做到这一点。此外,这里还引入了一些偏见(最明显的是位置偏见)。你应该有信心,你的判断列表很好地代表了你的搜索的相关性。
如果所有这些都已确定,那么让我们继续添加个性化。首先,我们将深入研究特征工程。
特征工程 - Feature engineering
在特征工程中,我们会问自己,在特定搜索中可以使用哪些具体的用户属性来使结果更相关?我们如何将这些属性编码为排名特征?你应该能够准确地想象添加用户的位置如何提高结果质量。例如,代码搜索通常是与用户位置无关的用例。另一方面,音乐品味受到当地趋势的影响。如果我们知道搜索者在哪里,并且知道我们可以将文档归因于哪个地理位置,那么这就可以奏效。仔细考虑哪些用户特征和哪些文档特征可以协同工作是值得的。如果你无法想象这在理论上如何工作,那么可能不值得在你的模型中添加新特征。无论如何,你应该始终在训练后的离线和稍后的在线 A/B 测试中测试新特征的有效性。
一些属性可以直接从跟踪数据中收集,例如用户的位置或文档的上传位置。当涉及到表示用户偏好时,我们必须进行更多计算(如下所示)。此外,我们必须考虑如何将我们的属性编码为特征,因为所有特征都必须是数字。例如,我们必须决定是否将分类特征表示为由整数表示的标签,还是表示为多个二进制标签的独热编码(one-hot encoding)。
为了说明用户特征如何影响相关性排名,请考虑下面的虚构示例提升树,它可以成为音乐搜索引擎的 XGBoost 模型的一部分。训练过程了解了位置特征 "from France - 来自法国"(左侧)的重要性,并将其与其他特征(如文本相似性和文档特征)进行权衡。请注意,这些树通常更深,数量更多。我们在搜索和文档中都为位置特征选择了独热编码(one-hot encoding)。
请注意,添加的特征越多,这些树中需要的节点就越多。因此,在训练过程中需要更多的时间和资源才能达到收敛。从小处着手,衡量改进并逐步扩大。
示例:音乐偏好
我们如何在 Elasticsearch 中实现这一点?我们再次假设我们有一个音乐网站的搜索引擎,用户可以在其中查找和收听歌曲。每首歌曲都归类为高层次流派。示例文档可能如下所示:
{
"title": "Personal Jesus",
"artist": "Depeche Mode",
"genre": "pop"
}
进一步假设我们已经建立了从使用数据中提取判断列表的方法。这里我们使用相关性等级从 0 到 3 作为示例,这可以通过无交互(no interaction)、点击结果(clicking on a result)、收听歌曲(listening to the song)和为歌曲点赞(thumbs-up rating for the song)来计算。这样做会在我们的数据中引入一些偏差,包括位置偏差(以后的文章中会对此进行详细介绍)。判断列表(judgement list)可能如下所示:
query_id query user_id document_id grade
q:1 jump u:1 d:1 1
q:1 jump u:1 d:2 3
q:1 jump u:1 d:3 0
q:2 crazy u:2 d:4 2
q:2 crazy u:2 d:5 0
我们跟踪用户在我们网站上听过的歌曲,因此我们可以为每个用户构建一个音乐类型偏好(genre preferences)数据集。例如,我们可以回顾过去一段时间并汇总用户听过的所有类型。在这里,我们可以尝试不同类型的类型偏好表示,包括潜在特征,但为了简单起见,我们将坚持相对的收听频率。在这个例子中,我们希望针对单个用户进行个性化设置,但请注意,我们也可以根据用户细分进行计算(并使用细分 ID)。
user_id user_hiphop user_pop user_rock
u:1 0.2 0.7 0.1
u:2 0.4 0.2 0.4
u:3 0.8 0.0 0.2
在计算时,最好将用户的活动量考虑在内。这可以追溯到上面的民间舞蹈示例。如果用户只与一首歌曲互动,那么流派偏好(genr preference)将完全偏向其流派。为了防止后续的个性化过分强调这一点,我们可以将互动次数添加为特征,以便模型可以学习何时将权重放在流派播放上。我们还可以在归一化之前平滑互动并为所有频率添加一个常数,这样它们就不会偏离低计数的均匀分布。这里我们假设后者。
上述数据需要存储在特征存储中,以便我们可以在训练和搜索时通过用户 ID 查找用户偏好值。你可以在此处使用专用的 Elasticsearch 索引,例如:
PUT genre-preferences/_doc/u:1
{
"user_hiphop": 0,2,
"user_pop": 0.7,
"user_rock": 0.1
}
使用 user ID 作为 Elasticsearch 文档 ID,我们可以使用 Get API(见下文)来检索偏好值。从 Elasticsearch 版本 8.15 开始,必须在应用程序代码中完成此操作。另请注意,这些单独存储的特征值需要通过定期运行的作业进行刷新,以便在偏好随时间变化时保持值最新。
现在我们已准备好定义特征提取。在这里,我们对类型进行独热编码(one-hot-encode)。我们还计划在未来版本中启用将类别表示为整数的功能。
from eland.ml.ltr import LTRModelConfig, QueryFeatureExtractor
feature_extractors = [
# Example text similarity feature
QueryFeatureExtractor(
feature_name="title_match",
query={"match": {"title": "{{query}}"}},
),
# One-hot encode genre categories. Make sure `genre` is of type `keyword`.
QueryFeatureExtractor(
feature_name="is_hiphop",
query={
"constant_score": {
"filter": { "term": { "genre": "hiphop" } },
"boost": 1,
}
},
),
QueryFeatureExtractor(
feature_name="is_pop",
query={
"constant_score": {
"filter": { "term": { "genre": "pop" } },
"boost": 1,
}
},
),
QueryFeatureExtractor(
feature_name="is_rock",
query={
"constant_score": {
"filter": { "term": { "genre": "rock" } },
"boost": 1,
}
},
),
# Forward user preference values from the params as features
QueryFeatureExtractor(
feature_name="user_hiphop",
query={
"query": {"match_all": {}},
"script_score": {"script": {"source": "{{user_hiphop}}"} },
},
),
QueryFeatureExtractor(
feature_name="user_pop",
query={
"query": {"match_all": {}},
"script_score": {"script": {"source": "{{user_pop}}"} },
},
),
QueryFeatureExtractor(
feature_name="user_rock",
query={
"query": {"match_all": {}},
"script_score": {"script": {"source": "{{user_rock}}"} },
},
),
]
ltr_config = LTRModelConfig(feature_extractors)
现在,在应用特征提取时,我们必须首先查找流派偏好值并将其转发给特征记录器(feature logger)。根据性能,批量查找这些值可能会更好。
import numpy as np
PREFERENCES_INDEX = "genre-preferences"
def get_genre_preferences(es_client, index_name, user_id):
return es_client.get(index=index_name, id=user_id)["_source"]
def extract_query_features(query_group):
# get query string, user ID and document IDs from the judgment list
query_string = query_group["query"].iloc[0]
user_id = query_group["query"].iloc[0]
doc_ids = query_group["doc_id"].astype("str").to_list()
# get genre preference values from Elasticsearch index
# (consider using mget outside this function in case of slowness)
genre_preferences = get_genre_preferences(es_client, PREFERENCES_INDEX, user_id)
# run the extraction
search_params = {
"query": query_string,
"user_hiphop": genre_preferences["user_hiphop"],
"user_pop": genre_preferences["user_pop"],
"user_rock": genre_preferences["user_rock"],
}
features = feature_logger.extract_features(search_params, doc_ids)
# add features as new columns
for feature_index, feature_name in enumerate(ltr_config.feature_names):
query_group[feature_name] = np.array(
[doc_features[doc_id][feature_index] for doc_id in doc_ids]
)
return query_group
# extract features for all data with the same query ID
judgments_df.groupby("query_id", group_keys=False).apply(_extract_query_features)
特征提取后,我们就可以准备好训练数据了。请参阅之前的 LTR 帖子和随附的 notebook,了解如何训练和部署模型(并确保不要将 ID 作为特征发送)。
query_id query user_id document_id grade title_match is_hiphop is_pop is_rock user_hiphop user_pop user_rock
q:1 jump u:1 d:1 1 1.4 1 0 0 0.2 0.7 0.1
q:1 jump u:1 d:2 3 1.4 0 0 1 0.2 0.7 0.1
q:1 jump u:1 d:3 0 1.2 0 1 0 0.2 0.7 0.1
q:2 crazy u:2 d:4 2 2.2 0 0 1 0.4 0.2 0.4
q:2 crazy u:2 d:5 0 2.2 0 0 0 0.4 0.2 0.4
模型训练并部署后,你可以在这样的重新评分器中使用它。请注意,在搜索时,你还需要事先查找用户偏好值,并将这些值添加到查询中。
# inputs
user_query = "crazy"
user_id = "u:42"
# preference lookup
genre_preferences = get_genre_preferences(es_client, PREFERENCES_INDEX, user_id)
# search
query = {
"match": {
"title": user_query
}
}
rescore = {
"learning_to_rank": {
"model_id": "my-genre-personalization-model",
"params": {
"query": user_query,
"user_hiphop": genre_preferences["user_hiphop"],
"user_pop": genre_preferences["user_pop"],
"user_rock": genre_preferences["user_rock"]
}
},
"window_size": 100
}
es_client.search(index="my-music-index", query=query, rescore=rescore)
现在,我们音乐网站中不同类型偏好的用户都可以从你的个性化搜索中受益。摇滚和流行音乐爱好者都会在搜索结果顶部找到他们最喜欢的歌曲版本 Crazy。
结论
添加个性化可能会提高相关性。个性化搜索的一种方法是通过 Elasticsearch 中的 LTR。我们已经研究了一些应该提供的先决条件,并进行了实际操作示例。
但是,为了突出重点,我们遗漏了几个重要细节。我们将如何评估模型?在模型开发期间可以应用离线指标,但最终必须通过真实用户的在线 A/B 测试来决定模型是否提高了相关性。我们如何知道我们是否使用了足够的数据?在此阶段投入更多资源可以提高质量,但我们需要知道在什么条件下这样做是值得的。我们如何建立一个好的判断列表并处理使用行为跟踪数据引入的不同偏差?部署后我们是否可以忘记我们的个性化模型,还是需要重复维护来解决偏差问题?其中一些问题将在 LTR 的未来帖子中得到解答,敬请期待。
准备好自己尝试一下了吗?开始免费试用。
想要获得 Elastic 认证吗?了解下一期 Elasticsearch 工程师培训何时举行!
原文:Personalized search with Elasticsearch Learning to Rank --- Search Labs