<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Relevance - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Relevance - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/search-labs/blog/category/relevance</link>
    </image>
    <link>https://www.elastic.co/search-labs/blog/category/relevance</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/category/relevance.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Thu, 24 Sep 2026 16:21:20 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Search relevance from click streams: Using Learn To Rank and behavioral signals with OpenTelemetry]]></title>
    <description><![CDATA[Learn how to turn click streams and behavioral signals from OpenTelemetry search analytics into judgment lists, rank features and Learn To Rank models that make search relevance improve over time.]]></description>
    <content:encoded><![CDATA[<p>Every click on a search result is an implicit relevance judgment, and conversions are a stronger signal. The search analytics you've been capturing through OpenTelemetry contain the behavioral data to improve search relevance. Techniques start simple, with fixes you can ship this week and build toward Learn To Rank (LTR) models trained on real click data. The instrumentation that surfaces problems also generates the training data to fix them.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Build judgment lists from click data to evaluate and improve relevance.</p></li><li><p>Apply basic search tuning (field weights, boosts, query rules) informed by analytics.</p></li><li><p>Create rank features from behavioral signals, like popularity and conversion rate.</p></li><li><p>Understand LTR and how click data becomes training data.</p></li><li><p>Close the feedback loop between analytics and relevance improvement.</p></li></ul><h2>What you'll need</h2><ul><li><p>Search analytics data from the previous blogs in this <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">series</a> (<a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">search</a>, <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">click</a>, and <a href="https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry">conversion</a> spans in Elastic).</p></li><li><p>An Elasticsearch index with product data (the reference project includes sample data with rank features).</p></li><li><p>Familiarity with Elasticsearch queries (BM25 [Elasticsearch's default text scoring algorithm], <code>rank_feature</code>, function scores).</p></li></ul><h2>Search relevance techniques at a glance</h2><p><strong>Technique</strong></p><p><strong>Effort</strong></p><p><strong>What it improves</strong></p><p><strong>When to use</strong></p><p><strong>Field weight tuning</strong></p><p>Low</p><p>Relevance for attribute-rich queries</p><p>First step; quick wins</p><p><strong>Query rules</strong></p><p>Low–medium</p><p>Specific high-value queries</p><p>Known bad results for specific terms</p><p><strong>Rank features</strong></p><p>Medium</p><p>Blending behavioral signals with text score</p><p>Popularity, conversion, freshness boosts</p><p><strong>LTR</strong></p><p>High</p><p>Systematic ranking from click data</p><p>When you have ≥5k labeled query-doc pairs</p><h2>From search analytics to search relevance improvements</h2><p>Over the past three posts, you built a full instrumentation pipeline, including search spans with <code>search.*</code> attributes and click tracking with position data and click-through rate (CTR) / Mean Reciprocal Rank (MRR) metrics. This pipeline also includes conversion spans tying searches to revenue. And it all sits in <code>traces-generic.otel-default</code>, queryable with Elasticsearch Query Language (ES|QL).</p><p></p><p>Now you have dashboards, and you know that your CTR is 28%. You also know which queries generate revenue and which ones users abandon. Plus, you can tell your product manager exactly where the funnel leaks.</p><p></p><p>Now what?</p><p></p><p>The real value of search analytics is using behavioral data to improve relevance and close feedback loops. It’s also important to make search learn from its users. Your experience tells you that measurement is necessary but not sufficient, and a dashboard that shows poor ranking doesn't improve that ranking. </p><p></p><p></p><p></p><p>This post covers four practical areas:</p><p></p><ol><li><p><strong>Judgment lists:</strong> The foundation for evaluating and improving relevance.</p></li><li><p><strong>Basic search tuning:</strong> Field weights, boosts, and query rules informed by analytics.</p></li><li><p><strong>Rank features for personalization:</strong> Feeding behavioral signals back into ranking.</p></li><li><p><strong>LTR:</strong> Training machine learning (ML) models on click data to optimize ranking automatically.</p></li></ol><p></p><p>Each one draws directly from the <code>search.*</code> attributes you're already collecting, and they don’t require any new instrumentation.</p><h2>What are judgment lists?</h2><p>Before diving into specific techniques, it's worth understanding the concept that ties them all together: <em>judgment lists</em>.</p><p></p><p>A judgment list is a set of query-document pairs with relevance grades: For a given query, how relevant is each document? They look like this:</p><p></p><p><strong>Query</strong></p><p><strong>Document</strong></p><p><strong>Grade</strong></p><p><strong>Label</strong></p><p>"wireless headphones"</p><p>SKU-001 (Sony WH-1000XM5)</p><p>3</p><p>Highly relevant</p><p>"wireless headphones"</p><p>SKU-042 (AirPods Max)</p><p>2</p><p>Relevant</p><p>"wireless headphones"</p><p>SKU-099 (Wired earbuds)</p><p>0</p><p>Not relevant</p><p></p><p>Judgment lists serve three purposes:</p><p></p><ul><li><p><strong>Evaluating current relevance.</strong> Given a set of queries and known-good results, how well does your search rank them? Metrics like <a href="https://en.wikipedia.org/wiki/Discounted_cumulative_gain">Normalized Discounted Cumulative Gain</a> (NDCG) and the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html">Rank Eval API</a> use judgment lists to score your ranking quality. This gives you a baseline before making changes.</p></li></ul><p></p><ul><li><p><strong>Measuring the impact of changes.</strong> When you adjust field weights or add synonyms, judgment lists tell you whether the change helped or hurt. The same is true when you change boosting rules. Run the same evaluation before and after: If NDCG went up, the change improved relevance for those queries.</p></li></ul><p></p><ul><li><p><strong>Training LTR models.</strong> LTR algorithms need labeled training data, such as, <em>For this query, these documents are relevant and those aren't</em>. Judgment lists are that training data.</p></li></ul><h3>Manual vs. automated judgment lists</h3><p>Traditionally, judgment lists are created by human assessors who manually rate documents for a set of test queries. This works well for a small number of high-value queries (your top 50 searches, for example), but it doesn't scale. A large catalog with thousands of distinct queries and frequent inventory changes makes manual assessment impractical.</p><p></p><p>This is where your click data becomes valuable. Every click is an implicit relevance judgment; that is, a signal that for a given query, a given document was relevant enough to engage with. Conversions are even stronger signals. By aggregating this data, you can build judgment lists automatically from real user behavior, at a scale that manual assessment can't match.</p><p></p><p>The trade-off is noise. Clicks are influenced by position bias (users click higher-ranked results more often regardless of relevance) and presentation effects. They’re also influenced by accidental clicks. We'll cover techniques to handle this noise later in this post. But the key insight is that approaches like personalization and LTR lean toward automated judgment lists because it's not scalable to manually create lists for every query or user segment, nor for every inventory change.</p><p></p><p>The <a href="https://www.elastic.co/search-labs/blog/judgment-lists-search-query-relevance-elasticsearch">judgment lists guide on Search Labs</a> covers the concept in depth, including how to structure lists for evaluation with the Rank Eval API.</p><h2>How search analytics identify search relevance problems</h2><p>The most immediate use of your analytics data is to identify and fix specific relevance problems. This only requires using data to direct manual improvements, no personalization or ML.</p><h3>Finding problem queries with search analytics</h3><p>The ES|QL queries from the click in your application give you per-query CTR and MRR. Sort by search volume descending and CTR ascending to find your highest-impact relevance failures:</p>FROM traces-generic.otel-default
| WHERE ((name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true)
  AND attributes.search.query IS NOT NULL
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
  BY attributes.search.query
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)
| WHERE searches &gt; 5
| SORT ctr_pct ASC, searches DESC
| LIMIT 20<p>This is the CTR-by-query query re-sorted to surface problems first. The <code>WHERE searches &gt; 5</code> filter removes one-off queries that would dominate the low-CTR list with small sample noise.</p><ul><li><p><strong>Zero-CTR queries with results. </strong> Your ranking is returning content, but none of it’s compelling. These queries often benefit from synonym expansion. You can also improve them with boosting rules or pinned results.</p></li><li><p><strong>Low-CTR, high-volume queries.</strong> These are the biggest relevance investment opportunities, and they affect the most users.</p></li><li><p><strong>Low-MRR, high-CTR queries.</strong> Users find what they need but have to scroll for it. In these instances, the relevant documents exist, but they're ranked wrong.</p></li></ul><h3>Try it: Close the loop in the reference project</h3><p>If you have click data in the reference project(<code>python generate_traffic.py --blog 3 --sessions 100</code>), you can run the problem-query query above against <code>traces-generic.otel-default</code> and observe your lowest-CTR queries.</p><p></p><p>The reference project's <code>app.py</code> already uses <code>rank_feature</code> boosting: <code>rank_features.popularity</code>, <code>rank_features.conversion_rate</code>, <code>rank_features.margin_score</code>, and <code>rank_features.freshness</code> are indexed on every product and blended into the BM25 score at query time. To see the effect of adjusting a boost:</p><p></p><ol><li><p>Open <code>reference/app.py</code>, and find the <code>"should"</code> clause in <code>_build_search_query()</code>.</p></li><li><p>Change the <code>"boost"</code> value on <code>rank_features.popularity</code> from <code>2</code> to <code>5</code>.</p></li><li><p>Restart the server (<code>python app.py</code>) and rerun <code>generate_traffic.py --blog 3 --sessions 50</code>.</p></li><li><p>Compare the top-queries and click position distribution in ES|QL before and after.</p></li></ol><p></p><p>This is informed manual tuning using the behavioral data you collected, not ML. That's the pattern for the rest of this post: First, ES|QL tells you what's wrong. Then, configuration changes and, eventually, LTR models fix it.</p><h3>Field weights, boosts, and scoring for search relevance</h3><p>The simplest tuning lever in Elasticsearch is adjusting how different fields contribute to the relevance score. A <code>multi_match</code> query across <code>title</code>, <code>description</code>, and <code>brand</code> fields can weight the title higher because a match there is usually more relevant. Your analytics data tells you where these weights are wrong: If users consistently click products with titles that don't match the query but with descriptions that do, your title boost may be too aggressive.</p><p></p><p>Beyond field weights, you can incorporate business metrics directly into scoring. A common example is <em>boosting by profit margin</em>; that is, products with higher margins rank slightly higher when relevance scores are similar. The <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/rank-feature.html"><code>rank_feature</code> field type</a> is designed for exactly this: Index a numeric signal (margin, popularity, recency) alongside each document, and the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-rank-feature-query.html"><code>rank_feature</code> query</a> blends it with text relevance at query time. For more complex scoring combinations, like decay functions, weighted field values, and scripts, the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html"><code>function_score</code> query</a> gives you full control. The <a href="https://www.elastic.co/search-labs/blog/function-score-query-boosting-profit-popularity-elasticsearch">boosting by profit and popularity guide on Search Labs</a> walks through this in detail, including the trade-offs between additive and <a href="https://www.elastic.co/search-labs/blog/bm25-ranking-multiplicative-boosting-elasticsearch">multiplicative boosting</a> approaches.</p><h3>Query rules for targeted search relevance fixes</h3><p>For targeted interventions, <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/searching-with-query-rules">query rules</a> let you pin, boost, or exclude specific results for specific queries, and no model training is required. They're the search equivalent of a manual override.</p><p></p><p>Your analytics data tells you exactly where to apply them. When you see a zero-CTR query, like "returns policy", that consistently returns product results instead of the returns page, you can pin the returns page at position 1 for that query. If you notice a high-revenue query where the best-selling product appears at position 4, you can boost it.</p><p></p><p>Query rules are valuable precisely because they're simple. They solve known problems immediately while you build toward more sophisticated approaches. The <a href="https://www.elastic.co/search-labs/blog/elasticsearch-query-rules-ui-introduction">query rules tutorial</a> on Search Labs walks through the setup.</p><h2>Building judgment lists from click data</h2><p>The basic tuning above handles individual problems. To improve relevance systematically, you need judgment lists, and your click data can build them automatically.</p><h3>Converting click data into relevance grades</h3><p>The simplest approach is to count clicks per query-document pair and assign graded relevance:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
  AND attributes.search.query IS NOT NULL
| STATS
    click_count = COUNT(*),
    avg_position = AVG(attributes.search.result_click_position)
  BY attributes.search.query, attributes.search.result_click_id
| SORT attributes.search.query, click_count DESC<p>This gives you a table of (<code>query</code>, <code>document</code>, <code>click_count</code>, <code>avg_position</code>) tuples. The grading step maps click counts to relevance levels:</p><p><strong>Click count</strong></p><p><strong>Suggested grade</strong></p><p><strong>Label</strong></p><p>0</p><p>0</p><p>Not relevant (never clicked for this query)</p><p>1</p><p>1</p><p>Marginally relevant</p><p>2–3</p><p>2</p><p>Relevant</p><p>4+</p><p>3</p><p>Highly relevant</p><p></p><p>The thresholds depend on your traffic volume. For a high-traffic site, you might need 10+ clicks before calling something "highly relevant." For lower traffic, even two or three clicks is a meaningful signal. The point is that click frequency across users is a stronger signal than any single click.</p><p></p><p>You can strengthen judgments further by incorporating conversion data, a document that gets clicked <em>and</em> added to cart is a stronger relevance signal than one that gets clicked and abandoned:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action IN ("click", "add_to_cart")
  AND attributes.search.query IS NOT NULL
| STATS
    clicks = COUNT(CASE(attributes.search.action == "click", 1)),
    carts = COUNT(CASE(attributes.search.action == "add_to_cart", 1))
  BY attributes.search.query, attributes.search.result_click_id
| SORT attributes.search.query, clicks DESC<p>A document with five clicks and three cart additions is a stronger candidate for grade 3 than one with five clicks and zero cart additions.</p><h3>Handling position bias in click data</h3><p>There's a problem with raw click counts: <em>position bias</em>. Users click position 1 more often because they <em>see</em> it first, not necessarily because it's the most relevant result. Blog 3 introduced this concept when we discussed click position distribution and referenced the foundational work by <a href="https://www.cs.cornell.edu/people/tj/publications/joachims_etal_05a.pdf">Joachims et al. (2005)</a>.</p><p></p><p>Position bias matters for judgment lists because it means raw click data over-weights whatever the current ranking happens to surface first. If you train an LTR model on biased judgments, it learns to replicate the existing ranking, which defeats the purpose.</p><p></p><p>Two practical approaches to handle this:</p><p></p><ul><li><p><strong>Position-normalized click rates.</strong> Instead of raw click counts, calculate click-through rate <em>per position</em>. A document clicked 3 out of 10 times when shown at position 5 is arguably more relevant than one clicked 5 out of 10 times at position 1. Position 5 gets less visibility, so a higher click rate there is a stronger relevance signal.</p></li></ul><ul><li><p><strong>Skip-above heuristics.</strong> When a user clicks position 3 but skips positions 1 and 2, those skipped documents are implicitly judged "less relevant" for that query. This is the core insight from Joachims et al.: Skipped-above results provide negative training signals. You can extract these pairs from your click data:</p></li></ul>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
  AND attributes.search.query IS NOT NULL
| STATS
    min_click_position = MIN(attributes.search.result_click_position),
    max_click_position = MAX(attributes.search.result_click_position),
    click_count = COUNT(*)
  BY attributes.search.query_id, attributes.search.query
| WHERE min_click_position &gt; 1
| SORT click_count DESC<p>Queries where the minimum click position is greater than 1 are sessions where the user skipped the top result(s). The documents at those skipped positions, for those queries, are candidates for grade 0 in your judgment list, because the user saw them and chose something lower.</p><p></p><p>For production judgment list generation, the <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">LTR tutorial on Search Labs</a> walks through the complete pipeline. The <a href="https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data">training LTR models with user behavior data</a> guide covers the specific workflow of deriving training data from click-through behavior, including the Clicks Over Expected Clicks (COEC) algorithm for debiasing. And the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb">LTR Jupyter notebooks</a> provide working Python code for feature extraction and model training.</p><h2>Rank features for search relevance personalization</h2><p>Basic tuning applies the same ranking to every user. Personalization means adapting results based on who’s searching (their history, preferences, or segment). Rank features are the most accessible way to do this in Elasticsearch.</p><h3>Building behavioral signals from click streams</h3><p>You can aggregate your click and conversion data at the document level to produce signals that reflect overall or segment-specific popularity:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action IN ("click", "add_to_cart")
| STATS
    clicks = COUNT(CASE(attributes.search.action == "click", 1)),
    carts = COUNT(CASE(attributes.search.action == "add_to_cart", 1))
  BY attributes.search.result_click_id
| EVAL cart_rate_pct = ROUND(100.0 * carts / clicks, 1)
| SORT clicks DESC
| LIMIT 20<p>These document-level signals (click popularity, cart rate, and conversion rate) are indexed as <code>rank_feature</code> fields on each product document. The workflow:</p><p></p><ol><li><p>Run the ES|QL query above periodically (daily or weekly).</p></li><li><p>Write the results back to a <code>click_popularity</code> or <code>conversion_rate</code> <code>rank_feature</code> field on each product document.</p></li><li><p>Use a <code>rank_feature</code> query to blend text relevance with behavioral signals.</p></li></ol><p></p><p>The <code>rank_feature</code> query applies a saturation function by default: Initial popularity gains matter most, diminishing as values get large. This prevents a single viral product from dominating all queries. You can tune the function's pivot point to control how much influence the feature has relative to text relevance.</p><h3>Personalizing rank features by user segment</h3><p>The query above produces <em>global</em> popularity; that is, it’s the same for every user. Personalization comes from segmenting these signals. If you're tracking <code>enduser.pseudo.id</code> or <code>user.id</code>, you can compute features per user cohort:</p><p></p><ul><li><p><strong>Category affinity:</strong> How often does this user click products in "electronics" versus "clothing"?</p></li><li><p><strong>Price sensitivity:</strong> Does this user tend to click and convert on higher- or lower-priced items?</p></li><li><p><strong>Brand preference:</strong> Which brands does this user engage with most?</p></li></ul><p></p><p>These become additional rank features, applied at query time based on who's searching. The <a href="https://www.elastic.co/search-labs/blog/personalized-search-elasticsearch-ltr">personalized search with LTR</a> guide on Search Labs walks through training per-user ranking models. For a lighter approach, the <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-relevance-cohort-aware-ranking-elasticsearch">cohort-aware ranking guide</a> shows how to use multiplicative boosting to personalize at the segment level without ML, just analytics-derived weights applied to rank features at query time.</p><h3>Combining multiple signals with the linear retriever</h3><p>When you're blending text relevance with semantic search and behavioral rank features, you need a way to combine them. Elasticsearch's <a href="https://www.elastic.co/search-labs/blog/linear-retriever-hybrid-search">linear retriever</a> gives you precise control over how different query types contribute to the final ranking. It computes a weighted sum of normalized scores, so you can say <em>text relevance matters 60%, semantic similarity 30%, popularity 10%</em> and adjust those weights based on your analytics. This is unlike Reciprocal Rank Fusion (RRF), which only considers relative rank positions.</p><p></p><p>This is particularly useful for personalization because you can vary the weights per user segment. A returning customer might get more weight on purchase history, while a first-time visitor gets more weight on global popularity.</p><p></p><p>If you're also using semantic search with embedding models, the <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v3-elastic-inference-service">Elastic Inference Service</a> (EIS) now offers GPU-accelerated embedding generation, including multilingual models, directly within Elastic Cloud, making it straightforward to add a semantic retriever alongside your text and behavioral signals.</p><h2>How Learn To Rank uses click streams to optimize ranking</h2><p>Rank features handle individual signals, but LTR handles all of them at once. It trains an ML model to combine features (like text relevance, popularity, CTR, recency, margin, or user affinity) into a single ranking function optimized for your users.</p><h3>What you need for Learn To Rank in Elasticsearch</h3><p>Elasticsearch has supported <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">native Learning to Rank since version 8.12</a> as an Enterprise subscription feature. The pipeline:</p><p></p><ol><li><p><strong>Judgment list.</strong> Query-document pairs with relevance grades (built from click data, as above).</p></li><li><p><strong>Feature extraction.</strong> Numeric signals for each query-document pair (BM25 score, popularity, CTR, recency, price, margin, user segment features).</p></li><li><p><strong>Model training.</strong> Typically XGBoost or LambdaMART, trained on your judgment list with features.</p></li><li><p><strong>Deployment.</strong> Upload the trained model to Elasticsearch via Eland, and use it as a rescorer.</p></li></ol><p></p><p>The click and conversion data from this series feeds steps 1 and 2, and the judgment list is your training labels. The document-level features from the rank features section above (click popularity, conversion rate, margin) are additional features alongside text relevance scores.</p><h3>Why automated judgment lists from click data scale better</h3><p>This is where the scalability argument for automated judgment lists becomes concrete. A manually curated judgment list might cover your top 100 queries well, but personalized ranking needs judgment data across thousands of queries and multiple user segments. You can't hire assessors to rate results for "wireless headphones" separately for electronics enthusiasts, budget shoppers, and professional audio engineers.</p><p></p><p>Automated judgment lists from click data scale to every query your users actually run and update as inventory and user behavior change. They can also be segmented by cohort. The <a href="https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data">training LTR models with user behavior data</a> guide demonstrates this end-to-end workflow, showing how to go from raw click events to trained ranking models.</p><p></p><p>For teams looking to close this loop even further, the <a href="https://www.elastic.co/search-labs/blog/agentic-search-relevance-autotuning-elasticsearch">agentic autotuning approach</a> demonstrates using an AI agent to continuously monitor search quality and generate judgment lists from user interactions. It automatically retrains LTR models, turning the feedback loop into an autonomous system.</p><h3>What Learn To Rank requires: Traffic, pipeline, and evaluation</h3><p>LTR requires enough traffic to generate meaningful judgment lists and engineering time to build and maintain the pipeline. It also requires ongoing evaluation to ensure that the model improves over time. But for search applications with sufficient volume, it's the most effective way to make ranking learn from user behavior. The <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">LTR introduction on Search Labs</a> covers the full scope of what's involved.</p><h2>Elasticsearch Relevance Studio: Visual search relevance tuning</h2><p>Between query rules (manual, targeted) and LTR (ML, systemic), there's a middle ground: visual relevance tuning. <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a> is a tool for comparing and tuning search configurations side by side. It lets you adjust boost values, field weights, and query structures while seeing the results update in real time.</p><p></p><p>Analytics data, especially CTR and MRR, tells you which queries to focus on. Start from a ranked list of problem queries (the low-CTR, high-volume queries from the ES|QL analysis above), and work through them systematically in Relevance Studio, instead of guessing which searches need tuning.</p><p></p><p>The workflow:</p><p></p><ol><li><p><strong>Identify problem queries.</strong> Run the CTR-by-query and MRR-by-query analyses.</p></li><li><p><strong>Open those queries in Relevance Studio.</strong> See current results alongside the tuned version.</p></li><li><p><strong>Adjust field weights and boosting.</strong> Experiment with configuration changes.</p></li><li><p><strong>Evaluate with judgment lists.</strong> Use the Rank Eval API to confirm that the change improves NDCG for your test queries.</p></li><li><p><strong>Measure the impact in production.</strong> Rerun the analytics after deploying changes, and compare CTR / MRR.</p></li></ol><p></p><p>This before-and-after loop is where analytics and tuning connect. With the data analytics you can prioritize the queries that affect the most users and verify that changes actually helped. Without this data, tuning is guesswork, and you're adjusting weights without knowing which queries matter or how to measure improvement. </p><p></p><p>If you are looking at tuning and evaluating relevance you should also investigate the <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a> project which lets you directly compare search strategies. </p><h2>Closing the loop between search analytics and search relevance</h2><p>Search relevance improvement follows a consistent loop: measure quality with ES|QL analytics, apply changes (query rules, field weights, rank features or LTR models), then measure again.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc895531c8a5ff31/6a7ef484af7924b0b425af35/image1.png" alt="Diagram showing the search analytics feedback loop from click streams through judgment lists to search relevance improvement" /><p>The key insight is that the same instrumentation that measures search quality also generates the data to improve it. Your search spans produce the queries to analyze, and your click spans produce the judgment data for evaluation and LTR training. Plus, your conversion spans tell you which improvements matter most to the business.</p><h3>Measuring search relevance improvements after changes</h3><p>After deploying a change, like a new query rule or updated field weights, or deploying an LTR model, you need to know whether it helped:</p><p></p><p><strong>Metric</strong></p><p><strong>What it measures</strong></p><p><strong>How to compare</strong></p><p>CTR trend</p><p>Whether more users are clicking results after the change</p><p>Run the CTR-by-query ES|QL query for the week before and after; compare per-query percentages</p><p>MRR trend</p><p>Whether clicks are shifting toward higher-ranked positions</p><p>Compare mean reciprocal rank per query before and after using the MRR-by-query analysis</p><p>Conversion rate trend</p><p>Whether more clicks are turning into purchases or cart additions</p><p>Run the click-to-conversion ES|QL query for both periods; compare cart rate percentages</p><p>Revenue per query</p><p>Whether the business impact of the change is positive</p><p>Compare revenue attributed to affected queries via conversion spans before and after deployment</p><p></p><p>Run the same ES|QL queries before and after. If your change was a query rule for "laptop bag", compare that query's CTR and MRR from the week before to the week after.</p><h3>A/B testing with experiment attributes</h3><p>The <code>feature_flag.key</code> attribute  is designed for exactly this. Route a percentage of traffic to a new ranking configuration, and set <code>feature_flag.key</code> to the experiment name and optionally <code>feature_flag.result.variant</code> to the variant (for example, <code>"control"</code> or <code>"variant-boost-v2"</code>). Then propagate it to click spans the same way you propagate `query_id`, and compare metrics between groups:</p>FROM traces-generic.otel-default
| WHERE ((name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true)
  AND attributes.feature_flag.key IS NOT NULL
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
  BY attributes.feature_flag.key, attributes.feature_flag.result.variant
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)<p>This gives you an A/B comparison of CTR by experiment variant. You don’t need a separate experimentation platform for basic comparisons, although you’ll want a proper framework for statistical rigor on sample sizes and significance.</p><h3>Monitoring for search relevance regressions</h3><p>Once you've identified your high-value queries, that is, the ones that drive revenue and have been tuned for good engagement, you need to protect them. Relevance regressions on your top 20 revenue-generating queries are business-critical incidents.</p><p></p><p>This is where monitoring comes in. In the next blog in the series, we'll cover setting up alerts on these metrics: CTR drops on high-revenue queries and MRR regressions after deployments. We’ll also cover conversion rate anomalies. The same ES|QL queries that power your analytics dashboards can drive alerting rules; the feedback loop covers measurement and improvement. It also provides operational protection.</p><h2>Search relevance improvement roadmap: Week 1 to Quarter 1</h2><p>Here's a practical starting point. You don't need to implement LTR on day one. The approaches build on each other:</p><p></p><ul><li><p><strong>Week 1: Evaluate and fix known problems.</strong> Run the CTR-by-query analysis. Find your zero-CTR queries with high search volume, and fix the worst ones with synonyms or pinned results via query rules. You could also use adjusted field weights. Use judgment lists (even a small manual one for your top queries) to confirm that the changes improve NDCG before deploying. You should get immediate, targeted impact.</p></li></ul><p></p><ul><li><p><strong>Month 1: Add behavioral rank features.</strong> Extract document-level click popularity and conversion rates from your analytics. Index them as <code>rank_feature</code> fields, and blend behavioral signals with text relevance using the linear retriever or `rank_feature` queries. Then consider simple business boosts like margin. This lifts baseline quality across all queries without manual intervention per query.</p></li></ul><p></p><ul><li><p><strong>Quarter 1: Automate with LTR.</strong> Once you have enough click data (typically several weeks of production traffic), build judgment lists automatically from click and conversion data. Train an LTR model that combines text features, behavioral features, and business features, and then deploy it as a rescorer. The ranking now learns from user behavior and improves as you collect more data.</p></li></ul><p></p><p>At each stage, measure the impact with the same metrics. CTR and MRR are your scorecards, as is conversion rate. If a change doesn't move them, it didn't help, regardless of how sophisticated the approach.</p><h2>What's next: Search reliability engineering and SLOs</h2><p>We've covered the full sequence: instrument search, measure quality, track conversions, and improve relevance. The missing piece is making sure it all keeps working.</p><p></p><p>Next, we close the series with search reliability engineering, including Service Level Objectives (SLOs) for search quality and alerting on metric regressions, along with operational dashboards that catch problems before users notice them. The same metrics you've been building become the basis for search health monitoring, turning your analytics pipeline into a reliability system.</p><p></p><h2>Get started</h2><h3>Working code</h3><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series; clone, configure, and run.</p></li></ul><p></p><p></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-analytics-relevance-click-streams</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-analytics-relevance-click-streams</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt259007788af667fe/6a7ee686ef5bef8db54fa878/image2.png" length="0" type="image/png"/>
    <pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From search to checkout in 20 lines of code: building a 4-stage conversion funnel with OpenTelemetry]]></title>
    <description><![CDATA[Add cart and purchase tracking to your search analytics pipeline and use ES|QL to answer the question every product manager asks: which search queries drive the most revenue?]]></description>
    <content:encoded><![CDATA[<p>Your product manager wants to know which searches drive the most revenue. You can tell them what users search for (<a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">check out our second blog</a>) and what they click (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">in our third blog</a>), but not what they buy. Two new span types, add-to-cart and purchase, complete a four-stage funnel from search query to checkout, built on the same search.* attributes and ES|QL queries you've been using since Blog 2. About 20 lines of code, and every relevance decision you make gets a revenue number attached to it.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Add conversion tracking (add-to-cart and purchase spans) with <code>search.*</code> attributes that tie back to the originating search.</p></li><li><p>Build a full search-to-revenue funnel: search → click → add-to-cart → purchase.</p></li><li><p>Write Elasticsearch Query Language (ES|QL) queries to calculate conversion rates, revenue per query, and average order value.</p></li><li><p>Identify where users drop off and which team should own each drop-off point.</p></li><li><p>Attribute revenue to specific search queries for prioritizing relevance work.</p></li></ul><h2>What you'll need</h2><ul><li><p>Click tracking from <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a> (search + click spans flowing to Elastic via OTel-native ingestion).</p></li><li><p>Backend endpoints for add-to-cart and checkout events (example code provided).</p></li><li><p>Basic understanding of ecommerce conversion funnels.</p></li></ul><h2>Why search revenue attribution matters</h2><p>Your product manager walks into a meeting and asks, "Which searches are driving the most revenue?"</p><p>You can tell them what users search for (<a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blog 2</a>) and what they click (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>). But you can't tell them what they <em>buy</em>. The gap between "clicked a result" and "purchased a product" is where the business case for search investment lives, and right now it's invisible.</p><p>This post closes the loop. By adding two more span types (add-to-cart and purchase), you get a full funnel from search to revenue, built on the same <code>search.*</code> attributes and ES|QL queries you've been using since <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blog 2</a>.</p><h3>What search conversion tracking answers for your team</h3><p>Here's what conversion tracking lets you answer and why each question matters to different people on your team.</p><ul><li><p><strong>Which searches drive revenue?</strong> This is the product manager's question. When you can attribute dollars to specific queries, you can prioritize relevance work by business impact. A query with mediocre click-through rate (CTR) but high conversion value is more important than a high-CTR query that never leads to a purchase.</p></li></ul><ul><li><p><strong>Where do users drop off?</strong> The funnel from search to purchase has four stages: search, click, add-to-cart, and purchase. Each drop-off points to a different problem. High click-to-cart drop-off suggests that product pages aren't convincing. High cart-to-purchase drop-off is checkout friction rather than a search problem. Knowing <em>where</em> users abandon tells you <em>which team</em> should fix it.</p></li></ul><ul><li><p><strong>Which queries to protect?</strong> Once you know that "laptop bag" generates $12,000/month in attributed revenue, you treat it differently. Any relevance change that touches high-revenue queries gets extra scrutiny. You can set up monitoring (Blog 6, coming soon) to alert when conversion rates drop for your top-earning searches.</p></li></ul><p>Two new instrumentation points total about 20 lines of code, and they follow the same pattern as you’ve used before. You add attributes to spans, and query them with ES|QL.</p><p><strong>Following along with code?</strong> The <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> has conversion tracking ready to enable. Uncomment the Blog 4 sections in <code>app.py</code> and <code>app.js</code>, restart, and then generate traffic with <code>python generate_traffic.py --blog 4</code>.</p><h2>The four-stage search conversion funnel</h2><p>Before we write any code, here's the shape of what we're building. Each stage is an instrumentation point, and each creates spans in <code>traces-generic.otel-default</code>:</p>search          →  click              →  cart.add         →  checkout.complete
(Blog 2)           (Blog 3)              (this post)         (this post)
query_id=abc       query_id=abc          query_id=abc        query_id=abc
user_query=...     click_position=1      product_id=...      order_total=$149
result_count=15    product_id=...        quantity=1           item_count=2<p>The thread running through the entire chain is <code>search.query_id</code><code>.</code> The same identifier you derived from the trace ID in <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a> and used to link clicks to searches in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a> now carries through to add-to-cart and purchase events. This is what makes revenue attribution possible: You can trace a purchase back to the search that started the journey.</p><h2>Add conversion tracking spans with OpenTelemetry</h2><h3>Add-to-cart span instrumentation</h3><p>When a user adds a product to their cart from a search results page (or from a product detail page they reached via search), you create a <code>cart.add</code> span. This captures the moment that intent turns into action.</p>@app.post("/api/cart/add")
async def add_to_cart(event: AddToCartRequest):  # reference project uses CartEvent
    with tracer.start_as_current_span("cart.add") as span:
        span.set_attribute("search.action", "add_to_cart")
        span.set_attribute("search.result_click_id", event.object_id)
        span.set_attribute("search.result_click_position", event.position)
        span.set_attribute("search.query_id", event.query_id)
        span.set_attribute("enduser.pseudo.id", event.client_id)
        span.set_attribute("cart.quantity", event.quantity)
        if event.price is not None:
            span.set_attribute("cart.price", event.price)
        if event.user_query:
            span.set_attribute("search.query", event.user_query)<p>This follows the same pattern as click tracking in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>; that is, an independent span linked to the originating search via <code>query_id</code>. The new attributes are <code>cart.quantity</code> and <code>cart.price</code> which let you aggregate revenue at query time.</p><h3>Purchase span instrumentation</h3><p>When the user completes checkout, you create a <code>checkout.complete</code> span. This is the revenue event, the one that answers the product manager's question.</p>@app.post("/api/checkout")
async def checkout(event: CheckoutRequest):  # reference project uses CheckoutEvent
    with tracer.start_as_current_span("checkout.complete") as span:
        span.set_attribute("search.action", "purchase")
        span.set_attribute("checkout.order_id", event.order_id)
        span.set_attribute("checkout.total_amount", event.total_amount)
        span.set_attribute("checkout.item_count", len(event.items))
        span.set_attribute("enduser.pseudo.id", event.client_id)
        if event.query_id:  # last search in journey
            span.set_attribute("search.query_id", event.query_id)
            span.set_attribute("search.query", event.user_query)<p></p><ul><li><p><strong>Spans capture errors automatically.</strong> This is a side benefit of using OTel spans for conversion events. If an add-to-cart or checkout call throws an unhandled exception, the span's status is automatically set to <code>ERROR</code> and the exception details are recorded. These errors are business-critical (a broken checkout flow means lost revenue), and they show up immediately in Elastic APM's error tracking, service maps, and alerting. You get conversion analytics <em>and</em> operational monitoring from the same instrumentation, with no extra code.</p></li></ul><ul><li><p><strong><code>checkout.total_amount</code></strong><strong> is the revenue number.</strong> This is what you'll aggregate in ES|QL to get revenue-by-query. It represents the order total, not the price of a single item.</p></li></ul><ul><li><p><strong><code>search.query_id</code></strong><strong> is conditional.</strong> Not every purchase originates from search. Users browse categories and follow promotional links. Then they return to their cart days later. The <code>if event.query_id:</code> guard ensures that you only attribute purchases to search when there's a genuine connection. Purchases without a <code>query_id</code> still get recorded; they just don't appear in search attribution queries.</p></li></ul><ul><li><p><strong><code>search.query</code></strong><strong> is set on both cart and purchase spans.</strong> This is a deliberate denormalization. You could join back to searches via <code>query_id</code> to get the query text, but while ES|QL does support <code>LOOKUP JOIN</code>, it’s likely not the right choice here due to the need to optimize the lookup index. By putting the query text directly on conversion spans, your revenue-by-query and cart-by-query queries are single, straightforward aggregations.</p></li></ul><h3>Span attributes for cart and purchase events</h3><p><strong>Add-to-cart attributes:</strong></p><p><strong>Attribute</strong></p><p><strong>Type</strong></p><p><strong>Required</strong></p><p><strong>Purpose</strong></p><p><code>search.action</code></p><p>string</p><p>yes</p><p><code>"add_to_cart"</code></p><p><code>search.result_click_id</code></p><p>string</p><p>yes</p><p>Product document ID</p><p><code>search.result_click_position</code></p><p>int</p><p>yes</p><p>Position in results when added</p><p><code>search.query_id</code></p><p>string</p><p>yes</p><p>Links to originating search</p><p><code>enduser.pseudo.id</code></p><p>string</p><p>yes</p><p>Client/device identifier (OTel Semantic Conventions  [SemConv])</p><p><code>cart.quantity</code></p><p>int</p><p>yes</p><p>Quantity added</p><p><code>cart.price</code></p><p>float</p><p>optional</p><p>Product price</p><p><code>search.query</code></p><p>string</p><p>recommended</p><p>The search query text (for cart-by-query analysis)</p><p><strong>Purchase attributes:</strong></p><p><strong>Attribute</strong></p><p><strong>Type</strong></p><p><strong>Required</strong></p><p><strong>Purpose</strong></p><p><code>search.action</code></p><p>string</p><p>yes</p><p><code>"purchase"</code></p><p><code>checkout.order_id</code></p><p>string</p><p>yes</p><p>Unique order identifier</p><p><code>checkout.total_amount</code></p><p>float</p><p>yes</p><p>Order total</p><p><code>checkout.item_count</code></p><p>int</p><p>yes</p><p>Number of items purchased</p><p><code>enduser.pseudo.id</code></p><p>string</p><p>yes</p><p>Client/device identifier (OTel SemConv)</p><p><code>search.query_id</code></p><p>string</p><p>recommended</p><p>Links to originating search (last search in journey)</p><p><code>search.query</code></p><p>string</p><p>recommended</p><p>The search query text (for revenue-by-query analysis)</p><p>These follow the same <code>search.*</code> namespace from Blogs 2 and 3, with new <code>cart.*</code> and <code>checkout.*</code> prefixes for conversion-specific data. With OTel-native ingestion, all attributes are stored under <code>attributes.*</code> with dot notation preserved. That means no more mapping strings to <code>labels.*</code> and numbers to <code>numeric_labels.*</code>. A <code>search.query</code> attribute is queryable as <code>attributes.search.query</code>. Likewise, <code>checkout.total_amount</code> is queryable as <code>attributes.checkout.total_amount</code>.</p><h3>User identity: connecting search to purchase across sessions</h3><p>You'll notice that <code>enduser.pseudo.id</code> appears on every span type in this series. It's the <em>minimum identity level</em>; that is, a persistent identifier stored in the browser's localStorage that ties events to a device across sessions.</p><p>For conversion tracking, identity becomes more important. You need to connect a search on Monday to a purchase on Tuesday, or correlate cart additions across tabs. Our schema supports three identity levels, aligned with OTel semantic conventions:</p><p><strong>Attribute</strong></p><p><strong>Persistence</strong></p><p><strong>Purpose</strong></p><p><code>enduser.pseudo.id</code></p><p>Permanent (localStorage)</p><p>Device/browser identifier (OTel SemConv)</p><p><code>session.id</code></p><p>Per-visit (sessionStorage)</p><p>Groups events within a single visit (OTel SemConv)</p><p><code>user.id</code></p><p>Account (auth system)</p><p>Authenticated user (OTel SemConv)</p><p>For the funnel queries in this post, <code>enduser.pseudo.id</code> is sufficient. It links the journey from search to purchase within a browser. If your users authenticate, adding <code>user.id</code> enables cross-device attribution (searched on mobile, purchased on desktop) and richer personalization. <code>session.id</code> helps disambiguate when the same client has multiple active sessions.</p><p>All three are optional on interaction spans. Start with <code>enduser.pseudo.id</code>, and add the others when your use case requires them. The important thing is consistency: Use the same identifiers across search, click, and conversion spans so the joins work.</p><h3>Frontend integration</h3><p>The front end needs to propagate <code>query_id</code> through the user journey. When the user clicks a search result, you already have <code>query_id</code> from the search response (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>). The key is carrying it forward.</p>// CLIENT_ID: persistent browser identifier from localStorage (set up in Blog 3)
// const CLIENT_ID = localStorage.getItem("search_client_id") || ...

// On add-to-cart from a search result page
fetch('/api/cart/add', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    object_id: product.id,
    position: product.resultPosition,  // from search results
    query_id: product.queryId,         // from search response
    client_id: CLIENT_ID,              // persistent browser identifier → enduser.pseudo.id
    quantity: 1,
    price: product.price,
  })
});

