RAG 09 — Au-delà du texte : le plafond multimodal du service OSS et le pont vision

Navigation : Index | << Précédent

Les notebooks 07 et 08 ont ingéré des documents texte — et tout a bien se passé. Mais un corpus d’entreprise réel n’est pas fait que de texte : schémas techniques, captures d’écran, diagrammes d’architecture. Que devient le pipeline extract → partition → gen_embeddings → save_records quand le document est une image ?

Ce notebook mesure la frontière modale du service Kernel Memory OSS, puis construit le pont standard qui la franchit :

  1. Corpus modalement polarisé : un PDF réel du dépôt (contrôle positif), quatre documents texte (substrat de recherche), et un schéma PNG dont le texte n’existe nulle part ailleurs dans le corpus ;
  2. Constat : le service accepte l’image (HTTP 202) mais son pipeline stand silencieusement à l’étape d’extraction — aucun record, aucune erreur, le statut d’upload ne complète jamais. Le plafond est réel : l’extraction d’image exige un connecteur OCR que le conteneur OSS n’embarque pas ;
  3. Extracteur dédié (tika) : le cluster héberge un conteneur Apache Tika (OCR Tesseract). Il lit le PDF proprement, mais sur le PNG son OCR dégrade la restitution lexicale : la mesure à deux bras du §4 montre que le plafond n’est pas le vocabulaire technique — un même PNG agrandi ×3 (Lanczos, sans réécriture de glyphes) remonte de 2/5 à 5/5 tokens. Le facteur explicatif est la taille de rendu (police bitmap ~11 px sans font= dans ImageDraw.text), pas la nature technique des termes ;
  4. Pont vision : un modèle de vision du cluster (Qwen3.6 casa, servi par le vLLM maison, endpoint OpenAI-compatible du .env) décrit le PNG — en citant son texte verbatim — puis cette description est ingérée comme document texte ;
  5. Mesure before/after : les termes qui n’existaient que dans l’image (capteur impair, faux negatifs) sont introuvables avant le pont et retrouvés après — sur le document-pont, avec citations.

Dépendances : pip install requests python-dotenv pillow (+ Docker pour les deux conteneurs). Le .env de la série (MyIA.AI.Notebooks/GenAI/.env, gitignored) fournit l’endpoint OpenAI-compatible — même configuration que le 07 et le 08.

Plan

  1. Infrastructure : Qdrant + service Kernel Memory
  2. Corpus : l’axe modalité (PDF réel, quatre textes, un schéma PNG)
  3. Ingestion et constat : le 202 qui ne complète jamais
  4. Ce qu’un extracteur dédié fait (et ne fait pas) : tika sur PDF et PNG
  5. Le pont vision : décrire l’image pour l’ingérer
  6. Mesure contrôlée avant/après (attendus écrits avant)
  7. Lecture, coûts, exercices, nettoyage
import json
import os
import shutil
import subprocess
import tempfile
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# --- Resolution du .env de la serie GenAI (gitignored) -------------
ENV_CANDIDATES = [
    Path(os.environ.get("GENAI_ENV_FILE", "")) if os.environ.get("GENAI_ENV_FILE") else None,
    Path.cwd() / "MyIA.AI.Notebooks" / "GenAI" / ".env",
    Path.cwd().parent / "MyIA.AI.Notebooks" / "GenAI" / ".env",
    Path.cwd().parents[1] / "MyIA.AI.Notebooks" / "GenAI" / ".env" if len(Path.cwd().parents) > 1 else None,
]
ENV_PATH = next((p for p in ENV_CANDIDATES if p and p.is_file()), None)
if ENV_PATH is None:
    # Degradation propre (regle C.1) : sans .env, la mesure sera sautee via INFRA_OK
    print("AVERTISSEMENT : .env de la serie GenAI introuvable "
          "(attendu : MyIA.AI.Notebooks/GenAI/.env, cf. .env.example)")
else:
    load_dotenv(ENV_PATH)

# --- Configuration --------------------------------------------------
INDEX = "km13421-multi"
KM_PORT = 9001
KM_URL = f"http://localhost:{KM_PORT}"
QDRANT_LOCAL = "http://localhost:6333"
QDRANT_CONTAINER = "qdrant_rag09"
KM_CONTAINER = "km_service_rag09"
KM_FILES_VOLUME = "km_rag09_files"
QDRANT_VOLUME = "qdrant_rag09_data"

EMBED_MODEL = "text-embedding-3-small"

# Pont vision -- chemin principal : le vLLM maison du cluster (Qwen3.6-35B-A3B AWQ,
# vision+thinking, GPU 0+1). Aucun cout par execution, aucune cle tierce : la cle est
# la VLLM_API_KEY du cluster. Alternative documentee (secours) : openrouter, BYOK.
VLLM_BASE = os.getenv("VLLM_BASE_URL", "https://api.medium.text-generation-webui.myia.io/v1").rstrip("/")
VLLM_KEY = os.getenv("VLLM_API_KEY", "")
VISION_MODEL = os.getenv("VISION_MODEL", "qwen3.6-35b-a3b")

OR_BASE = os.getenv("OPENROUTER_BASE_URL", "").rstrip("/")
OR_KEY = os.getenv("OPENROUTER_API_KEY", "")
VISION_FALLBACK_MODEL = "google/gemini-3.7-flash"
# Modele TEXTE du service KM (generation/embedding cote service) -- separe du modele
# VISION : le service KM parle a un endpoint texte (openrouter ici), pas au vLLM maison.
TEXT_MODEL = os.getenv("KM_TEXT_MODEL", "google/gemini-3.7-flash")
corpus_dir = Path(tempfile.mkdtemp(prefix="km09_corpus_"))

def mask(url: str) -> str:
    """Host seul d'une URL -- jamais d'eventuels credentials."""
    try:
        return url.split("//", 1)[1].split("/", 1)[0]
    except Exception:
        return "(non configure)"

env_desc = f"{ENV_PATH.name} ({ENV_PATH.parent.parent.name}/{ENV_PATH.parent.name})" if ENV_PATH else "INTROUVABLE"
print(f".env charge          : {env_desc}")
print(f"Endpoint embeddings  : {mask(OR_BASE) or '(non configure)'}")
print(f"Endpoint vision      : {mask(VLLM_BASE)} ({'cle presente' if VLLM_KEY else 'CLE ABSENTE'})")
print(f"Modele embeddings    : {EMBED_MODEL}")
print(f"Modele vision        : {VISION_MODEL} (vLLM maison ; secours openrouter : {VISION_FALLBACK_MODEL})")
print(f"Corpus provisoire    : {corpus_dir.name}/ (repertoire temporaire)")
.env charge          : .env (MyIA.AI.Notebooks/GenAI)
Endpoint embeddings  : openrouter.ai
Endpoint vision      : api.medium.text-generation-webui.myia.io (CLE ABSENTE)
Modele embeddings    : text-embedding-3-small
Modele vision        : qwen3.6-35b-a3b (vLLM maison ; secours openrouter : google/gemini-3.7-flash)
Corpus provisoire    : km09_corpus_jmcjkgov/ (repertoire temporaire)

