7.4 KiB
#70 - Recherche sémantique - Embeddings vectoriels
Statut : ✅ Terminé — 100 % implémenté + 27 tests backend + 4 tests frontend (2026-09-12) Effort : 4-5 jours (réalisé) | Impact : 🟢 Références : Roadmap · Changelog
-
Fichiers clés :
backend/semantic_search.py- chunking, providers d'embeddings,VectorStore,SemanticIndex, RRF, hook incrémentalbackend/search.py- fusion RRF dansadvanced_search(..., semantic=True)+_fuse_semantic_results()backend/services/search.py- paramètresemanticdu servicebackend/main.py- paramètresemanticde/api/search/advanced, schémas,init_semantic_index()au démarragefrontend/js/search.js- toggle#rb-semantic, score de similarité, raccourciAlt+Sfrontend/js/state.js-semanticSearch,semanticAvailablefrontend/index.html- bouton#rb-semanticdans la barre de résultatsfrontend/locales/fr.json,frontend/locales/en.json- cléssearch.semantic_*backend/requirements-semantic.txt- dépendances optionnelles (sentence-transformers, numpy, faiss-cpu)tests/test_semantic_search.py- 27 tests backendtests/frontend/semantic-search.test.mjs- 4 tests JSDOM
-
Description : la recherche TF-IDF historique ne trouve que les documents contenant exactement les mots tapés. La recherche sémantique comprend le sens de la requête et retrouve des documents pertinents même formulés différemment (« comment sauvegarder mes données » remonte aussi « Stratégie de backup automatique »). Les deux classements sont fusionnés via RRF (Reciprocal Rank Fusion), ce qui combine précision lexicale et rappel sémantique.
Architecture
┌───────────────────────────────┐
indexer (watcher) │ on_index_change(action, …) │
─────────────────► │ SemanticIndex.add/remove │
└──────────────┬────────────────┘
│ chunks (512 mots, recouvrement 64)
▼
┌───────────────────────────────┐
│ EmbeddingProvider │
│ sentence-transformers │ API │
│ │ hash (fallback sans dep) │
└──────────────┬────────────────┘
│ vecteurs 384 dim, L2-normalisés
▼
┌───────────────────────────────┐
│ VectorStore (numpy / faiss │
│ / pur Python) — cosinus │
└──────────────┬────────────────┘
requête ──► embed ──► similarité ──┘
│
TF-IDF ranking ──────────► RRF ◄──┘ ──► résultats fusionnés (semantic_score)
Backend - backend/semantic_search.py
- Chunking :
chunk_text(text, chunk_tokens=512, overlap=64)découpe chaque document en fenêtres glissantes de 512 mots avec 64 mots de recouvrement, pour ne pas perdre le contexte aux frontières. - Providers d'embeddings (
EmbeddingProvider) :SentenceTransformerProvider— modèle localall-MiniLM-L6-v2(384 dim), chargé paresseusement. Actif sisentence-transformersest installé.RemoteEmbeddingProvider— endpoint/embeddingscompatible OpenAI (OBSIGATE_EMBEDDING_API_KEY,OBSIGATE_EMBEDDING_BASE_URL,OBSIGATE_EMBEDDING_MODEL).HashEmbeddingProvider— repli sans aucune dépendance : hachage signé déterministe des unigrammes, bigrammes et trigrammes de caractères (hashing trick) + normalisation L2. Il capture le vocabulaire partagé et les variantes morphologiques, mais pas les synonymes.- Sélection via
OBSIGATE_EMBEDDING_PROVIDER=auto|local|remote|hash(défautauto).
VectorStore: stocke les vecteurs par chunk. Accélération optionnelle numpy (produit matriciel) puis faiss (IndexFlatIP) ; sinon cosinus pur Python. Les vecteurs étant L2-normalisés, le produit scalaire vaut la similarité cosinus.SemanticIndex: singletonget_semantic_index().rebuild()construit tout depuisbackend.indexer.index;add_document()/remove_document()mettent à jour un document à chaud (appelés par le hookon_index_change).search()regroupe les chunks par document en gardant la meilleure similarité et filtre par vault.- RRF :
rrf_fuse(rankings, k=60)—score = Σ 1/(k + rang).
Backend - intégration recherche
advanced_search(..., semantic=True) :
- Le classement TF-IDF est calculé comme avant.
_fuse_semantic_results()récupère les hits sémantiques, les restreint aux mêmes filtres (vault, tags,title:,path:,ext:, dates, taille, include/exclude) et fusionne les deux classements par RRF. Les documents trouvés uniquement par le sémantique sont matérialisés depuis l'index inversé (snippet brut, sans<mark>).- Chaque résultat porte
semantic_score(similarité cosinus,0.0si absent) et la réponse exposesemantic_available.
Le mode sémantique est ignoré si regex=True (notion purement lexicale).
API
GET /api/search/advanced?...&semantic=true ajoute :
results[].semantic_score(float) ;semantic_available(bool) : l'index sémantique est prêt.
Frontend
- Bouton
~(#rb-semantic) dans la barre de résultats, actif seulement quandsemantic_availableest vrai (sinondisabled). - Raccourci clavier Alt+S ; l'état est conservé dans
state.semanticSearch. - Le badge de score affiche
score: … · sim: …quand un score sémantique existe. - Clés i18n
search.semantic_title,search.semantic_score,search.semantic_unavailable.
Configuration
| Variable | Défaut | Rôle |
|---|---|---|
OBSIGATE_EMBEDDING_PROVIDER |
auto |
auto, local, remote ou hash |
OBSIGATE_EMBEDDING_MODEL |
all-MiniLM-L6-v2 |
Modèle local ou nom de modèle distant |
OBSIGATE_EMBEDDING_API_KEY |
— | Clé du provider distant |
OBSIGATE_EMBEDDING_BASE_URL |
— | URL de base du provider distant |
OBSIGATE_EMBEDDING_DIM |
384 |
Dimension attendue pour le provider distant |
Dépendances optionnelles
pip install -r backend/requirements-semantic.txt (sentence-transformers + numpy + faiss-cpu).
Sans installation, la fonctionnalité reste opérationnelle grâce au provider hash et au
stockage pur Python : le CI n'installe que backend/requirements.txt.
Tests
tests/test_semantic_search.py(27) : chunking, provider hash (déterminisme, normalisation, similarité),VectorStore, RRF,SemanticIndex, hook incrémental,advanced_searchsémantique, endpoint API.tests/frontend/semantic-search.test.mjs(4) : toggle désactivé tant que l'index n'est pas prêt, bascule état + classe active, no-op si indisponible.