Valkey: Recherche vectorielle et RAG

Recherche sémantique, recommandations et retrieval-augmented generation avec Valkey

👋 Bienvenue sur la documentation de Stackhero !

Stackhero propose une solution Valkey cloud prête à l'emploi qui offre de nombreux avantages, notamment :

  • Interface web Valkey Admin incluse.
  • Taille des messages et transferts illimités.
  • Mises à jour simplifiées en un clic.
  • Performance optimale et sécurité renforcée grâce à une infrastructure privée et dédiée.

Gagnez du temps et simplifiez-vous la vie : il suffit de 5 minutes pour essayer la solution Valkey cloud hosting de Stackhero !

Si vous développez une recherche sémantique, un moteur de recommandations ou un pipeline de retrieval-augmented generation (RAG), vous devez stocker des embeddings et retrouver rapidement les correspondances les plus proches pour une requête. Stackhero pour Valkey gère cela nativement : le module de recherche indexe les vecteurs et répond aux requêtes de similarité en quelques millisecondes. Il n'est pas nécessaire de mettre en place une base de données vectorielle séparée en plus du service Valkey que vous utilisez déjà.

Deux fonctionnalités rendent cette approche pratique et efficace :

  • Requêtes hybrides. Vous pouvez effectuer des recherches de similarité vectorielle et appliquer des filtres classiques (plages numériques, tags ou texte) en une seule requête. Par exemple, il est possible de récupérer "les 5 segments les plus proches de cette question, mais uniquement parmi les documents que cet utilisateur est autorisé à lire" en une seule requête, sans avoir à en faire deux.
  • Un composant en moins à gérer. Vos embeddings sont stockés avec vos données de cache et de session, en utilisant les mêmes identifiants, sauvegardes et outils de supervision. Cela simplifie votre stack et facilite la gestion.

Activez le module Search (et JSON si vous souhaitez stocker vos documents au format JSON) dans la section Modules de la configuration de votre service sur le tableau de bord Stackhero. Pour plus de détails, consultez le guide search et JSON.

Déclarez un champ vectoriel en précisant sa dimension et la métrique de distance. La dimension doit correspondre à votre modèle : par exemple, 1536 pour OpenAI text-embedding-3-small, ou 768 pour de nombreux modèles open source.

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 crée un index graphique, permettant des recherches approximatives rapides même lorsque votre collection devient volumineuse. C'est la méthode recommandée pour les collections de plus de quelques milliers de vecteurs. Pour de petites collections, vous pouvez utiliser FLAT pour une recherche exacte et exhaustive.

COSINE est généralement la meilleure métrique de distance pour les embeddings de texte. Les métriques L2 et IP sont également prises en charge.

Une requête K-nearest neighbors (KNN) transmet le vecteur en paramètre, sous forme de bytes float32 little-endian bruts :

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

Pour combiner recherche vectorielle et filtres, il suffit de remplacer le * par votre expression de filtre :

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

Valkey utilise le même protocole que Redis, vous pouvez donc utiliser n'importe quel client compatible Redis.

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. Créez l'index une seule fois, au démarrage
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. Indexez vos segments (embedding fourni par votre modèle)
async function indexChunk({ id, documentId, content, embedding }) {
  await client.hSet(`chunk:${id}`, {
    content,
    documentId,
    embedding: toBytes(embedding)
  });
}

// 3. Récupérez les segments les plus proches d'une question, restreints à un 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. Fournissez-les à votre modèle comme contexte
const context = await retrieve({ questionEmbedding, documentId: 'doc42' });
const prompt = `Répondez uniquement en utilisant ce contexte :\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'])

# Créez l'index une seule fois, au démarrage
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]

Les vecteurs sont stockés en mémoire, il est donc important d'anticiper leur empreinte. L'estimation pour des vecteurs float32 est la suivante :

numberOfVectors x dimensions x 4 bytes, plus environ 30 à 50 % supplémentaires pour le graphe HNSW.

Par exemple, un million de vecteurs de 1536 dimensions nécessitent environ 6 Go pour les vecteurs bruts. Une offre de 20 Go constitue un point de départ confortable. Vous pouvez réduire la consommation mémoire en choisissant un modèle avec moins de dimensions : un modèle à 768 dimensions utilise seulement la moitié de la mémoire d'un modèle à 1536 dimensions.

Vous pouvez vérifier la consommation mémoire réelle avec FT.INFO chunksIndex et avec la métrique used_memory dans votre supervision Prometheus. Si vous avez besoin de plus de mémoire, vous pouvez à tout moment faire évoluer votre offre depuis votre tableau de bord.

  • L'indexation des clés existantes s'effectue en arrière-plan. Vous pouvez suivre la progression avec FT.INFO. Les recherches renverront des résultats partiels tant que le remplissage n'est pas terminé.
  • Le module de recherche implémente un ensemble ciblé de commandes FT.*. Pour la recherche vectorielle, il couvre l'essentiel : HNSW et KNN exact, filtrage hybride et FT.AGGREGATE.

Pour une documentation complète, consultez la documentation Valkey search.