Skip to content

LlamaIndex 2026 — data layer specialist

TL;DR — Si LangChain veut être un framework all-in (modèles, prompts, agents, RAG), LlamaIndex s'est resserré sur ce qu'il fait le mieux : la couche données. En 2026, on l'utilise pour 3 choses : LlamaParse (le meilleur parser PDF/DOCX/PPT du marché, surtout sur tableaux complexes et schémas), les index avancés (Knowledge Graph, Document Summary, Tree, Composable Graph), et les Workflows (alternative à LangGraph, plus event-driven). LlamaCloud (managed) gère ingestion + parsing + indexing en SaaS. Règle freelance FR : LangChain pour orchestrer, LlamaIndex pour ingérer.

🧠 Mental model

                ┌─────────────────────────────────────────────────┐
                │             LLAMAINDEX 2026 STACK                │
                └─────────────────────────────────────────────────┘

  Documents bruts                  Index                    Query
  ───────────────                ─────────              ─────────────
                                                                       
  PDF (DCE, bilans)  ─┐                                                
  DOCX (contrats)     │   ┌──────────────┐    ┌──────────────┐         
  PPT (decks)         ├──▶│ LlamaParse   │───▶│ Nodes        │         
  HTML (web)          │   │ (cloud OCR + │    │ + metadata   │         
  CSV/Parquet         │   │  layout AI)  │    │ + relations  │         
  Notion/Confluence  ─┘   └──────────────┘    └──────┬───────┘         


                                    ┌──────────────────────────────┐    
                                    │  Indexes                      │    
                                    │  • VectorStoreIndex           │    
                                    │  • TreeIndex (résumé hiér.)   │    
                                    │  • KnowledgeGraphIndex        │    
                                    │  • DocumentSummaryIndex       │    
                                    │  • KeywordTableIndex          │    
                                    │  • ComposableGraph (mix)      │    
                                    └──────────────┬───────────────┘    


                                    ┌──────────────────────────────┐    
                                    │  Query Engines                │    
                                    │  • RouterQueryEngine          │    
                                    │  • SubQuestionQueryEngine     │    
                                    │  • FLAREInstructQueryEngine   │    
                                    │  • KnowledgeGraphRAGRet.      │    
                                    └──────────────┬───────────────┘    


                                    ┌──────────────────────────────┐    
                                    │  Workflows (event-driven)     │    
                                    │  step → step → step           │    
                                    │  + branching, loops, HITL     │    
                                    └──────────────────────────────┘    

Analogie : LlamaIndex = ETL spécialisé LLM.
LlamaParse = Tesseract + AWS Textract sous stéroïdes (avec LLM en post-process).
Workflows = AWS Step Functions pour LLMs (event-driven, vs LangGraph qui est state-graph).

Diff vs LangChain en une phrase : LangChain pense « chaîne d'appels LLM ». LlamaIndex pense « comment indexer 50K PDF et les interroger ». Sur un RAG simple ils se valent. Sur ingestion industrielle de docs hétérogènes, LlamaIndex gagne nettement.

🛠️ Code minimal

python
# pip install llama-index llama-index-llms-anthropic llama-index-embeddings-voyageai
#             llama-parse llama-index-vector-stores-qdrant

from llama_index.core import VectorStoreIndex, Settings, SimpleDirectoryReader
from llama_index.llms.anthropic import Anthropic
from llama_index.embeddings.voyageai import VoyageEmbedding
from llama_parse import LlamaParse

# 1. Config globale
Settings.llm = Anthropic(model="claude-opus-4-8", temperature=0)
Settings.embed_model = VoyageEmbedding(model_name="voyage-3-large")

# 2. Parse + index
parser = LlamaParse(result_type="markdown", premium_mode=True)
documents = SimpleDirectoryReader(
    "./pdfs",
    file_extractor={".pdf": parser},
).load_data()

index = VectorStoreIndex.from_documents(documents)

# 3. Query
query_engine = index.as_query_engine(similarity_top_k=6, response_mode="compact")
response = query_engine.query("Quel est le ratio de solvabilité Banque X en 2024 ?")
print(response)
for src in response.source_nodes:
    print(f"- {src.metadata['file_name']} p.{src.metadata.get('page')}: score={src.score:.3f}")

🧩 Câbler Anthropic proprement (le détail qui sépare le junior du staff)

LlamaIndex abstrait le LLM, mais les défauts de l'intégration coûtent cher en prod. Trois choses que le wrapper Anthropic(...) ne fait pas pour toi et que tu dois piloter :

1. Tiering modèle par rôle. Tu n'as pas UN modèle, tu en as trois jobs distincts dans un pipeline RAG/KG. Ne les sers jamais avec le même tier :

