Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

Cassandra SAI (Storage Attached Index)

SAI (Storage Attached Index) is Cassandra's modern indexing system. It was initially available in experimental form in Cassandra 4.x and became production-ready and recommended starting with Cassandra 5.0 (2023). SAI provides support for equality, range, and text queries with improved performance characteristics compared to legacy indexing approaches.

Recommended Index Type

SAI is the recommended indexing solution for Cassandra 5.0+ deployments. It replaces both legacy secondary indexes (2i) and SASI for most use cases.


SAI was developed by DataStax and contributed to Apache Cassandra for version 5.0. The implementation draws from production experience with DataStax Enterprise (DSE) Search and lessons learned from SASI's experimental deployment.

Key influences on SAI design:

  • DSE Search experience: Years of production secondary index usage
  • SASI limitations: Memory management and stability issues
  • Modern storage formats: Efficient columnar and indexed structures
  • Lucene concepts: Proven indexing algorithms adapted for Cassandra

SAI was designed to provide:

  1. Production stability: Address SASI's experimental status
  2. Low write overhead: Minimize impact on write path
  3. Efficient reads: Single-pass query execution
  4. Memory efficiency: Bounded memory consumption
  5. Operational simplicity: Standard Cassandra operations

SAI represents the third generation of Cassandra secondary indexing:

GenerationIndex TypeStatusIssues Addressed
1stSecondary Index (2i)Legacy-
2ndSASIExperimentalRange queries, text search
3rdSAIExperimental (4.x), Production (5.0+)Stability, memory, performance

Like SASI, SAI stores index data alongside SSTable components. However, SAI uses a more sophisticated storage format:

SAI: Storage Attached Index ArchitectureSAI: Storage Attached Index ArchitectureSSTable with SAI ComponentsSAI ComponentsData.dbIndex.dbFilter.dbKDTree.db(numeric ranges)Terms.db(string index)Postings.db(row references)Meta.db(index metadata)Separate component filesper indexed columnOptimized structuresper data type

SAI storage characteristics:

  • Per-column index files within SSTable directory
  • Data type-specific storage formats
  • Compacts atomically with base SSTable
  • Efficient on-disk representation

SAI creates multiple component files per indexed column within the SSTable directory. The exact file naming scheme uses the format SAI+<version>+<indexName>+<Component>.db and may vary between Cassandra versions:

data/
└── keyspace/
└── users-<table_id>/
├── nb-1-big-Data.db # Base table row data
├── nb-1-big-Index.db # Primary key partition index
├── nb-1-big-Filter.db # Bloom filter
├── nb-1-big-Summary.db # Partition summary
├── nb-1-big-Statistics.db # SSTable statistics
├── nb-1-big-CompressionInfo.db # Compression metadata
├── nb-1-big-TOC.txt # Table of contents
│ # SAI components for 'city' column index (example names)
├── nb-1-big-SAI+aa+city_idx+ColumnComplete.db # Completion marker
├── nb-1-big-SAI+aa+city_idx+TermsData.db # Term dictionary
├── nb-1-big-SAI+aa+city_idx+PostingLists.db # Row ID lists
├── nb-1-big-SAI+aa+city_idx+Meta.db # Index metadata
│ # SAI components for 'age' column index (numeric)
├── nb-1-big-SAI+aa+age_idx+ColumnComplete.db
├── nb-1-big-SAI+aa+age_idx+BalancedTree.db # Balanced tree for ranges
├── nb-1-big-SAI+aa+age_idx+PostingLists.db
├── nb-1-big-SAI+aa+age_idx+Meta.db
│ # Per-SSTable completion marker
└── nb-1-big-SAI+aa+GroupComplete.db

Note: File naming conventions may differ between Cassandra versions. The version component (e.g., aa) indicates the SAI format version.

ComponentDescription
ColumnComplete.dbSignals column index fully written; used for crash recovery
TermsData.dbTerm dictionary (trie structure for strings)
BalancedTree.dbBalanced tree structure for numeric range queries
PostingLists.dbCompressed row ID lists for each indexed term
Meta.dbIndex metadata, statistics, and configuration
GroupComplete.dbMarks all SAI indexes for this SSTable as complete

