Skip to content

Memory and a semantic cache for AI agents on Valkey or Redis

By KVNestUpdated

GET use-cases:ai-agent-memory

One Valkey or Redis instance can hold an agent's chat history as JSON, its long-term memory as an HNSW vector index, and a semantic cache that skips the LLM call for a question already answered. On KVNest this needs the JSON, Bloom and Search modules, which you turn on per instance.

On this page

Use JSON documents for chat history, an FT.CREATE HNSW index for long-term memory, and a second index as a semantic cache. One instance does all three. You need an embedding model and an instance with modules on; the code is Node.js with iovalkey, and VALKEY_URL is the rediss:// URL from your instance page.

Turn on modules

Modules are off by default. Create the instance with modules: true, or flip the switch on its Settings tab. Turning them on restarts the instance. An agent can do it too: the MCP server has 13 tools, including create_instance and configure_instance.

KVNest loads JSON, Bloom and Search on both engines. TimeSeries is Redis only. The command syntax below ran on Valkey 9.1 and on Redis 8.10 without changes.

import Valkey from "iovalkey";
import { randomUUID, createHash } from "node:crypto";

const client = new Valkey(process.env.VALKEY_URL);
const DIM = 384; // must match your embedding model

// embed(text) -> number[DIM]: call your embedding model here.
const blob = (v) => Buffer.from(new Float32Array(v).buffer);

Short-term memory

One JSON document per conversation, with a TTL so abandoned chats clean themselves up.

const chat = "chat:alice:c1";
await client.call("JSON.SET", chat, "$", JSON.stringify({ messages: [] }));
await client.call("JSON.ARRAPPEND", chat, "$.messages",
  JSON.stringify({ role: "user", content: "Hi" }));
await client.expire(chat, 86400);

const history = await client.call("JSON.GET", chat, "$.messages");

Long-term memory

The index

Store each memory as a JSON document with its text, an owner and the embedding as an array of floats. The user field is a TAG so you can filter on it. AS names each JSON path, which both engines require.

await client.call(
  "FT.CREATE", "idx:memory", "ON", "JSON", "PREFIX", "1", "mem:",
  "SCHEMA",
  "$.user", "AS", "user", "TAG",
  "$.embedding", "AS", "embedding", "VECTOR", "HNSW", "6",
  "TYPE", "FLOAT32", "DIM", String(DIM), "DISTANCE_METRIC", "COSINE",
);

async function remember(user, text) {
  await client.call("JSON.SET", `mem:${randomUUID()}`, "$",
    JSON.stringify({ user, text, embedding: await embed(text) }));
}

Recall, filtered by user

@user:{alice} narrows the search before the nearest-neighbor step, so one user never sees another's memories. TAG values with punctuation need escaping, so use plain ids. DIALECT 2 is required on Redis and the only dialect Valkey accepts.

function rows(reply) { // [total, key, [field, value, ...], key, [...]]
  const out = [];
  for (let i = 1; i < reply.length; i += 2) {
    const f = reply[i + 1].map(String);
    out.push(Object.fromEntries(f.flatMap((x, j) => (j % 2 ? [] : [[x, f[j + 1]]]))));
  }
  return out;
}

async function recall(user, query, k = 3) {
  const reply = await client.callBuffer(
    "FT.SEARCH", "idx:memory",
    `@user:{${user}}=>[KNN ${k} @embedding $vec AS dist]`,
    "PARAMS", "2", "vec", blob(await embed(query)),
    "RETURN", "4", "$.text", "AS", "text", "dist",
    "DIALECT", "2",
  );
  return rows(reply).sort((a, b) => a.dist - b.dist);
}

Where the two engines differ: Redis does not return KNN hits in distance order by default, and Valkey rejects SORTBY dist on a JSON index. Sorting in code works on both. COSINE distance is 0 for identical vectors and grows as they diverge.

Semantic cache

Embed the prompt and look up the nearest cached one. Under your threshold, return its answer; otherwise call the LLM and store the result with a TTL. Start near 0.1 and tune it on your own prompts: too high returns wrong answers, too low never hits.

