Why KiviDB, why now#

Every small RAG project hits the same fork in the road: keep your cache in Redis and bolt on a second service for vector search, or move everything into a dedicated vector database and rewrite the caching layer. KiviDB 1.2.0, released November 3, 2026, refuses the fork. It is a Redis-protocol-compatible store, written in Rust, with full-text and HNSW vector search built into the same binary — no module to load, no second service to operate.

The numbers explain why it is on this desk. KiviDB was created on October 16, 2026. By November 9 it had 993 stars, and its pace nearly doubled in a week: from 26 stars a day to 51 a day, gaining 254 stars between November 4 and 9, enough to land in the top ten fastest-accelerating AI repositories that week. Version 1.2.0 is a durability and compatibility release — append-only-file writes are now acknowledged only once they reach disk, search scores match Redis 8.10, and every release file carries a signed build attestation.

This guide does the thing the README cannot: it installs KiviDB, proves the Redis compatibility with plain client code, and builds a working semantic search over real documents — then proves the index survives a restart. Every command below ran in a sandbox on November 8–9, 2026, and every output quoted is the output that came back.

What you'll need#

  • A Linux x86_64 machine (macOS Apple silicon and Intel binaries exist too; there is no Windows binary — use WSL). The sandbox used here is a small VM with 7 GB of RAM.
  • Python 3.10 or later with pip. The sandbox ran Python 3.12.3.
  • About 30 minutes. The download is 6 MB; nothing here compiles, needs a GPU, or downloads model weights.
  • No account, no API key, no Docker. KiviDB is a single static binary and the client is plain redis-py.

Pinned versions, exactly as verified: KiviDB 1.2.0, redis 8.1.0, scikit-learn 1.9.1, numpy 2.5.3.

Step 1 — Install the release binary#

KiviDB ships prebuilt binaries on GitHub Releases. Download the Linux x86_64 tarball for 1.2.0, verify its checksum against the release's SHA256SUMS.txt, and extract it:

curl -LO https://github.com/kividbio/kividb/releases/download/v1.2.0/kividb-linux-x86_64.tar.gz
curl -LO https://github.com/kividbio/kividb/releases/download/v1.2.0/SHA256SUMS.txt
sha256sum -c <(grep 'kividb-linux-x86_64.tar.gz' SHA256SUMS.txt)
tar xzf kividb-linux-x86_64.tar.gz

Expected output:

kividb-linux-x86_64.tar.gz: OK

The tarball is 6,051,503 bytes and extracts to a kividb/ directory holding one 16 MB binary plus the license files. Four variants exist per platform — default, -lua (Lua scripting), -tls (TLS termination), and -full (both). The default is what this guide uses; the .deb and .rpm packages install it as a systemd service instead, if you prefer that.

Start it, pointing at a data directory of your choice:

KIVIDB_NO_UPDATE_CHECK=1 ./kividb/kividb --dir ./data --port 6380 --loglevel notice

Expected output (banner trimmed):

KiviDB v1.2.0 - A fast Redis®-compatible store written in Rust
  * Version        : 1.2.0
  * Mode           : standalone
  * OS             : linux x86_64
  * Listening on   : 0.0.0.0:6380
  * Worker threads : 2
  * AOF            : disabled
  * Persistence    : KDB snapshot (SAVE / BGSAVE)
  * Databases      : 16
Ready to accept connections on 0.0.0.0:6380

Three details matter. First, the port is 6380, not Redis's 6379 — deliberate, so KiviDB and Redis can run side by side. Second, protected mode is on by default: with no password set, only loopback clients may connect, exactly as in Redis. Third, a release build makes one HTTPS request at startup to check for a newer release; KIVIDB_NO_UPDATE_CHECK=1 (or --update-check no) turns it off, which is what the command above does.

Step 2 — Prove it's Redis#

Install the client libraries, then run the plain redis-py code you would run against Redis. Nothing in this script is KiviDB-specific — that is the point.

python3 -m venv kvvenv
source kvvenv/bin/activate
pip install redis==8.1.0 scikit-learn==1.9.1 numpy==2.5.3
import redis

r = redis.Redis(host="127.0.0.1", port=6380, decode_responses=True)
print("PING:", r.ping())

info = r.info("server")
print("redis_version:", info.get("redis_version"))
print("kividb_version:", info.get("kividb_version"))

r.flushdb()
r.set("hello", "world")
r.incr("counter")
r.lpush("queue", "a", "b", "c")
r.hset("user:1", mapping={"name": "Ada", "role": "editor"})
r.sadd("tags", "rag", "cache", "vectors")
r.set("temp", "x", ex=60)
print("GET hello:", r.get("hello"))
print("LRANGE queue:", r.lrange("queue", 0, -1))
print("HGETALL user:1:", r.hgetall("user:1"))
print("DBSIZE:", r.dbsize())