1. Infrastructure : Qdrant + service Kernel Memory

Même patron que le 07 et le 08 : un Qdrant local jetable et le conteneur kernelmemory/service configuré par variables d’environnement (mapping appsettings par doubles underscores). Le backend d’embeddings est l’endpoint OpenAI-compatible du .env. Sans Docker ou sans clé, les cellules passent en mode dégradé (INFRA_OK = False) et la mesure est sautée proprement (règle C.1).

def docker_available() -> bool:
    try:
        r = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
        return r.returncode == 0
    except Exception:
        return False

def qdrant_up() -> bool:
    try:
        r = requests.get(f"{QDRANT_LOCAL}/healthz", timeout=5)
        return "healthz check passed" in r.text
    except Exception:
        return False

def km_up() -> bool:
    try:
        r = requests.get(f"{KM_URL}/", timeout=5)
        return r.status_code == 200 and "Ingestion service" in r.text
    except Exception:
        return False

DOCKER_OK = docker_available()
print(f"Docker dispo : {DOCKER_OK}")

qdrant_ready = qdrant_up()
if not qdrant_ready and DOCKER_OK:
    subprocess.run(["docker", "rm", "-f", QDRANT_CONTAINER], capture_output=True)
    r = subprocess.run(
        ["docker", "run", "-d", "--rm", "--name", QDRANT_CONTAINER,
         "-p", "6333:6333", "-p", "6334:6334",
         "-v", f"{QDRANT_VOLUME}:/qdrant/storage",
         "qdrant/qdrant:latest"],
        capture_output=True, timeout=180)
    print(f"Conteneur Qdrant lance (rc={r.returncode})")
    for _ in range(30):
        time.sleep(2)
        if qdrant_up():
            break

if DOCKER_OK and qdrant_up() and not km_up():
    subprocess.run(["docker", "rm", "-f", KM_CONTAINER], capture_output=True)
    cmd = [
        "docker", "run", "-d", "--rm", "--user", "root",
        "--name", KM_CONTAINER,
        "-p", f"{KM_PORT}:9001",
        "-v", f"{KM_FILES_VOLUME}:/km-files",
        # Backend embeddings (endpoint OpenAI-compatible) -- cles jamais affichees
        "-e", f"KernelMemory__Services__OpenAI__Endpoint={OR_BASE}",
        "-e", f"KernelMemory__Services__OpenAI__APIKey={OR_KEY}",
        "-e", f"KernelMemory__Services__OpenAI__EmbeddingModel={EMBED_MODEL}",
        "-e", "KernelMemory__Services__OpenAI__EmbeddingModelMaxTokenTotal=8191",
        "-e", f"KernelMemory__Services__OpenAI__TextModel={TEXT_MODEL}",
        "-e", "KernelMemory__Services__OpenAI__TextModelMaxTokenTotal=60000",
        "-e", "KernelMemory__Services__OpenAI__TextGenerationType=Chat",
        "-e", "KernelMemory__TextGeneratorType=OpenAI",
        "-e", "KernelMemory__Retrieval__EmbeddingGeneratorType=OpenAI",
        "-e", "KernelMemory__DataIngestion__EmbeddingGeneratorTypes__0=OpenAI",
        "-e", "KernelMemory__Retrieval__MemoryDbType=Qdrant",
        "-e", "KernelMemory__DataIngestion__MemoryDbTypes__0=Qdrant",
        "-e", "KernelMemory__Services__Qdrant__Endpoint=http://host.docker.internal:6333",
        "-e", "KernelMemory__Services__SimpleFileStorage__StorageType=Disk",
        "-e", "KernelMemory__Services__SimpleFileStorage__Directory=/km-files",
        "-e", "KernelMemory__DataIngestion__DefaultSteps=extract,partition,gen_embeddings,save_records",
        "kernelmemory/service:latest",
    ]
    r = subprocess.run(cmd, capture_output=True, timeout=300)
    print(f"Conteneur service KM lance (rc={r.returncode})")
    for _ in range(45):
        time.sleep(2)
        if km_up():
            break

qdrant_ready = qdrant_up()
km_ready = km_up()
INFRA_OK = bool(qdrant_ready and km_ready and OR_KEY)
print(f"Qdrant pret : {qdrant_ready} | service KM pret : {km_ready}")
print(f"INFRA_OK (mesure possible) : {INFRA_OK}")
Docker dispo : True
Conteneur Qdrant lance (rc=0)
Conteneur service KM lance (rc=0)
Qdrant pret : True | service KM pret : True
INFRA_OK (mesure possible) : True

2. Corpus : l’axe modalité — PDF réel, quatre textes, un schéma PNG

Le corpus est petit parce que la question est pectorale, pas quantitative : il s’agit de situer la frontière modale du pipeline, pas de bachoter un classement (le 08 a fait la mesure de recherche). Trois jambes :

  • PDF réel (cours-introduction-ia.pdf, copié du dépôt — même fichier que le 07) : format texte riche, le contrôle positif attendu ;
  • quatre documents texte (s1–s4) : le substrat de recherche — dont un document (s1) porteur du terme exact fenetre glissante recouvrement qui servira de requête contrôle, et un distracteur (s4) qui parle de recherche et de citations sans contenir aucun terme du schéma ;
  • un schéma PNG (schema-capteur.png) dessiné par le notebook : deux lignes de texte technique — Qdrant hybride : BM25 dense fusion RRF (clin d’œil au 08) et capteur impairfaux faux negatifs detection. Ces termes n’existent nulle part ailleurs dans le corpus : la seule voie d’accès à capteur impairfaux passe par l’image.

Si l’extraction d’image fonctionnait, une recherche sur capteur impairfaux retrouverait le schéma. C’est exactement ce que la section 3 met à l’épreuve.

# --- 2a. PDF reel du depot (meme source que le 07) -------------------
PDF_CANDIDATES = [
    Path.cwd() / "slides" / "01-introduction" / "pptx-reference" / "slides.pdf",
    Path.cwd().parent / "slides" / "01-introduction" / "pptx-reference" / "slides.pdf",
    Path.cwd().parents[2] / "slides" / "01-introduction" / "pptx-reference" / "slides.pdf"
        if len(Path.cwd().parents) > 2 else None,
]
PDF_SRC = next((p for p in PDF_CANDIDATES if p and p.is_file()), None)

