如今,用户期望搜索结果能够根据他们的个人兴趣进行调整。如果我们听的所有歌曲都是摇滚歌曲,那么当搜索“Crazy”时,我们希望 Aerosmith 的歌曲排在结果的首位,而不是 Gnarls Barkley 的歌曲。在本文中,我们将首先了解个性化搜索的方法,然后深入探讨如何使用学习排序 (LTR) 来实现这一点,并以音乐偏好为例。
排序因素
首先,让我们回顾一下哪些因素通常在搜索排序中很重要。给定一个用户查询,相关性函数可以考虑以下一个或多个因素:
- 文本相似性可以使用多种方法进行衡量,包括 BM25、稠密向量相似性、稀疏向量相似性或交叉编码器模型。我们可以计算查询字符串与文档中多个字段(标题、描述、标签等)的相似性分数,以确定输入查询与文档的匹配程度。
- 查询属性可以从查询本身推断出来,例如语言、命名实体或用户意图。领域将影响哪些属性最有助于提高相关性。
- 文档属性与文档本身有关,例如文档的流行度或文档所代表产品的价格。这些属性在应用正确的权重时,通常会对相关性产生重大影响。
- 用户和上下文属性指的是与查询或文档无关,而与搜索请求上下文相关的数据,例如用户的地理位置、过去的搜索行为或用户偏好。这些是帮助我们个性化搜索的信号。
个性化结果
在查看最后一类因素(用户和上下文属性)时,我们可以区分三种类型的系统:
- “通用”搜索不考虑任何用户属性。只有查询输入和文档属性决定搜索结果的相关性。两个输入相同查询的用户会看到相同的结果。当您启动 Elasticsearch时,您就拥有了这样一个系统。
- 个性化搜索增加了用户属性。输入查询仍然很重要,但现在它得到了用户和/或上下文属性的补充。在这种情况下,用户可以对相同的查询获得不同的结果,并且希望这些结果对个人更相关。
- 推荐更进一步,完全专注于文档、用户和上下文属性。这些系统没有主动提供的用户查询。许多平台会推荐针对用户帐户量身定制的首页内容,例如基于购物历史记录或之前观看的电影。
如果我们将个性化视为一个频谱,那么个性化搜索处于中间位置。用户输入和用户偏好都是相关性等式的一部分。这也意味着搜索中的个性化应谨慎应用。如果我们过分重视过去的用 户行为而过分轻视当前的搜索意图,那么当用户专门搜索其他内容时,我们可能会因其喜爱的文档而让用户感到沮丧。也许您也有过这样的经历:观看朋友发布的一个民间舞蹈视频,随后在搜索舞蹈音乐时又找到了更多类似的视频。这里的教训是,为了自信地将搜索结果偏向特定方向,确保用户拥有足够的历史数据非常重要。还要记住,个性化主要会在模糊的用户输入和探索性查询中发挥作用。明确的导航查询应该已经由您的通用搜索机制涵盖。
个性化的方法有很多。有一些基于规则的启发式方法,开发人员通过这些方法手工将用户属性与特定文档集进行匹配,例如手动提升新用户的入门文档。还有一些低技术方法是从通用结果列表和个人结果列表中抽样结果。许多更规范的方法使用经过训练的向量表示,这些表示要么基于项目相似性,要么使用协同过滤技术(例如,“客户也购买了”)。您可以在网上找到许多关于这些方法的文章。在这篇文章中,我们将重点关注使用学习排序。
使用 LTR 进行个性化搜索
学习排序 (LTR) 是创建用于相关性排序的统计模型的过程。您可以将其视为自动调整不同相关性因素的权重。我们不需要手动为所有文本相似性、查询属性和文档属性设计结构化查询和权重,而是训练一个模型,在给定一些数据的情况下找到最佳的权衡。数据以判断列表的形式出现。在这里,我们将研究基于行为的个性化使用 LTR,这意味着我们将利用过去的用户行为来提取将在我们的 LTR 训练过程中使用的用户属性。
重要的是要注意,为了确保成功,您应该在开始个性化之前就已经在 LTR 之旅中取得了良好的进展。
- 您应该已经具备 LTR。如果您想将 LTR 引入您的搜索中,最好首先优化您的通用(非个性化)搜索。那里可能有一些唾手可得的成果,这将使您有机会在增加复杂性之前建立坚实的技术基础。处理依赖于用户的 数据意味着您在训练期间需要更多的数据,并且评估变得更加棘手。我们建议等到您的整体 LTR 设置处于稳定状态后再进行个性化。
- 您应该已经开始收集使用数据。没有它,您将无法获得足够的数据来对您的相关性进行有意义的改进:冷启动问题。确保您对使用跟踪数据的正确性充满信心也很重要。错误发送的跟踪事件和错误的数据管道通常无法检测到,因为它们不会引发任何错误,但最终数据会错误地表示实际的用户行为。随后,基于此数据进行的个性化项目可能不会成功。
- 您应该已经开始根据使用数据创建判断列表。此过程也称为点击建模,它既是一门科学,也是一门艺术。在这里,您不是手动标记搜索结果中相关和不相关的文档,而是使用点击信号(点击搜索结果、添加到购物车、购买、收听整首歌曲等)来估计作为过去搜索结果一部分提供给用户的文档的相关性。您可能需要多次实验才能做到这一点。此外,这里还引入了一些偏差(最显著的是位置偏差)。您应该相信您的判断列表能够很好地代表您搜索的相关性。
如果所有这些都是既定的,那么让我们继续添加个性化。首先,我们将深入研究特征工程。
特征工程
在特征工程中,我们会思考哪些具体的用户属性可以用于你的特定搜索以提高结果的相关性?以及如何将这些属性编码为排序特征?你应该能够准确地想象一下,例如添加用户的地理位置是如何提高结果质量的。例如,代码搜索通常是一个独立于用户位置的用例。另一方面,音乐品味受当地趋势的影响。如果我们知道搜索者在哪里,并且知道我们可以将文档归属到哪个地理位置,那么这就可以奏效。仔细考虑哪些用户特征和哪些文档特征可能一起工作是值得的。如果你无法想象这在理论上是如何工作的,那么可能不值得向你的模型添加新特征。无论如何,你都应该始终在离线训练后以及稍后的在线 A/B 测试中测试新特征的有效性。
某些属性可以直接从跟踪数据中收集,例如用户的地理位置或文档的上传位置。当涉及到表示用户偏好时,我们必须进行更多计算(如下所示)。此外,我们必须考虑如何将我们的属性编码为特征,因为所有特征都必须是数值型的。例如,我们必须决定是将分类特征表示为由整数表示的标签,还是表示为多个二进制标签的独热编码。
为了说明用户特征如何影响相关性排序,请考虑下面这个虚构的增强树示例,它可能是用于音乐搜索引擎的 XGBoost 模型的一部分。训练过程学习了“来自法国”的位置特征(位于左侧)的重要性,并将它们与其他特征(如文本相似性和文档特征)进行权衡。请注意,这些树通常更深,而且数量更多。我们对搜索和文档中的位置特征都使用了独热编码。
请注意,添加的特征越多,这些树中需要的节点就越多才能利用它们。因此,在训练期间需要更多的时间和资源才能达到收敛。从小处着手,衡量改进情况,逐步扩展。
使用 LTR 的个性化搜索示例:音乐偏好
我们如何在 Elasticsearch 中实现这一点?让我们再次假设我们有一个音乐网站的搜索引擎,用户可以在其中搜索歌曲并收听。每首歌曲都分类到一个高级别类型中。一个示例**文档**可能如下所示
{
"title": "Personal Jesus",
"artist": "Depeche Mode",
"genre": "pop"
}
进一步假设我们有一种从使用数据中提取判断列表的既定方法。这里我们使用 0 到 3 的相关性等级作为示例,这可以根据没有交互、点击结果、收听歌曲以及为歌曲点赞来计算。这样做会在我们的数据中引入一些偏差,包括位置偏差(以后的文章中会详细介绍)。**判断列表**可能如下所示
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
我们跟踪用户在我们网站上收听的歌曲,因此我们可以为每个用户构建一个音乐**类型偏好**数据集。例如,我们可以回顾过去一段时间,并汇总用户收听的所有类型。在这里,我们可以尝试不同的类型偏好表示,包括潜在特征,但为简单起见,我们将坚持使用收听的相对频率。在这个例子中,我们想要为单个用户个性化,但请注意,我们也可以根据用户群体(并使用群体 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
在计算此值时,明智的做法是考虑用户的活动量。这回到了上面提到的民间舞蹈的例子。如果用户只与一首歌曲互动,则类型偏好将完全偏向其类型。为了防止随后的个性化过分依赖这一点,我们可以添加交互次数作为特征,以便模型可以学习何时对类型播放赋予权重。我们还可以平滑交互并向所有频率添加一个常数,然后再进行归一化,这样它们在低计数时就不会偏离均匀分布。这里我们假设后者。
上述数据需要存储在特征存储中,以便我们可以在训练期间和搜索期间通过用户 ID 查询用户偏好值。例如,你可以在这里使用一个专用的 Elasticsearch 索引
PUT genre-preferences/_doc/u:1
{
"user_hiphop": 0,2,
"user_pop": 0.7,
"user_rock": 0.1
}
使用用户 ID 作为 Elasticsearch 文档 ID,我们可以使用Get API(如下所示)来检索偏好值。这必须在你的应用程序代码中完成,从 Elasticsearch 8.15 版本开始。还要注意,这些单独存储的特征值需要通过定期运行的作业刷新,以便随着时间的推移偏好发生变化时保持值的最新状态。
现在我们准备定义特征提取。在这里,我们对类型进行独热编码。我们计划在未来的版本中也支持将类别表示为整数。
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)
现在,在应用特征提取时,我们必须首先查找类型偏好值并将它们转发到特征记录器。根据性能,批量查找这些值可能更好。
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 文章中得到解答,敬请期待。