// On checkout completion
fetch('/api/checkout', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    order_id: order.id,
    total_amount: order.total,
    items: order.items,
    client_id: CLIENT_ID,              // same identifier as search and click spans
    query_id: lastSearchQueryId,       // last search in session
    user_query: lastSearchQuery,
  })
});<p>The front end sends <code>client_id</code> as an HTTP field name; and the back end maps it to the OTel semantic convention <code>enduser.pseudo.id</code> when setting span attributes. The <code>query_id</code> propagation is the critical piece. Store it alongside the product in the cart data structure so it survives navigation between pages. We'll discuss the design challenges of this in the attribution section below.</p><p></p><p><strong>Using the reference project?</strong> The reference app's <code>frontend/app.js</code> wires up add-to-cart buttons, but the checkout flow isn’t implemented in the browser UI. It goes through the traffic generator. To simulate conversion events, run: <code>python generate_traffic.py --blog 4 --sessions 100</code>. This sends a realistic mix of searches, clicks, cart additions, and purchases to all three backend endpoints.</p><h3>Verify that conversion events are arriving</h3><p>Before building funnel queries, confirm that both span types are flowing to Elastic:</p><p>Add-to-cart events:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "add_to_cart"
| KEEP attributes.search.result_click_id, attributes.search.query_id,
       attributes.cart.quantity
| LIMIT 5<p>Purchase events:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
| KEEP attributes.checkout.order_id, attributes.checkout.total_amount,
       attributes.checkout.item_count, attributes.search.query_id,
       attributes.search.query
| LIMIT 5<p>If these return rows, you have the full funnel instrumented. If not, check the same things as always: OpenTelemetry Protocol (OTLP) endpoint, auth token, and span export.</p><h2>Funnel analysis with ES|QL</h2><h3>Count search, click, cart and purchase events in a single query</h3><p>You can count all four funnel stages in a single ES|QL query using the same <code>COUNT(CASE(...))</code>pattern from the CTR query in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>:</p>FROM traces-generic.otel-default
| WHERE (name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true
    OR attributes.search.action IN ("add_to_cart", "purchase")
| STATS
    searches  = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked   = COUNT(CASE(attributes.search.first_click == true, 1)),
    carts     = COUNT(CASE(attributes.search.action == "add_to_cart", 1)),
    purchases = COUNT(CASE(attributes.search.action == "purchase", 1))
| EVAL
    click_rate    = ROUND(100.0 * clicked   / searches, 1),
    cart_rate     = ROUND(100.0 * carts     / searches, 1),
    purchase_rate = ROUND(100.0 * purchases / searches, 1)<p><strong>Example output:</strong></p><p><strong>searches</strong></p><p><strong>clicked</strong></p><p><strong>carts</strong></p><p><strong>purchases</strong></p><p><strong>click_rate</strong></p><p><strong>cart_rate</strong></p><p><strong>purchase_rate</strong></p><p>146</p><p>41</p><p>28</p><p>12</p><p>28.1%</p><p>19.2%</p><p>8.2%</p><p>You get all four counts and three conversion rates from a single query. The <code>WHERE</code> clause pulls all four span types into one result set, and <code>COUNT(CASE(...))</code> counts each type separately. This is the same technique that made the CTR query in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a> so clean.</p><p>Notice that we use <code>search.first_click</code>for the click stage rather than counting all click events. This gives you the number of searches that received at least one click (the same definition used for CTR in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>). Without this, a search with three clicks would inflate the click count to three while only counting as one search, making the funnel numbers misleading. Each stage now represents a unique progression: how many searches happened, how many of those got clicked, how many led to a cart addition, and how many resulted in a purchase.</p><p>You can turn this into a funnel visualization using Kibana Lens. Run each stage count as a separate ES|QL query, save them as dashboard panels, and arrange them as a horizontal bar chart with the four stages on the y-axis and counts on the x-axis. The drop-off at each step becomes immediately visible.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcc9d695520cd0aff/6a7458d85751aa8c3e7e0a4f/image2.png" alt="Kibana dashboard showing search analytics: conversion funnel, revenue by query, click-through rate and latency SLOs" /><p>The dashboard above (from <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">the first blog in the series</a>) shows this in practice: The <strong>Conversion Funnel</strong> panel in the lower left uses a horizontal bar chart to visualize the four stages, and the <strong>Top Queries by Revenue</strong> table alongside it shows revenue attribution. You can build these directly from the ES|QL queries in this post.</p><h3>What drop-off rates tell you and who owns each bottleneck</h3><p>Each transition in the funnel tells you something specific:</p><ul><li><p><strong>Search to click (CTR):</strong> You measured this in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>. Low CTR means that results aren't compelling. This is a relevance problem.</p></li></ul><ul><li><p><strong>Click to cart:</strong> The user engaged with a result but didn't add it to their cart. This could mean that the product page isn't persuasive or the price isn't competitive. It could also mean that the item was out of stock. It's often <em>not</em> a search problem. It’s possible that the search worked (the user clicked), but something downstream lost them.</p></li></ul><ul><li><p><strong>Cart to purchase:</strong> The user committed to buying but didn't complete checkout. Complicated forms, unexpected shipping costs, and payment issues cause this <em>checkout friction</em>. This is almost never a search problem, but it's useful to know where the funnel leaks so you don't waste time optimizing relevance when checkout is the bottleneck.</p></li></ul><p>The diagnostic pattern is straightforward:</p><p><strong>Drop-off point</strong></p><p><strong>Likely cause</strong></p><p><strong>Who owns it</strong></p><p>Search to click</p><p>Relevance / ranking</p><p>Search team</p><p>Click to cart</p><p>Product page / pricing / availability</p><p>Product / merchandising</p><p>Cart to purchase</p><p>Checkout UX / payment / shipping</p><p>Checkout / growth</p><p>This is one of the most valuable things that full-funnel data gives you: the ability to point at the right problem. When the VP asks, "Why aren't searches converting?", you can show whether the bottleneck is relevance, product pages, or checkout.</p><h2>Revenue attribution by search query</h2><p>Here's the query your product manager actually wants:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
  AND attributes.search.query IS NOT NULL
| STATS
    purchase_count = COUNT(*),
    total_revenue = SUM(attributes.checkout.total_amount)
  BY attributes.search.query
| SORT total_revenue DESC<p>This gives you a ranked list of queries by the revenue they generated. The <code>search.query</code> attribute on purchase spans (the denormalization we discussed earlier) makes this a single aggregation query, without joins or subqueries.</p><h3>How to act on search revenue  data</h3><ul><li><p><strong>Protect high-revenue queries.</strong> If "laptop bag" generates the most revenue, any relevance change that affects that query gets extra scrutiny. You might add it to a regression test suite or pin specific results with <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/searching-with-query-rules">query rules</a>. You might even set up an alert when its conversion rate drops (Blog 6).</p></li></ul><ul><li><p><strong>Prioritize relevance investment.</strong> The queries at the top of this list are where relevance improvements have the most business impact. A 10% CTR improvement on a query that generates $500/month in revenue is worth more than a 50% improvement on one that generates $20.</p></li></ul><ul><li><p><strong>Identify missed opportunities.</strong> Cross-reference with the top-queries data from <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a>. A query with high search volume but no purchase attribution is either a browsing query (informational intent) or a conversion gap worth investigating.</p></li></ul><h3>Top revenue queries: where to focus relevance investment</h3><p>To focus your relevance team's efforts, pull the top revenue-generating queries:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
  AND attributes.search.query IS NOT NULL
| STATS
    purchases = COUNT(*),
    revenue = SUM(attributes.checkout.total_amount)
  BY attributes.search.query
| SORT revenue DESC
| LIMIT 10<p>Cross-reference this with the per-query CTR from Blog 3. A query with high revenue but low CTR is underperforming; even small relevance improvements have outsized business impact. A query with high CTR but no purchase attribution might be informational (such as users browsing but not buying). The queries at the top of <em>both</em> lists deserve the most attention from your relevance team.</p><h3>Average order value by search query</h3><p>Average order value (AOV) tells you how much purchases are worth, in addition to which queries convert to those purchases. Queries with high AOV are your premium-intent searches; ranking improvements there have the biggest per-purchase impact:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
  AND attributes.search.query IS NOT NULL
| STATS
    purchase_count = COUNT(*),
    total_revenue = SUM(attributes.checkout.total_amount),
    avg_order_value = ROUND(AVG(attributes.checkout.total_amount), 2)
  BY attributes.search.query
| SORT avg_order_value DESC
| LIMIT 10<p>A query with high AOV but low volume is a different opportunity than high volume + low AOV. The first means premium intent from a small audience (consider featured results or dedicated landing pages); the second means broad reach with budget buyers (price sensitivity may be limiting conversion more than relevance).</p><h2>Search revenue attribution: limitations and workarounds</h2><p>Revenue attribution from search is valuable, but it's imperfect. Understanding the limitations helps you set appropriate expectations and design around them.</p><h3>Last-touch attribution in multi-search journeys</h3><p>Users rarely search once and buy. A typical journey might look like:</p><ol><li><p>Search "laptop bag": Browse results and click a few.</p></li><li><p>Search "laptop bag leather": Refine the search.</p></li><li><p>Search "laptop sleeve 15 inch": Try a different angle.</p></li><li><p>Add to cart from the third search's results.</p></li><li><p>Purchase.</p></li></ol><p>With the instrumentation above, this purchase attributes to the third search, the one whose <code>query_id</code> was on the cart item. The first two searches contributed to the journey but get no credit.</p><p>This is <em>last-touch attribution</em>, and it's the simplest model that works within a single <code>query_id</code> linkage. It's not perfect, but it's concrete and unambiguous. The alternative, that is,tracking every <code>query_id</code> in a user's session and distributing credit, adds significant complexity to both instrumentation and analysis.</p><p>For most teams, last-touch is a good starting point. If you need multi-touch attribution later, the raw data is there. You can query all searches and clicks for a given <code>client_id</code> within a time window and reconstruct the full journey:</p>FROM traces-generic.otel-default
| WHERE attributes.enduser.pseudo.id == "client-abc-123"
  AND (name == "search"
    OR attributes.search.action == "click"
    OR attributes.search.action == "add_to_cart"
    OR attributes.search.action == "purchase")
| KEEP @timestamp, name, attributes.search.action,
       attributes.search.query, attributes.search.query_id,
       attributes.search.result_click_id
| SORT @timestamp ASC<p>This reconstructs a user's full search journey in chronological order. It's useful for debugging individual sessions, even if you don't build automated multi-touch attribution.</p><h3>Cross-session attribution limits with query_id</h3><p>A user searches for "wireless headphones" on Monday, clicks a few results, leaves, and comes back on Wednesday to buy. The <code>query_id</code> from Monday's search is long gone, since it was a property of that specific search request.</p><p>This is a fundamental limitation of <code>query_id</code>-based attribution. It works within a session (or more precisely, within the scope where the front end retains the <code>query_id</code>). It doesn't work across sessions.</p><p>For cross-session attribution, you'd need a different approach, typically a user-level event store where you associate product views, cart additions, and purchases with a persistent user ID and then look back in time to find the originating search. That's a more complex analytics pipeline and is outside the scope of what we're building here.</p><p>The practical impact is that your search-attributed revenue will be an <em>undercount</em>. Some purchases that were genuinely influenced by search won't carry a <code>query_id</code>. This is fine for relative comparisons (such as, <em>Which queries generate </em>more<em> revenue than others?</em>), even if the absolute numbers are conservative.</p><h3>How to persist query_id from search to checkout</h3><p>A few practical decisions affect how far your <code>query_id</code> propagation reaches:</p><ul><li><p><strong>Store </strong><strong><code>query_id</code></strong><strong> in the cart.</strong> When a user adds a product to their cart, persist the <code>query_id</code> alongside the item. This way, even if the user navigates away and comes back to checkout later (within the same session), the attribution survives.</p></li></ul><ul><li><p><strong>Don't overwrite </strong><strong><code>query_id</code></strong><strong> on re-search.</strong> If a user adds a product from search A and then searches again and adds another product from search B, each cart item should keep its own <code>query_id</code>. The purchase event carries the last search's <code>query_id</code> as a summary, but per-item attribution gives you richer data.</p></li></ul><ul><li><p><strong>Accept the limitations.</strong> Not every purchase will have search attribution. Direct navigation, category browsing, promotional links, and returning customers who go straight to their cart will all produce purchases without a <code>query_id</code>. That's correct behavior, not missing data.</p></li></ul><h3>Spans vs. log events for conversion tracking</h3><p>Conversion events can emit both an OTel span and a UBI-compatible log event, the same dual-signal pattern used for click tracking in Blog 3. The log event is actually richer for conversions. It can contain the full items list with per-item <code>query_id</code> attribution, which doesn't map cleanly to flat span attributes.</p><p>The span gives you the simple, aggregatable view (total revenue by query), and the log gives you the detailed, per-item view (which specific products from which specific searches). For the funnel queries in this post, spans are sufficient. If you need item-level attribution analysis, the log events in <code>logs-generic.otel-default</code> have the detail you need, and ES|QL queries them the same way, just against a different index pattern.</p><h2>What's next: turning conversion data into relevance improvements</h2><p>We now have the complete instrumentation picture, with four span types: <code>search</code> (<a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a>), <code>search.result.click</code> (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>), <code>cart.add</code>, and <code>checkout.complete</code> (this post). These capture the full user journey from query to purchase. Every span lives in <code>traces-generic.otel-default</code>, and every metric is queryable with ES|QL. The <code>search.query_id</code> thread ties the entire funnel together.</p><p>But measuring the funnel is only half the story. The real payoff is using this data to make search better.</p><p>In a later blog in this series, we take everything we've built and turn it into relevance improvements. Click positions and conversion data become judgment lists for <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">Learning To Rank</a>, and per-query CTR and revenue become rank features for boosting. Plus, high-revenue queries get protective monitoring. And tools like <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a> give you a visual interface for tuning the searches that matter most, using exactly the data you're now collecting.</p><p>The instrumentation you've built in Blogs 2–4 is a feedback loop, beyond analytics: Measure, improve, and measure again.</p><h2>Get started with search conversion tracking</h2><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series; clone, configure, and run.</p></li><li><p><a href="https://github.com/elastic/elastic-otel-python">Elastic Distribution of OpenTelemetry for Python:</a> EDOT for Python.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry with Elastic:</a> How to send OTel data to Elastic APM.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation:</a> Query language reference.</p></li><li><p><a href="https://www.ubisearch.dev/">UBI Standard:</a> Reference schema for search event structure.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-rules.html">Query rules:</a> Pin, boost, or exclude results for specific queries.</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9492cc64bcc1d0dc/6ab3ead098a4e3c62b53f8a0/diagram-building-a-4-stage-funnel.webp" length="0" type="image/webp"/>
    <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[15 lines of click tracking code that tell you what search logs can't ]]></title>
    <description><![CDATA[Three ES|QL queries calculate click-through rate, mean reciprocal rank and click position distribution from your search click data, so you can pinpoint which queries need relevance tuning and where ranking improvements will have the most impact.]]></description>
    <content:encoded><![CDATA[<p>Search volume and latency tell you that search is working, not that it's useful. About 15 lines of OpenTelemetry (OTel) instrumentation lets you track clicks on search results and then query click-through rate (CTR), Mean Reciprocal Rank (MRR), and click position distribution with Elasticsearch Query Language (ES|QL) against the same traces index that your search spans already live in. You'll wire click tracking to your existing <code>search.query_id</code> and write the queries that show which searches need relevance tuning.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Add client-side click tracking that links clicks back to their originating search via <code>search.query_id</code>.</p></li><li><p>Calculate CTR; that is, the percentage of searches that produce at least one click.</p></li><li><p>Calculate MRR; that is, how far down the results users click on average.</p></li><li><p>Analyze click position distribution to see the full shape of user engagement.</p></li><li><p>Write ES|QL queries for all three metrics against  <code>traces-generic.otel-default</code>.</p></li><li><p>Identify which specific queries need relevance tuning.</p></li></ul><h3>What you'll need</h3><ul><li><p>A working OTel instrumentation setup from Blog 2 (search spans with <code>search.*</code> attributes flowing to Elastic via OTel-native ingestion).</p></li><li><p>A front end that can send click events (JavaScript example provided).</p></li><li><p>Familiarity with the <code>attributes.*</code> field mapping from Blog 2.</p></li></ul><h2>Why search logs alone can't measure search quality</h2><p>In the <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">second blog</a> of the series, we instrumented search requests and ran six ES|QL queries against the data. We can see what users search for, which queries return nothing, and how fast search is.</p><p>But there's a blind spot. A search that returns 15 results looks healthy from the server side. Every metric we have says it worked. But if nobody clicks any of those results, your ranking has a problem, and none of the queries from Blog 2 will tell you.</p><p>This is the gap between <em>results returned</em> and <em>results that are useful</em>. Search volume, zero-results rate, and latency measure the mechanics of search, but they don't measure whether search is actually helping users find what they need.</p><p>To answer that question, you need a second instrumentation point: <em>click tracking</em>.</p><p>If you’re following along with code, the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> has click tracking ready to enable. Uncomment the Blog 3 sections in <code>app.py</code> and <code>frontend/app.js</code>, restart, and then generate traffic with <code>python generate_traffic.py --blog 3</code>.</p><h2>How click data measures search relevance</h2><p>Before we write any code, here's what click data lets you measure and why each metric matters:</p><ul><li><p><strong>CTR</strong> answers the most basic engagement question: <em>What percentage of searches result in at least one click?</em> If your CTR is low, users are seeing results but not finding them compelling enough to engage. Establish your own baseline once you have data; CTR varies considerably across product category, query type, and industry vertical.</p></li></ul><ul><li><p><strong>MRR</strong> goes deeper: <em>When users do click, where in the results are they clicking?</em> An MRR of 1.0 means every user clicks the top result (a perfect ranking). An MRR of 0.5 means the average click is at position 2. Low MRR with high CTR is particularly telling. It means that users are finding what they need, but your ranking is making them work for it.</p></li></ul><ul><li><p><strong>Click position distribution</strong> shows the full shape of where users click. A healthy search engine shows most clicks at position 1 with a sharp drop-off. A flat distribution across positions 1–5 means that your ranking isn't differentiating well. Per-query distributions reveal exactly which searches need relevance tuning.</p></li></ul><p>Together, these metrics move you from <em>Did search work?</em> (Blog 2) to <em>Did search work well?</em>, and they pinpoint exactly where to invest in relevance improvements. Later in the series, we'll show how to turn these metrics into concrete actions: building judgment lists for Learning To Rank (LTR), tuning relevance with tools like <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a>, and evaluating changes with the Rank Eval API. But first, you need the data.</p><p>All three metrics require just one new instrumentation point: about 15 lines of code.</p><h2>Add click tracking</h2><p>Click tracking captures what happens after the results appear. When a user clicks a search result, we create a new span with attributes describing the interaction, including which document they clicked, where it appeared in the results, and which search produced it.</p><p>Here's the code:</p># Track which query_ids have already received a click
_clicked_queries: set[str] = set()

@app.post("/api/events")
async def track_event(event: EventRequest):  # reference project uses ClickEvent
with tracer.start_as_current_span("search.result.click") as span:
        span.set_attribute("search.action", "click")
        span.set_attribute("search.result_click_id", event.object_id)
        span.set_attribute("search.result_click_position", event.position)
        span.set_attribute("search.result_click_type", event.object_id_type)
        span.set_attribute("search.query_id", event.query_id)
        span.set_attribute("enduser.pseudo.id", event.client_id)

# First click per search — enables single-query CTR
if event.query_id not in _clicked_queries:
            span.set_attribute("search.first_click", True)
            _clicked_queries.add(event.query_id)

if event.user_query:
            span.set_attribute("search.query", event.user_query)<p>Let's unpack what matters.</p><h3>Click spans are separate traces</h3><p>This is the key architectural difference from Blog 2. Search spans are created synchronously during the API request: The user searches, the span opens, Elasticsearch responds, and the span closes. Click spans are <em>asynchronous</em>. The user searches, gets results, browses the page, and might click 30 seconds later (or they might never click).</p><p>That means click spans aren't children of the search span's trace. They're independent traces, linked to the originating search through <code>search.query_id</code>. This is the same <code>query_id</code> we derived from the trace ID in Blog 2, and it now serves as the join key between searches and clicks across <code>traces-generic.otel-default</code>.</p><h3>Choosing the right OTel signal for clicks</h3><p>OTel gives you three signal types, and clicks could be modeled as any of them. Each has strengths:</p><p><strong>Signal</strong></p><p><strong>Index</strong></p><p><strong>Weight</strong></p><p><strong>Best for…</strong></p><p>Spans</p><p><code>traces-generic.otel-default</code></p><p>Full trace context</p><p>Same-index queries with search spans</p><p>Logs</p><p><code>logs-*</code></p><p>Lighter weight</p><p>Log-centric pipelines, high volume</p><p>Span events</p><p><code>logs-generic.otel-default</code></p><p>Lightest instrumentation</p><p>Attaching to existing spans</p><p></p><ul><li><p><strong>Spans</strong> land in <code>traces-generic.otel-default</code> alongside search spans, are fully queryable in ES|QL, appear in Kibana APM views, and carry timing information. Since our search spans are already in <code>traces-generic.otel-default</code>, using spans for clicks means you can query searches and clicks together in a single ES|QL statement, and no cross-index joins are needed.</p></li><li><p><strong>Log records</strong> are also independently queryable in ES|QL, living in <code>logs-*</code>. If you use the same <code>search.*</code> attribute names, the analytics queries are almost identical; just change the index pattern. Logs are lighter weight (no trace context overhead) and are a natural fit if your team already has a log-centric observability pipeline. One of Elastic's strengths here is that traces, logs, and metrics all land in the same platform and are all queryable with ES|QL, so choosing logs over spans doesn't mean giving up any query capability.</p></li><li><p><strong>Span events </strong>are lightweight at instrumentation time (attached to an existing span in the OTel API). In Elastic's OpenTelemetry Protocol (OTLP) ingestion pipeline, span events are written as separate documents to <code>logs-*</code> data streams (for example <code>logs-generic.otel-default</code>), and they’re independently queryable in the same way as logs. They’re a good, lightweight option but might require more code changes than logs, which can even pull in logs from legacy code.</p></li></ul><p>In this series, we use <em>spans </em>because they keep searches and clicks in the same index with the simplest query path. But if you're at high volume and want to optimize for cost, or if your organization already routes OTel logs to Elasticsearch, the log-based approach works well; the <code>search.*</code> attribute schema is the same either way, and ES|QL queries against <code>logs-*</code> follow the same patterns you'll see below.</p><h3>The <code>search.first_click</code> attribute</h3><p><code>search.first_click</code> is a Boolean set only on the first click for a given <code>query_id</code>. It exists for one reason: accurate CTR calculation without post-processing.</p><p>CTR is defined as the percentage of searches with at least one click. Without <code>search.first_click</code>, you'd need to deduplicate clicks by <code>query_id</code> at query time: grouping, counting distinct values, and subquerying. By marking the first click at instrumentation time, the ES|QL query becomes a simple count.</p><p>The set above is demo-only. It grows unbounded and breaks with multiple API replicas. The <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference implementation</a> uses a thread-safe time-to-live (TTL) dict with a 30-minute expiry window (<code>_is_first_click()</code> in <code>app.py</code>). For production with multiple replicas, use a shared external cache (Redis, Memcached) keyed by <code>query_id</code> with a TTL matching your session window.</p><h3>Where to track first click: Front end vs. back end</h3><p>The <code>search.first_click</code> deduplication could live in either the front end or the back end. Both are valid, and here are the trade-offs:</p><ul><li><p><strong>Frontend tracking</strong> is simpler to implement. The browser already knows the current query and whether the user has clicked before. No server-side state is required, you don’t have to worry about multiple replicas, and it works without any backend changes. The downside is that browser state is ephemeral; a page refresh, multiple tabs, or an ad blocker can interfere with accurate tracking.</p></li></ul><ul><li><p><strong>Backend tracking</strong> (our approach) gives you a single source of truth. All click events flow through one place, so the deduplication is consistent regardless of what the client does. It also means that the analytics logic is colocated with the instrumentation code, which simplifies reasoning about data quality. The trade-off is that the back end needs to maintain state: the <code>_clicked_queries</code> set. For a single-instance API, this is trivial; for multiple replicas behind a load balancer, you'd use a shared TTL cache (Redis or similar).</p></li></ul><p>We chose backend tracking here because we want the analytics data to be authoritative. This click data will later feed into relevance tuning and judgment lists, where accuracy matters. But if you're starting simple or running a client-side–only setup, frontend tracking is a perfectly reasonable first step. The <code>search.first_click</code> attribute works the same way regardless of where you set it.</p><h3>Sending click events from the browser</h3><p>The browser sends click events to the back end when a user clicks a result. It needs three things from the search response: the document ID, the position, and the <code>query_id</code>.</p>// Generate a persistent client ID once per browser (stored in localStorage)
const CLIENT_ID = localStorage.getItem("search_client_id")
    || (() =&gt; {
const id = crypto.randomUUID();
        localStorage.setItem("search_client_id", id);
return id;
    })();

// On result click
fetch('/api/events', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    object_id: product.id,
    position: index + 1,       // 1-indexed
query_id: lastQueryId,     // from the most recent search response
client_id: CLIENT_ID,      // persistent browser identifier → enduser.pseudo.id
user_query: currentQuery,
    object_id_type: 'product', // optional; defaults to "product" on the backend
})
});<p><code>CLIENT_ID</code> is generated once and stored in <code>localStorage</code>. It survives page reloads and gives you a stable <code>enduser.pseudo.id</code> without requiring a login. The back end maps <code>client_id</code> → <code>enduser.pseudo.id</code> on the span.</p><p>Positions are 1-indexed; that is, the first result is position 1, not 0.</p><h3>Click tracking OTel attributes and ES|QL field mapping</h3><p></p><p><strong>Attribute</strong></p><p><strong>Type</strong></p><p><strong>Required</strong></p><p><strong>Purpose</strong></p><p><code>search.action</code></p><p>string</p><p>yes</p><p>Event type: <code>"click"</code></p><p><code>search.result_click_id</code></p><p>string</p><p>yes</p><p>Document ID clicked</p><p><code>search.result_click_position</code></p><p>int</p><p>yes</p><p>Position in results (1-indexed)</p><p><code>search.query_id</code></p><p>string</p><p>yes</p><p>Links to originating search</p><p><code>enduser.pseudo.id</code></p><p>string</p><p>yes</p><p>Client/device identifier</p><p><code>search.first_click</code></p><p>boolean</p><p>recommended</p><p><code>true</code> if first click for this <code>query_id</code></p><p><code>search.result_click_type</code></p><p>string</p><p>recommended</p><p>Object type: <code>"product"</code>, <code>"article"</code></p><p><code>search.query</code></p><p>string</p><p>recommended</p><p>The search query text (for queryability)</p><p>These follow the same <code>search.*</code> namespace we established in Blog 2. With OTel-native ingestion, attributes map directly to <code>attributes.*</code> fields in ES|QL:</p><p></p><p><strong>OTel attribute</strong></p><p><strong>ES|QL field</strong></p><p><code>search.action</code></p><p><code>attributes.search.action</code></p><p><code>search.result_click_id</code></p><p><code>attributes.search.result_click_id</code></p><p><code>search.result_click_position</code></p><p><code>attributes.search.result_click_position</code></p><p><code>search.query_id</code></p><p><code>attributes.search.query_id</code></p><p><code>search.first_click</code></p><p><code>attributes.search.first_click</code></p><p>With OTel-native ingestion, <code>search.first_click</code> is stored as a native Boolean; you query it with <code>== true</code>, not <code>== "true"</code>, and no string coercion is needed.</p><h3>Verify that clicks are arriving</h3><p>Before calculating metrics, confirm that click spans are flowing to APM:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
| KEEP attributes.search.result_click_id, attributes.search.result_click_position,
       attributes.search.query_id, attributes.search.query