Rôle dans le pipelineModèlePourquoi
Extraction KG (SchemaLLMPathExtractor, 1 appel/chunk × 50K)claude-haiku-4-5 (1 $/5 $)Volume massif, tâche structurée bornée par un schéma Pydantic → un petit modèle suffit. Opus ici = facture ×25 pour ~+3 % de précision.
Synthèse de réponse (response_synthesizer) sur le hot pathclaude-sonnet-4-6Le user attend la réponse, latence compte, qualité doit être bonne mais pas chirurgicale.
Routing / décomposition (RouterQueryEngine, SubQuestionQueryEngine)claude-haiku-4-5Choix de route = classification, pas de génération longue.
Eval / golden judge offlineclaude-opus-4-8 (5 $/25 $ @ 1M)Le juge doit être ≥ au modèle jugé, et c'est offline donc le coût est amorti.
python
from llama_index.llms.anthropic import Anthropic

EXTRACTOR_LLM  = Anthropic(model="claude-haiku-4-5",  temperature=0, max_retries=4, timeout=60)
SYNTH_LLM      = Anthropic(model="claude-sonnet-4-6", temperature=0, max_retries=4, timeout=60)
JUDGE_LLM      = Anthropic(model="claude-opus-4-8",   temperature=0, max_retries=4, timeout=120)

2. Adaptive thinking, PAS de budget_tokens. Sur 4.8 l'extended thinking se pilote en mode adaptatif + un niveau d'effort, jamais via un budget de tokens (qui renvoie 400). Réserve-le à la synthèse hybride difficile, surtout pas à l'extraction de masse :

python
# Synthèse d'une réponse hybride multi-source : on autorise du raisonnement.
SYNTH_LLM = Anthropic(
    model="claude-sonnet-4-6",
    temperature=0,
    thinking_dict={"type": "adaptive"},      # adaptatif, le modèle décide combien réfléchir
    # effort piloté côté output_config / generation_kwargs : low | medium | high
    max_tokens=4000,
)
# ❌ NE PLUS FAIRE : thinking={"type": "enabled", "budget_tokens": 8000}  -> HTTP 400 sur 4.7/4.8

3. Prompt caching sur le préfixe stable. Le parsing_instruction LlamaParse, le schéma KG, et le system prompt de synthèse sont identiques sur 50K appels. Sans cache_control, tu paies le plein tarif input à chaque chunk. Le wrapper Anthropic LlamaIndex expose le caching ; place le marqueur sur le plus long préfixe invariant (system + outils + schéma), jamais sur la partie variable (le chunk). Sur l'extraction KG de 50K docs, le caching du schéma + instruction divise typiquement la facture input par 3-5.

Mental model coût : dans un pipeline LlamaIndex, le LLM est appelé bien plus souvent que tu ne crois. Un seul index.query() « simple » peut déclencher : 1 appel routing + N appels de décomposition (sub-question) + 1 appel par node retrieved en mode refine + 1 appel reranking. Toujours logger.info(resp.raw.usage) et agréger par trace_id, sinon tu découvres la facture en fin de mois.

🎬 Cas d'usage concrets

Cas 1 — Ingestion 50K PDF archive (banque privée Paris, 1880 → 2024)

Contexte — Banque privée familiale, 50 K dossiers clients archivés depuis 1880 (registres manuscrits scannés), bilans annuels jusqu'en 2024 (PDF complexes avec tableaux financiers), correspondance. Mission : indexer le tout pour répondre à des questions de compliance KYC historique.

Pourquoi LlamaIndex — LlamaParse en mode premium_mode gère OCR + reconstruction de tableaux + layout. Sur scans manuscrits dégradés, j'ai testé Tesseract (catastrophe), AWS Textract (mieux, mais 0.04 $/page), LlamaParse premium ($0.045/page) avec post-process LLM = qualité supérieure (recall 92 % vs Textract 78 % sur tableau de bilan).

Pipeline retenu :

  1. LlamaParse premium → Markdown structuré + métadonnées par page.
  2. MarkdownNodeParser chunking respectueux de la structure (titres, tableaux préservés en bloc).
  3. Embeddings Voyage-3-large.
  4. Qdrant + métadonnées (année, type doc, client_id).
  5. RouterQueryEngine : route vers KeywordTableIndex pour recherches par nom propre, vs VectorStoreIndex pour questions sémantiques.

Pitfall réel — 50K PDF, parsing séquentiel = 11 jours. Passage en parallèle avec aparse_files + queue Redis = 18 h. Coût LlamaParse : ~$2 200 (50K × ~10 pages × $0.0045 estimé après remise volume).

ROI — Mission 32 j × 1300 € = 41 600 € HT. Le client a comparé à un dev maison estimé à 4-5 mois.

Cas 2 — Knowledge Graph sur BODACC + Infogreffe (legaltech Bordeaux)

Contexte — Startup legaltech qui vend de la "due diligence accélérée" à des fonds de PE. Veut un KG des entreprises françaises avec leurs dirigeants, filiales, procédures collectives, modifications statutaires. Sources : BODACC, Infogreffe, Pappers API.

Pourquoi LlamaIndex KGKnowledgeGraphIndex + PropertyGraphIndex (v0.11+) qui combine vector embeddings ET triples (sujet, prédicat, objet). On peut requêter "Quelles sociétés dont le dirigeant a aussi dirigé une société en LJ ?" → mix Cypher-like + sémantique.

