Files
ObsiGate/docs/features/semantic-search.md
T

129 lines
7.4 KiB
Markdown

# #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 `<mark>`).
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.