Vector columns generate specialized graph-based components:

data/
└── keyspace/
└── documents-<table_id>/
├── nb-1-big-Data.db
│ # Vector SAI components
├── nb-1-big-SAI_embedding_embedding_idx_ColumnComplete.db
├── nb-1-big-SAI_embedding_embedding_idx_Meta.db
├── nb-1-big-SAI_embedding_embedding_idx_VectorPostings.db
├── nb-1-big-SAI_embedding_embedding_idx_VectorPQ.db
└── nb-1-big-SAI_embedding_embedding_idx_VectorGraph.db
ComponentDescription
VectorPostings.dbMaps HNSW graph node IDs to row IDs
VectorPQ.dbProduct quantization codebook for vector compression
VectorGraph.dbHNSW (Hierarchical Navigable Small World) graph for ANN search

The following size estimates are illustrative ranges; actual index sizes depend heavily on data characteristics, cardinality, and workload patterns:

Index TypeSize Relative to Indexed ColumnNotes
String (trie)5-30% (typical)Varies with string length, cardinality, and compression
Numeric (balanced tree)10-25% (typical)Varies with value distribution
Vector100-200% (typical)Graph structures and quantization add significant overhead

Monitoring Index Size

Use nodetool tablestats keyspace.table to view actual index sizes. The SAI components are included in the SSTable size metrics.

SAI uses different internal structures based on data type:

SAI Index Structures by Data TypeSAI Index Structures by Data TypeNumeric Types (int, bigint, float, double, timestamp)String Types (text, varchar, ascii)Other Types (uuid, inet, blob)Balanced Tree Range queries: O(log n + k)Point queries: O(log n)Space efficientTrie / Term Dictionary Prefix queries: O(m)Equality: O(m)Optional analysisHash-based Index Equality only: O(1)No range supportCompact storage
Data TypeStructureQuery Types
NumericBalanced TreeEquality, range, comparison
StringTrie/Term DictionaryEquality, prefix, analyzed
UUIDHash-basedEquality
BooleanBitmapEquality
CollectionPer-elementEquality on elements

SAI implements a notification-based architecture that receives mutation events from the storage engine, maintaining synchronized index state throughout the write lifecycle.

Upon table mutation, SAI receives notification callbacks for all write operations. The system processes inserts and updates uniformly, and supports partition-level deletions, range tombstones, and individual row removals.

SAI Write Path ArchitectureSAI Write Path ArchitectureMemtable PhaseFlush Phase (Two-Stage)1. Mutation Notificationto SAI Indexer2. Column ValueExtraction & Validation3. Memtable Index Update+ Heap Estimation4. Phase 1: Row Metadata• Token → Row ID mapping• Partition offset index• Primary key mappings5. Phase 2: Term Index• String: Trie construction• Numeric: KD-tree constructionCQL Mutation(INSERT/UPDATE/DELETE)6. SSTable + SAI ComponentsPersisted to Diskflush trigger

For each mutation containing valid indexed column content, SAI performs the following operations:

  1. Value extraction: Indexed column values extracted from the mutation
  2. Primary key association: Row's primary key associated with indexed value
  3. Heap estimation: Incremental heap consumption calculated for memory pressure monitoring
  4. In-memory structure update: Value added to type-appropriate in-memory index structure
Data TypeMemtable StructureMemory Characteristics
StringIn-memory triePrefix-compressed, O(m) insertion
NumericBalanced treeO(log n) insertion
CollectionPer-element structuresLinear in collection size

The memtable index enables immediate query visibility (read-your-writes semantics) for recently written data without requiring flush to disk.

When memtable flush is triggered, SAI executes a two-phase serialization process that minimizes memory overhead by streaming directly to disk rather than constructing intermediate in-memory representations.

Phase 1: Row Metadata Construction

During row serialization, SAI generates row identifiers and constructs three index components:

ComponentPurposeStructure
Token-to-Row-ID mappingMaps partition tokens to row identifiersSupports Murmur3Partitioner token ordering
Partition offset indexRecords SSTable byte offsets for partitionsEnables direct partition access
Primary key mappingsTemporary PK-to-Row-ID associationsUsed during Phase 2, discarded after

Phase 2: Term Index Construction

Following row metadata generation, SAI iterates over the memtable index contents, producing term-and-row-ID pairs for persistence.

For string columns, the construction process:

  1. Iterates over unique terms in lexicographic order
  2. Writes posting lists (row ID sets) to disk sequentially
  3. Records term-to-offset mappings in byte-ordered trie structure
  4. Finalizes trie with prefix compression

For numeric columns, the construction process:

  1. Constructs balanced tree from value-row-ID pairs
  2. Writes tree nodes to disk during construction
  3. Buffers leaf block postings temporarily in memory
  4. Builds final posting structures at leaf and internal node levels
String Index Construction:
┌─────────────────────────────────────────────────────────┐
│ Terms (sorted) Postings File Trie Structure │
│ ──────────────── ───────────────── ────────────── │
│ "alice" ──────► [row_1, row_5] ◄─── offset_0 │
│ "bob" ──────► [row_2] ◄─── offset_1 │
│ "charlie" ──────► [row_3, row_4] ◄─── offset_2 │
└─────────────────────────────────────────────────────────┘
Numeric Index Construction (Balanced Tree):
┌─────────────────────────────────────────────────────────┐
│ [pivot: 50] │
│ / \ │
│ [pivot: 25] [pivot: 75] │
│ / \ / \ │
│ [10,20] [30,40] [60,70] [80,90] ← leaf postings │
└─────────────────────────────────────────────────────────┘

During SSTable compaction, SAI coordinates index merging with the following mechanism:

  1. Buffered accumulation: Indexed values and row IDs buffered in token order
  2. Segment-based flushing: Accumulated segment buffer flushed to disk synchronously upon reaching size threshold
  3. Heap pressure management: Synchronous flushing prevents unbounded heap growth during large compactions
  4. Atomic completion: SAI components finalized atomically with SSTable output
Compaction ParameterEffect on SAI
Concurrent compactorsParallel SAI index construction
SSTable size targetDetermines index segment sizes
Compaction throughputBounds index construction rate

Write Amplification

SAI introduces minimal write amplification compared to legacy secondary indexes. The append-only design and direct-to-disk flush process avoid the separate table mutations required by 2i implementations.

This architecture enables read-your-writes consistency for indexed queries while maintaining bounded memory consumption during high-throughput write workloads.


-- Basic SAI index
CREATE INDEX ON users (city) USING 'sai';
-- Named index
CREATE INDEX users_city_idx ON users (city) USING 'sai';
-- Index with options
CREATE INDEX ON users (email) USING 'sai'
WITH OPTIONS = { 'case_sensitive': 'false' };
-- Numeric index (auto-detected)
CREATE INDEX ON events (timestamp) USING 'sai';
-- Index on collection values
CREATE INDEX ON users (tags) USING 'sai';
OptionValuesDefaultDescription
case_sensitivetrue, falsetrueCase sensitivity for string columns
normalizetrue, falsefalseUnicode normalization
asciitrue, falsefalseASCII folding for accented characters
index_analyzeranalyzer namenoneText analyzer for tokenization
-- Standard analyzer (tokenization, lowercase)
CREATE INDEX ON articles (content) USING 'sai'
WITH OPTIONS = {
'index_analyzer': 'standard'
};
-- Whitespace analyzer (tokenize on whitespace only)
CREATE INDEX ON logs (message) USING 'sai'
WITH OPTIONS = {
'index_analyzer': 'whitespace'
};
-- Case insensitive without tokenization
CREATE INDEX ON users (email) USING 'sai'
WITH OPTIONS = {
'case_sensitive': 'false',
'normalize': 'true'
};
-- Drop index
DROP INDEX IF EXISTS users_city_idx;
-- View indexes
SELECT * FROM system_schema.indexes
WHERE keyspace_name = 'my_keyspace';
-- Describe table shows indexes
DESCRIBE TABLE my_keyspace.users;