Pipeline :

  1. Ingestion API BODACC → documents JSON.
  2. SchemaLLMPathExtractor avec schéma Pydantic (Entité = Société | Personne | Procédure ; Relations = DIRIGE, FILIALE_DE, EN_PROCEDURE).
  3. Stockage : Neo4j pour graph + Qdrant pour vectors (intégration native LlamaIndex).
  4. Query engine custom : sub-question router (graph query si entité nommée détectée, vector sinon).

Gain mesurable — Question "Sociétés liées à M. Dupont en LJ depuis 2023" : avec LlamaIndex KG, 4 hops graph + 2 sources sémantiques = réponse en 2.8s avec 94 % de précision. Sans KG (vector pur), précision 61 %.

ROI — Build initial 45 j × 1400 € = 63 K€ HT. Mission de TMA récurrente 4 j/mois.

Cas 3 — Catalogue produits mode avec tableaux et images (e-commerce Sentier)

Contexte — Marketplace mode FR/IT, 80K références. Catalogues fournisseurs en PDF (lookbooks luxe : photos + tableaux de matières + grille de tailles + composition). Objectif : assistant interne pour acheteurs ("Trouve-moi des trenchs en gabardine coton-poly < 380 €, dispo en 38-42").

Pourquoi LlamaIndex — LlamaParse multimodal extrait images + texte + tableaux. DocumentSummaryIndex génère un résumé par produit, utilisable comme première couche de filtrage avant retrieval fin sur les détails techniques.

Architecture :

  • Niveau 1 : DocumentSummaryIndex (résumé court par produit) → filtrage rapide.
  • Niveau 2 : VectorStoreIndex sur détails techniques (matière, composition).
  • Niveau 3 : ImageVectorIndex (CLIP-like embeddings) pour recherche visuelle.
  • Orchestration : ComposableGraph qui route selon le type de requête (texte / visuel / mixte).

ROI client — Acheteurs gagnent ~1h/jour de feuilletage manuel. 12 acheteurs × 1h × 220 j × 35 €/h = 92 K€/an de productivité débloquée. Build : 28 j × 1300 € = 36 400 € HT.

🛠️ Exemple end-to-end

Use case réel : ingestion + interrogation de dossiers DCE (Dossier de Consultation des Entreprises) en architecture/BTP. Chaque DCE = 30-200 PDF mêlant CCTP, plans, BPU, mémoires techniques. Requêtes hybrides "trouve-moi les marchés où le matériau X est imposé" (KG) + "résume les exigences thermiques RT2020" (vector).

python
"""
DCE BTP — pipeline LlamaParse + LlamaIndex hybride vector + Knowledge Graph
Mission : Cabinet de maîtrise d'œuvre, 600 DCE actifs, recherche transverse.
"""
import os, asyncio
from pathlib import Path
from typing import Literal

from llama_index.core import (
    Settings, SimpleDirectoryReader, StorageContext,
    VectorStoreIndex, PropertyGraphIndex, load_index_from_storage,
)
from llama_index.core.node_parser import MarkdownNodeParser
from llama_index.core.indices.property_graph import SchemaLLMPathExtractor
from llama_index.core.query_engine import RouterQueryEngine, SubQuestionQueryEngine
from llama_index.core.tools import QueryEngineTool, ToolMetadata
from llama_index.core.selectors import LLMSingleSelector
from llama_index.llms.anthropic import Anthropic
from llama_index.embeddings.voyageai import VoyageEmbedding
from llama_index.vector_stores.qdrant import QdrantVectorStore
from llama_index.graph_stores.neo4j import Neo4jPropertyGraphStore
from llama_parse import LlamaParse
from qdrant_client import QdrantClient

# ----------------------------------------------------------------------------
# 1. CONFIG
# ----------------------------------------------------------------------------
Settings.llm = Anthropic(model="claude-opus-4-8", temperature=0, max_tokens=2000)
Settings.embed_model = VoyageEmbedding(model_name="voyage-3-large", embed_batch_size=64)
Settings.chunk_size = 1024
Settings.chunk_overlap = 128

DCE_DIR = Path("./dce_archives")
STORAGE_DIR = Path("./storage")
QDRANT_URL = os.environ["QDRANT_URL"]
NEO4J_URL = os.environ["NEO4J_URL"]

# ----------------------------------------------------------------------------
# 2. SCHEMA KG  (entités et relations métier BTP)
# Schéma FERMÉ : SchemaLLMPathExtractor n'extrait QUE ces types + triples validés.
# C'est ce qui coupe 60-80 % des triples parasites vs un extracteur libre.
# ----------------------------------------------------------------------------
ENTITY_TYPES = Literal[
    "Marche", "Lot", "Materiau", "NormeTechnique",
    "Acteur", "Localisation", "Equipement",
]
RELATION_TYPES = Literal[
    "CONTIENT_LOT", "IMPOSE_MATERIAU", "EXIGE_NORME",
    "ATTRIBUE_A", "SITUE_A", "INCLUT_EQUIPEMENT",
]
VALID_TRIPLES = [
    ("Marche", "CONTIENT_LOT", "Lot"),
    ("Lot", "IMPOSE_MATERIAU", "Materiau"),
    ("Lot", "EXIGE_NORME", "NormeTechnique"),
    ("Marche", "ATTRIBUE_A", "Acteur"),
    ("Marche", "SITUE_A", "Localisation"),
    ("Lot", "INCLUT_EQUIPEMENT", "Equipement"),
]