# --- 2b. Quatre documents texte (substrat) ---------------------------
SUBSTRAT = {
    "s1-partition.txt":
        "La partition en fenetre glissante conserve un recouvrement entre morceaux "
        "consecutifs : une phrase coupee a la frontiere garde son contexte des deux cotes. "
        "Taille et recouvrement se reglent ensemble, jamais l'un sans l'autre.",
    "s2-formats.txt":
        "Un corpus d'entreprise melange formats texte et images. Les schemas, captures "
        "d'ecran et diagrammes echappent a l'extraction textuelle : sans connecteur OCR, "
        "le pipeline d'ingestion n'en tire rien, et le contenu visuel reste invisible a "
        "la recherche.",
    "s3-pont-vision.txt":
        "Un modele de vision peut produire une description textuelle d'une image. Ingeree "
        "comme document ordinaire, cette description rend l'image cherchable : c'est le "
        "pont vision, un motif d'architecture standard des systemes RAG multimodiaux.",
    "s4-distracteur.txt":
        "L'interface de recherche accepte des requetes en langue naturelle et affiche les "
        "citations avec leur score de pertinence. La qualite de l'index prime sur la "
        "formulation de la question ; un bon pipeline se juge sur ses sources.",
}

# --- 2c. Le schema PNG : texte qui n'existe que dans l'image ----------
try:
    from PIL import Image, ImageDraw
    img = Image.new("RGB", (640, 200), "white")
    d = ImageDraw.Draw(img)
    d.text((20, 40), "Qdrant hybride : BM25 dense fusion RRF", fill="black")
    d.text((20, 90), "capteur impairfaux faux negatifs detection", fill="black")
    PNG_PATH = corpus_dir / "schema-capteur.png"
    img.save(PNG_PATH, format="PNG")
    PIL_OK = True
except ImportError:
    PIL_OK = False
    PNG_PATH = None
    print("AVERTISSEMENT : Pillow absent -- le schema PNG ne sera pas genere (pip install pillow)")

# --- 2d. Ecriture + inventaire ----------------------------------------
uploaded = []          # (documentId, chemin, kind)
if PDF_SRC:
    pdf_dst = corpus_dir / "cours-introduction-ia.pdf"
    shutil.copyfile(PDF_SRC, pdf_dst)
    uploaded.append(("pdf-cours", pdf_dst, "pdf"))
for name, txt in SUBSTRAT.items():
    p = corpus_dir / name
    p.write_text(txt, encoding="utf-8")
    uploaded.append((name.replace(".txt", ""), p, "txt"))
if PIL_OK:
    uploaded.append(("schema-png", PNG_PATH, "png"))

print(f"Corpus : {len(uploaded)} documents dans {corpus_dir.name}/")
for doc_id, path, kind in uploaded:
    print(f"  - {doc_id:14s} [{kind:3s}] {path.name} ({path.stat().st_size} octets)")
Corpus : 6 documents dans km09_corpus_jmcjkgov/
  - pdf-cours      [pdf] cours-introduction-ia.pdf (1291133 octets)
  - s1-partition   [txt] s1-partition.txt (225 octets)
  - s2-formats     [txt] s2-formats.txt (250 octets)
  - s3-pont-vision [txt] s3-pont-vision.txt (236 octets)
  - s4-distracteur [txt] s4-distracteur.txt (226 octets)
  - schema-png     [png] schema-capteur.png (4219 octets)

3. Ingestion et constat : le 202 qui ne complète jamais

Idempotence d’abord (le 07 l’a montré : on repart d’un index vide), puis upload de chaque document. Le point pédagogique est dans l’écart entre la promesse et le fait :

  • l’upload répond 202 Accepted pour tous les documents, y compris le PNG — l’acceptation n’engage que le réceptionnaire ;
  • le statut d’ingestion (/upload-status) complète pour les documents texte en quelques secondes ;
  • pour le PNG, le statut ne complète jamais : ni erreur, ni étape en échec — le pipeline attend à l’étape extract, et la boucle de polling expire sur lui seul.

Le décompte des points Qdrant par fichier confirme le constat côté stockage : le PNG n’a produit aucun record.

def km_delete_index() -> bool:
    try:
        r = requests.delete(f"{KM_URL}/indexes", params={"index": INDEX}, timeout=30)
        return r.status_code in (200, 204)
    except Exception:
        return False

def km_status(doc_id: str) -> dict:
    r = requests.get(f"{KM_URL}/upload-status",
                     params={"index": INDEX, "documentId": doc_id}, timeout=30)
    r.raise_for_status()
    return r.json()

codes = []
if INFRA_OK:
    km_delete_index()
    time.sleep(1)
    for doc_id, path, kind in uploaded:
        with open(path, "rb") as fh:
            r = requests.post(f"{KM_URL}/upload",
                              data={"documentId": doc_id, "index": INDEX},
                              files={"files": (path.name, fh, None)}, timeout=120)
        codes.append((doc_id, r.status_code))
    print("Codes d'upload (202 = accepte) :")
    for doc_id, c in codes:
        print(f"  {doc_id:14s} -> {c}")
else:
    print("Mode degrade : pas d'upload.")
Codes d'upload (202 = accepte) :
  pdf-cours      -> 202
  s1-partition   -> 202
  s2-formats     -> 202
  s3-pont-vision -> 202
  s4-distracteur -> 202
  schema-png     -> 202
# Polling borne : les textes completent, le PNG ne complete pas.
STALL_LIMIT_S = 150
statuses = {}
if INFRA_OK:
    pending = {doc_id for doc_id, c in codes if c == 202}
    t0 = time.time()
    while pending and (time.time() - t0) < STALL_LIMIT_S:
        for doc_id in list(pending):
            try:
                st = km_status(doc_id)
                statuses[doc_id] = st.get("completed", False)
                if st.get("completed"):
                    pending.discard(doc_id)
            except Exception:
                pass
        if pending:
            time.sleep(4)
    elapsed = int(time.time() - t0)
    n_done = sum(1 for v in statuses.values() if v)
    print(f"Documents completes : {n_done}/{len(codes)} (borne de polling {STALL_LIMIT_S}s, ecoule {elapsed}s)")
    if pending:
        print(f"Toujours en attente (pipeline bloque a une etape) : {sorted(pending)}")
        st_stuck = km_status(sorted(pending)[0])
        # Liste COMPLETE des steps avec leur statut : le pipeline n'est pas une boite
        # noire, l'etape bloquante doit se lire nommement (concern repris de #13514).
        print(f"  statut brut du premier bloque : {json.dumps(st_stuck, ensure_ascii=False)}")
        print("  Steps du pipeline (complets) :")
        # `steps` est une liste de NOMS (str) -- pas de dicts. completed_steps /
        # remaining_steps la partitionnent : lire nommement l'etape bloquante.
        steps = st_stuck.get("steps", [])
        if not steps:
            print("    (aucun step rapporte par le service)")
        else:
            done = set(st_stuck.get("completed_steps", []))
            for s in steps:
                label = "fait" if s in done else "EN ATTENTE"
                print(f"    - {s:20s} [{label}]")
            rem = st_stuck.get("remaining_steps", [])
            print(f"    etape(s) restante(s) du premier bloque : "
                  f"{', '.join(rem) if rem else 'aucune'}")
else:
    print("Mode degrade : pas de polling.")
