La recherche vectorielle dense excelle à faire correspondre l’intention et le concept : une requête comme comment annuler mon abonnement retrouvera un chunk disant résilier votre forfait même si les mots ne se recoupent presque pas, car les deux expressions se situent proches l’une de l’autre dans l’espace d’embedding. Cette même force explique pourquoi la recherche vectorielle peine sur tout ce où le jeton exact compte : références produit, codes d’erreur, numéros de pièce, noms de personnes ou acronymes. Un modèle d’embedding n’a aucun moyen fiable de garantir que INV-20487 doit correspondre à INV-20487 et rien d’autre, car il a été entraîné à généraliser le sens, pas à préserver des chaînes exactes.
Cet angle mort n’est pas un cas marginal dans la plupart des applications réelles ; il représente une part importante des requêtes réelles. Les tickets de support référencent des numéros de commande, la documentation technique référence des noms de fonctions et des clés de configuration, et la recherche juridique ou de conformité a besoin de références exactes de clauses et d’articles. Les équipes qui déploient une recherche uniquement vectorielle le découvrent régulièrement en production lorsqu’un utilisateur cherche un code d’erreur exact et obtient des résultats sémantiquement proches mais faux, car les plus proches voisins dans l’espace d’embedding sont conceptuellement similaires, pas lexicalement identiques.
La solution n’est pas de remplacer la recherche vectorielle mais de la faire tourner en parallèle d’une recherche lexicale classique (un classement de type BM25 via les fonctionnalités natives tsvector et les index GIN de Postgres) puis de fusionner les deux listes classées. C’est ce que signifie la recherche hybride en pratique, et la raison pour laquelle elle a naturellement sa place directement dans Postgres plutôt que dans un service de recherche séparé est que pgvector et tsvector peuvent partager la même ligne, la même transaction et les mêmes garanties de cohérence, ce qui élimine toute une classe de bugs de synchronisation entre une base primaire et un index de recherche externe.
2. Reciprocal Rank Fusion (RRF) en SQL pur
Combiner un score de similarité vectorielle et un score de type BM25 est plus difficile qu’il n’y paraît, car les deux scores vivent sur des échelles totalement différentes et non comparables : la distance cosinus est bornée entre 0 et 2, tandis que les scores ts_rank ne sont pas bornés et dépendent de la longueur du document et de la fréquence des termes. Moyenner ou pondérer naïvement ces deux scores bruts produit des résultats dominés par celui qui a, ce jour-là, la plage numérique la plus large, ce qui est fragile et difficile à raisonner.
Reciprocal Rank Fusion contourne entièrement ce problème en abandonnant les scores bruts et en ne travaillant qu’avec la position de rang : chaque document reçoit un score de fusion égal à 1 divisé par (k plus son rang) dans chaque liste de résultats, sommé sur toutes les listes, où k est une constante de lissage (60 est la valeur par défaut standard issue de l’article original sur RRF) qui atténue l’influence d’un rang isolé très élevé. Comme elle n’a besoin que de l’ordre, pas de l’amplitude, de chaque recherche sous-jacente, RRF se compose proprement quel que soit le nombre de listes classées, sans aucune étape de normalisation de score.
L’élégance pour une architecture basée sur Postgres est que RRF s’exprime comme une seule requête SQL utilisant deux CTE, une par méthode de recherche, chacune produisant un rang via ROW_NUMBER() OVER (ORDER BY ...), jointes par un FULL OUTER JOIN afin que les documents trouvés par une seule méthode ne soient pas écartés. La recherche hybride s’exécute donc entièrement dans la base de données, en un seul aller-retour, sans logique de fusion de scores à maintenir côté application.
3. Optimisation des index HNSW et GIN
Chaque volet d’une requête hybride a besoin de sa propre stratégie d’indexation, et se tromper sur l’un des deux domine le budget de latence quelle que soit l’efficacité de l’autre moitié. Pour le volet vectoriel, un index HNSW (graphe hiérarchique de petit monde navigable) est aujourd’hui le choix par défaut face à IVFFlat pour les charges dominées par la lecture : il offre un meilleur rappel à latence égale et n’a pas besoin d’être réentraîné périodiquement à mesure que le jeu de données grandit, au prix de constructions d’index plus lentes et plus gourmandes en mémoire, ce qui pèse sur l’ingestion mais pas sur la latence des requêtes.
Pour le volet textuel, un index GIN sur une colonne tsvector générée est le standard, et le détail opérationnel clé est d’en faire une colonne générée avec STORED afin que Postgres la maintienne automatiquement à l’insertion et à la mise à jour, plutôt que de s’appuyer sur des triggers ou du code applicatif pour la garder synchronisée. Les deux index coexistent sur la même table sans conflit, puisque l’un porte sur la colonne vectorielle et l’autre sur la colonne tsvector, et le planificateur de requêtes de Postgres utilisera les deux au sein du même plan d’exécution lorsque les CTE sont structurées correctement.
Les deux paramètres qui méritent un réglage explicite sont m de HNSW (connexions maximales par nœud du graphe, 16 par défaut, une valeur plus élevée améliore le rappel au prix de la taille de l’index et du temps de construction) et ef_search (taille de la liste de candidats au moment de la requête, 40 par défaut, une valeur plus élevée améliore le rappel au prix de la latence de requête) ; un point de départ raisonnable en production est m=16, ef_search=100, puis mesurer le rappel sur un jeu de requêtes étiquetées et n’augmenter ef_search que si le rappel est en deçà de l’objectif, car c’est le levier le moins coûteux à ajuster sans reconstruire l’index entièrement.
4. Pondération, filtrage et requêtes hybrides sensibles aux métadonnées
Le RRF pur traite par défaut les signaux vectoriel et textuel comme également importants, mais les applications réelles veulent en général privilégier l’un ou l’autre selon la forme de la requête : une requête ressemblant à un identifiant exact (une référence produit, un numéro de commande, une courte chaîne entre guillemets) devrait fortement pondérer la recherche textuelle, tandis qu’une question plus longue en langage naturel devrait pondérer davantage la recherche vectorielle. Cela s’implémente en multipliant la contribution RRF de chaque liste par un poids avant de sommer, ce qui préserve la formule de fusion tout en permettant d’ajuster l’équilibre par type de requête, voire par requête individuelle.
Le filtrage par métadonnées (restriction à un locataire, une plage de dates, un statut de document) doit être poussé dans les deux CTE sous forme de clause WHERE avant le LIMIT, et non appliqué en post-filtrage sur les résultats fusionnés, car filtrer après la fusion peut laisser moins de résultats que souhaité si la plupart des meilleurs candidats se retrouvent exclus. Appliquer le filtre à l’intérieur de chaque CTE permet aussi à Postgres d’utiliser un index combiné (partiel ou composite incluant la colonne de filtre) pour maintenir la requête rapide même sous filtrage.
Pour les applications SaaS multi-locataires en particulier, incluez toujours le filtre tenant_id dans les deux CTE de recherche et ajoutez-le comme colonne de tête d’un index composite aux côtés de l’index vectoriel ou GIN ; omettre cela est une erreur fréquente et coûteuse, car une recherche HNSW non filtrée sur les vecteurs de tous les locataires avant filtrage est à la fois plus lente et, si elle est mal gérée au niveau applicatif, un risque d’isolation des données.
5. Gérer l’ambiguïté des requêtes : quel signal privilégier
Toutes les requêtes ne bénéficient pas également des deux méthodes de recherche, et comprendre quel signal privilégier compte autant que de calculer les deux. Les requêtes courtes et riches en mots-clés (deux ou trois mots, sans structure de phrase naturelle) ont tendance à relever d’une intention lexicale et sont bien servies par le seul volet textuel ou une forte pondération en sa faveur, tandis que les requêtes longues et conversationnelles portent une intention sémantique que le volet vectoriel capte bien mieux qu’une correspondance de termes exacte.
Une heuristique pratique qui évite de construire un classificateur de requêtes séparé consiste à vérifier si websearch_to_tsquery ne renvoie aucun résultat : si le volet plein texte n’a strictement aucune correspondance, c’est un signal fort que la requête est purement sémantique (reformulée, conceptuelle, ou utilisant un vocabulaire différent de celui des documents source) et le classement fusionné devrait alors reposer entièrement sur l’ordre du volet vectoriel plutôt que de laisser le volet textuel vide contribuer silencieusement pour rien tout en coûtant quand même une requête.
Le cas inverse, où une requête ressemble à un code ou un identifiant exact et où la recherche textuelle renvoie une seule correspondance exacte, mérite un traitement spécial consistant à ignorer la contribution vectorielle et à faire remonter cette correspondance exacte en tête, car un utilisateur qui cherche INV-20487 veut cette ligne précise avant tout ce qui lui est conceptuellement proche, et payer une traversée HNSW dans ce cas est de la latence pure perte.
6. Avantage d’une base unifiée
Faire tourner la recherche hybride entièrement dans Postgres, plutôt que de l’associer à une base vectorielle dédiée ou un moteur de recherche comme Elasticsearch, supprime toute une catégorie de modes de défaillance propres aux systèmes distribués : il n’y a pas de second système à maintenir synchronisé, pas de problème de cohérence d’écriture double entre la base primaire et l’index de recherche, et pas de service supplémentaire à surveiller, sauvegarder et mettre à jour indépendamment. Pour la plupart des applications en deçà de la centaine de millions de vecteurs, cette simplicité opérationnelle l’emporte sur le plafond de performance marginal qu’une base vectorielle spécialisée pourrait offrir.
Cela signifie aussi que les résultats de recherche sont, par construction, cohérents transactionnellement avec le reste de vos données : un document qui vient d’être inséré ou mis à jour dans la même transaction est immédiatement recherchable, sans le délai de cohérence éventuelle le temps qu’un index séparé se mette à jour. Pour les applications où les résultats de recherche doivent refléter la toute dernière écriture, comme un outil d’administration interne ou un système de tickets de support, cette propriété peut à elle seule faire pencher la balance vers une approche unifiée en base de données.
Le compromis à assumer honnêtement est que l’implémentation HNSW de pgvector, bien que solide, n’est pas aussi agressivement optimisée que des bases vectorielles dédiées conçues pour de la recherche ANN à l’échelle du milliard, et des charges à très fort débit de requêtes avec des exigences strictes de latence à un chiffre en millisecondes finiront par dépasser une instance Postgres unique. L’approche pragmatique consiste à démarrer unifié dans Postgres, car c’est correct et simple par défaut, et à ne séparer un service vectoriel dédié qu’une fois que vous disposez de preuves concrètes (latence p99 mesurée ou plafonds de débit) que Postgres est le goulot d’étranglement, plutôt que de supposer ce plafond avant d’avoir des données.
7. Synthèse
La recherche hybride n’est pas un ajout exotique à une architecture RAG ou de recherche, c’est presque une exigence par défaut dès que votre corpus contient des identifiants, des codes, des noms, ou tout contenu où les échecs de correspondance exacte sont visibles et coûteux pour les utilisateurs. La recherche vectorielle seule sous-performe systématiquement sur cette classe de contenu, et la solution, RRF sur les résultats vectoriels et plein texte, est une technique bien comprise et peu coûteuse à mettre en œuvre, d’autant plus que Postgres fournit déjà les deux primitives nativement.
Le chemin concret vers la production est : ajouter une colonne tsvector générée avec un index GIN aux côtés de votre index pgvector HNSW existant, implémenter RRF comme une seule requête SQL paramétrée avec les filtres de métadonnées poussés dans les deux CTE, ajuster ef_search face à un benchmark de rappel étiqueté, et ajouter des heuristiques sur la forme de la requête (résultats plein texte vides, motifs d’identifiants exacts) pour biaiser les poids de fusion avant même d’envisager une infrastructure de recherche séparée.
Mesurez avant de chercher plus d’infrastructure : une instance Postgres correctement indexée exécutant une recherche hybride par RRF sert confortablement des latences de requête sous les 15 ms pour des centaines de milliers à quelques millions de documents sur du matériel modeste, ce qui couvre l’écrasante majorité des outils internes, des recherches produit SaaS et des backends de récupération RAG que les équipes d’ingénierie construisent réellement.
sql/hybrid_search_rrf.sqlsql
WITH vector_search AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank FROM documents ORDER BY embedding <=> $1 LIMIT 50),fulltext_search AS ( SELECT id, ROW_NUMBER() OVER ( ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('english', $2)) DESC ) AS rank FROM documents WHERE search_vector @@ websearch_to_tsquery('english', $2) LIMIT 50)SELECT COALESCE(v.id, f.id) AS id, COALESCE(1.0 / (60 + v.rank), 0.0) + COALESCE(1.0 / (60 + f.rank), 0.0) AS rrf_scoreFROM vector_search vFULL OUTER JOIN fulltext_search f ON v.id = f.idORDER BY rrf_score DESCLIMIT 10;
sql/hybrid_indexes.sqlsql
-- Index vectoriel : HNSW pour une recherche ANN a fort rappel et faible latenceCREATE INDEX documents_embedding_hnsw_idx ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);-- Index plein texte : colonne tsvector generee + GINALTER TABLE documents ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))) STORED;CREATE INDEX documents_search_vector_gin_idx ON documents USING gin (search_vector);-- Ajuster rappel/latence au niveau de la sessionSET hnsw.ef_search = 100;