# ----------------------------------------------------------------------------
# 3. INGESTION (LlamaParse premium pour DCE complexes)
# ----------------------------------------------------------------------------
parser = LlamaParse(
    result_type="markdown",
    premium_mode=True,           # OCR + tables + figures
    language="fr",
    parsing_instruction=(
        "Tu parses un DCE (Dossier Consultation Entreprises) du BTP français. "
        "Préserve les structures suivantes en blocs distincts : "
        "CCTP par lot, BPU (Bordereau Prix Unitaires) en tableaux, "
        "DPGF, références normatives (NF, EN, DTU). "
        "Ne reformule pas les exigences techniques."
    ),
    skip_diagonal_text=True,
)

async def parse_dce_folder(folder: Path) -> list:
    """Parse récursivement un dossier DCE → docs LlamaIndex."""
    file_extractor = {".pdf": parser, ".docx": parser}
    reader = SimpleDirectoryReader(
        input_dir=str(folder),
        recursive=True,
        file_extractor=file_extractor,
        filename_as_id=True,
    )
    docs = await reader.aload_data()
    # Métadonnées : nom DCE = dossier parent
    for d in docs:
        rel = Path(d.metadata["file_path"]).relative_to(folder)
        d.metadata["dce_name"] = rel.parts[0] if len(rel.parts) > 1 else "root"
        d.metadata["doc_type"] = _infer_doc_type(rel.name)
    return docs

def _infer_doc_type(filename: str) -> str:
    n = filename.lower()
    if "cctp" in n: return "CCTP"
    if "bpu" in n: return "BPU"
    if "dpgf" in n: return "DPGF"
    if "memoire" in n or "mémoire" in n: return "Memoire"
    if "plan" in n: return "Plan"
    return "Autre"

# ----------------------------------------------------------------------------
# 4. INDEX HYBRIDE  (vector + property graph)
# ----------------------------------------------------------------------------
def build_vector_index(docs):
    qdrant = QdrantClient(url=QDRANT_URL)
    vector_store = QdrantVectorStore(client=qdrant, collection_name="dce_vector")
    storage = StorageContext.from_defaults(vector_store=vector_store)
    node_parser = MarkdownNodeParser()
    nodes = node_parser.get_nodes_from_documents(docs)
    return VectorStoreIndex(nodes, storage_context=storage, show_progress=True)

def build_graph_index(docs):
    graph_store = Neo4jPropertyGraphStore(
        username=os.environ["NEO4J_USER"],
        password=os.environ["NEO4J_PASS"],
        url=NEO4J_URL,
    )
    extractor = SchemaLLMPathExtractor(
        llm=Settings.llm,
        possible_entities=ENTITY_TYPES,
        possible_relations=RELATION_TYPES,
        kg_validation_schema=VALID_TRIPLES,
        strict=True,
    )
    return PropertyGraphIndex.from_documents(
        docs,
        kg_extractors=[extractor],
        property_graph_store=graph_store,
        show_progress=True,
    )

# ----------------------------------------------------------------------------
# 5. QUERY ENGINE HYBRIDE  (router → vector OR graph OR sub-question)
# ----------------------------------------------------------------------------
def build_hybrid_engine(vector_index, graph_index):
    vector_engine = vector_index.as_query_engine(
        similarity_top_k=8,
        response_mode="compact",
    )
    graph_engine = graph_index.as_query_engine(
        include_text=True,
        sub_retrievers=["llm_synonym", "vector_context"],
    )

    sub_q_engine = SubQuestionQueryEngine.from_defaults(
        query_engine_tools=[
            QueryEngineTool(
                query_engine=vector_engine,
                metadata=ToolMetadata(
                    name="vector_dce",
                    description="Cherche dans le contenu textuel des DCE (CCTP, BPU, mémoires).",
                ),
            ),
            QueryEngineTool(
                query_engine=graph_engine,
                metadata=ToolMetadata(
                    name="kg_dce",
                    description="Cherche dans le graphe entités (Marchés, Lots, Matériaux, Normes).",
                ),
            ),
        ],
    )

    router = RouterQueryEngine(
        selector=LLMSingleSelector.from_defaults(llm=Settings.llm),
        query_engine_tools=[
            QueryEngineTool(
                query_engine=vector_engine,
                metadata=ToolMetadata(
                    name="texte",
                    description="Questions sémantiques, résumés, citations exactes."
                ),
            ),
            QueryEngineTool(
                query_engine=graph_engine,
                metadata=ToolMetadata(
                    name="graphe",
                    description="Questions structurelles : relations entre marchés, matériaux, normes."
                ),
            ),
            QueryEngineTool(
                query_engine=sub_q_engine,
                metadata=ToolMetadata(
                    name="multi",
                    description="Questions complexes nécessitant décomposition en sous-questions."
                ),
            ),
        ],
    )
    return router