Expected output:

PING: True
redis_version: 8.10.0
kividb_version: 1.2.0
GET hello: world
LRANGE queue: ['c', 'b', 'a']
HGETALL user:1: {'name': 'Ada', 'role': 'editor'}
DBSIZE: 6

The two version lines are the whole compatibility story. KiviDB reports itself as Redis 8.10.0, and its own version rides along in INFO as kividb_version. The project runs Redis 8.10.2's own test suite against every change and reports 2,697 tests passing with 6 documented differences. Strings, lists, hashes, sets, expiry — the code above is unmodified redis-py, and it works because KiviDB speaks RESP2 and RESP3, the Redis wire protocols.

Step 3 — Create the vector index#

KiviDB implements the Redis Stack search commands, so the index definition reads like Redis documentation. Create an index over hashes with the doc: prefix, with two text fields and one vector field:

r.execute_command(
    "FT.CREATE", "idx", "ON", "HASH", "PREFIX", "1", "doc:",
    "SCHEMA", "title", "TEXT", "WEIGHT", "1.0",
    "body", "TEXT",
    "vec", "VECTOR", "HNSW", "6", "TYPE", "FLOAT32",
    "DIM", 87, "DISTANCE_METRIC", "COSINE",
)

What each piece does:

  • ON HASH PREFIX 1 doc: — index every hash whose key starts with doc:.
  • title TEXT WEIGHT 1.0 and body TEXT — full-text fields; title matches count the same as body matches.
  • vec VECTOR HNSW 6 ... — an HNSW approximate-nearest-neighbour index. The 6 is the count of the parameters that follow: TYPE FLOAT32 (32-bit floats), DIM 87 (vector width), DISTANCE_METRIC COSINE.

DIM 87 is not a magic number — it is the width of the vectors this guide produces, and Step 4 explains where it comes from. HNSW's graph parameters default to M 16 and EF_CONSTRUCTION 200, which FT.INFO confirms after the index is built:

info = r.execute_command("FT.INFO", "idx")
print(info["num_docs"], info["fields"])
0 ['title (TEXT)', 'body (TEXT)', 'vec (VECTOR HNSW(M=16,ef=200) DIM=87 COSINE)']

Zero documents, as expected — nothing is indexed yet.

Step 4 — Embed eight documents#

A vector index needs vectors. Production systems use an embedding model; this guide uses TF-IDF vectors instead, for a deliberate reason: they need no model download, no API key, and no GPU, and they make the mechanics visible — one dimension per vocabulary term, so you can see exactly what the search is ranking. The honest trade-off is that TF-IDF ranks by word overlap, not meaning; the retrieval below is a real retrieval demo, not a semantic miracle. Swapping in a real embedder later changes only the vector production and the DIM — the index, the storage, and every query stay identical.

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer

DOCS = [
    ("doc:1", "KiviDB persistence",
     "KiviDB saves snapshots to dump.kdb and can append every write to an append-only file for durability after a crash."),
    ("doc:2", "Redis compatibility",
     "KiviDB speaks the Redis protocol RESP2 and RESP3, so existing Redis clients and libraries work unchanged."),
    ("doc:3", "Vector search",
     "KiviDB has full-text and vector search built in with HNSW indexes, so a retrieval index needs no separate service."),
    ("doc:4", "In-memory speed",
     "KiviDB runs every core on a sharded in-memory key-value design and serves millions of pipelined GET operations per second."),
    ("doc:5", "Full-text search",
     "Text fields are stemmed like Redis, so searching for preferences also finds documents containing prefer."),
    ("doc:6", "Hybrid search",
     "Combine a text filter with a KNN vector query to search only matching documents by meaning and by keyword."),
    ("doc:7", "Eviction policies",
     "Set maxmemory and a maxmemory-policy such as allkeys-lru so the cache evicts the least recently used keys when full."),
    ("doc:8", "Replication",
     "A replica copies the dataset from its primary and stays in step, so reads can scale out and failover keeps serving."),
]

texts = [title + ". " + body for _, title, body in DOCS]
vec = TfidfVectorizer(stop_words="english")
X = vec.fit_transform(texts).toarray().astype(np.float32)
X = X / np.linalg.norm(X, axis=1, keepdims=True)  # L2-normalise: cosine ranking
print("vocab size / DIM:", X.shape[1])

Expected output:

vocab size / DIM: 87

