Un RAG Magique !
Publié le 17 juillet 2026
Il y a peu, je me suis mis à rejouer à Magic: The Gathering avec des amis. C’est un jeu de cartes à collectionner dans lequel on construit des decks pour affronter ses adversaires à travers divers modes de jeux possibles. Le jeu existe depuis 1993 et n’a cessé d’évoluer depuis. Résultat : même les joueurs et joueuses les plus expérimenté·es se font parfois surprendre par certaines règles ou interactions de cartes. Alors pour moi, qui n’avait pas joué depuis 20 ans 👴, autant vous dire qu’à chaque partie, je passe un bon moment sur Internet à chercher telle ou telle règle pour éviter de faire n’importe quoi. C’est là que je me suis dit que ce serait sympa d’avoir à portée de main un petit assistant à qui poser directement mes questions. Pourquoi ne pas simplement ouvrir ChatGPT ou un autre agent conversationnel, me direz-vous ? Déjà parce que je souhaitais éviter au maximum les hallucinations, comme une réponse qui viendrait d’un fil Reddit d’il y a dix ans et ne correspondrait pas ou plus à la réalité. Ensuite, et surtout, parce que c’était l’occasion parfaite de coder un petit truc et d’apprendre en même temps ! C’est comme ça que j’ai décidé de me lancer dans la création d’une application mobile basée sur des LLM et du RAG. Si vous souhaitez jeter un œil au code (encore un peu en chantier, ne jugez pas), il se trouve sur GitHub.
RAG : qu’est-ce que c’est, et pourquoi ?
Pour nos lecteurs et lectrices francophones, mon collègue Clément a fait un chouette article sur l’architecture RAG ainsi qu’un talk au dernier Forum PHP. C’est d’ailleurs cela qui m’a poussé vers ce choix : je préférais guider le modèle en basant ses réponses sur les règles à jour et la liste des cartes existantes plutôt que de risquer une hallucination. Un chatbot classique aurait aussi potentiellement plus de difficultés, avec de simples recherches web par exemple à déterminer l’interaction entre plusieurs cartes.
J’ai donc choisi le corpus suivant, qui en soit devrait permettre de couvrir la majeure partie des cas d’usage classiques :
- la liste complète des règles de Magic à jour, disponible sur le site web du jeu.
- la liste complète des cartes, téléchargeable via l’API de Scryfall en JSON.
Comme ça, désormais, avec un peu de manipulation de prompt, je peux m’assurer que le chatbot ne répondra qu’en basant ses réponses sur les règles et les cartes, en les citant précisément, et qu’il me répondra “Je ne sais pas” plutôt que d’inventer une réponse.
“Attends, tu fournis l’ensemble des règles et des cartes au contexte du LLM à chaque question que tu poses ?”
Fort heureusement non ! C’est là que la partie RAG intervient. On va tout d’abord préparer un pipeline d’ingestion et d’embedding des données :

Pour les règles, l’idée va être de :
- Télécharger la liste des règles au format .txt
- Parser et séparer le texte en chunks, qui correspondront chacun à une section (ou sous-section) des règles. À noter que ces chunks n’ont pas de taille fixe : ils s’adaptent au paragraphe de la règle, car il est nécessaire d’avoir l’intégralité de celui-ci pour conserver un maximum de sens.
- Insérer ces chunks en base de données.
Pour base de données, j’ai choisi PostgreSQL. Grâce à son extension pgvector, elle nous permettra par la suite de stocker des versions vectorisées de ces règles, et surtout de les requêter pour faire remonter celles qui ressemblent le plus à la question posée !
Une fois insérée, une ligne de règles va pouvoir ressembler à ça en base :

On voit qu’elles y sont bien découpées par sous-section.
Pour les plus attentifs·ves, vous avez pu remarquer la colonne “embedding” dans la capture d’écran d’au-dessus, c’est l’étape suivante.
L’embedding, c’est le moment où l’on va transformer le corps du texte de la règle en un vecteur qui portera au mieux la sémantique de cette règle, et qu’on pourra plus facilement comparer aux vecteurs des questions posées, pour trouver les règles qui s’approchent le plus desdites questions. Pour réaliser cela, j’ai choisi de faire au plus simple. Pas besoin de créer son propre modèle d’embedding, nous allons appeler l’API d’OpenAI (choix arbitraire ici, il est aussi tout à fait possible de choisir un modèle de Voyage, ou Qwen3 par exemple) avec comme modèle text-embedding-3-small, qui est peu coûteux, et fait largement l’affaire pour nos besoins. Le flux est très simple : récupérer chaque ligne de la table de règles, envoyer le contenu à l’API d’embedding, recevoir un vecteur et l’insérer dans la ligne correspondante.
Concernant les cartes, même principe au départ :
- Récupérer toutes les cartes au format JSON depuis l’API de Scryfall.
- Décoder le body de la réponse, puis effectuer quelques manipulations pour gérer les cas spécifiques (comme les cartes à double face).
- Insérer le tout dans notre table en base de données.
On a finalement une table qui ressemble à ça :

