Construire un Retriever Hybride pour 16 894 fichiers Obsidian
Un grep à travers 16 894 fichiers markdown prend 11 à 66 secondes selon le terme et renvoie des centaines de résultats peu pertinents. Une recherche vectorielle renvoie du contenu sémantiquement lié mais manque le nom exact de la fonction que vous avez tapé. Un retriever hybride qui fusionne les deux méthodes renvoie la bonne réponse en 23 millisecondes (de bout en bout, embedding de la requête inclus) à partir d’un seul fichier SQLite de 83 Mo sans aucun appel API.1
Le problème du preneur de notes obsessionnel n’est pas la collecte. Le problème, c’est la recherche. Obsidian rend la capture sans friction. Accumulez suffisamment de fichiers et le coffre-fort devient une base de données en écriture seule : facile à alimenter, impossible à interroger. La recherche par nom de fichier fonctionne jusqu’à ce que les noms de fichiers deviennent insignifiants. La recherche en texte intégral fonctionne jusqu’à ce que le même mot-clé apparaisse dans 400 documents. Les tags fonctionnent jusqu’à ce que vous oubliiez d’en ajouter un.
Un commentateur sur HN a demandé l’architecture complète derrière le système de recherche que j’ai construit pour mon coffre-fort Obsidian.2 La voici : la stratégie de découpage, le modèle d’embedding, le schéma SQLite à double index, les calculs de fusion avec des chiffres réels, et les modes de défaillance que j’ai découverts après avoir interrogé le système des centaines de fois.
TL;DR
Le retriever combine la recherche par mots-clés FTS5 BM25 avec la recherche par similarité vectorielle Model2Vec, fusionnées via Reciprocal Rank Fusion (RRF) en une seule liste ordonnée. Tout s’exécute localement dans une seule base de données SQLite : 49 746 chunks issus de 16 894 fichiers dans 83 Mo. Une réindexation complète prend quatre minutes. Les mises à jour incrémentales s’exécutent en moins de dix secondes. Le système s’intègre à Claude Code via des hooks, donnant à l’agent accès aux connaissances du coffre-fort sans charger les fichiers en contexte. BM25 capture les identifiants exacts et les noms de fonctions. La recherche vectorielle capture les correspondances sémantiques à travers différentes terminologies. RRF fusionne les deux sans nécessiter de calibration des scores. Le compromis honnête : un contenu superficiel bien taggé peut surpasser un contenu approfondi mal structuré, car BM25 récompense la densité de mots-clés, pas la profondeur.
Points clés
Pour les preneurs de notes avec de grands coffres-forts. D’après mon expérience, la recherche en texte intégral seule est devenue inutilisable au-delà de quelques milliers de fichiers — et les plugins de recherche Obsidian existants (Smart Connections, Omnisearch) indexent au sein de l’application, pas en tant que bibliothèque externe que d’autres outils peuvent interroger.1 Ajouter la recherche vectorielle par-dessus BM25 capture les requêtes où vous vous souvenez du concept mais pas du mot-clé. Le retriever fonctionne entièrement sur SQLite sans services externes, sans GPU, et sans coûts API. Model2Vec génère les embeddings à vitesse CPU car le modèle est constitué de 30 Mo de vecteurs de mots statiques, pas un transformer.3
Pour les développeurs construisant des systèmes de recherche. RRF est la méthode de fusion qui nécessite le moins de réglages. La formule utilise uniquement les positions de rang, pas les scores bruts, vous n’avez donc jamais besoin de calibrer les scores BM25 par rapport aux distances cosinus. Commencez avec k=60 et des poids égaux. N’ajustez qu’après avoir mesuré les cas d’échec sur vos propres données. L’extension sqlite-vec apporte la recherche vectorielle KNN dans SQLite sans base de données vectorielle séparée.4
Pour les utilisateurs de Claude Code. Le retriever fonctionne comme une bibliothèque que les hooks peuvent appeler. Un hook PreToolUse interroge le coffre-fort avant que l’agent ne commence à travailler. L’agent voit 2-3 Ko de résultats ciblés avec l’attribution du chemin de fichier au lieu de charger des fichiers entiers. L’intégration maintient les fenêtres de contexte compactes tout en donnant à l’agent accès à 16 894 fichiers de connaissances.
Version minimum viable. Le point de départ le plus simple : créez une table virtuelle FTS5 sur vos fichiers markdown (BM25 uniquement, sans embeddings). Ajoutez sqlite-vec et Model2Vec quand la recherche par mots-clés commence à manquer les correspondances sémantiques. Ajoutez la fusion RRF en dernier. Chaque couche fonctionne indépendamment. La pile complète nécessite Python 3, un téléchargement de modèle de 30 Mo, et pip install model2vec sqlite-vec. Pas de GPU, pas de Docker, pas de services externes. Empreinte disque totale pour 16 894 fichiers : 83 Mo.
Vous voulez le guide opérationnel complet ? La référence Obsidian AI Infrastructure couvre l’architecture du coffre-fort, la configuration des plugins, la mise en place du serveur MCP, les recettes d’indexation incrémentale et le dépannage — le compagnon pas à pas de l’analyse architecturale approfondie de cet article.
Pourquoi la recherche par mots-clés seule échoue à grande échelle
La recherche en texte intégral se dégrade à l’échelle du coffre-fort de manière prévisible. FTS5 avec le classement BM25 excelle dans les correspondances exactes : recherchez requestAnimationFrame et chaque fichier contenant ce token exact apparaît, classé par fréquence du terme et longueur du document.5 L’étude de Robertson et Zaragoza sur les modèles de pertinence probabiliste confirme la force de BM25 : l’algorithme performe bien sur les requêtes riches en mots-clés avec un réglage minimal des paramètres.14 Le mode de défaillance, ce sont les synonymes et la correspondance conceptuelle. Recherchez « how to handle authentication failures » et BM25 renvoie chaque fichier mentionnant « authentication » ou « failures » individuellement, diluant les résultats avec du contenu tangentiellement lié.
La recherche vectorielle résout le problème des synonymes. Encodez la requête et trouvez les chunks dont les embeddings sont proches dans l’espace vectoriel. « How to handle authentication failures » correspond à du contenu sur « login error recovery » et « session expiration handling » car l’embedding capture la similarité sémantique à travers différentes terminologies.6 Karpukhin et al. ont démontré avec Dense Passage Retrieval (DPR) que les embeddings denses surpassent BM25 en question-réponse en domaine ouvert de 9-19 % en précision top-20, précisément parce que les représentations denses capturent le sens au-delà du chevauchement lexical.15 Le mode de défaillance est l’inverse : la recherche vectorielle manque les identifiants exacts. Recherchez le nom de fonction _rrf_fuse et la recherche vectorielle renvoie du contenu sur les algorithmes de fusion et de classement mais peut classer la définition réelle de la fonction en dessous d’une explication conceptuelle.
Aucune des deux méthodes ne couvre seule les deux modes de défaillance. Une seule requête illustre la différence (pas une preuve de supériorité — l’évaluation agrégée nécessite un ensemble de référence, que le système ne possède pas encore). La requête « PostToolUse hook for context compression » renvoie des top-3 différents selon chaque méthode :
| Rang | BM25 seul | Vectoriel seul | Hybride (RRF) |
|---|---|---|---|
| 1 | hook-stdlib.sh “PostToolUse Handler” | context-is-the-new-memory.md “Compression Layers” | context-is-the-new-memory.md “Compression Layers” |
| 2 | settings.json “PostToolUse Events” | token-budget-analysis.md “Context Engineering” | hook-stdlib.sh “PostToolUse Handler” |
| 3 | compress-output.sh “Tool Output Filter” | agent-memory-patterns.md “Retrieval Integration” | compress-output.sh “Tool Output Filter” |
BM25 a trouvé le fichier de hook exact et la référence de paramètres (correspondance de mots-clés sur « PostToolUse ») mais a manqué la note conceptuelle sur l’ingénierie de contexte. La recherche vectorielle a trouvé les notes de stratégie de compression (correspondance sémantique sur « context compression ») mais a manqué l’implémentation spécifique du hook. RRF a promu les notes qui comptent à la fois pour le concept et l’implémentation, plaçant la note de stratégie et le fichier de hook aux positions un et deux.13
La recherche sur le classement de passages MS MARCO confirme le schéma dans les benchmarks de recherche web : la recherche hybride surpasse systématiquement soit BM25 soit la recherche dense seule, avec les gains les plus importants sur les requêtes contenant à la fois des termes spécifiques et des concepts abstraits.716
L’architecture : trois couches qui se renforcent mutuellement
Le système comporte trois couches indépendantes. Chacune fonctionne sans les autres, mais ensemble elles se renforcent.
Couche 1 : Intake. Un pipeline de scoring Python de 733 lignes évalue chaque signal entrant sur quatre dimensions : pertinence, actionnabilité, profondeur et autorité. Les signaux obtenant 0,55 ou plus sont routés automatiquement vers l’un des 12 dossiers de domaine. Les signaux entre 0,40 et 0,55 sont mis en file d’attente pour revue manuelle. En dessous de 0,40, le pipeline écarte le signal. Le pipeline a traité 7 771 signaux sur 14 mois sans étiquetage manuel.1 La couche d’intake détermine ce qui entre dans le coffre-fort. La couche de recherche le rend trouvable.
Couche 2 : Recherche. Le moteur de recherche hybride détaillé ci-dessous. Le moteur découpe chaque fichier aux limites de titres, encode les chunks avec Model2Vec, et les indexe dans SQLite avec à la fois une table vec0 pour le KNN vectoriel et une table virtuelle FTS5 pour BM25. Une requête s’exécute simultanément contre les deux index, et RRF fusionne les résultats en une seule liste ordonnée.
Couche 3 : Intégration. Des hooks Claude Code qui connectent le retriever au flux de travail de l’agent. Un hook se déclenche à la soumission du prompt, interroge le coffre-fort pour trouver du contexte pertinent, et injecte les meilleurs résultats dans la conversation. L’agent voit des chunks ciblés avec l’attribution de la source au lieu du contenu brut des fichiers :
# Illustrative output (format matches production, content simplified)
## Relevant Memory Context
### OAuth Token Rotation (security-patterns)
Rotate tokens on 401 response. Store refresh token in keychain,
not environment variable. Implement retry with backoff...
### Session Expiration Handling (auth-architecture)
Three expiration modes: absolute (24h), sliding (30min idle),
refresh (7d with rotation). Hook into 401 interceptor...
Chaque résultat porte le titre de section et le projet source, plafonné à un budget de 500 tokens pour éviter la surcharge de contexte.
Le retriever permet également un second point d’intégration : un hook PostToolUse qui compresse les sorties d’outils avant qu’elles n’entrent dans la conversation. La sortie brute des outils contient des horodatages, des artefacts d’ordonnancement et un formatage verbeux qui varient d’une exécution à l’autre. Le retriever remplace le dump brut par un sous-ensemble stable et ciblé. L’agent ne voit jamais le bruit, seulement l’extrait pertinent. Un avantage collatéral : comme la sortie du retriever est déterministe pour la même requête (le même état d’index produit les mêmes résultats classés), la sortie compressée facilite la mise en cache des prompts. Les requêtes répétées contre des données inchangées produisent des blocs de contexte identiques, et la mise en cache automatique des prompts de l’CLI réutilise le préfixe mis en cache.
L’article plus large sur l’infrastructure explique comment les hooks, les skills et les agents se composent en une couche programmable autour du modèle.
Les couches sont découplées par conception. Le scoring d’intake ne connaît rien aux embeddings. Le retriever ne connaît rien aux règles de routage des signaux. Mais l’intake garantit que le coffre-fort contient du contenu de haute qualité, la recherche fait émerger le bon sous-ensemble pour toute requête, et l’intégration délivre ce sous-ensemble à l’agent sans surcharge de contexte. J’ai écrit sur le cadre théorique du contexte comme ressource critique. Le retriever en est l’implémentation pratique.
Découpage : là où la qualité de recherche commence
Le découpage détermine la granularité des résultats de recherche. Des chunks trop grands et la recherche vectorielle renvoie des fichiers entiers dont seul un paragraphe est pertinent. Des chunks trop petits et l’embedding perd le contexte nécessaire à la correspondance sémantique. La recherche sur les pipelines RAG confirme que la taille des chunks a un impact plus important sur la qualité de recherche que le choix du modèle dans la plupart des cas d’usage, avec des chunks de 200-500 tokens offrant les meilleures performances pour les tâches de recherche au niveau du paragraphe.18
Le chunker découpe aux limites des titres H2 (##), en préservant la structure markdown.8 Une note sur la rotation des tokens OAuth avec trois sections H2 devient trois chunks, chacun suffisamment autonome pour que l’embedding capture son sens. L’indexeur stocke le texte du titre et le titre de la note parente comme métadonnées à côté de chaque chunk, fournissant du contexte pour la correspondance BM25 même lorsque le texte du chunk est peu dense.
# chunker.py: H2 splitting with heading context
MIN_CHUNK_CHARS = 30
MAX_CHUNK_CHARS = 2000
def _split_at_headings(body):
sections = []
current_heading = ""
current_lines = []
for line in body.split("\n"):
if line.startswith("## "):
if current_lines:
text = "\n".join(current_lines).strip()
if text:
sections.append((current_heading, text))
current_heading = line[3:].strip()
current_lines = []
else:
current_lines.append(line)
if current_lines:
text = "\n".join(current_lines).strip()
if text:
sections.append((current_heading, text))
return sections
Le chunker découpe davantage les sections dépassant 2 000 caractères : d’abord aux limites H3, puis aux sauts de paragraphe. Il écarte les sections de moins de 30 caractères. Le chunker ignore également les sections Related, See Also, Links et References, qui sont typiquement des listes de wiki-links plutôt que du contenu recherchable.
Deux choix de conception comptent pour la qualité de recherche. Premièrement, l’indexeur stocke la chaîne de contexte du titre ("OAuth Token Rotation | note | security, authentication") dans une colonne séparée et l’indexe dans FTS5 avec un poids inférieur (0,3) à celui du texte du chunk (1,0). BM25 fait toujours correspondre le titre quand le corps du chunk ne contient pas le terme recherché, mais la correspondance de titre obtient un score inférieur à une correspondance dans le corps. Deuxièmement, le chunker extrait les tags du frontmatter et le type de note et les inclut dans le contexte du titre, de sorte qu’une recherche de « security » correspond aux notes taguées avec security même quand le corps du texte utilise une terminologie différente.
Embedding : modèle de 30 Mo, zéro appel API
Le modèle d’embedding est potion-base-8M de Model2Vec, un modèle d’embedding de mots statique avec 7,6 millions de paramètres produisant des vecteurs à 256 dimensions.3 Sur la suite de benchmarks MTEB, potion-base-8M atteint 89 % de la performance d’all-MiniLM-L6-v2 (50,03 vs 56,09 en moyenne) à une vitesse d’inférence jusqu’à 500 fois supérieure, ce qui le rend pratique pour indexer de grands corpus sur du matériel grand public.917 Une nuance : le sous-score MTEB Retrieval du modèle est notablement inférieur (31,71) à ses scores Classification (64,44) ou STS (73,24). Les benchmarks de recherche de MTEB testent le classement au niveau document sur des corpus web, pas la correspondance au niveau paragraphe sur des chunks markdown homogènes. L’écart importe moins quand les chunks sont courts, thématiquement ciblés, et écrits avec un vocabulaire cohérent. Contrairement aux modèles d’embedding basés sur des transformers, Model2Vec n’exécute pas de couches d’attention sur l’entrée. Le modèle distille les connaissances d’un sentence transformer en embeddings de tokens statiques, produisant des vecteurs par moyenne pondérée plutôt que par calcul séquentiel.9
Pourquoi les embeddings statiques fonctionnent-ils pour ce cas d’usage ? Les chunks markdown courts (200-400 mots en moyenne) contiennent un vocabulaire concentré sur un seul sujet. La moyenne pondérée de ces vecteurs de tokens atterrit dans une région significative de l’espace d’embedding car il y a peu de dilution hors-sujet. En pratique, un document de 2 000 mots couvrant trois sujets différents tend à produire un centroïde flou qui se situe entre les clusters thématiques plutôt qu’au sein d’un seul. Un chunk sur la rotation des tokens OAuth, en revanche, produit un vecteur qui se regroupe étroitement avec d’autres contenus sur l’authentification. Les embeddings statiques échangent la désambiguïsation contextuelle (le mot « bank » dans « river bank » vs « bank account ») contre la vitesse brute. Dans une base de connaissances personnelle où chaque chunk couvre un seul concept, la pénalité d’ambiguïté est faible et l’article rapporte une accélération d’inférence jusqu’à 500x.9
# embedder.py: lazy-loading Model2Vec in a dedicated venv
DEFAULT_MODEL = "minishlab/potion-base-8M"
EMBEDDING_DIM = 256
class Model2VecEmbedder:
def __init__(self, model_name=DEFAULT_MODEL):
self._model_name = model_name
self._model = None
def _ensure_model(self):
if self._model is not None:
return
_activate_venv() # Add memory venv to sys.path
from model2vec import StaticModel
self._model = StaticModel.from_pretrained(self._model_name)
def embed_batch(self, texts):
self._ensure_model()
vecs = self._model.encode(texts)
return [v.tolist() for v in vecs]
La conséquence pratique : une réindexation complète de 16 894 fichiers se termine en quatre minutes sur un Apple M3 Pro. L’indexation incrémentale (uniquement les fichiers modifiés, détectés par comparaison de mtime) s’exécute en moins de dix secondes pour les modifications d’une journée typique.1
Le modèle s’exécute dans un environnement virtuel isolé à ~/.claude/venvs/memory/ pour éviter les conflits de dépendances avec le reste de la chaîne d’outils. L’embedder charge le modèle en lazy-loading au premier usage, pas à l’import, de sorte qu’importer le module ne coûte rien quand le retriever se replie sur le mode BM25 seul.
Pourquoi pas un modèle plus grand ? Deux raisons. Premièrement, les vecteurs à 256 dimensions maintiennent la base de données SQLite à 83 Mo pour 49 746 chunks. Des vecteurs de plus haute dimension (768 ou 1 024) tripleraient ou quadrupleraient la taille de la base de données pour une amélioration marginale de la qualité sur des chunks markdown courts.10 Deuxièmement, les embeddings basés sur des API (text-embedding-3-small d’OpenAI à 0,02 $ par million de tokens, par exemple) introduisent de la latence, des coûts et une dépendance réseau pour un système qui devrait fonctionner hors ligne.11 Le ré-embedding complet du coffre-fort coûte environ 0,30 $ aux tarifs API, trivial en isolation, mais le coût réel est la latence aller-retour multipliée par 49 746 chunks et l’implication en termes de confidentialité d’envoyer des notes personnelles à une API externe.
Un mécanisme de hachage du modèle assure le suivi de la compatibilité des embeddings. L’indexeur stocke un hash dérivé du nom du modèle et de la taille du vocabulaire. Si le modèle change, l’indexation incrémentale détecte l’inadéquation et déclenche automatiquement une réindexation complète.
Le schéma SQLite : trois tables, un seul fichier
L’index entier réside dans un seul fichier SQLite (vectors.db, 83 Mo) utilisant le mode WAL pour la sécurité des lectures concurrentes.12 Trois tables servent des objectifs différents :
-- Chunk content and metadata
CREATE TABLE chunks (
id INTEGER PRIMARY KEY,
file_path TEXT NOT NULL,
section TEXT NOT NULL,
chunk_text TEXT NOT NULL,
heading_context TEXT DEFAULT '',
mtime_ns INTEGER NOT NULL,
embedded_at REAL NOT NULL
);
-- FTS5 for BM25 search (content-synced to chunks)
CREATE VIRTUAL TABLE chunks_fts USING fts5(
chunk_text, section, heading_context,
content=chunks, content_rowid=id
);
-- sqlite-vec for vector KNN search
CREATE VIRTUAL TABLE chunk_vecs USING vec0(
id INTEGER PRIMARY KEY,
embedding float[256]
);
La table FTS5 utilise un pattern de synchronisation de contenu : elle référence directement la table chunks plutôt que de stocker une copie dupliquée du texte.5 Un piège : les tables à contenu synchronisé ne propagent pas les suppressions automatiquement. L’indexeur doit émettre des commandes explicites INSERT INTO chunks_fts(chunks_fts, rowid) VALUES('delete', ?) avant de supprimer des lignes de la table chunks, sinon l’index FTS5 devient silencieusement incohérent. Les poids de colonnes dans les requêtes BM25 attribuent 1,0 au texte du chunk, 0,5 aux titres de section, et 0,3 au contexte du titre :
# vector_index.py: BM25 search with column weights
bm25(chunks_fts, 1.0, 0.5, 0.3) as score
L’extension sqlite-vec stocke les vecteurs float à 256 dimensions sous forme de données binaires empaquetées et supporte les requêtes KNN avec la distance cosinus.4 struct.pack de Python sérialise les vecteurs :
def _serialize_vector(vec):
return struct.pack(f"{len(vec)}f", *vec)
Le schéma gère la dégradation gracieuse par conception. Si sqlite-vec échoue au chargement (extension manquante, plateforme incompatible), le retriever se replie sur la recherche BM25 uniquement. La propriété vec_available suit si la recherche vectorielle est opérationnelle.
Reciprocal Rank Fusion : les mathématiques qui font fonctionner le tout
RRF fusionne deux listes ordonnées sans nécessiter de calibration des scores.7 Pourquoi ne pas combiner les scores bruts directement ? BM25 renvoie des scores de pertinence négatifs (plus négatif = plus pertinent dans l’implémentation FTS5 de SQLite) tandis que la distance cosinus renvoie des valeurs entre 0 et 2. Comparer ces échelles nécessite une normalisation sensible à la distribution des requêtes. RRF contourne entièrement le problème en utilisant uniquement les positions de rang, pas les scores. La formule attribue à chaque document un score basé sur sa position dans chaque liste :
score(d) = Σ (weight_i / (k + rank_i))
Où k est une constante (60 dans l’implémentation, suivant l’article original de Cormack et al.7), rank_i est le rang du document dans la liste de résultats i, et weight_i est un multiplicateur optionnel par liste (par défaut 1,0 pour les deux).
Voici un exemple détaillé avec des rangs réels. Considérons une requête : « how does the review aggregator handle disagreements. » Cinq chunks apparaissent dans les résultats combinés :
| Chunk | Rang BM25 | Rang Vec | RRF BM25 | RRF Vec | Score fusionné |
|---|---|---|---|---|---|
| review-aggregator.py “Disagreement Resolution” | 3 | 1 | 1/63 = 0,0159 | 1/61 = 0,0164 | 0,0323 |
| deliberation-config.json “Review Weights” | 1 | 8 | 1/61 = 0,0164 | 1/68 = 0,0147 | 0,0311 |
| code-review MOC “Multi-Agent Review” | 7 | 2 | 1/67 = 0,0149 | 1/62 = 0,0161 | 0,0310 |
| jiro-artisan.sh “Review State Machine” | 2 | 12 | 1/62 = 0,0161 | 1/72 = 0,0139 | 0,0300 |
| quality-loop.md “Evidence Gate” | - | 3 | 0 | 1/63 = 0,0159 | 0,0159 |
Le premier chunk l’emporte car il se classe bien dans les deux listes. BM25 a fait correspondre « review », « aggregator » et « disagreements » dans le texte. La recherche vectorielle a fait correspondre le concept sémantique de résolution de conflits dans la revue de code. Le deuxième chunk était premier en BM25 (correspondance exacte de mots-clés sur « review » dans le fichier de configuration) mais huitième en recherche vectorielle (le JSON de configuration est sémantiquement pauvre). RRF l’a rétrogradé de manière appropriée. Le dernier chunk n’est apparu que dans les résultats vectoriels, il a donc reçu un score RRF d’une seule source.
# retriever.py: RRF fusion core
RRF_K = 60
def _rrf_fuse(self, bm25_results, vec_results,
bm25_weight=1.0, vec_weight=1.0):
scores = {}
for rank, r in enumerate(bm25_results, start=1):
cid = r["id"]
if cid not in scores:
scores[cid] = {"rrf_score": 0.0, ...}
scores[cid]["rrf_score"] += bm25_weight / (self._rrf_k + rank)
scores[cid]["bm25_rank"] = rank
for rank, r in enumerate(vec_results, start=1):
cid = r["id"]
if cid not in scores:
scores[cid] = {"rrf_score": 0.0, ...}
scores[cid]["rrf_score"] += vec_weight / (self._rrf_k + rank)
scores[cid]["vec_rank"] = rank
return [SearchResult(chunk_id=cid, **data)
for cid, data in scores.items()]
Le pool de candidats par défaut est de 30 résultats de chaque source avant fusion, produisant jusqu’à 60 candidats. Le retriever renvoie les 10 meilleurs résultats fusionnés. Un paramètre optionnel max_tokens tronque les résultats pour respecter un budget de tokens, estimant à 4 caractères par token.
Indexation : complète et incrémentale
L’indexeur supporte deux modes. La réindexation complète efface la base de données et reconstruit à partir de zéro. L’indexation incrémentale compare les dates de modification des fichiers (mtime_ns) avec les horodatages stockés et ne re-traite que les fichiers modifiés.1
# index_vault.py: incremental detection
stale = index.get_stale_files(vault_mtimes) # mtime changed or new
deleted = index.get_deleted_files(vault_paths) # no longer in vault
L’embedding s’exécute par lots de 64 textes pour amortir la surcharge de Model2Vec.8 Un compteur de progression s’affiche tous les 500 fichiers pendant la réindexation complète. Un gestionnaire SIGINT permet l’arrêt gracieux, terminant le fichier en cours avant de s’arrêter.
Le fichier de configuration utilise un modèle de liste blanche pour contrôler l’indexation des dossiers. Le coffre-fort a 22 dossiers autorisés et 5 dossiers exclus en permanence (notes de santé personnelles, documents de carrière, répertoires internes Obsidian).20 L’indexeur ne traite que les fichiers dans les dossiers autorisés et ignore tout le reste.
Un choix de conception critique : l’indexeur exécute un filtre de credentials sur chaque chunk avant de le stocker. Les notes personnelles contiennent des clés API, des bearer tokens, des chaînes de connexion à des bases de données et des clés privées collées pendant les sessions de débogage. Le filtre de credentials fait correspondre 21 patterns spécifiques à des fournisseurs (clés OpenAI, PATs GitHub, clés d’accès AWS, tokens Stripe, et 17 autres) plus 11 détecteurs génériques pour les URLs de bases de données, les JWTs, les bearer tokens, les assignations de mots de passe et les chaînes base64 à haute entropie.20 Le filtre remplace le contenu correspondant par des tokens [REDACTED:pattern-name] et enregistre quels patterns se sont déclenchés sans jamais enregistrer le secret lui-même.
# chunker.py: credential filtering before storage
cleaned_text, scan_result = clean_content(sub_text)
if not scan_result.is_clean:
logger.info("Scrubbed %d credential(s) from %s [%s]",
scan_result.match_count, file_path, sub_heading)
Indexer des notes personnelles sans filtrage de credentials créerait une base de données interrogeable de secrets. Le filtre s’exécute avant l’embedding, de sorte que les représentations vectorielles n’encodent jamais de patterns de credentials. Une requête pour « clé API » renvoie les notes qui discutent de la gestion des clés API, pas les notes qui contiennent des clés réelles.
Ce qui ne fonctionne pas : les modes de défaillance honnêtes
Après des centaines de requêtes contre l’index de production, quatre schémas de défaillance sont clairs.
Le contenu superficiel dense en mots-clés surpasse le contenu approfondi. Une note courte taguée security, authentication, oauth avec un résumé de trois phrases obtient un score plus élevé en BM25 qu’une analyse approfondie de 2 000 mots sur l’implémentation OAuth qui utilise la terminologie une seule fois dans l’introduction puis passe aux détails spécifiques du protocole. BM25 récompense la fréquence des termes relativement à la longueur du document, une propriété que Robertson et Zaragoza ont documentée comme le composant de « saturation de fréquence de terme » de l’algorithme.514 La note superficielle a une densité de mots-clés plus élevée. RRF corrige partiellement le problème car la recherche vectorielle classe le contenu approfondi plus haut (l’embedding capture la profondeur sémantique), mais la note superficielle apparaît toujours dans les résultats fusionnés alors qu’elle ne devrait probablement pas.
Les données structurées s’indexent mal. Les fichiers de configuration JSON, les blocs de frontmatter YAML et les extraits de code avec des noms de variables produisent des correspondances BM25 de faible qualité. Une recherche de « review configuration » correspond à chaque fichier JSON avec une clé review. La recherche vectorielle gère les données structurées légèrement mieux car l’embedding capture les relations clé-valeur, mais le contenu structuré est fondamentalement plus difficile à découper que la prose. Aplatir le JSON en paires chemin-clé: valeur avant l’embedding améliorerait la qualité de recherche pour les notes riches en configuration.
Les limites de chunks coupent le contexte. Le chunker divise un paragraphe qui chevauche la frontière entre deux sections H2 en deux chunks. Chaque chunk contient la moitié de l’explication. Aucun chunk ne s’encode bien car l’embedding manque du contexte complet. Le chunker atténue le problème avec le contexte de titre (en propageant le titre parent dans les métadonnées), mais le corps du texte perd quand même la continuité à la frontière. Des fenêtres chevauchantes aideraient mais augmenteraient le nombre de chunks et la taille de la base de données.
La pertinence temporelle est invisible. Le retriever n’a aucune notion de récence. Une note d’il y a 14 mois sur une décision architecturale précoce se classe au même niveau qu’une note d’hier sur l’implémentation actuelle. Pour une base de connaissances qui évolue, les notes plus récentes remplacent souvent les plus anciennes. Le retriever ne le sait pas.
Ce qui vient ensuite : la feuille de route d’expansion
Cinq ajouts permettraient de traiter les modes de défaillance et d’étendre les capacités du système.
Couche de re-ranking par apprentissage. Après la fusion RRF, un re-ranker léger pourrait ajuster les scores en fonction de signaux de métadonnées : récence de la note, pertinence des tags par rapport au domaine de la requête, densité de liens (les notes très liées sont souvent plus autoritaires). Le re-ranker s’exécuterait sur les 30 meilleurs résultats fusionnés, pas sur le corpus entier, maintenant la latence sous la ligne de base de 23 ms.
Classification d’intention de requête. Différentes requêtes nécessitent différentes stratégies de recherche. Une recherche d’identifiant exact (_rrf_fuse) devrait pondérer fortement BM25. Une question conceptuelle (« how does review handle disagreements ») devrait pondérer la recherche vectorielle. Un classificateur léger qui ajuste bm25_weight et vec_weight par requête améliorerait la précision sans changer l’architecture de fusion.
Décroissance temporelle. Pondérer les notes récentes légèrement plus haut pour les requêtes sur l’état actuel. Une fonction de décroissance appliquée après la fusion réduirait le score des chunks provenant de fichiers modifiés il y a plus de N mois. L’horodatage mtime_ns existe déjà dans le schéma ; la décroissance ne nécessite qu’une fonction de pondération dans le retriever.
Harnais d’évaluation avec des requêtes de référence. Le système n’a actuellement aucune mesure de qualité automatisée. Un ensemble de 50-100 paires requête-réponse curatées permettrait des tests de régression de qualité de recherche : exécutez la suite de tests après tout changement de découpage, d’embedding ou de paramètres de fusion et vérifiez que le recall@10 ne se dégrade pas. Le benchmark BEIR a démontré que les systèmes de recherche peuvent varier de plus de 20 points en nDCG@10 selon les distributions de requêtes, rendant l’évaluation spécifique au domaine essentielle.19 Sans ensemble de référence, les améliorations sont anecdotiques.
Indexation des relations inter-notes. Les wiki-links Obsidian ([[note-name]]) encodent des relations explicites entre les notes. Le système actuel ignore complètement la structure des liens. Indexer les cibles de liens comme métadonnées permettrait au retriever de booster les chunks provenant de notes vers lesquelles de nombreuses autres notes bien classées pointent, similaire au PageRank pour le coffre-fort.
L’analyse de topologie de l’espace d’embedding que j’ai effectuée sur le coffre-fort complet révèle où ces améliorations auraient le plus d’impact. Les clusters denses (outillage IA, sécurité) se retrouvent déjà bien car la terminologie est cohérente. Les régions de pont éparses entre les clusters sont là où le retriever rencontre le plus de difficultés, et où l’indexation des relations et la classification d’intention apporteraient les gains les plus importants.
FAQ
Pourquoi SQLite au lieu d’une base de données vectorielle dédiée ?
Toute la pile de recherche s’exécute dans un seul fichier sans aucune dépendance externe. Le mode WAL de SQLite gère les lectures concurrentes depuis plusieurs sessions Claude Code. L’extension sqlite-vec ajoute la recherche vectorielle KNN sans nécessiter une instance Pinecone, Weaviate ou Qdrant séparée.4 À 49 746 chunks, la latence de requête est de 23 ms.1 Une base de données vectorielle dédiée ajouterait de la complexité opérationnelle (hébergement, sauvegardes, authentification) pour une base de connaissances mono-utilisateur qui tient dans 83 Mo.
Pourquoi Model2Vec au lieu des embeddings OpenAI ou d’un modèle plus grand ?
Trois raisons : latence, confidentialité et coût. Model2Vec s’exécute localement à vitesse CPU sans appel réseau.3 Les notes personnelles ne quittent jamais la machine. Les embeddings basés sur des API coûteraient environ 0,30 $ par réindexation complète pour la taille actuelle du coffre-fort,11 négligeable en isolation, mais la latence aller-retour sur 49 746 chunks et l’exposition à la confidentialité du contenu personnel sont les coûts réels.
Qu’est-ce que Reciprocal Rank Fusion et quand faut-il l’utiliser ?
RRF ne nécessite aucune donnée d’entraînement, aucune calibration de scores, et aucun réglage d’hyperparamètres au-delà de la constante k.7 Un modèle de fusion appris nécessiterait des jugements de pertinence étiquetés pour l’entraînement, qui n’existent pas pour une base de connaissances personnelle. RRF est la méthode de fusion avec la barrière la plus basse pour produire des résultats utiles. Utilisez RRF lorsque vous combinez des listes ordonnées provenant de méthodes de recherche qui produisent des types de scores incompatibles.
Comment un retriever local se connecte-t-il à Claude Code ?
Un hook PreToolUse appelle la méthode search() du retriever avec le prompt courant, formate les meilleurs résultats sous forme de bloc de contexte avec les chemins de fichiers et les titres de section, et injecte le bloc dans la conversation. L’agent voit des chunks ciblés, pas des fichiers bruts. Un paramètre max_tokens garantit que le contexte injecté respecte un budget.
Comment empêcher l’indexation de secrets dans un système de recherche ?
Exécutez un filtre de credentials sur chaque chunk avant le stockage. Le filtre de ce système fait correspondre 21 patterns spécifiques à des fournisseurs et 11 détecteurs génériques pour les JWTs, les bearer tokens et les clés privées.20 Il remplace le contenu correspondant par des tokens [REDACTED:pattern-name] et s’exécute avant l’embedding, de sorte que les représentations vectorielles n’encodent jamais de patterns de credentials.
Références
-
Données de production de l’auteur. 49 746 chunks, 16 894 fichiers, base de données SQLite de 83,56 Mo, 7 771 signaux traités sur 14 mois. Latence de requête (23 ms) mesurée via
time.perf_counter()dans retriever.py, englobant le chemin de recherche complet : recherche BM25, embedding de requête via Model2Vec, recherche vectorielle KNN et fusion RRF.grep -rlmesuré à 11-66 secondes selon la fréquence du terme (Apple M3 Pro, APFS). Réindexation complète mesurée à ~4 minutes sur Apple M3 Pro. Incrémentale mesurée à <10 secondes pour les changements quotidiens typiques. La recherche FTS5 seule est devenue inutilisable pour l’auteur au-delà de ~3 000 fichiers en raison des taux de collision de mots-clés. ↩↩↩↩↩↩ -
Fil HN : « Stop Burning Your Context Window ». Commentaires de danw1979 et tclancy demandant un article détaillé. ↩
-
Model2Vec: Distill a Small Fast Model from any Sentence Transformer. Minish Lab, 2024. Le modèle potion-base-8M utilise des embeddings de mots statiques distillés d’un sentence transformer, produisant des vecteurs à 256 dimensions sans exécuter de couches d’attention. ↩↩↩
-
sqlite-vec: A vector search SQLite extension. Alex Garcia, 2024. Fournit des tables virtuelles
vec0pour la recherche vectorielle KNN au sein de SQLite, utilisant la même interface de requête que les tables standard. ↩↩↩ -
SQLite FTS5 Extension. Documentation SQLite. FTS5 fournit la recherche en texte intégral avec classement BM25, tables à contenu synchronisé et poids de colonnes configurables via la fonction auxiliaire
bm25(). ↩↩↩ -
Reimers, N. and Gurevych, I. Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. EMNLP, 2019. Travail fondateur sur la similarité sémantique dense pour la recherche textuelle, établissant l’approche de recherche vectorielle utilisée dans les systèmes de recherche hybrides. ↩
-
Cormack, G.V., Clarke, C.L.A., and Buettcher, S. Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR, 2009. Introduit RRF avec k=60 comme méthode sans paramètre pour combiner des listes ordonnées qui surpasse les modèles de fusion appris. ↩↩↩↩
-
Implémentation de l’auteur.
chunker.pydécoupe aux limites H2 dans la fonction_split_at_headings, avec repli vers H3 puis découpage par paragraphe pour les sections dépassant 2 000 caractères. MIN_CHUNK_CHARS=30, MAX_CHUNK_CHARS=2000.index_vault.pyencode par lots de 64 (BATCH_SIZE=64). ↩↩ -
van Dongen, T. et al. Model2Vec: Turn any Sentence Transformer into a Small Fast Model. arXiv, 2025. Décrit l’approche de distillation produisant des embeddings statiques à partir de sentence transformers avec une accélération d’inférence de 50-500x. ↩↩↩
-
Mesure de l’auteur. Vecteurs à 256 dimensions pour 49 746 chunks produisent 83 Mo en SQLite. Extrapolation à 768 dimensions : ~215 Mo. À 1 024 dimensions : ~280 Mo. L’amélioration marginale de la qualité sur des chunks markdown courts (moy. 200-400 mots) ne justifie pas l’augmentation de stockage et de latence. ↩
-
OpenAI Embeddings Pricing. text-embedding-3-small : 0,02 $ par million de tokens. Coût estimé du coffre-fort par réindexation complète : ~0,30 $ basé sur une longueur moyenne de chunk de ~200 tokens. ↩↩
-
SQLite Write-Ahead Logging. Documentation SQLite. Le mode WAL permet des lecteurs concurrents avec un seul écrivain, adapté au schéma d’accès en lecture intensive du retriever. ↩
-
Trace de requête de l’auteur. Exécution de « PostToolUse hook for context compression » contre les modes BM25 seul, vectoriel seul et hybride. Résultats capturés depuis retriever.py avec le champ
methodtraçant quel chemin de recherche a produit chaque résultat. ↩ -
Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 2009. Étude des fonctions de classement de la famille BM25 et de leurs fondements théoriques. ↩↩
-
Karpukhin, V. et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP, 2020. A démontré que les représentations denses apprises surpassent BM25 de 9-19 % sur les benchmarks de QA en domaine ouvert, établissant la recherche dense comme complément à la recherche lexicale. ↩
-
Luan, Y. et al. Sparse, Dense, and Attentional Representations for Text Retrieval. TACL, 2021. Analyse de la recherche hybride sparse-dense sur MS MARCO, montrant des améliorations constantes par rapport aux approches à modalité unique. ↩
-
MTEB: Massive Text Embedding Benchmark. Muennighoff, N. et al., 2023. potion-base-8M obtient 50,03 en moyenne MTEB vs 56,09 pour all-MiniLM-L6-v2 (89,2 % de rétention). Détail par tâche : Classification 64,44, Clustering 32,93, Retrieval 31,71, STS 73,24. Source : Résultats Model2Vec. ↩
-
Gao, Y. et al. Retrieval-Augmented Generation for Large Language Models: A Survey. arXiv, 2024. Étude des architectures RAG incluant l’analyse des stratégies de découpage et leur impact sur la qualité de recherche. ↩
-
Thakur, N. et al. BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models. NeurIPS, 2021. Démontre une variance élevée des performances de recherche entre domaines, soulignant la nécessité d’une évaluation spécifique au domaine. ↩
-
Configuration et implémentation du filtre de credentials de l’auteur.
memory-config.jsondéfinit 22 entréesallowed_folderset 5 entréesexcluded_always.credential_filter.pydéfinit 21CREDENTIAL_PATTERNSspécifiques à des fournisseurs (d’OpenAI à Turnstile) plus 9 patterns génériques mono-ligne (URLs de BD, bearer tokens, JWTs, mots de passe, secrets, clés API, tokens d’auth, secrets base64) et 2 patterns multi-lignes (clés privées RSA/SSH, clés PGP). Total : 32 patterns. ↩↩↩