await client.call(
  "FT.CREATE", "idx:cache", "ON", "JSON", "PREFIX", "1", "cache:",
  "SCHEMA", "$.embedding", "AS", "embedding", "VECTOR", "HNSW", "6",
  "TYPE", "FLOAT32", "DIM", String(DIM), "DISTANCE_METRIC", "COSINE",
);

async function cachedAnswer(prompt, callLlm, threshold = 0.1) {
  const vec = await embed(prompt);
  const reply = await client.callBuffer(
    "FT.SEARCH", "idx:cache", "*=>[KNN 1 @embedding $vec AS dist]",
    "PARAMS", "2", "vec", blob(vec),
    "RETURN", "4", "$.response", "AS", "response", "dist",
    "DIALECT", "2",
  );
  const [best] = rows(reply);
  if (best && Number(best.dist) < threshold) return best.response;

  const response = await callLlm(prompt);
  const key = `cache:${randomUUID()}`;
  await client.call("JSON.SET", key, "$", JSON.stringify({ prompt, response, embedding: vec }));
  await client.expire(key, 3600);
  return response;
}

Skip duplicates with a Bloom filter

BF.ADD returns 1 for a new item and 0 if it was probably seen. A false positive skips a new document, at the rate you reserve.

await client.call("BF.RESERVE", "seen:docs", "0.01", "100000");
const digest = (s) => createHash("sha256").update(s).digest("hex");
const isNew = (await client.call("BF.ADD", "seen:docs", digest(text))) === 1;

Forgetting a user

Delete a user's memories through the index, then their chats by key pattern. A Bloom filter cannot remove one item, so store only hashes in it.

async function forget(user) {
  for (;;) {
    const [, ...keys] = await client.call(
      "FT.SEARCH", "idx:memory", `@user:{${user}}`,
      "NOCONTENT", "LIMIT", "0", "500", "DIALECT", "2");
    if (!keys.length) break;
    await client.del(...keys);
  }
  for await (const keys of client.scanStream({ match: `chat:${user}:*` })) {
    if (keys.length) await client.del(...keys);
  }
}

Sizing

A 384-dimension FLOAT32 vector is 1,536 bytes raw. Indexed JSON costs more, because the array is also stored as text and HNSW adds graph links, so load a sample and read INFO memory. Indexes count against maxmemory: Starter has 192 MB of data and 0.1 vCPU, so large index builds are best effort.

Where KVNest falls short

See the Node.js and Python guides, pricing, and the EU managed Redis and Valkey comparison.

FAQ

Can Valkey or Redis replace a vector database?

For a small corpus, yes: the instance stores vectors, indexes them with HNSW or FLAT, and filters by tags and numbers. It is held in memory, so it suits thousands to low millions of vectors, not billions.

Does Valkey support vector search?

Yes, through the Valkey Search module (FT.CREATE and FT.SEARCH, HNSW and FLAT, hash and JSON data). KVNest loads it from the valkey-bundle image when modules are on.

What is a semantic cache?

A cache keyed by meaning instead of exact text. You embed each prompt, look up the nearest earlier prompt, and return its stored answer if the distance is under a threshold.

How many embeddings fit in 256 MiB?

Starter allows 192 MB of data. A 384-dimension FLOAT32 vector is 1,536 bytes, but a JSON document also stores the numbers as text and the index adds its own copy and graph, so measure with INFO memory on your own data.

Is it GDPR-friendly to keep chat history in a cache?

The data stays in Germany, you can expire it with a TTL, and you can delete one user's keys on request. Whether that meets your obligations is a legal question; this is not legal advice.

Sources

All 6 sources
  1. [1]Valkey Search overview (valkey.io), checked 5 Nov 2026.
  2. [2]FT.CREATE, Valkey documentation (valkey.io), checked 5 Nov 2026.
  3. [3]FT.SEARCH, Valkey documentation (valkey.io), checked 5 Nov 2026.
  4. [4]Vector search concepts, Redis Query Engine (redis.io), checked 5 Nov 2026.
  5. [5]Valkey JSON (valkey.io), checked 5 Nov 2026.
  6. [6]Valkey Bloom filters (valkey.io), checked 5 Nov 2026.

Try KVNest: free managed Redis or Valkey

A free Starter instance in Germany during the public preview, no card needed. Redis 8.10 or Valkey 9.1, your pick.