<?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[Aurélien Foucret - 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[Aurélien Foucret - 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/author/aurelien-foucret</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/aurelien-foucret</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/aurelien-foucret.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Wed, 16 Sep 2026 16:06:49 GMT</lastBuildDate>
  <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[Using ES|QL COMPLETION + an LLM to write a Chuck Norris fact generator in 5 minutes]]></title>
    <description><![CDATA[Discover how to use the ES|QL COMPLETION command to turn your Elasticsearch data into creative output using an LLM in just a few lines of code.]]></description>
    <content:encoded><![CDATA[<p>What if you could turn your Elasticsearch data into creative output using an LLM—in just a few lines of code? With the new <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/completion">COMPLETION command</a> in <strong>ES|QL</strong>, now you can.</p><p>Let’s build something fun to show it off: a Chuck Norris fact generator. We'll combine movie descriptions with a GPT model to generate facts so legendary even Rambo would be impressed.</p><h2>What you'll need</h2><ul><li><p>Access to an LLM (like OpenAI’s GPT-4o in our example below)</p></li><li><p>A dataset of movie descriptions </p></li></ul><p>You can download a <a href="https://www.kaggle.com/datasets/ursmaheshj/top-10000-popular-movies-tmdb-05-2023?resource=download">sample dataset</a> from Kaggle and upload it to your Elasticsearch cluster using the Data Visualizer in Kibana or the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk"><code>_bulk</code></a><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk"> API</a>.</p><h2>Setting up the inference endpoint</h2><p>Before you can run the <code>COMPLETION</code> command, you need to create an inference endpoint for the model you want to use via the <code>_inference</code>  API.</p><p>Here’s how to set up GPT-4o with OpenAI:</p><p>Once this is in place, you can reference <code>my-gpt-4o-endpoint</code> directly in your query.</p><h2>The query</h2><p>Here’s the magic in action. This single <strong>ES|QL</strong> query handles the entire workflow: it finds a movie based on your input, constructs a prompt from its description, and then calls the LLM to generate a legendary Chuck Norris fact. Below is the full <strong>ES|QL</strong> query that powers our Chuck Norris fact generator. It takes in a movie query, retrieves the most relevant description, turns it into a prompt, and sends it off to the LLM—all in a single, piped query.</p><p>Here’s what comes back:</p><p>Yes, the model really said that. 💪🐐🚁</p><h2>Dissecting the query</h2><p>Let’s dissect the query and break down what’s happening, step by step.</p><h3>Step 1: Retrieve relevant movie data</h3><p>We begin by searching for the most relevant movie for the user query.
We use the <code>MATCH</code> function to search both the title and overview fields for the text provided by the <code>query</code> parameter, keeping only the first result, sorted by relevance using the metadata <code>_score</code> field:</p><p>This narrows down our dataset to the best match, giving us the movie's title and description, which will become the context for the LLM.</p><h3>Step 2: Build the prompt from the context</h3><p>Now we create the input prompt for the LLM by concatenating a static instruction provided as a query parameter, denoted by <code>?instruction</code>, with the movie’s overview:</p><p>This creates a new <code>prompt</code> column combining the provided instruction with the overview field from the returned document, which for our request looks a bit like this:</p>Generate a Chuck Norris Fact from the following description:
Combat has taken its toll on Rambo, but he's finally begun to find inner peace in a monastery. When Rambo's friend and mentor Col. Trautman asks for his help on a top secret mission to Afghanistan, Rambo declines but must reconsider when Trautman is captured.<p>You can easily swap in different instructions to change the tone or style of what the LLM generates by tweaking the instruction parameter. And because the prompt is just another <strong>ES|QL</strong> expression, you can compose it with any string-generating function—whether it’s simple concatenation, conditional logic, or even formatting based on your document content.</p><h3>Step 3: Generate text using the LLM</h3><p>Finally, we pass the prompt to the inference endpoint connected to our model using our new <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/completion"><code>COMPLETION</code></a><a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/completion"> command</a>, and select which fields to return:</p><p>The result? A Chuck Norris fact, rooted in your movie data without any extra tooling required.</p><p>This example also demonstrates the full power of ES|QL's piped structure. Each step flows naturally into the next, letting you express a full retrieval augmented generation (RAG) pipeline in a single, declarative query. It’s clean, composable, and stays entirely inside Elasticsearch.</p><h2>What’s next?</h2><p>While the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/completion">COMPLETION command</a> is still a tech preview, this new feature unlocks a whole new world of possibilities—from summarization and content generation to enrichment and storytelling. Try it yourself! Point it at your favorite movie, tweak the prompt, or go wild and generate haikus from SQL errors. The power is yours.</p><p>Let us know what you build! 💬</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-completion-command-llm-fact-generator</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-completion-command-llm-fact-generator</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Aurélien Foucret]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbca7a25e3c272aa8/6a17ffb1be60868d100049d2/6494d88d51edf6a5b31a92b8439792354eae7190-1536x1024.png" length="0" type="image/png"/>
    <pubDate>Thu, 28 Aug 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>