-- String equality
SELECT * FROM users WHERE city = 'NYC';
-- Numeric equality
SELECT * FROM sensors WHERE reading = 100.0;
-- UUID equality
SELECT * FROM events WHERE event_id = 123e4567-e89b-12d3-a456-426614174000;
-- Greater than
SELECT * FROM events WHERE timestamp > '2024-01-01';
-- Less than or equal
SELECT * FROM sensors WHERE temperature <= 30.0;
-- Between (two conditions)
SELECT * FROM orders
WHERE created_at >= '2024-01-01'
AND created_at < '2024-02-01';
-- Numeric ranges
SELECT * FROM products WHERE price > 10.0 AND price < 100.0;
-- Prefix matching (with case_sensitive: false)
SELECT * FROM users WHERE name LIKE 'John%';
-- With analyzer: tokenized search
SELECT * FROM articles WHERE content : 'database';
-- Multiple terms (AND semantics with analyzer)
SELECT * FROM articles WHERE content : 'distributed database';
-- Contains value in set/list
SELECT * FROM users WHERE tags CONTAINS 'premium';
-- Contains key in map
SELECT * FROM users WHERE attributes CONTAINS KEY 'verified';
-- Contains entry in map
SELECT * FROM users WHERE attributes['status'] = 'active';

Vector Search is Fundamentally Different

Vector search uses SAI infrastructure but operates completely differently from scalar indexes. It is not "just another column type"—it introduces approximate results, different query semantics, and significant storage overhead.

Vector search enables Approximate Nearest Neighbor (ANN) queries on high-dimensional embedding vectors. This supports semantic similarity search for AI/ML applications like:

  • Document similarity
  • Image search
  • Recommendation systems
  • RAG (Retrieval-Augmented Generation) pipelines
ExpectationReality
Exact resultsApproximate - may miss true nearest neighbors
Fast like scalar indexesSlower - graph traversal, not B-tree lookup
Low storage overhead100-200% overhead vs indexed column
Works with all predicatesOnly ORDER BY ... ANN OF syntax
Scales linearlySub-linear but memory-intensive
-- Create table with vector column (Cassandra 5.0+)
CREATE TABLE documents (
doc_id uuid PRIMARY KEY,
title text,
content text,
embedding vector<float, 1536> -- OpenAI ada-002 dimension
);
-- Create SAI index on vector column
CREATE INDEX ON documents (embedding) USING 'sai';

Vector Queries Use Different Syntax

Vector queries use ORDER BY ... ANN OF, not WHERE. The query returns approximate nearest neighbors, not exact matches.

-- Find 10 most similar documents to query vector
SELECT doc_id, title, similarity_cosine(embedding, [0.1, 0.2, ...]) AS score
FROM documents
ORDER BY embedding ANN OF [0.1, 0.2, ...]
LIMIT 10;
OperatorSyntaxDescription
ANN searchORDER BY col ANN OF [...]Approximate nearest neighbor
Cosine similaritysimilarity_cosine(col, [...])Returns similarity score (0-1)
Euclidean distancesimilarity_euclidean(col, [...])Returns distance score
Dot productsimilarity_dot_product(col, [...])Returns dot product score

Hard Constraints

  • No filtering before ANN: Cannot use WHERE with vector queries (except partition key)
  • No exact search: ANN is always approximate
  • Fixed dimensions: Vector dimension set at table creation, cannot change
  • No nulls: Vector columns cannot contain null values
  • Memory intensive: HNSW graph loaded during queries
CREATE INDEX ON documents (embedding) USING 'sai'
WITH OPTIONS = {
'similarity_function': 'cosine' -- cosine, euclidean, dot_product
};
OptionValuesDefaultDescription
similarity_functioncosine, euclidean, dot_productcosineDistance metric for ANN

Vector indexes create specialized graph structures that significantly increase storage:

ComponentDescriptionSize Impact
HNSW GraphNavigable small world graph for ANN50-100% of vector data
Product QuantizationCompressed vector representations20-50% of vector data
PostingsRow ID mappings10-20% overhead

Total overhead: 100-200% of the vector column size.