# ----------------------------------------------------------------------------
# 6. MAIN
# ----------------------------------------------------------------------------
async def main():
    if not (STORAGE_DIR / "built").exists():
        print(">> Parsing DCE folder…")
        docs = await parse_dce_folder(DCE_DIR)
        print(f"   {len(docs)} documents parsed.")

        print(">> Building vector index…")
        vec = build_vector_index(docs)
        vec.storage_context.persist(persist_dir=str(STORAGE_DIR / "vector"))

        print(">> Building knowledge graph…")
        kg = build_graph_index(docs)
        kg.storage_context.persist(persist_dir=str(STORAGE_DIR / "graph"))

        (STORAGE_DIR / "built").touch()
    else:
        print(">> Loading existing indexes…")
        vec_sc = StorageContext.from_defaults(persist_dir=str(STORAGE_DIR / "vector"))
        vec = load_index_from_storage(vec_sc)
        kg_sc = StorageContext.from_defaults(persist_dir=str(STORAGE_DIR / "graph"))
        kg = load_index_from_storage(kg_sc)

    engine = build_hybrid_engine(vec, kg)

    questions = [
        # Question vector (sémantique)
        "Quelles sont les exigences thermiques RT2020 pour les lots de menuiserie extérieure dans le DCE Lyon-Confluence ?",
        # Question graph (structurelle)
        "Liste les marchés où le matériau 'béton bas carbone' est imposé.",
        # Question hybride (sub-question)
        "Compare les normes acoustiques exigées entre les DCE de logements collectifs vs tertiaire sur 2024-2025.",
    ]
    for q in questions:
        print(f"\n## {q}\n")
        resp = engine.query(q)
        print(resp)
        for s in resp.source_nodes[:3]:
            print(f"   src: {s.metadata.get('file_name')} dce={s.metadata.get('dce_name')}")

if __name__ == "__main__":
    asyncio.run(main())

Métriques mesurées sur 80 DCE pilotes (cabinet MOE Bordeaux) :

  • Parsing : 6.2 s/PDF moyenne (LlamaParse premium).
  • Indexing : 11 min pour 80 DCE (~3 200 docs après split).
  • Query latence p50 : 3.1 s (router + retrieve + generate).
  • Précision sur 50 questions test : 87 % (validée par chef de projet).
  • Coût ingestion totale : ~$340 LlamaParse + ~$60 LLM extraction KG + ~$15 embeddings = ~$415.

🎯 Patterns courants

1. Composable Graph — indexer N DCE comme sous-index puis méta-index par-dessus :

python
from llama_index.core import ComposableGraph
graph = ComposableGraph.from_indices(
    SummaryIndex, [dce1_index, dce2_index, ...],
    index_summaries=[f"DCE {n}" for n in dce_names],
)

2. Metadata filters avancés sur retrieval :

python
from llama_index.core.vector_stores import MetadataFilter, MetadataFilters
filters = MetadataFilters(filters=[
    MetadataFilter(key="dce_name", value="lyon-confluence"),
    MetadataFilter(key="doc_type", value="CCTP"),
])
engine = index.as_query_engine(filters=filters, similarity_top_k=6)

3. Hybrid search BM25 + vector :

python
from llama_index.retrievers.bm25 import BM25Retriever
from llama_index.core.retrievers import QueryFusionRetriever
fusion = QueryFusionRetriever(
    [vector_index.as_retriever(), BM25Retriever.from_defaults(nodes=nodes)],
    similarity_top_k=8, num_queries=4, mode="reciprocal_rerank",
)

4. Workflows event-driven (alternative LangGraph) :

python
from llama_index.core.workflow import Workflow, step, Event, StartEvent, StopEvent

class IngestEvent(Event): path: str
class IndexEvent(Event): nodes: list

class IngestPipeline(Workflow):
    @step
    async def parse(self, ev: StartEvent) -> IngestEvent:
        return IngestEvent(path=ev.path)
    @step
    async def index(self, ev: IngestEvent) -> IndexEvent:
        ...
    @step
    async def finalize(self, ev: IndexEvent) -> StopEvent:
        return StopEvent(result="ok")

5. Reranking :

python
from llama_index.postprocessor.cohere_rerank import CohereRerank
engine = index.as_query_engine(
    node_postprocessors=[CohereRerank(top_n=4, model="rerank-multilingual-v3.0")],
)

6. Streaming réponse :

python
engine = index.as_query_engine(streaming=True)
for tok in engine.query("...").response_gen:
    print(tok, end="", flush=True)

7. Citation node parser — préserve les références exactes pour citation finale.

📊 Observabilité, scale & failure modes (vue staff)

Un pipeline LlamaIndex en prod casse rarement à l'endroit où le junior regarde. La couche données est batch, longue, coûteuse, et silencieuse — l'observabilité s'y joue différemment d'un service requête/réponse.

Instrumenter le pipeline. LlamaIndex émet des spans OpenTelemetry via llama-index-callbacks / instrumentation native. Branche un collector (Langfuse, Phoenix/Arize, ou OTel → ton backend) pour voir chaque appel LLM, chaque retrieve, chaque rerank avec tokens et latence. Sans ça, tu ne sais pas que ton RouterQueryEngine fait 4 appels là où tu en imaginais 1.

