<?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[Ioana Tagirta - 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[Ioana Tagirta - 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/ioana-alina-tagirta</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/ioana-alina-tagirta</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/ioana-alina-tagirta.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Sun, 13 Sep 2026 22:58:47 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Elasticsearch ES|QL brings full-text search to data you never indexed]]></title>
    <description><![CDATA[MATCH and TO_TEXT bring full-text search to data you never indexed. Search computed columns, unmapped fields and federated sources in ES|QL.]]></description>
    <content:encoded><![CDATA[<p>ES|QL <code>MATCH</code> now runs full-text search on data you never indexed. Computed columns, unmapped fields, strings assembled on the fly, even federated data sitting in S3. The new <code>TO_TEXT</code> function tells ES|QL to treat any string as analyzable text, so <code>MATCH</code> can tokenize, case-fold and term-match values that exist only for the lifetime of a query. This goes beyond the <code>LIKE</code> and <code>RLIKE</code> pattern matching that most query engines offer for unindexed strings: it's real analysis. Available now in Elastic Cloud Serverless and as a technical preview in Elasticsearch 9.5.</p><h2>How MATCH and TO_TEXT enable full-text search on any ES|QL expression</h2><p>Let's start with a query that was impossible in Elasticsearch 9.4, which uses <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/eval">the <code>EVAL</code> command</a>:</p><p>In this example, <code>summary</code> has no mapping or analyzer configuration. It’s also not associated with any inverted index. It exists only for the lifetime of this query, but now you can search it anyway. Two additions make this work.</p><p>First, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/search-functions/match"><code>MATCH</code></a> now accepts any expression as its first argument, not just a mapped field. That includes columns produced by <code>EVAL</code> and function results used inline. It also includes unmapped fields loaded directly from the original document. Furthermore, all data types normally accepted by <code>MATCH</code> are supported in this new use case.</p><p>The second part of this is the new <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/type-conversion-functions/to_text"><code>TO_TEXT</code></a> function, which is the first ES|QL conversion function that produces output of type <code>text</code>. Until now, <code>text</code> columns could only come from indexed mapped fields, and all strings produced by ES|QL expressions were <code>keyword</code> values rather than <code>text</code>. The distinction matters because <code>MATCH</code> treats the two differently: <code>text</code> values are analyzed, while <code>keyword</code> values are compared exactly, mirroring how a <code>MATCH</code> query on an indexed keyword field rewrites to a term query. <code>TO_TEXT(x)</code> is how you tell ES|QL: <em>treat this string as full text</em>.</p><p>This ships as a technical preview in Elasticsearch 9.5, and as such, it has some limitations:</p><ul><li><p>It’s currently filtering only. A <code>MATCH</code> on an expression doesn't contribute to the relevance score yet; only matches on indexed fields affect the score.</p></li><li><p>Querying options like <code>fuzziness</code> and others aren't yet supported when matching an expression.</p></li><li><p>Runtime text is analyzed with the standard analyzer. This isn’t configurable yet.</p></li></ul><p>Work is underway to address these limitations.</p><h2>Why use full-text search instead of LIKE or RLIKE in ES|QL?</h2><p>ES|QL already had two ways to search strings without an index: <code>LIKE</code> (wildcard patterns) and <code>RLIKE</code> (regular expressions). Both work on any string expression, so it's fair to ask what <code>MATCH</code> adds. The answer is <a href="https://www.elastic.co/docs/manage-data/data-store/text-analysis">analysis</a>, a more advanced form of search which uses techniques such as stemming and synonyms. It also uses stopword handling.</p><p><code>LIKE</code> is simple substring matching, without any understanding of the words that comprise a string. Say, for example, you're looking for log messages about a fox:</p><p>This misses <code>"Fox spotted near the henhouse"</code> due to the capitalization, while matching <code>"Outfoxed by the competition"</code>, which isn't about a fox at all. It fails in both directions, with false negatives on capitalization and false positives on substrings buried inside other words.</p><p>Regular expressions can patch the case problem, but the word-boundary problem gets ugly fast. Something like:</p><p>And even that's not right yet. It misses a fox at the end of a sentence followed by <code>!</code> or <code>?</code>, and it says nothing about tabs, quotes, or parentheses. Each fix makes the pattern longer, and the next person to read the query has to reverse-engineer what it’s actually doing.</p><p><code>MATCH</code> makes the problem go away, because it runs both the query and the value through an <a href="https://www.elastic.co/docs/reference/text-analysis/analyzer-reference">analyzer</a>, which tokenizes text into lowercase terms and then matches term against term:</p><p>This query will match values like <code>"The quick brown fox"</code> and <code>"FOX spotted near the henhouse"</code> but not <code>"Outfoxed by the competition"</code> or <code>“FOXTROT protocol enabled"</code>, regardless of any punctuation surrounding the words. Of course, this all works for multi-term queries, like <code>MATCH(TO_TEXT(message), "brown fox")</code>, too, just the way you’d expect it to.</p><p>Work is underway to enable the use of the <a href="https://www.elastic.co/docs/reference/text-analysis/analysis-lang-analyzer">36 dedicated language analyzers</a>, with support for natural languages on data that was never indexed or mapped.</p><h2>Full-text search use cases for unindexed and unmapped data</h2><p>The examples above searched values computed from <a href="https://www.elastic.co/docs/manage-data/data-store/mapping">mapped fields</a>. . The more interesting use cases for ES|QL <code>MATCH</code> on expressions involve data that was never searchable at all. Let's walk through a few.</p><h3>How to search unmapped fields in ES|QL without adding a mapping</h3><p>Sometimes you deliberately leave a field out of your mappings, such as a verbose stack trace or a raw request payload. You might even leave out a debug blob. Indexing one of these would cost disk and heap space on every document and wouldn’t be worth it for a field you might query once a quarter.</p><p>That decision has always been final, because <a href="https://www.elastic.co/search-labs/blog/esql-unmapped-fields">unmapped fields</a> were invisible to queries entirely. In Elasticsearch 9.5, you can use <code>SET unmapped_fields="load"</code> to make ES|QL load unmapped fields directly from the source document as keywords. Follow that up by wrapping it in <code>TO_TEXT</code>, and now you can run full-text search on it:</p><p>Here, <code>stack_trace</code> was never mapped. Every value is fetched from the original documents and analyzed on the fly. They’re matched row by row. That’s real work, and it will never be as fast as an <a href="https://www.elastic.co/docs/manage-data/data-store/index-basics">inverted index</a> lookup. But now, that field you didn't index is no longer unsearchable. You get to keep the mapping small for the everyday case and still answer the once-a-quarter question when it matters.</p><h3>Full-text search on a keyword field without reindexing</h3><p>Keyword fields can do a lot. They give you exact matching, fast aggregations, and sorting, which is why so many fields end up mapped that way. But mappings are decided when data arrives, and it’s easy to end up in a situation where you want to do something different with your data than you had originally intended. Maybe <code>product_name</code> was mapped as a <code>keyword</code> because the dashboards aggregate on it, and then, after receiving a year’s worth of product data, someone wants to be able to search within <code>product_name</code> values.</p><p>The old answer was to change the mapping to <code>text</code> (or add a multi-field) and reindex everything. This can be both time-consuming and costly, and in many cases, users simply won’t want to bother with it. The new answer is one function call:</p><p><code>TO_TEXT</code> converts the <code>keyword</code> values to <code>text</code> on the fly, so <code>MATCH</code> analyzes them instead of comparing them exactly. This allows you to query a <code>keyword</code> field without creating a mapping or reindexing the source document. If the search becomes an everyday query, indexing the field as <code>text</code> is still the right long-term move, but <code>TO_TEXT</code> gets you an answer today, without any extra work.</p><h3>Searching the same field across indices with different mappings</h3><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-multi-index">ES|QL can span many indices</a>, and the same field doesn't need to always look the same in all of them. When the same field has different types in different indices, ES|QL treats it as a union type, and a conversion function resolves the conflict. Let’s consider an example in which the <code>message</code> field has type <code>text</code> in this year's index template but was a <code>keyword</code> in last year's:</p><p>Every value is analyzed at query time, whether it came from the <code>text</code> index or the <code>keyword</code> one. The keyword values from the older indices are tokenized and lowercased like everything else, so "connection reset" finds “Connection RESET by peer”, no matter which index it lives in.</p><p>Another interesting case is when a field is mapped in only one index but also present (and unmapped) in the other:</p><p>There's a nuance worth calling out here. If <code>error_details</code> is mapped in <code>logs-2026</code> but not in <code>logs-2025</code>, Elasticsearch cannot push this query down to <a href="https://lucene.apache.org/">Lucene</a>, because the indices where the field is unmapped would silently return no matches. Instead, the planner notices that the field is potentially unmapped and evaluates the whole <code>MATCH</code> row by row, wherever the rows came from. You don't have to know which of your indices have the field mapped; the query just answers the question.</p><h2>How ES|QL analyzes text at query time without an inverted index</h2><p>When ES|QL plans a <code>MATCH</code> against an expression, it analyzes the query string once, up front, into a set of terms. How each row is then evaluated depends on the expression's type:</p><p><strong>Expression type</strong></p><p><strong>Processing</strong></p><p><strong>Matching behavior</strong></p><p><code>text</code> (via <code>TO_TEXT</code>)</p><p>Analyzer tokenizes value into lowercase terms</p><p>Token-against-token comparison; a row matches if any token equals any query term (<code>OR</code> semantics)</p><p><code>keyword</code>,<code>ip</code>, <code>date</code>, numeric</p><p>No analysis; query constant converted once to the native type</p><p>Exact comparison per row</p><p>Both paths bypass Lucene entirely and evaluate values row by row. The non-text path mirrors exactly what a match query does when pushed down to Lucene against those field types, so the semantics stay consistent regardless of whether your query hits an index.</p><p>An inverted-index lookup does its work at ingest time and never touches non-matching documents at query time. A runtime <code>MATCH</code> does that analysis at query time, for every row that reaches it. One is fast because the work already happened; the other is flexible because the data doesn't need to have been indexed at all.</p><h2>What's next for ES|QL full-text search</h2><p>Everything in this post is the first installment of a larger effort to make search in ES|QL work on anything, not just on what you indexed ahead of time. The limitations called out earlier are actively being worked on, and the roadmap goes further:</p><ul><li><p><strong>Scoring.</strong> Runtime matches will contribute to <code>_score</code>, so you can sort by relevance even when the data was never indexed.</p></li><li><p><strong><code>MATCH_PHRASE</code></strong><strong> on expressions.</strong> Already available in Elastic Cloud Serverless, and coming to the Elastic Stack in 9.6.</p></li><li><p><strong>Configurable analyzers.</strong> Analyzer support for <code>MATCH</code> and <code>MATCH_PHRASE</code> on expressions, enabling language analyzers, stemming, and synonyms at query time.</p></li><li><p><strong>Match options.</strong> Options like <code>fuzziness</code> and <code>operator</code> for runtime matches.</p></li><li><p><strong>Vector search.</strong> Generating embeddings per row and running k-nearest neighbors (kNN) on runtime <code>dense_vector</code> expressions, bringing semantic search to unindexed data, too.</p></li></ul><h2>Try ES|QL full-text search on expressions today</h2><p>You can try runtime search today. It's available now in Elastic Cloud Serverless, where new ES|QL capabilities land first, and it ships as a technical preview in Elasticsearch 9.5. Start with the <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/search-functions">search functions</a> reference, and check the <a href="https://www.elastic.co/docs/reference/query-languages/esql/limitations">ES|QL limitations</a> page for the current boundaries. It's a technical preview because we want your feedback: If you search something that was never indexed and it surprises you, either positively or negatively, <a href="https://www.elastic.co/community">we'd love to hear about it</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/full-text-search-unindexed-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/full-text-search-unindexed-data</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Mappings]]></category>
    <dc:creator><![CDATA[Kevin Corcoran,Ioana Tagirta]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a74e633d05585a8/6a730774b8c2e64c3ebe0fd4/image1.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 05 Aug 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[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>
  </channel>
</rss>