Un outil Hybrid Search pour ReAct

, par Bertrand Degoy

Un outil ReAct pour LlamaIndex qui effectue des recherches RAG via embedding-based retrieval.
A partir d’une question de l’utilisateur, c’est une approche en deux temps :
– On construit un outil ReAct prêt à brancher sur nos index RAG existants : un objet RetrieverTool qui exécute une recherche par embeddings, renvoie les passages top‑k formatés pour l’agent, et supporte le filtrage par métadonnées et hybrid search.
– On passe le résultat au LLM pour mettre la réponse en forme.

Nota : développement en cours

Contexte et Prérequis

Contexte :
– un serveur privé,
– le langage Python,
– les bibliothèques LlamaIndex et HugginFace,
– un LLM capable de fonctions (ex. : Mistral medium ou large),
– un modèle d’embeddings compatible (ex. : Sentence-BERT, OpenAI embeddings, BAAI Bge small etc.).

Prérequis :
Un index de documents construit avec nos outils : Ingestion : le RAG Manager, Le Web Service IngestWSG.

La construction de l’outil RetrieverTool

# retriever_tool.py
from typing import List, Dict, Any
from llama_index import Document

class RetrieverTool:
   def __init__(self, retriever, embedder=None, hybrid_fn=None):
       self.retriever = retriever
       self.embedder = embedder
       self.hybrid_fn = hybrid_fn

   def run(self, query: str, k: int = 5, metadata_filter: Dict[str,Any] = None, hybrid: bool = False) -> List[Dict]:
       # Optionnel : hybrid search combine lexical + vector
       if hybrid and self.hybrid_fn:
           hits = self.hybrid_fn(query, k=k, metadata_filter=metadata_filter)
       else:
           # LlamaIndex retriever: get top-k documents (API may be retrieve or get_relevant_documents)
           hits = self.retriever.retrieve(query, search_kwargs={"k": k, "filter": metadata_filter})
       results = []
       for doc in hits:
           text = getattr(doc, "text", str(doc))
           meta = getattr(doc, "metadata", {})
           # Optionnel : tronquer / extraire passage le plus pertinent
           snippet = text[:1000]
           results.append({"snippet": snippet, "metadata": meta, "source_id": meta.get("source_id")})
       return results

Filtrage par métadonnées

Le filtrage par métadonnées consiste à appliquer des contraintes booléennes ou de plage avant ou après la recherche vectorielle pour ne considérer que les documents pertinents au contexte métier. Exemples de métadonnées : source, langue, date, client_id, mention de confidentialité etc.

En pratique on peut :
• Pré‑filtrer l’ensemble indexé (ex. ) puis exécuter la recherche vectorielle sur ce sous‑ensemble ;
• Post‑filtrer les résultats vectoriels (réduire ou réordonner) si le moteur vectoriel ne supporte pas de filtre natif.
Avantage clé : réduit le bruit et évite que des documents hors‑contexte soient ramenés au LLM, ce qui diminue les hallucinations et le coût des appels LLM.

Patterns d’implémentation
• Filtres simples : dictionnaire transmis au retriever (beaucoup de vector stores supportent un paramètre filter).
• Filtres complexes : expressions booléennes ou fonctions de scoring métier (ex. boost si client_id correspond).
• Sécurité : appliquer les règles d’accès (ACL) en pré‑filtre pour éviter toute fuite d’information.

Bonnes pratiques :
• limiter le nombre de documents renvoyés (k = 3–10),
• renvoyer des snippets + métadonnées pour que l’agent puisse raisonner.

Hybrid Search

Le concept hybrid search désigne la combinaison d’une recherche lexicale (BM25/keyword) et d’une recherche sémantique (embeddings) pour améliorer précision et rappel.

