作者:来自 Elastc Matthew Adams

三个 ES|QL 查询可根据你的搜索点击数据计算点击率(Click-Through Rate,CTR)、平均倒数排名(Mean Reciprocal Rank,MRR)和点击位置分布(Click Position Distribution),帮助你准确找出哪些查询需要进行相关性调优,以及在哪些位置改进排序能够带来最大的效果。
搜索量和延迟只能告诉你搜索是否正常运行,却无法告诉你搜索是否真正有用。只需约 15 行 OpenTelemetry(OTel)埋点代码,你就可以跟踪搜索结果的点击情况,然后针对搜索 span 已经存储的同一个 traces 索引,使用 Elasticsearch Query Language(ES|QL)查询点击率(CTR)、平均倒数排名(MRR)和点击位置分布。你将把点击跟踪关联到现有的 search.query_id,并编写查询来找出哪些搜索需要进行相关性调优。
你将了解到
在这篇文章中,你将学习如何:
-
添加客户端点击跟踪,通过 search.query_id 将点击关联回其对应的搜索。
-
计算点击率(CTR),即至少产生一次点击的搜索所占的百分比。
-
计算平均倒数排名(MRR),即用户平均点击的是第几个搜索结果。
-
分析点击位置分布,了解用户参与行为的整体模式。
-
针对 traces-generic.otel-default 编写上述三个指标的 ES|QL 查询。
-
找出哪些具体查询需要进行相关性调优。
你需要准备
-
已完成 Blog 2 中的 OTel 埋点(带有 search.* 属性的搜索 span,并通过 OTel 原生摄取发送到 Elastic)。
-
一个能够发送点击事件的前端(本文提供 JavaScript 示例)。
-
熟悉 Blog 2 中介绍的 attributes.* 字段映射。
为什么仅靠搜索日志无法衡量搜索质量
在本系列的第二篇博客中,我们对搜索请求进行了埋点,并针对这些数据运行了六个 ES|QL 查询。我们能够看到用户搜索了什么、哪些查询没有返回结果,以及搜索速度有多快。
但这里存在一个盲点。一个返回了 15 条结果的搜索,从服务端来看一切正常。我们拥有的所有指标都表明它成功了。但如果没有任何用户点击这些结果,那么你的排序就存在问题,而 Blog 2 中的所有查询都无法告诉你这一点。
这正是 "返回了结果" 和 "返回了有用结果" 之间的差距。搜索量、零结果率和延迟衡量的是搜索机制本身,却无法衡量搜索是否真正帮助用户找到了他们需要的内容。
要回答这个问题,你需要第二个埋点:点击跟踪。
如果你正在跟着代码一起实践,参考项目已经准备好了点击跟踪。取消 app.py 和 frontend/app.js 中 Blog 3 部分的注释,重新启动,然后运行 python generate_traffic.py --blog 3 生成流量。
点击数据如何衡量搜索相关性
在编写任何代码之前,我们先看看点击数据能够衡量什么,以及每个指标为什么重要。
- CTR 回答的是最基本的用户参与问题:有多少搜索最终产生了至少一次点击?如果你的 CTR 很低,说明用户虽然看到了搜索结果,但没有觉得它们足够有吸引力,因此没有点击。在拥有足够数据后,你应该建立自己的基线;CTR 会因产品类别、查询类型和行业不同而有很大差异。
- MRR 更进一步:当用户点击时,他们点击的是第几个搜索结果?MRR 为 1.0 表示所有用户都点击了第一条结果(完美排序);MRR 为 0.5 表示平均点击的是第二条结果。高 CTR 而低 MRR 尤其值得关注。这意味着用户最终找到了想要的内容,但你的排序让他们不得不往下翻找。
- 点击位置分布展示了用户点击位置的整体形态。一个健康的搜索引擎,大多数点击都会集中在第一条结果,并随着排名下降迅速减少。如果第 1 到第 5 个位置的点击分布比较平均,则说明你的排序没有很好地区分结果。按查询统计的位置分布能够准确指出哪些搜索需要进行相关性调优。
这三个指标共同帮助你把问题从"搜索是否成功?"(Blog 2)提升到"搜索是否做得足够好?",并准确指出应该把精力投入到哪些相关性改进上。在本系列后续文章中,我们将展示如何利用这些指标采取实际行动,例如构建用于 Learning To Rank(LTR)的判断列表(judgment list)、使用 Elasticsearch Relevance Studio 等工具进行相关性调优,以及使用 Rank Eval API 评估改进效果。但首先,你需要收集这些数据。
而所有这三个指标,只需要增加一个新的埋点,大约 15 行代码即可。
添加点击跟踪
点击跟踪用于捕获搜索结果展示之后发生的行为。当用户点击某个搜索结果时,我们会创建一个新的 span,并添加描述此次交互的属性,包括用户点击了哪个文档、该文档在搜索结果中的位置,以及此次点击对应的是哪一次搜索。
下面是代码:
less
`
1. # Track which query_ids have already received a click
2. _clicked_queries: set[str] = set()
4. @app.post("/api/events")
5. async def track_event(event: EventRequest): # reference project uses ClickEvent
6. with tracer.start_as_current_span("search.result.click") as span:
7. span.set_attribute("search.action", "click")
8. span.set_attribute("search.result_click_id", event.object_id)
9. span.set_attribute("search.result_click_position", event.position)
10. span.set_attribute("search.result_click_type", event.object_id_type)
11. span.set_attribute("search.query_id", event.query_id)
12. span.set_attribute("enduser.pseudo.id", event.client_id)
14. # First click per search --- enables single-query CTR
15. if event.query_id not in _clicked_queries:
16. span.set_attribute("search.first_click", True)
17. _clicked_queries.add(event.query_id)
19. if event.user_query:
20. span.set_attribute("search.query", event.user_query)
`AI写代码
让我们来看看其中的关键点。
点击 span 是独立的 trace
这是与 Blog 2 最大的架构区别。搜索 span 是在 API 请求期间同步创建的:用户发起搜索、span 开始、Elasticsearch 返回响应,然后 span 结束。而点击 span 是异步的。用户执行搜索、获得结果、浏览页面,可能在 30 秒后才点击某个结果(也可能根本不会点击)。
这意味着点击 span 并不是搜索 span 所属 trace 的子 span。它们是独立的 trace,通过 search.query_id 与对应的搜索关联起来。这与我们在 Blog 2 中从 trace ID 派生出的 query_id 是同一个值,现在它充当 traces-generic.otel-default 中搜索与点击之间的关联键(join key)。
为点击选择合适的 OTel 信号
OTel 提供了三种信号类型,而点击事件可以建模为其中任何一种。每种方式都有各自的优势:
| 信号 | 索引 | 开销 | 最适合用于 |
|---|---|---|---|
| Span | traces-generic.otel-default | 完整的 trace 上下文 | 与搜索 span 在同一索引中进行查询 |
| Log | logs-* | 较低开销 | 以日志为中心的流水线、高数据量场景 |
| Span event | logs-generic.otel-default | 最低埋点开销 | 附加到现有 span |
- Spans 会和 search span 一起存储在 traces-generic.otel-default 中,可以在 ES|QL 中进行完整查询,会出现在 Kibana APM 视图中,并且携带时间信息。由于我们的 search span 已经位于 traces-generic.otel-default 中,使用 span 来表示点击意味着你可以在单个 ES|QL 语句中同时查询搜索和点击,并且不需要跨索引连接。
- 日志记录也可以独立在 ES|QL 中查询,存储在 logs-* 中。如果你使用相同的 search.* 属性名称,分析查询几乎完全相同;只需要更改索引模式即可。日志更加轻量(没有 trace 上下文开销),如果你的团队已经拥有以日志为中心的可观测性流水线,那么日志是一个自然选择。Elastic 在这里的优势之一是,trace、日志和指标都会进入同一个平台,并且都可以使用 ES|QL 查询,因此选择日志而不是 span 并不会失去任何查询能力。
- Span events 在进行插桩时非常轻量(通过 OTel API 附加到现有 span 上)。在 Elastic 的 OpenTelemetry Protocol(OTLP)摄取流水线中,span event 会作为独立文档写入 logs-* 数据流(例如 logs-generic.otel-default),并且可以像日志一样独立查询。它们是一个不错的轻量级选择,但相比日志可能需要更多代码修改,而日志甚至可以从旧代码中获取日志数据。
在这个系列中,我们使用 span,因为它们可以让搜索和点击保持在同一个索引中,并提供最简单的查询路径。但是,如果你的数据量很大并且希望优化成本,或者你的组织已经将 OTel 日志路由到 Elasticsearch,那么基于日志的方法也能很好地工作;无论采用哪种方式,search.* 属性 schema 都是相同的,并且针对 logs-* 的 ES|QL 查询遵循与你将在下面看到的相同模式。
search.first_click 属性
search.first_click 是一个 Boolean 值,只会针对给定 query_id 的第一次点击进行设置。它存在的原因只有一个:无需后处理即可准确计算 CTR。
CTR 定义为至少有一次点击的搜索所占的百分比。如果没有 search.first_click,你需要在查询时根据 query_id 对点击进行去重:分组、计算不同值数量,以及执行子查询。通过在插桩时标记第一次点击,ES|QL 查询就变成了一个简单的计数。
上面的 set 仅用于演示。它会无限增长,并且在多个 API 副本情况下会失效。参考实现使用了一个线程安全的、带有 30 分钟过期窗口的 TTL 字典(app.py 中的 _is_first_click())。在生产环境中,如果存在多个副本,请使用由 query_id 作为 key 的共享外部缓存(Redis、Memcached),并将 TTL 设置为与你的会话窗口匹配。
在哪里跟踪第一次点击:前端 vs. 后端
search.first_click 去重逻辑可以位于前端或后端。两种方式都有效,下面是它们之间的权衡:
前端跟踪实现更加简单。浏览器已经知道当前查询以及用户是否已经点击过。不需要服务器端状态,不需要担心多个副本,并且无需任何后端修改即可工作。缺点是浏览器状态是临时的;页面刷新、多个标签页或者广告拦截器可能会影响准确跟踪。
后端跟踪(我们采用的方法)提供了单一事实来源。所有点击事件都会流经同一个位置,因此无论客户端如何操作,去重结果都是一致的。它还意味着分析逻辑与插桩代码位于同一位置,这简化了对数据质量的理解。代价是后端需要维护状态:_clicked_queries 集合。对于单实例 API,这非常简单;对于负载均衡器后面的多个副本,你需要使用共享 TTL 缓存(Redis 或类似方案)。
我们在这里选择后端跟踪,因为我们希望分析数据具有权威性。这些点击数据之后会用于相关性调优和判断列表,其中准确性非常重要。但是,如果你刚开始构建简单系统,或者运行仅客户端的方案,前端跟踪也是一个完全合理的第一步。无论你在哪里设置 search.first_click 属性,它的工作方式都是一样的。
从浏览器发送点击事件
当用户点击搜索结果时,浏览器会向后端发送点击事件。它需要从搜索响应中获取三个信息:文档 ID、位置和 query_id。
php
`
1. // Generate a persistent client ID once per browser (stored in localStorage)
2. const CLIENT_ID = localStorage.getItem("search_client_id")
3. || (() => {
4. const id = crypto.randomUUID();
5. localStorage.setItem("search_client_id", id);
6. return id;
7. })();
9. // On result click
10. fetch('/api/events', {
11. method: 'POST',
12. headers: { 'Content-Type': 'application/json' },
13. body: JSON.stringify({
14. object_id: product.id,
15. position: index + 1, // 1-indexed
16. query_id: lastQueryId, // from the most recent search response
17. client_id: CLIENT_ID, // persistent browser identifier → enduser.pseudo.id
18. user_query: currentQuery,
19. object_id_type: 'product', // optional; defaults to "product" on the backend
20. })
21. });
`AI写代码
CLIENT_ID 只生成一次并存储在 localStorage 中。它可以在页面重新加载后继续存在,并且无需登录即可提供稳定的 enduser.pseudo.id。后端会在 span 上将 client_id 映射到 enduser.pseudo.id。
位置使用从 1 开始的索引;也就是说,第一个结果的位置是 1,而不是 0。
点击跟踪 OTel 属性和 ES|QL 字段映射
| 属性 | 类型 | 必需 | 用途 |
|---|---|---|---|
| search.action | 字符串 | 是 | 事件类型:"click" |
| search.result_click_id | 字符串 | 是 | 被点击的文档 ID |
| search.result_click_position | 整数 | 是 | 结果中的位置(从 1 开始索引) |
| search.query_id | 字符串 | 是 | 关联到原始搜索 |
| enduser.pseudo.id | 字符串 | 是 | 客户端/设备标识符 |
| search.first_click | 布尔值 | 推荐 | 如果这是该 query_id 的第一次点击,则为 true |
| search.result_click_type | 字符串 | 推荐 | 对象类型:"product"、"article" |
| search.query | 字符串 | 推荐 | 搜索查询文本(用于可查询性) |
这些遵循我们在 Blog 2 中建立的相同 search.* 命名空间。通过基于 OTel 的原生摄取,属性会直接映射到 ES|QL 中的 attributes.* 字段:
| OTel 属性 | ES|QL 字段 |
|---|---|
| search.action | attributes.search.action |
| search.result_click_id | attributes.search.result_click_id |
| search.result_click_position | attributes.search.result_click_position |
| search.query_id | attributes.search.query_id |
| search.first_click | attributes.search.first_click |
通过基于 OTel 的原生摄取,search.first_click 会作为原生 Boolean 类型存储;你需要使用 == true 进行查询,而不是 == "true",并且不需要进行字符串强制转换。
验证点击是否正在到达
在计算指标之前,确认点击 span 正在流入 APM:
markdown
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.action == "click"
3. | KEEP attributes.search.result_click_id, attributes.search.result_click_position,
4. attributes.search.query_id, attributes.search.query
5. | LIMIT 5
`AI写代码
如果这返回了结果行,你就可以进行分析了。如果没有,请检查我们在 Blog 2 中查看过的相同内容:OTLP endpoint、认证 token 和 span 导出。
注意 :本文中的结果仅用于说明,通过在参考项目中运行 python generate_traffic.py --blog 3 --sessions 50 生成。运行 Blog 3 流量会在 Blog 2 的 62 个基础搜索会话之上添加点击事件和额外搜索会话,因此累计搜索数量会超过 62。你的具体数字会根据会话数量以及流量模拟器的随机性而有所不同。需要关注的是指标计算方式和 ES|QL 模式。
CTR
CTR 是搜索相关性的主要信号,它回答了 Blog 2 无法回答的问题:用户是否正在与结果进行交互?
CTR = 至少有一次点击的搜索次数 / 总搜索次数 * 100
CTR 是一个按搜索计算的二元指标:一次搜索要么被点击,要么没有被点击。最大值是 100%。
使用 ES|QL 计算整体搜索 CTR
ini
`
1. FROM traces-generic.otel-default
2. | WHERE (name == "search" AND attributes.search.query IS NOT NULL)
3. OR attributes.search.first_click == true
4. | STATS
5. searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
6. clicked = COUNT(CASE(attributes.search.first_click == true, 1))
7. | EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)
`AI写代码
结果 :146 次总搜索中有 41 次产生了点击。CTR:28.1%
这是一个同时从 traces-generic.otel-default 中获取 search span 和 first-click span 的单个查询。WHERE 子句中的 OR 将两者放入同一个结果集中。COUNT(CASE(...)) 分别计算每种类型的数量,而 EVAL 用于执行除法计算。
这之所以有效,是因为有 search.first_click。没有它,你计算的将是原始点击次数(用户在一次搜索中点击三个结果会使计数被夸大)。去重操作已经在插桩时完成;查询保持简单。
按搜索查询计算 CTR:发现最严重的相关性问题
整体数值适用于仪表板。按每个查询进行细分,才能发现问题。
sql
`
1. FROM traces-generic.otel-default
2. | WHERE ((name == "search" AND attributes.search.query IS NOT NULL)
3. OR attributes.search.first_click == true)
4. AND attributes.search.query IS NOT NULL
5. | STATS
6. searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
7. clicked = COUNT(CASE(attributes.search.first_click == true, 1))
8. BY attributes.search.query
9. | EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)
10. | SORT searches DESC
11. | LIMIT 20
`AI写代码
这展示了按查询文本拆分后的 CTR。
CTR 可以告诉你什么(以及它不能告诉你什么)
- 高搜索量 + 零点击:这是最严重的相关性失败。优先修复这些问题。
- 高搜索量 + 低 CTR:结果出现了,但它们没有吸引力。检查排序。
- 低 CTR + 高无结果率:这是双重问题;可能是没有结果,或者结果质量差。
- CTR 随时间变化趋势:这可以衡量相关性改进的影响。
CTR 不衡量满意度。一个点击了位置 1 的用户,返回后又点击位置 3,仍然只会被计为一次有点击的搜索。为了获得更完整的情况,你需要知道用户点击了哪个位置。这就是 MRR 衡量的内容。
CTR 与每次搜索点击数
CTR(我们刚刚计算的指标)的上限是 100%。这是行业标准定义。
每次搜索点击数是总点击次数除以总搜索次数。它可以超过 1.0。例如,一次搜索中用户点击了三个结果,则得分为 3.0。它衡量的是交互深度,这很有用,但与 CTR 不同。如果你需要这个指标,可以统计所有 click span(而不仅仅是 first_click),然后除以 search span 数量。
MRR
MRR 告诉你用户点击的位置。它衡量用户需要在结果列表中向下查看多远,才能找到值得点击的内容。
MRR = 所有点击中 (1 / 点击位置) 的平均值
倒数排名会将点击位置转换为 0 到 1 的范围,其中越高越好:
| 点击位置 | 倒数排名 |
|---|---|
| 1 | 1.000 |
| 2 | 0.500 |
| 3 | 0.333 |
| 5 | 0.200 |
| 10 | 0.100 |
使用 ES|QL 计算整体搜索 MRR
MRR 可以通过两种方式计算,具体取决于你想衡量什么:
- 所有点击 MRR:计算每次点击的倒数排名平均值,反映整体点击质量,包括重复交互。
- 首次点击 MRR:只计算每次搜索中的第一次点击(使用 search.first_click == true)。它更接近传统的信息检索(IR)评估方式,并且与你计算 CTR 的方式保持一致。
为了与 CTR 保持一致,并与 Blog 5 中的判断列表工作流保持一致,我们更倾向于使用首次点击 MRR:
ini
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.action == "click"
3. AND attributes.search.first_click == true
4. | EVAL reciprocal_rank = 1.0 / attributes.search.result_click_position
5. | STATS mrr = ROUND(AVG(reciprocal_rank), 3)
`AI写代码
结果:MRR = 0.495
这个结果还不错,但也显示出改进空间。MRR 为 0.495 意味着平均首次点击大约落在第 2 个位置。这并不是严重问题,但存在一些查询的排序效果可以提升。
按搜索查询计算 MRR:发现排名最差的结果
和 CTR 一样,按每个查询进行细分,才能找到可操作的数据。为了优先显示排名最差的查询,请按升序排序。
ini
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.action == "click"
3. AND attributes.search.first_click == true
4. AND attributes.search.query IS NOT NULL
5. | EVAL reciprocal_rank = 1.0 / attributes.search.result_click_position
6. | STATS
7. mrr = ROUND(AVG(reciprocal_rank), 3),
8. clicks = COUNT(*)
9. BY attributes.search.query
10. | SORT mrr ASC
11. | LIMIT 20
`AI写代码
这可以揭示哪些查询具有最差的排名效果。一个具有多次点击但 MRR 较低的查询,意味着该搜索的排名持续较差;也就是说,用户能够找到结果,但需要深入查找。
什么是好的搜索 MRR 分数?
- MRR > 0.8:这个排名效果很好;用户通常会点击第 1--2 个位置。
- MRR 0.5--0.8:这个结果还不错,但仍有改进空间。
- MRR < 0.5:这是一个排名问题,用户正在跳过顶部结果继续向下浏览。
- 更改后的 MRR 下降:这是排名回退,需要立即调查。
- 低 MRR + 高 CTR:用户能够找到内容,但需要付出更多努力。
最后一种模式尤其值得关注。高 CTR 结合低 MRR 表示你的结果是相关的(用户会点击),但你的排序没有将最佳结果优先展示。这是一个优化机会,而不是危机。
MRR 的限制:位置偏差和多点击会话
MRR 会受到第 1 个位置和第 2 个位置之间差距的强烈影响(1.0 对比 0.5)。第 5 个位置及之后的位置几乎不会影响平均值。这意味着 MRR 对顶部结果是否良好最敏感,而这通常正是你希望优化的目标。
MRR 也只衡量点击,而不是满意度。对于所有点击 MRR,一个点击位置 1 的用户,返回后又点击位置 3,会贡献两个数据点,但只有第二次点击是真正有用的。上面的首次点击 MRR 查询通过 search.first_click == true 只统计每次搜索中的第一次点击,避免了这个问题。
点击位置分布
点击位置分布可以展示完整情况,即用户在结果中的哪个位置进行交互。
sql
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.action == "click"
3. | STATS click_count = COUNT(*) BY attributes.search.result_click_position
4. | SORT attributes.search.result_click_position ASC
`AI写代码
结果:
| 位置 | 点击次数 |
|---|---|
| 1 | 21 |
| 2 | 10 |
| 3 | 7 |
| 4 | 4 |
| 5 | 3 |
这是一个合理的分布:45 次点击中有 21 次(47%)落在第 1 个位置,并且后续位置的点击次数逐渐减少。如果你将此查询粘贴到 Discover 的 ES|QL 编辑器中,Kibana 会自动生成一个柱状图,使这种分布形态立即可见。
如何通过点击位置分布判断搜索相关性
- 位置 1 之后快速下降:排序效果很好,顶部结果通常是正确的。
- 位置 1--5 之间较为平坦:排序区分能力不足,所有位置被点击的可能性接近。
- 特定查询在位置 3+ 出现峰值:这些查询存在排序问题。
- 位置 5 之后没有点击:用户不会向下滚动太多。前 5 个结果的排序最重要。
按搜索查询查看点击位置分布
要查看特定查询的分布形态:
sql
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.action == "click"
3. AND attributes.search.query IS NOT NULL
4. | STATS click_count = COUNT(*)
5. BY attributes.search.query, attributes.search.result_click_position
6. | SORT attributes.search.query, attributes.search.result_click_position
`AI写代码
一个所有点击都落在位置 1 的查询表示排序效果完美。一个点击分布在位置 1--5 的查询则需要进行相关性调优。
位置偏差和点击模型
需要注意的一点是:点击位置分布会受到位置偏差的影响;也就是说,用户首先看到位置 1,因此无论相关性如何,它都会获得更多点击。位置 1 的点击并不一定表示它更相关,只是它更容易被看到。
这是信息检索领域中一个经过深入研究的问题。点击模型是尝试从点击数据中分离真实相关性和位置偏差的统计模型。Joachims 等人在 2005 年的基础研究表明,用户会明显偏向更高排名的结果;在提出的方法(例如跳过上方分析 skip-above analysis)中(如果用户点击位置 3,但跳过位置 1 和 2),这些被跳过的结果很可能对于该查询来说相关性较低。
对于本文中的指标,你不需要实现完整的点击模型。关键的实际经验是:比较不同查询之间的分布,而不是将绝对位置计数视为真实标准。如果查询 A 有 80% 的点击发生在位置 1,而查询 B 的点击分布在位置 1--5 之间,那么即使考虑位置偏差,查询 B 的排序效果也更差。在后续系列中,当我们讨论为 LTR 构建判断列表时,位置偏差校正会变得更加重要,而你在这里收集的点击数据正是这些模型所需要的输入。
CTR、MRR 和点击分布:结合阅读搜索质量指标
这三个指标从三个不同角度提供了搜索结果质量的信息:
| 指标 | 衡量内容 | 我们的值 | 解释 |
|---|---|---|---|
| CTR | 用户是否会点击? | 28.1% | 中等:大约三分之一的搜索会产生交互,但仍有改进空间。 |
| MRR | 用户点击了哪里? | 0.495 | 不错:平均点击位置大约在第 2 位,但排序仍可以改进。 |
| 分布 | 分布形态是什么? | 47% 位于位置 1 | 合理的下降趋势:顶部结果赢得大多数点击,但并不占据绝对优势。 |
综合来看,这三个指标讲述了一个一致的故事。对于我们的演示数据,搜索表现基本良好:用户正在与结果进行交互,并且能够找到所需内容,但排序仍有改进空间。对于一个尚未进行调优的新搜索实现来说,28% 的 CTR 和 0.495 的 MRR 是合理的起点。
这些指标最有价值的地方在于结合查询级别进行分析。优先修复的查询是那些高搜索量 + 低 CTR + 低 MRR 的查询;也就是说,大量用户正在搜索,很少有人点击,并且点击的用户需要向下浏览很深的位置。这些查询是相关性投入回报最高的地方。
使用点击数据进行 LTR 和相关性调优
这些指标不仅告诉你搜索表现如何;它们也是改进搜索的基础。你现在收集的点击数据可以直接用于相关性改进工作流:
- 用于 LTR 的判断列表:点击位置和点击频率会成为训练机器学习(ML)排序模型的分级相关性标签。一个文档在多个查询中都被点击到位置 1,是一个强正向信号。
- 相关性调优工具 :按查询统计的 CTR 和 MRR 可以准确告诉你应该重点关注哪些查询,例如 Relevance Studio 或 Rank Eval API,这些工具可以根据预期结果评估你的排序效果。
- 查询规则和提升:有结果但 CTR 为零的查询,可以考虑使用固定排序、提升或同义词规则。
我们会在 Blog 5 中详细介绍这些应用。目前,重要的是你在这里构建的插桩具有双重作用:它既可以衡量搜索质量,也可以提供用于改进搜索的训练数据。
系列下一篇:从搜索到购买的转化跟踪
现在我们可以衡量用户是否找到了结果(Blog 2),以及他们是否与结果进行了交互(本文)。但是点击并不等于转化。一个点击了产品但随后离开页面的用户,并没有获得他们需要的内容。
在下一篇文章中,我们将添加转化跟踪,这是连接搜索到购买流程的第三个插桩点。它使用相同的模式:向加入购物车和结账 span 添加 search.* 属性,使用 ES|QL 进行查询,并回答你的产品经理真正关心的问题:哪些搜索带来了收入?
开始使用
- 参考项目:整个博客系列的可运行代码(克隆、配置并运行)。
- Elastic Distribution of OpenTelemetry Python:EDOT Python。
- 使用 Elastic 的 OpenTelemetry:如何将 OTel 数据发送到 Elastic APM。
- ES|QL 文档:查询语言参考。
- UBI 标准:搜索事件结构的参考 schema。
这是关于使用 OpenTelemetry 和 Elastic 进行搜索分析系列中的第三篇文章。下一篇:从点击到转化:转化跟踪、漏斗分析和收入归因。
原文:3 search quality metrics from 15 lines of OTel click code | Elasticsearch Labs