“RAG fallback” multimodal (photo + texte)
oici comment implémenter un “RAG fallback” multimodal (photo + texte) qui retourne le meilleur slide_ref quand les règles/regex ne suffisent pas.
Objectif
Quand la vision + règles ne donnent pas une correspondance sûre, on bascule sur un recherche sémantique (RAG) dans l’index des slides OdoLearn, en utilisant le texte + des indices tirés de l’image.
1) Données à indexer (par slide)
Index vectoriel (pgvector/Qdrant/Weaviate) avec, pour chaque slide_ref :
- title, summary, body_text nettoyé (HTML→texte).
- tags (catégorie, marque, modèle, série).
- lang (fr_FR, mg_MG, en_US).
- champs dérivés utiles : brand_norm, model_norm, category.
- Embedding texte du document complet condensé (ex. 512–1024 tokens max).
- (option) Image de couverture du slide → embedding image si dispo.
Garder aussi une table de métadonnées (slide_id, website_url, privacy).
2) Construction de la requête (query builder)
À partir de l’entrée utilisateur :
- Texte: question WhatsApp + OCR + labels vision (objets/attributs).
-
Image:
- soit caption + objects + OCR (extraits par modèle vision),
- soit embedding image si votre moteur le permet (cross-modal).
Query text final (concat pondérée) :
[task:"trouver slide de support"] lang: fr_FR contexte_utilisateur: technicien solaire junior observations_image: {caption, objects, ocr} mots_clés_normés: {category=pv_hybrid_inverter, brand=Growatt, model=SPF 5000 ES} question: "Problème de câblage et mise en service onduleur SPF 5000 ES"
- Appliquez un poids plus fort aux champs normés (brand/model/category).
3) Recherche & fusion de scores
- Texte→Doc: cosine similarity(query_text_emb, slide_text_emb).
- (si dispo) Image→Doc: cosine(image_emb, slide_image_emb).
-
Boosts (règles légères) :
- +α si brand_norm match exact.
- +β si model_norm match exact ou “startswith série”.
- +γ si lang match.
Score final (ex.) :
score = 0.6 * sim_text + 0.3 * sim_image + boosts
Seuil typique min_score ≈ 0.75 (à calibrer).
4) Désambiguisation (si ex æquo)
- Re-rank avec un LLM cross-encoder sur le top-10 (prompt de comparaison).
-
Si score_gap < Δ (ex. 0.05), poser une question courte à l’utilisateur :
“Plutôt mise en service ou câblage batterie 48 V pour Growatt SPF 5000 ES ?”
5) Garde-fous (qualité)
- Langue : traduire la requête vers la langue du contenu dominant si besoin.
- Contenu trop général : si top-1 < seuil → renvoyer 2–3 options (liste interactive WhatsApp).
- Contexte persistant : conserver un profil (marque/site/projet) pour rebooster les mêmes familles de slides.
6) Exemple de pipeline (pseudo-code Python)
def rag_fallback(image_bytes, user_text, norm, lang="fr_FR"): # 1) Extraire contexte visuel vision = analyze_image(image_bytes) # {caption, objects[], ocr[]} obs = "caption: {c}\nobjects: {o}\nocr: {t}".format( c=vision["caption"], o=", ".join(vision["objects"]), t=" ".join(vision["ocr"]) ) # 2) Construire query text keywords = f"cat:{norm.get('category','?')} brand:{norm.get('brand','?')} model:{norm.get('model','?')}" query_text = f"[task:slide_support]\nlang:{lang}\n{keywords}\nobs:\n{obs}\nquestion:{user_text}" # 3) Embeddings q_vec = embed_text(query_text) img_vec = embed_image(image_bytes) if supports_image_embedding() else None # 4) Recherche vecteur (top 20) candidates = vecdb.search(q_vec, top_k=20, filter={"lang": lang}) # 5) Scoring fusion results = [] for c in candidates: s = 0.6 * cosine(q_vec, c.text_vec) if img_vec is not None and hasattr(c, "image_vec"): s += 0.3 * cosine(img_vec, c.image_vec) if c.brand_norm == norm.get("brand"): s += 0.05 if c.model_norm == norm.get("model"): s += 0.07 results.append((s, c)) results.sort(reverse=True, key=lambda x: x[0]) top = results[:5] # 6) Re-rank LLM (cross-encoder) si proche if len(top) >= 2 and (top[0][0] - top[1][0]) < 0.05: reranked = cross_encoder_rerank(query_text, [c.doc for _, c in top]) top = [(r.score, r.doc) for r in reranked] # 7) Décision if top[0][0] >= 0.75: best = top[0][1] return { "best": { "slide_ref": best.slide_ref, "title": best.title, "score": round(top[0][0], 3) }, "alts": [{"slide_ref": d.slide_ref, "score": round(s,3)} for s, d in top[1:3]] } else: return { "need_clarification": True, "question": "Plutôt mise en service, câblage batterie, ou paramétrage Wi-Fi ?" }
7) Prompt de re-rank (ex.)
Rôle: tu es un moteur de recherche pédagogique.
Tâche: classe ces 5 extraits de slides par pertinence pour aider un technicien face à la situation suivante.
Contexte image: {caption, objects, OCR}.
Mots-clés normés: {brand, model, category}.
Question: {user_text}.
Donne un score 0–1 et un court motif. Renvoie JSON [{"slide_ref": "...","score":0.0,"reason":"..."}].
8) Sortie RAG → Résolution Odoo
- Une fois slide_ref sélectionné, appelez votre resolver (JSON-RPC Odoo) pour obtenir slide_id + website_url, puis renvoyez le deep link (WhatsApp bouton).
9) Mesures & tuning
- Recall@1 et MRR sur un jeu d’éval (100–300 cas).
- Ajustez poids fusion (texte/image/boosts).
- Ajoutez des synonymes (FR/MG/EN) et des descriptions longues aux slides faiblement textuels.
TL;DR
- Combinez texte (question + OCR + caption) et, si possible, embedding image.
- Fusionnez les scores + re-rank LLM.
- Appliquez des boosts (brand/model/category).
- Si < seuil, clarifiez avec l’utilisateur.
- Retournez slide_ref → deep link OdoLearn.