Le hybrid search combine une recherche lexicale (BM25 ou TF‑IDF) et une recherche vectorielle (embeddings) pour tirer parti des forces complémentaires :
– BM25 excelle sur les correspondances exactes et mots‑clés,
- les embeddings capturent le sens et les synonymes.
Le flux typique consiste à exécuter BM25 et vector search séparément, fusionner les listes et reranker (ou reranker uniquement les top N).

Stratégies de fusion et pondération
– Score normalisé : normaliser scores BM25 et vectoriels (min‑max) puis combiner : `score = α * score_vector + (1-α) * score_bm25`. Ajuster `α` selon requête (courte → plus lexical).
– Ensemble / cascade : BM25 pour récupérer candidates (large), puis rerank par similarité vectorielle. Ou inversement si on veut donner la priorité au sémantique.
– Reranking par LLM : prendre top‑N fusionnés et demander au LLM de reranker ou d’extraire les passages clés.

Conseils pratiques et pièges à éviter
– Mesurer : A/B test avec métriques de rappel/precision et qualité des réponses LLM. 
– Coût : hybrid augmente les opérations (deux recherches) — cache les embeddings de requêtes et les résultats fréquents.
– Normalisation : tokenisation cohérente entre index lexical et embeddings.
– Sécurité et filtres : toujours appliquer ACL en pré‑filtre.

Exemple minimal (pseudocode)

bm25_hits = bm25.search(query, k=50)
vec_hits = vector_store.search(query_embedding, k=50, filter=meta_filter)
candidates = merge_and_dedup(bm25_hits, vec_hits)
for c in candidates: c.score = alpha*norm(c.vec_score)+(1-alpha)*norm(c.bm25_score)
top = sorted(candidates, key=lambda x: x.score, reverse=True)[:k]

exemple concret pour FAISS + BM25 (code prêt à brancher sur l’index) :

from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np

# documents: list of dicts {'id':int,'text':str,'meta':{...}}
docs = load_documents()  

# BM25
tokenized = [d['text'].split() for d in docs]
bm25 = BM25Okapi(tokenized)

# Embeddings + FAISS
model = SentenceTransformer('paraphrase-MiniLM-L6-v2')
embs = model.encode([d['text'] for d in docs]).astype('float32')
index = faiss.IndexFlatL2(embs.shape[1])
index.add(embs)
id_map = {i: docs[i]['id'] for i in range(len(docs))}
meta_map = {d['id']: d['meta'] for d in docs}

def hybrid_search(query, k=10, meta_filter=None, alpha=0.6):
   # BM25 candidates
   bm25_scores = bm25.get_scores(query.split())
   bm25_top = np.argsort(bm25_scores)[-200:][::-1]

   # Vector candidates (apply meta_filter by masking ids)
   q_emb = model.encode([query]).astype('float32')
   D, I = index.search(q_emb, 200)
   vec_candidates = I[0]

   # Merge and dedupe
   candidates = []
   seen = set()
   for idx in list(bm25_top) + list(vec_candidates):
       doc_id = id_map[idx]
       if meta_filter and not meta_matches(meta_map[doc_id], meta_filter):
           continue
       if doc_id in seen: continue
       seen.add(doc_id)
       candidates.append({
           'doc_id': doc_id,
           'bm25_score': float(bm25_scores[idx]),
           'vec_score': float(1.0 / (1.0 + D[0][list(vec_candidates).index(idx)]) ) if idx in vec_candidates else 0.0
       })

   # Normalize and combine
   bm = np.array([c['bm25_score'] for c in candidates])
   ve = np.array([c['vec_score'] for c in candidates])
   if len(bm)>0:
       bm_n = (bm - bm.min())/(bm.ptp()+1e-9)
       ve_n = (ve - ve.min())/(ve.ptp()+1e-9)
       for i,c in enumerate(candidates):
           c['score'] = alpha*ve_n[i] + (1-alpha)*bm_n[i]

   topk = sorted(candidates, key=lambda x: x['score'], reverse=True)[:k]
   return topk