That 87 is the vocabulary size after English stop-word removal — and it is the DIM from Step 3. A real embedding model would give you a fixed width instead (384 for a small sentence-transformer, 1,536 for OpenAI's text-embedding-3-small); you would put that number in FT.CREATE and nothing else would change. Store the documents with their vectors as raw little-endian float32 blobs — the same convention Redis Stack uses:

for (key, title, body), row in zip(DOCS, X):
    r.hset(key, mapping={"title": title, "body": body, "vec": row.tobytes()})

info = r.execute_command("FT.INFO", "idx")
print("num_docs:", info["num_docs"])
num_docs: 8
Abstract illustration of documents flowing into vector arrows and an index, representing the retrieval pipeline
Diagram rendered for this article: documents become vectors, vectors become an HNSW index, queries return ranked results.

Step 5 — Ask it questions#

Embed the query with the same vectorizer, then run a KNN search. The query syntax is Redis Stack's: *=>[KNN 3 @vec $q] means "the three nearest neighbours of the query vector", passed as a binary parameter:

def knn(query_text, k=3):
    q = vec.transform([query_text]).toarray().astype(np.float32)
    q = q / np.linalg.norm(q)
    res = r.execute_command(
        "FT.SEARCH", "idx", f"*=>[KNN {k} @vec $q]",
        "PARAMS", "2", "q", q.tobytes(), "DIALECT", "2",
        "RETURN", "2", "title", "__vec_score",
    )
    return [(hit["id"], hit["extra_attributes"]["title"],
             hit["extra_attributes"]["__vec_score"])
            for hit in res["results"]]

for query in ["vector similarity search",
              "crash durability snapshots",
              "Redis protocol clients",
              "millions of operations per second"]:
    print(query, "->", knn(query))

Expected output — the exact scores from the verified run:

vector similarity search -> [('doc:3', 'Vector search', '0.40460086'),
  ('doc:6', 'Hybrid search', ...), ('doc:5', 'Full-text search', ...)]
crash durability snapshots -> [('doc:1', 'KiviDB persistence', '0.54682934'), ...]
Redis protocol clients -> [('doc:2', 'Redis compatibility', '0.3890546'), ...]
millions of operations per second -> [('doc:4', 'In-memory speed', '0.5723274'), ...]

All four queries return the right document first. The score is a cosine distance: 0 means identical direction, 2 means opposite. So 0.39 for the Redis-protocol query is a close match and 0.57 for the throughput query is a weaker one — the numbers are honest about how confident the match is. Two redis-py details worth knowing: version 8.x parses FT.SEARCH into a dict with total_results and results (each hit carrying id and extra_attributes), and the vector blob must be passed as bytes — with decode_responses=True, reading the vec field back through HGETALL fails, because the raw float32 bytes are not valid UTF-8. Read text fields individually instead.

Step 6 — Full-text and hybrid search#

The same index answers keyword queries, because the title and body are real text fields. Text is stemmed as in Redis, so a query for one word form finds the others:

res = r.execute_command("FT.SEARCH", "idx", "preferences", "RETURN", "1", "title")
print(res["total_results"], res["results"][0]["extra_attributes"]["title"])
1 Full-text search

preferences matched the document containing prefer — stemming works. The more useful pattern is hybrid: filter by keyword first, then rank the survivors by vector similarity. Only three documents contain the word "search" in their body, and the hybrid query finds exactly those three:

q = vec.transform(["vector similarity"]).toarray().astype(np.float32)
q = q / np.linalg.norm(q)
res = r.execute_command(
    "FT.SEARCH", "idx", "(@body:search)=>[KNN 3 @vec $q]",
    "PARAMS", "2", "q", q.tobytes(), "DIALECT", "2",
    "RETURN", "1", "title",
)
print(res["total_results"],
      [hit["extra_attributes"]["title"] for hit in res["results"]])
3 ['Vector search', 'Hybrid search', 'Full-text search']

This is the query shape real RAG pipelines use most: a cheap keyword filter to cut the candidate set, then vector ranking on what remains.

Step 7 — Vector sets, the shortcut#

If the full-text fields are more than you need, Redis 8.10's vector sets give you a lighter path: one key holds named vectors, no index definition at all. Add the same eight documents, then ask for the three most similar to the "vector similarity search" query from Step 5:

q = vec.transform(["vector similarity search"]).toarray().astype(np.float32)
q = q / np.linalg.norm(q)
for i, row in enumerate(X, start=1):
    r.execute_command("VADD", "vecset", "VALUES", 87, *row.tolist(), f"doc:{i}")

sim = r.execute_command("VSIM", "vecset", "VALUES", 87, *q[0].tolist(),
                        "COUNT", 3, "WITHSCORES")
print(sim)
['doc:3', '0.797700', 'doc:6', '0.727784', 'doc:5', '0.568139']

Same winner, different score scale: VSIM reports similarity as (1 + cosine) / 2, so 0.80 means close and 0.5 means unrelated. You can check the arithmetic against Step 5 — the KNN cosine distance for doc:3 was 0.4046, and (1 + (1 - 0.4046)) / 2 = 0.7977. Vector sets are the right tool when you want pure similarity over a named collection; the FT.* index is the right tool when you also need text search, filters, and hybrid queries.

Step 8 — Make it survive a restart#

A cache that forgets its index on restart is a demo, not infrastructure. Save a snapshot, shut down cleanly, restart, and re-query. The snippets below assume the same Python session as Steps 2–7 — if you closed it, re-run the setup code from Steps 2 and 4 first; the index and documents are on disk, only the in-memory vectorizer needs rebuilding.

r.execute_command("BGSAVE")
print(r.info("persistence")["rdb_last_bgsave_status"])
r.execute_command("SHUTDOWN")
ok

A dump.kdb file — about 10.8 KB for these eight documents, their HNSW graph, and the vector set — now sits in the data directory. Restart the server against the same directory:

KIVIDB_NO_UPDATE_CHECK=1 ./kividb/kividb --dir ./data --port 6380 --loglevel notice

After the restart, the data and the index are both back — fifteen keys, counting the six demo keys from Step 2, the eight documents, and the vector set:

r = redis.Redis(host="127.0.0.1", port=6380, decode_responses=True)
print("keys:", r.dbsize())
print("num_docs:", r.execute_command("FT.INFO", "idx")["num_docs"])
print("KNN top-1:", knn("vector similarity search")[0][1])
keys: 15
num_docs: 8
KNN top-1: Vector search

Two persistence notes. First, snapshots save on SAVE/BGSAVE and on a graceful shutdown; a killed process keeps only what was last snapshotted. For write durability, start with --aof: every write is appended to appendonly.aof and replayed on startup, so a crash loses at most about a second of writes. Second, the HNSW graph itself is stored in the snapshot, so search quality after a restart matches the graph you had before shutdown — the KNN result above is the proof.

Bar chart of the four verified KNN queries and the cosine distance of each top hit
Measured in the sandbox: cosine distance of the top hit for each of the four test queries (lower is closer).

When to use KiviDB — and when not to#

NeedUseWhy
Cache + vector search in one processKiviDBOne binary, one protocol, no second service; the tutorial above is the whole stack.
Drop-in Redis replacement for an existing appKiviDB or ValkeyKiviDB reports as Redis 8.10 and passes 2,697 of its tests; Valkey is the BSD-licensed fork with the larger ecosystem.
Vector search at scale with payload filteringQdrantA dedicated vector database with richer filtering, quantization, and a bigger community; see the blog's Qdrant tutorial.
Maximum Redis throughput on many coresMeasure firstKiviDB's own benchmarks claim 21M pipelined GETs/s on 32 cores — but those are the project's numbers on its hardware, not an independent test. Benchmark your workload.
A battle-tested store for production data you cannot loseNot KiviDB yetIt is three weeks old. The 1.2.0 release fixed real ACL and credential-leak vulnerabilities; that is good maintenance and a reminder of its age.

Three more honest limits. The license is AGPL-3.0: fine for a service you run yourself, but read it before embedding KiviDB in something you distribute. Lua scripting and TLS live in separate builds (-lua, -tls, -full) — the default binary has neither. And cluster mode, replication, and the io_uring fast path exist in the docs but were not exercised here; this guide verifies the standalone server only.

The takeaway#

KiviDB 1.2.0 earns its spike on a simple proposition: the code you already write against Redis now gets vector search for free. One 6 MB download, one binary, one port — and the same redis-py calls you have used for years store documents, build an HNSW index, and answer KNN, full-text, hybrid, and vector-set queries. The project is young and its big performance numbers are its own, so do not move production data onto it this week. But for a local RAG cache, a prototype, or a side project that needs search without a second service, it is already the shortest path from zero to a working answer — and the answer survives a restart.

Verification note#

Verified in a sandbox on November 8–9, 2026: KiviDB 1.2.0 (release binary, SHA256 2d918e71…), redis-py 8.1.0, scikit-learn 1.9.1, numpy 2.5.3, Python 3.12.3. The download, checksum, server start, core Redis operations, index creation, all four KNN queries, the stemming check, the hybrid query, the vector-set query, the snapshot, and the restart reload all ran here, with the outputs quoted above. The first snapshot attempt failed on the sandbox's disk quota, not on KiviDB; moving the data directory outside the quota fixed it, and the retry succeeded. Not verified here: the project's own throughput benchmarks (quoted as theirs), cluster mode and replication, the redis-cli path (the same commands were run through redis-py), and the Lua/TLS builds.

Sources#