<?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[Martijn van Groningen - 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[Martijn van Groningen - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/search-labs/author/martijn-van-groningen</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/martijn-van-groningen</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/martijn-van-groningen.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Sat, 26 Sep 2026 03:05:19 GMT</lastBuildDate>
  <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>
  </channel>
</rss>