Elasticsearch: 使用LTR实现个性化搜索

2024-09-13 07:15:44 浏览数 (3)

今天,用户已经习惯了根据他们个人兴趣量身定制的搜索结果。如果我们平时听的都是摇滚歌曲,那么当我们搜索“Crazy”时,我们希望结果列表的首位是Aerosmith的歌曲,而不是Gnarls Barkley的那首。在这篇文章中,我们将探讨如何在使用学习排序(LTR)进行个性化搜索之前,先了解一些个性化搜索的方法,并以音乐偏好为例进行说明。

排序因素

首先,让我们回顾一下在搜索排序中有哪些重要因素。对于一个用户查询,相关性函数可以考虑以下一个或多个因素:

  • 文本相似度:可以通过多种方法测量,包括BM25、密集向量相似度、稀疏向量相似度或通过交叉编码器模型。我们可以计算查询字符串与文档中多个字段(标题、描述、标签等)之间的相似度得分,以确定输入查询与文档的匹配程度。
  • 查询属性:可以从查询本身推断出来,例如语言、命名实体或用户意图。具体领域会影响哪些属性对提高相关性最有帮助。
  • 文档属性:与文档本身有关,例如文档的受欢迎程度或产品价格。这些属性在应用合适的权重时,通常对相关性有很大影响。
  • 用户和上下文属性:与查询或文档无关,而是与搜索请求的上下文有关,例如用户的位置、过去的搜索行为或用户偏好。这些信号有助于我们实现搜索个性化。
用户的需求可以是个性化的用户的需求可以是个性化的

个性化结果

当我们看最后一类因素,即用户和上下文属性时,可以区分出三种系统:

  1. “通用”搜索:不考虑任何用户属性。只有查询输入和文档属性决定搜索结果的相关性。两个输入相同查询的用户会看到相同的结果。当你启动Elasticsearch时,你会得到这样的一个系统。
  2. 个性化搜索:增加了用户属性。输入查询依然重要,但现在补充了用户和/或上下文属性。在这种情况下,用户可以为相同的查询获得不同的结果,希望这些结果对个体用户更具相关性。
  3. 推荐系统:更进一步,专注于文档、用户和上下文属性。这些系统没有主动提供的用户查询。许多平台在主页上推荐内容,基于用户的账户量身定制,例如基于购物历史或以前观看的电影。

如果我们把个性化看作一个光谱,个性化搜索位于中间。用户输入和用户偏好都参与到相关性计算中。这也意味着在搜索中应用个性化时需要小心。如果我们对过去的用户行为赋予过多的权重,而对当前的搜索意图赋予过少的权重,就可能让用户感到沮丧。例如,当你专门搜索舞曲时,看到的却是你朋友上传的民间舞蹈视频。这里的教训是,确保有足够的历史数据,以便有信心地将搜索结果偏向某个方向。同时要记住,个性化主要会在用户输入模糊和探索性查询时起作用。明确的导航查询应该已经被你的通用搜索机制覆盖。

个性化有很多方法。有基于规则的启发式方法,开发者手工将用户属性与一组特定文档匹配,例如手动提升新用户的入门文档。还有一些低技术的方法,从通用和个性化结果列表中抽样结果。许多更为系统的方法使用向量表示,或使用协同过滤技术(例如“客户也购买了”)。网上有很多关于这些方法的帖子。在这篇文章中,我们将重点讨论如何使用学习排序进行个性化。

使用LTR进行个性化

学习排序(LTR)是创建相关性排序统计模型的过程。你可以将其视为自动调节不同相关性因素权重的过程。与其手动为所有文本相似度、查询属性和文档属性制定结构化查询和权重,不如训练一个模型,通过一些数据找到最佳权衡。这些数据以判断列表的形式出现。我们将研究基于行为的个性化使用LTR,这意味着我们将利用过去的用户行为来提取用户属性,并在我们的LTR训练过程中使用这些属性。

