TL;DR
Le graph de code exige de connaître le nom du symbole. Pour plus de souplesse, cartog convertit chaque symbole en embeddings avec BGE-small (ONNX, CPU inférence), combine FTS5 (BM25) et KNN vectoriel via sqlite-vec, fusionne avec RRF, puis re-classe avec un cross-encoder. Tout est local par défaut et la config permet d’échanger provider d’embedding ou backend de stockage si besoin. Pour tester directement : documentation · code source.
Le graph structurel répond vite et juste, à condition de connaître le nom du symbole.
Que faire quand l’agent décrit une intention plutôt qu’un identifiant ?
Pour rappel, cartog parse chaque fichier avec Tree-sitter, extrait les symboles (fonctions, classes, méthodes) et leurs relations (appels, imports, héritage) depuis l’AST puis stocke le tout dans un graph SQLite.
Ce graph permet une navigation structurelle : cartog refs UserService ou cartog impact verify_token à condition de connaître le nom exact du symbole.
C’est précisément la limite : un agent IA qui reçoit « trouve la logique de validation des commandes » ne peut pas deviner que la fonction s’appelle validate_order_items.
Ni grep, ni une recherche exacte dans le graph ne trouveront ce symbole à partir d’une description en langage naturel.
Mais comment une machine peut-elle « comprendre le sens » d’un bout de code ? Le code n’est pas des mots ou phrases.
Il faut pouvoir chercher par le sens, pas par le texte. C’est ce que permet la recherche sémantique. Cet article montre comment cartog la met en place, en local par défaut, sans envoyer un seul caractère au cloud (nb: la config laisse la porte ouverte à un provider distant pour qui le souhaite).
Comment transformer une fonction et une question en quelque chose de comparable, alors que l’une est du code et l’autre du langage naturel ?
La réponse tient en un mot : embedding. Un modèle d’IA, entraîné sur des milliards de paires texte/code, sait projeter n’importe quelle séquence dans un espace vectoriel à plusieurs centaines de dimensions. Deux textes au sens proche atterrissent à des positions proches, même s’ils ne partagent aucun mot. C’est ce que fait Google quand vous cherchez « comment réparer un évier qui fuit » et qu’il retourne un article intitulé « réparation plomberie résidentielle ».
Cartog applique cette idée aux symboles : chaque fonction, classe, méthode du graph est transformée en un vecteur de 384 nombres. La requête en langage naturel passe par le même modèle, et les symboles les plus proches dans l’espace vectoriel sont retournés.
flowchart LR
Q["'validation des commandes'"] --> EQ["Embedding<br>query → vec 384d"]
S1["validate_order_items()"] --> ES1["Embedding<br>symbole → vec 384d"]
S2["process_payment()"] --> ES2["Embedding<br>symbole → vec 384d"]
EQ --> KNN["KNN<br>Distance L2"]
ES1 --> KNN
ES2 --> KNN
KNN --> R["validate_order_items ✓<br>(distance: 0.12)"]
En théorie, donc, on prend chaque fonction, on l’envoie au modèle, on stocke le vecteur.
Est-ce que cela suffit ?
Non. Un embedding naïf du code source brut tend à donner des résultats médiocres. Le code contient du bruit (lignes blanches, commentaires de formatage, accolades fermantes) qui dilue le signal sémantique.
Tronquer une fonction de 200 lignes pour la faire tenir dans la fenêtre du modèle perd justement les morceaux qui portent l’intention.
Si on envoie une fonction de 200 lignes au modèle, qu’est-ce qu’il en garde réellement ? Que faire des accolades, lignes blanches, commentaires de licence, ce bruit qui n’a aucun sens métier ?
Plutôt que d’embedder le code brut, Cartog utilise la structure AST extraite pour préparer un texte optimisé par symbole :
flowchart TD
SYM["Symbole extrait<br>validate_order_items()"] --> H["Header<br>signature + décorateurs"]
SYM --> B["Body<br>lignes significatives"]
H --> CHUNK["Chunk d'embedding<br>≤ 500 bytes"]
B --> CHUNK
CHUNK --> EMB["Embedding BGE-small<br>384 dimensions"]
Concrètement, le texte d’embedding est construit ainsi :
- Header : un commentaire normalisé qui inclut le chemin, le kind et la signature, par exemple
// File: auth/tokens.py | function validate_token.
Les décorateurs (@login_required,#[derive(Debug)]) sont gardés : ils portent du sens sémantique. - Corps significatif : les lignes de code en filtrant les lignes blanches, les commentaires purement formels, et les accolades fermantes isolées.
- Limites : ~800 bytes (~200 tokens) pour l’entrée du bi-encoder, suffisant pour capturer « ce que fait cette fonction » tout en restant dans la fenêtre de 512 tokens du modèle. 2048 bytes (~512 tokens) pour le contenu complet stocké en base, utilisé par FTS5 et le re-ranker.
Ce qui n’est pas embeddé :
- Les imports : ils existent déjà comme arêtes (relations d’import) dans le graph.
- Les variables simples : trop nombreuses, faible signal sémantique.
Les documents Markdown suivent un traitement spécifique : ils sont chunkés par heading (chaque section devient un symbole), avec un sous-découpage par paragraphe pour les grosses sections (~1 500 bytes). Les fichiers sans heading utilisent un découpage à taille fixe.
D’accord, mais ces embeddings, qui les calcule ? Faut un GPU ? Une API OpenAI ?
Ni l’un ni l’autre, par défaut. Cartog utilise BGE-small-en-v1.5, un petit modèle d’embedding (~80 MB après quantification) téléchargé une fois et exécuté en CPU via ONNX Runtime. Les paramètres exacts (dimensions, normalisation, sérialisation) sont dans la doc cartog. Pas d’appel réseau pour calculer un vecteur, pas de données qui quittent la machine.
Ce choix est délibéré. Cartog est un outil de développement qui tourne sur le poste du développeur : envoyer du code source à un service externe n’est pas acceptable pour beaucoup d’équipes (NDA, code propriétaire, conformité). Le trade-off : l’inférence CPU est plus lente qu’une API hébergée, mais quelques optimisations rattrapent une bonne partie du retard (batching par longueur, voir « Pièges rencontrés »).
Et si on veut justement utiliser une API hébergée, ou stocker le graph ailleurs que dans un SQLite local ?
Le pipeline est extensible par configuration : le provider d’embedding (modèle ONNX local, API distante) et le backend de stockage (SQLite local, base partagée) peuvent être surchargés sans toucher au reste de la chaîne. Le défaut local-first protège le code propriétaire ; l’override débloque les setups d’équipe (graph partagé en CI, embedding hébergé pour l’inférence GPU).
Si on a des embeddings qui captent le sens, pourquoi garder une recherche par mot-clé (FTS5) dans la boucle ?
Parce que la recherche sémantique seule rate les matches exacts. Quand l’agent cherche validate_order_items (nom exact qu’il a vu dans une trace ou un message d’erreur), un simple match textuel est plus rapide et plus fiable qu’un calcul de distance vectorielle. Inversement, quand il cherche « la logique de validation des commandes », le textuel ne donne rien.
Le coût d’exécution penche aussi pour le keyword en première ligne : la recherche keyword sur les noms de symboles répond en sub-milliseconde, tandis que la recherche sémantique (calcul d’embedding de la requête + KNN) tourne autour de 300 ms. Si on peut éviter d’appeler la sémantique, on le fait.
Les deux approches sont complémentaires. Cartog les exécute en parallèle et fusionne les résultats :
flowchart TD
Q["Requête utilisateur"] --> FTS["FTS5 Keyword<br>BM25 ranking"]
Q --> VEC["KNN Vectoriel<br>sqlite-vec"]
FTS --> RRF["Reciprocal Rank Fusion<br>k=60"]
VEC --> RRF
RRF --> TOP["Top candidats"]
TOP --> RE["Cross-encoder<br>re-ranking"]
RE --> RES["Résultats finaux"]
FTS5 et BM25, qu’est-ce que ça change par rapport à un
LIKE '%validate%'SQL classique ?
SQLite FTS5 est l’index full-text intégré à SQLite. Il classe les résultats avec BM25, un algorithme qui pondère les termes selon leur rareté dans le corpus : un mot fréquent comme « validate » pèse moins qu’un mot rare comme « ledger ».
Cartog interroge FTS5 avec un fallback en cascade pour gérer les requêtes de longueurs différentes :
| Niveau | Requête FTS5 | Quand |
|---|---|---|
| 1 | "validate order items" (phrase exacte) | Match adjacent |
| 2 | validate AND order AND items | Tous les termes présents |
| 3 | validate OR order OR items | Au moins un terme |
Le premier niveau qui retourne des résultats est gardé. La recherche porte sur le nom du symbole, le nom normalisé (camelCase → mots séparés, pour que validateOrderItems matche aussi « validate order items ») et le contenu complet.
KNN, distance L2, cosine : concrètement, comment SQLite trouve les vecteurs les plus proches d’une requête parmi des milliers ?
La requête utilisateur est embeddée avec le même modèle BGE-small, puis comparée aux vecteurs des symboles stockés dans sqlite-vec, une extension SQLite qui ajoute des opérateurs de recherche vectorielle native. Comme les vecteurs sont normalisés en sortie du modèle, la distance L2 est mathématiquement équivalente à la similarité cosine, mais plus rapide à calculer.
On a deux listes classées différemment. Comment les combiner sans tomber dans un casse-tête de poids arbitraires ?
Reciprocal Rank Fusion (Cormack et al., 2009) répond à ça avec une formule remarquablement simple :
score(doc) = 1/(k + rang_keyword + 1) + 1/(k + rang_vector + 1) avec k = 60
rang est la position du document dans chaque liste classée (0-indexé). Le score final est la somme sur les deux listes. L’astuce : RRF ignore complètement la valeur des scores originaux (BM25 vs distance L2, deux échelles incompatibles) et ne regarde que la position. Un document n°1 dans une liste contribue toujours 1/(60+1), qu’il ait un score BM25 de 12 ou 0,3.
Conséquences pratiques :
- Un document bien classé dans les deux listes est promu (signal renforcé).
- Un document n°1 en keyword mais absent en sémantique reste dans le top.
- Pas de poids à régler entre keyword et sémantique : la fusion est paramètre-free.
Pourquoi un troisième étage ? Le RRF n’a-t-il pas déjà classé ce qui sort des deux premiers ?
Le RRF classe sur la base de positions, pas de pertinence absolue. Pour les meilleurs candidats, on peut se permettre un calcul plus coûteux mais plus juste : le cross-encoder. Là où le bi-encodeur compare deux vecteurs pré-calculés indépendamment, le cross-encoder reçoit la requête et le document ensemble et produit un score de pertinence en croisant leurs tokens. Plus précis, mais plus lent, d’où le filtrage en cascade : seuls les 50 meilleurs candidats du RRF (configurable) sont envoyés au cross-encoder.
Cartog utilise un modèle BGE-reranker (~1.1 GB sur disque, plus gros que celui d’embedding mais utilisé seulement pour le top de la liste).
Le re-ranker est optionnel : s’il ne charge pas (modèle absent, erreur ONNX), la recherche retourne les résultats RRF sans re-ranking. Un cache à 3 états évite de retenter un chargement échoué :
stateDiagram-v2
[*] --> NonTenté
NonTenté --> Prêt : chargement OK
NonTenté --> Échec : erreur ONNX
Prêt --> Prêt : requêtes
Échec --> Échec : ne pas réessayer
Sur le papier, le pipeline est propre. En pratique, trois détails ont fait dérailler la recherche avant d’être corrigés.
Faux positifs sur les imports : les imports contiennent les noms des symboles importés. Sans filtrage, from auth import validate_token était embeddé et matchait les requêtes sur validate_token, en doublon avec le symbole réel. C’est ce qui a motivé l’exclusion des imports de l’embedding (voir section “Chunking AST-aware”).
Batching par longueur : ONNX Runtime pad les séquences à la longueur maximale du batch. Mélanger des symboles de 10 tokens avec des symboles de 200 tokens gaspille du calcul (l’attention BERT est O(n²) en longueur, donc le padding coûte cher). Trier par longueur avant le batching réduit le padding et accélère l’inférence.
Sécurité UTF-8 : le code source contient parfois des caractères multi-octets (commentaires en chinois, japonais ou coréen, emoji dans les strings). Tronquer à ~800 bytes peut couper au milieu d’un caractère UTF-8. Cartog utilise une troncature byte-safe qui recule jusqu’à la frontière de caractère la plus proche.
À ce stade, cartog combine deux briques :
- Un graph structurel (article 1) : symboles et arêtes extraits par tree-sitter, requêtables par nom exact (
refs,callees,impact). - Une recherche sémantique hybride (cet article) : FTS5 pour les noms et les matches exacts, KNN vectoriel pour le sens, RRF pour fusionner les deux, cross-encoder pour re-classer le top.
L’agent peut désormais retrouver une fonction qu’il connaît par son nom ou par son intention, en local, en quelques centaines de millisecondes. Paramètres techniques détaillés dans la doc cartog.
Mais la qualité du graph dépend encore d’une chose qu’on n’a pas réglée : la précision des arêtes. L’heuristique de l’article 1 résout 25-37% des appels. Quand l’agent demande « quelles fonctions appellent
validate_order_items? », il rate les deux tiers des appelants. Comment passer à 80% sans réimplémenter un compilateur ?
Ce sera le sujet du prochain article : brancher cartog sur les language servers (LSP) pour récupérer la précision des IDE.