<?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[Lucene - 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[Lucene - 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/lucene</link>
    </image>
    <link>https://www.elastic.co/search-labs/blog/category/lucene</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/category/lucene.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Sat, 24 Oct 2026 10:12:15 GMT</lastBuildDate>
  <item>
    <title><![CDATA[The fastest work is the work you never do: 100x faster sorted queries in Elasticsearch]]></title>
    <description><![CDATA[How Elasticsearch 9.6 lets ES|QL push its current TopN threshold into Lucene, and how that 100x shows up on some sorted queries and barely registers on others.]]></description>
    <content:encoded><![CDATA[<p>In <a href="https://www.elastic.co/elasticsearch">Elasticsearch</a> 9.6, a query filtering 346 million Nginx log documents dropped from around 500 seconds to 5 on a single node. That’s 100x faster, and the same query gains 37x on <a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/serverless">Elastic Cloud Serverless</a>. The optimization is called <em>min-competitive TopN</em>.As <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> accumulates result candidates, it raises the minimum value that Lucene has to return, so document ranges that can no longer improve the result are never loaded or decoded. You get the same 500 rows back, with hundreds of millions of documents left unread.</p><h2>A sorted ES|QL query over 346 million log documents</h2>FROM logs_*
| WHERE message LIKE "*request body too large*" OR message LIKE "*queue is full*"
| SORT @timestamp DESC
| LIMIT 500<p>Using real-world Nginx logs, the benchmark searched 346 million docs across 432 GB of total index data. It returned 500 results and took ~500 seconds. That's more than eight minutes.</p><p></p><h3>What’s happening under the hood?</h3><p></p><p>Before this optimization, candidate rows were loaded, decoded, and evaluated by the user filter before TopN could determine which of them could no longer contribute to the final result.
</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf091d4d4f50632fe/6abfaca2e7a20edb3234a965/esql-query-pipeline.png" alt="ES|QL query pipeline: Lucene scan, load and decode, filter, local TopN, final TopN, with the TopN decision last" /><p></p><h2>How min-competitive TopN prunes work in ES|QL</h2><p>TopN learns something useful while the query is still running. Once its heap is full, the worst value currently in the heap becomes a competitive threshold: that is, any row that cannot beat that value cannot make the final result.</p><p></p><p>ES|QL can feed that threshold back into earlier stages of execution. Two changes make this work:</p><p></p><ol><li><p><a href="https://www.elastic.co/search-labs/blog/esql-elasticsearch-8-19-9-1?#significant-performance-and-scalability-improvements"><strong>Dynamic Lucene pushdown</strong></a><strong>.</strong> As TopN finds better candidates, its competitive threshold improves. ES|QL pushes that threshold down to Lucene, allowing Lucene to skip candidates that cannot beat the current worst TopN value. This avoids loading, decoding, and evaluating rows that have no chance of making the result.</p></li><li><p><a href="https://github.com/elastic/elasticsearch/pull/142406"><strong>Shared threshold across drivers</strong></a><strong>.</strong> ES|QL processes data in parallel across multiple drivers. If each driver only uses its own local threshold, one driver cannot benefit from better candidates discovered by another. Sharing the best-known competitive threshold means a strong result found by one driver immediately helps all the others prune their remaining work.</p></li></ol><p></p><p>Together, these changes move TopN from a late-stage filter to something that shapes query execution much earlier in the pipeline.</p><h3>How does the min-competitive TopN optimization work?</h3><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7807646d99ccf5ff/6abfad1b0491ebddfc047acb/baseline-topn-query.png" alt="Baseline TopN query scanning all 30 rows across three Lucene segments to return the top 3 sorted results" /><p>The query asks for the top three rows matching a wildcard filter, sorted in descending order, across three segments of 10 rows each. Without the optimization, all 30 rows are loaded, decoded, and evaluated against the filter before TopN sees any of them, at a cost of 30 row loads and 15 TopN operations.</p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltca82b2e1bee9dfb7/6abfad6b23d193d2985b6ccb/dynamic-lucene-pushdown.png" alt="Dynamic Lucene pushdown cutting rows loaded from 30 to 16 as the TopN min-heap threshold tightens per segment" /><p>With dynamic pushdown, ES|QL maintains the TopN heap as it scans. The heap fills at the end of the first segment with [98, 51, 47], and its worst entry is pushed down to Lucene as the competitive threshold. That cuts the second segment to four rows out of 10, which improves the heap to [98, 83, 77] and tightens the threshold again, leaving just two rows to load in the third segment. This produces the same three results, with 16 row loads and 10 TopN operations.</p><h3>Sharing the TopN threshold across drivers</h3><p>One driver may find excellent recent candidates immediately. Without coordination, another driver doesn't know that and continues working with a weak threshold. Sharing the global worst competitive value lets that discovery benefit all drivers.</p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt71b29eda542cb155/6abfadc44e42ee2b277fce13/parallel-esql-drivers.png" alt="Parallel ES|QL drivers sharing a global TopN min-heap threshold to prune work across segments and shards" /><p></p><h2>Benchmark results on a local node and Elastic Cloud Serverless</h2><p>The min-competitive TopN optimization ships as generally available (GA) in Elasticsearch 9.6. For real-world Nginx data benchmarks (same query, same dataset, p50 of 51 measurements, with 20 warmup runs discarded):</p><p></p><p>
</p><p><strong>Local node</strong></p><p><strong>Serverless (GCP)</strong></p><p>Before optimization</p><p>~500s</p><p>~520s</p><p>Production implementation</p><p>~5s</p><p>~14s</p><p>Runtime improvement</p><p>~99%</p><p>~97%</p><p>Speedup</p><p>100x</p><p>37x</p><p></p><h3>Benchmark hardware</h3><p></p><p>
</p><p><strong>Local node</strong></p><p><strong>Serverless (GCP)</strong></p><p>CPU</p><p>Apple M4</p><p>Small GCP host</p><p>RAM</p><p>48 GB</p><p>8 GB</p><p>Java Virtual Machine (JVM) heap</p><p>24 GB</p><p>4 GB</p><p>Storage</p><p>Local external TB5 NVMe SSD</p><p>Cloud storage</p><p>Shards</p><p>100</p><p>100</p><h2>When does this query optimization technique help most?</h2><p>The biggest gains happen when TopN becomes competitive early. If good candidates appear at the start of execution, the threshold rises quickly and subsequent scans get pruned aggressively. Three conditions amplify this:</p><p></p><ol><li><p>N is small relative to the candidate population. Finding 500 rows out of 346 million documents gives the optimizer far more room to eliminate work than returning a large fraction of the matches does.</p></li><li><p>The post-Lucene work is expensive enough that skipping a row saves something measurable, like avoiding loading large strings, decompression, or wildcard evaluation.</p></li><li><p>Many candidates pass the coarse Lucene scan in the first place. If almost nothing matches the initial filter, there’s not much unnecessary work left to eliminate.</p></li></ol><p></p><p>If matching rows are rare or if good candidates are discovered late, the threshold takes longer to become useful and the gain can be much smaller.</p><p></p><p>The current implementation applies to <code>@timestamp</code> sorted queries. Queries sorted by other fields don’t yet benefit from this optimization.</p><h2>What's next for ES|QL query performance</h2><p>The same idea has room to run in several directions, including:</p><p></p><ul><li><p><strong>Other numeric sort fields. </strong>The underlying approach isn’t inherently limited to timestamps, so extending it to other numeric sort fields could benefit a much broader range of sorted TopN queries.</p></li></ul><p></p><ul><li><p><strong>Processing promising data first.</strong> How quickly the threshold becomes useful depends on how early TopN sees good candidates. Processing more promising data slices first could establish a strong threshold sooner, allowing more aggressive pruning of subsequent work.</p></li></ul><p></p><ul><li><p><strong>Early termination.</strong> As the threshold strengthens during execution, it becomes possible to recognize when remaining ranges cannot produce a better result and to stop processing them entirely, rather than running through to completion.</p></li></ul><p></p><ul><li><p><strong>Coordination across nodes.</strong> The current optimization shares competitive information across drivers within a node. Broader coordination across nodes or clusters could let strong candidates discovered in one part of a distributed query reduce work elsewhere.</p></li></ul><p></p><ul><li><p><strong>Late materialization and result streaming.</strong> Competitive filtering and late materialization attack the same problem from different angles. Competitive filtering eliminates rows that cannot reach the final TopN as early as possible, while a fetch phase can defer loading expensive field values until the set of likely winners is much smaller. Result streaming could extend this further. If the engine can determine that some leading results can no longer be displaced, those rows could be returned before the full query completes. For interactive investigations, getting the first useful result fast matters as much as total query runtime.</p></li></ul><p></p><h2>What this means for Elasticsearch performance tuning</h2><p>
The min-competitive TopN optimization in Elasticsearch 9.6 shows how much work can be avoided when information discovered during query execution is fed back into earlier stages. In our Nginx log benchmark, that turned a roughly 500-second query into a roughly 5-second query on a single node.</p><p></p><p>This is also a starting point. Extending competitive filtering to more sort fields, finding strong candidates earlier, terminating work sooner, and eventually coordinating these decisions across nodes offer opportunities to push the same idea further.</p><p></p><p>The earlier we know what can still win, the less work we need to do for everything that cannot.</p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p><p></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-performance-tuning-esql-topn</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-performance-tuning-esql-topn</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Attila Sahi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf092de2db9684039/6abfa9e5701a1a7aa18aa403/faster-query-runtime.png" length="0" type="image/png"/>
    <pubDate>Mon, 05 Oct 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Check 100 candidates, not 10 million documents: Faster kNN filters in Elasticsearch]]></title>
    <description><![CDATA[Elasticsearch now decides for each query whether to run a kNN filter before or after the vector search. On a 10M-vector corpus post-filtering was faster in 104 of 120 benchmark pairs while still returning k results.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch now decides, for each query, whether to apply a kNN filter before or after the vector search. Checking a broad <code>match_phrase</code> filter after the search took under a millisecond on a 1M-vector segment, compared with 33.4 ms before it. On a 10M-vector corpus, post-filtering was faster in 104 of 120 benchmark pairs, with recall within 0.01 of pre-filtering.</p><p>A filter inside the <code>knn</code> clause, such as a tenant ID or a language, runs as a pre-filter, so it's evaluated over every document in every segment before the search starts. When a filter matches enough of the corpus, Elasticsearch runs the vector search first and checks the filter only against the candidates that come back. The candidate pool is sized statistically so that at least <code>k</code> survive. If they fall short, Elasticsearch retries once and then falls back to the pre-filtered search. Your queries stay the same, and you still get k results. It's scheduled to be on by default in Elasticsearch 9.6.</p><h2>How a kNN filter works in Elasticsearch</h2><p>As a quick reminder, Elasticsearch has two approximate nearest-neighbor structures for <code>dense_vector</code> fields. <strong>HNSW</strong> is a proximity graph: the search walks from neighbor to neighbor, keeping the <code>num_candidates</code> closest vectors it has seen so far as its beam. <strong>IVF</strong>, the <code>bbq_disk</code> index type, clusters vectors around centroids, each with a posting list of its vectors, and scans the posting lists closest to the query; the fraction of the segment it scans, its <em>visit ratio</em>, is derived from <code>num_candidates</code> and <code>k</code>. The posts on <a href="https://www.elastic.co/search-labs/blog/hnsw-graph">HNSW graphs</a> and <a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction">DiskBBQ</a> cover both in depth.</p><p>What sets up everything that follows is that neither is a single index-wide structure. Every Lucene segment carries its own HNSW graph or its own set of IVF posting lists, so a kNN query searches each segment separately and merges the results, first per shard and then on the coordinating node. Anything the search has to prepare before it starts exploring is therefore prepared once for every segment.</p><p>Now let's add a <code>filter</code> to the kNN query:</p>POST my-index/_search
{
  "knn": {
    "field": "embedding",
    "query_vector": [0.12, -0.03, ...],
    "k": 10,
    "num_candidates": 100,
    "filter": {
      "term": { "language": "en" }
    }
  }
}<p>Because the filter sits inside the <code>knn</code> clause, it is a pre-filter: the 10 results are the 10 nearest documents among those matching <code>language: en</code>. To deliver that, the search has to know, for every document it comes across, whether that document passes the filter. In each segment, this happens in two steps.</p><h3>Step 1: Materializing the filter into a bitset</h3><p>Neither structure visits documents in doc ID order. An HNSW walk jumps from a node to its graph neighbors, while IVF scans posting lists one centroid at a time, meaning document IDs arrive in whatever order vector proximity dictates. The search needs to answer "does doc 84,219 match?" on demand, and a filter's regular <code>DocIdSetIterator</code> (which can only move forward) cannot do that. Before the search starts, the filter is run to completion over the segment, and its matches are recorded in a bitset. For HNSW, this happens in Lucene's <code>AcceptDocs</code>:</p>// org.apache.lucene.search.AcceptDocs
private void createBitSetAcceptDocsIfNecessary() throws IOException {
  if (acceptBitSet == null) {
    acceptBitSet = Objects.requireNonNull(createBitSet(iterator(), liveDocs, maxDoc));
    cardinality = acceptBitSet.cardinality();
  }
}<p><code>createBitSet</code> drains the filter's iterator to the end and fills either a <code>FixedBitSet</code> sized to the segment's <code>maxDoc</code> or a sparse bitset, depending on how many documents match. IVF does the equivalent through Elasticsearch's own <code>ESAcceptDocs</code> before it scans its first posting list. Either way, the cost depends on the size of the segment and how expensive the filter is to evaluate, not on how much of the segment the search goes on to visit.</p><h3>Step 2: Skipping documents that fail the filter</h3><p>Documents that fail the filter do not count toward the results. The search has to visit more of the segment to collect enough that pass: HNSW walks further through the graph, and IVF scans a few extra posting lists. Both keep this bounded. HNSW, for instance, stops walking the graph once it has visited as many vectors as the filter matches, and scores the matching documents directly instead. For a filter that matches most of the corpus, the extra work is small, as the profile in the next section shows.</p><p>The takeaway: The extra search work caused by a filter is bounded, and it is rarely where a filtered query's time goes. The materialization in step 1 has no such bound. That is why the interesting case is not a restrictive filter but a broad one, matching, say, 90% of the corpus. It prunes almost nothing and adds little search work, yet it is still materialized at full price in every segment.</p><h2>Why a filtered kNN search can be slower than an unfiltered one</h2><p>The search profile shows this cost directly, though not where you might first look for it.</p><p>Lucene's kNN queries do almost all of their work in <code>rewrite</code>. For HNSW, <code>AbstractKnnVectorQuery#rewrite</code> materializes the filter, searches every segment, merges the per-segment results, and returns a <code>DocAndScoreQuery</code> holding the final doc IDs and scores. IVF does the same and returns Elasticsearch's <code>KnnScoreDocQuery</code>. By the time the query is executed in the usual sense, the search is over, and all that is left is to hand back a precomputed list of hits.</p><p>A top-level <code>knn</code> clause runs in the DFS phase, so its profile lands under <code>profile.shards[].dfs.knn[]</code>. Here is an unfiltered search with <code>k: 10</code> and <code>num_candidates: 100</code> against a single HNSW segment of one million vectors, with each breakdown trimmed to its non-zero entries:</p>{
  "dfs": {
    "knn": [
      {
        "vector_operations_count": 2378,
        "query": [
          {
            "type": "DocAndScoreQuery",
            "description": "DocAndScoreQuery[177025,...][0.7580181,...],0.7580181",
            "time_in_nanos": 5708,
            "breakdown": {
              "create_weight": 1250,
              "build_scorer": 2583,
              "next_doc": 1041,
              "score": 834,
              ...
            }
          }
        ],
        "rewrite_time": 5158917,
        "collector": [...]
      }
    ]
  }
}<p><code>rewrite_time</code>, 5.2 ms, is the kNN search. The <code>DocAndScoreQuery</code> entry takes under 6 microseconds: it only returns the ten hits.</p><p>Now add a <code>match_phrase</code> filter that matches 90% of the documents. A phrase that matches nearly the whole corpus is not a common filter in practice, but it isolates the effect cleanly: it barely narrows the search, and it is expensive to evaluate. Most real filters that match this broadly cost somewhere between this one and the <code>term</code> filter we compare it to further down.</p>{
  "dfs": {
    "knn": [
      {
        "vector_operations_count": 2682,
        "query": [
          {
            "type": "CachingEnableFilterQuery",
            "description": "(ConstantScore(body:\"red fox\"))^0.0",
            "time_in_nanos": 33595666,
            "breakdown": {
              "create_weight": 9125,
              "build_scorer": 174083,
              "next_doc": 15708,
              "into_bit_set": 33396750,
              "into_bit_set_count": 1,
              ...
            },
            "children": [
              {
                "type": "PhraseQuery",
                "description": "body:\"red fox\"",
                "time_in_nanos": 33589916,
                "breakdown": {...}
              }
            ]
          },
          {
            "type": "DocAndScoreQuery",
            "description": "DocAndScoreQuery[163399,...][0.7200494,...],0.7580181",
            "time_in_nanos": 4092,
            "breakdown": {...}
          }
        ],
        "rewrite_time": 40076834,
        "collector": [...]
      }
    ]
  }
}<p>Three things changed:</p><ul><li><strong>The filter has its own entry</strong>, next to the <code>DocAndScoreQuery</code> rather than inside it, because it runs during the kNN query's rewrite. <code>CachingEnableFilterQuery</code> is the wrapper Elasticsearch puts around kNN filters so that the query cache always considers them worth caching; the query you wrote is its child. On a <code>bbq_disk</code> field, the entries are named differently, but the filter shows up the same way.</li><li><code>into_bit_set</code><strong> is the materialization from step 1</strong>: a single call that runs the phrase query over the whole segment and records every match in the bitset. It took 33.4 ms. That time is part of <code>rewrite_time</code>, not added to it; <code>rewrite_time</code> went from 5.2 ms to 40.1 ms, and the filter accounts for 33.6 ms of the difference. (A filter matching fewer than about 1 in 128 documents is collected into a sparse bitset one document at a time instead, so its cost shows up under <code>next_doc</code>.)</li><li><code>vector_operations_count</code><strong> barely moved.</strong> It went up 13%, roughly what you would expect when one visited document in ten fails the filter.</li></ul><p>More than 80% of this query's time went into evaluating the filter. One thing to keep in mind when reading profiles like this: profiling bypasses the query cache and the profile always shows the filter's uncached cost, even for a filter that unprofiled requests would find in the cache.</p><h3>kNN filter cost: Materialization vs. extra search work</h3><p>Two costs make up the difference between these two profiles, and it is worth separating them because they scale differently.</p><p><strong>Filter materialization</strong> is paid per segment, proportional to the segment size, before any vector work. How expensive it is depends entirely on the query type:</p><p>Filter</p><p>Materialization cost</p><p><code>term</code> on a keyword</p><p>Cheap - read one postings list, <code>intoBitSet</code> in bulk</p><p><code>range</code> on a numeric or date field</p><p>Usually cheap - walks the points (BKD) index; only a field with doc values alone (<code>index: false</code>) has to check every document</p><p><code>match_phrase</code></p><p>Positions decoding and intersection for every document containing the terms; by far the most expensive</p><p><code>bool</code> of several clauses</p><p>Cost of each clause, plus conjunction/disjunction bookkeeping</p><p>Cached filter</p><p>Nearly free on a cache hit, full price on a miss</p><p>On the one-million-document segment above, a <code>term</code> filter matching the same 90% of documents spends about 0.3 ms in <code>into_bit_set</code>, roughly 100x less than the phrase filter. And the cost grows with the segment: in the benchmarks below, a <code>match_phrase</code> filter matching 90% of a 10M-document segment takes an HNSW query from 2.3 ms to 395 ms.</p><p><strong>Extra search work</strong> adds something on top, but as we saw, it is bounded, and for a broad filter it is small: the 13% above. Materialization dominates, and a filtered kNN search on a broad, expensive filter can spend most of its time on work that is not vector comparison. The answer it arrives at also overlaps heavily with the unfiltered one: if a filter matches 90% of the corpus and is unrelated to the query, about nine of the ten nearest neighbors overall already pass it, and the ones that do not are replaced by documents only a few ranks further down.</p><p>That is the asymmetry the rest of this post is about. The filter's cost is tied to how many documents it is evaluated over, and evaluating it after the vector search instead of before changes that number by orders of magnitude:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt861384e7f45ab996/6abe26fbf53f5fb34856d8f7/postfilter-cost-asymmetry.png" alt="Filter cost scales with segment size when pre-filtering and with the candidate set when post-filtering" /><h2>Pre-filtering vs post-filtering in kNN search</h2><p>What if we just post-filter? That observation is not new, and Elasticsearch has always let you act on it. Move the filter <em>outside</em> the knn clause and it becomes a <strong>post-filter</strong>: the <code>kNN</code> search runs unconstrained, and the filter is applied to the <code>k</code> results it returns.</p>POST my-index/_search
{
  "query": {
    "bool": {
      "filter": { "term": { "language": "en" } },
      "must": {
        "knn": {
          "field": "embedding",
          "query_vector": [0.12, -0.03, ...],
          "k": 10,
          "num_candidates": 100
        }
      }
    }
  }
}<p>We covered this trade-off in detail in <a href="https://www.elastic.co/search-labs/blog/knn-exact-vs-approximate-search">How to choose between exact and approximate kNN search in Elasticsearch</a>, where the catch is put plainly:</p><p>The problem with using post-filters in kNN is that the filter is being applied <em>after</em> we have gathered the top k results. This means that we can end up with less than k results, as we need to remove the elements that don't pass the filter from the top k results that we've already retrieved from the HNSW graph.</p><p>Ask for 10, get back 6. Or 2. Or 0. The vector search does not know the filter exists, so nothing guarantees that 10 of its results survive it. The only remedy available to you is to over-collect by hand: ask for <code>k: 50</code> and hope 10 survive. That puts you in an awkward position:</p><ul><li>You have to guess the multiplier, and the right guess depends on how selective your filter is <em>for this particular query</em>; something you generally do not know.</li><li>Guess too low and you silently return short result sets. Guess too high and you pay for exploration you did not need, which is the cost you were trying to avoid.</li><li>Either way, <code>k</code> no longer means what the API says it means, so paging, <code>size</code>, and anything downstream has to be reasoned about separately.</li></ul><p>So both options are unsatisfying. Pre-filtering is correct but can be pathologically slow. Post-filtering is fast but shifts a statistics problem onto the user and still offers no guarantee.</p><p>The table below compares both options with the automatic post-filtering covered in the next section.</p><p></p><p>Pre-filtering</p><p>Manual post-filtering</p><p>Automatic post-filtering</p><p>Where the filter goes</p><p>Inside the <code>knn</code> clause</p><p>Outside the <code>knn</code> clause, in a <code>bool</code> query</p><p>Inside the <code>knn</code> clause (unchanged)</p><p>Filter is evaluated over</p><p>Every document in every segment</p><p>The <code>k</code> results the vector search returns</p><p>The candidates the vector search returns</p><p>Returns k results</p><p>Yes</p><p>Not guaranteed</p><p>Yes, by falling back to pre-filtering if needed</p><p>Who sizes the candidate pool</p><p>Not needed</p><p>You, by guessing a multiplier for <code>k</code></p><p>Elasticsearch, from the filter's estimated selectivity</p><p>Best suited to</p><p>Selective filters</p><p>Cases where a short result set is acceptable</p><p>Broad filters (selectivity of 0.7 or more by default)</p><h2>But we can do better</h2><p>The insight is that the choice between these two does not have to be the user's, and it does not have to be made once for the whole index. Elasticsearch is holding the filter's <code>Weight</code> at rewrite time. It can estimate how selective the filter is, decide whether post-filtering is a good bet <em>for this query on this shard</em>, size the over-collection from that estimate rather than from a guess, and keep a correctness net underneath so that a bad estimate costs latency instead of results.</p><p>This is what automatic post-filtering adds, for both HNSW and IVF (<code>bbq_disk</code>). Importantly, none of it changes the query you write: you keep expressing a pre-filter, you keep getting pre-filter semantics, and how that gets satisfied becomes an engine decision.</p><p>Four ideas, in order:</p><ol><li>Estimate the filter's selectivity.</li><li>Use it to size an over-collection.</li><li>Retry once if that comes up short.</li><li>Fall back to the original query if it still does.</li></ol><h3>Estimating kNN filter selectivity</h3><p>First, we need a number for "how much of the corpus does this filter let through?" The cheap and surprisingly effective answer is to ask the filter's own scorers what they expect to match, and divide by the number of vectors actually indexed for the field:</p>public static float computeSelectivity(Weight filterWeight, List&lt;LeafReaderContext&gt; leaves, int totalVectors)
    throws IOException {
    long filterCost = 0;
    for (LeafReaderContext leafCtx : leaves) {
        ScorerSupplier ss = filterWeight.scorerSupplier(leafCtx);
        if (ss != null) {
            filterCost += ss.cost();
        }
    }
    return totalVectors &gt; 0 ? Math.min(1f, (float) filterCost / totalVectors) : 0f;
}<p>Two things make this cheap. <code>ScorerSupplier#cost()</code> is an <em>estimate</em>. For a term query, it is the postings list length, read from metadata, so we get it without materializing anything. And the denominator is the vector count from the codec, not <code>maxDoc</code>, so a field that only some documents have does not skew the ratio.</p><p>Two things make it imperfect, and both are handled downstream rather than pretended away. <code>cost()</code> is an upper bound for conjunctions, and selectivity can be overestimated. More fundamentally, selectivity is a <em>global</em> property of the shard, while what actually matters is the filter's pass rate inside the neighborhood of the query vector. For a filter that is independent of vector content, the two match. For one that is correlated with it, they can drift apart in either direction.</p><p>Say <code>language: en</code> matches 90% of a shard. For an English query, nearly all of the nearest neighbors are English. The local pass rate is therefore close to 100% and the global estimate is merely conservative. For a query written in Spanish, the nearest neighbors can be dominated by the 10% of documents that are not English, and the local pass rate can fall far below 90% even though the global estimate has not changed. That second case is the one the retry and fallback rounds below exist for.</p><h3>Sizing the candidate pool with a binomial model</h3><p>Given selectivity , how many raw candidates  should the unfiltered search collect to get at least  survivors?</p><p>The naive answer is . That gets you  survivors <em>on average</em>, which means it falls short roughly half the time. Averages are the wrong tool here: we need a high-probability guarantee.</p><p>So model each candidate as independently passing the filter with probability . The number of survivors out of  candidates is then a binomial variable:Now instead of asking for the mean to equal , ask for , to sit  standard deviations *below* the mean:Solving that for exactly gives a quadratic. Substituting  into the variance term instead yields a closed form that is marginally conservative, which is the right direction to err: is a confidence dial: round one succeeds with probability . The implementation uses , or about 99.4%.</p><p>The assumption worth naming explicitly is independence. We assume it because, absent any signal about how the filter correlates with vector content, it is the only thing we <em>can</em> assume. The retry and fallback rounds below exist precisely to cover the cases where it is wrong.</p><h3>How many extra candidates post-filtering collects</h3><p>In code, that formula plus its guard rails:</p>/**
 * Minimum round-1 oversample factor. Round 1 always asks for at least this many ×
 * the target count, regardless of what the binomial variance formula computes. Active
 * when selectivity is near 1, where the variance term collapses to ≈ 0.
 */
float POST_FILTER_OVERSAMPLE_FLOOR = 1.2f;
float POST_FILTER_OVERSAMPLE_Z_SCORE = 2.5f;
static double zMargin(int k, float selectivity) {
    return POST_FILTER_OVERSAMPLE_Z_SCORE * Math.sqrt(k * (1.0f - selectivity) / selectivity);
}
static int computeScaledK(int k, float selectivity) {
    double zMargin = zMargin(k, selectivity);
    double floor = Math.min(Math.ceil(k * POST_FILTER_OVERSAMPLE_FLOOR), NUM_CANDS_LIMIT);
    return (int) Math.clamp(Math.ceil((k + zMargin) / selectivity), floor, NUM_CANDS_LIMIT);
}<p>The 1.2x floor matters because, as , the variance term vanishes and the formula would return exactly , leaving no slack for the handful of documents that <em>do</em> get filtered out. The <code>NUM_CANDS_LIMIT</code> cap (10,000) keeps a very restrictive filter from requesting an unbounded candidate pool.</p><p>For  and , the resulting oversample:</p><p>Selectivity </p><p> margin</p><p>Candidates </p><p>Oversample</p><p>0.99</p><p>0.79</p><p>12 <em>(floor)</em></p><p>1.2x</p><p>0.95</p><p>1.81</p><p>13</p><p>1.3x</p><p>0.90</p><p>2.64</p><p>15</p><p>1.5x</p><p>0.80</p><p>3.95</p><p>18</p><p>1.8x</p><p>0.70</p><p>5.18</p><p>22</p><p>2.2x</p><p>0.55</p><p>7.15</p><p>32</p><p>3.2x</p><p>Note how modest these are and how they shrink relative to <code>k</code> as <code>k</code> grows, because the standard deviation grows as   while the mean grows as . At  and  the oversample is only 2.2x, versus 3.2x at . Asking for 15 results instead of 10 is nothing compared to materializing an expensive filter over a 10M-document segment.</p><p>There is a second budget that must <em>not</em> be conflated with <code>k</code>, and getting this wrong is an easy way to accidentally retune the search. For HNSW, <code>num_candidates</code> is the beam width, and it is an independent knob. The delegate keeps the user's value, floored at the grown <code>k</code> (a beam narrower than the number of results requested cannot surface them):</p>static int cappedNumCands(int numCands, int scaledK) {
    return Math.clamp(numCands, scaledK, NUM_CANDS_LIMIT);
}<p>A consequence worth spelling out: an HNSW delegate hands back more candidates than the grown <code>k</code>. Each segment's graph walk keeps its whole beam (up to <code>num_candidates</code> candidates), and every one of them is checked against the filter. On HNSW, the grown  <code>k </code>mostly matters when <code>num_candidates</code> is close to <code>k</code>; with the usual wider beam, round one has plenty of candidates to draw from.</p><p>For IVF, <code>num_candidates</code> is only meaningful <em>relative</em> to  <code>k</code>: the codec derives its visit ratio from the ratio of the two. Carrying <code>num_candidates</code> over unchanged while <code>k </code>grows would silently reduce exploration, and IVF scales it to preserve the ratio:</p>static int numCandsPreservingRatio(int numCands, int k, int newK) {
    if (k &lt;= 0) {
        return Math.clamp(numCands, newK, NUM_CANDS_LIMIT);
    }
    long scaled = (long) Math.ceil((double) numCands * newK / k);
    return Math.clamp(scaled, newK, NUM_CANDS_LIMIT);
}<h3>Retrying when post-filtering comes up short</h3><p>The binomial model is calibrated for ~99.4% success, and its independence assumption can be wrong. Therefore, round one sometimes comes up short. The retry round is what turns "usually enough" into "enough."</p><p>It runs only if the shortfall looks like bad luck rather than a bad model. If round one returned far fewer survivors than global selectivity predicts, that is evidence that the filter is <em>correlated</em> with the query neighborhood; the case the binomial model explicitly cannot handle. More rounds will not fix a wrong model, so we exit early instead:</p>double expectedHits = k * (double) selectivity;
double threshold = expectedHits * 0.5;
boolean shouldExit = scoreDocsCount &lt; threshold;<p>Fewer than half the predicted survivors means the filter is hostile to this query's vector neighborhood, and pre-filtering is the right tool. The check is skipped for <code>k &lt; 5</code> , where the expected count is too small for the ratio to mean anything.</p><p>A retry is not just a re-run with a bigger <code>k </code>; that would re-find the same candidates. It has to search somewhere <em>new</em>, so it carries three pieces of state forward:</p>int remaining = expectedBaseQueryDocMatches - scoreDocs.length;
int retryK = PostFilterableKnnQuery.computeScaledK(remaining, selectivity);
Query retry = postFilterQuery.createRetryQuery(searcher.getIndexReader(), excluded, seedDocsPerLeaf, retryK);
TopDocs retryDocs = searcher.search(retry, retryK);<p><strong>An exclusion set.</strong> Every document seen in round one, both the ones that passed the filter and the ones that did not, is excluded via an <code>ExcludeDocsQuery</code>. HNSW composes it into the accept-docs; IVF pushes it into posting-list iteration so the codec skips those documents outright.</p><p><strong>Seed entry points.</strong> For HNSW, restarting the graph walk from the top layer would retrace the same descent. Instead, the retry seeds from the nearest round-one matches (up to four per segment) so it starts already in the right neighborhood and explores outward:</p>int[][] seedDocsPerLeaf = nearestSeedsPerLeaf(matching, MAX_SEEDS_PER_LEAF);<p>IVF ignores seeds (for now); it already knows which centroids are closest and simply rescans them with the excluded documents skipped.</p><p><strong>A re-sized target.</strong> <code>remaining</code> is the shortfall in <em>survivors</em>. It is run back through <code>computeScaledK</code> with the same selectivity to inflate it for the attrition the retry will also suffer.</p><p>There is exactly one retry. A second would be chasing a distribution the model has already been shown to be wrong about, and the fallback below is both cheaper and strictly correct.</p><h3>Falling back to pre-filtering</h3><p>The fallback round makes automatic post-filtering safe to enable by default. <strong>If post-filtering does not produce the full candidate pool, its results are thrown away, and the original pre-filtered query runs instead.</strong></p>if (scoreDocs.length &lt; expectedBaseQueryDocMatches) {
    logger.debug(
        "post filtering retrieved only [{}] results, less than the desired [{}] results. Falling back to original query",
        scoreDocs.length,
        expectedBaseQueryDocMatches
    );
    return null;
}<p>Returning <code>null</code> from <code>postFilterRewrite</code> sends <code>rewrite</code> down the ordinary path:</p>Query rewritten = ((Query) innerQuery).rewrite(searcher);
this.totalVectorOps += innerQuery.totalVectorOps();
return rewritten;
The user's query is the fallback. That means the worst case of a bad selectivity estimate is <em>latency</em> (a wasted candidate collection, then the pre-filter search you would have run anyway) and never a short or wrong result set. That property is what lets an estimate as rough as <code>ScorerSupplier#cost()</code> be good enough; it only has to be right often enough to pay for the times it is wrong.In short, after round one:<p>- If at least  candidates passed the filter, return the nearest  .</p><p>- If fewer than half the expected number passed (), exit early to the pre-filtered query.</p>- Otherwise, retry once. If at least  have passed by then, return the nearest ; if not, fall back to the pre-filtered query.<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1b65be0c2882ece4/6abe2cefee5ee3461ef0178b/postfilter-decision-zones.png" alt="Outcome after each round, by the number of candidates that passed the filter" /><p>One detail that matters for score correctness. For quantized fields, approximate scores get refined by an exact rescoring pass. Normally, IVF does that inside its own <code>rewrite</code>, but on a post-filter delegate it must happen <em>after</em> filtering, or we would rescore documents that are about to be discarded. The delegate skips it, and the orchestrator calls back in through <code>finalizeTopK</code> once the pool is final. Without that hook, post-filtered results would carry raw quantized scores while fallback results carried exact ones, mixing two score domains inside a single search.</p><h2>A filtered kNN query, end to end</h2><p>Say you run this against a 10M-document shard where <code>language: en</code> matches 9M of them:</p>{
  "knn": {
    "field": "embedding",
    "query_vector": [...],
    "k": 10,
    "num_candidates": 100,
    "filter": { "term": { "language": "en" } }
  }
}
<p></p><p><strong>1. Rewrite. </strong><code>PostFilterKnnQuery#rewrite</code> builds the filter <code>Weight</code>, conjoined with a <code>FieldExistsQuery</code> on <code>embedding</code>, so documents without a vector never count, but does not execute it.</p><p><strong>2. Estimate. </strong><code>ScorerSupplier#cost()</code> over the segments gives ~9M against 10M indexed vectors: . That clears the default threshold of 0.7, so post-filtering is on. (Had it come in at 0.4, we would stop here and run the ordinary pre-filtered query.)</p><p><strong>3. Size round one. </strong>, so a filter-less delegate is built asking for 15 results instead of 10. Its exploration budget follows the rules above: on HNSW, <code>num_candidates</code> stays at 100, the beam width; on IVF, it scales to 150 so the visit ratio does not change.</p><p><strong>4. Search unfiltered. </strong>The delegate runs across all segments with no accept-docs bitset: no filter materialization and no extra exploration. Each segment keeps every candidate its own search collected, so the orchestrator does not have to re-derive them. On HNSW that is the segment's whole beam, up to 100 candidates; on IVF it is a small multiple of the 15 requested, since IVF over-collects to absorb documents that appear in more than one posting list.</p><p><strong>5. Apply the filter to the candidates, not to 10 million documents.</strong> <code>applyFilter</code> walks each segment's candidates in doc-ID order and tests them through <code>Lucene#asSequentialAccessBits</code>. For a filter exposing a <code>TwoPhaseIterator</code>, that is one approximation advance per candidate, plus a <code>matches()</code> call whenever the approximation lands on it. This is the whole point: the filter is evaluated once per candidate rather than once per document in the segment.</p><p>It also means the filter no longer needs to be cached to be fast. With pre-filtering, the usual way to avoid paying for materialization on every query is the filter cache, which stores the filter's per-segment bitset for reuse. That only helps once a filter has been seen often enough to get cached, and every new segment starts cold. Post-filtering has nothing worth caching: applyFilter tells the filter how few candidates it will be checked against, and Lucene's query cache declines to build a bitset for a filter that matches millions of documents when only a tiny fraction of them will be looked at. The filter is just as fast on its first use as on its hundredth.</p><p><strong>6. Check against the target. </strong>The target is the 10 results the user asked for. With a filter that passes 90% of documents, roughly nine in ten candidates pass, far more than 10 across the segments. We deduplicate and keep the 10 nearest.</p><p>Suppose instead that only 7 distinct candidates had passed, as can happen when the filter is correlated with the query, like the Spanish-language query from earlier. 7 is above the hostile-filter threshold (), so a retry fires: exclude every candidate already seen, whether it passed or not, seed from up to 4 of the nearest passing candidates per segment, and ask for <code>computeScaledK(3, 0.9) = 5</code> more to cover the shortfall of 3. If that still leaves us short of 10, the pre-filtered query runs and post-filtering has cost nothing but the candidate collection.</p><p><strong>7.  Finalize. </strong>Select the top <code>k</code> by score via introselect and wrap the result in a <code>KnnScoreDocQuery</code>.</p><p>The filter was evaluated only on the candidates the vector search returned, instead of on ten million documents. The vector search was unconstrained, and the returned documents are the same 10 that pre-filtering would have found, with a guarantee that if they were not, you would have gotten the pre-filtered answer instead.</p><h2>Post-filtering vs. pre-filtering benchmarks on up to 10M vectors</h2><h3>Benchmark setup</h3><p>To measure the ceiling here, we ran a forced comparison: every configuration executed twice, once pre-filtered and once post-filtered, with the adaptive logic bypassed so that neither strategy could bail out to the other. That is a deliberately harsher test than production behavior; the real implementation picks per query and falls back, but it maps out where each strategy wins.</p><p><strong>Grid:</strong> 5 datasets from 523K to 10M vectors, each indexed with both IVF and HNSW, in both single- and multi-segment layouts, against 5 filter types at 7 selectivities, run both pre-filtered and post-filtered: 5 × 2 × 2 × 5 × 7 × 2 = 1400 measurements, or 700 matched pre/post pairs. The seven selectivities are 0.55, 0.7, 0.8, 0.9, 0.95, 0.99, and 1.0, where 1.0 means no filter. Fixed at <code>num_candidates=1000</code>, <code>k=10</code>, 1% visit ratio, uncached filters, 300 queries per point. IVF with <code>clusterSize=384</code>; HNSW with <code>m=16</code>, <code>efConstruction=200</code>; 1-bit quantization throughout.</p><p>Filters were uncached. That is the cold case for pre-filtering: a warm filter cache would narrow the gap for filters that repeat across queries. It is also the only case for post-filtering, which never relies on the cache in the first place.</p><p>Not every dataset has a numeric, keyword, or text field for the <code>range</code>, <code>term</code>, and <code>phrase</code> filters to target. Where one was missing, we added it and filled it with random content. We then sampled the corpus to pick filter values that match the target D% of documents:</p><ul><li><code>range</code> is a range query over a numeric field, with bounds chosen so that D% of the documents fall inside them.</li><li><code>term</code> matches a keyword value that D% of the documents carry.</li><li><code>phrase</code> is a phrase query over a text field, for a phrase that D% of the documents contain. Evaluating it means checking term positions, not just term presence, which makes it the most expensive filter here to materialize.</li><li><code>range_term</code> combines a <code>range</code> and a <code>term</code> filter in a <code>bool</code> query.</li><li><code>random</code> matches a randomly chosen D% of the documents.</li></ul><h3>Results across corpus sizes and filter types</h3><p>For filtering, the property of a dataset that matters most is its size, since materializing a filter costs time in proportion to the number of documents in each segment. We use the five datasets to show how the two strategies compare as the corpus grows, and take the detailed numbers below from the largest, cohere-msmarco-10M (10M vectors, 1024 dimensions, <code>float32</code>).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf94a3f10d504fe29/6abe35fdb2d147c465772517/postfilter-speedup-heatmap.png" alt="Median post-filter speedup over pre-filter by corpus size and filter type" /><p>Post-filtering comes out ahead almost everywhere: at every corpus size, with both index types, and in both layouts. How far ahead depends on how expensive the filter is to materialize, and in most layouts the gap widens as the corpus grows. The 16 cells out of 100 below 1.0x are all cheap <code>term</code> or <code>range</code> filters on multi-segment layouts, and the lowest is 0.68x.</p><p>On the cohere-msmarco-10M dataset, <strong>104 of the 120 filtered pairs are faster with post-filtering</strong>, including all 60 on a single segment, and 2 more are ties (within 2%). The remaining 14 are cheap <code>range</code> and <code>term</code> filters on multi-segment layouts, where pre-filtering is ahead by at most 1.33x (1.76 ms against 2.34 ms).</p><p>The latency-versus-selectivity curves show the mechanism directly:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9660674756ffbdce/6abe360ce791e085013626ff/postfilter-latency-by-selectivity.png" alt="Latency versus filter selectivity on cohere-msmarco-10M, single segment" /><p>The blue post-filter lines are nearly flat. With post-filtering, latency is driven mainly by the kNN search itself, which runs unfiltered and therefore costs the same no matter how much the filter matches. Applying the filter to the candidates adds little on top, and the over-collection stays small even as the filter narrows. For a given filter type, latency is almost indifferent to selectivity: in the median configuration it varies by about 10% across the whole 0.55-0.99 range, against about 50% for pre-filtering. The red pre-filter lines sit above them by roughly the cost of materializing the filter, meaning the gap is as large as the filter is expensive, and everything converges at 1.0, where there is no filter to evaluate.</p><p><strong>Recall is essentially strategy-independent</strong>: every one of the 140 pairs is within 0.01 recall, so the trade-off really is pure latency.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt94cc7f90915d3c66/6abe479b1a46ad5404815517/postfilter-pre-vs-post-paired.png" alt="Pre-filter versus post-filter recall and latency on cohere-msmarco-10M" /><p></p><h3>Limitations: Correlated filters and multi-segment layouts</h3><p>Two caveats are worth stating plainly.</p><p><strong>These filters are largely uncorrelated with the vectors.</strong> A filter built on random content passes documents independently of where they sit in vector space, which is exactly the assumption the binomial model makes. It shows in the results: within any one configuration, recall differs by at most 0.04 across the five filter types, and median recall is 0.74-0.75 at every selectivity. Correlated filters, like the <code>language</code> example earlier, are handled by the retry, early exit, and fallback rounds, which keep results correct even when the selectivity estimate is off. Measuring how much of the speedup carries over to strongly correlated filters is a natural next benchmark.</p><p><strong>Multi-segment layouts shrink post-filtering's edge.</strong> Compare the single- and multi-segment panels of the heatmap. Fixed per-segment costs are paid more times, and pre-filtering amortizes its bitset across a smaller <code>maxDoc</code> per segment. Post-filtering still wins on the expensive filters; it just wins by less.</p><h2>How to enable and tune automatic post-filtering</h2><p>Automatic post-filtering is <strong>scheduled to be enabled by default</strong> in Elasticsearch 9.6 and in upcoming Elastic Cloud Serverless releases. Once it is, there is nothing to change in your queries: you keep writing filters inside the <code>knn</code> clause, and each shard decides, query by query, whether post-filtering is worth attempting.</p><h3>Tuning the post-filter selectivity threshold</h3><p>That decision is controlled by a single index setting, index.dense_vector.post_filter_selectivity_threshold, which defaults to 0.7. A query goes through the post-filter pipeline when its estimated selectivity is greater than or equal to the threshold. The threshold is therefore a floor on how broad a filter must be before post-filtering is tried:</p><p>Threshold</p><p>Behavior</p><p><code>1.0</code></p><p>Off. No filter can qualify.</p><p><code>0.9</code></p><p>Only very broad filters - matching 90%+ of the corpus - post-filter.</p><p><code>0.7</code> <em>(default)</em></p><p>Filters matching 70%+ post-filter.</p><p><code>0.0</code></p><p>Every filtered kNN query attempts post-filtering.</p><p>To tune it for an index, lower it if your filters tend to be expensive to evaluate, or set it to <code>1.0</code> to opt out entirely. You can set this up in the index settings:</p>PUT my-index
{
  "settings": {
    "index.dense_vector.post_filter_selectivity_threshold": 0.5
  },
  "mappings": {
    "properties": {
      "embedding": {
        "type": "dense_vector",
        "dims": 1024,
        "index_options": { "type": "bbq_hnsw" }
      }
    }
  }
}<h3>Scope and limitations</h3><p>Worth knowing about scope:</p><ul><li>Applies to both <strong>HNSW</strong> and <code>bbq_disk</code><strong> (IVF)</strong> fields, for <code>float</code>, <code>bfloat16</code>, and <code>byte</code> element types, as well as <code>bit</code> vectors on HNSW.</li><li><strong>Nested vector fields</strong> are handled, with one wrinkle: because a nested kNN search keeps only one hit per parent, a parent that already produced a match is excluded from the retry as a <em>whole block</em>, while a parent whose child was merely filtered out stays eligible - a deeper child of it may still pass.</li><li><strong>Query semantics are unchanged.</strong> You still write a pre-filter and get pre-filter semantics. Nothing in the request body changes; this is purely an execution-strategy decision.</li><li><strong>The profile shows which strategy ran.</strong> When a query is post-filtered, the filter's entry has no <code>into_bit_set</code>: the filter is checked candidate by candidate, meaning it reports one <code>advance</code> per candidate the vector search returned. On the phrase-filter example from earlier, that is 100 <code>advance</code> calls (one per candidate in the beam), taking well under a millisecond in total, instead of a 33.4 ms <code>into_bit_set</code>. A second hit-list entry, a <code>KnnScoreDocQuery</code> holding the final top <code>k</code>, also appears next to the vector search's own. For the individual decisions (retries, early exits, fallbacks, and the selectivity estimates behind them), set <code>logger.org.elasticsearch.search.vectors.PostFilterKnnQuery: DEBUG</code>.</li></ul><h2>What's next for filtered kNN search in Elasticsearch</h2><p>The pieces we are most interested in improving:</p><p><strong>Better selectivity estimates.</strong> <code>ScorerSupplier#cost()</code> is free but crude, and it overestimates conjunctions. Elasticsearch already tracks richer field statistics; wiring those in, or sampling the filter over a bounded slice of a segment, would let the threshold be set less conservatively.</p><p><strong>Detecting correlation up front.</strong> The hostile-filter early exit is reactive: we find out the filter is correlated with the query neighborhood only after spending a round discovering it. Estimating the filter's <em>local</em> pass rate before committing (from the seed documents of the graph descent, say, or from cached per-query statistics) would turn that into a cheap up-front routing decision and let us safely lower the threshold.</p><p><strong>Cost-aware routing.</strong> The benchmarks say the right threshold depends on filter type as much as selectivity: a <code>match_phrase</code> filter is worth post-filtering at far lower selectivity than a <code>term</code> filter, because its materialization cost is orders of magnitude higher. A cost model over the filter's query shape, rather than selectivity alone, would let the engine make that call per query instead of relying on a single per-index threshold.</p><p><strong>Per-segment decisions.</strong> Routing is currently per shard, but selectivity and segment size vary between segments. Deciding per segment would let a small segment pre-filter while a large one post-filters in the same query.</p><p>The broader point generalizes beyond kNN. Elasticsearch has spent a lot of effort making vector comparisons fast (quantization, SIMD, better HNSW graphs, and a disk-friendly IVF index), and has been rewarded with queries where vector comparison is no longer the bottleneck. When most of a filtered kNN search goes into materializing an expensive filter, the optimization worth having is not a faster distance function. It is noticing that the filter did not need to run over ten million documents to answer a question about ten.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/knn-filter-post-filtering</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/knn-filter-post-filtering</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Lucene]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Panagiotis Bailis]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9352abad694dba9d/6abe1f326c7756be006457d9/knn-post-filtering-header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Columnar storage isn't a columnar database. What Columnar mode brings to Elasticsearch]]></title>
    <description><![CDATA[Elasticsearch has stored data in columns since 2013, but adding full columnar database capabilities required a new mode.]]></description>
    <content:encoded><![CDATA[<p>When we wrote that Elasticsearch is becoming a columnar database, the sharpest reply we got was that it already is one. That reply is correct on the facts. Doc values, the per-field column store that Elasticsearch inherited from Lucene, arrived in 2013, and nearly every aggregation, sort, and query in Elasticsearch Query Language (ES|QL) has read them since Elasticsearch 2.0 made them the default. Each field's values sit together in their own file on disk. So the interesting question is what else a columnar database needs (rather than whether we store columns), and the answer turns out to be five things.</p><p>Doc values were built to make aggregations, sorting, and grouping possible on a document engine, and they do that job well. <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-storage">Columnar Mode</a> changes what the columns are for, and this post walks through the five properties that separate storing columns from being a columnar database.</p><h2>Five properties separate a column store from a columnar database</h2><h3>Doc values were an optimization on top of <code>_source</code></h3><p>For most of the last decade, the original JSON document was the source of truth and the columns were a derived convenience. That ordering has consequences throughout the engine.</p><p>Because the engine could always fall back to <code>_source</code>, per-field storage was allowed to be lossy. Text fields had no doc values at all, since text could be reread from the stored document when needed. Even synthetic source, which reconstructs a document from its fields rather than storing a copy, sometimes reads from row-shaped structures to stay faithful to the JSON that arrived, with values that exceed <code>ignore_above</code> and fields that arrived unmapped going into stored fields.</p><p>The result is a clear contract; whatever JSON you send, you get back, and the columns accelerate everything else. For an engine whose job is to return your documents, that’s the right way round. Columnar Mode inverts it. Every field stores itself exactly once as doc values, doc values cannot be turned off, text fields get doc values, too, and the document is reconstructed from the columns when something asks for it.</p><h3>How dictionary encoding handles high-cardinality data</h3><p>Sorted doc values, the default for keyword fields, store a dictionary of distinct values plus one ordinal per document pointing into it. This is an excellent trade when values repeat. A <code>host.name</code> field drawn from a hundred machines, or a status code that’s almost always 200, compresses beautifully and groups quickly.</p><p>It works like the index cards in a warehouse. When 50 crates hold the same product, one card and 50 pointers beats writing the product name 50 times. When 50 crates hold the same product, you can store the product name in the index with the list of 50 crate IDs. When every crate holds something unique, you might as well just put the product name on the crates; the index will help you find what crate you want, but it won't save ink.</p><p>High-cardinality fields describe a lot of real data, including URLs and trace identifiers, along with message bodies. Columnar Mode, which skips the dictionary and compresses the values in blocks instead, uses binary doc values for high-cardinality strings. Which of the two a field gets isn’t something you configure. The engine decides per field, based on the values it sees, so each column is encoded for the data it actually holds instead of one default applied to every field. Pure columnar systems have long carried cardinality in their type system, but usually as something you declare, and you own the consequences when the data shifts underneath it. Here, it’s the engine's job.</p><h3>Why every field builds an inverted index by default</h3><p>By default, a keyword field also builds an inverted index and a numeric field also builds a BKD tree. That happens on every field because at write time the engine doesn’t know which capability you’ll want at read time, significantly increasing the footprint of each field. Those structures also have to be rebuilt during segment merges, which costs CPU exactly when ingest is heaviest.</p><p>Our time series engine (TSDB) is proof of what happens when you stop paying for capability that the workload doesn’t use. Replacing the indices on <code>@timestamp</code> and dimension fields with <em>doc value skippers</em>, which are sparse structures holding the minimum and maximum value for each block of documents, removed 10 bytes of the original 25 bytes per OpenTelemetry (OTel) data point. There was no measurable query regression on time range and dimension filters, and indexing CPU dropped by about 10%, as a bonus.</p><p>Columnar Mode generalizes that default. Fields aren’t indexed unless something needs them to be, with only text-mapped fields keeping their inverted index for fast free-text search.</p><h3>Metadata fields like _id and _routing were row-shaped</h3><p>The fields you never think about followed the same document-first design. The <code>_id</code> field was a stored field plus an inverted index. Custom <code>_routing</code> was a stored field. Sequence numbers were kept for optimistic concurrency control, regardless of whether a workload ever updated a document.</p><p>TSDB deals with all three. It synthesizes <code>_id</code> from the <code>_tsid</code> and <code>@timestamp</code> values that already identify a data point, using a segment-level bloom filter to catch duplicates, which removes 5 bytes per data point with no loss of functionality. It trims sequence numbers once replication no longer needs them, which removes 4 bytes. Add a codec block size increase from 128 to 512 elements for another 2 bytes, and those four changes contribute across versions 9.1 through 9.4 to the 21 bytes that took OTel metrics <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-metrics-engine-30x-faster-prometheus">from 25 bytes per data point down to 3.75</a>.</p><p>Columnar Mode makes those ideas general rather than metrics-specific. By general availability (GA), all metadata fields will store themselves as doc values, while we plan to follow up and add a sort id mode synthesizing the identifier from the index sort fields, in addition to derived fields that will generalize what <code>_tsid</code> does for time series to any set of fields.</p><h3>Columnar query execution in the ES|QL compute engine</h3><p>A column store only pays off if the engine reads it as columns. Aggregations inherited the document-at-a-time shape from search, which is the natural fit for an engine built around documents. Reading columns instead lets the engine hand a whole block of values to a single instruction, and that’s where the numbers below come from.</p><p>The ES|QL compute engine changed that shape, and TSDB again shows the size of the effect:</p><ul><li><p><strong>Vectorized execution</strong> of time series aggregations was worth up to 8x on its own.</p></li><li><p><strong>Decoding on-disk data</strong> straight into the primitive arrays the engine aggregates over, with no intermediate copies, was worth roughly another 10x.</p></li><li><p><strong>Constant blocks</strong> turned repeated values into a form of in-memory run-length encoding.</p></li><li><p><strong>Filter pushdown</strong> moved filters down to Lucene, where skippers can discard whole blocks unopened.</p></li></ul><p>Together with the rest of the block-level query work, query latency improved by up to 160x compared to earlier versions.</p><p>That work continues. Skipper-aware operators, aggregations that group on ordinals and convert to real values as late as possible, and richer per-block summaries are all in progress, and they benefit every index mode because every mode reads doc values underneath.</p><h2>What Columnar Mode changes for logs and analytical data</h2><p>Storing values in columns is a storage detail. A columnar database needs five things:</p><ol><li><p>The columns are the only copy of the data.</p></li><li><p>Each column is encoded for the data it actually holds.</p></li><li><p>Metadata is columnar, too.</p></li><li><p>Fields add indices only when something needs them.</p></li><li><p>The query engine processes blocks of values rather than records or documents.</p></li></ol><p>TSDB reached all five for metrics in Elasticsearch 9.4, which is why the numbers in this post come from metrics rather than from a slide. Columnar Mode applies the same treatment to logs and security telemetry, along with analytical data. It’s in technical preview in Elasticsearch 9.5, with GA targeted for 9.7.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt723063cac8566f76/6aa8fb5a5ceda93f62dae858/unnamed.png" alt="Elasticsearch doc values, inverted index and _source across standard index mode, LogsDB and Columnar Mode" /><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-doc-values-columnar-database</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-doc-values-columnar-database</guid>
    <category><![CDATA[Lucene]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Yannis Roussos]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt25aa087300badaae/6ab3e7938ab0c04cc32254df/diagram-one-field-three-structures.webp" length="0" type="image/webp"/>
    <pubDate>Tue, 15 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One field, one copy: How Elasticsearch columnar storage drops the inverted index]]></title>
    <description><![CDATA[Storing each field once means no inverted index, so doc values now read in bulk and skippers let queries skip whole ranges of documents, while new mapping attributes control what each field is allowed to contain.]]></description>
    <content:encoded><![CDATA[<p>As part of the 9.5.0 release, Elasticsearch introduced <a href="https://www.elastic.co/docs/reference/elasticsearch/columnar">columnar and logsdb_columnar index modes</a> in technical preview. Elasticsearch has had columnar storage using Lucene’s doc values since version 1.0.0. Lucene’s doc values power analytics and search functionalities, like group by and sorting by a field. So, what changes with the columnar index modes? </p><p>The changes are about storage and performance, along with the out-of-the-box (OOTB) experience. Up until 9.5.0, Elasticsearch operated as a document-based search engine by default. It could be set up to behave like a columnar system storage-wise, but that wasn’t the OOTB experience. <a href="https://www.elastic.co/docs/reference/elasticsearch/columnar">Columnar index modes</a> make a number of fundamental changes that allow Elasticsearch to optimize columnar analytic and search use cases:</p><ul><li><p>Fields are stored once as doc values only and are no longer indexed by default.</p></li><li><p>New multi-value semantics. The original ordering of multiple values per field per document (for example, in arrays) is preserved by default.</p></li><li><p>Mappings are always flat, and object and passthrough fields in mappings are always auto-flattened.</p></li></ul><h2>How columnar index modes fit into Elasticsearch</h2><p>Many of the columnar index mode changes originate from <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">time series data streams (TSDSs)</a>. As part of making TSDB a competitive metrics solution, we improved doc values format on disk and only store dimensions and metric fields once as doc values. We also improved query performance. TSDB is already columnar today. Essentially, this makes TSDB’s storage mode columnar. The lessons learned from TSDB are now being applied more broadly to Elasticsearch. </p><p>Note that columnar index modes are opt-in and columnar, and document-based indices can coexist in the same cluster. An enterprise search use case can use a document-oriented index mode, while a logging use case can use logsdb_columnar index mode and be fully columnar, all in the same cluster. In fact, there are currently seven index modes, and indices can all use them in the same cluster.</p><h2>How columnar storage stays fast without an inverted index</h2><p>Indexed fields, either an inverted index for string-based fields or block k-dimensional (BKD) tree for numeric fields, allow Elasticsearch to query or filter by field very efficiently. However, the cost for this is an additional expensive data structure that uses a lot of disk space and is expensive to build at index time and at merge time. With the columnar index modes, fields are no longer indexed by default, so what did we do for query performance to be still acceptable on fields that were no longer indexed?</p><p>One major change was improving doc values scanning performance. This is key and is the cornerstone that any columnar system relies on. Previously, the scanning of doc values was essentially document by document. Lucene’s doc values API only allowed for looking up one value at a time. Historically, this fit the execution model of a search engine. In our own doc value format, we build the capability to allow bulk reading of values for Elasticsearch Query Language (ES|QL) queries. Also over recent minor Lucene releases, Lucene doc values API added support for bulk reading. Without this, fast columnar scanning wouldn’t have been possible.</p><p>Secondly, we fully adopted <a href="https://www.elastic.co/search-labs/blog/docvaluesskippers-lucene-range-queries">doc value skippers</a>, a hierarchical skiplist over doc values. Contrary to an inverted index or a BKD tree, doc values skippers are lightweight data structures. At its core, a <em>skipper</em> allows queries to skip over a range of documents that don’t match a query. It can do this because it stores information, like min and max values. So, for example, when a range query is executed, an interval of documents can be skipped based on the intermediate result and a doc value skipper’s min and max values. The effectiveness of doc value skippers depends on the order in which documents are laid out on disk. This is why <a href="https://www.elastic.co/docs/reference/elasticsearch/columnar#index-sorting">index sorting</a> should be enabled or altered to match the use case.</p><p>By significantly improving our columnar scanning and doubling down on doc values skippers, we’re able to avoid indexing fields by default. Note that a field can still be indexed; <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-index">the <code>index</code> mapping attribute</a> just defaults to <code>false</code> in columnar mode. The exceptions to this rule are text-based fields, which are still indexed by default. This is because text fields provide free text search, which includes text analysis, along with phrase and wildcard matching. This is different from just filtering.</p><h2>How Elasticsearch handles high and low cardinality fields</h2><p>When setting up a schema with string fields, an important configuration parameter is often <em>cardinality</em>; that is, whether many unique values or a few unique string values are expected. Many systems have dedicated field or column types that target low and high cardinality string fields.</p><p>Fields that have low cardinality are typically stored with a dictionary, containing all unique values. Then, for each row offset, the offset into the dictionary containing the term the row has is stored. This is often called an <em>ordinal</em>. For low cardinality fields, this works well, as storing an ordinal per document takes up much less space. Encoding techniques, like delta encoding, offset encoding, and bitpacking, work well for ordinals to compact the per-document storage to just a few bits. </p><p>However, for high cardinality fields, the dictionary and ordinal approach can work counterintuitively. If a larger percentage of the documents have a unique value, building the dictionary becomes expensive and storage savings diminish. The dictionary then becomes another level of indirection for reading values. This is why most systems in that case store values in a columnar fashion using block-based compression. For example, values of multiple rows are stored in 128KB blocks using a sliding-window dictionary-based compression algorithm (like zstandard or lz4). This, in general, is a simple and effective method to store higher cardinality fields and avoids building and maintaining a dictionary. </p><p>With document-based Elasticsearch, there are two ways to map a string: using either the keyword field mapping or one of the text-based field mappings. The former stores an inverted index and dictionary-based doc values. The latter only stores an inverted index. This is why, typically, a text field mapper is often used in combination with a keyword mapper as a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/multi-fields">multi-field</a>. Also, keyword field mapper (as the name suggests) is meant for keywords or fields that have a lower cardinality and uses dictionary-based doc values implementation. However, in practice, keyword field mappers are also used for high cardinality.</p><p>In columnar mode, every field is only stored once by default. For keyword fields, this means only doc values are stored with no inverted index. Text-based fields now also store doc values and an inverted index by default. Text-based field mappers are different from the keyword field mapper, as these are not automatically used via Elasticsearch’s dynamic mapping logic for columnar indices, and therefore text fields keep storing an inverted index by default.</p><p>For both keyword- and text-based fields, we didn’t choose to expose a cardinality mapping attribute. It’s not always possible to know ahead of time whether a field is low or high cardinality. When flushing and merging segments to disk, Elasticsearch sees all values and can determine the cardinality of a field. This is why we’re choosing to automatically determine whether the usage of a dictionary and ordinal-based encoding is beneficial over block-based compression using a simple cardinality threshold. If a field is below this threshold, dictionary and ordinal-based encoding is used; otherwise block-based compression is used. This simplifies configuration of the mappings and makes it possible to automatically optimize storage as data evolves, since some segments may use dictionaries while others may use blocks for the same field. However, this is currently not ready yet and so, as part of 9.5.0, in columnar mode, both keyword- and text-based field mappers store values in doc values in a block-based compressed layout on disk.</p><p>The two approaches compare as follows:</p><p>
</p><p><strong>Dictionary and ordinal encoding</strong></p><p><strong>Block-based compression</strong></p><p>Suits</p><p>Low cardinality fields</p><p>High cardinality fields</p><p>What’s stored</p><p>A dictionary of unique values, plus one ordinal per document</p><p>Values for many documents compressed together in blocks</p><p>Compression</p><p>Delta encoding, offset encoding, and bitpacking reduce each ordinal to a few bits</p><p>Sliding-window dictionary compression, such as zstandard or lz4, typically over 128KB blocks</p><p>Read path</p><p>Resolve the ordinal, and then look up the value in the dictionary</p><p>Decompress the block, and then read the value directly</p><p>Cost as cardinality rises</p><p>Dictionary grows large, savings shrink, and the extra indirection stays</p><p>Stable, with no dictionary to build or maintain</p><p>Used in columnar mode tech preview</p><p>Not yet</p><p>Yes, for both keyword and text fields</p><h2>Columnar mapping attributes: multi_value, nullability, on_failure</h2><p>The columnar index modes provide more control over how data is stored as doc values. By default, Elasticsearch is lenient and accepts all non-malformed values (for example, nulls and multiple values per field and document). If documents have fields with multiple values per document or no value, doc values store additional data structures to deal with them and therefore implicitly increase costs. </p><p>With columnar, new mapping attributes provide additional control. Note that these new mapping attributes are currently only available with the columnar index modes but will eventually also be available for all index modes.</p><p>Three new mapping attributes are involved:</p><p><strong>Attribute</strong></p><p><strong>Default</strong></p><p><strong>Enforces</strong></p><p><strong>On violation</strong></p><p><strong>Available</strong></p><p><code>multi_value</code></p><p><code>true</code></p><p>One value per document per field</p><p>Document indexing fails</p><p>9.5.0</p><p><code>nullability</code></p><p><code>true</code></p><p>Field must have a value</p><p>Document indexing fails</p><p>9.5.0</p><p><code>on_failure</code></p><p><code>fail</code></p><p>How the above failures are handled</p><p>Sets <code>fail</code> or <code>ignore</code> behavior</p><p>Next minor release</p><h3>multi_value: Enforcing single-valued fields</h3><p>By default, Elasticsearch accepts multiple values per document. To understand the implications of this, we first should take a look at how Elasticsearch (using Lucene’s doc values) stores a dense numeric field where all documents have a single value: </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3abb1f6fb702b165/6a9fa698893681d9ef3df7d6/unnamed.png" alt="Columnar storage doc values layout: value blocks and a block index resolve which value belongs to a docId" /><p>With this layout, all values are stored in blocks. The number of values per block depends on the index mode but is typically 128 values and is always the same within an index. All values in a block are encoded using various encoding techniques, like delta encoding and bit packing, so each block can have a different size, depending on how well the encoding techniques compress the values. This is why a block index is required. </p><p>Lucene has the notion of a docid (internal numbering for a document), which is essentially a row identifier.</p><ol><li><p>Queries produce matching docids.</p></li><li><p>In case of a dense field, the block id can be resolved from the docid directly.</p></li><li><p>The offset of a block can be resolved from the block index.</p></li><li><p>Once that has been looked up, the target block gets decoded and all values are available.</p></li><li><p>Finally, from docid, the ordinal within the decoded values array can be resolved, which produces the final value.</p></li></ol><p>Now let’s have a look at how the data layout changes when documents have multiple values per document:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9d5ca280a19c7a70/6a9fa6edee57e5dec9051db9/unnamed.png" alt="Multi-value doc values layout in columnar storage: offsets map a docId to values spanning value blocks" /><p>To determine how many values belong to a single docid, an offset lookup is required.</p><p>In this case, a docid has one or more offsets. Each offset points to a block index. Values for a single document are adjacent but can stretch over blocks. In general, compaction of values works well in blocks because values are similar. However, multi-value fields can cause the compaction of values to be less efficient if the number of values per field and document is large and values aren’t similar. This and the additional storage of offsets result in multi-value fields typically having a higher storage footprint on disk.</p><p></p><p>If a field is truly single-valued, you may want to enforce this property. <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/doc-values#doc-values-multi-value">The <code>multi_value</code> mapping attribute</a> makes that possible now. An example is a log level field. Logs typically have one log level (such as debug, info, or error). Enforcing that this field is single-valued in your mappings can help avoid accidentally using more storage than anticipated. A mapping snippet example that disallows the field <code>log.level</code> to have multiple values per document:</p>{
	"properties": {
		"log.level": {
			"type": "keyword",
			"multi_value": false
		}
	}
}<p></p><p>Note that even if a field allows multiple values, this doesn’t mean an offset lookup is stored. This only happens when a Lucene segment has at least one document with two or more values. The <code>multi_value</code> mapping attribute exists just for enforcement.</p><h3>nullability: Requiring every document to have a value</h3><p>By default, Elasticsearch accepts documents with fields that have no value or null value. Just as  multiple values per document require additional accounting, documents with no value require additional accounting to identify which of them have at least one value.</p><p>Doc values store a docid to offset lookup (known as IndexedDISI in Lucene) in case not all documents have a value in a segment. The offset either points directly to the block index for single-valued fields or the offset lookup in case of multi-valued fields. This lookup is compact compared to the value blocks being stored. However, if it were to be created for fields that should have at least one value per document, that would be a waste.</p><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/doc-values#doc-values-nullability">The <code>nullability</code> mapping attribute</a> allows you to control whether documents are allowed to have no value. Just like the <code>multi_value</code> mapping attribute, the <code>nullability</code> attribute exists for enforcement.  Following is a mapping snippet example that requires the <code>log.level</code> field to have a value:</p>{
  "properties": {
     "log.level": {
        "type": "keyword",
        "nullability": false
     }
  }
}<h3>on_failure: What happens when validation fails</h3><p>What happens if a document has multiple values for a field and if the <code>multi_value</code> mapping attribute is set to <code>false</code> or when a field is mapped with nullability set to <code>false</code> and a document doesn’t have that field? At the moment, indexing such documents will fail with a bad request error.</p><p>As part of the next minor release, <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/doc-values#doc-values-on-failure">the <code>on_failure</code> mapping attribute</a> will be available. This allows you to indicate how to handle these validation failures, on a per-mapped field basis. This will support two values:</p><ol><li><p>Fail: Fail indexing of the entire document with a client error. This is the current behavior in Elasticsearch 9.5.0.</p></li><li><p>Ignore: Ignore the validation error, mark the field as ignored, and store values for that field in a hidden field so that it can be introspected when requesting the source. </p></li></ol><h2>Trying the columnar index modes</h2><p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/columnar">columnar index modes</a> are still under active development, but we encourage you to give them a test drive. As we prepare the columnar index modes for general availability (GA), we’ll add more performance and efficiency improvements. We believe that by adapting a columnar mindset, many use cases will benefit from being more cost effective or having better performance characteristics.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/columnar-storage-elasticsearch-index-modes</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/columnar-storage-elasticsearch-index-modes</guid>
    <category><![CDATA[Lucene]]></category>
    <category><![CDATA[Index Data]]></category>
    <category><![CDATA[Mappings]]></category>
    <dc:creator><![CDATA[Martijn van Groningen]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltef1303bd86788300/6a9fa5cdf08ee10715855390/unnamed.png" length="0" type="image/png"/>
    <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Query rewrite rules in Elasticsearch: 2.3x faster wildcard scans]]></title>
    <description><![CDATA[A second rule makes empty-string filters 1.6x faster. It reads string lengths straight from the offset array and never touches the compressed bytes. Both rules came from the same habit of running real queries and hunting for the special case.]]></description>
    <content:encoded><![CDATA[<p>Lucene query rewrite rules make two string scan queries in Elasticsearch's <a href="https://www.elastic.co/docs/reference/elasticsearch/columnar">columnar mode</a> 2.3x and 1.6x faster. Both rules spot a query shape at runtime and swap in a cheaper implementation. For a wildcard query like <code>*google*</code>, that's a substring search in place of the automaton. A filter like <code>SearchPhrase != ''</code> can skip Zstd decompression, because it only needs string lengths that are sitting in an offset array.</p><p>Columnar mode is Elasticsearch's analytics-optimized <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-storage">columnar storage</a> mode, built for scan-heavy workloads, like log analytics. In this mode, keyword fields don't get an inverted index by default, so term and wildcard queries scan doc values. <a href="https://www.elastic.co/search-labs/blog/docvaluesskippers-lucene-range-queries">DocValuesSkippers</a> (zone maps) already trim how much data a scan touches, but these rewrites cut the cost of what's left. </p><h2>How Lucene's query rewrite mechanism works</h2><p>In Lucene, every query has the option to implement a <code>rewrite</code> method that returns another query. This method returns a query with the same semantics but a different implementation. The query engine repeatedly calls the <code>rewrite</code> method until the returned query doesn’t change. This final query is the one that’s actually evaluated. Importantly, the <code>rewrite</code> can see the actual query arguments and specialize the implementation based on these.</p><p>For example, in a query looking for documents where a string field contains the value "foo", the <code>rewrite</code> method knows that the term we’re searching for is "foo". In theory, <code>rewrite</code> could replace the general query class with something specific to "foo". For example, the original query class <code>ScanningBinaryDocValuesTermQuery</code> could be replaced with <code>FooQuery</code>. Now this rule probably wouldn't be helpful, but it gives a sense for the level of specialization that’s achievable with rewrite rules.</p><h3>Rewrite rules and query optimization in database systems</h3><p>It's worth placing rewrite rules in the larger context of database systems. Lucene and Elasticsearch aren’t the first systems to use transformation rules to optimize queries. Most (or maybe all) database systems use some kind of rule system during query optimization. The most influential rewrite rule system was in IBM's <a href="https://dl.acm.org/doi/10.1145/141484.130294">Starburst</a> database. This system's core contribution was extensibility; for example, it was possible to add new data types and storage methods, along with (most importantly to us) optimizer rewrite rules.</p><p>Each rule consisted of two parts:</p><ol><li><p><strong>A condition function:</strong> A predicate determining whether the rule applies to the current query graph.</p></li><li><p><strong>An action function:</strong> The transformation that rewrites the query plan into a more optimal form.</p></li></ol><p>A rule engine applied matching rules until a stopping condition was met.</p><p>Though Lucene's <code>rewrite</code> method is superficially different from these condition and action functions, it achieves the same goal. It checks whether certain conditions match, and if they do, it applies the rewrite by returning a new query. If conditions don’t match, the <code>rewrite</code> returns <code>this</code>, replacing the query with itself; that is, choosing not to apply the rule.</p><h3>Why these rules live in Lucene, not the ES|QL query optimizer</h3><p>Elasticsearch actually contains a separate rewrite rule system within the <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> optimizer. This operates on the high-level structure of a query; for example, doing predicate pushdown to avoid unnecessary computation on documents that will be filtered out. But it’s still useful to have the rule system within Lucene. Since Lucene acts as the storage layer for ES|QL (and classic <code>_search</code>) queries, it’s easier to express rewrites that take advantage of the physical data format in Lucene rather than in a higher-level optimizer.</p><h2>A query rewrite rule for wildcard queries: Simpler code, no automaton</h2><p><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-wildcard-query">Wildcard queries</a> support the <code>?</code> and <code>*</code> operators to match any character once or any character multiple times. These operators can appear any number of times in a wildcard query. As with regexes, to evaluate whether a string matches a wildcard query, we build an automaton from the query string and then use the string bytes to do state transitions through the automaton. This is relatively fast, but if you have to evaluate it for every document, the latency really adds up.</p><p>But maybe we don't always have to run an automaton. Consider a query like <code>*foo*</code>. How would you implement this if you were writing a simple query engine to find matching strings in a list of strings? Pretty much every programming language has the tool you want built in: a method that finds a substring within a given string. This function doesn't need a complicated automaton; it probably just consists of a couple of <code>for</code> loops.</p><p>Now of course we couldn't use this function to implement an arbitrary wildcard query, but we don't have to. The rule rewrite system isn't for the general form. It's for implementing special cases, and it can see the specific query. It knows that we’re looking for <code>*foo*</code> and realizes that this specific case doesn't require the heavyweight automaton machinery. And it can do the same for any query that starts and ends with a <code>*</code>, with some term in the middle.</p><p>The following pseudo-code shows the pattern. At the top, we have the generic <code>WildcardQuery</code>. It has two notable fields: the query string (for example, <code>*foo*</code>) and the automaton built for that query. The <code>matches</code> method checks whether the field value for a given <code>docId</code> is a match by using it to evaluate the state transitions of the automaton. More interestingly, its rewrite method checks whether the query matches our special case. We show this with a regex that checks whether the query string starts with a <code>*</code>, has any non-<code>*</code>characters at least once, and then ends in a <code>*</code>. If so, we return the special case as a <code>ContainsQuery</code> and pass in the inner query string (since it doesn't care about the <code>*</code>s). The <code>ContainsQuery</code> then just does a simple <code>contains</code> check to see whether the term bytes are somewhere within the value bytes.</p>class WildcardQuery(query, automaton, docValues):

    boolean matches(docId):
        value = docValues.loadValue(docId)
        return automaton.matches(value)

    Query rewrite():
        if query matches r"^\*[^*]+\*$":
            return ContainsQuery(query[1:-1], docValues)
        return self


class ContainsQuery(term, docValues):

    boolean matches(docId):
        value = docValues.loadValue(docId)
        return value.contains(term)<h3>Benchmarking the wildcard rewrite on ClickBench Q20</h3><p>The wildcard rewrite is straightforward, but does it actually work? Yes, we can use the <a href="https://benchmark.clickhouse.com/">ClickBench</a> benchmark, which has several queries of this form. Query 20 (Q20) is <code>FROM hits | WHERE URL LIKE "*google*" | STATS count = COUNT(*)</code>. It's exactly the query shape that this rule matches: a string match against the wildcard query <code>*google*</code>. And since the query is just counting, we can see exactly how well this technique works. It turns out to be quite effective. Q20 saw a 1.75x improvement on median latency of hot query times, with no filter cache. All benchmarks in this post were run on an Intel Core i9-13900H.</p><h3>Adding SIMD to the substring search: 1.75x to 2.3x</h3><p>But can we do better? Yes, switching to a simple contains check opens up a new possibility. Instead of using the two for loops, we can swap scalar logic for single instruction, multiple data (SIMD) logic. Elasticsearch uses the <a href="https://openjdk.org/jeps/438">Panama vector API</a> (see our <a href="https://www.elastic.co/blog/accelerating-vector-search-simd-instructions">post on SIMD in Elasticsearch</a>), which lets us implement the contains check in SIMD. This works particularly well for longer strings that can take advantage of the wide SIMD registers; for strings under 24 characters, we still use the scalar approach. With this change, we saw another 1.32x improvement, resulting in a total speedup of 2.3x over the automaton-based approach.</p><h2>A query rewrite rule for empty strings: Less data, no decompression</h2><p>One benefit of Lucene-based rules is that they’re low level and can fit to the data format. That’s the case for this rule, which applies to string data. </p><h3>How columnar storage encodes string data</h3><p>In Elasticsearch's standard mode, string values are stored by document; this is a row-major format. But in columnar mode, unsurprisingly, the data is stored in columnar-major format. A column of string data is stored in chunks. Each chunk contains many string values and consists of an array of integer offsets and a (Zstd-compressed) blob of the strings' bytes. For a string at index <code>i</code>, <code>offsets[i]</code> points to the offset in the decompressed byte blob where the string starts. So the length of string <code>i</code> can be computed from <code>offset[i+1]-offsets[i]</code>. (There's a dummy extra offset at the end, so we can easily compute the length of the last string). The following diagram shows how a chunk with the strings ‘Feta’, ‘Asiago’, ‘’, ‘Stilton’, and ‘Brie’ is encoded.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt046cea94361c6305/6a9ee0c3508cca28e2a15ff9/image1.png" alt="Columnar storage chunk with an offsets array and byte blob, showing an empty string as two equal offsets" /><h3>Why a term query has to decompress the chunk</h3><p>Now that we understand the columnar format, let's get back to query optimization. First, consider a term query for the query <code>foo</code>. We’re looking for documents where a given string field exactly matches the string <code>foo</code>. So how do we implement this on a string column in the above format? The algorithm is straightforward:</p>docId = 0
for chunk in chunks:
    bytes = zstd_decompress(chunk.bytes)
    for i in range(len(chunk.offsets) - 1):
        value = bytes[chunk.offsets[i] : chunk.offsets[i+1]]
        if value == term:
            yield docId
        docId++<p>The bottleneck is the Zstd decompression step. But there's not much we can do about that; if we want to check the bytes, we have to decompress the chunks. But remember, we aren't trying to optimize the general case, we’re looking for special cases. (In reality, you don't just try to think up special cases. These optimizations came about by first running a useful query, realizing that it could be faster, and then looking for ways to improve it.)</p><h3>Rewriting the empty string query as a length check</h3><p>One special case we found that’s worth improving is a query for the term <code>""</code>. Admittedly, it's a silly term, but empty strings are all over the place. Since they're rarely useful, we usually filter them out with a query like <code>term != ""</code>. Thankfully, this is a query we can optimize.</p><p>Consider the above algorithm for the empty string term. The line <code>if value == term</code> is a bit weird; we’re asking <em>Does this value equal the empty string?</em> We can do that, but there are no bytes to compare, so the check unwinds:</p><ol><li><p>We only need to know whether the value has length 0.</p></li><li><p>If we only need the length, we don't need to look up the value in the decompressed chunk.</p></li><li><p>If we never look up a value, we don't need any bytes from the chunk at all.</p></li><li><p>If we need no bytes from the chunk, we don't need to decompress it.</p></li></ol><p>All we need are the lengths, and those live in the offsets array. It's compressed, too, but with cheap integer compression rather than Zstd, which is much faster.</p><p>With this realization, we can rewrite empty string term queries. The one new operation we need is <code>docValues.loadLength(docId)</code>, which reads directly from the offset array without touching the compressed bytes. After the previous example, this should look familiar. The most interesting part is <code>TermEqualsQuery.rewrite</code>; it finds the empty string special case and replaces the query with the simpler version that only checks the length.</p>class TermEqualsQuery(term, docValues):

    boolean matches(docId):
        value = docValues.loadValue(docId)  # requires Zstd decompression
        return value == term

    Query rewrite():
        if term == "":
            return LengthEqualsQuery(0, docValues)
        return self


class LengthEqualsQuery(queryLen, docValues):

    boolean matches(docId):
        length = docValues.loadLength(docId)  # reads only from offset array
        return length == queryLen<h3>Benchmarking the empty string rewrite: 1.6x faster</h3><p>Now let's see how this stacks up. There aren't any pure-scan ClickBench queries that use this rule as directly as Q20 does for the previous rule, so we'll make our own. Consider the query: <code>FROM hits | WHERE SearchPhrase != '' | STATS count(*)</code>. On this query, we see a 1.6x speedup, which is a great improvement for a fairly uncomplicated change. Better yet, ES|QL can take advantage of <code>loadLength</code> directly. Any time that ES|QL accesses a string's <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions/byte_length"><code>BYTE_LENGTH</code></a>, without needing the string itself, the request uses this same specialized length loading to avoid unnecessary decompression.</p><h2>What makes a good query rewrite rule</h2><p>The two rules covered here follow the same shape: identify that a query is a special case, and then swap it for a cheaper implementation. But they reduce cost in different ways. </p><p></p><p>
</p><p><strong>Wildcard rule</strong></p><p><strong>Empty string rule</strong></p><p>Query shape detected</p><p><code>*term*</code></p><p><code>field == ""</code></p><p>Replaced with</p><p>SIMD substring search</p><p>Length check on the offsets array</p><p>Cost reduced</p><p>Algorithmic work</p><p>Data access</p><p>Speedup</p><p>2.3x</p><p>1.6x</p><p>The underlying pattern is worth noting: finding a query that leaves performance on the table, finding a special case that can be optimized, and swapping in a cheaper implementation. The hard parts are finding queries that uncover these opportunities for optimization and then identifying the special cases. The actual fix is often relatively straightforward, as both rules here show. Our work on columnar mode has provided many opportunities to run interesting queries and hunt down exactly these kinds of wins.</p><p>That's also why extensibility in a rule system is so important. These rules can't be built into a database from the start; they're found through an incremental discovery process. Lucene's rewrite system makes that practical. As columnar mode grows to handle new workloads, rules like these will keep emerging.</p><p>To try columnar mode and the optimizations described in this article, use Elastic Cloud Serverless or Elasticsearch 9.5 or later, where columnar mode is available as a technical preview.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/query-rewrite-columnar-storage-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/query-rewrite-columnar-storage-elasticsearch</guid>
    <category><![CDATA[Lucene]]></category>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Parker Timmins,Martijn Van Groningen]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt838d10c743cf1f3e/6a9ee03c8936813a883df5d5/image2.png" length="0" type="image/png"/>
    <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Skip the mapping explosion: ES|QL queries schemaless JSON keys without dynamic mapping]]></title>
    <description><![CDATA[Flattened fields turn Elasticsearch into a schema-on-read store where you index schemaless data under one mapping, then use ES|QL's FIELD_EXTRACT to pull out any JSON key you need to filter, group or join on, with predicates pushed into the columnar store.]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a> now reads<a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/flattened"> <code>flattened</code> fields</a>. FIELD_EXTRACT pulls any key out of a schemaless JSON object so you can filter, group, sort and join on keys you never mapped. The planner pushes those predicates into the columnar store rather than parsing the whole blob per row, which means dynamic JSON keys from OTel attributes, log labels, user metadata or whatever else you didn't want to map individually are queryable without causing a<a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/mapping-limit"> mapping explosion</a>.</p><h2>Why dynamic mapping breaks down with schemaless data</h2><p>Elasticsearch wants a schema. Every field you index has a mapping that defines its type, its analyzer (for text), and how it is stored. That schema makes storage compression efficient, with fast search and cheap aggregation. It becomes a liability the moment your data stops looking like a database table.</p><p>Consider the shapes that show up in real systems:</p><ul><li><p>Log events where every service adds its own attributes. One emits <code>labels.region</code>, another <code>labels.k8s.pod</code>, and another <code>labels.tenant_id</code>.</p></li><li><p>OpenTelemetry resource attributes, where the set of keys is defined by whatever agent happened to send the span.</p></li><li><p>User-supplied metadata bags, feature flags, or tagging systems, where the keys are open-ended by design.</p></li></ul><p>If you map each key as its own field, the mapping grows unbounded. This is the classic "mapping explosion." Thousands of dynamically created fields inflate the cluster state, slowing down every mapping update and eventually hitting the field limit. Each field also carries overhead in the index. You pay a structural cost for keys you didn’t plan for and may query only once.</p><p>The naive escape hatch is to store the whole object as a string and give up on querying its contents. That trades one problem for another: you keep the data but lose the ability to filter or group by anything inside it.</p><p>The <code>flattened</code> field type is the better path. You can index an entire JSON object under a single mapped field, keeping the keys queryable with almost none of the mapping-explosion cost. </p><p>This post covers how a flattened field stores the data on a disk and how ES|QL reads it back.</p><h2>How flattened fields index dynamic JSON keys under one mapping</h2><p>Map one field as flattened:</p>PUT logs
{
 "mappings": {
"properties": {
"labels": { "type": "flattened" }
   }
 }
}<p>Then write arbitrary nested JSON into it:</p>POST logs/_doc
{
 "labels": {
   "region": "us-east-1",
   "k8s": { "pod": "web-7f9", "node": "ip-10-0-0-3" },
   "retries": 4
 }
}<p>There is exactly one field in the mapping, <code>labels</code>, no matter how many keys appear across your documents. The cluster state doesn’t grow when a new key shows up. The subkeys remain individually searchable. You can reference <code>labels.region</code> or <code>labels.k8s.pod</code> in queries, even though neither was ever declared.</p><p>The catch and central tradeoff is that every leaf value is a keyword. The number 4 above is indexed as the string <code>"4"</code>. There is no numeric typing, no date parsing, and no range math on dynamic keys. Flattened fields exchange per-field richness for schema flexibility. That fact explains almost every design decision that follows.</p><h2>How Elasticsearch stores flattened field JSON keys on disk</h2><p>There are two types of queries on flattened fields: an unkeyed query on the root flattened field, and a keyed query on a specific subfield. Following the previous example, a query of the form <code>labels: "us-east-1"</code> matches a value under <em>any</em> key, while the query<code>labels.region: "us-east-1"</code> matches a value under the <em>specific</em> <code>region</code>key.</p><p>To support these two distinct query formats, the flattened mapper writes each leaf value into two distinct Lucene fields.</p><p>Take this document:</p>{ "labels": { "region": "us-east-1", "k8s": { "pod": "web-7f9" } } }<p>The mapper produces:</p><ul><li><p>A root field under labels, holding the bare values:</p></li></ul>us-east-1
web-7f9<ul><li><p>A keyed field under labels._keyed, holding the flattened key concatenated with its value:</p></li></ul>region\0us-east-1
k8s.pod\0web-7f9<p>In the keyed field, nested objects are dot-flattened into a single key (k8s.pod), and the key is joined to its value with a reserved NULL byte (\0) as the separator. Keys that contain a NULL byte are rejected at parse time, so the separator is always unambiguous. To find the value, you split on the first NULL.</p><p>These two fields make both query shapes work:</p><ul><li><p><code>labels: "us-east-1"</code> matches a value under <em>any</em> key, so it searches the root field.</p></li><li><p><code>labels.region: "us-east-1"</code> matches a value under a <em>specific</em> key. It rewrites the query to the term <code>region\0us-east-1</code> and searches the keyed field.</p></li></ul><h2>Query restrictions on flattened field subkeys</h2><p>Because every key's terms live in one sorted list, the keyed field cannot answer every query shape a plain keyword field can. Three restrictions follow:</p><ol><li><p>No fuzzy, regexp, or wildcard on a specific subkey. Nothing about the layout makes them impossible,  but the pattern would have to be combined with the key prefix so it can’t walk past the key boundary. The mapper doesn’t do that at this writing.</p></li><li><p>Any range query on a subkey needs at least one bound. Elasticsearch already has a query for "this field has some value here, whatever it is": the <code>exists</code> query. On a flattened subkey it runs as a prefix query on key\0, which sweeps every term belonging to that key. A range with neither bound would sweep exactly the same terms. Rather than support two spellings of one scan, the mapper rejects the boundless range and asks for the <code>exists</code> query.</p></li><li><p>A range query on a subkey needs the field to be indexed. A flattened field can be mapped with <code>index: false</code>, which skips the inverted index and keeps only doc values, the columnar per-document storage covered in the next section. Exact-match queries survive that. With no terms to look up, Elasticsearch scans the doc values column instead, which is slower but gives the same answer. Range queries have no equivalent fallback, so a range on a subkey of an unindexed flattened field throws an Exception.</p></li></ol><p>All three are limits on the Lucene query the mapper is willing to build, and where you notice them depends on how you query.</p><p>On the search API, where you name <code>labels.region</code> directly, they come back as errors.</p><p>In ES|QL you will not see them as errors at all. There, the same limits decide only whether a predicate is pushed into Lucene or runs in the compute engine on the extracted column. </p><p>This is pushed to a term query on the keyed field:</p><p>This is not, so the filter runs per row on the extracted keyword:</p><p>Same answer, more work. That distinction is the subject of the second half of this post.</p><h2>Under the hood: how range queries stay inside key boundaries</h2><p>This part is internal. You don’t need it to use the field, but it explains where the bounds rule comes from.</p><p>For a handful of documents in one segment, the shared term list looks like this:</p>k8s.pod\0web-7f9
region\0us-east-1
region\0us-west-2
tenant_id\0acme<p>Each key owns a contiguous slice of that list. For example, all values for key "region" are clustered together in an ordered sublist. A range with both bounds set encodes each bound the same way a term is encoded, so a lower bound of "us-east" on the region key becomes region\0us-east and an upper bound of "us-west" becomes region\0us-west. Both endpoints already carry the key prefix, so the scan can’t leave the region slice. Nothing special is needed.</p><p>The half-open case is problematic. Handing Lucene a lower bound of <code>region\0us-east</code> with no upper bound would scan to the end of the term list, straight through <code>tenant_id\0acme</code> and every other key that sorts after region. So the mapper substitutes a sentinel for the missing side:</p><ul><li><p>A missing lower bound becomes key\0, inclusive. That’s the encoding of the empty value, and it’s the first term in the key's slice.</p></li><li><p>A missing upper bound becomes key\1, exclusive. Byte 0x01 is the next byte after the 0x00 separator, so it sorts after every key\0value term and before the first term of any other key.</p></li></ul><p>A one-sided range is therefore boxed into [key\0, key\1), which makes it exactly as safe as a closed one.</p><p>The upper sentinel also covers the case where one key is a prefix of another. If an index holds both region and regionx, then region\1 still sorts below regionx\0eu-west-1, because 0x01 is smaller than the x that follows the shared region prefix. A range on region cannot leak into regionx.</p><h3>How flattened fields use the inverted index and doc values</h3><p>Each leaf value can be written into two Lucene structures. Both are enabled by default, but can be disabled by the mapping configuration.</p><ul><li><p>The inverted index (when the field is indexed). 
Two untokenized keyword terms are indexed per value, one on the root path and one on the keyed path. This powers term, prefix, and range searches.</p></li><li><p>Doc values (when <code>doc_values</code> is enabled). 
A columnar, document-ordered structure. This powers sorting, aggregations, and ES|QL reads.</p></li></ul><p>The inverted index answers the question: "Which documents contain this term?" </p><p>Doc values answer another question: "For this document, what are the values?" Doc values are laid out column by column so a scan touches only the bytes it needs. A flattened field uses both inverted indexes and doc values, so it can serve search and analytics from the same field.</p><p>The nature of the index means that the root field is only present in some cases. When the inverted index is disabled by the mapping, any value search requires a linear scan of the doc values for the searched value. In this case, when performing a search on the root field, there isn’t much additional overhead compared to just scanning the keyed field and ignoring the key markers. So the flattened mapper skips writing the root field, relying on the keyed field for both root and keyed queries.</p><h3>Why flattened fields switched from dictionary to binary doc values</h3><p>Historically, flattened field doc values used Lucene's <code>SortedSetDocValues</code>, which is a dictionary-compressed format. This means that every <code>key\0value</code> value indexed across all documents per segment is stored in one big sorted, deduplicated set of values. Each document tracks a list of ordinals into that value set.</p><p>This dictionary approach provides great compression for low-cardinality fields that tend to have repeated values. It is byte-efficient to store a value only once, and then refer to it by a single integer value. However, there is overhead associated with building and maintaining that dictionary of values, and that overhead is wasted effort when operating on high-cardinality fields that don’t repeat values.</p><p>Because flattened fields are a catch-all type, their cardinality tends to be very high. So while the dictionary approach works, it’s not the most compact or scan-friendly layout for this data, especially in time-series indices where flattened bags are common and storage pressure is real.</p><p>Recent versions switched the storage to use Lucene’s <code>BinaryDocValues</code>. This format just stores a literal binary blob for each document, which is compressed using Zstandard by our doc values codec when written to disk.</p><p>This new binary format provides an additional benefit: it allows us to maintain original array ordering without any overhead. Flattened fields support the mapping parameter <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/flattened#flattened-params">preserve_leaf_arrays</a>, which affects how multivalued fields are returned when using <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-source-field#synthetic-source">synthetic source</a>. When configured to <code>preserve_leaf_arrays: exact</code>, returned values preserve the order, duplicates, and nulls from the original source value.</p><p>The dictionary encoding inherent to sorted-set doc values means the returned values are sorted, deduplicated, and de-nulled. To implement <code>preserve_leaf_arrays</code>, flattened fields have traditionally used an additional sidecar field, tracking the required metadata to reconstruct the original source value. However, the nature of binary doc values means that this sidecar field is no longer needed. The values are just stored and returned as indexed.</p><h3>Limitations of schema on read with flattened fields</h3><p>The limitations below follow from the keyword-only rule and the shared keyed field:</p><ul><li><p>No numeric, date, or Boolean typing on dynamic keys. <code>100</code> and <code>"100"</code> are the same term.</p></li><li><p>No fuzzy, regexp, or wildcard on a specific subkey.</p></li><li><p>No multi-fields (<code>fields</code>) or <code>copy_to</code> on the flattened field.</p></li><li><p>A <code>depth_limit</code> (default 20) on how deeply nested the object can be.</p></li></ul><p>If you need real typing for a <em>known</em> key, flattened now supports explicitly <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/flattened#flattened-properties">mapped subfields</a>: you declare individual keys with real types under a <code>properties</code> block, and those keys are indexed by their own typed mapper instead of the keyed field. You get numeric ranges on <code>labels.status_code</code>, while everything else in <code>labels</code> stays dynamic and keyword-only. This is the escape hatch for the handful of keys you actually know about.</p><h2>Schema on read: querying flattened field JSON keys in ES|QL</h2><p>Search has supported flattened fields for years. ES|QL, the newer, piped query language built on a columnar compute engine, now reads flattened fields, too. Support began in the Technical Preview of Elasticsearch 9.5.0. It has two distinct pieces.</p><h3>What ES|QL returns when you select a flattened field</h3><p>ES|QL uses a dedicated data type for flattened fields, rather than folding it into <code>keyword</code>. When you select the root, you get the whole object back as a JSON string:</p>labels:flattened
{"k8s.pod":"web-7f9","region":"us-east-1"}<p>Keys come back sorted. You can carry this value through a query, count it, group by it, and run the multi-value and comparison functions on it. But an opaque JSON blob is not usually what you want to filter or aggregate on. For that, you need to reach inside it and process its contents.</p><h3>How FIELD_EXTRACT reads JSON keys from flattened fields</h3><p>There is no dotted-path syntax for dynamic keys in ES|QL. You cannot write <code>labels.region</code> for an unmapped key, because to the engine the flattened root is a single leaf value, not a set of columns. Instead you use a function:</p><p>The absence of a dotted-path syntax is a tentative limitation. The keyed field already addresses individual subkeys, so a more natural syntax for reaching into a flattened root is something we are planning to support.</p><p>FIELD_EXTRACT(field, path) takes a flattened field and a key, and returns a keyword. The rules are easier to see against a document. Take this one:</p>POST logs/_doc
{
 "labels": {
   "region": "us-east-1",
   "k8s": { "pod": "web-7f9", "node": "ip-10-0-0-3" },
   "tags": ["prod", "canary"],
   "retries": 4
 }
}<p>And this query:</p><p>The result is:</p>region     | pod     | k8s  | tags            | retries | namespace
us-east-1  | web-7f9 | null | [prod, canary]  | 4       | null<p>Four things to take from this:</p><ul><li><p>The dot is part of the key, not a navigation operator. The mapper already collapsed the nested object into the flat key k8s.pod, so "k8s.pod is a direct lookup, not a walk from k8s to pod.</p></li><li><p>Matching is exact. "k8s" returns null because there is no leaf stored at k8s, only at k8s.pod and k8s.node. For the same reason, "host" will not find "host.name", and matching is case-sensitive, so "Region" will not find "region".</p></li><li><p>Arrays come back multi-valued. "tags" yields a multi-valued keyword you can <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/mv_expand">MV_EXPAND</a>, count, or filter on. A missing key yields null.</p></li><li><p>Everything is a keyword. "retries" comes back as the string "4", and a Boolean leaf comes back as "true" or "false".</p></li></ul><p>JSONPath syntax is rejected outright, at parse time rather than per row. Both FIELD_EXTRACT(labels, "['k8s.pod']") and FIELD_EXTRACT(labels, "tags[0]") fail with <em>field_extract path must be a literal flattened sub-field name</em>.</p><p>Once extracted, the value is an ordinary <code>keyword</code> column. You can filter on it, group by it, sort by it, or use it as the join key in a <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join"><code>LOOKUP JOIN</code></a>:</p><h3>How ES|QL pushes flattened field predicates into the columnar store</h3><p>The obvious way to implement FIELD_EXTRACT would be to read the whole flattened root out of storage, render it as a JSON string, hand it to the compute engine, and parse it once per row to pull out a single key. But that means reading every key to use once and paying for a JSON parse on every document. So this obvious implementation is not performant.</p><p>ES|QL avoids this whenever possible. Between storage and the compute engine sits the <em>block loader</em>, the step that turns stored data into the columnar blocks the engine operates on. FIELD_EXTRACT hooks into that step instead of running after it.</p><p>When ES|QL loads a column, the flattened field type inspects the request. If the request is an extraction of a single constant key and the field has doc values, it routes straight to the keyed doc-values loader, which reads the key\0value entries for only that key out of the columnar structure. The column that arrives at the compute engine already contains only that key's values. The JSON string is never built and never parsed, and the other keys in the object are never read.</p><p>When extraction can’t be fused into the block loader, for example, because the key is computed per row or the root is the output of another function such as CASE, ES|QL falls back to the parse-per-row path. The results are identical either way. Only the cost changes.</p><p>The comparison can push down further. A predicate like <code>FIELD_EXTRACT(labels, "region") == "us-east-1"</code> can be pushed to Lucene as a term query against the synthetic keyed field, the same <code>region\0us-east-1</code> term the search path uses. So a filter on an extracted subkey can be answered by the inverted index (if available), and the projection can be answered by doc values, exactly like a first-class field, even though the key was never in the mapping.</p><p>Ordering comparisons push down, too. The four, single-sided comparators (&gt;, &gt;=, &lt;, &lt;=) and closed BETWEEN-style ranges all become a range query on that same synthetic keyed field. This is where the key\0 / key\1 sentinels earn their keep: the single-sided forms are only pushable because the mapper can box an open bound inside the key. The pushed range is treated as a candidate, and the predicate is re-evaluated on the extracted keyword column afterwards, so multi-valued keys don’t slip through.</p><p>The values are keywords, so the ordering is lexicographic, not numeric. FIELD_EXTRACT(labels, "retries") &gt; "10" compares strings, which means "9" is greater than "10". If you need numeric ranges on a key, map it explicitly under properties, or cast the value in ESQL.</p><p>Explicitly mapped subfields behave differently on purpose. Because they carry real types, comparison semantics diverge from the keyword path, so they are loaded and compared through their own typed mapper rather than fused into the keyed loader. And when you select a flattened root that has mapped subfields, ES|QL loads it from <code>_source</code>, so every leaf renders as a string and no keys are dropped silently.</p><h2>When to use flattened fields vs. dynamic mapping in Elasticsearch</h2><p>Use <code>flattened</code> fields when:</p><ul><li><p>The set of keys is open-ended or unknown ahead of time.</p></li><li><p>You would otherwise cause a mapping explosion.</p></li><li><p>Keyword-level filtering and grouping on the values is enough, and you don’t need numeric or date semantics on the dynamic keys.</p></li><li><p>You have a few keys that <em>do</em> need real types. Map those explicitly under <code>properties</code>, and let the rest stay dynamic.</p></li></ul><p>Avoid it, or map fields normally, when the schema is stable and you need full-text analysis, numeric aggregation, or date math across the board.</p><p>Remember that flattened is not a dumping ground for JSON you have given up on. It’s a real columnar-and-inverted store for schemaless data, and with ES|QL support, it’s now a first-class analytical citizen. You can keep the messy, unmapped parts of your data messy, and still filter, group, join, and aggregate across them as needed.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/schema-on-read-esql-json-keys</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/schema-on-read-esql-json-keys</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Mappings]]></category>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Jordan Powers,Dima Leontyev]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb3fca0aedcfba9ab/6a8419fae41d7f68b46522b9/unnamed.png" length="0" type="image/png"/>
    <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch vs. OpenSearch: Vector Search Performance Comparison]]></title>
    <description><![CDATA[Elasticsearch is out-of-the-box 2x–12x faster than OpenSearch for vector search]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-opensearch-vector-search-performance-comparison#up-to-12x-faster-out-of-the-box">TLDR: Elasticsearch is up to 12x faster</a> - We at Elastic have received numerous requests from our community to clarify the performance differences between Elasticsearch and OpenSearch, particularly in the realm of Semantic Search / Vector Search, so we have undertaken this performance testing to provide a clear, data-driven comparison — no ambiguity, just straightforward facts to inform our users. The results show that <strong>Elasticsearch is up to 12x faster</strong> than OpenSearch for vector search and therefore requires fewer computational resources. This reflects Elastic's focus on consolidating Lucene as the best vector database for search and retrieval use cases.</p><p>Vector search is revolutionizing the way we conduct similarity searches, particularly in fields like AI and machine learning. With the increasing adoption of vector embedding models, the ability to efficiently search through millions of high-dimension vectors becomes critical.</p><p>When it comes to powering vector databases, Elastic and OpenSearch have taken notably different approaches. Elastic has invested heavily in optimizing Apache Lucene together with Elasticsearch to elevate them as the top-tier choice for vector search applications. In contrast, OpenSearch has broadened its focus, integrating other vector search implementations and exploring beyond Lucene's scope. Our focus on Lucene is strategic, enabling us to provide highly integrated support in our version of Elasticsearch, resulting in an enhanced feature set where each component complements and amplifies the capabilities of the other.</p><p>This blog presents a detailed comparison between Elasticsearch 8.14 and OpenSearch 2.14 accounting for different configurations and vector engines. In this performance analysis, Elasticsearch proved to be the superior platform for vector search operations, and upcoming <a href="https://www.elastic.co/search-labs/blog/vector-similarity-computations-ludicrous-speed">features</a> will widen the differences even more <a href="https://www.elastic.co/search-labs/blog/elasticsearch-lucene-vector-database-gains">significantly</a>. When pitted against OpenSearch, it excelled in every benchmark track — <strong>offering 2x to 12x faster performance on average</strong>. This was across scenarios using varying vector amounts and dimensions including <code>so_vector</code> (2M vectors, 768D), <code>openai_vector</code> (2.5M vectors, 1536D), and <code>dense_vector</code> (10M vectors, 96D), all available in <a href="https://github.com/elastic/elasticsearch-opensearch-vector-performance">this repository</a> alongside the Terraform scripts for provisioning all the required infrastructure on Google Cloud and Kubernetes manifests for running the tests.</p><p>The results detailed in this blog complement the results from a <a href="https://www.elastic.co/blog/elasticsearch-opensearch-performance-gap">previously published and third-party validated study</a> that shows Elasticsearch is 40%–140% faster than OpenSearch for the most common search analytics operations: Text Querying, Sort, Range, Date Histogram and Terms filtering. Now we can add another differentiator: Vector Search.</p><h2>Up to 12x faster out-of-the-box</h2><p>Our focused benchmarks across the four vector data sets involved both Approximate KNN and Exact KNN searches, considering different sizes, dimensions and configurations, totaling <code>40.189.820</code> uncached search requests. The results: <strong>Elasticsearch is up to 12x faster</strong> than OpenSearch for vector search and therefore requires fewer computational resources.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt34b83c6eba3bcb6e/6a17d727dbb4ff18d6fb54fb/cdb26e91f085b90e9b12aeb8fee53b04d365ecae-1440x1156.webp" alt="p90 average" /><p>Figure 1: Grouped tasks for ANN and Exact KNN across different combinations in Elasticsearch and OpenSearch.</p><p>The groups like <code>knn-10-100</code> means KNN search with  and . In HNSW vector search,  determines the number of nearest neighbors to retrieve for a query vector. It specifies how many similar vectors to find as a result.  sets the number of candidate vectors to retrieve at each segment. More candidates can enhance accuracy but require greater computational resources.</p><p>We also tested with different quantization techniques and leveraged engine-specific optimizations, the detailed results for each track, task and vector engine are available below.</p><h2>Exact KNN and Approximate KNN</h2><p>When dealing with varying data sets and use cases, the right approach for vector search will differ. In this blog all tasks stated as <code>knn-*</code> like <code>knn-10-100</code> use <strong>Approximate KNN</strong> and <code>script-score-*</code> refer to <strong>Exact KNN</strong>, but what is the difference between them, and why are they important?</p><p>In essence, if you're handling more substantial data sets, the preferred method is the Approximate K-Nearest Neighbor (ANN) due to its superior scalability. For more modest data sets that may require a filtration process, Exact KNN method is ideal.</p><p>Exact KNN uses a brute-force method, calculating the distance between one vector and every other vector in the data set. It then ranks these distances to find the  nearest neighbors. While this method ensures an exact match, it suffers from scalability challenges for large, high-dimensional data sets. However, there are many cases in which Exact KNN is needed:</p><ul><li><p><strong>Rescoring</strong>: In scenarios involving lexical or semantic searches followed by vector-based rescoring, Exact KNN is essential. For example, in a product search engine, initial search results can be filtered based on textual queries (e.g., keywords, categories), and then vectors associated with the filtered items are used for a more accurate similarity assessment.</p></li><li><p><strong>Personalization</strong>: When dealing with a large number of users, each represented by a relatively small number (like 1 million) of distinct vectors, sorting the index by user-specific metadata (e.g., user_id) and brute-force scoring with vectors becomes efficient. This approach allows for personalized recommendations or content delivery based on precise vector comparisons tailored to individual user preferences.</p></li></ul><p>Exact KNN therefore ensures that the final ranking and recommendations based on vector similarity are precise and tailored to user preferences.</p><p>Approximate KNN (or ANN) on the other hand employs methods to make data searching faster and more efficient than Exact KNN, especially in large, high-dimensional data sets. Instead of a brute-force approach, which measures the exact nearest distance between a query and all points leading to computation and scaling challenges, ANN uses certain techniques to efficiently restructure the indexes and dimensions of searchable vectors in the data set. While this may cause a slight inaccuracy, it significantly boosts the speed of the search process, making it an effective alternative for dealing with large data sets.</p><p>In this blog all tasks stated as <code>knn-*</code> like <code>knn-10-100</code> use <strong>Approximate KNN</strong> and <code>script-score-*</code> refer to <strong>Exact KNN</strong>.</p><h2>Testing methodology</h2><p>While Elasticsearch and OpenSearch are similar in terms of API for BM25 search operations, since the latter is a fork of the former, it is not the case for Vector Search, which was introduced after the fork. OpenSearch took a different approach than Elasticsearch when it comes to algorithms, by introducing two other engines — <code>nmslib</code> and <code>faiss</code> — apart from <code>lucene</code>, each with their specific configurations and limitations (e.g., <code>nmslib</code> in OpenSearch does not allow for filters, an essential feature for many use cases).</p><p>All three engines use the Hierarchical Navigable Small World (HNSW) algorithm, which is efficient for approximate nearest neighbor search, and especially powerful when dealing with high-dimensional data. It's important to note that <code>faiss</code> also supports a second algorithm, <code>ivf</code>, but since it requires pre-training on the data set, we are going to focus solely on HNSW. The core idea of HNSW is to organize the data into multiple layers of connected graphs, with each layer representing a different granularity of the data set. The search begins at the top layer with the coarsest view and progresses down to finer and finer layers until reaching the base level.</p><p>Both search engines were tested under identical conditions in a controlled environment to ensure fair testing grounds. The method applied is similar to <a href="https://www.elastic.co/blog/elasticsearch-opensearch-performance-gap#testing-methodology">this previously published performance comparison</a>, with dedicated node pools for Elasticsearch, OpenSearch, and Rally. The <a href="https://github.com/elastic/elasticsearch-opensearch-vector-performance/blob/main/terraform/main.tf">terraform script</a> is available (alongside all sources) to provision a Kubernetes cluster with:</p><ul><li><p>1 Node pool for Elasticsearch with 3 <code>e2-standard-32</code> machines (128GB RAM and 32 CPUs)</p></li><li><p>1 Node pool for OpenSearch with 3 <code>e2-standard-32</code> machines (128GB RAM and 32 CPUs)</p></li><li><p>1 Node pool for Rally with 2 <code>t2a-standard-16</code> machines (64GB RAM and 16 CPUs)</p></li></ul><p>Each "track" (or test) ran for 10 times for each configuration, which included different engines, different configurations and different vector types. The tracks have tasks that repeat between 1000 and 10000 times, depending on the track. If one of the tasks in a track failed for instance due to a network timeout, then all tasks were discarded, so all results represent tracks that started and finished without problems. All test results are statistically validated, ensuring that improvements aren’t coincidental.</p><h2>Detailed findings</h2><p>Why compare using the 99th percentile and not the average latency? Consider a hypothetical example of average house prices in a certain neighborhood. The average price may indicate an expensive area, but on closer inspection, it may turn out that most homes are valued much lower, with only a few luxury properties inflating the average figure. This illustrates how the average price can fail to accurately represent the full spectrum of house values in the area. This is akin to examining response times, where the average can conceal critical issues.</p><h4>Tasks</h4><ul><li><p>Approximate KNN with k:10 n:50</p></li><li><p>Approximate KNN with k:10 n:100</p></li><li><p>Approximate KNN with k:100 n:1000</p></li><li><p>Approximate KNN with k:10 n:50 and keyword filters</p></li><li><p>Approximate KNN with k:10 n:100 and keyword filters</p></li><li><p>Approximate KNN with k:100 n:1000 and keyword filters</p></li><li><p>Approximate KNN with k:10 n:100 in conjunction with indexing</p></li><li><p>Exact KNN (script score)</p></li></ul><h4>Vector engines</h4><ul><li><p><code>lucene</code> in Elasticsearch and OpenSearch, both on version 9.10</p></li><li><p><code>faiss</code> in OpenSearch</p></li><li><p><code>nmslib</code> in OpenSearch</p></li></ul><h4>Vector types</h4><ul><li><p><code>hnsw</code> in Elasticsearch and OpenSearch</p></li><li><p><code>int8_hnsw</code> in Elasticsearch (HNSW with automatic 8 bit quantization: <a href="https://www.elastic.co/search-labs/blog/evaluating-scalar-quantization">link</a>)</p></li><li><p><code>sq_fp16 hnsw </code>in OpenSearch (HNSW with automatic 16 bit quantization: <a href="https://opensearch.org/docs/2.14/search-plugins/knn/knn-vector-quantization#faiss-16-bit-scalar-quantization">link</a>)</p></li></ul><h4>Out-of-the-box and Concurrent Segment Search</h4><p>As you probably know, Lucene is a highly performant text search engine library written in Java that serves as the backbone for many search platforms like Elasticsearch, OpenSearch, and Solr. At its core, Lucene organizes data into segments, which are essentially self-contained indices that allow Lucene to execute searches more efficiently. So when you issue a search to any Lucene-based search engine, your search will end up being executed in those segments, either sequentially or in parallel.</p><p>OpenSearch introduced concurrent segment search as an optional flag, and does not use it by default, you must enable it using a special index setting <code>index.search.concurrent_segment_search.enabled</code> as detailed <a href="https://opensearch.org/docs/latest/search-plugins/concurrent-segment-search/">here</a>, with some <a href="https://opensearch.org/docs/latest/search-plugins/concurrent-segment-search/#other-considerations">limitations</a>.</p><p>Elasticsearch on the other hand searches on segments concurrently <a href="https://github.com/elastic/elasticsearch/pull/101230">out-of-the-box</a>, therefore the comparisons we make in this blog will take into consideration, on top of the different vector engines and vector types, also the different configurations:</p><ul><li><p>Elasticsearch ootb: Elasticsearch out-of-the-box, with concurrent segment search;</p></li><li><p>OpenSearch ootb: without concurrent segment search enabled;</p></li><li><p>OpenSearch css: with concurrent segment search enabled</p></li></ul><p>Now, let’s dive into some detailed results for each vector data set tested:</p><h2>2.5 million vectors, 1536 dimensions (openai_vector)</h2><p>Starting with the simplest track, but also the largest in terms of dimensions, <a href="https://github.com/elastic/rally-tracks/edit/master/openai_vector">openai_vector</a> - which uses the <a href="https://huggingface.co/datasets/BeIR/nq">NQ data set</a> enriched with embeddings generated using OpenAI's <a href="https://openai.com/blog/new-and-improved-embedding-model">text-embedding-ada-002 model</a>. It is the simplest since it tests only Approximate KNN and has only 5 tasks. It tests in standalone (without indexing) as well as alongside indexing, and using a single client and 8 simultaneous clients.</p><h3>Tasks</h3><ul><li><p><strong>standalone-search-knn-10-100-multiple-clients</strong>: searching on 2.5 million vectors with 8 clients simultaneously, k: 10 and n:100</p></li><li><p><strong>standalone-search-knn-100-1000-multiple-clients</strong>: searching on 2.5 million vectors with 8 clients simultaneously, k: 100 and n:1000</p></li><li><p><strong>standalone-search-knn-10-100-single-client</strong>: searching on 2.5 million vectors with a single client, k: 10 and n:100</p></li><li><p><strong>standalone-search-knn-100-1000-single-client</strong>: searching on 2.5 million vectors with a single client, k: 100 and n:1000</p></li><li><p><strong>parallel-documents-indexing-search-knn-10-100</strong>: searching on 2.5 million vectors while also indexing additional 100000 documents, k:10 and n:100</p></li></ul><p>The averaged p99 performance is outlined below:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf5791848eb0c12fb/6a17d72925daab9cae08a09e/eea0b2b49c690baada3e09d6968e513bfffe51a9-1440x318.webp" alt="openai_vector table" /><p>Here we observed that Elasticsearch is between <strong>3x-8x faster</strong> than OpenSearch when performing vector search alongside indexing (i.e. read+write) with :10 and :100 and <strong>2x-3x faster</strong> without indexing for the same k and n. For :100 and :1000 (<em>standalone-search-knn-100-1000-single-client</em> and <em>standalone-search-knn-100-1000-multiple-clients</em> Elasticsearch is <strong>2x to 7x</strong> faster than OpenSearch, on average.</p><p>The detailed results show the exact cases and vector engines compared:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdbe41d3187ced7ec/6a17d72a445de951c44cff4c/a7a761ed631d3e6211beb83d9d93d752d10123c9-1440x1728.webp" alt="openai_vector" /><h4>Recall</h4><p></p><p>knn-recall-10-100</p><p>knn-recall-100-1000</p><p>Elasticsearch-8.14.0@lucene-hnsw</p><p>0.969485</p><p>0.995138</p><p>Elasticsearch-8.14.0@lucene-int8_hnsw</p><p>0.781445</p><p>0.784817</p><p>OpenSearch-2.14.0@lucene-hnsw</p><p>0.96519</p><p>0.995422</p><p>OpenSearch-2.14.0@faiss</p><p>0.984154</p><p>0.98049</p><p>OpenSearch-2.14.0@faiss-sq_fp16</p><p>0.980012</p><p>0.97721</p><p>OpenSearch-2.14.0@nmslib</p><p>0.982532</p><p>0.99832</p><h2>10 million vectors, 96 dimensions (dense_vector)</h2><p>In <a href="https://github.com/elastic/rally-tracks/tree/master/dense_vector">dense_vector</a> with 10M vectors and 96 dimensions. It is based on the <a href="https://big-ann-benchmarks.com/">Yandex DEEP1B</a> image data set. The data set is created from the first 10 million vectors of the "sample data" file called <code>learn.350M.fbin</code>. The search operations use vectors from the "query data" file query.<code>public.10K.fbin</code>.</p><p>Both Elasticsearch and OpenSearch perform very well on this data set, especially after a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-forcemerge.html">force merge</a> which is usually done on read-only indices and it’s similar to defragmenting the index to have a single "table" to search on.</p><h3>Tasks</h3><p>Each task warms up for 100 requests and then 1000 requests are measured</p><ul><li><p><strong>knn-search-10-100</strong>: searching on 10 million vectors, k: 10 and n:100</p></li><li><p><strong>knn-search-100-1000</strong>: searching on 10 million vectors, k: 100 and n:1000</p></li><li><p><strong>knn-search-10-100-force-merge</strong>: searching on 10 million vectors after a force merge, k: 10 and n:100</p></li><li><p><strong>knn-search-100-1000-force-merge</strong>: searching on 10 million vectors after a force merge, k: 100 and n:1000</p></li><li><p><strong>knn-search-100-1000-concurrent-with-indexing</strong>: searching on 10 million vectors while also updating <a href="https://github.com/elastic/rally-tracks/blob/master/dense_vector/challenges/default.json#L76C36-L76C37">5% of the data set</a>, k: 100 and n:1000</p></li><li><p><strong>script-score-query</strong>: Exact KNN search of <a href="https://github.com/elastic/rally-tracks/blob/master/dense_vector/queries.json">2000 specific vectors</a>.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4629d06af85eb96c/6a17d72c6864a423e7b685dc/174995e0a2156d86359cdb7aa446dfaae6312ea4-1440x316.webp" alt="dense_vector" /><p>Both Elasticsearch and OpenSearch performed well for Approximate KNN. When the index is merged (i.e. has just a single segment) in <em>knn-search-100-1000-force-merge</em> and <em>knn-search-10-100-force-merge</em>, OpenSearch performs better than the others when using <code>nmslib</code> and <code>faiss</code>, even though they are all around 15ms and all very close.</p><p>However, when the index has multiple segments (a typical situation where an index receives updates to its documents) in <em>knn-search-10-100</em> and <em>knn-search-100-1000</em>, Elasticsearch keeps the latency in about ~7ms and ~16ms, while all other OpenSearch engines are slower.</p><p>Also when the index is being searched and written to at the same time (<em>knn-search-100-1000-concurrent-with-indexing</em>), Elasticsearch maintains the latency below 15ms (at 13.8ms), being almost <strong>4x faster</strong> than OpenSearch out-of-the-box (49.3ms) and still faster when concurrent segment search is enabled (17.9ms), but too close to be significative.</p><p>As for Exact KNN, the difference is much larger: Elasticsearch <strong>is 6x faster</strong> than OpenSearch (~260ms vs ~1600ms).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt254f43bcaa3dbfc2/6a17d72ddbb4ffc780fb54ff/17aec6be31117440bc4d1f99984aed95df1c4f6b-1440x1728.webp" alt="dense_vector" /><h4>Recall</h4><p></p><p>knn-recall-10-100</p><p>knn-recall-100-1000</p><p>Elasticsearch-8.14.0@lucene-hnsw</p><p>0.969843</p><p>0.996577</p><p>Elasticsearch-8.14.0@lucene-int8_hnsw</p><p>0.775458</p><p>0.840254</p><p>OpenSearch-2.14.0@lucene-hnsw</p><p>0.971333</p><p>0.996747</p><p>OpenSearch-2.14.0@faiss</p><p>0.9704</p><p>0.914755</p><p>OpenSearch-2.14.0@faiss-sq_fp16</p><p>0.968025</p><p>0.913862</p><p>OpenSearch-2.14.0@nmslib</p><p>0.9674</p><p>0.910303</p><h2>2 million vectors, 768 dimensions (so_vector)</h2><p>This <a href="https://github.com/elastic/rally-tracks/tree/master/so_vector">track</a>, <code>so_vector</code>, is derived from a <a href="https://archive.org/download/stackexchange/stackoverflow.com-Posts.7z">dump of StackOverflow posts downloaded</a> on April, 21st 2022. It only contains question documents — all documents representing answers have been removed. Each question title was encoded into a vector using the sentence transformer model <a href="https://huggingface.co/sentence-transformers/multi-qa-mpnet-base-cos-v1">multi-qa-mpnet-base-cos-v1</a>. This data set contains the first 2 million questions.</p><p>Unlike the previous track, each document here contains other fields besides vectors to support testing features like Approximate KNN with filtering and hybrid search. <code>nmslib</code> for OpenSearch is notably absent in this test since <a href="https://opensearch.org/docs/latest/search-plugins/knn/filter-search-knn/#k-nn-search-with-filters">it does not support filters</a>.</p><h3>Tasks</h3><p>Each task warms up for 100 requests and then 100 requests are measured. Note the tasks were grouped for sake of simplicity, since the test contains 16 search types * 2 different k values * 3 different n values.</p><ul><li><p><strong>knn-10-50</strong>: searching on 2 million vectors without filters, k:10 and n:50</p></li><li><p><strong>knn-10-50-filtered</strong>: searching on 2 million vectors <a href="https://github.com/elastic/rally-tracks/blob/master/so_vector/operations/default.json">with filters</a>, k:10 and n:50</p></li><li><p><strong>knn-10-50-after-force-merge</strong>: searching on 2 million vectors with filters and after a force merge, k:10 and n:50</p></li><li><p><strong>knn-10-100</strong>: searching on 2 million vectors without filters, k:10 and n:100</p></li><li><p><strong>knn-10-100-filtered</strong>: searching on 2 million vectors <a href="https://github.com/elastic/rally-tracks/blob/master/so_vector/operations/default.json">with filters</a>, k:10 and n:100</p></li><li><p><strong>knn-10-100-after-force-merge</strong>: searching on 2 million vectors with filters and after a force merge, k:10 and n:100</p></li><li><p><strong>knn-100-1000</strong>: searching on 2 million vectors without filters, k:100 and n:1000</p></li><li><p><strong>knn-100-1000-filtered</strong>: searching on 2 million vectors <a href="https://github.com/elastic/rally-tracks/blob/master/so_vector/operations/default.json">with filters</a>, k:100 and n:1000</p></li><li><p><strong>knn-100-1000-after-force-merge</strong>: searching on 2 million vectors with filters and after a force merge, k:100 and n:1000</p></li><li><p><strong>exact-knn</strong>: Exact KNN search <a href="https://github.com/elastic/rally-tracks/blob/master/so_vector/operations/default.json#L56">with and without filters</a>.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8b44e1306af35877/6a17d72f577262aca11bca3e/d4ed2982d55370cad4b2048b23ce97caa55017c0-1440x316.webp" alt="so_vector table" /><p>Elasticsearch is <strong>consistently faster</strong> than OpenSearch out-of-the-box on this test, only in two cases OpenSearch is faster, and not by much (<em>knn-10-100</em> and <em>knn-100-1000</em>). Tasks involving <em>knn-10-50</em>, <em>knn-10-100</em> and <em>knn-100-1000</em> in combination with filters show a difference of up to <strong>7x</strong> (112ms vs 803ms).</p><p>The performance of both solutions seems to even out after a "force merge", understandably, as evidenced by <em>knn-10-50-after-force-merge</em>, <em>knn-10-100-after-force-merge</em> and <em>knn-100-1000-after-force-merge.</em> On those tasks <code>faiss</code> is faster.</p><p>The performance for Exact KNN once again is very different, Elasticsearch being <strong>13 times faster</strong> than OpenSearch this time (~385ms vs ~5262ms).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf80ccc7c2df84559/6a17d7314b055de00d43203f/615cb9228eb05ddd2e9512b3a6a5bc88d4088a1a-1440x1440.webp" alt="so_vector" /><h4>Recall</h4><p></p><p>knn-recall-10-100</p><p>knn-recall-100-1000</p><p>knn-recall-10-50</p><p>Elasticsearch-8.14.0@lucene-hnsw</p><p>1</p><p>1</p><p>1</p><p>Elasticsearch-8.14.0@lucene-int8_hnsw</p><p>1</p><p>0.986667</p><p>1</p><p>OpenSearch-2.14.0@lucene-hnsw</p><p>1</p><p>1</p><p>1</p><p>OpenSearch-2.14.0@faiss</p><p>1</p><p>1</p><p>1</p><p>OpenSearch-2.14.0@faiss-sq_fp16</p><p>1</p><p>1</p><p>1</p><p>OpenSearch-2.14.0@nmslib</p><p>0.9674</p><p>0.910303</p><p>0.976394</p><h2>Elasticsearch and Lucene as clear victors</h2><p>At Elastic, we are relentlessly innovating Apache Lucene and Elasticsearch to ensure we are able to provide the premier vector database for search and retrieval use cases, including RAG (Retrieval Augmented Generation). Our recent advancements have dramatically increased performance, making vector search <a href="https://search-labs.elastic.co/search-labs/blog/elasticsearch-lucene-vector-database-gains">faster and more space efficient</a> than before, building upon the gains from Lucene 9.10. This blog presented a study that shows when comparing up-to-date versions Elasticsearch is up to 12 times faster than OpenSearch.</p><p>It's worth noting both products use the same version of Lucene (<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/release-notes-8.14.0.html">Elasticsearch 8.14 Release Notes</a> and <a href="https://github.com/opensearch-project/OpenSearch/blob/2.14/release-notes/opensearch.release-notes-2.14.0.md">OpenSearch 2.14 Release Notes</a>).</p><p>The pace of innovation at Elastic will deliver even more not only for our on-premises and Elastic Cloud customers but those using our <a href="https://www.elastic.co/search-labs/blog/stateless-your-new-state-of-find-with-elasticsearch">stateless platform</a>. Features like support for <a href="https://www.elastic.co/search-labs/blog/int4-scalar-quantization-in-lucene">scalar quantization to int4</a> will be offered with rigorous testing to ensure customers can utilize these techniques without a significant drop in recall, similar to <a href="https://www.elastic.co/search-labs/blog/evaluating-scalar-quantization">our testing for int8</a>.</p><p>Vector search efficiency is becoming a non-negotiable feature in modern search engines due to the proliferation of AI and machine learning applications. For organizations looking for a powerful search engine capable of keeping up with the demands of high-volume, high-complexity vector data, Elasticsearch is the definitive answer.</p><p>Whether expanding an established platform or initiating new projects, integrating Elasticsearch for vector search needs is a strategic move that will yield tangible, long-term benefits. With its proven performance advantage, Elasticsearch is poised to underpin the next wave of innovations in search.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-opensearch-vector-search-performance-comparison</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-opensearch-vector-search-performance-comparison</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Ugo Sangiorgi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5d70b25967c2194e/6a17d732b1e11383f879f0ca/13c3c0053e2968fb835ba2f90f34bec3a011b5c0-880x592.webp" length="0" type="image/webp"/>
    <pubDate>Wed, 26 Jun 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Bringing maximum-inner-product into Lucene]]></title>
    <description><![CDATA[Explore how we brought maximum-inner-product into Lucene and the investigations undertaken to ensure its support.]]></description>
    <content:encoded><![CDATA[<p>Currently Lucene restricts <code>dot_product</code> to be only used over normalized vectors. Normalization forces all <a href="https://en.wikipedia.org/wiki/Magnitude_(mathematics)#Euclidean_vector_space">vector magnitudes</a> to equal one. While for many cases this is acceptable, it can cause relevancy issues for certain data sets. A prime example are embeddings built by <a href="https://cohere.com/">Cohere</a>. Their vectors use magnitudes to provide more relevant information.</p><p>So, why not allow non-normalized vectors in dot-product and thus enable maximum-inner-product? What's the big deal?</p><h2>Negative values and Lucene optimizations</h2><p>Lucene requires non-negative scores, so that matching one more clause in a disjunctive query can only make the score greater, not lower. This is actually important for dynamic pruning optimizations such as <a href="https://www.elastic.co/blog/faster-retrieval-of-top-hits-in-elasticsearch-with-block-max-wand">block-max WAND</a>, whose efficiency is largely defeated if some clauses may produce negative scores. How does this requirement affect non-normalized vectors?</p><p>In the normalized case, all vectors are on a unit sphere. This allows handling negative scores to be simple scaling.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta9a1c9f6f1c8eebd/6a170da3cdacbf64197d2a61/b6ddddc9103479474c3bdb5f3b5d0ef0491fee7f-1179x1209.png" alt="Normalized Vectors" /><p>Figure 1: Two opposite, two dimensional vectors in a 2d unit sphere (e.g. a unit circle). When calculating the dot-product here, the worst it can be is -1 = [1, 0] * [-1, 0]. Lucene accounts for this by adding 1 to the result.</p><p>With vectors retaining their magnitude, the range of possible values is unknown.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7fd8462a258df0ec/6a170da41949f744c8e7aaac/0a8549e941e3b4dec79905e00e22e04b38b469c7-1181x1209.png" alt="Normalized Vectors" /><p>Figure 2: When calculating the dot-product for these vectors <code>[2, 2] \* [-5, -5] = -20</code></p><p>To allow Lucene to utilize blockMax WAND with non-normalized vectors, we must scale the scores. This is a fairly simple solution. Lucene will scale non-normalize vectors with a simple piecewise function:</p>if (dotProduct &lt; 0) {
  return 1 / (1 + -1 * dotProduct);
}
return dotProduct + 1;
<p>Now all negative scores are between 0-1, and all positives are scaled above 1. This still ensures that higher values mean better matches and removes negative scores. Simple enough, but this is not the final hurdle.</p><h2>The triangle problem</h2><p>Maximum-inner-product doesn't follow the same rules as of <a href="https://en.wikipedia.org/wiki/Euclidean_space">simple euclidean spaces</a>. The simple assumed knowledge of the <a href="https://en.wikipedia.org/wiki/Triangle_inequality">triangle inequality</a> is abandoned. Unintuitively, a vector is no longer nearest to itself. This can be troubling. Lucene’s underlying index structure for vectors is Hierarchical Navigable Small World (HNSW). This being a graph based algorithm, it might rely on euclidean space assumptions. Or would exploring the graph be too slow in non-euclidean space?</p><p>Some research has indicated that a transformation into <a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/XboxInnerProduct.pdf">euclidean space is required for fast search</a>. Others have gone through the trouble of <a href="https://blog.vespa.ai/announcing-maximum-inner-product-search/">updating their vector storage</a> enforcing transformations into euclidean space.</p><p>This caused us to pause and dig deep into some data. The key question is this: does HNSW provide good recall and latency with maximum-inner-product search? While the original <a href="https://arxiv.org/pdf/1603.09320.pdf">HNSW paper</a> and <a href="http://boytsov.info/pubs/thesis_boytsov.pdf">other published research</a> indicate that it does, we needed to do our due diligence.</p><h2>Experiments and results: Maximum-inner-product in Lucene</h2><p>The experiments we ran were simple. All of the experiments are over real data sets or slightly modified real data sets. This is vital for benchmarking as modern neural networks create vectors that adhere to specific characteristics (<a href="https://arxiv.org/pdf/1908.10396.pdf">see discussion in section 7.8 of this paper</a>). We measured latency (in milliseconds) vs. recall over non-normalized vectors. Comparing the numbers with the same measurements but with a euclidean space transformation. In each case, the vectors were indexed into Lucene’s HNSW implementation and we measured for 1000 iterations of queries. Three individual cases were considered for each dataset: data inserted ordered by magnitude (lesser to greater), data inserted in a random order, and data inserted in reverse order (greater to lesser).</p><p>Here are some results from real datasets from Cohere:</p><p>Figure 3: Here are results for the Cohere’s Multilingual model embedding wikipedia articles. <a href="https://huggingface.co/datasets/Cohere/wikipedia-22-12-en-embeddings">Available on HuggingFace</a>. The first 100k documents were indexed and tested.</p><p>Figure 4: This is a mixture of Cohere’s English and Japanese embeddings over wikipedia. <a href="https://huggingface.co/datasets/Cohere/wikipedia-22-12-en-embeddings">Both</a> <a href="https://huggingface.co/datasets/Cohere/wikipedia-22-12-ja-embeddings">datasets</a> are available on HuggingFace.</p><p>We also tested against some synthetic datasets to ensure our rigor. We created a data set with <a href="https://huggingface.co/intfloat/e5-small-v2">e5-small-v2</a> and scaled the vector's magnitudes by different statistical distributions. For brevity, I will only show two distributions.</p><p>Figure 5: <a href="https://en.wikipedia.org/wiki/Pareto_distribution">Pareto distribution</a> of magnitudes. A pareto distribution has a “fat tail” meaning there is a portion of the distribution with a much larger magnitude than others.</p><p>Figure 6: <a href="https://en.wikipedia.org/wiki/Gamma_distribution">Gamma distribution</a> of magnitudes. This distribution can have high variance and makes it unique in our experiments.</p><p>In all our experiments, the only time where the transformation seemed warranted was the synthetic dataset created with the gamma distribution. Even then, the vectors must be inserted in reverse order, largest magnitudes first, to justify the transformation. These are exceptional cases.</p><p>If you want to read about all the experiments, and about all the mistakes and improvements along the way, here is the <a href="https://github.com/apache/lucene/issues/12342">Lucene Github issue</a> with all the details (and mistakes along the way). Here’s one for open research and development!</p><h2>Conclusion</h2><p>This has been quite a journey requiring many investigations to make sure maximum-inner-product can be supported in Lucene. We believe the data speaks for itself. No significant transformations required or significant changes to Lucene. All this work will soon unlock maximum-inner-product support with Elasticsearch and allow models like the ones provided by Cohere to be first class citizens in the Elastic Stack.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/lucene-bringing-maximum-inner-product-to-lucene</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/lucene-bringing-maximum-inner-product-to-lucene</guid>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Benjamin Trent]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt762b38f91bd71c8e/6a170db8b339d547bb76a048/368db71c500e72d20fe225fe44c2c40231e29765-721x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 01 Sep 2023 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>