Skip to main content

Overview

Inverted indexes in Firebolt are general-purpose inverted indexes that map discrete tokens to the rows that contain them. They accelerate queries that filter on exact token membership, such as finding log lines tagged "error", matching rows whose tags array contains both "urgent" and "billing", or filtering events by a lowercased hostname. Under the hood, every token’s posting list is stored as a compressed Roaring bitmap, giving the engine a compact, set-operation-friendly representation that scales from a handful of rows to billions. Inverted-index filtering is stage 4 of the scan pipeline: it both prunes granules and supplies a row bitmap that the reader reuses to filter rows.
Inverted indexes are designed for exact token matching, not fuzzy or substring search. If you need substring or pattern-based search, use a full-text search index with n-grams instead.

Key capabilities

  • Granule pruning: entire granules are skipped when no row in the range matches, cutting I/O.
  • Row-level filtering: within qualifying granules, only matching rows are passed to downstream operators, cutting CPU work.
  • AND semantics: when you supply multiple tokens, the index intersects their posting lists and returns only rows that contain all of them.
  • Expression-based indexing: index the output of any scalar expression, such as LOWER(tags) or a struct field like event.type.
  • ARRAY(TEXT) support: each array element is indexed as a separate token, enabling efficient multi-value tag filtering.
  • Async cloud I/O: index lookups against cloud storage use coroutine-based parallelism to overlap multiple S3 reads, minimizing latency.

When to use

Quick start

SQL reference

CREATE INDEX

Supported expression types: You can create multiple inverted indexes on the same table with different expressions. Each index must have a unique name.
The tokenizing expression must return TEXT or ARRAY(TEXT). Attempting to create an index on a non-text type results in an error:
Creating an index on an existing table with data: When you create an inverted index on a table that already contains data, existing tablets do not automatically have index data. Run VACUUM with REINDEX=TRUE to rebuild the index for pre-existing tablets:
All subsequent inserts automatically populate the index.

has_all_tokens

has_all_tokens is the query-time function that evaluates token membership against an inverted index (or falls back to a full scan when no matching index exists).
Behavior with different column types: NULL handling: NULL values in the indexed column never match any token. has_all_tokens(NULL, 'x') returns NULL.

DROP INDEX

Dropping an inverted index is a metadata-only operation. To reclaim the physical storage occupied by index files, run VACUUM on the table after dropping the index.
You cannot drop a table while inverted indexes still depend on it. Either drop the indexes first, or use DROP TABLE ... CASCADE to drop the table and all its dependent indexes in one statement.

Capabilities

Granule pruning

When has_all_tokens appears in a top-level AND conjunction, the query planner attaches [InvertedIndexFilters] metadata to the read_tablets scan operator. At execution time, the storage layer uses the index to skip entire granules whose posting lists have no matching rows, significantly reducing the amount of data read from disk or cloud storage.
Pushdown is only applied when has_all_tokens appears in a top-level AND conjunction. The index is not used for pruning when the predicate is inside an OR, behind a NOT, or nested in a subquery.

Prune-only mode

By default, has_all_tokens both prunes non-matching granules and applies a row-level filter. When the token is highly selective, the row-level filter may be redundant. Use filter_mode => 'prune_only' to skip the row-level filter and let downstream operators handle any false positives:
In prune-only mode, the has_all_tokens predicate is removed from the Filter node in the query plan. Only the remaining predicates (for example, id > 100) are evaluated per row.

Expression-based indexing

You can index any scalar expression, not just a bare column. The expression is evaluated once at ingest time, and the resulting tokens are stored in the index. At query time, the predicate’s expression must match the indexed expression exactly for the planner to push down the filter:

Struct field indexing

You can index a text field nested inside a STRUCT column:

Coexistence with other index types

Inverted indexes coexist with all other index types on the same table: primary indexes, aggregating indexes, full-text search indexes, and vector search indexes. Each index type serves a different access pattern, and the planner selects the appropriate one based on the query predicate.

Observability

information_schema.indexes

Use information_schema.indexes to inspect inverted index metadata:

EXPLAIN (ANALYZE)

Use EXPLAIN (ANALYZE) to verify that the index is being used and to see how many granules were pruned:
Look for these indicators in the output:
If [InvertedIndexFilters] does not appear, verify that:
  1. The has_all_tokens expression matches the indexed expression exactly.
  2. The predicate is in a top-level AND conjunction (not inside OR or NOT).