| LIMIT 5<p>If this returns rows, you're ready for analytics. If not, check the same things as we looked at in Blog 2: OTLP endpoint, auth token, and span export.</p><p><strong>Note:</strong> Results in this post are illustrative, generated by running <code>python generate_traffic.py --blog 3 --sessions 50</code> on the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a>. Running Blog 3 traffic adds click events <em>and</em> additional search sessions on top of the 62 from Blog 2, so cumulative search counts will exceed 62. Your exact numbers will vary based on session count and the random nature of the traffic simulator. The metric calculations and ES|QL patterns are what to focus on.</p><h2>CTR</h2><p><strong>CTR</strong> is the primary signal for search relevance, answering the question that Blog 2 couldn't: <em>Are users engaging with the results?</em></p><p><strong>CTR = searches with at least one click / total searches * 100</strong></p><p>CTR is a binary per-search metric: Either a search got clicked or it didn't. The maximum is 100%.</p><h3>Overall search CTR with ES|QL</h3>FROM traces-generic.otel-default
| WHERE (name == "search" AND attributes.search.query IS NOT NULL)
OR attributes.search.first_click == true
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)<p><strong>Result:</strong> 41 clicked searches out of 146 total. <strong>CTR: 28.1%</strong></p><p>This is a single query that pulls both search spans and first-click spans from <code>traces-generic.otel-default</code>. The <code>OR</code> in the <code>WHERE</code> clause brings both into one result set. <code>COUNT(CASE(...))</code> counts each type separately, and <code>EVAL</code> does the division.</p><p>This works because of <code>search.first_click</code>. Without it, you'd be counting raw clicks (a user who clicks three results on one search would inflate the count). The deduplication happened at instrumentation time; the query stays simple.</p><h3>CTR by search query: Finding your worst relevance failures</h3><p>The overall number is useful for dashboards. The per-query breakdown is where you find problems.</p>FROM traces-generic.otel-default
| WHERE ((name == "search" AND attributes.search.query IS NOT NULL)
OR attributes.search.first_click == true)
AND attributes.search.query IS NOT NULL
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
BY attributes.search.query
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)
| SORT searches DESC
| LIMIT 20<p>This shows CTR broken down by query text. </p><h3>What CTR tells you (and what it doesn't)</h3><ul><li><p><strong>High searches + zero clicks:</strong> These are the worst relevance failures. Fix these first.</p></li><li><p><strong>High searches + low CTR:</strong> Results appear, but they aren't compelling. Check ranking.</p></li><li><p><strong>Low CTR + high zero-results rate:</strong> This is a double problem; either no results or bad results.</p></li><li><p><strong>CTR trend over time:</strong> This measures the impact of relevance changes.</p></li></ul><p>CTR doesn't measure satisfaction. A user who clicks position 1, bounces back, and then clicks position 3 still counts as one clicked search. For a fuller picture, you need to know <em>where </em>they're clicking. That's what MRR measures.</p><h3>CTR vs. clicks per search</h3><p><strong>CTR</strong> (what we just calculated) is capped at 100%. This is the industry-standard definition.</p><p><strong>Clicks per search</strong> is total clicks divided by total searches. It can exceed 1.0. For example, a search where the user clicks three results scores 3.0. It measures engagement depth, which is useful but different. If you need it, count all click spans (not just <code>first_click</code>) divided by search spans.</p><h2>MRR</h2><p><strong>MRR</strong> tells you <em>where users </em>click. It measures how far down the results list users go before finding something worth clicking.</p><p><strong>MRR = average of (1 / click_position) across all clicks</strong></p><p>The reciprocal rank transforms click positions into a 0–to–1 scale, where higher is better:</p><p></p><p><strong>Click position</strong></p><p><strong>Reciprocal rank</strong></p><p>1</p><p>1.000</p><p>2</p><p>0.500</p><p>3</p><p>0.333</p><p>5</p><p>0.200</p><p>10</p><p>0.100</p><p></p><h3>Overall search MRR with ES|QL</h3><p>MRR can be calculated two ways, depending on what you want to measure:</p><ul><li><p><strong>All-click MRR:</strong> Averages the reciprocal rank of every click and reflects overall click quality, including repeated interactions.</p></li><li><p><strong>First-click MRR:</strong> Averages only the first click per search (using <code>search.first_click == true</code>). It’s more comparable to traditional information retrieval (IR) evaluation and aligns with how you computed CTR.</p></li></ul><p>For consistency with CTR and alignment with judgment-list workflows in Blog 5, we prefer first-click MRR:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
AND attributes.search.first_click == true
| EVAL reciprocal_rank = 1.0 / attributes.search.result_click_position
| STATS mrr = ROUND(AVG(reciprocal_rank), 3)<p><strong>Result:</strong> MRR = <strong>0.495</strong></p><p>This is decent but shows room for improvement. An MRR of 0.495 means the average first click lands around position 2. It isn’t a crisis, but there are queries where ranking can be improved.</p><h3>MRR by search query: Finding your worst-ranked results</h3><p>Like CTR, the per-query breakdown is where the actionable data lives. To surface the worst-ranked queries first, sort ascending.</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
AND attributes.search.first_click == true
AND attributes.search.query IS NOT NULL
| EVAL reciprocal_rank = 1.0 / attributes.search.result_click_position
| STATS
    mrr = ROUND(AVG(reciprocal_rank), 3),
    clicks = COUNT(*)
BY attributes.search.query
| SORT mrr ASC
| LIMIT 20<p>This reveals which queries have the worst ranking. A query with multiple clicks and low MRR means that the ranking is consistently poor for that search; that is, users find results, but they have to dig for them.</p><h3>What is a good MRR score for search?</h3><ul><li><p><strong>MRR &gt; 0.8:</strong> This ranking is solid; users usually click position 1–2.</p></li><li><p><strong>MRR 0.5–0.8:</strong>  This is decent, but there’s room for improvement.</p></li><li><p><strong>MRR &lt; 0.5:</strong> This is a ranking problem, and users are scrolling past top results.</p></li><li><p><strong>MRR drop after a change:</strong> This is ranking regression that should be investigated immediately.</p></li><li><p><strong>Low MRR + high CTR:</strong> Users are finding things, but they have to work for it.</p></li></ul><p>That last pattern is particularly interesting. High CTR with low MRR means your results are relevant (users are clicking), but your ranking isn't surfacing the best results first. It's an optimization opportunity, not a crisis.</p><h3>MRR limitations: Position bias and multi-click sessions</h3><p>MRR is heavily influenced by the gap between position 1 and position 2 (1.0 versus 0.5). Positions 5 and beyond barely move the average. This means that MRR is most sensitive to whether your top result is good, which is often exactly what you want to optimize.</p><p>MRR also only measures clicks, not satisfaction. With <em>all-click MRR</em>, a user who clicks position 1, bounces, and then clicks position 3 contributes two data points, but only the second was useful. The <em>first-click MRR</em> queries above avoid this by counting only the first click per search via <code>search.first_click == true</code>.</p><h2>Click position distribution</h2><p>Click position distribution shows you the full picture of where in the results users are engaging.</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
| STATS click_count = COUNT(*) BY attributes.search.result_click_position
| SORT attributes.search.result_click_position ASC<p><strong>Results:</strong></p><p><strong>Position</strong></p><p><strong>Clicks</strong></p><p>1</p><p>21</p><p>2</p><p>10</p><p>3</p><p>7</p><p>4</p><p>4</p><p>5</p><p>3</p><p></p><p>This is a reasonable distribution: 21 of 45 clicks (47%) land on position 1, with a tapering tail. If you paste this query into Discover's ES|QL editor, Kibana auto-generates a bar chart that makes the shape immediately visible.</p><h3>How to read click position distribution for search relevance</h3><ul><li><p><strong>Sharp dropoff after position 1:</strong> The ranking is effective, and the top result is usually right.</p></li><li><p><strong>Flat across positions 1–5:</strong> The ranking isn't differentiating well, and all positions are equally likely to be clicked.</p></li><li><p><strong>Spike at position 3+ for specific queries:</strong> Those queries have ranking problems.</p></li><li><p><strong>No clicks beyond position 5:</strong> Users don't scroll far. Top 5 ranking matters most.</p></li></ul><h3>Click position distribution by search query</h3><p></p><p>To see the shape for specific queries:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
AND attributes.search.query IS NOT NULL
| STATS click_count = COUNT(*)
BY attributes.search.query, attributes.search.result_click_position
| SORT attributes.search.query, attributes.search.result_click_position<p>A query where all clicks land on position 1 has perfect ranking. A query where clicks spread across positions 1–5 needs relevance tuning.</p><h3>Position bias and click models</h3><p>One caveat: Click position distribution is influenced by <em>position bias</em>; that is, users see position 1 first, so it gets clicked more regardless of relevance. A click at position 1 isn't necessarily more relevant, just more visible.</p><p>This is a well-studied problem in information retrieval. <em>Click models</em> are statistical models that attempt to separate genuine relevance from position bias in click data. The foundational work by <a href="https://www.cs.cornell.edu/people/tj/publications/joachims_etal_05a.pdf">Joachims et al. (2005)</a> showed that users are significantly biased toward higher-ranked results, and, in proposed methods like skip-above analysis (if a user clicks position 3 but skips positions 1 and 2), those skipped results are likely less relevant for that query.</p><p>For the metrics in this post, you don't need to implement a full click model. The key insight is practical: Compare distributions <em>between queries</em> rather than treating absolute position counts as ground truth. If query A has 80% of clicks at position 1 and query B has clicks spread across positions 1–5, query B's ranking is worse, even accounting for position bias. Later in the series, when we look at building judgment lists for LTR, position bias correction becomes more important, and the click data you're collecting here is exactly what those models need as input.</p><h2>CTR, MRR, and click distribution: Reading search quality metrics together</h2><p>These three metrics offer three different angles on search result quality:</p><p></p><p><strong>Metric</strong></p><p><strong>What it measures</strong></p><p><strong>Our value</strong></p><p><strong>Interpretation</strong></p><p><strong>CTR</strong></p><p>Do users click at all?</p><p>28.1%</p><p>Moderate: Roughly a third of searches get engagement, but there’s room to improve.</p><p><strong>MRR</strong></p><p>Where do they click?</p><p>0.495</p><p>Decent: The average click is around position 2, but ranking can be improved.</p><p><strong>Distribution</strong></p><p>What's the shape?</p><p>47% at position 1</p><p>Reasonable drop-off: The top result wins most but isn’t dominant.</p><p>Together, they tell a coherent story. For our demo data, search is performing adequately: Users are engaging with results and can find what they need, but the ranking has room to improve. The CTR of 28% and MRR of 0.495 are realistic starting points for a new search implementation without tuning.</p><p>Where they're most valuable is in combination at the query level. The queries to fix first are those with <strong>high volume + low CTR + low MRR</strong>; that is, lots of users are searching, few are clicking, and those who do click are scrolling deep. That's where relevance investment has the highest return.</p><h3>Using click data for LTR and relevance tuning</h3><p>These metrics don't just tell you how search is performing; they're the foundation for making it better. The click data you're now collecting feeds directly into relevance improvement workflows:</p><ul><li><p><strong>Judgment lists for LTR:</strong> Click positions and frequencies become graded relevance labels for training machine learning (ML) ranking models. A document clicked at position 1 across many queries is a strong positive signal.</p></li><li><p><strong>Relevance tuning tools:</strong> Per-query CTR and MRR tell you exactly which queries to focus on in tools like <a href="https://elastic.github.io/relevance-studio/#/">Relevance Studio</a> or the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html">Rank Eval API</a>, which scores your ranking against expected results.</p></li><li><p><strong>Query rules and boosting:</strong> Zero-CTR queries with results are candidates for pinning, boosting, or synonym rules.</p></li></ul><p>We'll cover these applications in detail in Blog 5. For now, the important thing is that the instrumentation you've built here is doing double duty: It measures search quality <em>and</em> provides the training data to improve it.</p><h2>Next in the series: Conversion tracking from search to purchase</h2><p>We can now measure whether users find results (Blog 2) and whether they engage with them (this post). But a click isn't a conversion. A user who clicks a product and then abandons the page didn't get what they needed.</p><p>In the next post, we add <em>conversion tracking</em>, the third instrumentation point that closes the loop from search to purchase. It’s the same pattern: Add <code>search.*</code> attributes to add-to-cart and checkout spans, query with ES|QL, and answer the question your product manager actually cares about: <em>Which searches drive revenue?</em></p><h2>Get started</h2><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series (clone, configure, and run).</p></li><li><p><a href="https://github.com/elastic/elastic-otel-python">Elastic Distribution of OpenTelemetry Python:</a> EDOT Python.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry with Elastic:</a> How to send OTel data to Elastic APM.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation:</a> Query language reference.</p></li><li><p><a href="https://www.ubisearch.dev/">UBI standard:</a> Reference schema for search event structure.</p></li></ul><p><em>This is the third post in a </em><a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry"><em>series on search analytics with OpenTelemetry and Elastic</em></a><em>. Next up: From clicks to conversions: Conversion tracking, funnel analysis, and revenue attribution.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3f04684f0d65c705/6a6efbf02888390e8607b2c2/image1.png" length="0" type="image/png"/>
    <pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[56% faster, up to 50% better retrieval performance: What's inside Jina's new 600 million parameter listwise reranker]]></title>
    <description><![CDATA[Jina Reranker 3.5 beats v3 by 50%+ on case law, closes the gap with models 7x its size on legal, medical, and financial benchmarks, and beats them outright on structured data. It's a drop-in replacement for v3, with no API changes.]]></description>
    <content:encoded><![CDATA[<p><code>jina-reranker-v3.5</code> is a 600 million parameter reranker that delivers major gains over its predecessor, <a href="https://jina.ai/models/jina-reranker-v3"><code>jina-reranker-v3</code></a>, on legal retrieval, and closes most of the gap to models seven times its size on legal, medical, and financial reranking. On long documents, it runs up to 56% faster than <code>jina-reranker-v3</code> and scores over 50% higher on case law retrieval. It also beats Qwen3-Reranker-4B on the STaRK structured data benchmark."It’s a drop-in replacement for v3 users and requires no changes to the code that accesses the model.</p><h2>What’s a reranker and how does it work?</h2><p>A <em>reranker </em>is an AI model used near the end of an information retrieval pipeline, after other modules have assembled a short list of candidate matches to a query. It’s trained to order the candidate list from best matching to least. Using a specialized model focused purely on ranking candidate matches can improve result quality dramatically.</p><p>Jina AI’s latest rerankers use a technique called <em>late interaction</em>, where queries and documents are encoded separately into lists of token embeddings that reflect each token’s semantics in context and then compared to each other.</p><p>This is an AI analog of lexical and grammatical disambiguation.</p><p>For example, consider the meaning of the word <em>match </em>in these two sentences:</p><ul><li><p>She looked for a match to light the candle.</p></li><li><p>She looked for a match on Tinder.</p></li></ul><p>The first sentence might be a match for queries about matchboxes; the second for queries about romance.</p><p>Transformer-based models do this kind of in-context disambiguation but bring much richer information into the token embeddings they produce. The word <em>match</em> might have a semantic embedding near to words like <em>fire</em> or <em>illumination</em> in the first sentence, while in the second, it might be closer to <em>smartphone</em> or <em>swipe</em>.</p><p>Late interaction rerankers generate these context-enriched token embeddings for both the query and the candidate documents and then compare them to produce sortable scores. They’re completely agnostic about how candidate match lists are created. The reranker works exactly the same when combined with lexical search schemes, like BM25, AI-driven semantic embeddings-based retrieval, or hybrid and federated search systems that may retrieve multiple candidate lists from different sources or using different algorithms. Of course, the results always depend on the quality of the candidates, so a reranker can’t fix bad first-stage retrieval, but it almost always improves whatever you’ve got.</p><p><code>jina-reranker-v3.5</code> is a <em>listwise</em> reranker using the <a href="https://jina.ai/news/jina-reranker-v3-0-6b-listwise-reranker-for-sota-multilingual-retrieval/#:~:text=query%2Ddocument%20interaction%20%22-,last%20but%20not%20late,-.%22%20It%27s%20%22last"><em>last-but-not-late</em></a> technique developed for <code>jina-reranker-v3</code>. The query and a list of candidate matches are passed into the model together and processed in one pass, returning a numerical score for each candidate. This enables the model to use context information from the query and the full candidate list to make sense of the entire input, producing better results because of the richer information available to it.</p><p><code>jina-reranker-v3</code> proved that listwise rerankers with last-but-not-late interaction can compete with the largest models on general reranking benchmarks. Only <code>jina-reranker-v3.5</code> and models with over four billion parameters beat it on <a href="https://mteb-leaderboard.hf.space/benchmark/MTEB(Multilingual%2C%20v2)">Massive Text Embedding Benchmark (MTEB) reranker tasks</a>. However, this approach places strict limits on candidate list sizes. The query and all candidate matches must fit in the input context window of the model.</p><h2>What problems does Jina Reranker v3.5 solve?</h2><p>Despite having frontier-level performance overall, <code>jina-reranker-v3</code> has some notable performance gaps:</p><h3>Domain-specific text retrieval</h3><p><code>jina-reranker-v3</code> was trained on general text corpora and, as a result, it underperforms on important use cases, particularly:</p><ul><li><p>Legal texts, like case law and contract clauses.</p></li><li><p>Medical literature, like clinical trials and patient records.</p></li><li><p>Financial datasets and other texts full of important numbers.</p></li><li><p>Computer programming and IT documentation.</p></li><li><p>Product catalogs full of technical terminology and specifications.</p></li></ul><h3>Structured data: Tables, JSON, and key-value records</h3><p>Vast quantities of essential, real-world data is encoded in spreadsheets, tables, key-value lists, and structured records, like JSON data. However, rerankers trained purely for textual comparison, like <code>jina-reranker-v3</code>, perform poorly on this kind of data.</p><h3>Compute costs for long candidate lists</h3><p>The self-attention architecture at the core of most text-processing AI models means that memory and compute requirements grow quadratically with the size of its input. This makes <code>jina-reranker-v3</code>, like other AI models, very computationally expensive to run with a full input context window. But, to make the most effective use of the model, we want to put as many match candidates as possible into its input. When it’s at its most useful, it’s also slower and more expensive to run.</p><p>We’ve developed <code>jina-reranker-v3.5</code> specifically to address these issues without reducing its performance on general purpose text retrieval.</p><h2>What’s new in Jina Reranker v3.5?</h2><p><code>jina-reranker-v3.5</code> contains a modified self-attention mechanism that enhances performance, increases processing speed, and reduces the resources required at inference time to process a full input context window. We’ve also introduced a new three-stage self-distillation training process better suited to the sliding-window architecture of large input context models.</p><p>We’ve also curated and used training data focusing on the performance gaps we identified in <code>jina-reranker-v3</code>, including:</p><ul><li><p>Multilingual legal texts drawn from diverse international sources.</p></li><li><p>Medical texts drawn largely from scientific literature and materials used for other AI projects, including a collection of Chinese medical question-answer pairs.</p></li><li><p>Financial industry data, including investment-related question-answer pairs, regulations, and tables with numbers and associated texts.</p></li><li><p>Structured data, especially from ecommerce sources and public corpora of tables.</p></li><li><p>Expanded multilingual and cross-language texts.</p></li></ul><p>For details on the data sources and technical innovations in <code>jina-reranker-v3.5</code>, see <a href="https://arxiv.org/abs/2607.18152">our technical report</a>.</p><h2>How Jina Reranker 3.5 performs on retrieval benchmarks</h2><p>Parameters</p><p>597 million</p><p>Input modalities</p><p>Text only</p><p>Context window size</p><p>131,072 tokens</p><p>Maximum number of candidate matches</p><p>No fixed limit, but all candidates and query must fit in the context window.</p><p>Languages</p><p>Training in 52 languages</p><h3>General text reranking performance (BEIR and MIRACL)</h3><p><code>jina-reranker-v3.5</code> improves on <code>jina-reranker-v3</code>’s performance on general text reranking benchmarks. On the English-language <a href="https://github.com/beir-cellar/beir">Benchmarking Information Retrieval (BEIR) benchmark</a>, the average score has increased enough to surpass the frontier <a href="https://huggingface.co/Qwen/Qwen3-Reranker-4B">Qwen3-Reranker-4B</a> and <a href="https://huggingface.co/Qwen/Qwen3-Reranker-0.6B">0.6B</a> models and Mixedbread AI’s rerankers.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2be1aaecef5d0cbd/6a6a33f854090e576714707e/52c2c0bd6269881eb4e2716a81442030dbf23458-2048x785.png" alt="Jina Reranker v3.5 BEIR benchmark results compared to Qwen3 and Mixedbread rerankers" /><p>We’ve also improved <code>jina-reranker-v3</code>’s multilingual reranking performance on the Multilingual Information Retrieval Across a Continuum of Languages (MIRACL) benchmark. Only the four billion parameter Qwen3 reranker regularly beats <code>jina-reranker-v3.5</code>’s score.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta9d9b745a3c23efd/6a6a33f899442c66f1df1d8c/cc9ba1d219b1c77ef0840c86c038088a66b40f6d-2048x785.png" alt="Jina Reranker v3.5 MIRACL multilingual benchmark results across 18 languages" /><h3>Legal, medical, and financial reranking</h3><p>The <a href="https://huggingface.co/blog/rteb">Retrieval Embedding Benchmark (RTEB) suite</a> consists of diverse domain-specific retrieval benchmarks. <code>jina-reranker-v3.5</code> outperforms <code>jina-reranker-v3</code> on all RTEB tasks related to law, medicine, and finance. Only the large Qwen3 reranker, at almost seven times as many parameters, has better average performance in those three domains.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6024d5c8ac1355ac/6a6a33f9b62af41d832635d8/1426abb0397e0b9d7c8115334ca5c4f899fa8fb6-2048x693.png" alt="Jina Reranker v3.5 RTEB domain-specific benchmark results for legal, medical and financial retrieval" /><p>The new model shows particularly strong improvements for legal data, beating <code>jina-reranker-v3</code>’s score by over 50% on case law retrieval tasks.</p><p>Task</p><p>Reranker v3</p><p>Reranker v3.5</p><p>Improvement v3 to v3.5</p><p>AILA-Case</p><p>20.82</p><p>32.55</p><p>+11.73 (56%)</p><p>AILA-Statute</p><p>32.15</p><p>46.16</p><p>+14.01 (44%)</p><p>LegalQuAD</p><p>81.84</p><p>83.09</p><p>+1.25 (1.5%)</p><p>LegalSum</p><p>69.64</p><p>70.99</p><p>+1.33 (1.9%)</p><h3>Structured data reranking (Struct-IR and STaRK benchmarks)</h3><p>We evaluated <code>jina-reranker-v3.5</code>'s structured data reranking on two benchmarks: <a href="https://neurips.cc/virtual/2025/loc/mexico-city/poster/121702">Struct-IR</a> and <a href="https://stark.stanford.edu/">STaRK</a>. Both benchmarks contain AI-generated JSON text data covering a variety of applications, including product records, scientific papers, and biomedical knowledge bases.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1deca80ed81995d5/6a6a33f79b96f21e21baf2be/b847be39e6387c4d4eaf754c5d758fc59fad4349-2048x717.png" alt="Jina Reranker v3.5 structured data benchmark results on Struct-IR and STaRK" /><p><code>jina-reranker-v3.5</code> improves substantially on <code>jina-reranker-v3</code>’s score on the Struct-IR benchmark, once again only exceeded by Qwen3-Reranker-4B. On the STaRK benchmark, <code>jina-reranker-v3.5</code> beats all the other models we tested, of any size.</p><h3>Inference speed: Latency benchmarks for short and long documents</h3><p>The longer the candidate documents get, the more significant the architectural improvements we’ve brought to <code>jina-reranker-v3.5</code> are. To verify this, we used two retrieval datasets distinguished by large differences in the average document length:</p><p>Dataset</p><p>Avg. doc length</p><p>jina-reranker-v3</p><p>jina-reranker-v3.5</p><p>Speedup</p><p>BEIR Natural Questions</p><p>145.5 tokens</p><p>371.1 ms</p><p>305.3 ms</p><p>22%</p><p>RTEB AILAcasedocs</p><p>1,904.0 tokens</p><p>16,064.9 ms</p><p>10,290.9 ms</p><p>56%</p><p><code>jina-reranker-v3.5</code> is significantly faster in both cases. On the Natural Questions benchmark, there’s a 22% speedup compared to <code>jina-reranker-v3</code> with average request latency falling from 371.1 ms to 305.3 ms. Each query from the AILAcasedocs benchmark is much larger (more than 10 times larger on average) so it naturally takes longer to rerank on average: 16,064.9 ms for <code>jina-reranker-v3</code> and 10,290.9 ms for <code>jina-reranker-v3.5</code>. This represents a 56% speedup for the newer model, representing less latency for applications and lower computer costs.</p><h2>When should you use Jina Reranker v3.5?</h2><p>Reranking improves search precision in practically every case, and <code>jina-reranker-v3.5</code> has applications in a wide variety of information retrieval contexts. However, it has some limitations. The table below summarizes our best-practice advice:</p><p>Use case</p><p>Recommendation</p><p>General text retrieval in common international languages</p><p>Use `jina-reranker-v3.5`.</p><p>Legal, financial, and medical domain retrieval</p><p>Use `jina-reranker-v3.5`.</p><p>Semi-structured data, tables, product information texts for ecommerce</p><p>Use `jina-reranker-v3.5`.</p><p>Non-text or mixed-media data</p><p>Use `jina-reranker-m0`, which supports both text and image input.</p><h2>How to use Jina Reranker 3.5 with the Elastic Inference API</h2><p><strong><code>jina-reranker-v3.5</code></strong> is available via the <a href="https://jina.ai/reranker/">Jina API</a> with free tokens to try it out. It’s also available via the <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/inference-api">Elastic Inference API</a> and <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a>.</p><p>If you’re already using <strong><code>jina-reranker-v3</code></strong>, all you have to do is change the name of the model in the <code>model</code> field of your request to the Jina API or <code>model_id</code> field when configuring an Elastic Inference API endpoint. The two models have completely identical interfaces.</p><p>You can install <strong><code>jina-reranker-v3.5</code></strong> as a <a href="https://www.elastic.co/search-labs/blog/on-prem-ai-jina-embedding-models">Jina On-Prem container</a> to get a completely self-contained server that runs on your own hardware. The model weights are also available to download for testing and research. Follow the instructions on the <a href="https://huggingface.co/jinaai/jina-reranker-v3.5">model’s page at Hugging Face</a>. In both cases, the model is available under a <a href="https://creativecommons.org/licenses/by-nc/4.0/deed.en">CC BY-NC-4.0 license</a>, so you’re free to try it out for testing, building prototypes, or doing scientific research. For commercial use, please contact Elastic sales.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/jina-reranker-35-legal-medical-structured-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/jina-reranker-35-legal-medical-structured-data</guid>
    <category><![CDATA[Jina AI]]></category>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[ML Research]]></category>
    <dc:creator><![CDATA[Felix Wang,Scott Martens]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt29bbbf4463c73d8a/6a6a33fa55755baeaa2bd248/a6563ee307cc2d29722c490b043ee736c46974f3-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[AI shopping agents: Why context comes before the query]]></title>
    <description><![CDATA[AI shopping agents that guess at your vocabulary make expensive mistakes. Pre-computed catalog context stops the guessing before the first tool call.]]></description>
    <content:encoded><![CDATA[<p>The race is on for retailers to match the evolving expectations of their customers to provide significantly richer online shopping experiences. Customers want to go beyond searching for products; they want interactive, personalized, and proactive guidance powered by AI. The challenge is that, although large language models (LLMs) can be extremely powerful, how do you construct a system that acts like a knowledgeable employee of your store in a fast, accurate, and cost-effective way? We’ll discuss the challenges of building these AI shopping assistants as well as emerging context engineering approaches to optimize how they work.</p><p>AI shopping agents fail not because the model is wrong but because the agent arrives at every conversation not knowing your catalog, vocabulary, or business rules. It has to discover all of this context through tool calls, and that discovery is the cost. Precomputing a structured context layer from signals you already hold (vocabulary, policies, user profiles, session behavior) cuts the exploratory work the agent does before it can answer and makes its behavior governed and predictable.</p><p>In the analogous document-retrieval case, precomputing context reduced input tokens by up to 75% on a controlled benchmark; we expect comparable savings in ecommerce because the exploration pattern is the same, though the exact figure will vary by catalog and query mix. The signals are richer in ecommerce than in almost any other domain, and most retailers already have them. The question is whether or not these signals are assembled in a form that the agent can use before it starts reasoning. Elastic’s broad mix of search capabilities, semantic, hybrid, keyword, filtering, and aggregations make it a compelling choice for not only building your core retrieval tools but also as the vital context engine.</p><h2><strong>Why AI shopping agents fail in production</strong></h2><p>Retailers investing in AI shopping assistants are discovering an uncomfortable gap between what the demos promise and what the first few months in production deliver.</p><p>The assistant takes four seconds to respond. It confidently recommends a product in a size range that doesn't exist for that item. It tells a returning customer about a coat style they bought 18 months ago and returned. It filters by a category name that doesn't match the internal taxonomy and returns zero results. The customer gives up and abandons the chat altogether.</p><p>These aren't model failures. The frontier models powering these agents are capable of extraordinary reasoning when they have the right information in front of them. The problem is that the agent arrives at the conversation knowing nothing about the retailer's catalog, customer, or business rules that govern what should and shouldn't be recommended. It has to learn all of this through the conversation itself, making exploratory tool calls to discover what departments exist, what filter values are valid, and what the brand's policies are on certain product types. Every one of those discovery calls costs latency and tokens before the agent has said a single useful thing to the customer.</p><p>Latency matters in ecommerce in a way it doesn't in many other contexts. Shoppers expect response times measured in seconds, not the minutes that enterprise knowledge-base agents routinely take. It’s well established that slower responses reduce engagement and conversion in online retail. An AI shopping assistant that thinks visibly for five seconds before answering a question about gift ideas isn't a feature; it's friction.</p><p>The fix isn't a faster model or a bigger context window. <strong>The agent's latency and cost problem is a context problem.</strong> This can be solved by carefully computing context before the agent call, not during it.</p><h3><strong>What an AI shopping agent knows before it searches</strong></h3><p>Imagine you’re the agent. You have a search tool connected to the catalog, and this query arrives:</p><p><code>"an outfit for an autumn wedding"</code></p><p>What do you actually do with that?</p><p>Start with what you don't know. An outfit for a man or a woman? Is "autumn" a color, a season, a style of fabric, or just when the wedding happens? And who is this shopper, someone who has bought from you for years, or a stranger? Do they buy expensive designer brands or do they always hunt out a bargain on sale? You have none of these answers. So you do what an agent does when it's working blind: You ask loads of follow-up questions, guess, or fire off a series of exploratory searches to find out what departments and filter values even exist, watching the seconds tick by before you've offered the customer anything at all.</p><p>Hold onto that feeling of working blind. The rest of this article is about what changes when the agent is handed the answers first.</p><h2><strong>Why ecommerce is different from general RAG</strong></h2><p>Most of the published work on reducing agent costs focuses on document retrieval: question-answering over corpora of articles, reports, or knowledge-base entries. A recent experiment from the Elastic team (<a href="https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs">Cutting agent costs with pre-computed context</a>) demonstrated that pre-extracting structured facts from documents before the agent call reduced input token consumption by up to 75% and improved answer accuracy from 60% to 92% on a hard factual benchmark. That improvement came in stages, with the largest jump driven by feeding the agent's own wrong answers back into the extraction step rather than by precomputing context alone, which is a distinction we'll return to when we discuss governance.</p><p>Ecommerce applies the same principle to a fundamentally different structure. A product catalog isn't a document corpus. It's a highly structured index of items with strict field semantics, a domain-specific vocabulary of brand names, color codes, and category hierarchies, and a layer of business rules that override pure relevance in specific situations.</p><p>The failure modes that result are distinct from document retrieval augmented generation (RAG) failures:</p><ul><li><p><strong>Vocabulary mismatch:</strong> A customer asks for a "navy jumper." The agent constructs a filter against a field where the canonical value is NAVY and the category is stored as Knitwear &amp; Jumpers. Without a vocabulary mapping, the agent either guesses colors and categories and gets it wrong, or makes multiple exploratory calls to discover what values exist before it can filter correctly.</p></li><li><p><strong>Hallucinated filter values:</strong> Without knowing which filter dimensions are valid for a given query, agents can construct queries against fields that don't exist or with values that return zero results. A filter like category: knitwear looks reasonable; <code>masterCategoryNames: "Knitwear &amp; Jumpers"</code> is what the index actually contains. If the agent doesn’t know, it either hallucinates or has to do a separate tool call to find out, causing another LLM loop, which costs time and tokens.</p></li><li><p><strong>Context-free personalization:</strong> The same query from two different customers, one who typically shops in the premium range and dresses for formal occasions, and one who buys primarily casualwear under £40, should return different results. Without profile context, the agent treats every query identically, which is worse than a well-tuned keyword search because it creates the impression of a personal assistant while delivering generic answers.</p></li></ul><h2><strong>The context layer: What signals it needs</strong></h2><p>The reason ecommerce is particularly well suited to precomputed context is that retailers already hold an unusually rich set of signals. The challenge isn't data availability; it's assembly.</p><p>The signals fall into two groups. Two of them, the catalog vocabulary and the business policies, are the genuinely original work and the heart of this approach. The rest, live facet state, user profiles, and session history, are valuable but closer to table stakes, signals that most teams already understand how to fetch. Here's what most mid-to-large retailers have and what each signal prevents.</p><p>Signal</p><p>What it prevents</p><p>Effort to build</p><p>Catalog vocabulary</p><p>Vocabulary mismatch and hallucinated filter values; the agent guessing at colors, categories, or brand names instead of resolving them to canonical field values</p><p>One-time engineering effort (full-catalog aggregation); incremental to maintain as new categories and brands are added</p><p>Business policies</p><p>Recommendations that ignore legal or trading requirements, for example, missing age verification on alcohol queries or routing that misses a gluten-free range</p><p>Human-authored and governed, not automated; ongoing review as new policy types are added</p><p>Live facet state</p><p>Recommending filters that return zero results or out-of-stock options for the current query</p><p>Runs in parallel with vocabulary and policy lookups; leans on existing catalog and retrieval infrastructure</p><p>User profile</p><p>Making a returning customer restate sizes, budget, or brand preferences they've already given</p><p>Fastest signal to retrieve, a single document lookup by user ID</p><p>Session and purchase history</p><p>Re-recommending an item the customer already dismissed or bought and returned</p><p>Most aspirational layer; depends on customer relationship management (CRM) and analytics integration, best added once the agent is already live</p><p>The reason we say <em>semantic metadata</em> and not just <em>metadata</em> is that we’re trying to match the semantic (meaning) of the intent rather than the exact words. If a user is searching for “teal,” we should be able to understand that this is a color and which colors exist in our products that are semantically similar to teal, even if none of them are actually teal. So, if we search for “teal,” we might want to return:</p><p><code>ProductColours = “aquamarine, turquoise”</code></p><p>Hopefully, you can see how this semantic metadata is bridging the gap between user intent and the agent’s knowledge of the products.</p><h3><strong>Catalog vocabulary: The foundation of context engineering</strong></h3><p>Catalog vocabulary is the layer that does the most work, and it's the one most worth getting right first.</p><p>A vocabulary index maps natural language to the exact field values and category paths used in the product index. It answers questions like: <em>What does "navy" map to?</em> <em>Which categories fall under "knitwear"?</em> <em>Is "Autograph" a brand or a range?</em> <em>What's the correct spelling of a competitor brand the agent might encounter in a query?</em></p><p>What makes this more than a synonym list is how it's queried. The interesting thing a shopper says is rarely an exact field value. They say "something cozy for fall," not <code>colour: NAVY</code> and <code>masterCategoryNames: "Knitwear &amp; Jumpers"</code>. So the vocabulary index needs to resolve fuzzy, natural language intent into precise, exact-match filters, and that requires both kinds of matching at once: semantic search to understand that "cozy" leans toward knitwear and fleece, and exact keyword matching to pin the result to the canonical values the product index actually stores. A metadata index that supports both on the same documents is, in effect, a translation layer between how customers talk and how the catalog is structured.</p><p>This is also where the index earns the description "semantic metadata layer" rather than "lookup table." Each entry is a small natural language description of a facet value or schema concept, so the agent can match against meaning and then read back the exact filter to use. For a typical fashion retailer, this covers hundreds of color values, brand aliases, category synonyms, and size-range conventions. Building the semantic metadata layer from a full-catalog aggregation is a one-time engineering effort; maintaining it is incremental as new categories and brands are added. Without it, an agent encountering an unfamiliar term must either guess or make exploratory tool calls to discover what's there.</p><h3><strong>Business policies in the context layer</strong></h3><p>The second original layer is policy. Some queries carry implicit business requirements that pure relevance cannot handle. A query for "wine gift for a friend" should trigger an age-verification reminder in markets where it's legally required. A query for "gluten-free food gift" should route away from general confectionery toward the specific gluten-free range. A query mentioning "wedding guest outfit" in spring should apply different weighting than the same query in November.</p><p>These are policies, and most retail search teams already write them. They just call them boost rules, merchandising overlays, or synonym configurations. The difference in an agentic context is that instead of being applied silently as query modifications, they're surfaced as readable hints the agent can use when deciding how to frame its answer and which products to surface. The agent doesn't have to infer your trading rules from the catalog; it's handed them.</p><p>The critical point: These policies encode business intent, not just relevance. A policy that routes alcohol queries through an age-appropriate flow isn't a retrieval optimization; it's a trading requirement. That's why this layer must be human-authored and governed, not generated automatically from traffic patterns, a point we return to in the governance section.</p><p>Together, vocabulary and policy are what make the agent behave like it understands your business rather than just your data. The remaining three signals sharpen the experience, but they're more familiar engineering.</p><h3><strong>Facet, profile, and session signals in the context layer</strong></h3><ul><li><p><strong>Live facet state:</strong> Before the agent recommends filters, it should know which filters are available and how many results each returns for this specific query. An agent that suggests "filter by size 8" without knowing that size 8 is out of stock for this query undermines the customer's trust immediately. A facet state query against the product index, run in parallel with the vocabulary and policy lookups, returns the counts, ranges, and available values specific to the current query.</p></li><li><p><strong>User profile:</strong> A persistent profile (sizes, color preferences, budget range, brand affinities) lets a returning customer skip restating what they've already told you. It's typically the fastest signal to retrieve, a single document lookup by user ID.</p></li><li><p><strong>Session and purchase history:</strong> Within a session, the agent should know what the customer has already seen, dismissed, or added to their basket, so it doesn't re-recommend a dismissed item or repeat itself. Longer-term purchase history extends this, and signals like return history are richer still, but using them well depends on data most retailers hold in systems that aren't yet wired into their search path. This is the most aspirational layer and the one best approached last, once the earlier layers are delivering value. There must also be balance when building the initial context not to overinflate the size, which will slow down the first reply and increase token costs. There is, therefore, a careful balance to strike between providing information like purchase history in the initial context or providing it as a tool for the agent to use during conversation, but users will expect that, if they’re logged in, the agent should know what they’ve purchased. The precise optimal context is likely to be specific to each implementation and customer experience and will require careful testing.</p></li></ul><h2><strong>How context engineering works before the LLM call</strong></h2><p>The pattern that makes this work is simple to describe and moderately involved to implement:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt017dd46a26cf7bc6/6a6119f6f81792b87a07f605/e814160fc7e599aa3cd1603be366cc726114948f-1024x559.png" alt="Diagram of context engineering for AI shopping agents: a context layer resolves vocabulary, policy, facets, profile and session signals in parallel before the LLM call" /><p>Without this context build, the agent makes the same discoveries on its own, but through LLM-driven tool calls that each cost a full inference round trip. As a rough rule of thumb, an exploratory tool (for example, GetFilterValues) call tends to land somewhere in the region of 600ms to 1 second of latency in practice; an agent that discovers the vocabulary, checks policy hints, and retrieves facet state through three separate tool calls before it even begins answering can, therefore, add two to three seconds to the response time, and that's before it performs the actual product search. These are order-of-magnitude estimates, not benchmarked figures, and the real numbers depend heavily on the model, the network path, and how the tools are implemented.</p><p>Replacing those discovery calls with a parallel fetch (multiple context-building queries can be done in parallel) that runs before the LLM is invoked removes most of that cost. The context build takes roughly the same wall time as a single LLM tool call, but it replaces three or four of them. The LLM would either need to repeat failed searches or do its own context-building tool calls sequentially to get enough context to be successful. Precomputing context is also deterministic rather than subject to the model's tool-selection choices, which gives the business more control to fine-tune the experience.</p><p>The token reduction follows the same logic: Each exploratory tool call returns raw data the model must process. A preassembled context summary replaces that raw data with structured facts the model can consume in one pass. At the scale of usage possible in public-facing retail websites, this token cost saving can be significant. This work on document search (<a href="https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs">Cutting agent costs with pre-computed context</a>) showed up to 75% input token reduction on a controlled benchmark. That benchmark was document retrieval rather than ecommerce, and its authors are explicit that the multiplier isn't a fixed number you can expect everywhere. We expect the direction to hold in ecommerce, because the exploration pattern is the same; it just runs against a structured catalog rather than a document corpus. The magnitude is something each team should measure against its own traffic.</p><h3><strong>The AI shopping agent with full context</strong></h3><p>Remember the query that left you guessing: an outfit for an autumn wedding. Run it again, but this time, before you have to think, you’re handed a precomputed context as a short brief:</p><ul><li><p>This shopper’s name is Sarah, female, age 32, and she buys womenswear, size 12.</p></li><li><p>She typically buys your mid-tier ranges.</p></li><li><p>"Autumn" here matches these specific color labels: “rust”, “burgundy”, “forest green”, “camel”.</p></li><li><p>Matching departments: “Womenswear”, “Menswear”.</p></li><li><p>Matching tags: "occasion dresses”, “trouser suits”, “wedding”.</p></li><li><p>She already has a burgundy bag in her basket.</p></li><li><p>House rule for wedding-guest looks: Complete the outfit. Show hats and accessories, not just the dress.</p></li></ul><p>Suddenly, you’re not guessing; you’re styling for this customer. And the interesting part is how those facts combine rather than just stack. The season proposes a whole autumn palette; the burgundy bag already in her basket narrows that palette to the few tones that coordinate with it; the house rule tells you to finish the look with a matching fascinator rather than stopping at the dress. Together, these facts let you answer like someone who knows both this customer and this shop, in a single pass, with nothing invented.</p><p>That short brief is exactly what the signal stack produces: the shopper's profile, the resolved vocabulary, the live basket, and the business policy. These are assembled in parallel and placed in front of the agent before its first move, so the "<em>What do I even do with this?</em>" problem never has to be solved one expensive tool call at a time.</p><h2><strong>Governing the context layer without automated drift</strong></h2><p>One difference between the approach described here and automated knowledge extraction systems is worth addressing directly: In ecommerce, the context index cannot self-update without human review.</p><p>The policies that govern how an agent responds to gift queries, alcohol queries, or queries from customers in certain age brackets aren't just relevance configurations; they're trading decisions with potential legal and brand implications. An automated system that generates new policies from traffic patterns, without review, is a compliance risk before it's a technical asset.</p><p>This is actually the right constraint for most retail organizations, and it aligns with how search teams already work. Merchandisers write boost rules. Search teams maintain synonym configurations. Content teams approve what language appears in automated recommendations. The context policy layer is the same kind of governed configuration; it just serves a different consumer, namely, the agent's reasoning step rather than the query pipeline.</p><p>It's worth noting where this differs from the document-retrieval work referenced earlier. In that experiment, the biggest accuracy gain came from an automated feedback loop that fed the agent's wrong answers straight back into the extractor. That works well for factual question-answering, where "right" and "wrong" are unambiguous. In ecommerce, the equivalent signals still surface automatically, but a human decides what to do with them, because the changes carry trading and compliance weight. The loop is the same shape; the publication step has a person in it.</p><p>The governance loop that works in practice has two tiers:</p><ul><li><p><strong>Automatic signal surfacing:</strong> Zero-result queries, repeated reformulations on the same topic, and sessions that end without a purchase after an agent interaction are all signals that something in the context layer is missing or wrong. These surface automatically as candidates for improving the experience, for example: a vocabulary term that didn't resolve or a policy that didn't fire on a query type it should have covered. To do this, you need a thorough log of conversations, including the reasoning and tool call trace in a platform like Elastic. This allows you to analyze the performance of the agent using both structured tools, for example, percentage increase in thumbs-down conversations and semantically. You could also run an automated review of conversations about "gifts" around December to characterize the thumbs-up/down ratio across an AB test of two agents.</p></li><li><p><strong>Human authorship and review:</strong> The search or merchandising team reviews candidates and authors the appropriate vocabulary entry or policy. Policies go through approval before publication. This typically mirrors the workflow that already exists for synonym changes or boost rule modifications; the tooling is the only new part.</p></li></ul><h2><strong>How to implement context engineering in phases</strong></h2><ul><li><p><strong>Phase 1: Vocabulary layer</strong> (highest leverage, bounded engineering task).</p></li><li><p><strong>Phase 2: Facet state and initial policies</strong> (leans on the same catalog and retrieval primitives).</p></li><li><p><strong>Phase 3: User profiles and session signals</strong> (requires CRM and analytics integration; best added when the agent is active).</p></li><li><p><strong>Phase 4: Governed feedback loop</strong> (shifts to organizational alignment; surfaces gaps for merchandising teams).</p></li></ul><p>The full signal stack described above doesn't need to be built at once, and the order isn’t arbitrary. The highest-leverage starting point is also the lowest in implementation complexity: the vocabulary layer. A semantic metadata index built from a full-catalog aggregation (canonical color values, brand aliases, category paths, field names) is a bounded engineering task, and an agent that can resolve "navy jumper" to color: NAVY, masterCategoryNames: "Knitwear &amp; Jumpers" before its first tool call is materially better than one that discovers this through trial and error. If you build nothing else, build this.</p><p>Facet state and the first set of policies can follow close behind, often in parallel, because they lean on the same catalog and the same retrieval primitives. The later layers, such as user profiles, session signals, and the governed feedback loop, are where the work shifts from search engineering to organizational alignment. To implement CRM system integration, merchandising workflow changes, and the analytics needed to surface gaps can take a significant amount of work. Those layers are more valuable once the agent is already in regular use and generating the traffic signals that make the governed loop worth running. The important feature is that each layer stands on its own, so a retailer gets real value from phase one without committing to phase six.</p><h2><strong>What infrastructure does a context layer need?</strong></h2><p>Precomputing context at the depth described here places specific requirements on the underlying platform. It's worth being explicit about these, because the temptation in early agent builds is to reach for the simplest available tool for each capability.</p><ul><li><p><strong>Semantic search</strong> to match natural language queries against the vocabulary index and surface the right canonical values. Fuzzy keyword matching alone won't resolve ambiguity between similar brand names or color terms.</p></li><li><p><strong>A percolator</strong> to implement the policy layer. A percolator reverses the usual search direction: Instead of matching a query against stored documents, it stores the queries and matches an incoming piece of text (here, the customer's message) against them. That's exactly what policy matching needs, because each policy is essentially a saved pattern that says "When a query looks like this, surface this hint."</p></li><li><p><strong>Real-time aggregations</strong> over the full product catalog to produce accurate facet state at query time. Precomputed facet snapshots go stale quickly in active catalogs; query-time aggregations are the more reliable source.</p></li><li><p><strong>Document retrieval by key</strong> for user profiles: fast, single-document lookups by user ID that must complete within the context build window.</p></li><li><p><strong>Structured and semantic logging and analytics</strong> over query traces and agent interactions, which are the raw material for the governed loop's automatic signal surfacing.</p></li></ul><p>These are standard capabilities of a mature search and analytics platform, rather than six separate systems, and Elasticsearch provides all of them in one place. That matters less as a procurement point than as an architectural one: When semantic matching, percolation, aggregations, profile lookups, and analytics all run against the same catalog in the same cluster, the context layer stays consistent with the search layer by construction. Splitting these across a separate vector store and a separate analytics platform is a legitimate choice, but it adds operational surface and introduces a consistency problem between two systems that are reasoning about the same products. The context infrastructure is simplest to run when it lives where the product data already lives and can be updated without a separate extract, transform, load (ETL) step.</p><h2><strong>Conclusion: Context engineering is a search team's job to own</strong></h2><p>The retailers who run effective AI shopping experiences at scale aren't the ones with the largest models or the most generous token budgets. They're the ones who have done the work to make their catalog, vocabulary, and tpolicies legible to an agent before it starts reasoning.</p><p>The good news is that most of the work is already done. The vocabulary is implicit in the catalog. The policies exist as merchandising rules and compliance guidelines. The user profiles are in the CRM. The session signals are in the analytics stream. The gap isn't data; it's the assembly layer that turns those signals into a structured context the agent can consume before it starts reasoning.</p><p>The search team already owns the vocabulary, policies, and merchandising workflows. The context layer is the right home for work the search team is already doing, in a form that serves the agent as well as the query pipeline. And, because it grows every time a gap is found and filled, it behaves less like a setup cost and more like an asset that compounds.</p><p>To begin building a context layer in Elastic, you can start a <a href="https://www.elastic.co/cloud?utm_campaign=G-TXT-EMEA-UK+CA-Core-EN-Lead_Gen-CloudTrials-BR&amp;utm_content=Brand-Cloud&amp;utm_source=google&amp;utm_medium=cpc&amp;device=c&amp;utm_term=elastic%20cloud%20trial&amp;utm_id=701610000005lJVAAY&amp;gad_source=1&amp;gad_campaignid=22979576770&amp;gbraid=0AAAAADrDgoJnVYpNJwbmfVxoTcZSmr4S8&amp;gclid=CjwKCAjwx7LSBhB3EiwAjcodxPrvWRgCciehjf-6cu_sOb7FxbwDEJiS8Dpl95oQo7D2J61zXLJrgRoCtgQQAvD_BwE">trial of Elastic Cloud</a> or <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">run locally</a>. You should become familiar with configuring <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a>, and if you’re interested in how to build, store, and match search policies at query time, you will enjoy <a href="https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance">this blog</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-shopping-agents-context-engineering</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-shopping-agents-context-engineering</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt360319cdeac21a74/6ab3e9caf3b1262ef3650cdb/diagram-customer-query-query-input-context-build.webp" length="0" type="image/webp"/>
    <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[A picture is worth 1.5x the words: What we learned benchmarking product search embeddings]]></title>
    <description><![CDATA[We benchmarked two embedding models on 5,000 real products and found that combining image and text beats either alone by up to 50%. Here's the data and the model that won.]]></description>
    <content:encoded><![CDATA[<p>A picture is worth 1.5x the words: What we learned benchmarking product search embeddings</p><p>Combining image and text into one embedding beats either alone, and the gap isn't small. In our tests, averaged image and text embeddings put the correct product in the top spot up to 1.5 times as often as image embeddings alone. We benchmarked 5,000 real apparel and footwear products in English and German across two Jina embedding models, <a href="https://jina.ai/models/jina-clip-v2/"><code>jina-clip-v2</code></a> and <a href="https://jina.ai/models/jina-embeddings-v5-omni-small/"><code>jina-embeddings-v5-omni-small</code></a>, to see which model and which indexing strategy actually wins for ecommerce search. The older, narrower Contrastive Language–Image Pre-training–style (CLIP-style) model beat the newer, more general one, and that wasn't what we expected. This post walks through the data, the method, and what we'd recommend doing with it.</p><h2>jina-clip-v2 vs. jina-embeddings-v5-omni-small: What's different</h2><p>Multimodal embedding models work by generating representative semantic vectors for inputs of different kinds in a single high-dimensional space.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf638d8046a86a96d/6a6119f95144f70015b98971/9fb7128bc42109876edbe403985a8d621d535ead-1326x973.png" alt="Diagram of a dog and a cat mapped as vectors in a shared embedding space, illustrating how multimodal embeddings represent product similarity" /><p>We used two models that do this:</p><p></p><p>jina-clip-v2</p><p>jina-embeddings-v5-omni-small</p><p>Architecture</p><p>Dual encoder (separate text + image towers)</p><p>Single shared backbone + frozen encoders</p><p>Parameters</p><p>~865M total</p><p>~1.74B total</p><p>Embedding dimensions</p><p>1024</p><p>1024</p><p>Max input</p><p>512×512 images, 8k tokens text</p><p>32k tokens</p><p>Language coverage</p><p>Broad multilingual</p><p>~100 languages</p><p>Modality handling</p><p>Text ↔ image alignment (purpose-built)</p><p>Text, image, audio, video via projectors</p><p><code>jina-clip-v2</code> is a CLIP-style dual encoder: a text tower (Jina XLM-RoBERTa, 561M parameters) and a separate image tower (EVA02-L14, 304M parameters), about 865M parameters in total. The two towers are independently trained but fine-tuned to output to a common semantic space. It produces 1024-dimensional embeddings, handles images up to 512×512 and up to 8k tokens of text, and has broad multilingual support. It has been engineered specifically to support text-to-image, image-to-text, and text-to-text matching.</p><p><code>jina-embeddings-v5-omni-small</code> has a broader scope. It extends the <a href="https://jina.ai/models/jina-embeddings-v5-text-small"><code>jina-embeddings-v5-text</code></a> model to support images, audio, and video by attaching frozen vision and audio encoders to the frozen text backbone. The encoders connect through <em>cross-modal projectors</em>, small trained layers that map each encoder's output into the text model's embedding space. These projectors are the only part of the model to receive additional training. The resulting model produces 1024-dimensional embeddings, supports a 32k-token input context, and covers roughly 100 languages. It encodes queries and documents asymmetrically: a query with the <code>retrieval.query</code> task, a document with <code>retrieval.passage</code>.</p><p>There’s an important functional difference between the two models: <code>jina-clip-v2</code> is really two separate models trained to work together, but <code>jina-embeddings-v5-omni-small</code> uses a single shared backbone that produces embeddings for all its supported media types. Every modality maps into one shared vector space. In principle, it can handle text, images, audio, or video, or combine materials of different media types into one input, yielding one embedding that encompasses all the data. However, there are two important caveats when using <code>jina-embeddings-v5-omni-small</code>: Combining image and text into one input is a documented weak spot for the model, and the Jina API only allows users to embed one modality per request. Theoretically, it can create a joint image and text vector, but in practice, you can’t with the API (and shouldn’t anyway).</p><h2>The ecommerce product dataset we used</h2><p>For this article, we downloaded the <a href="https://www.kaggle.com/datasets/paramaggarwal/fashion-product-images-dataset">Fashion Product Images</a> dataset from Kaggle. It contains roughly 44,000 catalog entries for products from a real fashion retailer, each with a high-resolution photo and structured metadata. We only used the <code>Apparel</code> and <code>Footwear</code> categories (about 30,600 products) and sampled 5,000 from them with a fixed random seed.</p><p>For each product, the dataset contains three records:</p><ul><li><p>Each product is pictured in a 1800×2400 JPEG against a clean background.</p></li><li><p>Metadata with the labels <code>gender</code>, <code>masterCategory</code>, <code>subCategory</code>, <code>articleType</code>, <code>baseColour</code>, <code>season</code>, <code>year</code>, <code>usage</code>, and <code>productDisplayName</code>.</p></li><li><p>A collection of additional informationwith labels like <code>Neckline</code>, <code>Pattern</code>, <code>Sleeve Length</code>, <code>Fit</code>, and <code>Fabric</code>, and a free-text description in English.</p></li></ul><p>For example, item #13885 is labelled "<em>Scullers Men Check Black Shirts</em>," with an accompanying image (see below) and a description that reads <em>"Black and white checked shirt, made of 100% cotton, full length buttoned placket, long sleeves with buttoned cuffs."</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaaf6af3ebb32e4bc/6a6119fbde9481709fd173c2/fcbc520fecf5f9decda6f6b95054ddae972b9283-1500x1999.png" alt="Product photo of a black and white checked men's shirt used as a sample item in a multimodal product search embeddings benchmark" /><h2>How we generated the search queries</h2><p>We generated our test queries without an AI language model, using rules and substitution lists. </p><p>For each product, we started from its color and article type and added one descriptive modifier, drawn at random from the product’s available metadata: neckline, pattern, sleeve length, length, surface styling, fit, fabric, season, or usage. We then reworded the query by substituting words from a fixed synonym table. This avoids making queries that reuse the catalog's own words. For example, ″<em>t-shirt″</em> becomes ″<em>tee″</em>, ″<em>regular fit″ </em>becomes ″<em>classic cut″</em>, ″<em>printed″</em> becomes ″<em>with graphic design″</em>, ″<em>sleeveless″</em> becomes <em>″no sleeves″</em>. The longest phrase with a synonym was replaced first, so we swapped <em>″sports shoes″</em> rather than <em>″shoes″</em>. Because the pipeline is rule-based and seeded, query production is reproducible and all variation is accounted for. German queries were generated the same way, using a German term table, and then a native speaker corrected them for natural retail phrasing. Some examples:</p># usage / occasion
    "in casual style": "for everyday wear",
    "in formal style": "for the office",
    "in sports style": "for working out",
    "in ethnic style": "in traditional wear",
    "in party style": "for a night out"

# article types
    "t-shirt": "tee",
    "trousers": "pants",
    "sweatshirt": "pullover",
    "sweater": "knit pullover",
    "kurta": "tunic",
    "capris": "cropped pants",
    "track pants": "joggers",
    "innerwear vest": "undershirt",
    "briefs": "underwear"
 
# colours (safe near-equivalents)
    "navy blue": "dark blue",
    "off white": "cream",
    "maroon": "deep red",<p>The German queries used their own term table, mapping the same catalog attributes to natural German retail phrasing (later checked by a native speaker). Some examples:</p># usage / occasion
"casual": "im Freizeit-Stil",
"formal": "im Business-Stil",
"sports": "zum Sport",
"party": "für die Party"

# article types
"heels": "Pumps",
"casual shoes": "Freizeitschuhe",
"track pants": "Jogginghose",
"wallets": "Geldbörse"

# patterns
"checked": "mit Karomuster",
"polka dots": "mit Punkten",
"solid": "unifarben"<h2>The six embedding configurations we tested</h2><p>We tested retrieval in six configurations, using the same text queries in each test condition and the same 5,000 product indexed dataset. For both <code>jina-clip-v2</code> and <code>jina-embeddings-v5-omni-small</code>, we tested three different ways of generating embeddings for indexing:</p><ol><li><p><strong>Image-only. </strong>We generated embeddings from the images alone without any other data.</p></li><li><p><strong>Text-only.</strong> We generated embeddings for the free text descriptions alone.</p></li><li><p><strong>Averaged image and text.</strong> For each product, we generated embeddings for the image and text description separately and then averaged the two vectors into one.</p></li></ol><p>We used the Jina API to generate document and query embeddings for product images and free text descriptions, as shown in the code below. All images were resized to fit into a 512x512px square before processing.</p>import requests

def embed(inputs, model, task=None):
    body = {"model": model, "input": inputs}
    if task:
        body["task"] = task
    response = requests.post(
        "https://api.jina.ai/v1/embeddings",
        headers={"Authorization": f"Bearer {JINA_API_KEY}"},
        json=body, timeout=120,
    )
    response.raise_for_status()
    return [d["embedding"] for d in response.json()["data"]]

# Query example using text
query_vec = embed([{"text": "T-Shirt in Grau für den Herbst"}],
                  "jina-embeddings-v5-omni-small", task="retrieval.query")

# Document example using an image. "
# image_base64" is the resized and base64 encoded PNG.
image_vec = embed([{"image": image_base64}],
                  "jina-embeddings-v5-omni-small", task="retrieval.passage")<p>We combined images and texts by embedding them separately, averaging the two vectors, and then normalizing the result so we can speed up calculating cosines. This works for multimodal models because both embeddings share the same semantic space. The sum of the two vectors is a new vector with the semantic features of both.</p><p>This is very easy to do using the numpy package in Python. We used the code below:</p>import numpy as np

def l2(x):  # includes row-wise L2 normalization
    return x / np.linalg.norm(x, axis=1, keepdims=True)

# image_vecs and text_vecs are embeddings of the same products.
# They share one space, so averaging them is meaningful.
combined = l2((l2(image_vecs) + l2(text_vecs)) / 2)<p>
For this article, we did exact retrieval, calculating the cosine between queries and all 5,000 stored product embeddings. In Elasticsearch, we would use a shortcut to approximate the same result. From the ranked results, we calculate Recall@1, Recall@5, Recall@10, Mean Reciprocal Rank (MRR), and Normalized Discounted Cumulative Gain at position 10 (nDCG@10).</p><p>Each query has exactly one correct answer, so Recall@K is the share of queries whose product lands in the top K. MRR scores the results by how close the correct answer is to the top. nDCG@10 is a standard metric that penalizes putting the best answer lower on the results list.</p><h2>Product search benchmark results</h2><p>The table below is the German cross-lingual run. We evaluated German queries to find products with English descriptions:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6dbfbca9f7c7884f/6a6119fcf2e1c472a0fd2a2c/3599b15b5a61b5be1de02ae6b8fedd8f1615410b-1500x700.png" alt="Table comparing jina-clip-v2 and jina-embeddings-v5-omni-small on German product search queries, showing Recall@1, Recall@5, Recall@10, MRR and nDCG@10" /><p>Query benchmark results for <code>jina-clip-v2</code> and <code>jina-embeddings-v5-omni-small</code> using German-language text queries and the images and English descriptions of garments.</p><p>The averaged image/text embeddings score the best, both using <code>jina-clip-v2</code> and <code>jina-embeddings-v5-omni-small</code>. Surprisingly, averaged vectors from <code>jina-clip-v2</code> lead the table. It ranks the correct product first about 1.5 times as often as the image-only setup and significantly more often than the text-only setup. Furthermore, it beats every <code>jina-embeddings-v5-omni-small</code> condition. Results for the same tests using English-language queries:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbb878895472badde/6a6119fdae3a7c168883ef02/5ced60965d12dd3c64961acc92317a74c17fe8eb-1500x700.png" alt="Table comparing jina-clip-v2 and jina-embeddings-v5-omni-small on English product search queries, showing Recall@1, Recall@5, Recall@10, MRR and nDCG@10" /><p>Query benchmark results for <code>jina-clip-v2</code> and <code>jina-embeddings-v5-omni-small</code> using English-language text queries and the images and English descriptions of garments.</p><p>The English run, on the same 5,000 products, tells the same story. The main difference is that the <code>jina-embeddings-v5-omni-small</code> scores are significantly closer to those of <code>jina-clip-v2</code>, although still lower.</p><h3>What the benchmark scores actually mean</h3><p>The relatively low scores in the German and English benchmark tables above are to be expected. This is a real-world dataset full of near duplicates. A search for a <em>"black tee with classic cut"</em> has to sort through dozens of basic black T-shirts, and even on the best ecommerce sites, you would expect a result like this. The important thing to understand is the difference in scores between the different conditions, not their absolute values. Our key finding is that combining text and image embeddings yields better performance than either one alone, highlighting how multimodal AI-driven search can use different information sources to produce better performance than non-multimodal strategies.</p><h3>When a picture is worth a thousand words, and when it isn’t</h3><p>The gain from adding images to text embeddings isn’t evenly distributed. We did a deep dive to see if there was a pattern to the results and discovered a few things:</p><ul><li><p><strong>Images add a lot for visually distinctive items and attributes.</strong> For example, on footwear, image-only retrieval is on par with text-only: 0.029 versus 0.028 for Recall@1. Shoe styles have distinctive shapes, so the picture does the work (image-only 0.029 versus text 0.028). The same holds for visible attributes more broadly (color, pattern, sleeve length), where fusing image and text gives the biggest lift over text-only (0.085 versus 0.073).</p></li><li><p><strong>Images help the least with things the model can’t see.</strong> For example, if we query for fabric types, adding images to embeddings adds next to nothing. Humans and AI models alike struggle to see that something is or isn’t made of linen or polyester or some other fabric type. That information is only in the text description and metadata.</p></li></ul><p>As a rule of thumb, we find that searches for clothing lean more heavily on accurate text descriptions, while footwear leans more on the semantics of images. But in both cases, merging the two embeddings either improves results or doesn’t make them worse. This highlights how use-case–specific considerations drive optimal search strategies.</p><h3>Cross-language queries gain a lot from multimodal embeddings</h3><p>The gap in performance between text-only and combined image and text embeddings using <code>jina-clip-v2</code> is much larger for German queries (0.074 versus 0.065 Recall@1) than for English ones (0.076 versus 0.075). This implies that English queries can take advantage of being in the same language as the product descriptions. Whether that’s due to overlaps in the words or that the model is simply more competent with single-language semantics than cross-language doesn’t matter. But adding images to the text embeddings compensates almost completely for the model’s shortcomings in cross-language retrieval.</p><p>This gap is even larger for <code>jina-embeddings-v5-omni-small</code>. In any kind of cross-language or multilingual context, multimodal embeddings seem to significantly improve retrieval performance.</p><h3>Can AI-generated product descriptions replace human ones?</h3><p>AI-generated descriptions scored worse than human-written ones in our tests. We tried replacing human-authored product descriptions with ones written by <a href="https://jina.ai/models/jina-vlm"><code>jina-vlm</code></a> based on the image. For this test, we used a 1,000-product random subset. The results were much worse than with the original human text. This was what we expected: The automatically generated description was less accurate and less oriented toward the salient features of the product than the human authored one.</p><p>So it turns out that not everyone’s job can be replaced by AI. People who write blurbs for catalogs ought to be safe for now.</p><h2>How should you index your ecommerce data?</h2><p>Our tests aren’t totally scientifically rigorous, but they do offer some insights into the issues you might face if you have similar data. We offer the following as provisional conclusions:</p><ul><li><p><strong>If you have aligned texts and images (and most catalogs do), combine them in your embeddings.</strong> In every case, using a multimodal embedding model like the ones Jina AI by Elastic provides and then averaging the image and text embeddings significantly outperformed all other options. The combination adds no computing costs at inference time but does create additional costs at embedding time. For each product, you’ll need to generate two embeddings and combine them, roughly doubling the cost.</p></li><li><p><strong>Use the right model.</strong> You need to identify a model that supports all the modalities and languages you plan to use. All the inputs have to be embedded in the same semantic vector space or none of this will work. It won’t do to get two single-modality models or multiple language-specific text models, average their outputs, and hope for the best. Jina AI by Elastic currently supports texts in up to 100 languages, including computer code and technical terminology images of all kinds, such as scans and infographics, as well as audio and video data. You can change your mind about your models later, but only if you’re willing to reindex all your data.</p></li><li><p><strong>Whatever you pick, test it on your own data.</strong> The only way to know what the best model is for you, your data, and your use case is to try them out. We were very surprised that our older CLIP-style model outperformed our latest on this dataset, but it was trained almost specifically for this use case. Your data and use case could easily show the opposite. This result is from one catalog, with one style of photography and one kind of query. The ranking between two models can flip with a different domain, image style, or query mix. Run the same sort of benchmark on a sample of your own products before you commit. It’s the only way to know which model really fits your case.</p></li><li><p><strong>Use generated descriptions to fill gaps, not to replace good text. </strong>AI isn’t a replacement for good work done by conscientious people. Replacing human-made descriptions with machine-made ones made results worse. AI should only replace humans when it has to, like when data is missing or needs to be augmented and it’s impractical to have humans fill in the gaps. Yes, we have tools that work in those situations, but they aren’t necessarily good substitutes. They’re OK substitutes, sometimes.</p></li></ul><p><strong>Average your embeddings.</strong> Semantic embeddings are very robust, and averaging them is a relatively cheap solution that doesn’t affect inference-time costs at all. This is a real boon over methods that index each modality separately and require multiple queries to satisfy a single request. But they do require compatible multimodal models.</p><h2>Limitations of this product search benchmark</h2><p>A few things to keep in mind before generalizing too much from this experiment:</p><p>This article doesn’t perfectly match real-world use cases. Human users make messier queries and have more ambiguous matching criteria. The queries we used were generated specifically for this data. A test with actual customer-made queries from system logs would be a better one.</p><p>Embedding averaging isn’t the same as a true joint embedding. Embedding models rely on the different parts of their input to interact in order to extract a semantic representation of the whole. The approach used here is a bit of a hack, one that relies on the robust nature of semantic embedding spaces to get the job done. We expect future models from Jina AI to produce better embeddings by supporting more than one input modality at a time.</p><p>This is one dataset in one domain with distinctive features. Fashion photography is very foreground-focused and the descriptions are attribute-rich. Other kinds of materials, even for ecommerce, may look very different. It’s important to test as much as possible with your own data or something very similar.</p><h2>How to get started with multimodal product search embeddings</h2><p><code>jina-clip-v2</code> and <code>jina-embeddings-v5-omni-small</code> are available through the <a href="https://jina.ai/api-dashboard">Jina API</a>, <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a> (EIS), and <a href="https://huggingface.co/jinaai">Hugging Face</a>. The omni models are free to download under a CC-BY-NC-4.0 license and free for noncommercial use, with commercial licensing through Elastic. If you use Elasticsearch, EIS exposes both models through the <code>semantic</code> field type, with non-text media in Base64 encoding.</p><p>The takeaway from this article is intended to be practical and actionable: For product search, a picture and its description aren’t the same signal. Both add information, and you don’t have to pick one. Average your multimodal embeddings, and benchmark the results with your own data to get a good picture of the kinds of results you can expect.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/multimodal-embeddings-ecommerce-product-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/multimodal-embeddings-ecommerce-product-search</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[Jina AI]]></category>
    <dc:creator><![CDATA[Sofia Vasileva]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt93506c893fb19bb9/6a6119fea2ee17d20274edab/b08939addf8cfa92dd0b31211ff0a86511eb582c-1280x720.png" length="0" type="image/png"/>
    <pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to build search analytics on Elastic using OpenTelemetry, no extra pipeline required]]></title>
    <description><![CDATA[How to instrument your search application to use modern Open Telemetry standard to drive insights in to your search and users.]]></description>
    <content:encoded><![CDATA[<p>Know which searches drive revenue and which ones lose customers, no separate analytics pipeline required. Add search behavior as attributes to your existing application requests using OpenTelemetry (OTel), and query click-through rate, zero-result queries, and conversion funnels in Elastic and unlock modern Application Performance Monitoring at the same time. If you already run Elastic, you have everything you need. This post shows you how.</p><h2>What you'll discover</h2><p>In this post, you'll learn:</p><ul><li><p>The value of a comprehensive search analytics stack.</p></li><li><p>The case for using a modern observability standard for search analytics.</p></li><li><p>The benefits of combining search analytics and observability.</p></li><li><p>Practical first steps for getting started.</p></li></ul><h2>The search analytics challenge</h2><p>Your product manager wants to know which searches are driving purchases and which ones are losing customers. To answer, you have to grep through 5GB of Nginx logs, join it with a CSV of purchase data, and pray that the timestamps align. There has to be a better way.</p><p>The questions seem simple:</p><ul><li><p>Which searches lead to revenue, and which ones lose customers?</p></li><li><p>Are users finding what they're looking for?</p></li><li><p>Which queries are failing?</p></li><li><p>How do you connect search behavior to business outcomes?</p></li></ul><p>Getting answers is harder. Traditional approaches involve stitching together custom event pipelines, third-party analytics tools, and hand-rolled dashboards. You end up with data silos: search logs here, click events there, and business metrics somewhere else entirely.</p><p>What if there was a simpler way?</p><h2>Enter OpenTelemetry (and why it fits search)</h2><p>If you come from a search engineering background, you may not have crossed paths with <em>application performance monitoring (APM)</em><strong>,</strong> which is the practice of instrumenting your code to understand how it behaves in production. The key idea behind modern APM is the <em>trace</em>, a structured record that follows a single request as it flows through your system, from the initial API call through every database query and service hop. Unlike logs, which are isolated lines of text, traces connect the dots across your entire stack.</p><p>OpenTelemetry (OTel) has become the industry standard for producing these traces. Born from the merger of OpenTracing and OpenCensus, it provides a vendor-neutral way to collect traces, metrics, and logs. The core idea is simple: Instrument your code once, and send data anywhere.</p><p>Elastic has invested heavily in this standard as a contributor. Elastic actively aligns its <a href="https://www.elastic.co/guide/en/ecs/current/ecs-reference.html">Elastic Common Schema (ECS)</a> with <a href="https://opentelemetry.io/docs/specs/semconv/">OTel Semantic Conventions</a> so that field names are consistent across both, and it donated the Universal Profiling Agent to the OTel project to make profiling a core OTel signal.</p><p>Because of this alignment, Elastic natively understands OTel data. OTel transmits data using the OpenTelemetry Protocol (OTLP). On Elastic Cloud, the managed OTLP endpoint (mOTLP) accepts OTLP spans directly from your SDK, without requiring an intermediate collector. For self-managed deployments, the <a href="https://www.elastic.co/docs/reference/edot-collector">Elastic Distribution of OpenTelemetry (EDOT) Collector</a> provides the same path. This lets you use standard OTel traces for search analytics, without any additional infrastructure.</p><h3>How traces become analytics</h3><p>A <em>trace</em><em></em>is a collection of spans, where each span represents one operation (an API call, a database query, or a search request). Each span can carry arbitrary <em>attributes</em><em></em>(key-value pairs that describe what happened).</p><p>This is where it gets interesting for search. Search behavior can ride on the same spans OTel already generates for APM, with no new attributes required beyond what you add to existing calls.</p><ul><li><p><code>search.query</code>: What the user searched for.</p></li><li><p><code>search.result_count</code>: How many results came back.</p></li><li><p><code>search.result_click_position</code>: Which result they clicked.</p></li></ul><p>By extending OTel with attributes like these, we can capture rich behavioral data using the same infrastructure that powers application monitoring. You don’t need a new pipeline or a new vendor; you’re simply adding new attributes to existing spans.</p><h2>A unified approach: OTel + Elastic + ES|QL</h2><p>If you're building search, there's a good chance you're already running Elasticsearch. It powers search for thousands of ecommerce sites, content platforms, and enterprise applications. It’s often chosen for its speed, flexibility, and features like vector search, Learning To Rank (LTR), and query rules. What's less well-known in the search community is that Elastic also has over a decade of investment in observability. Elastic APM, logging, and infrastructure monitoring are used at scale across industries.</p><p>Running search and observability on the same platform is what makes this approach work. The same platform that runs your search engine can also analyze how people use it. Elastic natively ingests OTel data via its managed OTLP endpoint, and Elasticsearch Query Language (ES|QL), Elasticsearch's piped query language, makes it easy to explore and aggregate that data in Kibana.</p><p>Together, they offer a compelling approach to search analytics:</p><ul><li><p>Instrument once: Add OTel attributes to your search requests and user interactions.</p></li><li><p>Store centrally: Traces flow to Elastic via mOTLP alongside your other application telemetry.</p></li><li><p>Query flexibly: Use ES|QL in Kibana to calculate metrics, slice by any dimension, and explore patterns.</p></li></ul><p>You don’t need a separate analytics pipeline or a dedicated click-tracking service. You just need your search application with the proper instrumentation.</p><h2>What you can measure</h2><p>With the right instrumentation in place, you can answer the questions that matter:</p><h3>Search quality metrics</h3><ul><li><p>CTR: What percentage of searches result in a click? Low CTR might indicate poor relevance or unappealing result presentation.</p></li><li><p>Mean Reciprocal Rank (MRR): When users click, how far down the results list do they go? An MRR of 1.0 means every click is on position 1. If everybody clicks on the second position result, your MMR would be 0.5. Higher is better.</p></li><li><p>Zero Results Rate: What percentage of searches return nothing? These are your content gaps or query parsing failures. Every zero-result search is a missed opportunity.</p></li></ul><h3>Query-level analysis</h3><p>Beyond aggregate metrics, you can drill into specific queries:</p><ul><li><p>Top queries by volume: What are users actually searching for?</p></li><li><p>Queries with low CTR: Where is relevance failing?</p></li><li><p>Zero-result queries: What content is missing from your index?</p></li><li><p>Click position distribution: Are clicks concentrated at the top, or are they scattered?</p></li></ul><h3>Business impact</h3><p>Search doesn't exist in a vacuum. By tracking the journey from search to conversion, you can connect relevance to revenue:</p><ul><li><p>Which searches lead to add-to-cart events?</p></li><li><p>What's the conversion rate for searches versus browsing?</p></li><li><p>Which queries drive the most revenue?</p></li></ul><h3>Training data for machine learning</h3><p>The same click data that measures search quality can also train models to improve it. Click positions and frequencies can be transformed into judgment lists for LTR models, turning user behavior into relevance signals.</p><h3>Search analytics dashboard example in Kibana</h3><p>Here's what this looks like in practice. This Kibana dashboard is powered entirely by OTel traces and ES|QL, with no custom pipeline or separate analytics service:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9ed7033e646aa70d/6a55ea11924ca930505b6656/2d6413dd8646aab9b59eed49e1814cc07a7c75ac-2048x1989.png" alt="Search analytics dashboard in Kibana showing click-through rate, zero-result rate and revenue metrics from OpenTelemetry traces" /><p>Every panel on this dashboard comes from the same index. The headline metrics use value-based coloring to surface health at a glance: CTR and MRR are green (<em>healthy</em>), while the Zero Results Rate is red (<em>needs attention</em>). Below, you can see which queries drive the most clicks, which ones return nothing, and how searches convert through the funnel to revenue.</p><p>Notice the SLO cards at the bottom, with targets like "99% of searches under 250ms" that let you track reliability as a measurable commitment. That's a hint at something bigger.</p><h2>How does search analytics data flow into Elastic?</h2><p>The data flow is straightforward:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt29684c7db676ddcb/6a55ea1491ee6601e308f364/efadb0c12fae9cfc21a6fad2ace0fd256b2f3a69-1076x312.png" alt="" /><ul><li><p>Browser<strong>:</strong> User searches and clicks. The front end can send events directly via OTel or relay them through the back end.</p></li><li><p>Back end: Your search API, instrumented with OTel. Each search request becomes a span, with attributes like <code>search.query</code>, <code>search.result_count</code>, and <code>search.query_id</code>.</p></li><li><p>Elastic (mOTLP): Receives and stores the OTel traces via the mOTLP endpoint. Point <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> at your Elastic deployment with <code>ApiKey</code> auth. This doesn’t require a collector or transformation.</p></li><li><p>Kibana: Where you explore and visualize your data. Use ES|QL in Discover to run ad hoc queries, use the APM UI to inspect individual traces, build dashboards for ongoing monitoring, and set up alerts when metrics degrade.</p></li></ul><h2>How OTel attributes become queryable data</h2><p>Before we query, you need to understand how Elastic stores these OTel attributes, because it affects how you write your ES|QL.</p><p>With OTel-native ingestion, custom attributes are stored directly under <code>attributes.*</code>, and their original dot notation is preserved. There’s no underscore translation and no type splitting, and strings, numbers, and booleans all live in the same namespace.</p><p>OTel attribute</p><p>Type</p><p>ES|QL field</p><p>`search.query`</p><p>string</p><p>`attributes.search.query`</p><p>`search.result_count`</p><p>number</p><p>`attributes.search.result_count`</p><p>`search.first_click`</p><p>boolean</p><p>`attributes.search.first_click`</p><p>This mapping is as simple as it looks: The attribute name in your code is the field name in your query. We'll cover more examples in the next post, when you're writing queries hands-on.</p><h2>ES|QL: Your search data in five lines</h2><p>ES|QL is Elasticsearch's piped query language. You start with a data source, and then, one step at a time, pipe it through filters, aggregations, and calculations.</p><p>Here's a single query that calculates your CTR, the percentage of searches that result in at least one click:</p>FROM traces-generic.otel-default
| WHERE (name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)<p>Reading from the top down: Pull all search spans and first-click spans from <code>traces-generic.otel-default</code>, count each type separately with <code>COUNT(CASE(...))</code>, and then divide to get CTR. We label the first click on each search with <code>search.first_click</code> at instrumentation time, so the query just counts it and no deduplication is needed. One query provides one counted result, which gives you the CTR.</p><p>That's the pattern. ES|QL reads from the top down, and each pipe step transforms the data. In the next post, we'll run six queries like this, including top queries, zero-results analysis, search performance, and volume over time, all against real data. Stay tuned for it!</p><h2>From search analytics to full-stack observability</h2><p>By choosing OTel and Elastic for search analytics, you're investing in infrastructure that serves multiple needs.</p><p>The same traces that calculate your CTR also give you operational visibility, without requiring any extra instrumentation:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd35ddad1dffaa94d/6a55ea1663060bba00985895/f360b6c519b59586416c55467c480f670e16aec8-2048x1033.png" alt="Kibana SLO dashboard showing search latency, quality and availability targets alongside p50, p95 and p99 latency from OpenTelemetry traces" /><p>The search dashboard's SLO cards track three targets: your search latency target (99% under 250ms), search quality (85% returning results), and availability (99.9% success rate). Below them, latency percentiles use the same value-based coloring. Green means <em>healthy</em>, yellow means <em>watch it</em>, and red means <em>needs attention</em>.</p><p>The operational charts break down where time is spent (Elasticsearch query time versus application overhead) and how latency trends over time. All of this comes from the same <code>search.*</code> attributes you added for analytics, and no extra instrumentation is required.</p><p>This is the practical advantage of the OTel approach:</p><ul><li><p>Search latency: How long are queries taking? Where are the slow ones?</p></li><li><p>Error rates: Are searches failing? If so, why?</p></li><li><p>Dependencies: How does Elasticsearch performance affect your search API?</p></li><li><p>SLOs: Set targets for search latency or Zero Results Rate.</p></li><li><p>Alerting: Get notified when metrics degrade.</p></li><li><p>Anomaly detection: Let machine learning (ML) spot unusual patterns in search behavior.</p></li></ul><p>Start with search analytics, and you can extend that foundation into full-stack observability. It’s a single instrumentation investment that results in multiple returns.</p><h2>What OTel attributes do you need for search analytics?</h2><p>To get started, you'll want to capture a few key attributes on your search spans:</p><p>Attribute</p><p>Type</p><p>Purpose</p><p>`search.query`</p><p>string</p><p>The user's search terms</p><p>`search.result_count`</p><p>number</p><p>How many results returned (0 = zero-result search)</p><p>`search.query_id`</p><p>string</p><p>Unique query identifier (derived from trace ID)</p><p>`search.result_click_id`</p><p>string</p><p>Which result was clicked</p><p>`search.result_click_position`</p><p>number</p><p>Position of the clicked result (1-indexed)</p><p>For click tracking, add these attributes to click event spans:</p><p>Attribute</p><p>Type</p><p>Purpose</p><p>`search.result_click_id`</p><p>string</p><p>Document ID that was clicked</p><p>`search.result_click_position`</p><p>number</p><p>Position in results (1-indexed)</p><p>`search.action`</p><p>string</p><p>Event type: click, impression, add_to_cart, purchase</p><p>`search.query_id`</p><p>string</p><p>Links this interaction back to the originating search</p><p>`search.first_click`</p><p>boolean</p><p>`true` on the first click per search (for CTR without deduplication)</p><p>We use the <code>search.*</code> namespace following OTel's convention of domain-specific prefixes (<code>http.*</code>, <code>db.*</code>, <code>messaging.*</code>). While OTel doesn't yet have standardized search conventions, <code>search.*</code> is self-describing and vendor-neutral. Our naming is informed by the <a href="https://www.ubisearch.dev/">User Behavior Insights (UBI)</a> standard, which defines a detailed schema for search events. We reference it for event structure without coupling our instrumentation to it.</p><p>If you don't have APM set up yet, you can capture the same <code>search.*</code> attributes as OTel log records instead of spans. The analytics concepts are identical; you just query <code>logs-generic.otel-default</code> instead of <code>traces-generic.otel-default</code>, and the <code>attributes.*</code> field paths are the same. See the <a href="https://www.elastic.co/docs/solutions/observability/logs/stream-any-log-file-using-edot-collector">OpenTelemetry logs with Elastic</a> documentation for setup details.</p><h2>What's next in this search analytics series</h2><p>This post introduced the concept of using OTel instrumentation and ES|QL queries to build search analytics on Elastic. The rest of the series goes from concept to production. Stay tuned for it!</p><h2>Get started with search analytics on Elastic</h2><p>Ready to add search analytics to your application?</p><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project</a>: Clone, configure, and have search analytics flowing in 10 minutes.</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/open-telemetry.html">OpenTelemetry and Elastic</a>: How to send OTel data to Elastic via the managed OTLP endpoint.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation</a>: Learn the query language.</p></li><li><p><a href="https://www.elastic.co/observability">Elastic Observability</a>: The broader platform.</p></li></ul><p><em>This is the first post in a six-part series on search analytics with OpenTelemetry and Elastic. Next up: Instrument your search API: Add search attributes to your back end, and run your first ES|QL queries.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3e9a759480aa699d/6a55ea197f36cfa01cbe8ebe/0c4cf4b77045dd306e6e5b1254b34d7abd30f0e0-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Does MCP make search obsolete? Not even close]]></title>
    <description><![CDATA[Explore why search engines and indexed search remain the foundation for scalable, accurate, enterprise-grade AI, even in the age of MCP, federated search, and large context windows.]]></description>
    <content:encoded><![CDATA[<p>With the rise of large language models (LLMs), agent frameworks, and new protocols like Model Context Protocol (MCP), a provocative question is starting to surface:</p><strong>Do we still need a search engine at all?</strong><p>If agents can call tools on demand and models can reason over massive context windows, why not just fetch data live from every system and let the LLM figure it out?</p><p>It’s a reasonable question. It’s also the wrong conclusion.</p><p>The reality is that MCP and agent tooling don’t eliminate the need for search. They make the quality of search <strong>more critical than ever</strong>. In this blog, we’ll explore why MCP, federated search, and large context windows don’t replace search engines and why indexes remain the foundational layer for scalable, accurate, enterprise-grade AI.</p><h2><strong>What MCP actually is (and what it is not)</strong></h2><p>MCP is a <strong>coordination protocol</strong>. It standardizes how an agent requests information or actions from external systems.</p><p>What MCP <em>doesn’t</em> do:</p><ul><li><p>Rank results across systems.</p></li><li><p>Understand relevance across heterogeneous data.</p></li><li><p>Normalize schemas or metadata.</p></li><li><p>Data transformations or enrichments at scale.</p></li><li><p>Apply consistent security and permissions.</p></li><li><p>Optimize for latency, cost, or scale.</p></li></ul><p>In other words, <strong>MCP tells agents </strong><em><strong>how</strong></em><strong> to ask for data, not </strong><em><strong>which</strong></em><strong> data matters most</strong>.</p><h2><strong>Modern retrieval requires query intelligence, not just data access</strong></h2><p>In modern enterprise search architectures, retrieval quality is determined long before a query reaches an index. Raw queries — especially those generated by agents — may be incomplete, overly literal, schema-driven rather than intent-driven, and at times syntactically invalid.</p><p>This is why mature search platforms introduce a query intelligence layer that performs query rewriting, entity normalization, synonym expansion, and intent disambiguation before retrieval even begins.</p><p>For example, an agent-generated request such as: “Show severity 2 authentication failures from last sprint” may be rewritten to include authentication synonyms (login, SSO, OAuth), normalized severity mappings, and sprint-to-date-range translation. The result is not just more matches — it is more <em>relevant</em> matches.</p><p>In enterprise AI, retrieval is not a single step. It is a controlled pipeline.</p><p>This distinction is crucial because once MCP-based agents start pulling information live from multiple tools, they recreate a familiar pattern under a new name: <strong>federated search</strong>.</p><h2><strong>MCP-based retrieval is federated search in disguise</strong></h2><p>Federated search isn’t new. Enterprises have tried it for decades.</p><p>The model is simple:</p><ul><li><p>Send the user’s query to multiple systems in parallel (SharePoint, GitHub, Jira, customer relationship management [CRM]).</p></li><li><p>Collect the responses.</p></li><li><p>Merge and present the results.</p></li></ul><p>MCP-driven tool calls follow the same pattern, except that the caller is now an agent instead of a user interface.</p><p>And the same problems resurface.</p><h2><strong>Why federated search breaks down at enterprise scale</strong></h2><ul><li><p><strong>Latency becomes unpredictable:</strong> A federated query is only as fast as its slowest system. Enterprise systems can have wildly different response times and rate limits, so federated queries tend to be <strong>slow and jittery</strong>. Agents must wait for multiple round trips before reasoning can even begin. The result is a laggy experience and unpredictable wait times.</p></li><li><p><strong>Relevance is fragmented:</strong> Because each system ranks results on its own, there’s no unified relevance model. Federated search <strong>cannot apply a single ranking or semantic understanding across all content</strong>, so results often seem disjointed or incomplete. Agents may retrieve <em>correct</em> information but not the <em>most useful</em> information.</p></li><li><p><strong>Context is shallow and incomplete: </strong>Federated systems typically expose only what’s directly accessible through an API call.They rarely surface:</p><ul><li><p>Usage signals, like clicks, dwell time, recency of access, popularity, or authority.</p></li><li><p>Relationships between documents across different systems to correlate the insights.</p></li><li><p>Organizational knowledge beyond a single silo.

This strips agents of the broader context required for high-quality reasoning.
</p></li></ul></li><li><p><strong>Limited filtering and features:</strong> In a federated setup, you can only filter on fields that every system supports (the “lowest common denominator”). If one system doesn’t support a particular filter or facet, you lose that functionality entirely. This severely limits rich search features, like date ranges, categories, or tags.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6f82101d3ef2019b/6a170ca96f7f04f0219148ac/25bb778f4da9a3cb4f0d4e10af66221b8af73900-1376x768.jpg" alt="Federated search workflow" /><h2><strong>The power of an indexed search</strong></h2><p>Search engines achieve millisecond-level retrieval at massive scale by using specialized data structures, including inverted indexes for lexical search and k‑dimensional trees (k-d trees) for vector-based retrieval. The approach is to <strong>crawl or ingest every source into search engines</strong>, creating a central place of company knowledge. This brings big advantages:</p><ul><li><p><strong>Speed by design:</strong> Searching an index is lightning fast. Queries hit inverted indexes and specialized data structures, avoiding the need to poll each backend system.</p></li><li><p><strong>Relevance that compounds over time:</strong> Search engines that support <strong>semantic search </strong>are capable of comprehending the intent, and machine learning models can rerank results for enterprise contexts. In one Elastic <a href="https://www.elastic.co/blog/elastic-generative-ai-experiences?">experiment</a>, Elastic users see more accurate results when combining vector search with a question-answering (QA) model to extract answers. It gives better precision than keyword matching.</p></li><li><p><strong>Advanced features:</strong> Elastic’s <a href="https://www.elastic.co/search-labs/blog/rag-graph-traversal#:~:text=Retrieval,for%20deeper%2C%20more%20contextual%20retrieval">Graph retrieval augmented generation (RAG) solution</a> shows how structuring an index as a knowledge graph can power more contextual retrieval. In other words, indexes aren’t just backward-looking dumps of text; they can also encode relationships and ontologies that let AI connect the dots across documents.</p></li><li><p><strong>Permission-aware search:</strong> Enterprise AI cannot compromise on security. Indexed search allows:</p><ul><li><p><a href="https://www.elastic.co/docs/reference/search-connectors/document-level-security">Document-level security.</a></p></li><li><p><a href="https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/user-roles#roles">Role-based access control.</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/rag-and-rbac-integration">Permission-aware retrieval for RAG and agents.</a></p></li></ul></li></ul><p>Agents see only what users are allowed to see, without leaking data into model prompts or training. Elasticsearch is suitable for the indexed search layer in the diagram below, as it provides the essential components for context engineering.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb88e668cb4814bee/6a170cab8b73cb5d6918a090/8785e7806616273d086a90b3540273fb26d045ae-1392x768.jpg" alt="Essential components of context engineering, highlighting the Elasticsearch role in the indexed search layer." /><h2><strong>Retrieval consistency through search templates and governed execution</strong></h2><p>At scale, retrieval must be predictable, secure, and repeatable. This is where <a href="https://www.elastic.co/docs/solutions/search/search-templates">search templates</a> become critical.</p><p>Search templates act as retrieval contracts between applications, agents, and the search platform. Instead of dynamically constructing queries at runtime, agents invoke pre-defined retrieval patterns that enforce:</p><ul><li><p>Consistent relevance logic</p></li><li><p>Mandatory security filters</p></li><li><p>Cost and latency guardrails</p></li><li><p>Business-specific ranking rules</p></li><li><p>Explicit index and field scope boundaries</p></li></ul><p>In MCP-driven architectures, this becomes even more important. Agents should not dynamically invent retrieval strategies. Instead, MCP tool calls can map directly to approved search templates, ensuring that every retrieval request adheres to enterprise relevance and governance standards.</p><p>This approach shifts retrieval from ad-hoc query execution to controlled retrieval orchestration.</p><h2><strong>Retrieval is now a multi-layer engineering discipline</strong></h2><p>Modern enterprise retrieval is no longer a simple query-to-index operation. It typically includes multiple coordinated layers:</p><ul><li><p>Query understanding — rewriting, expansion, entity resolution</p></li><li><p>Retrieval strategy selection — hybrid search, vector search, graph retrieval, or synthetic query techniques such as Hypothetical Document Embeddings (HyDE), where the system generates a representative answer or expanded context first and retrieves documents using that richer semantic signal.</p></li><li><p>Execution governance — templates, security enforcement, and performance guardrails</p></li><li><p>Ranking and re-ranking — blending lexical precision, semantic similarity, and interaction-derived relevance signals such as click-through patterns, dwell time, and document usage frequency.</p></li></ul><p>When these layers are implemented upstream, agents receive clean, high-confidence context rather than raw, fragmented data.</p><p>This is what makes large-scale agent systems reliable in production environments.</p><h2><strong>Advanced retrieval techniques improve context quality before reasoning begins</strong></h2><p>Modern retrieval systems increasingly use AI-assisted techniques to improve recall and semantic coverage before ranking is applied.</p><p>One example is <a href="https://medium.com/@nirdiamant21/hyde-exploring-hypothetical-document-embeddings-for-ai-retrieval-cc5e5ac085a6">Hypothetical Document Embeddings (HyDE)</a>. Instead of embedding only the original query, the system first generates a hypothetical answer or expanded context, embeds that representation, and retrieves documents based on that richer semantic signal.</p><p>This is particularly useful in enterprise environments where:</p><ul><li><p>Users or agents may not know the exact terminology</p></li><li><p>Knowledge is distributed across silos</p></li><li><p>Important context is implied rather than explicitly stated</p></li></ul><p>Techniques like HyDE improve the probability that relevant documents are retrieved even when the original query is underspecified.</p><p>This reinforces a key principle of enterprise AI: better context retrieval produces better reasoning outcomes.</p><h2><strong>Agents aren’t data engineers; they’re reasoning systems</strong></h2><p>They shouldn’t be responsible for stitching together raw data, reconciling schemas, or compensating for poor retrieval.</p><p>This is where a search platform such as <strong>Elasticsearch</strong> becomes foundational.</p><p>By ingesting data once and normalizing it upstream (through pipelines, mappings, enrichment processors, and prebuilt indexes), Elasticsearch resolves schema mismatches, joins signals across sources, and materializes retrieval-ready views of the data. At query time, the agent receives clean, ranked, semantically enriched results rather than fragmented raw records.</p><p>For example, instead of an agent pulling independently from CRM, ticketing, and documentation systems and attempting to reconcile customer IDs, timestamps, and formats in real time, Elasticsearch can pre-index these sources into a unified customer interaction index with hybrid (keyword + vector) search and relevance ranking. The agent then queries a single, coherent interface and immediately reasons over the most relevant context.</p><p>This separation of concerns, that is, <strong>Elasticsearch handling data integration and retrieval, and agents focusing on reasoning, planning, and decision-making</strong>,is what makes agent systems scalable, reliable, and production ready.</p><h2><strong>Elastic’s role in the AI stack</strong></h2><p>Elastic sits at the intersection of search and AI by design.</p><ul><li><p><strong>Connectors and crawlers</strong> ingest data continuously from enterprise systems.</p></li><li><p><strong>Semantic and vector search</strong> enable intent-based retrieval.</p></li><li><p><strong>Hybrid search</strong> blends lexical precision with semantic understanding.</p></li><li><p><strong>RAG workflows</strong> ground LLMs in authoritative, permission-aware data.</p></li></ul><p>Elastic does not compete with agents or MCP. It <strong>makes them effective</strong>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt950398e2b4ab71f8/6a170cac2b835fb826f4b26b/193da239544ce858416db845f9fc34c7c0e9b6f9-1920x1080.png" alt="AI-native experiences powered by tools and agents, enabled by the platform, and built on enterprise data." /><h2><strong>Bigger models don’t eliminate retrieval</strong></h2><p>Some have wondered whether huge new LLMs can bypass traditional search, perhaps by letting the model read <em>everything</em> in one go. Large context windows feel powerful, but they introduce:</p><ul><li><p>Higher latency.</p></li><li><p>Higher cost.</p></li><li><p>Lower precision due to noise.</p></li><li><p>A higher propensity for confusion, context clash, and context poisoning.</p></li></ul><p>RAG wins because it filters first and then reasons.In another <a href="https://www.elastic.co/search-labs/blog/rag-vs-long-context-model-llm#:~:text=,context%20approach%20led%20to%20inaccuracies">Elastic Search Labs experiment</a>, RAG achieved answers in about <strong>1 second</strong>, versus 45 seconds for the raw-LM approach, at <strong>1/1250th</strong> the cost, and with far higher accuracy. In other words, giving an LLM a million tokens of documents is slower, more expensive, and actually <em>less precise</em> than filtering through an index first.</p><h2><strong>Conclusion: MCP changes the interface, not the fundamentals</strong></h2><p>MCP is a meaningful step forward in how agents interact with tools. But it doesn’t replace the need for fast, relevant, governed retrieval.</p><p>In enterprise AI:</p><ul><li><p>Context quality determines answer quality.</p></li><li><p>Indexes create that context.</p></li><li><p>Search is the foundation, not the legacy.</p></li></ul><p>Indexes aren’t obsolete in the era of MCP. They’re <strong>the reason that MCP-based agents can work at all</strong>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/future-of-search-engines-indexed-search-mcp</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/future-of-search-engines-indexed-search-mcp</guid>
    <category><![CDATA[Inside Elastic]]></category>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[Agentic AI]]></category>
    <dc:creator><![CDATA[Dayananda Srinivas]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1caa3ee906789415/6a170cae2b835f9a90f4b26f/5b8af1c3ca51f2c038406c714eb9a71b696bbc5a-1999x1091.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 05 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[An open‑source Hebrew analyzer for Elasticsearch lemmatization]]></title>
    <description><![CDATA[An open-source Elasticsearch 9.x analyzer plugin that improves Hebrew search by lemmatizing tokens in the analysis chain for better recall across Hebrew morphology.]]></description>
    <content:encoded><![CDATA[<p>Hebrew is morphologically rich: Prefixes, inflections, and clitics make exact-token search brittle. This project provides an open-source Hebrew analyzer plugin for Elasticsearch 9.x that performs neural lemmatization in the analysis chain, using an embedded DictaBERT model executed in-process via ONNX Runtime with an INT8-quantized model.</p><h2><strong>Quick start</strong></h2><p>Download the relevant <a href="https://github.com/liladler/elasticsearch-analysis-hebrew-plugin/releases">release</a> or build and install (Linux build script generates Elasticsearch‑compatible zip):</p>./scripts/build_plugin_linux.sh<p>Install in Elasticsearch:</p>/path/to/elasticsearch/bin/elasticsearch-plugin install file:///path/to/heb-lemmas-embedded-plugin-&lt;ES_VERSION&gt;.zip<p>Test:</p>curl -k -X POST "https://localhost:9200/_analyze" \
  -H "Content-Type: application/json" \
  -u "elastic:&lt;password&gt;" \
  -d '{"tokenizer":"whitespace","filter":["heb_lemmas","heb_stopwords"],"text":"הילדים אוכלים את הבננות"}'<h2>
Why Hebrew search is different</h2><p>Hebrew is morphologically rich: Prefixes, suffixes, inflection, and clitics all collapse into a single surface form. That makes naive tokenization insufficient. Without true lemmatization, search quality suffers; users miss relevant results due to simple variations in form. This project tackles that by embedding a Hebrew lemmatization model inside the analyzer itself, so every token passes through a neural model before indexing and querying.</p><h3>Example</h3><p>Users may search for the lemma “בית” (house), but documents might contain:</p><ul><li><p>בית (a house)</p></li><li><p>בבית (in the house)</p></li><li><p>לבית (to the house)</p></li><li><p>בבתים (in houses)</p></li><li><p>לבתים (to houses)</p></li></ul><p>Without lemmatization, these become different surface tokens; lemmatization normalizes them toward the same lemma (בית), improving recall:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbce7cc588b978eca/6a170e4facf0888338be9bec/d3dfd40569b3f4bfc79df639a49ae995e92b0bc1-1600x983.png" alt="Hebrew analyzer for Elasticsearch lemmatization" /><h2>What this plugin does</h2><p>Rather than relying on rule-based stemming, the analyzer runs a Hebrew lemmatization model as part of the Elasticsearch analysis chain and emits one normalized lemma per token. Because the model is neural, it can use local context within each analyzed segment to choose a lemma in ambiguous cases—while still producing stable tokens that work well for indexing and querying. The analyzer:</p><ul><li><p>Runs a Hebrew lemmatization model inside Elasticsearch.</p></li><li><p>Produces better normalized tokens for Hebrew text.</p></li><li><p>Supports stopwords and standard analyzer pipelines.</p></li></ul><h2>The result: Fast, reliable lemmatization</h2><p>This analyzer is optimized for real‑world throughput:</p><ul><li><p>ONNX Runtime in‑process inference.</p></li><li><p>INT8-quantized model for lower latency and memory footprint.</p></li><li><p>Java Foreign Function Interface (FFI) for high‑performance native inference.</p></li></ul><p>The result: fast, reliable lemmatization with predictable operational behavior.</p><p>To evaluate performance, we ran a benchmark in a Docker container (4 cores, 12 GB RAM) on 1 million large documents (5.7 GB of data) from the Hebrew Wikipedia dataset. You’ll find the results below:</p><p>Metric (search)</p><p>Task</p><p>Value</p><p>Unit</p><p>Min throughput</p><p>hebrew-query-search</p><p>409.75</p><p>ops/s</p><p>Mean throughput</p><p>hebrew-query-search</p><p>490.65</p><p>ops/s</p><p>Median throughput</p><p>hebrew-query-search</p><p>491.85</p><p>ops/s</p><p>Max throughput</p><p>hebrew-query-search</p><p>496.13</p><p>ops/s</p><p>50th percentile latency</p><p>hebrew-query-search</p><p>7.02242</p><p>ms</p><p>90th percentile latency</p><p>hebrew-query-search</p><p>10.7338</p><p>ms</p><p>99th percentile latency</p><p>hebrew-query-search</p><p>19.0406</p><p>ms</p><p>99.9th percentile latency</p><p>hebrew-query-search</p><p>27.165</p><p>ms</p><p>50th percentile service time</p><p>hebrew-query-search</p><p>7.02242</p><p>ms</p><p>90th percentile service time</p><p>hebrew-query-search</p><p>10.7338</p><p>ms</p><p>99th percentile service time</p><p>hebrew-query-search</p><p>19.0406</p><p>ms</p><p>99.9th percentile service time</p><p>hebrew-query-search</p><p>27.165</p><p>ms</p><p>Error rate</p><p>hebrew-query-search</p><p>0</p><p>%</p><h2>Open source and Elastic‑ready</h2><p>The plugin is fully open source and works on:</p><ul><li><p>Elastic open‑source distributions.</p></li><li><p>Elastic Cloud.</p></li></ul><p>You can build it yourself or download prebuilt releases and install it like any other plugin.</p><p>To upload the analyzer plugin to Elastic Cloud, navigate to the <strong>Extensions</strong> section within your Elastic Cloud console and proceed with the upload.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt63c562ad175596ae/6a170e517d8d6737f070e7ca/e2e8731aeb884e91624f2c8c0998cf8de08a16e3-1402x1600.png" alt="Open source and Elastic‑ready for Hebrew analyzer for Elasticsearch lemmatization" /><h2>Credits</h2><p>This project is a fork of the Korra ai Hebrew analysis plugin (MIT), which was implemented by <a href="http://Korra.ai">Korra.ai</a> with funding and guidance from the National NLP Program led by MAFAT and the Israel Innovation Authority.</p><p>This fork focuses on Elasticsearch 9.x compatibility and running lemmatization fully in-process via ONNX Runtime, using an INT8‑quantized model and bundled Hebrew stopwords. Lemmatization is powered by DictaBERT <a href="https://huggingface.co/dicta-il/dictabert-lex"><code>dicta-il/dictabert-lex</code></a> (CC‑BY‑4.0).</p><p>Huge thanks to the Dicta team for making high-quality Hebrew natural language processing (NLP) models available to the community.</p><h2>Links</h2><ul><li><p><a href="https://github.com/liladler/elasticsearch-analysis-hebrew-plugin">GitHub repo</a></p></li><li><p><a href="https://github.com/liladler/elasticsearch-analysis-hebrew-plugin/releases">Releases</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-lemmatization-hebrew-analyzer</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-lemmatization-hebrew-analyzer</guid>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Lily Adler]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte49ad43c2e28fd37/6a170e530e2e490f5741a1bd/8d9f79cec59d89f4e14657db7df846ed3104a2da-1024x565.png" length="0" type="image/png"/>
    <pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Hybrid search and multistage retrieval in ES|QL]]></title>
    <description><![CDATA[Explore the multistage retrieval capabilities of ES|QL, using FORK and FUSE commands to integrate hybrid search with semantic reranking and native LLM completions.]]></description>
    <content:encoded><![CDATA[<p>In Elasticsearch 9.2, we’ve introduced the ability to do dense vector search and hybrid search in Elasticsearch Query Language (ES|QL). This continues our investment in making ES|QL the best search language to solve modern search use cases.</p><h2>Multistage retrieval: The challenge of modern search</h2><p>Modern search has evolved beyond simple keyword matching. Today's search applications need to understand intent, handle natural language, and combine multiple ranking signals to deliver the best results.</p><p>Retrieval of the most relevant results happens in multiple stages, with each stage gradually refining the result set. This wasn’t the case in the past, where most use cases would require one or two stages of retrieval: an initial query to get results and a potential rescoring phase.	</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3382265814939417/6a170df3a929cf1246ae0a61/fceada10b0c09d6a4a372f137bb3040e1ff41fbf-1600x895.png" alt="" /><p>We start with an initial retrieval, where we cast a wide net to gather results that are relevant to our query. Since we need to sieve through all the data, we should use techniques that return results fast, even when we index billions of documents.</p><p>We therefore employ trusted techniques, such as lexical search that Elasticsearch has supported and optimized since the beginning, or vector search, where Elasticsearch excels in speed and accuracy.</p><p>Lexical search using BM25 is quite fast and best at exact term matching or phrase matching, and <a href="https://www.elastic.co/docs/solutions/search/vector">vector</a> or <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a> is better suited for handling natural language queries. <a href="https://www.elastic.co/what-is/hybrid-search">Hybrid search</a> combines lexical and <a href="https://www.elastic.co/docs/solutions/search/vector">vector search</a> results to bring the best from both. The challenge that hybrid search solves is that vector and lexical search have completely different and incompatible scoring functions which produce values in different intervals, following different distributions. A vector search score close to 1 can mean a very close match, but it doesn’t mean the same for lexical search. Hybrid search methods, such as <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">reciprocal rank fusion</a> (RRF) and linear combination of scores, assign new scores that blend the original scores from lexical and vector search.</p><p>After hybrid search, we can employ techniques such as <a href="https://www.elastic.co/docs/solutions/search/ranking/semantic-reranking">semantic reranking</a> and <a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr">Learning To Rank</a> (LTR), which use specialized machine learning models to rerank the result.</p><p>With our most relevant results, we can use large language models (LLMs) to further enrich our response or pass the most relevant results as context to LLMs in agentic workflows in tools such as <a href="https://www.elastic.co/search-labs/blog/elastic-ai-agent-builder-context-engineering-introduction">Elastic Agent Builder</a>.</p><p>ES|QL is able to handle all these stages of retrieval. By design, ES|QL is a piped language, where each command transforms the input and sends the output to the next command. Each stage of retrieval is represented by one or more consecutive ES|QL commands. In this article, we show how each stage is supported in ES|QL.</p><h2>Vector search</h2><p>In Elasticsearch 9.2, we introduced tech preview support for dense vector search in ES|QL. This is as simple as calling the <code>knn</code> function, which only requires a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector"><code>dense_vector</code></a> field and a query vector:</p>FROM books METADATA _score
| WHERE KNN(description_vector, ?query_vector)
| SORT _score DESC
| LIMIT 100<p>This query executes an approximate nearest neighbor search, retrieving 100 documents that are the most similar to the <code>query_vector</code>.</p><h2>Hybrid search: Reciprocal rank fusion</h2><p>In Elasticsearch 9.2, we introduced support for hybrid search using RRF and linear combination of results in ES|QL.</p><p>This allows combining vector search and lexical search results into a single result set.</p><p>To achieve this in ES|QL, we need to use the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code></a> and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fuse"><code>FUSE</code></a> commands. <code>FORK</code> runs multiple branches of execution, and <code>FUSE</code> merges the results and assigns new relevance scores using RRF or linear combination.</p><p>In the following example, we use <code>FORK</code> to run two separate branches, where one is doing a lexical search using the <code>match</code> function, while the other is doing a vector search using the <code>knn</code> function. We then merge the results together using <code>FUSE</code>:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE // uses RRF by default
| SORT _score DESC<p>Let's decompose the query to better understand the execution model and first look at the output of the <code>FORK</code> command:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)<p>The<code> FORK</code> commands outputs the results from both branches and adds a <code>_fork</code> discriminator column:</p><p>_id</p><p>title</p><p>_score</p><p>_fork</p><p>4001</p><p>The Hobbit</p><p>0.88</p><p>fork1</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.88</p><p>fork1</p><p>4005</p><p>The Two Towers</p><p>0.86</p><p>fork1</p><p>4006</p><p>The Return of the King</p><p>0.84</p><p>fork1</p><p>4123</p><p>The Silmarillion</p><p>0.78</p><p>fork1</p><p>4144</p><p>The Children of Húrin</p><p>0.79</p><p>fork1</p><p>4001</p><p>The Hobbit</p><p>4.55</p><p>fork2</p><p>3999</p><p>The Fellowship of the Ring</p><p>4.25</p><p>fork2</p><p>4123</p><p>The Silmarillion</p><p>4.11</p><p>fork2</p><p>4005</p><p>The Two Towers</p><p>3.8</p><p>fork2</p><p>4006</p><p>The Return of the King</p><p>4.1</p><p>fork2</p><p>As you’ll notice, certain documents appear twice, which is why we then use <code>FUSE</code> to merge rows that represent the same documents and assign new relevance scores. <code>FUSE</code> is executed in two stages:</p><ul><li><p>For each row, <code>FUSE</code> assigns a new relevance score, depending on the hybrid search algorithm that is being used.</p></li><li><p>Rows that represent the same document are merged together, and a new score is computed.</p></li></ul><p>In our example, we’re using RRF. As a first step, <code>FUSE</code> assigns a new score to each row using the RRF formula:</p>score(doc) = 1 / (rank_constant + rank(doc))<p>Where the <code>rank_constant</code> takes a default value of 60 and <code>rank(doc)</code>represents the position of the document in the result set.</p><p>In the first phase, our results become:</p><p>_id</p><p>title</p><p>_score</p><p>_fork</p><p>4001</p><p>The Hobbit</p><p>1 / (60 + 1) = 0.01639</p><p>fork1</p><p>3999</p><p>The Fellowship of the Ring</p><p>1 / (60 + 2) = 0.01613</p><p>fork1</p><p>4005</p><p>The Two Towers</p><p>1 / (60 + 3) = 0.01587</p><p>fork1</p><p>4006</p><p>The Return of the King</p><p>1 / (60 + 4) = 0.01563</p><p>fork1</p><p>4123</p><p> The Silmarillion</p><p>1 / (60 + 5) = 0.01538</p><p>fork1</p><p>4144</p><p>The Children of Húrin</p><p>1 / (60 + 6) = 0.01515</p><p>fork1</p><p>4001</p><p>The Hobbit</p><p>1 / (60 + 1) = 0.01639</p><p>fork2</p><p>3999</p><p>The Fellowship of the Ring</p><p>1 / (60 + 2) = 0.01613</p><p>fork2</p><p>4123</p><p>The Silmarillion</p><p>1 / (60 + 3) = 0.01587</p><p>fork2</p><p>4005</p><p>The Two Towers</p><p>1 / (60 + 4) = 0.01563</p><p>fork2</p><p>4006</p><p>The Return of the King</p><p>1 / (60 + 5) = 0.01538</p><p>fork2</p><p>Then the rows are merged together and a new score is assigned. Since a <code>SORT _score DESC</code> follows the <code>FUSE</code> command, the final results are:</p><p>_id</p><p>title</p><p>_score</p><p>4001</p><p>The Hobbit</p><p>0.01639 + 0.01639 = 0.03279</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.01613 + 0.01613 = 0.03226</p><p>4005</p><p>The Two Towers</p><p>0.01587 + 0.01563 = 0.0315</p><p>4123</p><p>The Silmarillion</p><p>0.01538 + 0.01587 = 0.03125</p><p>4006</p><p>The Return of the King</p><p>0.01563 + 0.01538 = 0.03101</p><p>4144</p><p>The Children of Húrin</p><p>0.01515</p><h2>Hybrid search: Linear combination of scores</h2><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">Reciprocal rank fusion</a> is the simplest way to do hybrid search, but it isn’t the only hybrid search method that we support in ES|QL.</p><p>In the following example, we use <code>FUSE</code> to combine lexical and <a href="https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text">semantic search</a> results using linear combination of scores:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE MATCH(semantic_description, ?query) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE LINEAR WITH { "weights": { "fork1": 0.7, "fork2": 0.3 } }
| SORT _score DESC<p>Let's first decompose the query and take a look at the input of the <code>FUSE</code> command when we only run the <code>FORK</code> command.</p><p>Notice that we use the <code>match</code> function, which is able to not only query lexical fields, such as <code>text</code> or <code>keyword</code>, but also <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> fields.</p><p>The first <code>FORK</code> branch executes a semantic query by querying a <code>semantic_text</code> field, while the second one executes a lexical query:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE MATCH(semantic_description, ?query) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)<p>The output of the <code>FORK</code> command can contain rows with the same <code>_id</code> and <code>_index</code> values representing the same Elasticsearch document:</p><p>_id</p><p>title</p><p>_score</p><p>_fork</p><p>4001</p><p>The Hobbit</p><p>0.88</p><p>fork1</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.88</p><p>fork1</p><p>4005</p><p>The Two Towers</p><p>0.86</p><p>fork1</p><p>4006</p><p>The Return of the King</p><p>0.84</p><p>fork1</p><p>4123</p><p>The Silmarillion</p><p>0.78</p><p>fork1</p><p>4144</p><p>The Children of Húrin</p><p>0.79</p><p>fork1</p><p>4001</p><p>The Hobbit</p><p>4.55</p><p>fork2</p><p>3999</p><p>The Fellowship of the Ring</p><p>4.25</p><p>fork2</p><p>4123</p><p>The Silmarillion</p><p>4.11</p><p>fork2</p><p>4005</p><p>The Two Towers</p><p>3.8</p><p>fork2</p><p>4006</p><p>The Return of the King</p><p>4.1</p><p>fork2</p><p>In the next step, we use <code>FUSE</code> to merge rows that have the same <code>_id</code> and <code>_index</code> values, and assign new relevance scores.</p><p>The new score is a linear combination of the scores the row had in each <code>FORK</code> branch:</p>_score = 0.7 *_score1 + 0.3 * _score2<p>Here, <code>_score1</code> and <code>_score2</code> represent the score a document has in the first <code>FORK</code> branch and the second <code>FORK</code> branch, respectively.</p><p>Notice that we also apply custom weights, giving more weight to the semantic score over the lexical one, resulting in this set of documents:</p><p>_id</p><p>title</p><p>_score</p><p>4001</p><p>The Hobbit</p><p>0.7 * 0.88 + 0.3 * 4.55 = 1.981</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.7 * 0.88 + 0.3 * 4.25 = 1.891</p><p>4006</p><p>The Return of the King</p><p>0.7 * 0.84 + 0.3 * 4.1 = 1.818</p><p>4123</p><p>The Silmarillion</p><p>0.7 * 0.78 + 0.3 * 4.11 = 1.779</p><p>4005</p><p>The Two Towers</p><p>0.7 * 0.86 + 0.3 * 3.8 = 1.742</p><p>4144</p><p>The Children of Húrin</p><p>0.7 * 0.79 + 0.3 * 0 = 0.553</p><p>One challenge is that the semantic and lexical scores can be incompatible to apply the linear combination, since they can follow completely different distributions. To mitigate this, we first need to normalize the scores, employing score normalization methods, such as <code>minmax</code>. This ensures that the scores from each <code>FORK</code> branch are first normalized to take values between 0 and 1, before applying the linear combination formula.</p><p>To achieve this with <code>FUSE</code>, we need to specify the <code>normalizer</code> option:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE MATCH(semantic_description, ?query) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE LINEAR WITH { "weights": { "fork1": 0.7, "fork2": 0.3 }, "normalizer": "minmax" }
| SORT _score DESC<h2>Semantic reranking</h2><p>At this stage, after hybrid search, we should be left with the most relevant documents. We can now use semantic reranking to reorder the results using the <code>RERANK</code> command. By default, <code>RERANK</code> uses the latest Elastic <a href="https://www.elastic.co/docs/solutions/search/ranking/semantic-reranking">semantic reranking</a> machine learning model, so no additional configuration is needed:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON description
| SORT _score DESC<p>We now have our best results, sorted by relevance.</p><p>One key feature that sets the <code>RERANK</code> command apart from other products that offer semantic reranking integrations is that it doesn’t require the input to represent a mapped field from an index. <code>RERANK</code> only expects an expression that evaluates to a string value, making it possible to do semantic reranking using multiple fields:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON CONCAT(title, "\n", description) 
| SORT _score DESC<h2>LLM completions</h2><p>Now we have a set of highly relevant, reranked results.</p><p>At this stage, you might simply decide to return the results back to your application or you might want to further enhance your results using LLM completions.</p><p>If you’re using ES|QL as part of a retrieval-augmented generation (RAG) workflow, you can choose to call your favorite LLM directly from ES|QL.
To achieve this, we’ve added a new <code>COMPLETION</code> command that takes in a prompt, a completion inference ID which designates which LLM to call, and a column identifier to specify where to output the LLM response.</p><p>In the following example, we’re using <code>COMPLETION</code> to add a new <code>_completion</code> column that contains the summary of the <code>content</code> column:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON description
| SORT _score DESC
| LIMIT 10
| COMPLETION CONCAT("Summarize the following:\n", description) WITH { "inference_id" : "my_inference_endpoint" } <p>Each row now contains a summary:</p><p>_id</p><p>title</p><p>_score</p><p>summary</p><p>4001</p><p>The Hobbit</p><p>0.03279</p><p>Bilbo helps dwarves reclaim Erebor from the dragon Smaug.</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.03226</p><p>Frodo begins the quest to destroy the One Ring.</p><p>4005</p><p>The Two Towers</p><p>0.0315</p><p>The Fellowship splits; war comes to Rohan; Frodo nears Mordor.</p><p>4123</p><p>The Silmarillion</p><p>0.03125</p><p>Ancient myths and history of Middle-earth's First Age.</p><p>4006</p><p>The Return of the King</p><p>0.3101</p><p>Sauron is defeated and Aragorn is crowned King.</p><p>4144</p><p>The Children of Húrin</p><p>0.01515</p><p>The tragic tale of Túrin Turambar's cursed life.</p><p>In another use case, you may simply want to answer a question using the proprietary data that you have indexed in Elasticsearch. In this case, the best search results that we’ve computed in the previous stage can be used as context for the prompt:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON description
| SORT _score DESC
| LIMIT 10
| STATS context = VALUES(CONCAT(title, "\n", description)
| COMPLETION CONCAT("Answer the following question ", ?query, "based on:\n", context) WITH { "inference_id" : "my_inference_endpoint" }<p>Since the <code>COMPLETION</code> command unlocks the ability to send any prompt to an LLM, the possibilities are endless. Although we’re only showing a few examples, the <code>COMPLETION</code> command can be used in a wide range of scenarios, from security analysts using it to assign scores depending on whether a log event can represent a malicious action or data scientists using it to analyze data, to cases where you just need to<a href="https://www.elastic.co/search-labs/blog/esql-completion-command-llm-fact-generator"> generate Chuck Norris facts based on your data</a>.</p><h2>This is only the beginning</h2><p>In the future, we’ll be expanding ES|QL to improve semantic reranking for long documents, better conditional execution of the ES|QL queries using multiple <code>FORK</code> commands, support sparse vector queries, removing close duplicate results to enhance result diversity, allowing full text search on runtime generated columns, and many other scenarios.</p><p>Additional tutorials and guides:</p><ul><li><p><a href="https://www.elastic.co/docs/solutions/search/esql-for-search">ES|QL for search</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-search-tutorial">ES|QL for search tutorial</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">Semantic_text field type</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code></a> and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fuse"><code>FUSE</code></a> documentation</p></li><li><p>ES|QL search functions</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/hybrid-search-multi-stage-retrieval-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/hybrid-search-multi-stage-retrieval-esql</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Ioana Tagirta,Aurélien Foucret,Carlos Delgado]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3382265814939417/6a170df3a929cf1246ae0a61/fceada10b0c09d6a4a372f137bb3040e1ff41fbf-1600x895.png" length="0" type="image/png"/>
    <pubDate>Thu, 08 Jan 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Evaluating search query relevance with judgment lists]]></title>
    <description><![CDATA[Explore how to build judgment lists to objectively evaluate search query relevance and improve performance metrics such as recall, for scalable search testing in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>Developers working on search engines often encounter the same issue: the business team is not satisfied with one particular search because the documents they expect to be at the top of the search results appear third or fourth on the list of results.</p><p>However, when you fix this one issue, you accidentally break other queries since you couldn’t test all cases manually. But how can you or your QA team test if a change in one query has a ripple effect in other queries? Or even more importantly, how can you be sure that your changes actually improved a query?</p><h2>Towards a systematic evaluation</h2><p>Here is where judgment lists come in useful. Instead of depending on manual and subjective testing any time you make a change, you can define a fixed set of queries that are relevant for your business case, together with their relevant results.</p><p>This set becomes your baseline. Every time you implement a change, you use it to evaluate if your search actually improved or not.</p><p>The value of this approach is that it:</p><ul><li><p><strong>Removes uncertainty</strong>: you no longer need to wonder if your changes impact other queries; the data will tell you.</p></li><li><p><strong>Stops manual testing</strong>: once the judgment sets are recorded, the test is automatic.</p></li><li><p><strong>Supports changes</strong>: You can show clear metrics that support the benefits of a change.</p></li></ul><h2>How to start building your judgment list</h2><p>One of the easiest ways to start is to take a representative query and manually select the relevant documents. There are two ways to do this list:</p><ul><li><p><strong>Binary Judgments:</strong> Each document associated with a query gets a <strong>simple tag</strong>: <em>relevant</em> (usually with a score of “1”) and not-relevant (“0”).</p></li><li><p><strong>Graded Judgments:</strong> Here, each document gets a score with different levels. For example: setting a 0 to 4 scale, similar to a <a href="https://en.wikipedia.org/wiki/Likert_scale">Likert scale</a>, where 0 = “not at all relevant” and 4 = “totally relevant,” with variations like “relevant,” “somewhat relevant,” etc.</p></li></ul><p>Binary judgments work well when the search intent has clear limits: Should this document be in the results or not?</p><p>Graded judgements are more useful when there are grey areas: some results are better than others, so you can get “very good,” “good,” and “useless” results and use metrics that value the order of the results and the user’s feedback. However, graded scales also introduce drawbacks: different reviewers may use the scoring levels differently, which makes the judgments less consistent. And because graded metrics give more weight to higher scores, even a small change (like rating something a 3 instead of a 4) can create a much bigger shift in the metric than the reviewer intended. This added subjectivity makes graded judgments noisier and harder to manage over time.</p><h2>Do I need to classify the documents myself?</h2><p>Not necessarily, since there are different ways to create your judgment list, each with its own advantages and disadvantages:</p><ul><li><p><strong>Explicit Judgments:</strong> Here, SMEs go over each query/document and manually decide if (or how) relevant it is. Though this provides quality and control, it is less scalable.</p></li><li><p><strong>Implicit Judgments:</strong> With this method, you infer the relevant documents based on real-user behavior like clicks, bounce rate, and purchases, among others. This approach allows you to gather data automatically, but it might be biased. For example, users tend to click top results more often, even if they are not relevant.</p></li><li><p><strong>AI-Generated Judgments:</strong> This last option uses models (like LLMs) to automatically evaluate queries and documents, often referred to as <a href="https://en.wikipedia.org/wiki/LLM-as-a-Judge">LLM juries</a>. It’s fast and easy to scale, but the quality of the data depends on the quality of the model you’re using and how well LLM training data aligns with your business <a href="http://interests.as/">interests</a>. As with human grades, LLM juries can introduce their own biases or inconsistencies, so it’s important to validate their output against a smaller set of trusted judgments. LLM models are probabilistic by nature, so it is not uncommon to see an LLM model giving different grades to the same result regardless of setting <a href="https://www.ibm.com/think/topics/llm-temperature">temperature</a> parameter as 0.</p></li></ul><p>Below are some recommendations to choose the best method for creating your judgment set:</p><ul><li><p>Decide how critical some features are for you that only users can properly judge (like price, brand, language, style, and product details). If those are critical, you need <strong>explicit judgments</strong> for at least some part of your <em>judgment list</em>.</p></li><li><p>Use <strong>implicit judgements</strong> when your search engine already has enough traffic so you can use clicks, conversions, and lingering time metrics to detect usage trends. You should still interpret these carefully, contrasting them with your explicit judgement sets to prevent any bias (e.g: users tend to click top-ranked results more often, even if lower-ranked results are more relevant)</p></li></ul><p>To address this, position debiasing techniques adjust or reweight click data to better reflect true user interest. Some approaches include:</p><ul><li><p><strong>Results shuffling</strong>: Change the order of search results for a subset of users to estimate how position affects clicks.</p></li><li><p><strong>Click models </strong>include<a href="https://wiki.math.uwaterloo.ca/statwiki/index.php?title=a_Dynamic_Bayesian_Network_Click_Model_for_web_search_ranking">Dynamic Bayesian Network </a><a href="https://wiki.math.uwaterloo.ca/statwiki/index.php?title=a_Dynamic_Bayesian_Network_Click_Model_for_web_search_ranking"><strong>DBN</strong></a>, <a href="https://rsrikant.com/papers/kdd10.pdf">User Browsing Model </a><a href="https://rsrikant.com/papers/kdd10.pdf"><strong>UBM</strong></a>. These Statistical models estimate the probability of a click reflects real interest rather than just position, using patterns like scrolling, dwell time, click sequence, and returning to the results page.</p></li></ul><h2>Example: Movie rating app</h2><h3>Prerequisites</h3><p>To run this example, you need a running Elasticsearch 8.x cluster, <a href="https://www.elastic.co/downloads/elasticsearch">locally</a> or <a href="https://www.elastic.co/cloud/cloud-trial-overview">Elastic Cloud</a> (Hosted or Serverless), and access to the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis">REST API</a> or Kibana.</p><p>Think about an app in which users can upload their opinions about movies and also search for movies to watch. As the texts are written by users themselves, they can have typos and many variations in terms of expression. So it’s essential that the search engine is able to interpret that diversity and provide helpful results for the users.</p><p>To be able to iterate queries without impacting the overall search behavior, the business team in your company created the following binary judgment set, based on the most frequent searches:</p><p>Query</p><p>DocID</p><p>Text</p><p>DiCaprio performance</p><p>doc1</p><p>DiCaprio's performance in The Revenant was breathtaking.</p><p>DiCaprio performance</p><p>doc2</p><p>Inception shows Leonardo DiCaprio in one of his most iconic roles.</p><p>DiCaprio performance</p><p>doc3</p><p>Brad Pitt delivers a solid performance in this crime thriller.</p><p>DiCaprio performance</p><p>doc4</p><p>An action-packed adventure with stunning visual effects.</p><p>sad movies that make you cry</p><p>doc5</p><p>A heartbreaking story of love and loss that made me cry for hours.</p><p>sad movies that make you cry</p><p>doc6</p><p>One of the saddest movies ever made — bring tissues!</p><p>sad movies that make you cry</p><p>doc7</p><p>A lighthearted comedy that will make you laugh</p><p>sad movies that make you cry</p><p>doc8</p><p>A science-fiction epic full of action and excitement.</p><p>Creating the index:</p>PUT movies
{
  "mappings": {
    "properties": {
      "text": {
        "type": "text"
      }
    }
  }
}<p>BULK request:</p>POST /movies/_bulk
{ "index": { "_id": "doc1" } }
{ "text": "DiCaprio performance in The Revenant was breathtaking." }
{ "index": { "_id": "doc2" } }
{ "text": "Inception shows Leonardo DiCaprio in one of his most iconic roles." }
{ "index": { "_id": "doc3" } }
{ "text": "Brad Pitt delivers a solid performance in this crime thriller." }
{ "index": { "_id": "doc4" } }
{ "text": "An action-packed adventure with stunning visual effects." }
{ "index": { "_id": "doc5" } }
{ "text": "A heartbreaking story of love and loss that made me cry for hours." }
{ "index": { "_id": "doc6" } }
{ "text": "One of the saddest movies ever made -- bring tissues!" }
{ "index": { "_id": "doc7" } }
{ "text": "A lighthearted comedy that will make you laugh." }
{ "index": { "_id": "doc8" } }
{ "text": "A science-fiction epic full of action and excitement." }<p>Below is the Elasticsearch query the app is using:</p>GET movies/_search
{
 "query": {
   "match": {
     "text": {
       "query": "DiCaprio performance",
       "minimum_should_match": "100%"
     }
   }
 }
}<h3>From judgment to metrics</h3><p>By themselves, judgment lists do not provide much information; they are only an expectation of the results from our queries. Where they really shine is when we use them to calculate objective metrics to measure our search performance.</p><p>Nowadays, most of the popular metrics include</p><ul><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-precision"><strong>Precision</strong></a><strong>: </strong>Measures the proportion of results that are truly relevant within all search results.</p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-recall"><strong>Recall</strong></a><strong>: </strong>Measures the proportion of relevant results the search engine found among x results.</p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#_discounted_cumulative_gain_dcg"><strong>Discounted Cumulative Gain (DCG)</strong></a><strong>: </strong>Measures the quality of the result’s ranking, considering the most relevant results should be at the top.</p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#_mean_reciprocal_rank"><strong>Mean Reciprocal Rank (MRR):</strong></a> Measures the position of the first relevant result. The higher it is in the list, the higher its score.</p></li></ul><p>Using the same movie rating app as an example, we’ll calculate the recall metric to see if there’s any information that is being left out of our queries.</p><p>In Elasticsearch, we can use the <em>judgment lists</em> to calculate metrics via the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval">Ranking Evaluation API</a>. This API receives as input the judgment list, the query, and the metric you want to evaluate, and returns a value, which is a comparison of the query result with the judgment list.</p><p>Let’s run the judgment list for the two queries that we have:</p>POST /movies/_rank_eval
{
 "requests": [
   {
     "id": "dicaprio-performance",
     "request": {
       "query": {
         "match": {
           "text": {
             "query": "DiCaprio performance",
             "minimum_should_match": "100%"
           }
         }
       }
     },
     "ratings": [
       {
         "_index": "movies",
         "_id": "doc1",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc2",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc3",
         "rating": 0
       },
       {
         "_index": "movies",
         "_id": "doc4",
         "rating": 0
       }
     ]
   },
   {
     "id": "sad-movies",
     "request": {
       "query": {
         "match": {
           "text": {
             "query": "sad movies that make you cry",
             "minimum_should_match": "100%"
           }
         }
       }
     },
     "ratings": [
       {
         "_index": "movies",
         "_id": "doc5",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc6",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc7",
         "rating": 0
       },
       {
         "_index": "movies",
         "_id": "doc8",
         "rating": 0
       }
     ]
   }
 ],
 "metric": {
   "recall": {
     "k": 10,
     "relevant_rating_threshold": 1
     }
 }
}<p>We’ll use two requests to _rank_eval: one for the DiCaprio query and another for sad movies. Each request includes a query and its judgment list (ratings). We don’t need to grade all documents since the ones not included in the ratings are considered as with no judgment. To do the calculations, recall only considers the “relevant set,” the documents that are considered relevant in the rating.</p><p>In this case, the DiCaprio query has a recall of 1, while the sad movies got 0. This means that for the first query, we were able to get all relevant results, while in the second query, we did not get any. The average recall is therefore 0.5.</p>{
 "metric_score": 0.5,
 "details": {
   "dicaprio-performance": {
     "metric_score": 1,
     "unrated_docs": [],
     "hits": [
       {
         "hit": {
           "_index": "movies",
           "_id": "doc1",
           "_score": 2.4826927
         },
         "rating": 1
       },
       {
         "hit": {
           "_index": "movies",
           "_id": "doc2",
           "_score": 2.0780432
         },
         "rating": 1
       }
     ],
     "metric_details": {
       "recall": {
         "relevant_docs_retrieved": 2,
         "relevant_docs": 2
       }
     }
   },
   "sad-movies": {
     "metric_score": 0,
     "unrated_docs": [],
     "hits": [],
     "metric_details": {
       "recall": {
         "relevant_docs_retrieved": 0,
         "relevant_docs": 2
       }
     }
   }
 },
 "failures": {}
}<p>Maybe we’re being too strict with the <strong>minimum_should_match </strong>parameter since by demanding that 100% of the words in the query are found in the documents, we’re probably leaving relevant results out. Let’s remove the <strong>minimum_should_match</strong> parameter so that a document is considered relevant if only one word in the query is found in it.</p>POST /movies/_rank_eval
{
 "requests": [
   {
     "id": "dicaprio-performance",
     "request": {
       "query": {
         "match": {
           "text": {
             "query": "DiCaprio performance"
           }
         }
       }
     },
     "ratings": [
       {
         "_index": "movies",
         "_id": "doc1",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc2",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc3",
         "rating": 0
       },
       {
         "_index": "movies",
         "_id": "doc4",
         "rating": 0
       }
     ]
   },
   {
     "id": "sad-movies",
     "request": {
       "query": {
         "match": {
           "text": {
             "query": "sad movies that make you cry"
           }
         }
       }
     },
     "ratings": [
       {
         "_index": "movies",
         "_id": "doc5",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc6",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc7",
         "rating": 0
       },
       {
         "_index": "movies",
         "_id": "doc8",
         "rating": 0
       }
     ]
   }
 ],
 "metric": {
   "recall": {
     "k": 10,
     "relevant_rating_threshold": 1
     }
 }
}<p>As you can see, by removing the <strong>minimum_should_match</strong> parameter in one of the two queries, we now get an average recall of 1 in both.</p>{
  "metric_score": 1,
  "details": {
    "dicaprio-performance": {
      "metric_score": 1,
      "unrated_docs": [],
      "hits": [
        {
          "hit": {
            "_index": "movies",
            "_id": "doc1",
            "_score": 2.0661702
          },
          "rating": 1
        },
        {
          "hit": {
            "_index": "movies",
            "_id": "doc3",
            "_score": 0.732218
          },
          "rating": 0
        },
        {
          "hit": {
            "_index": "movies",
            "_id": "doc2",
            "_score": 0.6271719
          },
          "rating": 1
        }
      ],
      "metric_details": {
        "recall": {
          "relevant_docs_retrieved": 2,
          "relevant_docs": 2
        }
      }
    },
    "sad-movies": {
      "metric_score": 1,
      "unrated_docs": [],
      "hits": [
        {
          "hit": {
            "_index": "movies",
            "_id": "doc7",
            "_score": 2.1307156
          },
          "rating": 0
        },
        {
          "hit": {
            "_index": "movies",
            "_id": "doc5",
            "_score": 1.3160692
          },
          "rating": 1
        },
        {
          "hit": {
            "_index": "movies",
            "_id": "doc6",
            "_score": 1.190063
          },
          "rating": 1
        }
      ],
      "metric_details": {
        "recall": {
          "relevant_docs_retrieved": 2,
          "relevant_docs": 2
        }
      }
    }
  },
  "failures": {}
}<p>In summary, removing the minimum_should_match: 100% clause, allows us to got a perfect recall for both queries.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaf4f08a8a2915180/6a170df61949f76cbfe7aaba/24d055da4348c63827ba7046fe8cafb6f47cadd8-546x628.png" alt="" /><p>We did it! Right?</p><p>Not so fast!</p><p>By improving recall, we open the door to a wider range of results. However, each adjustment implies a trade-off. This is why defining complete test cases, using different metrics to evaluate changes.</p><p>Using judgment lists and metrics prevents you from going in blind when making changes since you now have data to back them up. Validation is no longer manual and repetitive, and you can test your changes in more than just one use case. Additionally, A/B testing allows you to test live which configuration works best for your users and business case, thus coming full circle from technical metrics and real-world metrics.</p><h2>Final recommendations for using judgment lists</h2><p>Working with judgment lists is not only about measuring but also about creating a framework that allows you to iterate with confidence. To achieve this, you can follow these recommendations:</p><ol><li><p><strong>Start small, but start</strong>. You don’t need to have 10,000 queries with 50 judgment lists each. You only need to identify the 5–10 most critical queries for your business case and define which documents you expect to see at the top of the results. This already gives you a base. You typically want to start with the top queries plus the queries with no results. You can also start testing with an easy-to-configure metric like Precision and then work your way up in complexity.</p></li><li><p><strong>Validate with users.</strong> Complement the numbers with A/B testing in production. This way, you’ll know if changes that look good in the metrics are also generating a real impact.</p></li><li><p><strong>Keep the list alive.</strong> Your business case will evolve, and so will your critical queries. Update your judgment periodically to reflect new needs.</p></li><li><p><strong>Make it part of the flow.</strong> Integrate judgment lists into your development pipelines. Make sure each configuration change, synonym, or text analysis is automatically validated against your base list.</p></li><li><p><strong>Connect technical knowledge with strategy.</strong> Don’t stop at measuring technical metrics like precision or recall. Use your evaluation results to inform business outcomes.</p></li></ol>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/judgment-lists-search-query-relevance-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/judgment-lists-search-query-relevance-elasticsearch</guid>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Jhon Guzmán]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcadfd2fb1cc95b4c/6a170df7acf0887798be9bd0/25478d0ffb228afd5d65d82312998ec1c299c565-700x490.png" length="0" type="image/png"/>
    <pubDate>Thu, 11 Dec 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to improve e-commerce search relevance with personalized cohort-aware ranking]]></title>
    <description><![CDATA[Improve e-commerce search relevance with explainable, cohort-aware ranking in Elasticsearch. Learn how multiplicative boosting delivers stable, predictable personalization at query time.]]></description>
    <content:encoded><![CDATA[<h2>Overview</h2><p>In this article, we explore how to make Elasticsearch search results more relevant for different e-commerce user segments using an explainable, multiplicative boosting strategy — without any machine learning post-processing.</p><h2>Introduction: Why personalization matters</h2><p>Elasticsearch is very good at ranking results by textual relevance (BM25) and by semantic relevance (vectors). In e-commerce, that is necessary but not sufficient. Two people can type the same query and reasonably expect different results:</p><ul><li><p>A luxury shopper searching for “red lipstick” expects prestige brands near the top.</p></li><li><p>A budget shopper wants affordable options promoted.</p></li><li><p>A gift buyer may prefer popular bundles.</p></li></ul><p>The goal is to adjust ranking so that, for a given query, products that align with the user’s segment rise modestly in the list, without destroying the underlying relevance. This article shows how to add cohort-aware personalization on top of Elasticsearch’s relevance using only <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query">function_score</a>, a keyword field, and small multiplicative boosts.</p><h2>Multiplicative boosting for cohort personalization</h2><p>The core challenge in cohort personalization is stability. You want a product that is relevant to the query to remain relevant, with a controlled, explainable uplift when it matches the user’s segment. What often goes wrong is that personalization signals are added to the score in a way that either:</p><ul><li><p>overwhelms BM25 on some queries, or</p></li><li><p>has almost no effect on others.</p></li></ul><p>This happens because most boosting approaches use additive scoring. However, BM25 scales can vary dramatically across queries and datasets, so a fixed additive adjustment (e.g., “add +2.0 for a cohort match”) is sometimes a massive change to the BM25 score, and other times is negligible. Instead, what we want is a guarantee that if a product is a good match for the query, and it aligns with the user’s cohort, then its score is increased by a controlled percentage regardless of the absolute BM25 scale. We can achieve this with a multiplicative pattern:</p>final_score = BM25 × (1 + cohort_overlap × weight_per_cohort)<p>This article shows how to implement this pattern using Elasticsearch’s function_score query, a cohorts field on the product, and a list of user cohorts passed at query time.</p><h2>Modeling cohorts in your product catalog</h2><p>The simplest way to enable cohort-aware ranking is to treat cohorts as tags. For example, a product might carry tags such as:</p><ul><li><p>Lipstick: ["female", "beauty", "luxury"]</p></li><li><p>Men’s deodorant: ["male", "personal_care", "sport"]</p></li><li><p>Glitter gloss: ["female", "beauty", "youth", "party"].</p></li></ul><p>A user or session carries a set of tags inferred from behavior and profile:</p><ul><li><p>High-income female luxury shopper: ["female", "beauty", "luxury"]</p></li><li><p>Budget-oriented female shopper: ["female", "beauty", "budget"]</p></li></ul><p>Cohort overlap is the count of shared cohort tags between the user/session and the product. No weighting, no semantic similarity — just a simple intersection. For example, if the user cohorts are [“female”, “beauty”, “budget”] and a lipstick has [“female”, “beauty”, “luxury”], the overlap is 2. If a men’s deodorant has [“male”, “personal_care”, “sport”], the overlap with that same user is 0.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta288b12a3e3fa2a8/6a170bda286714007393e33b/e88ddfa1b453327fe75211500b850b49ad3776f5-1172x844.png" alt="" /><p>The intuition is that (a) BM25 ranks documents depending on how relevant they are to the user’s query, and (b) cohort overlap boosts products based on how well each product aligns with the user's segment. To accomplish this, we transform the cohort overlap between the user cohorts and the product cohorts into a multiplicative boost that scales BM25.</p><p>To avoid field explosion, we keep all cohort tags in a single keyword field, such as follows:</p>{
  "product_id": "LIP-001",
  "description": "Premium cherry red lipstick with velvet finish",
  "cohorts": ["female", "beauty", "luxury"]
}<p>This is easy for merchandisers to understand, avoids hundreds of boolean fields like is_female or is_luxury, and works efficiently with term filters.</p><h2>Why additive boosts don’t work</h2><p>One subtle but important point is that even a standard boolean query is additive. When Elasticsearch scores a document, the base BM25 score from the main query (typically in a <code>must</code>) and every matching <code>should</code> clause contributes an additive score. This means “additive boosting” is not just about boosts, it’s fundamental to how boolean scoring works.</p><p>Personalization built on additive logic behaves inconsistently because BM25 scales differ per query and dataset. For example the base BM25 scores for three products might be 12, 8, 4 in one instance, and might be 0.12, 0.08, 0.04 after updating your dataset or modifying your query. In this case an additive boost (e.g., +2.0) becomes a dominating force when the base BM25 scores are small (a +2.0 boost on a score of 0.12 is about 18× higher) or a rounding error when the base BM25 scores are large (+2.0 boost on a score of 12 is only about 1.17× higher). This creates inconsistent, unpredictable ranking behavior.</p><h2>Why multiplicative boosting is the right shape</h2><p>If we apply a multiplicative boost, the shape is consistent:</p>final_score = BM25 × boost
boost = 1 + overlap × weight_per_cohort<p>With weight_per_cohort = 0.1, an overlap of 2 gives a boost of 1.2 (20% increase), an overlap of 1 gives a boost of 1.1 (10% increase), and an overlap of 0 gives a boost of 1.0 (no change). This means that a product that is more aligned with the user cohort gets a predictable percentage uplift, regardless of whether its BM25 score is 0.01 or 10.0. BM25 remains the primary signal; cohort alignment gently reshapes the ranking.</p><h2>How function_score gives us multiplicative behavior</h2><p>To convert cohort overlap into a controlled percentage boost, we need a way to take the normal BM25 score and scale it up by a factor such as 1.1, 1.2, or 1.3. Elasticsearch does not support multiplying a score directly inside a standard query, but <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query">function_score</a> provides exactly this capability: it lets us compute an additional score component and combine it with the base score using a chosen strategy, which is "multiply" for this use case.</p><p>Elasticsearch’s function_score lets us implement multiplicative cohort boosting in three steps. First, each cohort match contributes a small weight (e.g., 0.1). Second, we include a baseline weight of 1.0 so that the final multiplier never drops below 1. Third, we sum all cohort contributions using score_mode: "sum" to produce a boost factor that represents (1 + overlap × weight). Finally, we combine this boost factor with the BM25 score using boost_mode: "multiply", which gives us the exact multiplicative behavior we want.</p><p>The calculation below shows how the final score is calculated, where BM25 is the base relevance; n is the number of matched cohorts; w is weight_per_cohort (e.g., 0.1); and additive baseline = 1.0:</p>sum_score = baseline + n × w
final_score = BM25 × sum_score<p>So, with 2 overlapping cohorts and w = 0.1:</p>sum_score = 1.0 + 2 × 0.1 = 1.2
final_score = BM25 × 1.2<p>This is exactly the multiplicative behavior we want.</p><h2>Putting it together: index, data, and baseline ranking</h2><p>Create a simple index:</p>PUT product_catalog
{
  "mappings": {
    "properties": {
      "product_id": {
        "type": "keyword"
      },
      "description": {
        "type": "text"
      },
      "cohorts": {
        "type": "keyword"
      }
    }
  }
}<p>Index a few products:</p>POST _bulk
{ "index": { "_index": "product_catalog", "_id": "LIP-001" }}
{ "product_id": "LIP-001", "description": "Premium cherry red lipstick with velvet finish", "cohorts": ["female", "beauty", "luxury"] }
{ "index": { "_index": "product_catalog", "_id": "LIP-002" }}
{ "product_id": "LIP-002", "description": "Affordable matte red lipstick for everyday wear", "cohorts": ["female", "beauty", "budget"] }
{ "index": { "_index": "product_catalog", "_id": "LIP-003" }}
{ "product_id": "LIP-003", "description": "Glitter red gloss for parties and festivals", "cohorts": ["female", "beauty", "youth", "party"] }<p>A baseline query for “red lipstick” might look like:
</p>POST product_catalog/_search
{
  "size": 5,
  "_source": ["product_id", "description"],
  "query": {
    "multi_match": {
      "query": "red lipstick",
      "fields": ["description"]
    }
  }
}<p>This returns a pure BM25 ranking (without any cohort boosting). In this example, the scores of LIP-001 and LIP-002 will be very close (or identical), because they match the same query terms with similar frequencies and have comparable lengths.</p><p>The relative ranking is what matters; the exact numeric scores may differ depending on shard configuration, analyzer differences, or Elasticsearch version.</p><p>Product ID</p><p>Description</p><p>BM25 score</p><p>LIP-001</p><p>Premium cherry red lipstick with velvet finish</p><p>0.603535</p><p>LIP-002</p><p>Affordable matte red lipstick for everyday wear</p><p>0.603535</p><p>LIP-003</p><p>Glitter red gloss for parties and festivals</p><p>0.13353139</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2ef304420fe7d036/6a170bdcb0367dc72072bd4b/513bfba3467fb9966ed00b6e798889eeb690fe84-1788x1306.png" alt="" /><h3>Persona A: high-income luxury shopper</h3><p>Suppose we know that Persona A fits into the following cohorts:</p>["female", "beauty", "luxury"]<p>We translate that into a set of cohort filters, each with a small weight, plus a baseline factor:</p>GET product_catalog/_search
{
  "explain": true,
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "red lipstick",
          "fields": ["description"]
        }
      },
      "functions": [
        { "filter": { "term": { "cohorts": "female" }},  "weight": 0.1 },
        { "filter": { "term": { "cohorts": "beauty" }},  "weight": 0.1 },
        { "filter": { "term": { "cohorts": "luxury" }},  "weight": 0.1 },
        { "weight": 1.0 }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}<p>For this persona LIP-001 (“Premium cherry red lipstick with velvet finish”) matches “female”, “beauty”, and “luxury” which means that the cohort overlap is 3 and therefore the boost factor is 1.3. On the other hand, LIP-002 and LIP-003 match “female” and “beauty” which results in a boost factor of 1.2.</p><p>Product ID</p><p>Description</p><p>Base BM25 score</p><p>Boost factor</p><p>New score</p><p>LIP-001</p><p>Premium cherry red lipstick with velvet finish</p><p>0.603535</p><p>1.3x (30%)</p><p>0.7845955</p><p>LIP-002</p><p>Affordable matte red lipstick for everyday wear</p><p>0.603535</p><p>1.2x (20%)</p><p>0.724242</p><p>LIP-003</p><p>Glitter red gloss for parties and festivals</p><p>0.13353139</p><p>1.2x (20%)</p><p>0.16023767</p><p>As desired for this luxury user, the luxury lipstick (LIP-001) receives the strongest uplift and will tend to rise above similar alternatives in the results.</p><h3>Persona B: budget-oriented shopper</h3><p>A budget-conscious shopper might belong to the following cohorts:</p><p>["female", "beauty", "budget"]</p><p>The query for this user is nearly identical to the previous query, except for the cohort values which now reflect “budget” rather than “luxury”:</p>GET product_catalog/_search
{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "red lipstick",
          "fields": ["description"]
        }
      },
      "functions": [
        { "filter": { "term": { "cohorts": "female" }},  "weight": 0.1 },
        { "filter": { "term": { "cohorts": "beauty" }},  "weight": 0.1 },
        { "filter": { "term": { "cohorts": "budget" }},  "weight": 0.1 },
        { "weight": 1.0 }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}<p>For this persona LIP-002 (“Affordable matte red lipstick for everyday wear”) matches “female”, “beauty”, and “budget” which means that the cohort overlap is 3 and therefore the boost factor is 1.3. On the other hand, LIP-001 and LIP-003 match “female” and “beauty” which results in a boost factor of 1.2.</p><p>Product ID</p><p>Description</p><p>Base BM25 score</p><p>Boost factor</p><p>New score</p><p>LIP-002</p><p>Affordable matte red lipstick for everyday wear</p><p>0.603535</p><p>1.3x (30%)</p><p>0.7845955</p><p>LIP-001</p><p>Premium cherry red lipstick with velvet finish</p><p>0.603535</p><p>1.2x (20%)</p><p>0.724242</p><p>LIP-003</p><p>Glitter red gloss for parties and festivals</p><p>0.13353139</p><p>1.2x (20%)</p><p>0.16023767</p><p>As desired for this budget user, the budget lipstick (LIP-002) receives the strongest uplift and will tend to rise above similar alternatives in the results.</p><h2>How to build the cohort filter dynamically (Python example)</h2><p>You will normally inject the cohort filters at query time based on the user/session profile. For example:</p>user_cohorts = ["female", "beauty"]
functions = [
    { "filter": { "term": { "cohorts": cohort } }, "weight": 0.1 }
    for cohort in user_cohorts
]
# add baseline multiplier
functions.append({ "weight": 1.0 })<p>Using term filters on a keyword field is fast, shard-cache friendly, and fully visible in the _explain API, which shows exactly which filters fired and which weights were applied.</p><h2>How cohort assignment works</h2><p>Cohort assignment is intentionally left outside Elasticsearch, and is outside the scope of this article. However, sources could include:</p><ul><li><p>browsing events (“has viewed lipstick” → beauty)</p></li><li><p>gender inference (from preferences or marketing profile)</p></li><li><p>device characteristics (mobile shopper)</p></li><li><p>location (“urban buyer”)</p></li><li><p>historical purchases</p></li><li><p>marketing segments</p></li><li><p>personalization cookies</p></li></ul><p>All of these are input signals, but the scoring mechanism in Elasticsearch remains the same. Elasticsearch does not need to know how you inferred the segments. This separation of concerns keeps Elasticsearch focused on ranking, while your application or data science layer owns the logic for inferring segments.</p><h3>How to choose the right boost weight</h3><p>In our examples, we used 0.1 per cohort. This value is tunable. Staying between 0.05 and 0.20 will likely provide good results. You should A/B test weights based on:</p><ul><li><p>catalog diversity</p></li><li><p>number of cohort tags per product</p></li><li><p>variability in BM25</p></li><li><p>business goals (revenue vs. discovery vs. personalization)</p></li></ul><h3>Limit the number of cohorts assigned to each product</h3><p>Giving a product 20 cohort tags leads to:</p><ul><li><p>Noise in the signals</p></li><li><p>Gaming by merchandisers (“tag everything as luxury”)</p></li><li><p>Loss of explainability</p></li><li><p>Over-boosting</p></li></ul><p>As a starting point (to be confirmed by your own testing), we recommend:</p><ul><li><p>Approximately 5 cohorts per product.</p></li><li><p>Optionally, an offline validation step (ingest pipeline, CI script, or index-time check) that warns or blocks when more than 5 tags are assigned.</p></li></ul><h3>Customized cohort boosting per user</h3><p>So far, our examples assume every cohort contributes equally. In reality, some users strongly prefer certain segments. In some cases, you might know that certain cohorts are especially important for an individual user. For example:</p><ul><li><p>A user who almost always buys luxury brands.</p></li><li><p>A user who consistently picks budget options.</p></li></ul><p>You can encode this by assigning different weights per cohort instead of a flat 0.1. For example, if your application has detected a “super-luxury” shopper, then you could modify the function scoring as follows:</p>"functions": [
  { "filter": { "term": { "cohorts": "female" }},  "weight": 0.1 },
  { "filter": { "term": { "cohorts": "beauty" }},  "weight": 0.1 },
  { "filter": { "term": { "cohorts": "luxury" }},  "weight": 0.2 },
  { "weight": 1.0 }
]<p>In the above example matching “female” or “beauty” each add <code>+0.1</code> while matching luxury adds <code>+0.2</code>. In this example, a product matching all three cohorts would get:</p>boost = 1.0 + 0.1 + 0.1 + 0.2 = 1.4<p>This remains fully explainable, and you can document the configuration (“luxury is 2× as important as other cohorts for this user”). Additionally, the explain API will show exactly how those numbers contributed to the final score.</p><h2>Conclusion:</h2><p>This Elasticsearch-native approach to cohort personalization uses only lightweight metadata and standard query constructs, while preserving explainability, stability, and business control over the relevance model. This delivers precise, predictable relevance that ensures the business goals never sacrifice the quality of the search results.</p><h2>Implementation summary</h2><p>If you want to adopt this pattern in production, the high-level steps are:</p><ul><li><p>Add a single keyword field (cohorts) to each product containing 3–5 cohort tags.</p></li><li><p>Compute user/session cohorts in your application logic (from browsing, purchase history, CRM, etc.) and pass them with the query.</p></li><li><p>Inject dynamic function_score filters into your query with one per user cohort, and each with a small weight (e.g., 0.1), plus a baseline weight (1.0).</p></li><li><p>Wrap your existing BM25 query in function_score with score_mode: "sum" and boost_mode: "multiply" to apply multiplicative boosting.</p></li><li><p>Tune per-cohort weights (typically 0.05–0.20) based on A/B experiments, ensuring BM25 remains the dominant signal.</p></li></ul><p>These steps let you layer cohort personalization cleanly on top of your existing search relevance, without scripts, ML models, or major architecture changes.</p><h2>What’s next?</h2><p>This pattern is a powerful example of how to build sophisticated relevance rules directly into your queries, ensuring speed and reliability.</p><ol><li><p><strong>Implement custom personalization faster:</strong> If you're ready to deploy and optimize this advanced cohort personalization strategy, or to tackle other complex relevance challenges, our team can help you build, tune, and operationalize your Elasticsearch solution quickly. Contact <a href="https://www.elastic.co/consulting">Elastic Services</a> for help implementing this and other advanced search techniques.</p></li><li><p><strong>Join the discussion:</strong> For general questions about advanced relevance techniques and implementation, join the <a href="https://discuss.elastic.co/">broader Elastic Stack community</a> for search discussions.</p></li></ol>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ecommerce-search-relevance-cohort-aware-ranking-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ecommerce-search-relevance-cohort-aware-ranking-elasticsearch</guid>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Alexander Marquardt]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta288b12a3e3fa2a8/6a170bda286714007393e33b/e88ddfa1b453327fe75211500b850b49ad3776f5-1172x844.png" length="0" type="image/png"/>
    <pubDate>Wed, 10 Dec 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ES|QL, you know, for Search - Introducing scoring and semantic search]]></title>
    <description><![CDATA[Elasticsearch 8.18 and 9.0 introduce several ES|QL enhancements: scoring, semantic search, expanded configuration for the match function, and a new KQL function.]]></description>
    <content:encoded><![CDATA[<h2>Search with ES|QL</h2><p>With Elasticsearch 8.18 and 9.0, ES|QL adds a host of new functionalities, including:</p><ul><li><p>support for scoring</p></li><li><p>semantic search</p></li><li><p>more configuration options for the match function</p></li><li><p>a new KQL function</p></li></ul><p>In this blog, we will review these 8.18 features and other exciting new features that we plan to add to ES|QL, reinforcing our investment in making ES|QL a modern search language ready to fit your needs, whether you are building a search application powered by ES|QL or analyzing your data in Kibana Discover.</p><h3>Introducing scoring</h3><p>In 8.17 we added the ability to filter documents using full text functions. If you are unfamiliar with full text filtering in ES|QL, we suggest reading our <a href="https://www.elastic.co/search-labs/blog/filtering-in-esql-full-text-search-match-qstr">original blog post</a> about it.</p><p>With 8.18 and 9.0 we introduce support for <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.18/esql-for-search.html#esql-for-search-scoring">scoring</a>, making it possible to return documents in order of their relevance. To access the score for each document, simply add the metadata <code>_score</code> field to your ES|QL query:</p><p>We retrieve the same scores we get from the equivalent search API query:</p>GET books/_search
{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "title": "Shakespeare"
          }
        },
        {
          "match": {
            "title": "Shakespeare"
          }
        }
      ]
    }
  }
}<p>Full text search functions such as <code>match</code>, <code>qstr</code> and <code>kql</code> can only be used in the context of a WHERE condition and are the only ones that contribute to the score.</p><p>The <code>_score</code> column can not only be used to sort documents by relevance, but also in custom scoring formulas. In the next example, we keep only the most relevant results using a score threshold and then add a score boost based on the reader rating:</p><h3>Improving the match function</h3><p>In ES|QL, the match function simply translates to a Query DSL <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html">match query</a>. In 8.18 and 9.0, we expanded the match function's capabilities to include all options that are currently available in Query DSL. It is now possible to set well-known match options such as boost, <code>fuzziness</code> and <code>operator</code> in ES|QL too:</p><h3>Enter semantic search</h3><p>The 8.18 release comes with the <a href="https://www.elastic.co/search-labs/blog/semantic-text-ga">exciting announcement</a> that semantic search is now generally available. We've expanded the <code>match</code> function to support querying over <code>semantic_text</code> field types. </p><p>In ES|QL, executing a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.18/esql-for-search.html#esql-for-search-semantic">semantic query</a> is now as simple as performing a full-text query, as shown in this example:</p><p>In this example, we set <code>semantic_title</code> to use the <code>semantic_text</code> field type.</p><p>Mapping your index fields as <code>semantic_text</code> is all it takes to set up your index for semantic search.</p><p>Check our <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search-semantic-text.html">search with semantic text tutorial</a> for more details.</p><h3>Hybrid search with ES|QL</h3><p>ES|QL makes it straightforward to do both semantic and lexical search at the same time. It is also possible to set different boosts, prioritizing results from semantic search or lexical search, depending on your use case:</p><h3>Transitioning from KQL</h3><p>If you are a long-term user of Kibana Discover and use KQL (<a href="https://www.elastic.co/guide/en/kibana/8.18/kuery-query.html">Kibana Query Language</a>) to query and visualize your data and you'd like to try ES|QL but don't know where to start, don't worry, we got you! </p><p>In 8.18 and 9.0, ES|QL adds a new function which allows you to use <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.18/esql-for-search.html#esql-for-search-kql">KQL inside ES|QL</a>. This is as simple as:</p><p>ES|QL is already available in Kibana Discover.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte9995e2ae807f22b/6a170ae80e2e49c62d41a0da/3311b56a07d2915d0896bb171b995f39f58d757c-1600x852.png" alt="ES|QL in Kibana Discover" /><p>This way, you get the best of both worlds: you can continue to use KQL and start getting more familiar with ES|QL at your own pace.</p><p>Check out our <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-getting-started.html">getting started with ES|QL</a> guide for more information.</p><h3>Beyond 8.18 and 9.0</h3><p>In future releases, we'll be adding more and more search capabilities to ES|QL, including vector search, semantic reranking, enhanced score customization options, and additional methods for combining hybrid search results, such as Reciprocal Rank Fusion (RRF).</p><h3>Try it out yourself</h3><p>These changes are available starting with Elasticsearch 8.18, but they are already available in Elasticsearch Serverless. For Elasticsearch Serverless, start a free trial cloud today or try Elastic on your <a href="https://github.com/elastic/start-local?cta=local-machine&amp;tech=github&amp;plcmt=cross%20module&amp;pg=search-labs">local machine</a> now!</p><p>Follow the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.18/esql-search-tutorial.html">Search and filter in ES|QL tutorial</a> for a hands-on introduction to the features described in this blog post! </p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-introducing-scoring-semantic-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-introducing-scoring-semantic-search</guid>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Ioana Tagirta]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt021070497c8cbd23/6a170aeab0367d782c72bd0e/c506a0f5c0a33ca6f85454d4f742d7cb266a7b78-715x413.png" length="0" type="image/png"/>
    <pubDate>Wed, 16 Apr 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Generating filters and facets using ML]]></title>
    <description><![CDATA[Exploring the pros and cons of automating the creation of filters and facets in a search experience using ML models vs the classical hard-coded approach.]]></description>
    <content:encoded><![CDATA[<p>Filters and facets are mechanisms used to refine search results, helping users find relevant content or products more quickly. In the classical approach, rules are manually defined. For example, in a movie catalog, attributes such as genre are pre-defined for use in filters and facets. On the other hand, with AI models, new attributes can be automatically extracted from the characteristics of movies, making the process more dynamic and personalized. In this blog, we explore the pros and cons of each method, highlighting their applications and challenges.</p><h2>Filters vs facets</h2><p>Before we begin, let's define what filters and facets are. <strong>Filters</strong> are predefined attributes used to restrict a set of results. In a marketplace, for example, filters are available even before a search is performed. The user can select a category, such as <strong>"Video games"</strong>, before searching for <strong>"PS5"</strong>, refining the search to a more specific subset instead of the entire database. This significantly increases the chances of obtaining more relevant results.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0a77b38aae238938/6a170b821949f72a52e7aa51/5ed8868fa5017d034e1273e35c884a5430afdf3c-1600x937.png" alt="Filters" /><p><strong>Facets</strong> work similarly to filters but are only available after the search is performed. In other words, the search returns results, and based on them, a new list of refinement options is generated. For example, when searching for a PS5 console, facets such as storage <strong>capacity</strong>, <strong>shipping cost</strong>, and <strong>color</strong> may be displayed to help users choose the ideal product.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt166e356b80d423ef/6a170b840e2e494ca341a10f/c5633fcc5b6fbb916110faf32144d8d43572e33a-1600x937.png" alt="Facets " /><p>Now that we have defined filters and facets, let's discuss the impact of the classical and Machine Learning (ML)-based approaches on their implementation and usage. Each method has advantages and challenges that influence search efficiency.</p><h2>Classical approach to filters and facets</h2><p>In this approach, filters and facets are manually defined based on predefined rules. This means that the attributes available for refining the search are fixed and planned in advance, considering the catalog structure and user needs.</p><p>For example, in a marketplace, categories such as "Electronics" or "Fashion" may have specific filters like brand, format and price range. These rules are created statically, ensuring consistency in the search experience but requiring manual adjustments whenever new products or categories emerge.</p><p>Although this approach provides predictability and control over the displayed filters and facets, it can be limited when new trends arise that demand dynamic refinement.</p><p><strong>Pros:</strong></p><ul><li><p><strong>Predictability and control:</strong> Since filters and facets are manually defined, management becomes easier.</p></li><li><p><strong>Low complexity:</strong> No need to train models.</p></li><li><p><strong>Ease of maintenance:</strong> As rules are predefined, adjustments and corrections can be made quickly.</p></li></ul><p><strong>Cons</strong>:</p><ul><li><p><strong>Reindexing required for new filters:</strong> Whenever a new attribute needs to be used as a filter, the entire dataset must be reindexed to ensure that documents contain this information.</p></li><li><p><strong>Lack of dynamic adaptation:</strong> Filters are static and do not automatically adjust to changes in user behavior.</p></li></ul><h3>Implementation of filters/facets – Classical approach</h3><p>In <strong>Dev Tools, Kibana</strong>, we will create a demonstration of filters/facets using the <strong>classical approach</strong>.</p><p>First, we define the mapping to structure the index:</p>PUT videogames
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "brand": { "type": "keyword" },
      "storage": { "type": "keyword" },
      "price": { "type": "float" },
      "description": { "type": "text" }
    }
  }
}<p>The <strong>brand</strong> and <strong>storage</strong> fields are set as <strong>keyword</strong>, allowing them to be used directly in aggregations (<strong>facets</strong>). The <strong>price</strong> field is of type <strong>float</strong>, enabling the creation of <strong>price ranges</strong>.</p><p>In the next step, the product data will be indexed:</p>POST videogames/_bulk
{ "index": { "_id": 1 } }
{ "name": "Play Station 5", "brand": "Sony", "storage": "1TB", "price": 499.99, "description": "Stunning Gaming: Marvel at stunning graphics and experience the features of the new PS5. Breathtaking Immersion: Discover a deeper gaming experience with support for haptic feedback, adaptive triggers, and 3D Audio technology. Slim Design: With the PS5 Digital Edition, gamers get powerful gaming technology in a sleek, compact design. 1TB of Storage: Have your favorite games ready and waiting for you to play with 1TB of built-in SSD storage. Backward Compatibility and Game Boost: The PS5 console can play over 4,000 PS4 games. With Game Boost, you can even enjoy faster, smoother frame rates in some of the best PS4 console games." }
{ "index": { "_id": 2 } }
{ "name": "Xbox Series X", "brand": "Microsoft", "storage": "1TB", "price": 499.99, "description": "Fastest, most powerful Xbox console ever. Play thousands of titles: Every game looks and plays better on Xbox Series X. At the heart of Series X is the Xbox Velocity. Architecture, which combines a custom SSD and built-in software to significantly reduce load times in and out of game. Switch between multiple games in an instant with Quick Resume. Explore new worlds and experience the action like never before with an unparalleled 12 teraflops of graphics processing power. Enjoy 4K gaming at up to 120 frames per second, premium advanced 3D sound, and more. 4K at 120 FPS: requires compatible content and display X version - with disc drive" }
{ "index": { "_id": 3 } }
{ "name": "Nintendo Switch", "brand": "Nintendo", "storage": "512GB", "price": 299.99, "description": "SHARPER, VIBRANT VISUALS. The new 7-inch screen on the Nintendo Switch OLED takes your gaming to the next level: vibrant colors with sharp contrasts for every moment. INTEGRATED GAMEPLAY. Enjoy the console's many multiplayer modes and connect with other players. Online or locally, the fun on the Nintendo Switch is guaranteed. ENJOY IMMERSION FOR LONGER. In addition to delivering an unparalleled experience, thanks to its improved audio, the Nintendo Switch has a rechargeable battery while you play. From 4.5 hours to 9 hours of battery life. INCLUDES SUPER MARIO BROS. WONDER. Transform your world with the phenomenal flowers in this new Mario game, full of amazing adventures, power-ups and new abilities. NINTENDO SWITCH ONLINE SUBSCRIPTION. Access online games, play with friends and enjoy the exclusive benefits of the Nintendo Switch Online subscription." }
{ "index": { "_id": 4 } }
{ "name": "Steam Deck", "brand": "Valve", "storage": "512GB", "price": 399.99, "description": "You can save games, apps, photos and videos without worrying about space. High-Level Performance: The 4-core processor and graphics ensure a dynamic experience and fast responses. High-Definition Images: Smooth transitions and sharp images provide complete immersion in the game. Wireless Connectivity: Wi-Fi technology allows you to play wherever you want, without wires or cables limiting your fun" }
{ "index": { "_id": 5 } }
{ "name": "Nintendo Switch Lite", "brand": "Nintendo", "storage": "512GB", "price": 299.99, "description": "MADE TO BE PORTABLE. Nintendo Switch Lite is designed specifically for portable gaming. The console lets you jump into your favorite games wherever you are. COMPACT AND LIGHTWEIGHT. With its sleek, lightweight design, this console is ready to hit the road wherever you are. COMPATIBLE GAMES. The Nintendo Switch Lite system plays the library of Nintendo Switch games that work in handheld mode. A WORLD OF COLOR TO CHOOSE FROM. Available in a variety of vibrant and unique colors, Nintendo Switch Lite lets you bring even more personality wherever you go." }<p>Now, let's retrieve classic facets by grouping the results by brand, storage, and price range. In the query, size:0 was defined. In this scenario, the goal is to retrieve only the aggregation results without including the documents corresponding to the query.</p>POST videogames/_search
{
  "size": 0,
  "aggs": {
    "brands": {
      "terms": { "field": "brand" }
    },
    "storage_sizes": {
      "terms": { "field": "storage" }
    },
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 300 },   
          { "from": 300, "to": 500 },  
          { "from": 500 }  
        ]
      }
    }
  }
}<p>The response will include counts for <strong>Brand</strong>, <strong>Storage</strong>, and <strong>Price</strong>, helping to create filters and facets.</p>"aggregations": {
   "brands": {
     "doc_count_error_upper_bound": 0,
     "sum_other_doc_count": 0,
     "buckets": [
       {
         "key": "Microsoft",
         "doc_count": 1
       },
       {
         "key": "Nintendo",
         "doc_count": 1
       },
       {
         "key": "Sony",
         "doc_count": 1
       },
       {
         "key": "Valve",
         "doc_count": 1
       }
     ]
   },
   "storage_sizes": {
     "doc_count_error_upper_bound": 0,
     "sum_other_doc_count": 0,
     "buckets": [
       {
         "key": "1TB",
         "doc_count": 2
       },
       {
         "key": "512GB",
         "doc_count": 2
       }
     ]
   },
   "price_ranges": {
     "buckets": [
       {
         "key": "*-300.0",
         "to": 300,
         "doc_count": 1
       },
       {
         "key": "300.0-500.0",
         "from": 300,
         "to": 500,
         "doc_count": 3
       },
       {
         "key": "500.0-*",
         "from": 500,
         "doc_count": 0
       }
     ]
   }
 }<h2>Machine learning/AI-based approach to filters and facets</h2><p>In this approach, Machine Learning (ML) models, including Artificial Intelligence (AI) techniques, analyze data attributes to generate relevant filters and facets. Instead of relying on predefined rules, ML/AI leverages indexed data characteristics. This enables the dynamic discovery of new facets and filters.</p><p><strong>Pros</strong>:</p><ul><li><p><strong>Automatic updates:</strong> New filters and facets are generated automatically, without the need for manual adjustments.</p></li><li><p><strong>Discovery of new attributes:</strong> It can identify <strong>previously unconsidered </strong>data characteristics as filters, enriching the search experience.</p></li><li><p><strong>Reduced manual effort:</strong> The team does not need to constantly define and update filtering rules as AI learns from available data.</p></li></ul><p><strong>Cons:</strong></p><ul><li><p><strong>Maintenance complexity:</strong> The use of models may require pre-validation to ensure the consistency of the generated filters.</p></li><li><p><strong>Requires ML and AI expertise:</strong> The solution demands qualified professionals to fine-tune and monitor model performance.</p></li><li><p><strong>Risk of irrelevant filters:</strong> If the model is not well-calibrated, it may generate facets that are not useful for users.</p></li><li><p><strong>Cost:</strong> The use of ML and AI may require third-party services, increasing operational costs.</p></li></ul><p>It's worth noting that even with a well-calibrated model and a well-crafted prompt, the generated facets should still go through a review step. This validation can be manual or based on moderation rules, ensuring that the content is appropriate and safe. While not necessarily a drawback, it is an important consideration to ensure the quality and suitability of the facets before they are made available to users.</p><h3>Implementation of filters/facets – AI approach</h3><p>In this demonstration, we will use an AI model to automatically analyze product characteristics and suggest relevant attributes. With a well-structured prompt, we extract information from the catalog and transform it into filters and facets. Below, we present each step of the process.</p><p>Initially, we will use the <strong>Inference API</strong> to register an endpoint for integration with an ML service. Below is an example of integration with <strong>OpenAI's service</strong>.</p>PUT _inference/completion/generate_filter_ia
{
   "service": "openai",
   "service_settings": {
       "api_key": "your-key",
       "model_id": "gpt-4o-mini"
   }
}<p>Now, we define the pipeline to execute the prompt and obtain the new filters generated by the model.</p>PUT /_ingest/pipeline/generate_filter_ai
{
   "processors": [
     {
       "script": {
         "source": """ctx.prompt = "You are an expert in data organization for search and product categorization. Your task is to analyze the following product and identify the best dynamic facets that can be used in an e-commerce search experience. Product: " + ctx.name + "description: " + ctx.description + "Instructions: - Analyze the product name and description. - Extract only the dynamic facets (technological features or product characteristics that can be inferred from the description, try to create max 3 facets by characteristics found). Put the values into an array. Using key and value, e.g. dynamic_facets: [{ \"name\": \"Gaming Experience\", \"value\": \"Haptic Feedback\" },{ \"name\": \"Gaming Experience\", \"value\": \"Adaptive Triggers\" } - Return only a JSON."
         """
       }
     },
     {
       "inference": {
         "model_id": "generate_filter_ia",
         "input_output": {
           "input_field": "prompt",
           "output_field": "result"
         }
       }
     },
     {
       "gsub": {
         "field": "result",
         "pattern": "```json",
         "replacement": ""
       }
     },
     {
       "json" : {
         "field" : "result",
         "strict_json_parsing": false,
         "add_to_root" : true
       }
     },
     {
       "remove": {
         "field": "result"
       }
     },
     {
       "remove": {
         "field": "prompt"
       }
     }
   ]
}<p>Running a simulation of this pipeline for the "PlayStation 5" product, with the following description:</p><p><em>Stunning Gaming: Marvel at stunning graphics and experience the features of the new PS5.</em></p><p><em>Breathtaking Immersion: Discover a deeper gaming experience with support for haptic feedback, adaptive triggers, and 3D Audio technology.</em></p><p><em>Slim Design: With the PS5 Digital Edition, gamers get powerful gaming technology in a sleek, compact design.</em></p><p><em>1TB of Storage: Have your favorite games ready and waiting for you to play with 1TB of built-in SSD storage.</em></p><p><em>Backward Compatibility and Game Boost: The PS5 console can play over 4,000 PS4 games. With Game Boost, you can even enjoy faster, smoother frame rates in some of the best PS4 console games.</em></p><p>Let's observe the prompt output generated from this simulation.</p>{
 "docs": [
   {
     "doc": {
       "_index": "index",
       "_version": "-3",
       "_id": "1",
       "_source": {
         "name": "Play Station 5",
         "result": """```json
{
 "dynamic_facets": [
   { "name": "Storage Capacity", "value": "1TB SSD" },
   { "name": "Graphics Technology", "value": "Stunning Graphics" },
   { "name": "Audio Technology", "value": "3D Audio" }
 ]
}
```""",
         "description": "Stunning Gaming: Marvel at stunning graphics and experience the features of the new PS5. Breathtaking Immersion: Discover a deeper gaming experience with support for haptic feedback, adaptive triggers, and 3D Audio technology. Slim Design: With the PS5 Digital Edition, gamers get powerful gaming technology in a sleek, compact design. 1TB of Storage: Have your favorite games ready and waiting for you to play with 1TB of built-in SSD storage. Backward Compatibility and Game Boost: The PS5 console can play over 4,000 PS4 games. With Game Boost, you can even enjoy faster, smoother frame rates in some of the best PS4 console games.",
         "model_id": "generate_filter_ia",
         "prompt": """You are an expert in data organization for search and product categorization. Your task is to analyze the following product and identify the best dynamic facets that can be used in an e-commerce search experience. Product: Play Station 5description: Stunning Gaming: Marvel at stunning graphics and experience the features of the new PS5. Breathtaking Immersion: Discover a deeper gaming experience with support for haptic feedback, adaptive triggers, and 3D Audio technology. Slim Design: With the PS5 Digital Edition, gamers get powerful gaming technology in a sleek, compact design. 1TB of Storage: Have your favorite games ready and waiting for you to play with 1TB of built-in SSD storage. Backward Compatibility and Game Boost: The PS5 console can play over 4,000 PS4 games. With Game Boost, you can even enjoy faster, smoother frame rates in some of the best PS4 console games.Instructions: - Analyze the product name and description. - Extract only the dynamic facets (technological features or product characteristics that can be inferred from the description, try create max 3 facets by characteristics found). Put the values like arrays. Using key and value, e.g. dynamic_facets: [{ "name": "Gaming Experience", "value": "Haptic Feedback" },{ "name": "Gaming Experience", "value": "Adaptive Triggers" } - Return only a JSON."""
       },
       "_ingest": {
         "timestamp": "2025-03-19T22:14:32.0161803Z"
       }
     }
   }
 ]
}<p>Now a new field, <strong>dynamic_facets</strong>, will be added to the new index to store the facets generated by the AI.</p>PUT videogames_1
{
 "mappings": {
   "properties": {
     "name": { "type": "text" },
     "brand": { "type": "keyword" },
     "storage": { "type": "keyword" },
     "price": { "type": "float" },
     "description": { "type": "text" },
     "dynamic_facets": { "type": "nested",
     "properties": { "name": { "type": "keyword" },
                     "value": { "type": "keyword" } } }
   }
 }
}<p>Using the <strong>Reindex API</strong>, we will reindex the <strong>videogames</strong> index to <strong>videogames_1</strong>, applying the <strong>generate_filter_ai</strong> pipeline during the process. This pipeline will automatically generate dynamic facets during indexing.</p>POST _reindex?wait_for_completion=false
{
 "source": {
   "index": "videogames"
 },
 "dest": {
   "index": "videogames_1",
   "pipeline": "generate_filter_ai"
 }
}<p>Now, we will run a search and get the new filters:</p>GET videogames_1/_search
{
 "size": 0,
 "query": {
   "match": {
     "name": "nintendo"
   }
 },
 "aggs": {
   "dynamic_facets": {
     "nested": {
       "path": "dynamic_facets"
     },
     "aggs": {
       "facets": {
         "terms": {
           "field": "dynamic_facets.name"
         },
         "aggs": {
           "facets": {
             "terms": {
               "field": "dynamic_facets.value"
             }
           }
         }
       }
     }
   }
 }
}<p>Results:</p>"aggregations": {
   "dynamic_facets": {
     "doc_count": 3,
     "facets": {
       "doc_count_error_upper_bound": 0,
       "sum_other_doc_count": 0,
       "buckets": [
         {
           "key": "Frame Rate",
           "doc_count": 1,
           "facets": {
             "doc_count_error_upper_bound": 0,
             "sum_other_doc_count": 0,
             "buckets": [
               {
                 "key": "120 FPS",
                 "doc_count": 1
               }
             ]
           }
         },
         {
           "key": "Gaming Resolution",
           "doc_count": 1,
           "facets": {
             "doc_count_error_upper_bound": 0,
             "sum_other_doc_count": 0,
             "buckets": [
               {
                 "key": "4K",
                 "doc_count": 1
               }
             ]
           }
         },
         {
           "key": "Graphics Processing Power",
           "doc_count": 1,
           "facets": {
             "doc_count_error_upper_bound": 0,
             "sum_other_doc_count": 0,
             "buckets": [
               {
                 "key": "12 Teraflops",
                 "doc_count": 1
               }
             ]
           }
         }
       ]
     }
   }
 }<p>To symbolize the implementation of the facets, below is a simple front-end:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb0d6aa40caf7a91a/6a170b86ab7f0839afdb9eb6/12b6d9d4f4d0985848d92841545fd22b7253ae6d-1600x1288.png" alt="implementation of the facets" /><p>The UI code presented is <a href="https://gist.github.com/andreluiz1987/06d9ec1b381e942e9def0e969bd811a0">here</a>.</p><h2>Conclusion</h2><p>Both approaches to creating filters and facets have their benefits and points of concern. The classic approach, based on manual rules, offers control and lower costs but requires constant updates and does not dynamically adapt to new products or features.</p><p>On the other hand, the AI ​​and Machine Learning-based approach automates facet extraction, making the search more flexible and allowing the discovery of new attributes without manual intervention. However, this approach can be more complex to implement and maintain, requiring calibration to ensure consistent results.</p><p>The choice between the classic and AI-based approaches depends on the needs and complexity of the business. For simpler scenarios, where data attributes are stable and predictable, the classic approach can be more efficient and easier to maintain, avoiding unnecessary costs with infrastructure and AI models. On the other hand, the use of ML/AI to extract facets can add significant value, improving the search experience and making filtering more intelligent.</p><p>The important thing is to evaluate whether automation justifies the investment or whether a more traditional solution already meets the business needs effectively.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/filters-facets-using-ml</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/filters-facets-using-ml</guid>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[ML Research]]></category>
    <dc:creator><![CDATA[Andre Luiz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4084864dcdaa25d3/6a170b880c485781f901aaa9/6f196643d573614fe5124705c7e4db9bfce004b0-1200x628.png" length="0" type="image/png"/>
    <pubDate>Thu, 03 Apr 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsesarch semantic search, leveled up: now with native match, knn and sparse_vector support]]></title>
    <description><![CDATA[Semantic text search becomes even more powerful, with native support for match, knn and sparse_vector queries. This allows us to keep the simplicity of the semantic query while offering the flexibility of the Elasticsearch query DSL. ]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch’s <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-semantic-query.html">semantic query</a> is incredibly powerful, allowing users to perform semantic search over data configured in <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-text.html">semantic_text</a> fields. Much of this power lies in simplicity: just set up a <code>semantic_text</code> field with the inference endpoint you want to use, and then ingest content as if indexing content into a regular <code>text</code> field. The inference happens automatically and transparently, making it simple to set up and use a search index with semantic functionality.</p><p></p><p>This ease of use does come with some tradeoffs: we simplified semantic search with <code>semantic_text</code> by making judgments on default behavior that fit the majority of use cases. Unfortunately, this means that some customizations available for traditional vector search queries aren’t present in the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-semantic-query.html">semantic query</a>. We didn’t want to add all of these options directly to the <code>semantic</code> query, as that would undermine the simplicity that we strive for. Instead, we expanded the queries that support the <code>semantic_text</code> field, leaving it up to you to choose the best query that meets your needs.</p><p></p><p>Let’s walk through these changes, starting with creating a simple index with a semantic_text field:</p>PUT index-songs-semantic
{
  "mappings": {
    "properties": {
      "song_title": {
        "type": "text"
      },
      "artist": {
        "type": "keyword"
      },
      "lyric": {
        "type": "semantic_text"
      }
    }
  }
}

// Now index a sample document
POST index-songs-semantic/_doc/1
{
  "song_title": "...Baby One More Time",
  "artist": "Britney Spears",
  "lyric": "When I'm with you, I lose my mind, give me a sign"
}
<p></p><h2>We made match happen in semantic search!</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc5e9a7041fc40292/6a17eeda414c646ff494524f/9b15e17822e297e35a46393ef2a7c1e6d55fedd9-1792x1024.png" alt="" /><p></p><p>First and most importantly, the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html">match query</a> will now work with <code>semantic_text</code> fields!</p><p></p><p>This means that you can change your old semantic query:</p>GET index-songs-semantic/_search
{
  "query": {
    "semantic": {
      "field": "lyric",
      "query": "song lyrics about love"
    }
  }
}
<p></p><p>Into a simple <code>match</code> query:</p>GET index-songs-semantic/_search
{
  "query": {
    "match": {
      "lyric": "song lyrics about love"
    }
  }
}
<p></p><p>We can see the benefits of semantic search here because we’re searching for “song lyrics about love”, none of which appears in the indexed document. This is because of ELSER’s text expansion.</p><p></p><p>But wait, it gets better!</p><p></p><p>If you have multiple indices, and the same field name is <code>semantic_text</code> in one field and perhaps text in the other field, you can still run <code>match</code> queries against these fields. Let’s create another index, with the same field names, but different types (<code>text</code> instead of <code>semantic_text</code>). Here’s a simple example to illustrate:</p>// Setup - Create a similar index without semantic fields
PUT index-songs-lexical
{
  "mappings": {
    "properties": {
      "song_title": {
        "type": "text"
      },
      "artist": {
        "type": "keyword"
      },
      "lyric": {
        "type": "text"
      }
    }
  }
}

POST index-songs-lexical/_doc/2
{
  "song_title": "Crazy",
  "artist": "Britney Spears",
  "lyric": "You drive me crazy, I just can't sleep, I'm so excited, I'm in too deep"
}

GET index-songs-semantic,index-songs-lexical/_search
{
  "query": {
    "match": {
      "lyric": "crazy"
    }
  }
}
<p></p><p>Here, searching for “crazy” brings up both the lexical match that has “crazy” in the title, and the semantic lyric “lose my mind.”</p><p></p><p>There are some caveats to keep in mind when using the <code>match</code> functionality with <code>semantic_text</code>:</p><ul><li><p>The underlying <code>semantic_text</code> field has a limitation where you can’t use multiple inference IDs on the same field. This limitation extends to <code>match</code><strong>—</strong>meaning that if you have two semantic_text fields with the same name, they need to have the same inference ID or you’ll get an error. You can work around this by creating different names and querying them in a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html">boolean query</a> or a compound <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/retrievers-overview.html">retriever</a>.</p></li><li><p>Depending on what model you use, the scores between lexical (text) matches and semantic matches will likely be very different. In order to get the best ranking of results, we recommend using second stage rerankers such as <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-reranking.html">semantic reranking</a> or <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html">RRF</a>.</p></li></ul><p></p><p>Semantic search using the <code>match</code> query is also available in <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-functions-operators.html#esql-match">ES|QL</a>! Here’s the same example as above, but using ES|QL:</p><p></p><h2>Expert-level semantic search with knn and sparse_vector</h2><p>Match is great, but sometimes you want to specify more vector search options than the semantic query supports. Remember, the tradeoff of making the semantic query as simple as it is involved making some decisions on default behavior.</p><p></p><p>This means that if you want to take advantage of some of the more advanced vector search features, perhaps <code>num_candidates</code> or <code>filter</code> from the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-knn-query.html">knn query</a> or <a href="https://www.elastic.co/search-labs/blog/text-expansion-pruning">token pruning</a> in the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-sparse-vector-query.html">sparse_vector query</a>, you won’t be able to do so using the semantic query.</p><p></p><p>In the past, we provided some workarounds to this, but they were convoluted and required knowing the inner workings and architecture of the <code>semantic_text</code> field and constructing a corresponding nested query. If you’re doing that workaround now, it will still work—however, we now support query DSL using <code>knn</code> or <code>sparse_vector</code> queries on <code>semantic_text</code> fields.</p><p></p><h3>All about that dense (vector), no trouble</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf6651a79c3e56345/6a17eedcb1e113820979f31d/aa3ea406fc072698db510a0b2ab64eb0266948c1-1792x1024.png" alt="" /><p>Here’s an example script that populates a <code>text_embedding</code> model and queries a <code>semantic_text</code> field using the knn query:</p>PUT index-dense-semantic-songs
{
  "mappings": {
    "properties": {
      "song_title": {
        "type": "text"
      },
      "artist": {
        "type": "keyword"
      },
      "lyric": {
        "type": "semantic_text",
        "inference_id": ".multilingual-e5-small-elasticsearch"
      }
    }
  }
}

// Index sample documents
POST index-dense-semantic-songs/_doc/4
{
  "song_title": "Oops! ...I Did It Again",
  "artist": "Britney Spears",
  "lyric": "Oops, I did it again, I played with your heart, got lost in the game."
}

POST index-dense-semantic-songs/_doc/5
{
  "song_title": "Poker Face",
  "artist": "Lady Gaga",
  "lyric": "Can't read my, can't read my, no, he can't read my poker face"
}

GET index-dense-semantic-songs/_search
{
  "query": {
    "knn": {
      "field": "lyric",
      "k": 10,
      "num_candidates": 100,
      "query_vector_builder": {
        "text_embedding": {
          "model_text": "game"
        }
      }
    }
  }
}
<p></p><p>The <code>knn</code> query can be modified with extra options to enable more advanced queries against the semantic_text field. Here, we perform the same query but add a pre-filter against the <code>semantic_text</code> field:</p>GET index-dense-semantic-songs/_search
{
  "query": {
    "knn": {
      "field": "lyric",
      "k": 10,
      "num_candidates": 100,
      "query_vector_builder": {
        "text_embedding": {
          "model_text": "game"
        }
      },
      "filter": {
        "term": {
          "artist": "Britney Spears"
        }
      }
    }
  }
}
<p></p><h3>Keepin’ it sparse (vector), keepin’ it real</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt540cbe07b8eeb546/6a17eedfe8fbce91343a1a09/1af6680a3bae23b0f1b3fd4605ba6cb3184318e2-1792x1024.png" alt="" /><p></p><p>Similarly, sparse embedding models can be queried more specifically using <code>semantic_text</code> fields as well. Here’s an example script that adds a few more documents and uses the <code>sparse_vector</code> query:</p>POST index-songs-semantic/_doc/6
{
  "song_title": "Crazy In Love",
  "artist": "Beyoncé",
  "lyric": "Looking so crazy, your love's got me looking, got me looking so crazy in love"
}


POST index-songs-semantic/_doc/7
{
  "song_title": "Complicated",
  "artist": "Avril Lavigne",
  "lyric": "Why'd you have to go and make things so complicated?, I see the way you're acting like you're somebody else"
}

GET index-songs-semantic/_search
{
  "query": {
    "sparse_vector": {
      "field": "lyric",
      "query": "crazy"
    }
  }
}
<p></p><p>The <code>sparse_vector</code> query can be modified with extra options, to enable more advanced queries against the <code>semantic_text</code> field. Here, we perform the same query but add <a href="https://www.elastic.co/search-labs/blog/text-expansion-pruning">token pruning</a> against a <code>semantic_text</code> field:</p>GET index-songs-semantic/_search
{
  "query": {
    "sparse_vector": {
      "field": "lyric",
      "query": "crazy",
      "prune": true,
      "pruning_config": {
        "tokens_freq_ratio_threshold": 1,
        "tokens_weight_threshold": 0.4,
        "only_score_pruned_tokens": false
      }
    }
  }
}
<p>This example significantly decreases the token frequency ratio required to pruning, which helps us show differences with such a small dataset, though they’re probably more aggressive than you’d want to see in production (remember, token pruning is about pruning irrelevant tokens to improve performance, not drastically change recall or relevance). You can see in this example that the Avril Lavigne song is no longer returned, and the scores have changed due to the pruned tokens. (Note that this is an illustrative example, and we still <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-sparse-vector-query.html#sparse-vector-query-with-pruning-config-and-rescore-example">recommend a rescore adding pruned tokens back into scoring</a> for most use cases).</p><p></p><p>You’ll note that with all of these queries if you’re only querying a <code>semantic_text</code> field, you no longer need to specify the inference ID in <code>knn</code>’s <code>query_vector_builder</code> or in the <code>sparse_vector</code> query. This is because it will be inferred from the <code>semantic_text</code> field. You <em>can</em> specify it if you want to override with a different (compatible!) inference ID for some reason or if you’re searching combined indices that have both <code>semantic_text</code> and <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/sparse-vector.html">sparse_vector</a> or <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html">dense_vector</a> fields though.</p><h2>Try it out yourself</h2><p>We’re keeping the original <code>semantic</code> query simple, but expanding our semantic search capabilities to power more use cases and seamlessly integrate semantic search with existing workflows. These power-ups are native to Elasticsearch and are already available in Serverless. They’ll be available in stack-hosted Elasticsearch starting with version 8.18.</p><p></p><p>Try it out today!</p><p></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/semantic-search-match-knn-sparse-vector</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/semantic-search-match-knn-sparse-vector</guid>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Kathleen DeRusso]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf6651a79c3e56345/6a17eedcb1e113820979f31d/aa3ea406fc072698db510a0b2ab64eb0266948c1-1792x1024.png" length="0" type="image/png"/>
    <pubDate>Thu, 06 Mar 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Introducing Learning To Rank (LTR) in Elasticsearch]]></title>
    <description><![CDATA[Discover how Learning To Rank (LTR) can help you to improve your search ranking and how to implement it in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>Starting with Elasticsearch 8.13, we provide an implementation of <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">Learning To Rank</a> (LTR) natively integrated into Elasticsearch. LTR uses a trained machine learning (ML) model to build a ranking function for your search engine. Typically, the model is used as a second stage re-ranker, to improve the relevance of search results returned by a simpler, first stage retrieval algorithm.</p><p>This blog post will explain how this new feature can help in improving your document ranking in text search and how to implement it in Elasticsearch.</p><p>Whether you are trying to optimize an eCommerce search, build the best context for a Retrieval Augmented Generation(RAG) application or craft a question answering based search on millions of academic papers, you have probably realized how challenging it can be to accurately optimize document ranking in a search engine. That's where Learning to Rank comes in.</p><h2>Understanding relevance features and how to build a scoring function</h2><p>Relevance features are the signals to determine how well a document matches a user's query or interest, all of which impact <a href="https://www.elastic.co/what-is/search-relevance">search relevance</a>. These features can vary significantly depending on the context, but they generally fall into several categories. Let’s take a look at some common relevance features used across different domains:</p><ul><li><p><strong>Text Relevance Scores</strong> (e.g., <a href="https://www.elastic.co/blog/practical-bm25-part-2-the-bm25-algorithm-and-its-variables">BM25</a>, TF-IDF): Scores derived from text matching algorithms that measure the similarity of document content to the search query. These scores can be obtained from Elasticsearch.</p></li><li><p><strong>Document Properties</strong> (e.g., price of a product, publication date): Features that can be extracted directly from the stored document.</p></li><li><p><strong>Popularity Metrics</strong> (e.g., click-through rate, views): Indicators of how popular or frequently accessed a document is. Popularity metrics can be obtained with <a href="https://www.elastic.co/enterprise-search/search-analytics">Search analytics</a> tools, of which Elasticsearch provides out-of-the-box.</p></li></ul><p>The scoring function combines these features to produce a final relevance score for each document. Documents with higher scores are ranked higher in search results.</p><p>When using the Elasticsearch Query DSL, you are implicitly writing a scoring function that weights relevance features and ultimately defines your search relevance</p><h2>Scoring in the Elasticsearch Query DSL</h2><p>Consider the following example query:</p>{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "the quick brown fox",
          "fields": ["title^10", "content"]
        }
      },
      "field_value_factor": {
        "field": "monthly_views",
        "modifier": "log1p"
      }
    }
  }
}
<p>This query translates into the following scoring function:</p>score = 10 x title_bm25_score + content_bm25_score + log(1+ monthly_views)
<p>While this approach works well, it has a few limitations:</p><ul><li><p><strong>Weights are estimated</strong>: The weights assigned to each feature are often based on heuristics or intuition. These guesses may not accurately reflect the true importance of each feature in determining relevance.</p></li><li><p><strong>Uniform Weights Across Documents</strong>: Manually assigned weights apply uniformly to all documents, ignoring potential interactions between features and how their importance might vary across different queries or document types. For instance, the relevance of recency might be more significant for news articles but less so for academic papers.</p></li></ul><p>As the number of features and documents increases, these limitations become more pronounced, making it increasingly challenging to determine accurate weights. Ultimately, the chosen weights become a compromise, potentially leading to suboptimal ranking in many scenarios.</p><p>A compelling alternative is to replace the scoring function that uses manual weights by a ML-based model that computes the score using relevance features.</p><h2>Hello Learning To Rank (LTR)!</h2><p><a href="https://www.microsoft.com/en-us/research/uploads/prod/2016/02/MSR-TR-2010-82.pdf">LambdaMART</a> is a popular and effective LTR technique that uses gradient boosting decision trees <a href="https://en.wikipedia.org/wiki/Gradient_boosting#Gradient_tree_boosting">(GBDT</a>) to learn the optimal scoring function from a judgment list.</p><p>The judgment list is a dataset that contains pairs of queries and documents, along with their corresponding relevance labels or grades. Relevance labels are typically either binary, (e.g. relevant/irrelevant) or graded (e.g between 0 for completely irrelevant and 4 for highly relevant). Judgment lists can be created manually by humans or be generated from user engagement data, such as clicks or conversions.</p><p>The example below uses a graded relevance judgment.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt36917248d619fee7/6a170e0b66c4f9484cf8c0d0/297f931b6b1565aaf4b7de9648fa73f145337c45-798x560.png" alt="judment list example" /><p>LambdaMART treats the ranking problem as a regression task using a decision tree where the inner nodes of the tree are conditions over the relevance features, and the leaves are the predicted scores.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0c683e8ebff2a960/6a170e0d4a531be33636a9e7/07ce40d7902e8b7cbd6246a369c7f36191c18937-1440x864.png" alt="decision tree example" /><p>LambdaMART uses a gradient boosted tree approach, and in the training process it builds multiple decision trees where each tree corrects errors of its predecessors. This process aims to optimize a ranking metric like NDCG, based on examples from the judgment list. The final model is a weighted sum of individual trees.</p><p><a href="https://xgboost.readthedocs.io/en/stable/">XGBoost</a> is a well known library that provides an <a href="https://xgboost.readthedocs.io/en/stable/tutorials/learning_to_rank.html">implementation</a> of LambdaMART, making it a popular choice to implement ranking based on gradient boosting decision trees.</p><h2>Getting started with LTR in Elasticsearch</h2><p>Starting with version 8.13, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">Learning To Rank</a> is integrated directly into Elasticsearch and associated tooling as a technical preview feature.</p><h3>Train and deploy an LTR model to Elasticsearch</h3><p><a href="https://eland.readthedocs.io/en/v8.13.1/">Eland</a> is our Python client and toolkit for DataFrames and machine learning in Elasticsearch. Eland is compatible with most of the standard Python data science tools like Pandas, scikit-learn and XGBoost.</p><p>We highly recommend using it to train and deploy your LTR XGBoost model, as it provides features to simplify this process:</p><ol><li><p>The first step of the training process is to define the relevant features of the LTR model. Using the Python code below, you can specify the relevant features using the Elasticsearch Query DSL.</p></li></ol>from eland.ml.ltr import LTRModelConfig, QueryFeatureExtractor

