Valkey: Vector search and RAG

Semantic search, recommendations, and retrieval-augmented generation with Valkey

👋 Welcome to the Stackhero documentation!

Stackhero offers a ready-to-use Valkey cloud solution that delivers numerous advantages, including:

  • Valkey Admin web UI included.
  • Unlimited message size and transfers.
  • One-click updates for easy maintenance.
  • Optimal performance and enhanced security thanks to a private, dedicated infrastructure.

Save time and make your life easier: it only takes 5 minutes to try Stackhero's Valkey cloud hosting solution!

If you are developing semantic search, a recommendation engine, or a retrieval-augmented generation (RAG) pipeline, you need to store embeddings and quickly retrieve the closest matches for a query. Stackhero for Valkey handles this natively: the search module indexes vectors and responds to similarity queries in milliseconds. There is no need to set up a separate vector database in addition to the Valkey service you already use.

Two features make this approach practical and efficient:

  • Hybrid queries. You can perform vector similarity searches and apply standard filters (such as numeric ranges, tags, or text) in a single request. For example, you can retrieve "the 5 chunks closest to this question, but only from documents this user is allowed to read" with a single query, not two.
  • One less component to manage. Your embeddings are stored alongside your cache and session data, using the same credentials, backups, and monitoring tools. This keeps your stack simpler and easier to manage.

Enable the Search module (and JSON if you want to store your documents as JSON) in the Modules section of your service configuration on the Stackhero dashboard. For more details, see the search and JSON guide.

Declare a vector field by specifying its dimension and distance metric. The dimension should match your model: for example, 1536 for OpenAI text-embedding-3-small, or 768 for many open source models.

FT.CREATE chunksIndex
  ON HASH
  PREFIX 1 chunk:
  SCHEMA
    content TEXT
    documentId TAG
    embedding VECTOR HNSW 6
      TYPE FLOAT32
      DIM 1536
      DISTANCE_METRIC COSINE

HNSW creates a graph index, allowing for fast approximate searches even as your collection grows large. This is recommended for collections larger than a few thousand vectors. For small collections, you can use FLAT for exact, brute-force search.

COSINE is generally the best distance metric for text embeddings. L2 and IP metrics are also supported.

A K-nearest neighbors (KNN) query passes the vector as a parameter, using raw little-endian float32 bytes:

FT.SEARCH chunksIndex "*=>[KNN 5 @embedding $queryVector]"
  PARAMS 2 queryVector "<rawBytes>"

To combine vector search with filters, simply replace the * with your filter expression:

FT.SEARCH chunksIndex "@documentId:{doc42}=>[KNN 5 @embedding $queryVector]"
  PARAMS 2 queryVector "<rawBytes>"

Valkey uses the same protocol as Redis, so you can use any Redis-compatible client.

import { createClient, SCHEMA_FIELD_TYPE, SCHEMA_VECTOR_FIELD_ALGORITHM } from 'redis';

const client = createClient({ url: process.env.STACKHERO_VALKEY_URL_TLS });
await client.connect();

const DIMENSIONS = 1536;
const toBytes = embedding => Buffer.from(new Float32Array(embedding).buffer);

// 1. Create the index once, at startup
try {
  await client.ft.create(
    'chunksIndex',
    {
      content: SCHEMA_FIELD_TYPE.TEXT,
      documentId: SCHEMA_FIELD_TYPE.TAG,
      embedding: {
        type: SCHEMA_FIELD_TYPE.VECTOR,
        ALGORITHM: SCHEMA_VECTOR_FIELD_ALGORITHM.HNSW,
        TYPE: 'FLOAT32',
        DIM: DIMENSIONS,
        DISTANCE_METRIC: 'COSINE'
      }
    },
    { ON: 'HASH', PREFIX: 'chunk:' }
  );
}
catch (error) {
  if (!error.message.includes('Index already exists')) {
    throw error;
  }
}

// 2. Index your chunks (embedding comes from your model provider)
async function indexChunk({ id, documentId, content, embedding }) {
  await client.hSet(`chunk:${id}`, {
    content,
    documentId,
    embedding: toBytes(embedding)
  });
}

// 3. Retrieve the chunks closest to a question, restricted to one document
async function retrieve({ questionEmbedding, documentId, count = 5 }) {
  const results = await client.ft.search(
    'chunksIndex',
    `@documentId:{${documentId}}=>[KNN ${count} @embedding $queryVector]`,
    { PARAMS: { queryVector: toBytes(questionEmbedding) } }
  );

  return results.documents.map(({ value }) => value.content);
}

// 4. Feed them to your model as context
const context = await retrieve({ questionEmbedding, documentId: 'doc42' });
const prompt = `Answer using only this context:\n\n${context.join('\n\n')}\n\nQuestion: ${question}`;
import os
import numpy as np
import redis
from redis.commands.search.field import TextField, TagField, VectorField
from redis.commands.search.index_definition import IndexDefinition, IndexType
from redis.commands.search.query import Query

DIMENSIONS = 1536

r = redis.from_url(os.environ['STACKHERO_VALKEY_URL_TLS'])

# Create the index once, at startup
try:
    r.ft('chunksIndex').create_index(
        (
            TextField('content'),
            TagField('documentId'),
            VectorField(
                'embedding',
                'HNSW',
                {'TYPE': 'FLOAT32', 'DIM': DIMENSIONS, 'DISTANCE_METRIC': 'COSINE'},
            ),
        ),
        definition=IndexDefinition(prefix=['chunk:'], index_type=IndexType.HASH),
    )
except redis.ResponseError as error:
    if 'Index already exists' not in str(error):
        raise


def index_chunk(chunk_id, document_id, content, embedding):
    r.hset(
        f'chunk:{chunk_id}',
        mapping={
            'content': content,
            'documentId': document_id,
            'embedding': np.array(embedding, dtype=np.float32).tobytes(),
        },
    )


def retrieve(question_embedding, document_id, count=5):
    query = Query(f'@documentId:{{{document_id}}}=>[KNN {count} @embedding $queryVector]')
    results = r.ft('chunksIndex').search(
        query,
        query_params={'queryVector': np.array(question_embedding, dtype=np.float32).tobytes()},
    )
    return [document.content for document in results.docs]

Vectors are stored in memory, so it is important to anticipate their footprint. The estimate for float32 vectors is:

numberOfVectors x dimensions x 4 bytes, plus about 30 to 50 percent extra for the HNSW graph.

For example, one million 1536-dimension vectors require about 6 GB for the raw vectors. A 20 GB plan is a comfortable starting point. You can reduce memory usage by choosing a model with fewer dimensions: a 768-dimension model uses only half the memory of a 1536-dimension one.

You can check actual memory usage with FT.INFO chunksIndex and with the used_memory metric in your Prometheus monitoring. If you need more memory, you can upgrade your plan at any time from your dashboard.

  • Indexing existing keys runs in the background. You can track progress with FT.INFO. Searches will return partial results until backfilling is complete.
  • The search module implements a focused set of FT.* commands. For vector search, it covers all essentials: HNSW and exact KNN, hybrid filtering, and FT.AGGREGATE.

For full documentation, see the Valkey search documentation.