For a table with 1M rows and 1536-dimension float vectors (~6KB per row):

  • Vector data: ~6GB
  • Vector index: ~6-12GB additional

Do Not Use Vector Search For

  • Exact matching: Use equality index instead
  • Small datasets (<10K rows): Full scan may be faster
  • Frequently updated vectors: Index rebuild overhead
  • High-throughput queries: ANN is computationally expensive
-- Multiple SAI indexes (intersected)
SELECT * FROM users
WHERE age > 25
AND city = 'NYC'
AND status = 'active';
-- SAI with partition key (most efficient)
SELECT * FROM events
WHERE sensor_id = ?
AND timestamp > '2024-01-01'
AND level = 'ERROR';

SAI addresses SASI's experimental issues:

  • Bounded memory consumption during queries
  • No known memory leaks
  • Consistent behavior across workloads
  • Extensive production testing

SAI minimizes write path impact:

Benchmark comparison (relative overhead):
- Secondary Index (2i): ~15-20% write overhead
- SASI: ~10-15% write overhead
- SAI: ~5-10% write overhead
Reasons:
- Efficient memtable index structures
- Optimized flush process
- Minimal synchronization

Single-pass intersection within each SSTable:

-- Query with three predicates
SELECT * FROM users
WHERE age > 25 AND city = 'NYC' AND status = 'active';
-- Execution:
-- For each SSTable:
-- 1. Query age index → bitmap of matching rows
-- 2. Query city index → bitmap of matching rows
-- 3. Query status index → bitmap of matching rows
-- 4. AND bitmaps together
-- 5. Fetch only intersected rows

SAI uses streaming and bounded buffers:

  • Query results streamed, not fully materialized
  • Per-SSTable memory bounds
  • Configurable memory limits
  • No unbounded allocations

Memtable indexing provides immediate visibility:

-- Write
INSERT INTO users (id, city) VALUES (uuid(), 'NYC');
-- Immediately queryable via SAI
SELECT * FROM users WHERE city = 'NYC';
-- Returns the just-inserted row

SAI Is Not a Silver Bullet

While SAI is significantly better than legacy indexes, it still has fundamental limitations. These are not soft recommendations—violating them will cause problems.

Query PatternSupportedAlternative
= (equality)-
>, >=, <, <=-
LIKE 'prefix%'-
IN (...)-
CONTAINS-
LIKE '%suffix'Reverse the string in a separate column
LIKE '%substring%'⚠️Requires analyzer; consider external search
!= (not equal)Application-level filtering
OR across columnsUnion multiple queries in application
NOT predicatesApplication-level filtering

SAI Is Not Elasticsearch

SAI provides basic text analysis, not full-text search. For advanced search, use a dedicated search engine.

FeatureSAIElasticsearch/Solr
Tokenization
Case normalization
Prefix matching
Fuzzy matching
Phonetic search
Synonyms
Relevance scoring
Faceted search
Highlighting

Do Not Index UUIDs or Unique Identifiers

Problem: Index size equals data size. Query must contact all nodes to find one row.

Symptoms: Query latency exceeds full table scan, disk usage doubles.

-- DO NOT DO THIS
CREATE INDEX ON events (event_id) USING 'sai';
-- INSTEAD: Make it the partition key
CREATE TABLE events (event_id uuid PRIMARY KEY, ...);

Anti-Pattern: Low Cardinality Without Partition Key

Section titled “Anti-Pattern: Low Cardinality Without Partition Key”

Do Not Query Low-Cardinality Indexes Globally

Problem: Query returns unbounded results, potentially millions of rows.

Safe: Combined with partition key restriction.

Dangerous: Global query on boolean/enum column.

-- DANGEROUS: Returns 50% of all rows across all nodes
SELECT * FROM users WHERE is_active = true;
-- SAFE: Restricted to one partition
SELECT * FROM users WHERE region = 'us-east' AND is_active = true;

Avoid Indexing Volatile Columns

Every update creates index maintenance. For columns updated frequently (e.g., last_seen, session_count), the index overhead may exceed benefits.