python
from llama_index.core import set_global_handler
set_global_handler("langfuse")   # ou "arize_phoenix"
# chaque query() devient une trace : route -> retrieve -> rerank -> synth, avec usage par span

Les failure modes réels et leur signature :

Failure modeSignature observableCause racineFix
Recall qui s'effondre silencieusementEval recall@k chute, pas d'erreurChunking par défaut coupe les tableaux post-LlamaParseMarkdownNodeParser, garder les tables en bloc
Facture LLM ×10 inexpliquéeusage agrégé exploseresponse_mode="refine" → 1 appel/node ; ou Opus sur l'extraction KGcompact/tree_summarize, Haiku sur extraction
Ingestion qui ne finit jamaisJob bloqué, throughput → 0LlamaParse séquentiel + rate limit Anthropic sur l'extraction KGnum_workers, backoff (max_retries), queue Redis
Updates incrémentaux dupliquésDoublons en base vectorielleSimpleDirectoryReader sans filename_as_id → nouveaux doc_idsfilename_as_id=True + refresh_ref_docs
KG plein de triples bidonPrécision graph faible, nœuds explosésSchemaLLMPathExtractor sans strict=True, schéma trop permissifstrict=True, schéma fermé, kg_validation_schema
OverloadedError en pic d'ingestion529 par rafalesParallélisme trop agressif vs quotaasyncio.Semaphore borné + OverloadedError retry SDK

Idempotence & reprise sur incident. Une ingestion de 50K docs DOIT être reprenable. Le pattern : un manifeste {doc_id: hash, status, parsed_at}. Au redémarrage, skip ce qui est déjà indexed. Sans ça, un crash à 80 % te coûte 11 jours de re-parsing. La gate (STORAGE_DIR / "built").exists() du code end-to-end est la version naïve ; en prod c'est un manifeste par document, pas un flag global.

Scale : le goulot n'est pas le vector store. Sur >10K docs, le coût et la latence d'ingestion sont dominés par (1) LlamaParse premium (I/O réseau + facture page) et (2) l'extraction KG (1 appel LLM/chunk). Le vector store ingère vite. Donc on parallélise le parsing (workers bornés), on batch les embeddings (embed_batch_size=64), et on rend l'extraction KG asynchrone et reprenable, pas le vector index.

🔄 Versions & écosystème 2026

  • llama-index 0.12.x — Settings global (remplace ServiceContext), Workflows stables, PropertyGraphIndex remplace KnowledgeGraphIndex legacy.
  • llama-parse — versions premium, multimodal_premium, et balanced (économique). Tarif 2026 ~ $0.003-$0.045/page selon mode et volume.
  • LlamaCloud — managed ingestion pipelines + parsing + indexing. $99/mois starter, enterprise custom. RAG-as-a-service de fait.
  • llama-index-llms-anthropic — support Opus 4.8 / Sonnet 4.6 / Haiku 4.5, prompt caching, adaptive thinking (thinking={"type": "adaptive"} + output_config effort low/medium/high). ⚠️ L'ancienne forme budget_tokens renvoie HTTP 400 sur 4.7/4.8 — ne plus l'utiliser.
  • llama-index-llms-bedrock-converse — Anthropic via AWS Bedrock pour souveraineté FR (régions Paris).
  • llama-index-vector-stores- — connecteurs Qdrant, Weaviate, Pinecone, pgvector, ChromaDB, Milvus, Vespa, LanceDB, Elasticsearch, Mongo Atlas.
  • llama-index-graph-stores-neo4j — KG officiel.
  • JS / TS : llamaindex npm package, parité ≈ 70 % (en retard sur PropertyGraphIndex et Workflows complexes).

⚠️ Pitfalls

  1. LlamaParse async = parallèle, mais coûte — par défaut séquentiel. Passer num_workers=10 et surveiller la facture (premium $0.045/page × 50K pages = $2 250).
  2. PropertyGraphIndex ≠ KnowledgeGraphIndex — l'ancien est legacy, le nouveau utilise Pydantic schemas et est nettement plus précis. Migration manuelle requise.
  3. SchemaLLMPathExtractor lent et cher — appelle le LLM par chunk. Sur 50K docs, c'est 50K appels. Utiliser un modèle plus petit (Sonnet ou Haiku) avec strict=True pour réduire la facture.
  4. Tokens cachésRouterQueryEngine fait un appel LLM JUSTE pour choisir la route, en plus de la query. Sur petits volumes négligeable, sur 1M req/jour ça compte.
  5. SimpleDirectoryReader non-idempotent — recrée des doc_ids différents à chaque run si pas de filename_as_id=True. Casse les updates incrémentaux.
  6. Settings global = singleton — pratique en script, dangereux en serveur multi-tenant. Préférer passer llm= et embed_model= explicites par contexte.
  7. Chunking par défaut massacre les tableaux — utiliser MarkdownNodeParser après LlamaParse markdown, sinon les tableaux sont coupés et illisibles.
  8. Neo4j-aura quotas free — vite saturé (200K nodes max free). Prévoir Aura Pro ou self-hosted dès la prod.
  9. PDF scannés sans OCR — sans premium_mode, LlamaParse renvoie du texte vide pour les scans. Activer explicitement.
  10. Reranking coûteux — Cohere rerank $1/1K req, sur 500 req/min = $720/jour. Cacher le résultat ou skipper si top_k déjà petit.