Documents completes : 5/6 (borne de polling 150s, ecoule 152s)
Toujours en attente (pipeline bloque a une etape) : ['schema-png']
  statut brut du premier bloque : {"completed": false, "empty": false, "index": "km13421-multi", "document_id": "schema-png", "tags": {}, "creation": "2026-09-18T00:04:28.8548719+00:00", "last_update": "2026-09-18T00:04:28.8558797+00:00", "steps": ["extract", "partition", "gen_embeddings", "save_records"], "remaining_steps": ["extract", "partition", "gen_embeddings", "save_records"], "completed_steps": []}
  Steps du pipeline (complets) :
    - extract              [EN ATTENTE]
    - partition            [EN ATTENTE]
    - gen_embeddings       [EN ATTENTE]
    - save_records         [EN ATTENTE]
    etape(s) restante(s) du premier bloque : extract, partition, gen_embeddings, save_records
# Preuve cote stockage : points Qdrant par fichier (le PNG doit etre absent)
def parse_km_tags(tag_list):
    tags = {}
    for t in tag_list or []:
        if isinstance(t, str) and "__" in t:
            k, v = t.split("__", 1)
            tags[k] = v
    return tags

if INFRA_OK:
    r = requests.post(f"{QDRANT_LOCAL}/collections/{INDEX}/points/scroll",
                      json={"limit": 100, "with_payload": True}, timeout=20)
    pts = r.json().get("result", {}).get("points", [])
    per_file = {}
    for p in pts:
        pl = p.get("payload", {})
        inner = json.loads(pl.get("payload", "{}"))
        fname = inner.get("file", "?")
        per_file[fname] = per_file.get(fname, 0) + 1
    print(f"Records par fichier dans la collection '{INDEX}' :")
    for f in sorted(per_file):
        print(f"  {f:32s} {per_file[f]} record(s)")
    attendus = {p.name for _, p, _ in uploaded}
    vus = set(per_file)
    print(f"Fichiers uploades sans aucun record : {sorted(attendus - vus) or 'aucun'}")
else:
    print("Mode degrade : pas de scroll.")
Records par fichier dans la collection 'km13421-multi' :
  cours-introduction-ia.pdf        7 record(s)
  s1-partition.txt                 1 record(s)
  s2-formats.txt                   1 record(s)
  s3-pont-vision.txt               1 record(s)
  s4-distracteur.txt               1 record(s)
Fichiers uploades sans aucun record : ['schema-capteur.png']

Mesure AVANT le pont : l’image est invisible pour la recherche

Trois requêtes sur l’index sans le document-pont — c’est la baseline. q1 et q2 ne ciblent que le texte du schéma PNG ; si l’extraction d’image ne produit rien, leur top-1 ne peut pas être pertinent (le service renvoie quand même des voisins — des non-pertinents). q3 est le témoin (terme du substrat s1) : il doit trouver — sinon l’échec de q1/q2 ne prouverait rien (ce pourrait être la recherche elle-même qui dysfonctionne).

def km_search(query: str, limit: int = 3) -> list:
    r = requests.post(f"{KM_URL}/search",
                      json={"index": INDEX, "query": query, "limit": limit}, timeout=120)
    r.raise_for_status()
    return r.json().get("results", [])

def top_hit(results: list):
    if not results:
        return "(miss)"
    return results[0].get("documentId", "?")

QUERIES = [
    ("q1", "capteur impairfaux"),
    ("q2", "comment limiter les faux negatifs de detection"),
    ("q3", "fenetre glissante recouvrement"),
]

MESURE_AVANT = {}
if INFRA_OK:
    for qid, q in QUERIES:
        MESURE_AVANT[qid] = top_hit(km_search(q, limit=3))
        print(f"AVANT {qid} '{q}' -> {MESURE_AVANT[qid]}")
else:
    print("Mode degrade : pas de mesure avant.")
AVANT q1 'capteur impairfaux' -> pdf-cours
AVANT q2 'comment limiter les faux negatifs de detection' -> s4-distracteur
AVANT q3 'fenetre glissante recouvrement' -> s1-partition

Interprétation : [INTERP-CONSTAT] un plafond réel, pas un bug à corriger chez nous

La mesure est sans ambiguïté : 5 documents sur 6 complètent leur pipeline, le PNG reste bloqué à l’étape extract — et côté stockage, il a produit 0 record. Le statut brut le montre sans fard : "completed": false, une liste d’étapes qui démarre par extract et n’avance plus, aucune erreur. C’est le pire mode d’échec pour un système asynchrone : silencieux, indéfini, sans trace d’exception.

Ce n’est pas un défaut de notre configuration : le même service, la même clé, la même boucle d’upload indexent le PDF et les quatre textes quelques secondes plus tôt. C’est un plafond de la distribution OSS : l’extraction de contenu d’image exige un connecteur (OCR / Azure Document Intelligence) que le conteneur kernelmemory/service n’embarque pas — l’accepter (HTTP 202) sans pouvoir l’extraire est son comportement documenté de bord. La leçon opérationnelle est dans l’écart acceptés vs records : comparer le nombre de documents uploadés au nombre de points réellement écrits reste le contrôle le moins coûteux et le plus révélateur (cf. s2-formats.txt dans le corpus).

4. Ce qu’un extracteur dedie fait (et ne fait pas) : tika

Le stall du [3] n’est pas un bug du service Kernel Memory OSS : il n’embarque pas de connecteur OCR, point. La question pedagogique devient : que ferait un extracteur dedie sur ce corpus ? Le cluster heberge un container Apache Tika (image apache/tika:latest-full qui embarque Tesseract ; la version est lue du service via /version et imprimee par la cellule d’extraction – 4.0.0 sur le conteneur de ce run, 3.2.3 mesure sur l’hote distant au run precedent : la version est celle de l’image qui repond sur 9917, et la mesure a deux bras reproduit les memes valeurs sous les deux) sur le port hote http://localhost:9917 (le port interne du conteneur est 9998 : l’hote publie 9917, d’ou le mapping 0.0.0.0:9917->9998/tcp). C’est l’outil canonique d’extraction multi-format, exactement le role que le service OSS laisse vide.

Protocole – attendus ecrits avant l’execution :

  1. PDF du cours : tika doit extraire le texte integre (c’est un PDF texte, pas un scan) – le meme contenu que le pipeline KM a ingere avec succes.
  2. Schema PNG : le PNG genere par Pillow ne porte aucun texte embarque ; le seul chemin est l’OCR Tesseract interne. Attendu : un texte partiellement extrait, mais avec les termes techniques pixelises (font bitmap ~11 px) degrades – c’est la mesure qui decide, pas l’intuition.

La comparaison porte sur les cinq tokens attendus de l’image : Qdrant, hybride, BM25, RRF, capteur.

# Extraction tika sur les deux artefacts du corpus (PDF + PNG).
# Le verbatim attendu de l'image est connu : nous l'avons genere nous-memes en [2c].
TIKA_URL = os.getenv("TIKA_URL", "http://localhost:9917").rstrip("/")  # port hote (9917 publie 9998 interne conteneur)
ATTENDUS_PNG = ["Qdrant", "hybride", "BM25", "RRF", "capteur"]

