Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Aug 9, 2026·22 min read

Specialized Data Stores: Redis, Elasticsearch, Cassandra, Neo4j & Vector Databases

Specialized data stores are purpose-built acceleration layers — each solves an access pattern that general-purpose databases serve poorly. This article covers Redis persistence trade-offs, Elasticsearch inverted index mechanics, Cassandra's LSM-tree write path, Neo4j graph traversal, and a beginner-level introduction to vector databases and ANN search.

Specialized Data Stores: Redis, Elasticsearch, Cassandra, Neo4j & Vector Databases

Selecting the right database is a foundational architectural decision; data gravity ultimately dictates the scalability and resilience of an application — choose the paradigm first, the product second. The broken pattern for specialized stores is adopting them by category rather than by access pattern: "we need caching, so Redis"; "we need search, so Elasticsearch"; "we need a graph, so Neo4j." Each of these stores has a canonical failure mode that appears when it is used for the wrong workload — and a canonical strength that makes it orders of magnitude better than a general-purpose database for the right one. This article covers all five stores, their mechanics, their data modeling discipline, their failure modes, and the precise conditions under which each is justified.

Redis — From Cache to Architectural Store

Architecture

Redis is a single-threaded, in-memory data structure server. Its performance comes from two sources: all data in RAM (no disk I/O on reads) and a non-blocking I/O event loop that processes thousands of commands per second on a single thread.
bash
# ❌ Broken pattern: using Redis as a durable primary store without persistence
# Default Redis config: no persistence, no AOF, no RDB
# Any crash → total data loss

# ✅ Correct: enable AOF persistence before storing any data you cannot lose
# redis.conf:
appendonly yes
appendfsync everysec          # Flush to disk every second — at most 1 second of data loss
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

Data Structures and Use Cases

StructureCommand PatternProduction Use Case
StringSET key value EX ttlCache, idempotency keys, feature flags
HashHSET session:userId field valueSession objects (field-level GET/SET)
Sorted SetZADD leaderboard score userIdLeaderboards, rate limiting windows
ListLPUSH queue task; RPOP queueWork queue, activity feed
SetSADD online:users userIdUnique visitor tracking, tag sets
StreamXADD events * action login userId 42Event sourcing, pub/sub, audit log

Data Modeling for Redis

bash
# Key naming: {entity}:{id}:{attribute} — namespaced, scannable, evictable
SET user:42:session '{"role":"admin","expires":1756512000}' EX 86400

# Hash vs. String for session objects:
# ❌ String — requires full JSON round-trip on every field access
SET user:42:session '{"role":"admin","cartSize":3,"lastSeen":"2026-08-30"}'
# Must deserialize entire blob to read just "role"

# ✅ Hash — field-level GET/SET without full deserialization
HSET user:42:session role admin cartSize 3 lastSeen "2026-08-30"
HGET user:42:session role   # reads ONE field, no deserialization overhead
HINCRBY user:42:session cartSize 1  # atomic increment without read-modify-write

# Rate limiting: sliding window using a Sorted Set
ZADD ratelimit:ip:1.2.3.4 1725000000000 "req-uuid-abc"  # score = current timestamp ms
ZREMRANGEBYSCORE ratelimit:ip:1.2.3.4 0 1724999940000   # remove entries older than 1 min
ZCARD ratelimit:ip:1.2.3.4                               # count requests in window

Redis Stack — Beyond Caching

javascript
// RedisJSON: document store inside Redis
await client.json.set('product:42', '$', {
  name: 'Wireless Headphones',
  price: 99.99,
  specs: { battery: '30h', bluetooth: '5.2' }
})
const price = await client.json.get('product:42', { path: '$.price' })

// RediSearch: full-text search index on JSON documents
await client.ft.create('idx:products', {
  name: { type: SchemaFieldTypes.TEXT, WEIGHT: 5 },
  'specs.bluetooth': { type: SchemaFieldTypes.TAG }
}, { ON: 'JSON', PREFIX: 'product:' })

const results = await client.ft.search('idx:products', 'wireless @specs.bluetooth:{5\\.2}')

When NOT to Use Redis

  • As a primary store without AOF enabled — RDB snapshots can lose up to 60 seconds of writes on crash
  • For datasets exceeding available RAM without an explicit eviction policy (allkeys-lru for cache-only workloads)
  • For complex relational queries or multi-entity aggregations — no join primitives
  • For compliance-auditable data that must survive every crash without a durable secondary backing it

Elasticsearch / OpenSearch — Full-Text Search and Observability

Inverted Index Mechanics

Document: "PostgreSQL is an advanced open-source relational database"