feature_extractors=[
    # We want to use the score of the match query for the fields title and content as a feature:
    QueryFeatureExtractor(
        feature_name="title_bm25_score",
        query={"match": {"title": "{{query_text}}"}}
    ),
    QueryFeatureExtractor(
        feature_name="content_bm25_score",
        query={"match": {"content": "{{query_text}}"}}
    ),
    # We can use a script_score query to get the value
    # of the field popularity directly as a feature
    QueryFeatureExtractor(
        feature_name="popularity",
        query={
            "script_score": {
                "query": {"exists": {"field": "popularity"}},
                "script": {"source": "return doc['popularity'].value;"},
            }
        },
    )
]

ltr_config = LTRModelConfig(feature_extractors)
<ol><li><p>The second step of the process is to build your training dataset. At this step you will compute and add relevance features for each rows of your judgment list:</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1a1668945beda7f7/6a170e0e8b73cb45ff18a0c0/9648cf6585f91bba82502548bf48092c5d3ce251-1360x718.png" alt="judgment kist with features example" /><p>To help you with this task, Eland provides the FeatureLogger class:</p>from eland.ml.ltr import FeatureLogger

feature_logger = FeatureLogger(es_client, MOVIE_INDEX, ltr_config)

feature_logger.extract_features(
    query_params={"query": "foo"},
    doc_ids=["doc-1", "doc-2"]
)
<ol><li><p>When the training dataset is built, the model is trained very easily (as also shown in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb#Building-the-training-dataset">notebook</a>):</p></li></ol>from xgboost import XGBRanker
from sklearn.model_selection import GroupShuffleSplit

# Create the ranker model:
ranker = XGBRanker(
    objective="rank:ndcg",
    eval_metric=["ndcg@10"],
    early_stopping_rounds=20,
)

# Shaping training and eval data in the expected format.
X = judgments_with_features[ltr_config.feature_names]
y = judgments_with_features["grade"]
groups = judgments_with_features["query_id"]

# Split the dataset in two parts respectively used for training and evaluation of the model.
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]