try:
    tika_version = requests.get(f"{TIKA_URL}/version", timeout=15).text.strip()
except Exception as e:
    tika_version = f"(version illisible : {e})"
print(f"Moteur d'extraction : {tika_version} ({TIKA_URL})")

def tika_text(path: Path, mime: str) -> str:
    r = requests.put(
        f"{TIKA_URL}/tika",
        data=path.read_bytes(),
        headers={"Content-Type": mime, "Accept": "text/plain"},
        timeout=120,
    )
    r.raise_for_status()
    return r.text

TIKA_RESULTS = {}
try:
    pdf_txt = tika_text(corpus_dir / "cours-introduction-ia.pdf", "application/pdf")
    TIKA_RESULTS["pdf"] = pdf_txt
    print(f"PDF  : {len(pdf_txt)} caracteres extraits")
    print(f"       debut : {pdf_txt[:80]!r}")
except Exception as e:
    print(f"PDF  : ECHEC -- {e}")

try:
    png_txt = tika_text(PNG_PATH, "image/png")
    TIKA_RESULTS["png"] = png_txt
    print(f"PNG  : {len(png_txt.strip())} caracteres extraits via OCR interne")
    print(f"       texte OCR : {png_txt.strip()!r}")
    exacts = [t for t in ATTENDUS_PNG if t in png_txt]
    print(f"       tokens attendus exacts : {len(exacts)}/{len(ATTENDUS_PNG)} -- {exacts}")
except Exception as e:
    print(f"PNG  : ECHEC -- {e}")
Moteur d'extraction : Apache Tika 4.0.0 (http://localhost:9917)
PDF  : 18927 caracteres extraits
       debut : 'Intelligence Artificielle - 1 - Introduction  \n\nE N S E I G N A N T :  J E A N -'
PNG  : 78 caracteres extraits via OCR interne
       texte OCR : '(drant hybride: B25 dense fusion RAF  capteur impairfaux faux negatfsdetection'
       tokens attendus exacts : 2/5 -- ['hybride', 'capteur']
# --- 4b. Mesure a deux bras : la taille de rendu, pas le vocabulaire -----
# Le meme PNG part deux fois au meme moteur Tesseract, avec le meme parametrage
# par defaut : une fois tel quel (bras A), une fois agrandi x3 par interpolation
# Lanczos sans reecriture de glyphes (bras B). Deux reglages Tika appliques au
# bras A servent de controles : aucun effet attendu -- la piste d'un defaut de
# parametrage du moteur s'ecarte en meme temps que celle du vocabulaire.

import io

def ocr_tika_png(img, reglages=None):
    """OCR Tika d'une image PIL ; rend (texte brut, tokens attendus exacts)."""
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    headers = {"Content-Type": "image/png", "Accept": "text/plain"}
    if reglages:
        headers.update(reglages)
    r = requests.put(f"{TIKA_URL}/tika", data=buf.getvalue(),
                     headers=headers, timeout=120)
    r.raise_for_status()
    txt = r.text.strip()
    return txt, [t for t in ATTENDUS_PNG if t in txt]

DEUX_BRAS = []
if PIL_OK:
    img_a = Image.open(PNG_PATH)  # fixture d'origine de [2c], pixels intacts
    variantes = [
        ("A  fixture d'origine (~11 px)", img_a, None),
        ("A+ X-Tika-OCRpageSegMode=6 (controle)", img_a, {"X-Tika-OCRpageSegMode": "6"}),
        ("A+ X-Tika-OCRdensity=300 (controle)", img_a, {"X-Tika-OCRdensity": "300"}),
        ("B  meme image x3 LANCZOS",
         img_a.resize((img_a.width * 3, img_a.height * 3), Image.LANCZOS), None),
    ]
    for label, img, reglages in variantes:
        txt, exacts = ocr_tika_png(img, reglages)
        DEUX_BRAS.append((label, exacts, txt))

    print(f"Mesure a deux bras -- {len(DEUX_BRAS)} variantes, meme moteur Tesseract :")
    for label, exacts, txt in DEUX_BRAS:
        print(f"  {label:40s} {len(exacts)}/5  {txt[:64]!r}")
    print()
    n_a = len(DEUX_BRAS[0][1])
    n_b = len(DEUX_BRAS[-1][1])
    print(f"Lecture : A = {n_a}/5, B = {n_b}/5. Entre A et B, seuls les pixels changent")
    print("(meme texte genere, meme moteur, meme parametrage par defaut) : le facteur")
    print("explicatif du plafond est la taille de rendu, pas la nature du vocabulaire.")
else:
    print("Mesure a deux bras sautee : Pillow absent (schema PNG non genere en [2c])")
