Moteur RAG — référence du composant
Nature
Le moteur RAG est un bundle Symfony headless fournissant le volet retrieval d'une chaîne RAG (retrieval-augmented generation). Il ne génère pas de texte (pas d'appel LLM, pas d'UI de chat) : il transforme une arborescence de fichiers Markdown en un index vectoriel interrogeable par similarité sémantique, chaque résultat conservant sa provenance (fichier, section, ancre, position) pour permettre la citation des sources.
Exposition : une CLI et des services autowirables. Aucune interface graphique.
Hors périmètre du composant : génération LLM, UI, outils MCP, sources externes / veille.
Pipeline
Indexation (batch)
racine ─▶ Walk (parcours + filtres) ─▶ Chunking (sections → chunks)
─▶ Client embeddings (POST /embed, type=passage) ─▶ Store (upsert par document)
Requête (à la demande)
question ─▶ Client embeddings (POST /embed, type=query)
─▶ Store.search (kNN cosinus) ─▶ Hit[]
Architecture — couches et responsabilités
Trois contrats publics interchangeables (points de substitution), plus des services concrets d'orchestration.
| Couche | Interface | Rôle |
|---|---|---|
| Découpage | ChunkerInterface |
document → Chunk[] |
| Embeddings | EmbeddingClientInterface |
texte(s) → vecteurs (via HTTP) |
| Store | VectorStoreInterface |
persistance + recherche kNN |
| Ingestion | (service concret) | walk → chunk → embed → upsert |
| Récupération | (service concret) | embed(query) → search |
| Parcours | (service concret) | fichiers → Document |
| Estimation tokens | (service concret) | comptage approché de tokens |
Le bundle n'embarque pas le microservice d'embeddings (dépendance réseau externe) ni l'extension vectorielle SQLite (dépendance système). Pas d'ORM : le schéma SQL est géré à la main, sans migrations.
Chaîne d'exceptions
Interface marqueur RagException (extends \Throwable) que l'appelant peut
attraper de façon générique. Exceptions concrètes : EmbeddingException
(invalidInput / unavailable / malformedResponse) et VectorStoreException
(dont extensionUnavailable).
Ingestion et gouvernance
Parcours récursif d'une racine source. Un fichier est retenu si toutes les
conditions sont réunies : extension md/markdown (insensible à la casse) ;
fichier régulier ; contenu lisible et non vide après trim ; passe les filtres
de gouvernance.
Filtres de gouvernance (ordre d'application)
- Exclusions dures, non configurables — un fichier de type bac-à -sable
(
SCRATCH.md) et tout segment de dossier de typelegacysont toujours exclus, à n'importe quel niveau. excluded_paths(configurable) — préfixes relatifs exclus.included_paths(configurable) — si non vide, allowlist stricte ; sinon tout est autorisé.- Fichier d'ignore (
.aiignore) à la racine du parcours — motifs glob simples (un par ligne,#= commentaire), testés sur le chemin relatif.
Modèle d'inclusion par défaut : default-allow (
included_pathsvide → tout Markdown de la racine est candidat). Pour un modèle default-deny, renseigner explicitement une allowlist viaincluded_paths. Point de vigilance pour l'intégrateur : sans allowlist, tout.mddéposé sous la racine est indexé.
Extraction front-matter et titre
- Front-matter YAML scalaire uniquement (paires
clé: valeur; pas de listes/blocs/imbrication). Le corps indexé est le document sans le bloc front-matter. - Titre résolu par priorité :
titledu front-matter → premier# H1du corps →basenamedu fichier. content_hash=sha256du fichier brut entier (front-matter inclus) — clé de l'incrémental.
Découpage (chunking)
- Sections délimitées par les titres
H1–H3(H4+ non traités comme titres). Chaque section porte untitleet uneanchor(slug UTF-8 du titre, accents conservés). Le préambule avant le premier titre porte le titre du document. - Paragraphes séparés par les lignes vides.
- Empaquetage : accumulation de paragraphes jusqu'Ã la taille cible en tokens
(
chunk.size), puis émission d'un chunk et amorçage du suivant par une queue de chevauchement (chunk.overlaptokens). Un paragraphe plus grand que la cible forme un chunk isolé (pas de coupe intra-phrase). - Estimation de tokens : heuristique déterministe
ceil(nb_mots × 1.33)— approximation FR/EN, pas le compte exact du tokenizer du modèle. - Bornes :
size > 0;overlap ∈ [0, size[(sinon exception).
Embeddings — contrat client
Le bundle n'embarque aucun modèle : il appelle un microservice HTTP
externe. La distinction query / passage (préfixage attendu par les modèles
de type e5) est gérée côté microservice.
Interface (EmbeddingClientInterface) :
const TYPE_QUERY = 'query'; const TYPE_PASSAGE = 'passage'; public function embed(string $type, array $texts): array; // float[][], @throws EmbeddingException public function health(): array; // {status, model, dim}
Contrat HTTP consommé :
GET /health → { "status": "ok", "model": "...", "dim": <int> }
POST /embed → body { "type": "query"|"passage", "texts": [...] }
→ { "model": "...", "dim": <int>, "vectors": [[...], ...] }
Règles de validation et d'erreur :
- Validation locale avant tout appel réseau :
typeinconnu outextsvide →invalidInput(aucune requête émise). - Réponse HTTP 422 →
invalidInput(ledetaildu corps JSON est extrait) — jamais de repli silencieux. - Autre code ≠200, ou transport/timeout →
unavailable. - Clé
vectorsabsente / non-tableau, oucount(vectors) ≠count(texts)→malformedResponse. - Les vecteurs sont renvoyés dans le même ordre que les textes en entrée.
Store vectoriel — modèle de données
Store SQLite avec extension vectorielle (table virtuelle de type vec0,
recherche kNN, métrique cosinus). Schéma créé à la main, idempotent
(CREATE TABLE IF NOT EXISTS).
Table document
| Colonne | Type | Contraintes |
|---|---|---|
| id | INTEGER | PK AUTOINCREMENT |
| path | TEXT | NOT NULL UNIQUE |
| title | TEXT | NOT NULL |
| content_hash | TEXT | NOT NULL |
| indexed_at | TEXT | NOT NULL (ISO 8601) |
Table chunk
| Colonne | Type | Contraintes |
|---|---|---|
| id | INTEGER | PK AUTOINCREMENT |
| document_id | INTEGER | NOT NULL, FK → document(id) ON DELETE CASCADE |
| section | TEXT | NOT NULL |
| anchor | TEXT | NOT NULL |
| position | INTEGER | NOT NULL |
| content | TEXT | NOT NULL |
| token_count | INTEGER | NOT NULL |
| content_hash | TEXT | NOT NULL |
| — | — | UNIQUE(document_id, position) |
Table vectorielle chunk_vector
Table virtuelle vec0, colonne embedding float[<dimension>],
distance_metric=cosine. Le rowid référence chunk.id (lien 1-1 par
convention). La dimension provient de la configuration.
Une table ingest_run (traçabilité des runs) est prévue au schéma.
Écriture — upsertDocument (transactionnelle)
Remplacement complet par document (pas de diff par chunk) : à la réindexation
d'un path existant, ses vecteurs puis son document sont supprimés (CASCADE
purge les chunks), puis le document, ses chunks et vecteurs sont réinsérés
(position repart de 0). Rollback sur toute exception. Garde-fou :
count(chunks) === count(vectors).
Recherche — search (kNN)
Jointure chunk_vector × chunk × document, WHERE embedding MATCH ? AND k=?,
tri par distance croissante. k ≤ 0 court-circuite à [].
Score = 1.0 - distance (distance cosinus ∈ [0,2] → score typiquement dans
[0,1]) : plus haut = plus proche.
Récupération (retrieval)
RetrievalService::retrieve(string $query, ?int $k = null): Hit[].
Requête vide (après trim) → [] sans appel réseau ni store. Sinon : vectorise
la requête en type=query, exécute search, renvoie des Hit
({ chunk, score }). k par défaut = retrieval.k.
Incrémentalité
- Clé =
content_hash(sha256du fichier brut complet). - Mode incrémental (défaut) : document sauté si le hash stocké est identique au hash courant ; sinon remplacement complet.
- Mode complet : tous les documents sont retraités.
- Pas de détection de suppression : un fichier retiré de la source reste
dans l'index (seul un
pathréingéré est remplacé). - Embeddings d'ingestion émis par lots (
embedding.batch_size), typepassage.
Contrats publics (points d'intégration)
ChunkerInterface
public function chunk(Document $document): array; // Chunk[]
VectorStoreInterface
public function initialize(): void; public function upsertDocument(Document $d, array $chunks, array $vectors): void; public function documentHash(string $documentPath): ?string; public function search(array $vector, int $k): array; // Hit[] public function stats(): array; // {documents, chunks, last_indexed_at}
Un intégrateur peut substituer chacune des trois implémentations (chunker, client d'embeddings, store) en redéfinissant le service correspondant.
Services concrets appelables
IngestionService::ingest(?string $sourceRoot = null, bool $incremental = true): IngestReportRetrievalService::retrieve(string $query, ?int $k = null): Hit[]SourceWalker::walk(string $sourceRoot): \Generator<Document>TokenEstimator::estimate(string $text): int
Modèles de données (DTO immuables)
DTO final, propriétés public readonly, sans logique.
Document: path, title, content, contentHash, frontMatter.Chunk: documentPath, documentTitle, section, anchor, position, content, tokenCount, contentHash.Hit: chunk, score.IngestReport: sourceRoot, documentsSeen/Indexed/Skipped, chunksUpserted, chunksUnchanged, durationSeconds.
Interface CLI
| Commande | Rôle | Options |
|---|---|---|
ingest |
Indexe une racine Markdown | --source=PATH, --full |
reindex |
Réindexation toujours incrémentale | --source=PATH |
search |
Teste une requête | argument query, --k=N |
stats |
État de l'index + santé du microservice | — |
stats dégrade proprement si l'index ou le microservice est indisponible
(avertissement, pas d'exception).
Configuration (racine telaria_rag)
| Clé | Type | Rôle |
|---|---|---|
source_root |
scalar | Racine des documents à indexer. |
included_paths |
list | Allowlist de préfixes relatifs (vide = tout). |
excluded_paths |
list | Préfixes relatifs exclus. |
embedding.service_url |
scalar | URL du microservice d'embeddings. |
embedding.model |
scalar | Identifiant du modèle (informatif côté bundle). |
embedding.dimension |
int ≥ 1 | Dimension des vecteurs (contrainte du store). |
embedding.timeout |
float | Timeout HTTP. |
embedding.batch_size |
int ≥ 1 | Taille des lots d'embedding. |
chunk.size |
int ≥ 1 | Taille cible d'un chunk (tokens estimés). |
chunk.overlap |
int ≥ 0 | Chevauchement (tokens). |
retrieval.k |
int ≥ 1 | Nombre de passages récupérés par défaut. |
store.database_path |
scalar | Fichier SQLite de l'index. |
store.extension_path |
scalar | Chemin du binaire d'extension vectorielle. |
Contraintes vérifiées ailleurs qu'au niveau config : overlap < size (à la
construction du chunker) et cohérence dimension ↔ dimension réelle du modèle (Ã
l'encodage dans le store).
Prérequis runtime
- Runtime PHP moderne (API objet PDO/SQLite avec chargement d'extension).
- Service HTTP client du framework activé.
- Extension vectorielle SQLite installée et chargeable (dépendance système, hors gestionnaire de paquets PHP).
- Microservice d'embeddings joignable à l'URL configurée (dépendance réseau externe, non fournie par le bundle).