How it works

This section explains the internal architecture for users who want to understand the performance characteristics in depth.

Three-file on-disk layout

Each inverted index is stored as three files per tablet:
Sparse index (.idx): A lightweight file that stores the first token of each dictionary block and the corresponding byte offset into the .dict file. The lookup begins with a binary search over these entries to locate the relevant dictionary block. Offsets are delta-encoded (v2) to minimize file size. Dictionary (.dict): Stores all tokens sorted alphabetically, grouped into blocks of 512 tokens. Each block is individually LZ4-compressed. For each token, the dictionary records its encoding type and a pointer into the posting file. Because blocks are independently compressed, a lookup reads and decompresses only the single block that may contain the target token. Posting (.posting): Contains the actual row ID sets. The encoding varies by posting list size to balance space efficiency with decode speed (see Tiered encoding below).

Tiered encoding

Not all posting lists are the same size, and using a single encoding for all of them would waste either space (for small lists) or CPU (for large lists). The index uses three encoding tiers: Inline entries avoid a round-trip to the posting file entirely, because the row IDs are embedded in the dictionary block itself. This is particularly effective for high-cardinality, low-frequency tokens (for example, unique request IDs) where most tokens appear in only a few rows. SmallSorted entries use delta encoding: the first row ID is stored as an absolute value, and each subsequent ID is stored as the difference from the previous one. This produces small VarUInt values that compress well. Roaring entries use the full Roaring64Map serialization, which internally switches between array, bitmap, and run-length containers depending on the density of each 2^16-element chunk. This ensures efficient storage across the full range of posting list sizes.

LZ4 dictionary compression

Each 512-token dictionary block is individually LZ4-compressed before being written to the .dict file. The file stores: VarUInt(compressed_size) || compressed_bytes for each block. This block-level compression scheme has two advantages:
  1. Selective decompression: only the block containing the target token is decompressed, avoiding wasted CPU on unrelated blocks.
  2. High compression ratio: tokens within a block tend to share common prefixes (because they are sorted), and LZ4 exploits this locality effectively.

Async coroutine-based cloud I/O

When index files reside in cloud storage (S3), the engine uses C++ coroutines (folly::coro) to issue non-blocking reads. The lookup flow for a multi-token query proceeds as follows:
  1. Read .idx file: a single small async read retrieves the entire sparse index.
  2. Locate dictionary blocks: binary search (CPU-only, no I/O) finds the block offset for each token.
  3. Read dictionary blocks in parallel: one async read per block, all launched concurrently.
  4. Read posting entries in parallel: for non-inline tokens, the exact byte range of each posting list is fetched concurrently.
  5. Eager intersection: as each bitmap arrives (in completion order, not submission order), it is immediately intersected with the running result. If the intersection becomes empty, all remaining in-flight I/O is cancelled.
Each async read suspends the calling coroutine until the buffer manager completes the I/O, allowing the thread to service other coroutines in the meantime. This achieves high throughput without dedicating a thread per I/O. Local caching: When index files are fully primed in the local layered storage (disk cache), the engine switches to POSIX-based reads, bypassing the S3 path entirely. This provides the lowest possible latency for repeated queries against the same tablets.

Write path

During data ingestion (INSERT), the index is built incrementally:
  1. Tokenization: for each row, the indexed expression is evaluated and tokens are extracted. For TEXT columns, each cell value is a single token. For ARRAY(TEXT) columns, each array element is a separate token.
  2. In-memory accumulation: tokens are collected in memory with their row IDs. To bound memory, accumulated entries are flushed to Roaring bitmaps when they reach 512 entries.
  3. Bitmap optimization: before serialization, all bitmaps are run-optimized and shrunk to minimize on-disk size.
  4. Sorted serialization: tokens are sorted alphabetically, then the posting file, dictionary, and sparse index are written in a single pass.
The write path is designed so that row IDs arrive in sorted order within each vector, enabling efficient addMany batch insertions into the Roaring bitmaps without per-element overhead.

Merge and VACUUM

When tablets are merged (via VACUUM or automatic background compaction), the inverted index is rebuilt from scratch for the merged tablet. If you create an inverted index on a table that already contains data, the existing tablets do not have index data until you run VACUUM (REINDEX=TRUE), which reconstructs the index for all tablets.