# #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](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) - **Fichiers clés :** - `backend/semantic_search.py` - chunking, providers d'embeddings, `VectorStore`, `SemanticIndex`, RRF, hook incrémental - `backend/search.py` - fusion RRF dans `advanced_search(..., semantic=True)` + `_fuse_semantic_results()` - `backend/services/search.py` - paramètre `semantic` du service - `backend/main.py` - paramètre `semantic` de `/api/search/advanced`, schémas, `init_semantic_index()` au démarrage - `frontend/js/search.js` - toggle `#rb-semantic`, score de similarité, raccourci `Alt+S` - `frontend/js/state.js` - `semanticSearch`, `semanticAvailable` - `frontend/index.html` - bouton `#rb-semantic` dans la barre de résultats - `frontend/locales/fr.json`, `frontend/locales/en.json` - clés `search.semantic_*` - `backend/requirements-semantic.txt` - dépendances **optionnelles** (sentence-transformers, numpy, faiss-cpu) - `tests/test_semantic_search.py` - 27 tests backend - `tests/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 local `all-MiniLM-L6-v2` (384 dim), chargé paresseusement. Actif si `sentence-transformers` est installé. - `RemoteEmbeddingProvider` — endpoint `/embeddings` compatible 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éfaut `auto`). - **`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`** : singleton `get_semantic_index()`. `rebuild()` construit tout depuis `backend.indexer.index` ; `add_document()` / `remove_document()` mettent à jour un document à chaud (appelés par le hook `on_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)` : 1. Le classement TF-IDF est calculé comme avant. 2. `_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 ``). 3. Chaque résultat porte `semantic_score` (similarité cosinus, `0.0` si absent) et la réponse expose `semantic_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 quand `semantic_available` est vrai (sinon `disabled`). - 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_search` sé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.