Inverted Index (simplified):
  "postgresql"  → [doc_1, doc_4, doc_12]
  "advanced"    → [doc_1, doc_7, doc_33]
  "open-source" → [doc_1, doc_2, doc_15]
  "relational"  → [doc_1, doc_6, doc_8, doc_21]
  "database"    → [doc_1, doc_2, doc_3, ...]

Query: "open source relational database"
  → intersection of posting lists → [doc_1, doc_2, doc_6, ...]
  → scored by BM25 (term frequency × inverse document frequency)
  → doc_1 scores highest (all terms present)
SQL LIKE '%keyword%' requires a sequential scan of every row. The inverted index maps tokens to document IDs in O(1) — this is why Elasticsearch retrieves relevant results in milliseconds across billions of documents.

Data Modeling for Elasticsearch

json
// ❌ Broken: dynamic mapping — Elasticsearch infers field types on first document
// If document 1 has { "price": 10 } and document 2 has { "price": "ten" }
// Elasticsearch throws a mapping conflict exception at document 2 — field type locked

// ✅ Correct: explicit mapping defined at index creation
PUT /products
{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "name":     { "type": "text", "analyzer": "english" },
      "category": { "type": "keyword" },
      "price":    { "type": "float" },
      "tags":     { "type": "keyword" },
      "description": {
        "type": "text",
        "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }
      }
    }
  }
}
Performance / Safety Warning
"dynamic": "strict" causes Elasticsearch to reject documents with unmapped fields, preventing mapping explosion. Mapping explosion (thousands of dynamic fields from log data) bloats the cluster state, triggers GC pauses, and is the most common Elasticsearch production incident. Always set dynamic: strict in production indexes.

Index Lifecycle Management

bash
# ILM policy: hot → warm → cold → delete based on index age
PUT _ilm/policy/logs-policy
{
  "policy": {
    "phases": {
      "hot":   { "actions": { "rollover": { "max_age": "7d", "max_size": "50gb" } } },
      "warm":  { "min_age": "30d", "actions": { "shrink": { "number_of_shards": 1 } } },
      "cold":  { "min_age": "90d", "actions": { "freeze": {} } },
      "delete":{ "min_age": "365d","actions": { "delete": {} } }
    }
  }
}

When NOT to Use Elasticsearch

  • As a primary database — no ACID guarantees, no foreign keys, writes are eventually indexed
  • For transactional writes requiring sub-5ms acknowledgment — Lucene indexing adds overhead
  • For small datasets (< 1M documents) — Postgres tsvector with a GIN index is operationally simpler and sufficient

Apache Cassandra — Write-Optimized Distributed Storage

LSM-Tree Write Path

Write path (why Cassandra writes are fast):
  1. Write → Commit Log (sequential disk append — durability guarantee)
  2. Write → MemTable (in-memory sorted structure — fast)
  3. MemTable full → flush to SSTable (immutable sorted file on disk)
  4. Background compaction: merge SSTables → remove tombstones → reduce read amplification

Read path (why Cassandra reads can be slow):
  1. Check MemTable (newest data)
  2. Check each SSTable in order (oldest to newest) — read amplification
  3. Bloom filter eliminates most SSTable checks (~95% miss rate)
  4. Merge results across surviving SSTables
The implication: Cassandra is optimized for writes that are never updated. A workload that frequently updates individual records creates read amplification and compaction pressure — the wrong fit for an LSM-tree.

Data Modeling for Cassandra

sql
-- ❌ Broken: modeling by entity like a relational database
CREATE TABLE users (
  user_id UUID PRIMARY KEY,
  name TEXT,
  email TEXT
);
-- Reading "all orders for a user" requires ALLOW FILTERING — full cluster scan

-- ✅ Correct: model by query — the table shape IS the query
-- Query: "Get all events for a sensor, ordered by time, for the last 7 days"
CREATE TABLE sensor_events (
  sensor_id  UUID,
  event_date DATE,
  ts         TIMESTAMP,  -- clustering column: ordered within partition
  value      DOUBLE,
  PRIMARY KEY ((sensor_id, event_date), ts)  -- composite partition key
) WITH CLUSTERING ORDER BY (ts DESC)
  AND default_time_to_live = 604800;  -- auto-expire after 7 days

-- Query is now a partition scan — no ALLOW FILTERING, no cross-node scatter
SELECT ts, value FROM sensor_events
WHERE sensor_id = ? AND event_date = ?
ORDER BY ts DESC LIMIT 1000;
Crucial Requirement
GDPR Compliance: Cassandra's LSM-tree and distributed replication make immediate physical erasure impossible. A DELETE statement writes a tombstone — the actual data persists in SSTables until compaction completes, which may take hours or days across replicas. Design a compliance strategy upfront: use TTL (expiry, not erasure) for time-bounded data, and encryption-key-deletion for immediate logical erasure of PII. Document this strategy before your first compliance audit — "we deleted it" is not sufficient if the data exists in SSTables.

