<?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, 26 Sep 2026 04:50:16 GMT</lastBuildDate>
  <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>