💰 Pricing / ROI client

LlamaIndex (open source) — gratuit.

LlamaParse (cloud) — tarifs 2026 indicatifs :

  • balanced : $0.003/page (économique, qualité OK)
  • premium : $0.025/page (OCR + tables + figures, RAG-ready)
  • multimodal_premium : $0.045/page (analyse images + reasoning)

LlamaCloud — $99/mois starter (10K pages/mois), $499/mois growth, enterprise custom.

ROI freelance FR :

  • Mission ingestion archive bancaire 50K docs : 35 j × 1300 € = 45 500 € HT. LlamaParse costs $2 200 (refacturable). Économise au client ~4 mois de dev custom.
  • KG Infogreffe/BODACC : build 45 j × 1400 € = 63 000 € HT. Maintenance 4 j/mois × 1300 € = 5 200 €/mois.
  • Audit "ingestion catastrophique" client existant : 5 j × 1200 € = 6 000 € HT. Découverte typique : chunking cassé, gain recall +30 % en 5 j.

Argumentaire commercial — "LlamaParse premium coûte 2K€ pour digérer 50K pages, vs 6 mois d'un junior à recoder. ROI évident, et la qualité parse est supérieure à AWS Textract sur PDF complexes français."

🧪 Testing / Eval

python
from llama_index.core.evaluation import (
    FaithfulnessEvaluator,
    RelevancyEvaluator,
    CorrectnessEvaluator,
    BatchEvalRunner,
)

evaluator_dict = {
    "faithfulness": FaithfulnessEvaluator(llm=Settings.llm),
    "relevancy": RelevancyEvaluator(llm=Settings.llm),
    "correctness": CorrectnessEvaluator(llm=Settings.llm),
}
runner = BatchEvalRunner(evaluator_dict, workers=4, show_progress=True)

# eval_questions = ["...", "..."]  + gold answers
results = await runner.aevaluate_queries(
    query_engine=engine,
    queries=eval_questions,
)
for metric, scores in results.items():
    mean = sum(s.score for s in scores if s.score is not None) / len(scores)
    print(f"{metric}: {mean:.3f}")

LlamaIndex ships avec :

  • Faithfulness : la réponse est-elle fidèle au contexte retrieved ?
  • Relevancy : le contexte est-il pertinent vs la question ?
  • Correctness : la réponse vs gold answer ?
  • Semantic Similarity : embedding-based eval.

Pour eval ingestion : créer un eval set "X devrait être trouvé dans le doc Y page Z" → mesurer recall@k.

🔁 Quand utiliser / éviter

Utiliser LlamaIndex quand :

  • Ingestion massive de documents complexes (PDF avec tables, scans, multi-format)
  • Besoin d'un Knowledge Graph (PropertyGraphIndex)
  • Mix vector + lexical + structured retrieval
  • Volumes importants (> 10K docs) — pipelines optimisés
  • Multi-format (PDF, DOCX, HTML, Notion, Confluence, Slack) — connecteurs prêts
  • LlamaParse pour qualité OCR

Éviter LlamaIndex quand :

  • Application simple LLM sans RAG → overhead inutile
  • Besoin d'orchestration multi-agent complexe → LangGraph ou Workflows
  • Stack JS uniquement et features avancées → parité incomplète, LangChain.js plus mature
  • Contrôle fin sur prompts et chains → trop d'abstraction parfois

Combo gagnant 2026 : LlamaIndex pour ingestion + indexes, LangGraph pour orchestration, raw SDK pour le hot path de génération.

🏋️ Exercices

Progressifs, du « ça marche » au « défends le chiffre en prod ». Fais-les sur un vrai corpus (10-20 PDF avec tableaux suffisent pour sentir la douleur).

Exo 1 — Le RAG qui ment sur les tableaux

Objectif : prouver que le chunking par défaut détruit le recall sur les données tabulaires, puis le réparer. Ingère 10 bilans PDF avec SimpleDirectoryReader chunking par défaut, puis avec LlamaParse markdown + MarkdownNodeParser. Pose 15 questions dont la réponse est dans une cellule de tableau. Mesure recall@5 dans les deux cas. Indice/Solution : le défaut splitte au milieu des lignes de table → le node retrieved contient une demi-ligne sans en-tête. MarkdownNodeParser garde la table en bloc. Attends-toi à +25-40 pts de recall. Le livrable est le chiffre, pas le code.

Exo 2 — Le routeur qui coûte un appel caché

Objectif : quantifier le coût réel d'un RouterQueryEngine et décider s'il vaut le coup. Branche Langfuse/Phoenix. Lance 100 requêtes à travers un router 3-voies. Compte le nombre exact d'appels LLM et de tokens par requête. Compare au coût d'un retrieval direct sans router. À partir de quel volume/jour le router devient-il le mauvais choix ? Indice/Solution : le router ajoute 1 appel de classification systématique. Sur Haiku c'est négligeable ; si tu l'as câblé sur Opus (erreur classique), c'est le poste de coût n°1. Réponse attendue : « route avec Haiku, ou remplace par un classifieur embeddings si le routing est stable ».