# Training the model
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,
)
<ol><li><p>Deploy your model to Elasticsearch once the training process is complete:</p></li></ol>from eland.ml import MLModel

LEARNING_TO_RANK_MODEL_ID = "ltr-model-xgboost"

MLModel.import_ltr_model(
    es_client=es_client,
    model=trained_model,
    model_id=LEARNING_TO_RANK_MODEL_ID,
    ltr_model_config=ltr_config,
    es_if_exists="replace",
)
<p>To learn more about how our tooling can help you to train and deploy the model, check out this end-to-end <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb#Building-the-training-dataset">notebook</a>.</p><h3>Use your LTR model as a rescorer in Elasticsearch</h3><p>Once you deploy your model in Elasticsearch, you can enhance your search results through a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.13/filter-search-results.html#rescore">rescorer</a>. The rescorer allows you to refine a first-pass ranking of search results using the more sophisticated scoring provided by your LTR model:</p>GET my-index/_search
{
  "query": {
    "multi_match": {
      "fields": ["title", "content"],
      "query": "the quick brown fox"
    }
  },
  "rescore": {
    "learning_to_rank": {
      "model_id": "ltr-model-xgboost",
      "params": {
        "query_text": "the quick brown fox"
      }
    },
    "window_size": 100
  }
}
<p>In this example:</p><ul><li><p>First-pass query: <code>The multi_match</code> query retrieves documents that match the query <code>the quick brown fox</code> in the title and content fields. This query is designed to be fast and capture a large set of potentially relevant documents.</p></li><li><p>Rescore phase: The <code>learning_to_rank</code> rescorer refines the top results from the first-pass query using the LTR model. </p><ul><li><p><code>model_id</code>: Specifies the ID of the deployed LTR model (<code>ltr-model-xgboost</code> in our example).</p></li><li><p><code>params</code>: Provides any parameters required by the LTR model to extract features relevant to the query. Here <code>query_text</code> allows you to specify the query issued by the user that some of our features extractors expect.</p></li><li><p><code>window_size</code>: Defines the number of top documents from the search results issued by the first-pass query to be rescored. In this example, the top 100 documents will be rescored.</p></li></ul></li></ul><p>By integrating LTR as a two stage retrieval process, you can can optimize both performance and accuracy of your retrieval process by combining:</p><ul><li><p>Speed of Traditional Search: The first-pass query retrieves a large number of documents with a broad match very quickly, ensuring fast response times.</p></li><li><p>Precision of Machine Learning Models: The LTR model is applied only to the top results, refining their ranking to ensure optimal relevance. This targeted application of the model enhances precision without compromising overall performance.</p></li></ul><h2>Try LTR yourself!?</h2><p>Whether you are struggling to configure search relevance for an eCommerce platform, aiming to improve the context relevance of your RAG application, or you are simply curious about enhancing your existing search engine's performance, you should consider LTR seriously.</p><p>To start your journey with implementing LTR, make sure to visit our <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb#Building-the-training-dataset">notebook</a> detailing how to train, deploy, and use an LTR model in Elasticsearch and to read our <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb#Building-the-training-dataset">documentation</a>. Let us know if you built anything based on this blog post or if you have questions on our <a href="https://discuss.elastic.co/">Discuss forums</a> and <a href="https://communityinviter.com/apps/elasticstack/elastic-community">the community Slack channel</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction</guid>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Aurélien Foucret]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4a3fba607900049/6a170e0fcdacbf8fe17d2a7a/8b3b5910abfe16d48d309341a0027008b16c4340-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 15 Jul 2024 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>