Tous les articles

Recherche hybride dans PostgreSQL : BM25, pgvector, RRF et reranker

Pas de moteur de recherche à part. Une base PostgreSQL, deux classements, une fusion et un reranker, avec les réglages que nous faisons vraiment tourner.

Publié le 6 octobre 2026 · 9 min de lecture

La recherche vectorielle (ou sémantique) trouve un texte qui veut dire la même chose que votre question. Elle est mauvaise pour retrouver la chaîne exacte que vous avez tapée : un numéro de facture, un nom de famille, une version de bibliothèque. La recherche par mots-clés a le profil inverse. La recherche hybride lance les deux et fusionne les résultats. Pour une mémoire IA, où l'on pose les deux types de questions, c'est la différence entre « elle s'en souvient » et « elle a trouvé un truc vaguement lié ».

Pour les notions de base, notre article sur le fonctionnement d'une mémoire IA persistante les explique sans code. Celui-ci est le guide d'implémentation : la chaîne que Pryzm Memory fait tourner en production, dans une seule base PostgreSQL, avec le SQL, le code de fusion, les réglages retenus et les erreurs qui nous les ont fait retenir.

La chaîne en un coup d'œil

  1. Bras mots-clés. Recherche plein texte PostgreSQL avec un score de type BM25, 50 candidats.
  2. Bras vectoriel. pgvector avec un index HNSW sur des embeddings multilingues, au moins 50 candidats.
  3. Fusion. La fusion par rang réciproque (RRF, k = 60) combine les deux classements.
  4. Reranking. Un cross-encoder multilingue renote les 40 premiers.
  5. Fraîcheur. Un petit bonus décroissant pour les souvenirs récents, appliqué après le reranker.

Chaque étape s'ajoute facilement à un PostgreSQL existant. Le vrai travail est dans les détails qui suivent.

Le bras mots-clés : un score de type BM25 en SQL

PostgreSQL intègre la recherche plein texte, mais ses fonctions de classement ne sont pas du BM25 : elles ne pondèrent pas un terme selon sa rareté dans vos documents. Or c'est la rareté qui rend les mots-clés utiles à côté des vecteurs : un identifiant rare doit peser bien plus qu'un mot courant. Nous calculons donc nous-mêmes la fréquence inverse de document (IDF) :

SQL
-- Bras mots-clés : score de type BM25 (IDF, tf binaire), en SQL pur.
WITH terms AS (
  SELECT DISTINCT quote_literal(t)::tsquery AS tq
  FROM unnest(tsvector_to_array(
         to_tsvector('french', unaccent($1)))) AS t
),
docs AS MATERIALIZED (
  SELECT id, to_tsvector('french', unaccent(content)) AS v
  FROM memories
),
total AS (SELECT count(*)::float8 AS n FROM docs),
weights AS (
  SELECT terms.tq,
         ln(1 + (total.n - df.df + 0.5) / (df.df + 0.5)) AS idf
  FROM terms, total,
       LATERAL (SELECT count(*)::float8 AS df
                FROM docs WHERE docs.v @@ terms.tq) df
  WHERE df.df > 0
)
SELECT d.id, sum(w.idf) AS score
FROM docs d JOIN weights w ON d.v @@ w.tq
GROUP BY d.id
ORDER BY score DESC
LIMIT 50;

Trois choix de cette requête viennent de bugs, pas de la théorie :

  • OU, pas ET. Notre première version utilisait websearch_to_tsquery, qui exige tous les mots de la requête. Une question naturelle (« qu'avait-on décidé pour la page tarifs ? ») ne contient presque jamais que des mots présents dans un même souvenir : le bras mots-clés ne renvoyait rien. Découper la requête en termes et additionner leurs poids a réglé le problème.
  • IDF par utilisateur. Les souvenirs de chaque compte sont notés par rapport au corpus de ce compte. Un mot rare dans vos notes est informatif pour vous, quelle que soit sa fréquence chez quelqu'un d'autre.
  • Accents retirés des deux côtés. Nous indexions le texte avec ses accents et cherchions sans : les requêtes françaises rataient sans bruit. unaccent doit s'appliquer aux documents et à la requête, avec la même configuration de langue (ici, la racinisation française).

La fréquence des termes est binaire (un terme est présent ou non) : c'est donc un BM25 simplifié. Sur des souvenirs courts, la perte est faible. En production, stockez le tsvector dans une colonne indexée au lieu de le recalculer à chaque requête.

Le bras vectoriel : pgvector, HNSW et filtres

Les embeddings viennent de multilingual-e5-small (384 dimensions). Les modèles e5 attendent un préfixe, « query: » pour la question et « passage: » pour le texte stocké, et donnent de moins bons résultats sans, sans aucun message d'erreur. Les vecteurs vivent dans pgvector derrière un index HNSW, avec ef_search à 100.

Le piège, ce sont les filtres. Un index approximatif est parcouru d'abord et filtré ensuite : une recherche limitée à un projet peut donc renvoyer bien moins de lignes que demandé. pgvector 0.8 a ajouté les parcours itératifs, qui continuent jusqu'à ce qu'assez de lignes passent le filtre. Nous les utilisons en mode relaxed_order.

La fusion RRF : combiner des scores sans unité commune

Une distance cosinus et un score de mots-clés ne s'additionnent pas : ils ne sont pas sur la même échelle, et le second varie avec la taille du corpus. La fusion par rang réciproque ignore les scores bruts et n'utilise que les positions. Chaque document reçoit 1 / (k + rang) pour chaque liste où il apparaît, puis on trie les sommes :

Python
def rrf(dense_ids, lexical_ids, k=60):
    scores = {}
    for ranking in (dense_ids, lexical_ids):
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

k = 60 est la valeur de l'article d'origine, et nous l'avons gardée : elle réduit l'écart entre le rang 1 et le rang 5, si bien qu'un document correct dans les deux listes passe devant un document premier dans une seule. Demandez à chaque bras plus de candidats qu'il n'en faut (nous en prenons 50 de chaque) : la fusion n'aide que si les deux listes se recoupent un peu.

Le reranker : d'où vient l'essentiel de la qualité

Les deux premiers bras comparent une question et un document transformés en nombres chacun de leur côté. Un cross-encoder lit les deux ensemble et note à quel point ce document répond à cette question. Il est bien plus précis et bien plus lent : il ne voit donc que la présélection.

Python
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/mmarco-mMiniLMv2-L12-H384-v1",
                        max_length=128, device="cpu")