-- PROBLEMATIC: Updated on every request
CREATE INDEX ON sessions (last_activity) USING 'sai';
-- CONSIDER: Store in separate table without index, or accept staleness

SAI Always Reads Base Table

Unlike some databases, SAI cannot return results from index alone. Every match requires a base table read.

-- WRONG expectation: "Index-only scan"
SELECT city FROM users WHERE city = 'NYC';
-- Reality: Reads full row from base table, returns city column
-- This is fine for filtering, but not for avoiding base table reads

Queries without partition key restriction contact all nodes:

-- Global query: contacts all replicas
SELECT * FROM users WHERE city = 'NYC';
-- Partition-restricted: contacts specific replicas
SELECT * FROM users WHERE user_id = ? AND city = 'NYC';

Global Query Impact

  • Latency = slowest responding node
  • Load distributed across entire cluster
  • Acceptable for low-frequency queries
  • Problematic for high-throughput workloads

Create indexes strategically:

-- Good: Frequently queried column with medium cardinality
CREATE INDEX ON orders (status) USING 'sai';
-- Good: Range queries on timestamp
CREATE INDEX ON events (created_at) USING 'sai';
-- Consider carefully: Very high cardinality
CREATE INDEX ON logs (request_id) USING 'sai';
-- May be better as partition key
-- Avoid: Frequently updated columns
CREATE INDEX ON sessions (last_activity) USING 'sai';
-- Every update requires index update

Optimize queries for SAI:

-- Best: Include partition key
SELECT * FROM events
WHERE device_id = ?
AND timestamp > '2024-01-01';
-- Good: Selective predicates first
SELECT * FROM users
WHERE rare_status = 'suspended'
AND common_city = 'NYC';
-- Add LIMIT for large result sets
SELECT * FROM logs
WHERE level = 'ERROR'
LIMIT 1000;
# cassandra.yaml SAI settings
# Memory limit for SAI queries (per query)
sai_query_cache_size_in_mb: 256
# Concurrent SAI index builds
sai_concurrent_index_builders: 2

ScenarioConfigurationWhy SAI Works
Query on non-key columnsDefaultProduction-ready secondary access
Range queries on timestampsDefaultEfficient KD-tree for ranges
Filter by status/categoryDefaultHandles medium cardinality well
Multi-predicate queriesMultiple indexesSingle-pass intersection
Combined with partition keyDefaultRestricts scope efficiently
ScenarioAlternative
Full-text search needsElasticsearch, Solr
Very high cardinality columnInclude in partition key
Frequently updated columnData model redesign
OR queries across columnsApplication-level union
High-throughput analyticsPurpose-built analytics DB

-- Drop legacy index
DROP INDEX IF EXISTS users_city_idx;
-- Create SAI index (same syntax with USING clause)
CREATE INDEX users_city_idx ON users (city) USING 'sai';
-- Drop SASI index
DROP INDEX IF EXISTS users_email_idx;
-- Create SAI index
CREATE INDEX users_email_idx ON users (email) USING 'sai'
WITH OPTIONS = { 'case_sensitive': 'false' };
-- Note: Some SASI analyzers need adjustment
-- SASI CONTAINS mode → SAI with analyzer
-- SASI SPARSE mode → SAI (automatic for numeric)
AspectLegacy/SASISAI
Query syntaxSameSame
LIKE queriesDepends on modePrefix only (without analyzer)
Range queriesSASI onlySupported
Build timeVariesGenerally faster
Storage overheadVariesGenerally lower

Terminal window
# View index build progress
nodetool describecluster
# Table statistics including SAI
nodetool tablestats keyspace.table
# Compaction (includes SAI components)
nodetool compactionstats
# SAI-specific metrics
org.apache.cassandra.metrics:type=StorageAttachedIndex,*
# Per-index metrics
org.apache.cassandra.metrics:type=Index,scope=<index_name>,name=*
# Query latency
org.apache.cassandra.metrics:type=Table,keyspace=*,scope=*,name=SAIQueryLatency
MetricHealthyInvestigate
SAI query latency P99<100ms>500ms
Index build pending0>0 for extended time
Index size vs data size<50%>100%