Compaction Strategies

StrategyWhen to UseAnti-Pattern
STCS (SizeTieredCompactionStrategy)Write-heavy workloads with infrequent readsTime-series — causes unbounded SSTable growth
LCS (LeveledCompactionStrategy)Read-heavy workloads requiring predictable latencyHigh write throughput — compaction cannot keep up
TWCS (TimeWindowCompactionStrategy)Time-series, IoT, append-only with TTLAny workload with frequent overwrites to the same key

When NOT to Use Cassandra

  • Read-heavy workloads with random point-access — read amplification across SSTables degrades P99
  • Workloads requiring complex aggregations — Cassandra has no aggregation primitives beyond COUNT and SUM per partition
  • Small teams without JVM expertise — compaction tuning, GC pressure, and tombstone monitoring require operational depth

Neo4j — Graph Database for Connected Data

Property Graph Model

cypher
// ❌ Broken: modeling a recommendation query as SQL joins
// "Find products purchased by users who also bought product X"
SELECT DISTINCT p2.name
FROM orders o1
JOIN order_items i1 ON o1.id = i1.order_id
JOIN order_items i2 ON i1.product_id != i2.product_id
JOIN orders o2 ON i2.order_id = o2.id AND o1.user_id = o2.user_id
JOIN products p2 ON i2.product_id = p2.id
WHERE i1.product_id = 'prod_headphones'
-- At 10M orders: 4-table join → seconds. At 100M: impractical.

// ✅ Correct: Cypher graph traversal — native pattern matching on relationships
MATCH (p1:Product {id: 'prod_headphones'})<-[:PURCHASED]-(:User)-[:PURCHASED]->(p2:Product)
WHERE p1 <> p2
RETURN p2.name, COUNT(*) AS frequency
ORDER BY frequency DESC
LIMIT 10
// At 10M orders: milliseconds. The graph index traverses edges, not table rows.

Data Modeling for Neo4j

cypher
// Node label granularity: one label per conceptual entity type
CREATE (:User {id: 'u42', name: 'Alice', country: 'DE'})
CREATE (:Product {id: 'p_headphones', name: 'Wireless Headphones', price: 99.99})
CREATE (:Category {name: 'Electronics'})

// Relationships: model in the direction of the dominant traversal query
// Query: "what did this user buy?" → (User)-[:PURCHASED]->(Product)
MATCH (u:User {id: 'u42'})-[:PURCHASED]->(p:Product)

// Query: "who bought this product?" → traverse same edge in reverse — Cypher handles both directions
MATCH (p:Product {id: 'p_headphones'})<-[:PURCHASED]-(u:User)

// ❌ Anti-pattern: storing tabular data in Neo4j nodes and querying without traversal
MATCH (u:User) WHERE u.country = 'DE' RETURN u
// This is a property scan — no graph traversal, no edge following
// This is a relational query wearing a graph costume — use Postgres instead

When NOT to Use Neo4j

  • Tabular reporting where rows are filtered by properties, not traversed by relationships
  • High-volume transactional writes — graph locking on shared nodes degrades throughput at Cassandra/DynamoDB write rates
  • Workloads with no meaningful multi-hop relationship traversal — if queries don't follow edges, it is a relational dataset in a graph store

Vector Databases — Beginner Introduction

What Is an Embedding?

python
from openai import OpenAI
client = OpenAI()

# Text → embedding: a 1536-dimension dense vector representing semantic meaning
response = client.embeddings.create(
    model="text-embedding-3-small",
    input="PostgreSQL is an advanced relational database"
)
embedding = response.data[0].embedding  # list of 1536 floats

# Two semantically similar sentences produce similar vectors (high cosine similarity)
# Two unrelated sentences produce dissimilar vectors (low cosine similarity)
# This is why vector search finds relevant content without exact keyword matching

ANN — Why Approximate Search Is Necessary

Exact nearest neighbor search across 10M 1536-dimension vectors requires computing 10M cosine distances per query — O(n×d) time where d is the dimension count. At 10M vectors and 1536 dimensions, this is ~15 billion float multiplications per query — approximately 10 seconds on a single CPU core.
HNSW (Hierarchical Navigable Small World): builds a multi-layer graph where each node links to its nearest neighbors. Queries traverse from sparse upper layers to dense lower layers — finding approximate nearest neighbors in O(log n) time with high recall.
IVFFlat (Inverted File with Flat quantization): clusters vectors into nlist buckets at training time. Queries search only the top nprobe closest buckets — fast, but recall degrades if the query vector's neighbors span multiple buckets.