Mesure a deux bras -- 4 variantes, meme moteur Tesseract :
  A  fixture d'origine (~11 px)            2/5  '(drant hybride: B25 dense fusion RAF  capteur impairfaux faux ne'
  A+ X-Tika-OCRpageSegMode=6 (controle)    2/5  '(drant hybride: B25 dense fusion RAF  capteur impairfaux faux ne'
  A+ X-Tika-OCRdensity=300 (controle)      2/5  '(drant hybride: B25 dense fusion RAF  capteur impairfaux faux ne'
  B  meme image x3 LANCZOS                 5/5  'Qdrant hybride : BM25 dense fusion RRF  capteur impairfaux faux '

Lecture : A = 2/5, B = 5/5. Entre A et B, seuls les pixels changent
(meme texte genere, meme moteur, meme parametrage par defaut) : le facteur
explicatif du plafond est la taille de rendu, pas la nature du vocabulaire.

Interpretation : [INTERP-TIKA] l’extracteur dedie lit le PDF, tord le PNG

Les attendus sont confirmes par la mesure : le PDF sort integralement (le service OSS avait raison de l’ingerer), le PNG passe par l’OCR Tesseract et rend un texte partiellement restitue. Mais le defaut n’est pas dans le vocabulaire : la mesure a deux bras executee dans la cellule [4b] ci-dessus – meme PNG envoye deux fois au meme moteur Tesseract, un seul agrandi par interpolation Lanczos x3 sans reecriture de glyphes – tranche la cause : le delta est dans la taille de rendu (police bitmap ~11 px de la fixture Pillow d’origine), pas dans la nature des termes.

Recapitulatif de la mesure executee :

Variante fixture Tokens exacts / 5 Note
A – fixture d’origine (font bitmap ~11 px) 2/5 controle positif : memes substitutions que la cellule d’extraction (B25, RAF)
A + X-Tika-OCRpageSegMode: 6 2/5 controle : reglage OCR sans effet
A + X-Tika-OCRdensity: 300 2/5 controle : reglage OCR sans effet
B – meme image x3 (LANCZOS, sans reecriture de glyphes) 5/5 recuperation complete

Les quatre lignes sont la sortie commitée de [4b], pas un report exterieur au notebook. Les deux reglages Tika laissent le resultat strictement inchange – la piste d’un defaut de parametrage du moteur s’ecarte en meme temps que celle du vocabulaire. Seuls les pixels different entre A et B : le bras A reproduit les substitutions de la cellule d’extraction (Qdrant -> (drant, BM25 -> B25, RRF -> RAF), le bras B les efface toutes.

Le pont vision du [5] reste necessaire, et la justification se renforce : il ne comble pas un defaut de l’OCR sur le vocabulaire technique (defaut inexistant dans tika, c’est la mesure a deux bras qui le tranche), mais une capacite absente – restituer la semantique du schema (relations, disposition, lecture du graphe), pas la fidelite lexicale. C’est exactement ce que le modele de vision sait faire qu’un OCR ne fait pas.

5. Le pont vision : décrire l’image pour l’ingérer

Le service OSS n’embarque pas de connecteur d’extraction d’image — c’est un plafond de la distribution, documenté comme tel (l’extraction d’images avec OCR existe dans l’écosystème KM côté connecteurs Azure). Le motif standard pour franchir cette frontière sans changer de service : faire décrire l’image par un modèle de vision, puis ingérer la description comme document texte.

Concrètement : le PNG est encodé en base64 et envoyé au vLLM maison du cluster (qwen3.6-35b-a3b, Qwen3.6-35B-A3B AWQ servi sur GPU 0+1, endpoint OpenAI-compatible VLLM_BASE_URL du .env, canal /chat/completions, partie image_url). Le cluster héberge la capacité vision : le notebook n’achète rien à un service tiers pour la franchir. Un secours externe (openrouter, BYOK) reste câblé comme alternative documentée — il ne s’active que si le service maison est injoignable. Le prompt demande une description en citant les termes techniques visibles — un modèle de vision digne de ce nom lit le texte dans l’image ; un modèle texte pur n’aurait aucun accès au pixels. Détail d’ingénierie qui a son importance : max_tokens = 1500 — les modèles à raisonnement consomment des jetons de réflexion avant la réponse, et un budget trop petit tronque la description.

import base64

DESCRIPTION = ""
VISION_USAGE = {}
VISION_PROVIDER = None

def vision_describe(base: str, key: str, model: str, extra: dict | None = None) -> tuple[str, dict]:
    """Decrit le schema PNG via un endpoint OpenAI-compatible. Retourne (texte, usage)."""
    b64 = base64.b64encode(PNG_PATH.read_bytes()).decode()
    payload = {
        "model": model,
        "messages": [{
            "role": "user",
            "content": [
                {"type": "text",
                 "text": "Decris ce schema technique en 3-4 phrases en francais, "
                         "en citant verbatim les termes techniques visibles."},
                {"type": "image_url",
                 "image_url": {"url": "data:image/png;base64," + b64}},
            ],
        }],
        "max_tokens": 1500,
        "temperature": 0,
    }
    if extra:
        payload.update(extra)
    r = requests.post(
        f"{base}/chat/completions",
        headers={"Authorization": "Bearer " + key},
        json=payload,
        timeout=180)
    r.raise_for_status()
    body = r.json()
    return body["choices"][0]["message"]["content"].strip(), body.get("usage", {})

if PIL_OK and VLLM_KEY:
    # Chemin principal : vLLM maison. enable_thinking=False sinon le budget de
    # generation est consomme par le raisonnement et content revient vide.
    try:
        DESCRIPTION, VISION_USAGE = vision_describe(
            VLLM_BASE, VLLM_KEY, VISION_MODEL,
            extra={"chat_template_kwargs": {"enable_thinking": False}})
        VISION_PROVIDER = "vllm-local"
    except Exception as e:
        print(f"vLLM maison indisponible ({e}) -- bascule sur le secours documente.")
if PIL_OK and not DESCRIPTION and OR_KEY:
    try:
        DESCRIPTION, VISION_USAGE = vision_describe(
            OR_BASE, OR_KEY, VISION_FALLBACK_MODEL)
        VISION_PROVIDER = "openrouter"
    except Exception as e:
        print(f"Secours openrouter indisponible ({e}).")
if DESCRIPTION:
    print(f"Fournisseur : {VISION_PROVIDER}")
    print("Usage :", json.dumps(VISION_USAGE))
    print("Description generee :")
    print(DESCRIPTION)
elif PIL_OK:
    print("Mode degrade : ni vLLM maison ni openrouter configure (VLLM_API_KEY / OPENROUTER_API_KEY).")
else:
    print("Mode degrade : pas d'appel vision (Pillow absent).")
Fournisseur : openrouter
Usage : {"prompt_tokens": 1087, "completion_tokens": 544, "total_tokens": 1631, "cost": 0, "is_byok": true, "prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "cost_details": {"upstream_inference_cost": 0.0057105, "upstream_inference_prompt_cost": 0.0016305, "upstream_inference_completions_cost": 0.00408}, "completion_tokens_details": {"reasoning_tokens": 384, "image_tokens": 0, "audio_tokens": 0}}
Description generee :
Cette image présente deux lignes de texte énumérant des concepts techniques relatifs à la recherche d'information et à l'analyse de signaux. La première ligne mentionne « **Qdrant hybride : BM25 dense fusion RRF** », faisant référence à un système de recherche vectorielle combinant l'algorithme lexical BM25, des représentations denses et une méthode de fusion par reclassement (RRF). La seconde ligne indique « **capteur impairfaux faux negatifs detection** », se rapportant à l'évaluation et à la détection d'anomalies ou d'erreurs de mesure issues de capteurs. L'ensemble associe ainsi des problématiques d'indexation hybride de données à des enjeux de fiabilité de détection.

Interprétation : [INTERP-DESC] ce que le modèle a réellement lu

La description citée en sortie est la preuve de lecture : le modèle restitue verbatim les deux lignes du schéma — « Qdrant hybride : BM25 dense fusion RRF » et « capteur impairfaux faux negatifs detection », jusqu’à la faute d’orthographe volontaire (impairfaux) qu’aucune devine orthographique ne corrigerait : elle n’existe que dans les pixels de l’image. C’est exactement ce que le pont exige — la description est un témoin fidèle, pas une paraphrase approximative.

Ligne usage : ~1090 jetons de prompt (l’image + la consigne) et ~540 de complétion dont ~380 jetons de raisonnement — le modèle « pense » avant d’écrire, et ces jetons comptent dans le budget. C’est la raison du max_tokens = 1500 : avec 200, la réponse serait tronquée avant le premier mot utile. Le temperature = 0 n’est pas cosmétique : la mesure de la section 5 dépend du contenu de cette description, sa stabilité d’une exécution à l’autre est un prérequis expérimental.

# Ingestion du document-pont : la description devient un document texte cherchable
BRIDGE_ID = "pont-vision-schema"
bridge_done = False
if INFRA_OK and DESCRIPTION:
    bridge_path = corpus_dir / (BRIDGE_ID + ".txt")
    vision_used = VISION_MODEL if VISION_PROVIDER == "vllm-local" else VISION_FALLBACK_MODEL
    bridge_path.write_text(
        "Schema technique 'schema-capteur.png' (description generee par le modele de vision "
        f"{vision_used} via {VISION_PROVIDER}, pont vision du notebook 09) :\n\n{DESCRIPTION}\n",
        encoding="utf-8")
    with open(bridge_path, "rb") as fh:
        r = requests.post(f"{KM_URL}/upload",
                          data={"documentId": BRIDGE_ID, "index": INDEX},
                          files={"files": (bridge_path.name, fh, None)}, timeout=120)
    print(f"Upload du document-pont -> {r.status_code}")
    deadline = time.time() + 180
    while time.time() < deadline:
        st = km_status(BRIDGE_ID)
        if st.get("completed"):
            bridge_done = True
            break
        time.sleep(4)
    print(f"Document-pont complete : {bridge_done}")
    if bridge_done:
        rc = requests.post(f"{QDRANT_LOCAL}/collections/{INDEX}/points/count",
                           json={"exact": True}, timeout=15)
        print(f"Points totaux dans la collection : {rc.json()['result']['count']}")
else:
    print("Mode degrade : pas d'ingestion du pont.")
Upload du document-pont -> 202
Document-pont complete : True
Points totaux dans la collection : 12

6. Mesure contrôlée : avant / après le pont

Protocole — attendus écrits avant l’exécution. Trois requêtes, une seule variable manipulée (l’ingestion du document-pont). Une précision de vocabulaire d’abord : le service KM ne s’abstient jamais — /search renvoie toujours les plus proches voisins, pertinents ou non. Un « miss » opérationnel se lit donc top-1 non pertinent (le bon document n’est pas premier), jamais « liste vide » :

# requête cible réelle attendu AVANT attendu APRÈS
q1 capteur impairfaux terme exact, unique au schéma PNG top-1 non pertinent (aucun record ne contient le terme) top-1 = pont-vision-schema
q2 comment limiter les faux negatifs de detection paraphrase du texte du schéma top-1 non pertinent top-1 = pont-vision-schema
q3 fenetre glissante recouvrement terme exact du substrat s1 top-1 = s1-partition (contrôle) top-1 = s1-partition (inchangé)

Le critère d’effet est le déplacement : q1/q2 voient leur top-1 devenir le document-pont (seul à contenir les termes de l’image), tandis que q3 — le témoin — ne bouge pas. Si q3 bougeait aussi, l’effet serait un shift global de l’index, pas le pont. La mesure AVANT a été prise à la fin de la section 3 (index sans le pont) ; la mesure APRÈS est prise ici, la seule différence étant l’ingestion du document-pont.

MESURE_APRES = {}
if INFRA_OK:
    for qid, q in QUERIES:
        MESURE_APRES[qid] = top_hit(km_search(q, limit=3))

if INFRA_OK:
    print(f"{'req':4s} {'AVANT pont':24s} {'APRES pont':24s} attendu")
    attendus = {"q1": BRIDGE_ID, "q2": BRIDGE_ID, "q3": "s1-partition"}
    for qid, q in QUERIES:
        av, ap = MESURE_AVANT.get(qid, "?"), MESURE_APRES.get(qid, "?")
        if qid == "q3":
            # temoin : le top-1 ne doit pas bouger
            ok = (av == "s1-partition") and (ap == "s1-partition")
        else:
            # critere = deplacement : le pont devient top-1 alors qu'il n'existait
            # pas a l'AVANT (top-1 d'alors etait necessairement non pertinent :
            # aucun record ne contenait les termes de l'image)
            ok = (ap == attendus[qid]) and (av != ap)
        print(f"{qid:4s} {av:24s} {ap:24s} {'conforme' if ok else 'ECART'}")
    print()
    for qid, q in QUERIES:
        res = km_search(q, limit=2)
        print(f"{qid} \"{q}\"")
        for i, c in enumerate(res, 1):
            part = (c.get("partitions") or [{}])[0]
            score = part.get("relevance", 0.0)
            snippet = (part.get("text") or "").replace(chr(10), " ")[:100]
            print(f"  [{i}] {c.get('documentId', '?')} score={score:.3f} \"{snippet}\"")
        print()
else:
    print("Mode degrade : pas de mesure.")
req  AVANT pont               APRES pont               attendu
q1   pdf-cours                pont-vision-schema       conforme
q2   s4-distracteur           pont-vision-schema       conforme
q3   s1-partition             s1-partition             conforme

q1 "capteur impairfaux"
  [1] pont-vision-schema score=0.474 "Schema technique 'schema-capteur.png' (description generee par le modele de vision google/gemini-3.7"
  [2] pdf-cours score=0.356 "isation de la mesure de performance  A partir de la suite de percepts et de l’état de connaissance"

q2 "comment limiter les faux negatifs de detection"
  [1] pont-vision-schema score=0.433 "Schema technique 'schema-capteur.png' (description generee par le modele de vision google/gemini-3.7"
  [2] s4-distracteur score=0.323 "L'interface de recherche accepte des requetes en langue naturelle et affiche les citations avec leur"

q3 "fenetre glissante recouvrement"
  [1] s1-partition score=0.568 "La partition en fenetre glissante conserve un recouvrement entre morceaux consecutifs : une phrase c"
  [2] s3-pont-vision score=0.310 "Un modele de vision peut produire une description textuelle d'une image. Ingeree comme document ordi"

Interprétation : [INTERP-RESULTATS] lecture de la mesure

Trois « conforme », un effet net et localisé :

  • q1 et q2 se déplacent vers le pont. Avant, leurs top-1 étaient des documents non pertinents (le PDF du cours pour capteur impairfaux, le distracteur s4 pour la paraphrase) avec des scores bas — ~0.32–0.36, la signature de voisins attrapés par défaut, puisque /search ne s’abstient jamais. Après, le document-pont est premier avec une marge franche (+0.07 à +0.08 de score sur le second). Les termes de l’image sont passés d’introuvables à top-1 avec citations.
  • q3 ne bouge pas. Le témoin garde s1-partition en top-1 avec un score élevé (~0.57, un vrai voisin sémantique cette fois) avant comme après. L’ingestion du document-pont n’a donc pas déplacé l’index globalement — seule la région sémantique de l’image a changé. Sans ce témoin, l’argument serait plus faible : un top-1 qui change pourrait être un artefact de ré-ingestion, pas un effet du pont.

Leçon de méthode (amendement documenté) : la première passe de ce notebook attendait un « miss » strict — liste vide — avant le pont. C’était mal calibré : /search renvoie toujours des voisins. La reformulation (« top-1 non pertinent », critère de déplacement) est la bonne lecture opérationnelle d’un moteur sans abstention — et c’est une leçon en soi : un RAG sans seuil de pertinence masque ses miss derrière des résultats toujours remplis.

7. Ce que coûte le pont, ce qu’il achète

Le coût marginal du pont vision est d’un appel modèle par image — servi par le vLLM maison du cluster : 0 € par exécution (le coût est en jetons GPU locaux, ~150 à ~400 par image d’après la télémétrie de ce run), plus l’embedding de la description — quelques milliers de jetons d’embedding, négligeables. Le secours openrouter, lui, se facture (~0,007 $/exécution au tarif mesuré lors de la livraison initiale) : c’est la contrepartie de son indépendance au cluster. En échange : chaque image du corpus devient cherchable par son contenu décrit, avec citations vers le document-pont — qui lui-même cite le fichier d’origine dans son en-tête.

Limites honnêtes du pont : (a) la description est une compression avec perte — ce que le modèle de vision ne mentionne pas est définitivement perdu pour la recherche ; (b) le texte dans l’image est lu, mais pas la topologie fine du schéma (flèches, positions relatives) sauf à le demander explicitement ; (c) le pont ajoute un appel modèle par image — gratuit sur le service maison tant que la capacité GPU suit, mais qui entre en concurrence avec les autres charges du cluster ; le recours externe n’est justifié qu’en débordement, et de préférence sur un moteur aligné sur les harnais du cluster (MiniMax, DeepSeek) plutôt qu’un généraliste tiers. Pour un corpus mixte de taille modérée, le pont vision est le bon rapport coût/effet ; pour des millions d’images, l’OCR spécialisé redevient compétitif.

Exercice 1 — Décrire un lot d’images

Le pont du notebook décrit une image. Étendez-le à un dossier : pour chaque *.png d’un répertoire, appeler le modèle de vision, ingérer chaque description avec un documentId prévisible (pont-<nom>).

Indice : boucle sur sorted(Path.glob("*.png")), réutiliser la cellule du pont, décaler les appels de quelques secondes pour respecter le rate-limit de l’endpoint.

# Exercice 1 -- a completer
print("Exercice a completer : decrire et ingerer un lot d'images")
Exercice a completer : decrire et ingerer un lot d'images

Exercice 2 — Marquer l’origine des documents du pont

Les documents-pont sont des dérivés d’images ; une recherche ne devrait pas les confondre avec les sources primaires. Ajoutez au moment de l’upload un tag (origine=image;source=schema-capteur.png) et montrez une recherche filtrée qui ne retient que les documents-pont.

Indice : le 07 a montré le filtre par tag sur /search ("filters": [...]).

# Exercice 2 -- a completer
print("Exercice a completer : tagguer et filtrer les documents du pont")
Exercice a completer : tagguer et filtrer les documents du pont

Exercice 3 — Mesurer la couverture du pont

Le pont rend l’image cherchable via la description — mais la description est une perte. Mesurez la couverture : listez les termes exacts dessinés dans le PNG, comptez ceux que la recherche retrouve après le pont, et rapportez la fraction.

Indice : la cellule de dessin liste les deux lignes de texte ; découpez-les en termes, interrogez /search terme à terme, len(trouvés)/len(termes).

# Exercice 3 -- a completer
print("Exercice a completer : mesurer la couverture du pont")
Exercice a completer : mesurer la couverture du pont

8. Nettoyage

Même discipline que le 07 : supprimer l’index, retirer les conteneurs et volumes, effacer le corpus provisoire. L’infrastructure est jetable, et le dépôt ne garde que le notebook — les outputs commités sont la preuve d’exécution.

if INFRA_OK:
    requests.delete(f"{KM_URL}/indexes", params={"index": INDEX}, timeout=30)
    print("Index KM supprime :", INDEX)
subprocess.run(["docker", "rm", "-f", KM_CONTAINER, QDRANT_CONTAINER], capture_output=True)
subprocess.run(["docker", "volume", "rm", "-f", KM_FILES_VOLUME, QDRANT_VOLUME],
               capture_output=True)
print("Conteneurs et volumes retires")
try:
    shutil.rmtree(corpus_dir, ignore_errors=True)
    print(f"Corpus provisoire {corpus_dir.name}/ efface")
except Exception as exc:
    print("Cleanup corpus :", exc)
Index KM supprime : km13421-multi
Conteneurs et volumes retires
Corpus provisoire km09_corpus_jmcjkgov/ efface

Conclusion : [INTERP-CONCLUSION] la frontière franchie, et son prix

Le plafond était réel — un PNG accepté (202) mais jamais indexé, 0 record, un statut qui ne complète pas — et le pont l’a franchi mesurablement : les termes qui n’existaient que dans l’image (capteur impairfaux, faux negatifs) sont passés de top-1 non pertinents à top-1 sur le document-pont, avec citations, sans déplacer le témoin. La mesure à deux bras du [4] tranche en outre la cause du plafond de l’OCR de tika : ce n’est pas le vocabulaire technique (un redimensionnement Lanczos des mêmes pixels remonte de 2/5 à 5/5 tokens), c’est la taille de rendu — information utile pour quiconque branche tika sur des fixtures réelles (PDF scannés, captures basse def). Trois idées à retenir :

  1. Le plafond OCR du service OSS est un fait de distribution, pas un échec de configuration — le pipeline traite les formats texte au même moment sans broncher. Le contrôle acceptés-vs-records le détecte en une requête.
  2. Le pont vision est un motif d’architecture, pas un contournement : le modèle de vision lit l’image (verbatim, faute d’orthographe comprise), sa description entre dans le pipeline standard, et la recherche fonctionne dessus comme sur tout texte.
  3. Le prix est borné et visible : un appel vision par image (~1 630 jetons ici, dont ~380 de raisonnement), une compression avec perte (ce que la description ne mentionne pas est perdu pour la recherche — la topologie fine du schéma n’y est pas sauf à la demander explicitement dans le prompt), et une latence d’ingestion du même ordre. Pour un corpus mixte modéré, c’est le bon rapport coût/effet ; à l’échelle du million d’images, l’OCR spécialisé redevient compétitif.

Coût de la passe complète de ce notebook : 6 uploads KM + 1 appel vision + les embeddings d’une douzaine de partitions — quelques centimes au total sur l’endpoint du .env, l’infrastructure étant jetable (2 conteneurs, 2 volumes, tout est retiré en section 7).

# Cellule finale : etat d'execution (patron 07/08)
print(f"INFRA_OK                  : {INFRA_OK}")
print(f"Documents uploades        : {len(uploaded)}")
print(f"Textes completes          : {sum(1 for v in statuses.values() if v)}")
print(f"PNG complete (stall OCR)  : {any(k == 'schema-png' and v for k, v in statuses.items())}")
print(f"Description vision generee: {bool(DESCRIPTION)}")
print(f"Document-pont complete    : {bridge_done}")
if MESURE_APRES:
    print(f"Mesure apres pont         : {json.dumps(MESURE_APRES, ensure_ascii=False)}")
print("Notebook execute integralement (regle C.1/C.2).")
INFRA_OK                  : True
Documents uploades        : 6
Textes completes          : 5
PNG complete (stall OCR)  : False
Description vision generee: True
Document-pont complete    : True
Mesure apres pont         : {"q1": "pont-vision-schema", "q2": "pont-vision-schema", "q3": "s1-partition"}
Notebook execute integralement (regle C.1/C.2).
Retour au sommet