Siddhant Deval
Siddhant Deval
backend22 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

Structure Command Pattern Production Use Case
String SET key value EX ttl Cache, idempotency keys, feature flags
Hash HSET session:userId field value Session objects (field-level GET/SET)
Sorted Set ZADD leaderboard score userId Leaderboards, rate limiting windows
List LPUSH queue task; RPOP queue Work queue, activity feed
Set SADD online:users userId Unique visitor tracking, tag sets
Stream XADD events * action login userId 42 Event 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

Strategy When to Use Anti-Pattern
STCS (SizeTieredCompactionStrategy) Write-heavy workloads with infrequent reads Time-series — causes unbounded SSTable growth
LCS (LeveledCompactionStrategy) Read-heavy workloads requiring predictable latency High write throughput — compaction cannot keep up
TWCS (TimeWindowCompactionStrategy) Time-series, IoT, append-only with TTL Any 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

Store Config 1M ops/day 10M ops/day 100M ops/day
Redis ElastiCache r6g.large ~$50/mo ~$120/mo ~$500+/mo
Elasticsearch OpenSearch t3.medium.search ~$50/mo ~$200/mo ~$800+/mo
Cassandra 3× i3.large self-hosted ~$300/mo (fixed) ~$300/mo (fixed) ~$600/mo (6 nodes)
Neo4j AuraDB Professional ~$65/mo ~$200/mo Custom
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

Store Auth Mechanism Transport Encryption at Rest
Redis requirepass + ACL (per-command) TLS (optional, enable explicitly) Managed: enabled. Self-hosted: OS-level
Elasticsearch X-Pack RBAC, field-level security TLS mandatory (v8+) Enabled by default (v8+)
Cassandra Native auth + RBAC TLS (node-to-node + client) Transparent Data Encryption (TDE)
Neo4j RBAC on labels/relationships TLS (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

Concept Rule
Redis persistence Redis AOF persistence (appendfsync everysec) is mandatory whenever Redis stores data you cannot afford to lose — RDB alone loses up to 60 seconds.
Elasticsearch mapping Elasticsearch dynamic mapping causes index bloat and query degradation — enforce explicit mappings and disable dynamic: true on production indexes.
Cassandra write model Cassandra's write performance is structural (LSM-tree append-only), not configurable — never model workloads that require frequent point-read updates.
Neo4j graph traversal Neo4j'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 point Start vector search with pgvector inside your existing Postgres stack; migrate to a dedicated vector store only when retrieval P99 exceeds your latency budget.
Architectural Note

Related: Database Foundations Series — Apache Cassandra is a masterless BASE store, using gossip membership, tunable quorums (LOCAL_QUORUM, ONE, ALL), and Merkle tree anti-entropy to achieve eventual consistency without a global coordinator. For the formal BASE paradigm that Cassandra implements, see BASE Database Properties: Basically Available, Soft State & Eventual Consistency.

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.