The RAG Pipeline

Documents
    │
    ▼
[Chunking]  ← 512-token recursive character splitting (defensible default)
    │
    ▼
[Embedding Model]  ← text-embedding-3-small (1536-dim) or sentence-transformers
    │
    ▼
[Vector Store Upsert]  ← pgvector (< 10M), Qdrant / Pinecone (> 10M)
    │
[At query time:]
    │
    ▼
[Query Embedding]  ← same model as at index time
    │
    ▼
[ANN Retrieval]  ← top-K similar chunks by cosine distance
    │
    ▼
[Context Assembly]  ← inject retrieved chunks into LLM prompt
    │
    ▼
[LLM Response]
Pro Tip & Optimization
Start with pgvector inside your existing Postgres instance. It eliminates a separate infrastructure dependency, supports HNSW and IVFFlat indexes, enables hybrid queries (vector similarity + SQL filters in one query), and handles under 10M vectors at sub-100ms P99 on modern NVMe hardware. Graduate to Qdrant or Pinecone when retrieval P99 consistently exceeds your latency budget.

Cost Model at Scale

StoreConfig1M ops/day10M ops/day100M ops/day
RedisElastiCache r6g.large~$50/mo~$120/mo~$500+/mo
ElasticsearchOpenSearch t3.medium.search~$50/mo~$200/mo~$800+/mo
Cassandra3× i3.large self-hosted~$300/mo (fixed)~$300/mo (fixed)~$600/mo (6 nodes)
Neo4jAuraDB Professional~$65/mo~$200/moCustom
Architectural Note
Cassandra's cost model is flat until you need more nodes — it scales by adding nodes, not by paying per operation. This makes it cost-effective at extreme write volumes where DynamoDB's per-request pricing would be prohibitive.

Security

StoreAuth MechanismTransportEncryption at Rest
Redisrequirepass + ACL (per-command)TLS (optional, enable explicitly)Managed: enabled. Self-hosted: OS-level
ElasticsearchX-Pack RBAC, field-level securityTLS mandatory (v8+)Enabled by default (v8+)
CassandraNative auth + RBACTLS (node-to-node + client)Transparent Data Encryption (TDE)
Neo4jRBAC on labels/relationshipsTLS (bolt protocol)Managed: enabled. Self-hosted: OS-level

Comparison matrix for Redis, Elasticsearch, Cassandra, Neo4j, and vector database tier across data model, dominant query type, write throughput, consistency model, and primary use case — showing which store owns which access pattern.
Comparison matrix for Redis, Elasticsearch, Cassandra, Neo4j, and vector database tier across data model, dominant query type, write throughput, consistency…
Layered architecture showing primary database at top feeding into specialized accelerator stores below — Redis for cache/session, Elasticsearch for search, Cassandra for time-series/append writes, Neo4j for graph traversal, pgvector for AI similarity — each labeled with the access pattern it owns.
Layered architecture showing primary database at top feeding into specialized accelerator stores below — Redis for cache/session, Elasticsearch for search, C…
Cassandra LSM-tree write path from client write to MemTable flush to SSTable on disk to compaction merge — showing where write speed originates and where read amplification enters the path.
Cassandra LSM-tree write path from client write to MemTable flush to SSTable on disk to compaction merge — showing where write speed originates and where rea…

Summary

ConceptRule
Redis persistenceRedis AOF persistence (appendfsync everysec) is mandatory whenever Redis stores data you cannot afford to lose — RDB alone loses up to 60 seconds.
Elasticsearch mappingElasticsearch dynamic mapping causes index bloat and query degradation — enforce explicit mappings and disable dynamic: true on production indexes.
Cassandra write modelCassandra's write performance is structural (LSM-tree append-only), not configurable — never model workloads that require frequent point-read updates.
Neo4j graph traversalNeo4j's Cypher is orders of magnitude faster than recursive SQL CTEs for 3+ hop relationship queries — but only when the access pattern is genuinely relational.
Vector database entry pointStart vector search with pgvector inside your existing Postgres stack; migrate to a dedicated vector store only when retrieval P99 exceeds your latency budget.

What's Next

In Part 6, we cover polyglot persistence — how to quantify when multiple databases earn their operational cost, how Change Data Capture with Debezium replaces the dual-write anti-pattern, and when Postgres extensions eliminate entire database tiers.
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Redis#Elasticsearch#Cassandra#Neo4j#Vector Database#LSM-Tree#Graph Database#Polyglot Persistence
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.