def rerank(query, fused, n=40):
    head, tail = fused[:n], fused[n:]
    scores = reranker.predict([(query, doc["content"]) for doc in head])
    for doc, s in zip(head, scores):
        doc["score"] = float(s)
    return sorted(head, key=lambda d: d["score"], reverse=True) + tail

Nous utilisons un cross-encoder multilingue, parce que nos utilisateurs écrivent en français, en allemand, en espagnol et d'autres langues : beaucoup de rerankers sont entraînés sur l'anglais seul et se dégradent ailleurs sans prévenir. L'entrée est coupée à 128 tokens, ce qui suffit pour un souvenir et limite le coût. Sur un serveur sans GPU, le reranker est de loin l'étape la plus lente : dimensionnez la présélection en conséquence.

Les dates : ce que le reranker ne voit pas

Une mémoire accumule des versions d'un même fait : « le lancement est le 3 », puis « le lancement passe au 5 ». Le reranker juge la pertinence et ignore les dates : les deux versions obtiennent des notes proches, et l'ancienne peut gagner. Nous ajoutons un bonus de fraîcheur décroissant après le reranking, avec une demi-vie de 180 jours :

Python
def with_recency(score, age_days, weight, half_life=180):
    return score + weight * 2 ** (-age_days / half_life)

Appliqué après le reranker, il ne fait que départager les quasi-égalités. Appliqué avant, il laisserait un souvenir récent mais hors sujet évincer la bonne réponse.

Quand le bras mots-clés échoue

La requête mots-clés tourne dans un SAVEPOINT. Si elle échoue (extension absente, configuration de langue non prise en charge), on revient au point de sauvegarde, on le journalise et on continue avec les seuls résultats vectoriels. Une recherche dégradée vaut mieux qu'une erreur au milieu d'une conversation.

Quand tout ceci est superflu

  • Quelques centaines de documents, une seule langue, des questions surtout conceptuelles : pgvector seul suffit.
  • Budget de latence serré sans GPU : commencez par la fusion, ajoutez le reranker quand vous pouvez mesurer ce qu'il change.
  • Très gros corpus ou nombreuses facettes : un moteur de recherche dédié vous servira mieux que du SQL.

Pour une mémoire IA, où la même personne demande « quel était le numéro de facture du client X ? » et « qu'est-ce qu'on pensait des tarifs ? » dans le même après-midi, chaque étape nous a paru utile. Si vous préférez l'utiliser plutôt que la construire, c'est cette chaîne qui répond quand Claude ou ChatGPT interrogent Pryzm Memory : voir notre comparatif des serveurs MCP de mémoire.

Questions fréquentes

Qu'est-ce que la recherche hybride ?

C'est lancer une recherche par mots-clés et une recherche vectorielle (sémantique) sur les mêmes documents, puis fusionner les deux classements, le plus souvent par fusion de rang réciproque. Elle retrouve à la fois les chaînes exactes et les reformulations.

Quelle différence entre recherche vectorielle et recherche sémantique ?

Dans la pratique, les deux termes désignent la même chose : le texte est transformé en vecteurs (embeddings) et la recherche renvoie les plus proches en sens. « Vectorielle » décrit la technique, « sémantique » le résultat.

PostgreSQL gère-t-il BM25 ?

Pas nativement : ses fonctions de classement plein texte n'utilisent pas la fréquence inverse de document. On peut calculer un score de type BM25 en SQL, comme ci-dessus, ou passer par une extension.

Un reranker vaut-il la latence ajoutée ?

C'est généralement l'étape qui améliore le plus les résultats, mais aussi la plus lente. Ne l'appliquez qu'à une présélection (nous prenons 40 candidats) et mesurez sur vos propres données.