feat(search): recherche semantique - embeddings vectoriels + hybride RRF (#70)
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# #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.
|
||||
Reference in New Issue
Block a user