Exo 3 — Extraction KG à l'échelle sans exploser la facture

Objectif : indexer 1K docs en PropertyGraphIndex pour < X $ et un graph propre. Lance SchemaLLMPathExtractor sur 1K docs avec Opus, schéma ouvert. Mesure coût + nombre de triples bidon. Refais avec Haiku, strict=True, schéma fermé + kg_validation_schema. Compare coût, précision des triples, et latence totale. Indice/Solution : Opus ici = facture ×25 pour un gain marginal sur une tâche bornée par schéma. Le schéma fermé + strict=True coupe 60-80 % des triples parasites. Bonus : ajoute prompt caching sur le schéma/instruction → input ÷3-5.

Exo 4 — Rends l'ingestion reprenable (casse-la, puis répare-la)

Objectif : transformer le pipeline end-to-end en pipeline production-grade idempotent. Lance l'ingestion de 200 docs, tue le process à 50 %. Relance : combien de docs sont re-parsés ? Maintenant implémente un manifeste {doc_id, content_hash, status} qui skip les docs déjà indexed et re-parse seulement ceux dont le hash a changé. Re-tue, re-lance : zéro re-parsing inutile. Indice/Solution : la gate built.exists() du code de référence est tout-ou-rien. Remplace par un manifeste persistant (SQLite/Redis) + refresh_ref_docs. Le hash de contenu permet aussi les updates incrémentaux. C'est ce qui sépare un POC d'un pipeline qu'on ose relancer un lundi matin.

Exo 5 — Hybrid retrieval : prouve que la fusion bat chaque retriever seul

Objectif : démontrer chiffres à l'appui que BM25 + vector + rerank > chacun isolé, sur des requêtes adverses. Construis un eval set de 30 questions dont 10 « lexicales » (référence normative exacte type « DTU 13.2 ») et 10 « sémantiques » (paraphrases). Mesure recall pour : vector seul, BM25 seul, QueryFusionRetriever (reciprocal rerank), puis + CohereRerank. Trace où chaque méthode échoue. Indice/Solution : le vector rate les codes/références exactes (DTU 13.2 ≈ DTU 13.3 en embedding), BM25 rate les paraphrases. La fusion récupère les deux familles ; le rerank améliore la précision@k mais ajoute du coût ($1/1K req Cohere) — quantifie-le et décide.

Exo 6 — Défends le « build vs LlamaCloud » devant un CTO

Objectif : poser le calcul de make-vs-buy sur l'ingestion, pas l'intuition. Pour un corpus de 200K pages/mois, compare : (a) LlamaCloud growth, (b) self-hosted LlamaParse premium + Qdrant managé + ton orchestration. Inclus coût infra, coût LlamaParse à la page, coût LLM extraction, et coût humain de maintenance (jours/mois). Donne le seuil de bascule. Indice/Solution : LlamaCloud gagne sous un certain volume et quand tu n'as pas d'équipe data ; le self-host gagne au volume élevé et quand la souveraineté/le tuning du parsing comptent (régions FR via Bedrock). La bonne réponse est un seuil chiffré + les 2-3 facteurs non monétaires (souveraineté, lock-in, vitesse de delivery).

🎤 En entretien

Q : LangChain ou LlamaIndex pour un RAG d'entreprise ? Pourquoi pas les deux ? R : LlamaIndex pour la couche données (LlamaParse + indexes avancés + ingestion reprenable), LangGraph pour l'orchestration multi-agent stateful, raw SDK Anthropic sur le hot path de génération. Ce ne sont pas des concurrents mais des couches.

Q : Tu ingères 50K PDF, le recall est mauvais. Par où tu débugues ? R : D'abord le parsing (LlamaParse premium activé ? tables préservées ?), puis le chunking (MarkdownNodeParser, pas le splitter par défaut qui coupe les tables), puis le retrieval (top_k, hybrid BM25+vector, rerank). Je mesure recall@k à chaque étape — le problème est presque toujours en amont du retrieval, pas dans le LLM.

Q : KnowledgeGraphIndex vs PropertyGraphIndex ? R : L'ancien (legacy) extrait des triples libres, bruités et difficiles à requêter. PropertyGraphIndex (0.11+) combine embeddings ET triples typés par un schéma Pydantic + validation, et permet le retrieval hybride graph + vector. En 2026 on part sur PropertyGraphIndex, et on borne l'extraction avec SchemaLLMPathExtractor(strict=True) sur un petit modèle.

Q : Comment tu maîtrises le coût LLM d'un pipeline LlamaIndex ? R : Tiering par rôle (Haiku pour extraction/routing, Sonnet pour synthèse, Opus seulement pour l'eval offline), prompt caching sur le préfixe stable (schéma + instructions), response_mode compact plutôt que refine, et observabilité (usage agrégé par trace) pour voir les appels cachés du router/sub-question. Le piège classique : Opus partout « pour la qualité » → facture ×25 sur des tâches qu'un Haiku borné par schéma fait aussi bien.

🔗 Liens

Bibliothèque tech perso — Achref