需要注意的是,为了确保成功,你应该在个性化之前已经在你的LTR旅程中取得了进展:

  1. 你应该已经有了LTR。如果你想将LTR引入你的搜索,最好先优化你的通用(非个性化)搜索。这里可能会有一些简单的改进机会,这也会让你有机会在增加复杂性之前建立一个坚实的技术基础。处理用户相关的数据意味着在训练期间需要更多的数据,评估也会变得更加复杂。我们建议在你的总体LTR设置达到稳定状态之前再进行个性化。
  2. 你应该已经在收集使用数据。没有这些数据,你将没有足够的数据来进行有意义的相关性改进:冷启动问题。同时,你必须对你的使用跟踪数据的正确性有高度信心。错误的跟踪事件和错误的数据管道往往不会抛出任何错误,但结果数据会误导实际的用户行为。基于这些数据进行个性化项目可能不会成功。
  3. 你应该已经在使用数据创建判断列表。这一过程也称为点击建模,既是一门科学也是一门艺术。在这里,代替手动标注搜索结果中的相关和不相关文档,你可以使用点击信号(点击搜索结果、加入购物车、购买、听完整首歌等)来估计用户在过去搜索结果中看到的文档的相关性。你可能需要进行多次实验以达到正确结果。此外,这里会引入一些偏差(最显著的是位置偏差)。你应该对你的判断列表充分代表你的搜索的相关性有信心。
乐器设备齐全,歌曲排练充分,已经演出多次乐器设备齐全,歌曲排练充分,已经演出多次

如果所有这些条件都已满足,那么让我们继续进行个性化。首先,我们将深入了解特征工程。

特征工程

在特征工程中,我们需要问自己哪些具体的用户属性可以在你的特定搜索中使用,以使结果更具相关性?我们如何将这些属性编码为排序特征?你应该能够想象,添加用户位置如何提高结果质量。例如,代码搜索通常是独立于用户位置的,而音乐偏好则受当地趋势影响。如果我们知道搜索者的位置,并且知道可以将文档归属到哪个地理位置,这会有帮助。仔细考虑哪些用户特征和哪些文档特征可能共同起作用。如果你无法在理论上想象它们的工作原理,那么可能不值得将新特征添加到你的模型中。无论如何,你应该在训练后离线测试新特征的有效性,并在以后进行在线A/B测试。

有些属性可以直接从跟踪数据中收集,例如用户的位置或文档的上传位置。当涉及到表示用户偏好时,我们需要进行一些额外的计算(如下所示)。此外,我们还必须考虑如何将属性编码为特征,因为所有特征都必须是数值的。例如,我们必须决定是否将分类特征表示为用整数表示的标签,还是将多个二进制标签的一热编码。

为说明用户特征如何影响相关性排序,考虑下面这个虚构的提升树示例,它可能是音乐搜索引擎的XGBoost模型的一部分。训练过程学习到位置特征“来自法国”的重要性,并将其与其他特征(如文本相似度和文档特征)进行权衡。请注意,这些树通常更深,并且数量更多。我们选择了一热编码来表示位置特征,无论是在搜索还是在文档上。

XGBoost梯度提升树示例XGBoost梯度提升树示例

请注意,添加的特征越多,这些树中需要的节点就越多,才能利用它们。因此,在训练期间需要更多的时间和资源以达到收敛。开始时要小,测量改进并逐步扩展。

示例:音乐偏好

我们如何在Elasticsearch中实现这一点?假设我们有一个音乐网站的搜索引擎,用户可以搜索和收听歌曲。每首歌被分类为一个高级别的流派。一个示例文档可能如下:

代码语言:json复制
{
  "title": "Personal Jesus",
  "artist": "Depeche Mode",
  "genre": "pop"
}

假设我们已经有一种从使用数据中提取判断列表的方式。这里我们使用从0到3的相关性等级作为示例,这些等级可以根据没有互动、点击结果、听完整首歌和对歌曲点赞来计算。这样做会引入一些数据偏差,包括位置偏差(稍后会详细讨论)。一个判断列表可能如下:

代码语言:javascript复制
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)。

代码语言:javascript复制
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索引,例如:

代码语言:json复制
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版本中,这需要在你的应用代码中完成。此外,请注意,这些单独存储的特征值需要通过定期运行的作业刷新,以随着时间的推移保持值的最新。

现在我们准备定义特征提取。这里我们对流派进行一热编码。我们计划在未来的版本中也启用将类别表示为整数。

代码语言:python代码运行次数:0复制
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)

现在在应用特征提取时,我们必须首先查找流派偏好值并将它们传递给特征记录器。根据性能,最好批量查找这些值。

代码语言:python代码运行次数:0复制
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帖子和附带的笔记本了解如何训练和部署模型(并确保不要将ID作为特征发送)。

代码语言:csv复制
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

一旦模型训练并部署,你可以像这样在重新排序器中使用它。请注意,在搜索时你还需要提前查找用户偏好值并将这些值添加到查询中。

代码语言:python代码运行次数:0复制
# 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文章中得到解答,敬请期待。

0 人点赞