Par contre ici, on va spécifiquement choisir de ne PAS embedder les cartes en vecteurs !
En effet, lorsque des questions concernant des cartes seront posées, on ne veut pas qu’il y ait une tentative de comparaison sémantique sur les titres des cartes. Une question à propos de la carte “Lightning bolt” ne doit pas essayer de remonter plusieurs cartes dont le titre aurait un sens similaire, au risque de suggérer de mauvaises informations au LLM derrière.
Néanmoins, avoir une tolérance au “fuzzy typing” est tout de même important, et ajouter un index Trigram (grâce à l’extension pg_trgm) sur le nom des cartes nous donne cette flexibilité.
Et le LLM dans tout ça ?
A ce stade, toutes les données sont prêtes. C’était une étape quasi-offline qui nous a permis d’avoir une base de données remplies pour que le LLM ne puisse piocher que dans ces informations. Elle est potentiellement à répéter tous les quelques mois pour avoir les dernières versions des règles et des cartes.
Voici un schéma explicatif du flux final :

L’application mobile est une petite app React Native / Expo façon chatbot, avec une zone de saisie utilisateur et un espace pour afficher la réponse finale. Je ne rentrerai pas forcément dans les détails ici, mais le code est disponible sur le dépôt pour les curieux·ses !
Lorsque l’utilisateur envoie son message, celui-ci est réceptionné par un simple serveur HTTP en Go avec quelques routes d’API disponibles, dont la principale : POST /chat est un passe-plat classique qui va prendre la question, la passer à un service, et renvoyer la réponse formatée de ce service.
Regardons donc plutôt le service en question :
func (e *Engine) Answer(ctx context.Context, question string) (*Result, error) {
p, err := e.prepare(ctx, question)
if err != nil {
return nil, err
}
answer, err := e.llm.Generate(ctx, p.analysis.AnswerLanguage, p.context)
if err != nil {
return nil, err
}
return &Result{
Answer: answer,
Analysis: p.analysis,
Rules: p.rules,
Glossary: p.glossary,
Cards: p.cards,
}, nil
}
On peut voir qu’on passe ici par une phase de pré-processing `e.prepare(ctx, question)` au lieu d’essayer de trouver tout de suite la réponse. En effet, ici, toutes nos données sont en anglais en BDD, mais si mon niveau d’anglais est évidemment irréprochable, un bon nombre des cartes que j’ai sont en français. On envoie donc une première requête à un LLM simple et pas trop cher (Anthropic Haïku en l’occurrence) pour lui demander de normaliser un peu la question, mais aussi d’essayer de détecter des noms de cartes parmi la question pour les avoir à part et mieux requêter leur table ensuite.
Voici le prompt de pré-processing passé à Haïku :
You extract structured information from a Magic: The Gathering rules question. The user may write in English, French, or a mix. Respond with ONLY a JSON object (no prose, no code fences) with exactly these keys:
– « question_en »: the question rewritten in clear English, suitable for semantic search over the Comprehensive Rules. If it is already English, lightly clean it up.
– « cards »: array of Magic card names explicitly named in the question, each given as its canonical ENGLISH name (translate French names, e.g. « Foudre » -> « Lightning Bolt »). Use [] if no specific card is named.
– « answer_language »: the ISO 639-1 code of the language the user wrote in (« en », « fr », …). For a mix, pick the dominant one. When rewriting the question, use the exact canonical ENGLISH keyword names for Magic mechanics rather than paraphrasing them (e.g. « défense talismanique » -> « hexproof », « piétinement » -> « trample », « lien de vie » -> « lifelink »). Common keywords are already substituted for you, but map any that remain.
func (e *Engine) prepare(ctx context.Context, question string) (*prepared, error) {
analysis, err := e.llm.Preprocess(ctx, question)
if err != nil {
return nil, err
}
vecs, err := e.embed.Embed(ctx, []string{analysis.QuestionEN})
if err != nil {
return nil, err
}
query := vecs[0]
rules, err := retrieval.SearchRules(ctx, e.conn, query, e.k)
if err != nil {
return nil, err
}
glossary, err := retrieval.SearchGlossary(ctx, e.conn, query, e.k)
if err != nil {
return nil, err
}
var cards []CardContext
seen := make(map[string]bool)
for _, name := range analysis.Cards {
c, err := retrieval.ResolveCard(ctx, e.conn, name)
if err != nil {
return nil, err
}
if c == nil || seen[c.OracleID] {
continue // no confident match, or already added
}
seen[c.OracleID] = true
rulings, err := retrieval.Rulings(ctx, e.conn, c.OracleID)
if err != nil {
return nil, err
}
cards = append(cards, CardContext{Card: *c, Rulings: rulings})
}
return &prepared{
analysis: analysis,
rules: rules,
glossary: glossary,
cards: cards,
context: buildContext(question, rules, glossary, cards),
}, nil
}
Une fois cette étape réalisée (ligne 1 ci-dessus), on peut voir qu’on va, dans l’ordre:
- Embedder la question en vecteurs (nouvel appel à OpenAI).
- Aller chercher dans les règles celles qui s’approchent le plus sémantiquement de la question (`retrieval.SearchRules(ctx, e.conn, query, e.k)`). Et grâce à pgvector, rien n’est plus simple :
const q = `
SELECT rule_number, section_title, body, embedding <=> $1::vector AS dist
FROM rules
WHERE embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT $2`
On fait juste un select sur la table, en effectuant une comparaison de distance sinusoïdale ( l’opérateur ⇔ ici), avec un ORDER BY sur la valeur de cette distance – plus la distance est petite, plus la similarité est grande – et on garde les X premiers résultats. X vaut ici 10 et est sur le papier tout à fait modifiable ! Je l’ai mis à 10 après un peu d’expérimentation manuelle, où 5 par exemple ne remontait pas assez de règles pertinentes. Une valeur encore plus élevée risquerait de polluer les résultats envoyés au LLM final avec des informations moins pertinentes.
- On fait la même chose pour le glossaire (que je n’avais pas mentionné, mais qui est rempli de la même façon que les règles)
Si le pré-processing avait détecté des noms de carte dans la question, on va pour chaque nom ainsi fourni regarder si on trouve les infos de la carte, avec une simple requête par similarity pour le fuzzy matching sur le nom.
const q = `
SELECT oracle_id, name, mana_cost, type_line, oracle_text, similarity(name, $1) AS sim
FROM cards
WHERE name % $1
ORDER BY sim DESC, length(name)
LIMIT 1`
Une fois toutes ces infos remontées, il ne nous reste plus qu’à appeler un LLM (ici Sonnet 5, pour de meilleures capacités de raisonnement et de synthèse qu’Haïku, et toujours un prix raisonnable) avec elles pour renvoyer une réponse construite uniquement sur ces résultats !
Voici le prompt :
You are a Magic: The Gathering rules assistant for a casual playgroup.
Answer the question using ONLY the provided context (Comprehensive Rules excerpts, glossary entries, and card data). Follow these rules strictly:
– Ground every claim in the context. Do NOT use outside knowledge of the rules, even if you are confident — the context is the single source of truth.
– Cite the specific rule numbers you rely on, in parentheses, e.g. (601.2a). Every rule claim needs a citation.
– If the provided context does not contain enough to answer correctly, say so plainly (« Je ne suis pas sûr d’après les règles récupérées … ») rather than guessing.
– Be concise and concrete. Walk through the interaction step by step when it is subtle.
– Card data is from Scryfall.
– Write in plain text — no Markdown (no #, *, backticks or tables). Use short paragraphs, and simple « – » bullets only if a list genuinely helps. The answer is shown in a mobile chat bubble.
– Write your entire answer in the language identified by this ISO code: %s.
La réponse du LLM est alors streamée à l’application mobile, et l’utilisateur la voit s’afficher sur son écran 🤩
Voici quelques petits exemples :

Mais si je lui demande des infos qui ne matchent pas :

La conclusion du MJ
En partant d’un simple débat de table sur une obscure règle de Magic datant de 1995, on a fini par mettre sur pied un pipeline RAG complet, robuste et résolument moderne.
De l’ingestion des données au requêtage sémantique dans PostgreSQL avec pgvector, en passant par un double niveau de LLM, notre assistant est désormais paré à toute épreuve. Le tout est propulsé par un serveur en Go et une application mobile React Native / Expo.
Ce petit side project de passionné·e démontre aussi que, bien architecturée, l’IA dépasse le stade de gadget sujet aux hallucinations pour devenir un outil de précision redoutable. Vous souhaitez échanger sur vos problématiques de RAG ? Discutons-en !