Recherche hybride pour le RAG en persan : guide pratique
Recherche hybride pour le RAG en persan sur PostgreSQL et pgvector : normalisation sûre, fusion RRF des rangs, aucun filtre par langue, tests bilingues.
La recherche hybride pour un RAG en persan (farsi) consiste à faire tourner côte à côte deux retrievers, une recherche dense par embeddings et une recherche lexicale, puis à fusionner leurs classements par fusion de rangs réciproques (reciprocal rank fusion, RRF). Il vous faut les deux, car le persan met à mal les hypothèses tacites des recettes pensées pour l’anglais : un même mot peut être stocké avec des lettres arabes ou persanes, un antiliant sans chasse (zero-width non-joiner) se glisse à l’intérieur des mots, et ni Snowball ni PostgreSQL ne fournissent de stemmer persan. En bref : normalisez à l’écriture, gardez le côté lexical sans racinisation, fusionnez des rangs plutôt que des scores, ne filtrez jamais par langue, découpez en suivant la structure propre du document, et testez sur un jeu de questions bilingue avant de vous fier à quoi que ce soit.
Ce guide s’appuie sur deux systèmes. Le premier est l’assistant de ce site, que j’ai construit et que je maintiens. Le second est Kartez Contract, où j’ai travaillé comme ingénieur IA ; son copilote devait retrouver les bonnes clauses dans des contrats persans, numérotés par article, clause et note. La conception de recherche décrite ici (dense et lexical, fusion de rangs, pgvector) est celle de ce site ; de Kartez, je ne reprends que ce qu’en dit son étude de cas. Ni l’un ni l’autre système ne fait ici l’objet d’un benchmark.
Pourquoi les recettes RAG pensées pour l’anglais échouent sur le texte persan
L’échec typique n’est pas une mauvaise réponse mais une absence de réponse : la recherche manque un mot que l’utilisateur voit pourtant dans le document, parce que deux chaînes d’apparence identique diffèrent sous la surface.
Un mot, deux chaînes : le yeh et le kaf arabes et persans
Le persan et l’arabe partagent une écriture, pas tous leurs points de code. Le ی persan (U+06CC ARABIC LETTER FARSI YEH) et le yeh arabe (U+064A ARABIC LETTER YEH) sont deux caractères différents, tout comme le ک persan (U+06A9 ARABIC LETTER KEHEH) et le kaf arabe (U+0643 ARABIC LETTER KAF). Les Arabic and Persian Layout Requirements du W3C nomment explicitement la paire du yeh : la lettre est « représentée par deux caractères différents ». Un passage stocké sous une forme ne correspond pas à une question saisie avec l’autre ; l’étape de normalisation du schéma ci-dessus représente les deux paires.
Ce site ramène notamment le yeh arabe, l’alef maksura et le kaf arabe à leurs formes persanes avant qu’un passage n’entre dans l’index de recherche, retire de cette copie les marques bidi invisibles, et applique la même conversion à chaque question.
L’antiliant sans chasse (ZWNJ) fait partie du mot
میشود, میشود et می شود sont trois chaînes différentes. La première sépare le préfixe du verbe par un antiliant sans chasse (zero-width non-joiner, ZWNJ, U+200C), qui empêche les lettres de se lier sans ajouter d’espace. Le standard Unicode décrit cette liaison, contraire au comportement par défaut, comme « parfois nécessaire pour écrire le persan avec l’écriture arabe », et les exigences du W3C précisent que les caractères de contrôle « devraient être conservés lors du stockage et de l’affichage des textes ».
Supprimez le ZWNJ, comme pourrait le faire un pipeline pensé pour l’anglais, et میشود devient میشود ; remplacez-le par un espace, et un mot devient deux tokens. Le normaliseur de hazm, une boîte à outils de TAL pour le persan, inclut une « correction du ZWNJ » (ZWNJ correction), qui transforme کتاب های en کتابهای. Sur ce site, le texte stocké conserve le ZWNJ, et seule la conversion utilisée à l’intérieur des comparaisons le traite comme un espace.
Trois jeux de chiffres
Le persan écrit ۱۲۳ avec les chiffres indo-arabes orientaux (Eastern Arabic-Indic, U+06F0 à U+06F9), l’arabe écrit le même nombre avec les chiffres indo-arabes (Arabic-Indic, U+0660 à U+0669), et les textes techniques écrivent souvent 123.
N’unifiez les chiffres que pour la comparaison, jamais dans le texte stocké ; sur ce site, réécrire les chiffres persans stockés changerait l’affichage du CV persan. L’unification doit toutefois s’appliquer des deux côtés : n’unifiez que les chiffres de la question, et une question en chiffres persans ne correspond plus au passage dont elle a été copiée. L’article sur les garde-fous pour LLM en streaming applique la même unification aux nombres qu’émet un modèle.
Les diacritiques sont rares : un signe isolé suffit à casser une correspondance exacte
Les exigences du W3C le disent sans détour : « Dans un texte en écriture arabe, il est inhabituel d’utiliser des diacritiques pour indiquer les voyelles et l’allongement des consonnes. » Ainsi, le seul passage qui porte une kasra (un signe de voyelle brève), ou la seule question collée avec un tashdid (le signe de redoublement), échoue à toute correspondance exacte avec la graphie sans signes. Dans les travaux de recherche sur la synthèse vocale en persan sur lesquels j’ai travaillé pour MCI, cette même propriété était ce qu’il y avait de plus difficile, du côté de la parole cette fois : les voyelles brèves et l’ezāfe, qui relie un nom à ce qui le qualifie, ne s’écrivent pas. Pour la recherche, retirez les diacritiques à l’intérieur des comparaisons et conservez-les dans le texte stocké.
Les utilisateurs écrivent en langue familière, les documents en registre soutenu
« Le persan est l’une des langues qui présentent de nombreuses différences entre leurs styles d’écriture formel et informel », écrivent Tajalli, Shamsfard et Kalantari en présentant un corpus parallèle de 50 000 paires de phrases. Un utilisateur tape میتونم là où un texte formel écrit میتوانم. Ma lecture de la conséquence : un appariement lexical voit peu de recouvrement entre une question familière et une clause formelle, c’est donc le retriever dense qui a une chance, et un jeu de test composé de questions bien formées masquera cet écart.
Une normalisation sans risque pour le persan
Ramenez le yeh et le kaf arabes aux formes persanes ی et ک à l’écriture, dans les contenus persans uniquement.
Conservez le ZWNJ dans le texte stocké.
Retirez les marques bidi de la copie que vous indexez et comparez, pas du texte affiché.
N’unifiez les chiffres et ne retirez les diacritiques qu’à l’intérieur des comparaisons, des deux côtés.
Appliquez exactement la même fonction aux questions et aux passages.
N’appliquez jamais la conversion persane à un texte arabe, où le yeh (U+064A) et le kaf (U+0643) arabes sont les lettres correctes.
Le retriever de ce site ne satisfait pas encore cette liste : il applique la conversion des lettres persanes dans toutes les langues, arabe compris ; sa liste lexicale peut manquer un passage écrit en chiffres persans ; et les diacritiques de l’écriture arabe sont retirés des termes de la question et de toutes les comparaisons que fait le sas par phrase, mais pas du texte stocké que parcourt la recherche par mots-clés. Lisez cette liste comme un objectif.
Recherche plein texte en persan dans PostgreSQL : aucune configuration intégrée
La recherche plein texte est la moitié lexicale évidente d’une recherche hybride, et, d’après sa documentation, PostgreSQL « est livré avec des configurations prédéfinies pour de nombreuses langues ». Le persan n’en fait pas partie.
Snowball a un stemmer arabe, pas de stemmer persan
La liste des algorithmes de racinisation de Snowball comprend l’arabe et ne contient aucune entrée pour le persan ou le farsi. PostgreSQL construit ses dictionnaires de racinisation à partir de Snowball : la liste des langues dans le code source de PostgreSQL 18 comprend arabic et pas persian, et la liste des dictionnaires de la documentation montre arabic_stem et simple, sans aucun dictionnaire persan. Un stemmer arabe partage l’écriture du persan, pas sa grammaire. Hors de la base de données, hazm fournit un stemmer et un lemmatiseur pour le persan ; à l’intérieur, il n’y en a aucun.
Ce que la configuration simple vous apporte, et ce qu’elle ne vous apporte pas
Le modèle de dictionnaire simple fonctionne, selon la documentation de PostgreSQL sur les dictionnaires, en « convertissant le token d’entrée en minuscules et en le comparant à un fichier de mots vides ». Le dictionnaire intégré simple n’a pas de fichier de mots vides : tous les tokens sont donc conservés, et il n’y a pas de racinisation, si bien que قرارداد et قراردادها restent deux lexèmes, une perte pour le rappel et un gain en prévisibilité.
Avant l’exécution de tout dictionnaire, l’analyseur décide de ce qu’est un mot, et sa notion de lettre « est déterminée par le paramètre de locale de la base de données, plus précisément lc_ctype ». Ne présumez donc pas de la façon dont votre base découpe le persan, et encore moins les mots liés par un ZWNJ. Interrogez-la, avec la fonction ts_debug de la documentation sur le test et le débogage de la recherche plein texte :
-- How does this database tokenise Persian? Check; do not assume.
SELECT alias, token, lexemes
FROM ts_debug('simple', 'قراردادها را میتوان فسخ کرد');La recherche par mots-clés de ce site utilise simple pour les lignes en persan et english pour les lignes en anglais. La démo Recherche sémantique et par mots-clés la fait tourner à côté du retriever hybride de l’assistant, sur les contenus de ce site.
La similarité par trigrammes contre les fautes de frappe
L’extension pg_trgm compare des séquences de trois caractères, si bien qu’un mot mal orthographié partage encore des trigrammes avec la forme correcte. Elle « ignore les caractères qui n’appartiennent pas aux mots (non alphanumériques) lors de l’extraction des trigrammes » ; vérifiez donc avant de vous y fier : SELECT show_trgm('قرارداد'); doit renvoyer des trigrammes composés de lettres persanes. La recherche par mots-clés du site ajoute une branche word_similarity, qui compare une requête courte à la meilleure portion continue d’un passage long.
Couverture en mots entiers : la liste lexicale du retriever de ce site
Le retriever de l’assistant n’utilise pas la recherche plein texte pour sa liste lexicale, puisqu’il n’existe pas de configuration persane. Il compte combien de mots de contenu de la question apparaissent dans un passage en tant que mots entiers, à l’aide de limites de mot d’expression régulière. Il retire les mots vides de chacune des langues du site, écarte les termes très courts, garde les signes vocaliques du hindi dans le mot auquel ils appartiennent et n’applique aucune racinisation. Le résultat est un taux de couverture compris entre 0 et 1.
Les limites de mot comptent : une correspondance de sous-chaîne sur « is » trouve « his », « this » et « Isfahan », et chaque passage finit par ressembler à une preuve. Le fait que le taux soit borné compte aussi, car on peut le comparer à un seuil de similarité. Ce taux ne compte pour le seuil de refus que si au moins deux mots de contenu différents correspondent.
Vecteurs creux appris : l’option documentée
La recherche creuse apprise (learned sparse retrieval) apparie des termes comme une recherche lexicale, avec des poids appris par un modèle. Dans BGE-M3, ces termes sont des morceaux produits par le tokeniseur — sa carte de modèle pondère « De », « fin » et « ation » —, et le vecteur a une « taille égale au vocabulaire », selon les mots de l’article qui l’a présenté : 250 002 dans la configuration du modèle. Le retriever de ce site en production ne les utilise pas : il combine dense et lexical, et l’API d’embeddings qu’il appelle ne renvoie aucun poids creux. Si vous en ajoutez, lisez d’abord la section sur le décalage d’indice, plus bas.
Recherche hybride avec la fusion de rangs réciproques : fusionner des rangs, pas des scores
Un retriever dense et un retriever lexical renvoient des listes classées dont les scores n’ont pas la même signification. La fusion de rangs réciproques ignore les scores et combine les positions.
La formule, et d’où vient k = 60
Cormack, Clarke et Büttcher définissent le score fusionné d’un document comme la somme, sur l’ensemble des classements, de 1/(k + r(d)), où r(d) est son rang dans chaque liste. La constante est moins délicate qu’il n’y paraît : « k = 60 a été fixé lors d’une étude pilote et n’a pas été modifié lors de la validation qui a suivi », et l’étude pilote « indiquait que k = 60 était proche de l’optimum, mais que ce choix n’était pas critique ».
Le retriever de ce site utilise k = 60, et chaque liste fournit plus de candidats que la réponse n’en utilisera : un passage situé au milieu d’une liste et près du sommet de l’autre peut ainsi encore remonter. Dans les grandes lignes :
K = 60 # Cormack, Clarke and Büttcher
def fuse(lists, k=K):
"""lists: (weight, passage ids best-first) for each retriever."""
scores = {}
for weight, ranked in lists:
for rank, passage in enumerate(ranked, start=1):
scores[passage] = scores.get(passage, 0.0) + weight / (k + rank)
return scores
def order(scores, locales_of, query_locale):
# The fused score decides. Locale only separates exactly equal
# scores, and the id keeps the order identical on every run.
return sorted(scores, key=lambda p: (-scores[p], query_locale not in locales_of[p], str(p)))
fused = fuse([(1.0, dense_ids), (0.5, lexical_ids)])
passages = order(fused, locales_of, query_locale="fa")[:top_k]Pourquoi une somme de scores exige des poids à régler
L’alternative consiste à additionner les scores, comme le fait l’article à l’origine de BGE-M3 : s_rank ← w1·s_dense + w2·s_lex + w3·s_mul, « où les valeurs de w1, w2 et w3 dépendent du scénario en aval ». Pour la combinaison dense et creuse, les auteurs fixent w1 = 1 et w2 = 0,3 sur MIRACL, et w1 = 0,2 et w2 = 0,8 sur MLDR, un benchmark de documents longs.
Une similarité cosinus, un produit scalaire creux et un taux de couverture vivent sur des échelles différentes, et le taux de change entre eux varie avec le corpus. La fusion par rangs contourne cet étalonnage. C’est un compromis, pas un gain gratuit : l’article obtient de solides résultats avec des sommes pondérées, et la RRF ne sait pas distinguer une victoire écrasante d’une courte victoire.
Pourquoi la liste lexicale pèse moitié moins
Sur ce site, la liste lexicale sert à faire remonter les passages porteurs de chaînes exactes qu’un modèle dense peut brouiller : un nom de domaine comme booqai.app, un sigle, le nom d’un programme comme SAMPAD. C’est la raison habituelle d’en conserver une. Dans le billet d’Anthropic sur Contextual Retrieval, un modèle d’embedding « pourrait trouver du contenu sur les codes d’erreur en général, mais manquer la correspondance exacte “TS-999” ».
La liste lexicale contribue à mi-poids, ce qui fait remonter un passage que la liste dense a renvoyé elle aussi, sans laisser le recouvrement de mots-clés l’emporter sur le classement dense. Le prix à payer est réel : avec k = 60 et les nombres de candidats de ce site, un passage que seule la liste lexicale trouve se classe derrière tous les candidats denses ; la fusion ne peut donc pas repêcher un passage que le modèle dense a entièrement manqué. La seule exception est le passage dont la similarité est la plus élevée, qui est toujours renvoyé, comme l’explique la section suivante. Si l’appel d’embedding échoue, la recherche se replie sur la seule liste lexicale, et le seuil de similarité s’applique toujours.
Un score RRF n’est pas un indice de confiance
Un score RRF est construit uniquement à partir de positions : un corpus de pur bruit produit donc quand même un passage classé premier, avec le meilleur score fusionné du lot. Le seuil de refus de ce site est par conséquent comparé, pour chaque passage, à une vraie similarité, jamais au score fusionné : la plus élevée entre sa similarité cosinus avec la question telle qu’elle a été posée (ou, pour une question de suivi, avec la question jointe à la précédente, entre lesquelles s’insère l’élément qu’elle désigne quand la question est formulée comme une position dans une liste, ou le début de la réponse précédente quand cet élément ne peut pas être lu) et sa couverture en mots entiers, qui ne compte que si au moins deux mots de contenu correspondent. Le seuil s’applique avant tout appel au modèle de langue.
Le passage dont la similarité est la plus élevée parmi tous les candidats figure toujours parmi ceux qui sont renvoyés, même si la fusion l’a classé trop bas pour être retenu. Sous le seuil, une question qui ne me concerne pas est refusée sans appel au modèle, et une question qui me concerne reçoit à la place les enregistrements du site ; l’article sur les garde-fous contre les hallucinations en RAG traite des deux.
Ne filtrez jamais la recherche par langue
Un filtre par langue a l’air d’une simple mesure d’ordre : les questions en persan cherchent dans les passages en persan. Sur un corpus bilingue, il supprime des preuves.
Votre meilleure preuve peut être dans l’autre langue
Sur ce site, les passages de toutes les langues cohabitent dans une même table, et une question les parcourt tous. Le CV persan est le document source et le CV anglais une adaptation : une question en anglais sur un poste peut donc trouver sa meilleure preuve en persan. Kartez Contract présentait la même configuration dans l’autre sens : son assistant de rédaction devait transposer des exigences formulées en persan dans une bibliothèque de textes anglais de rédaction contractuelle.
La langue ne sert qu’à départager les égalités exactes
La langue garde une utilité dans le classement : entre deux passages ayant exactement le même score fusionné, celui qui existe dans la langue de la question vient en premier, puis un identifiant stable tranche, comme dans la clé de tri ci-dessus. Avec une seule liste dense et la liste lexicale à mi-poids, les égalités exactes ne se produisent pratiquement jamais. Elles peuvent survenir quand une question est recherchée sous plusieurs versions, comme décrit plus bas, et que deux passages obtiennent les mêmes rangs dans les listes denses de ces versions, qui ont le même poids ; même alors, la langue ne tranche que si un seul des deux existe dans la langue de la question. C’est voulu : la langue ne doit jamais primer sur un score. La langue a une autre fonction : choisir quelle traduction d’un passage le lecteur voit.
Un petit bonus accordé aux passages de même langue n’est pas un départage. Deux contributions adjacentes d’une même liste à plein poids diffèrent de 1/((k + r)(k + r + 1)), un écart qui se resserre à mesure que l’on descend dans la liste, et des sommes issues de deux listes peuvent être plus proches encore. Un bonus fixe n’est sûr que tant qu’il reste inférieur à tous les écarts que vos profondeurs de liste et vos poids peuvent produire, une limite qui bouge dès que l’un ou l’autre change. Dès qu’il dépasse cette limite, des résultats qui n’étaient pas à égalité échangent leurs places au milieu de la liste, précisément parmi les candidats que la fusion est censée faire remonter.
Un passage, dix traductions
Les pages de ce site sont publiées en dix langues au plus, et les copies d’un même passage dans différentes langues pouvaient occuper plusieurs des huit places transmises au modèle. La recherche traite désormais les traductions d’un passage comme un seul candidat : il est noté d’après sa meilleure traduction, classé une seule fois et affiché dans la langue du lecteur quand cette traduction existe. Les articles sont regroupés par section plutôt que par chunk, car une longue section se découpe en un nombre de chunks différent selon la langue ; une traduction dont les sections ne correspondent pas à celles de la version anglaise reste à part au lieu d’être mal regroupée. La langue du lecteur décide quelle traduction s’affiche ; hors égalité exacte, elle ne décide jamais du rang du passage.
Questions en persan, documents en anglais
Seul le côté dense peut franchir la barrière de la langue : une question en persan et un passage en anglais n’ont presque aucun mot en commun, hormis les termes en alphabet latin comme RAG. L’évaluation de BGE-M3 elle-même ne tranche pas la question pour le persan : son benchmark interlingue, MKQA, recherche des passages de Wikipédia en anglais pour des requêtes formulées dans 25 langues autres que l’anglais, et l’arabe en fait partie, pas le persan. Testez vos propres paires persan–anglais.
Questions sans mots-clés
La question d’un visiteur « Qu’a-t-il fait récemment ? » ne contient aucun mot que le corpus puisse retrouver, et « Et avant ? » ne contient rien du tout. Quand une question me concerne, elle est aussi recherchée avec mon nom en tête, et une question de suivi est aussi recherchée jointe à la question précédente, avec entre les deux l’élément qu’elle désigne quand elle est formulée comme une position dans une liste, comme « le deuxième », ou le début de la réponse précédente quand cet élément ne peut pas être lu. Toutes les versions sont transformées en embeddings en un seul appel et fusionnées par rang. La version portant mon nom ordonne les passages, mais ne compte jamais pour le seuil de refus, car mon nom, à lui seul, peut faire paraître proche de mon CV une question sur moi qui n’a rien à voir avec mon travail. Les questions sur l’ensemble de mon travail ne dépendent pas de la recherche : elles trouvent leur réponse dans des enregistrements datés que le site tire de mon CV, de mes études de cas, de mes articles et de mes services, comme l’explique l’article sur les garde-fous contre les hallucinations en RAG.
Chunking des contrats persans par article, clause et note
Les contrats persans ont leur propre structure d’articles (ماده), de clauses (بند) et de notes (تبصره). Dans le copilote de Kartez Contract, le contrat de l’utilisateur était découpé selon cette structure, et seules les sections pertinentes pour une question servaient à y répondre ; les réponses « s’appuient uniquement sur les clauses sélectionnées ». L’étude de cas Kartez Contract couvre le reste du projet, et que construire face aux hallucinations de l’IA juridique traite du domaine.
Le même principe guide le découpage de ce site : un chunk est un enregistrement sémantique. Un poste du CV n’est jamais séparé de ses dates, une étude de cas se découpe à ses sections telles qu’elles ont été rédigées, un article à ses intertitres, et une section longue ne se coupe qu’à une frontière de paragraphe. Chaque chunk de section porte le titre du document : un chunk retrouvé isolément indique toujours d’où il vient.
Pour les contrats persans, on peut en tirer quelques règles, proposées comme conseils généraux et non comme description de la façon dont Kartez a été construit :
Gardez le numéro avec le texte. « ماده ۵ » et son contenu vont dans un même chunk.
Reportez l’intitulé de l’article parent dans chaque chunk de clause et de note.
Ne coupez jamais une clause sur un nombre de tokens. Une note séparée de sa clause peut changer ce que la clause semble dire.
Stockez le chemin structurel, article › clause › note, pour qu’une réponse puisse renvoyer un professionnel à l’endroit exact.
pgvector : parcours exact ou HNSW ?
pgvector répond lui-même à la moitié de la question : « Par défaut, pgvector effectue une recherche exacte des plus proches voisins, qui offre un rappel parfait. » Un index approximatif « échange un peu de rappel contre de la vitesse ».
Les filtres sont le coût le moins visible. « Avec les index approximatifs, le filtrage est appliqué après le parcours de l’index. Si une condition correspond à 10 % des lignes, avec HNSW et la valeur par défaut de hnsw.ef_search, soit 40, seules 4 lignes correspondront en moyenne. » À partir de la version 0.8.0, pgvector peut parcourir l’index de façon itérative jusqu’à obtenir assez de lignes correspondantes, une fois l’option activée.
La table qui sert à la recherche sur ce site n’a aucun index approximatif : la recherche vectorielle est un parcours exact. L’état de publication fait partie de la clause SQL WHERE et n’est jamais un filtre appliqué après LIMIT, où il réduirait silencieusement l’ensemble de résultats. Commencez par un parcours exact, ajoutez un index quand la latence l’exige, et comparez son top-k à celui d’un parcours exact sur vos propres questions avant de vous y fier.
Le décalage d’indice (off-by-one) de sparsevec dans pgvector
Ce point ne vous concerne que si vous ajoutez des vecteurs creux appris à PostgreSQL, et l’erreur est silencieuse.
Le format texte des vecteurs creux dans pgvector est {index1:value1,index2:value2}/dimensions, « et les indices commencent à 1, comme les tableaux SQL ». Les identifiants de tokens, eux, commencent à 0. Le client Python prend des indices à partir de 0 (« Indices start at 0 ») et ajoute lui-même 1 lorsqu’il écrit la forme texte.
from pgvector import SparseVector
weights = {0: 0.05, 2: 0.14, 4: 0.04} # tokeniser ids, 0-based
# Client library: pass 0-based ids; it adds 1 when it writes SQL text.
vec = SparseVector(weights, 6)
# Hand-built SQL literal: shift exactly once, here.
literal = "{" + ",".join(f"{i + 1}:{w}" for i, w in weights.items()) + "}/6"
# "{1:0.05,3:0.14,5:0.04}/6"Décalez exactement une fois, là où un littéral SQL est construit à la main. L’entrée texte de pgvector convertit chaque indice en base 0 et ne lève « sparsevec index out of bounds » que si le résultat est inférieur à 0, ou supérieur ou égal à la dimension. Un double décalage n’échoue donc que pour le haut du vocabulaire, un décalage oublié seulement pour le token d’identifiant 0, et tous les autres poids atterrissent sans bruit sur le token voisin.
Choisir un modèle d’embedding pour le persan (farsi)
Ce que MIRACL dit du persan et de l’arabe
MIRACL est un benchmark de recherche d’information couvrant 18 langues, dont le persan et l’arabe. L’article à l’origine de BGE-M3 rapporte le nDCG@10, un score de qualité du classement sur les dix premiers résultats, sur son jeu de développement (version ACL ; les auteurs ont mis à jour ces résultats en juillet 2024) :
Retriever | Persan (fa) | Arabe (ar) |
|---|---|---|
BM25 sur tokens XLM-RoBERTa | 28,7 | 39,5 |
mE5-large (E5 multilingue) | 59,0 | 76,0 |
BGE-M3 creux (sparse) | 45,1 | 67,1 |
BGE-M3 dense | 60,9 | 78,4 |
BGE-M3 dense + creux + multivecteur | 63,4 | 80,2 |
Sur le persan, la recherche dense obtient un score bien supérieur à ce BM25, la recherche creuse apprise se situe entre les deux, et la combinaison des trois modes ajoute un peu au score dense. Quatre réserves accompagnent ces chiffres :
Ce BM25 utilise des tokens de sous-mots. Les auteurs l’ont fait tourner sur le tokeniseur de XLM-RoBERTa pour s’aligner sur le modèle ; leur annexe montre que l’analyseur de Lucene fait passer la moyenne MIRACL toutes langues confondues de 31,9 à 38,5. La baseline BM25 de MIRACL elle-même, avec des analyseurs propres à chaque langue lorsqu’il en existe, atteint 0,333 de nDCG@10 sur le persan et 0,481 sur l’arabe, sur une échelle de 0 à 1.
MIRACL est monolingue. Il est « conçu pour les tâches de recherche monolingues, où les requêtes et les corpus sont dans la même langue » : il ne dit donc rien des questions en persan sur des documents en anglais.
Persan et arabe ne forment pas une comparaison contrôlée. Ils reposent sur des questions et des corpus Wikipédia différents ; la page du projet recense 632 requêtes de développement pour le persan et 2 896 pour l’arabe, si bien que l’écart est un signal, pas une mesure de difficulté.
BGE-M3 a été affiné sur les données d’entraînement de MIRACL, ses lignes du tableau sont donc des scores intra-domaine.
FaMTEB et Hakim
FaMTEB, publié dans Findings of EMNLP 2025, est un benchmark persan construit sur MTEB, avec « 63 jeux de données couvrant sept tâches différentes », dont la recherche d’information.
Hakim est un modèle d’embedding persan décrit dans une prépublication arXiv, mise en ligne en mai 2025 et révisée en octobre 2025. Ses auteurs font état d’une amélioration de 8,5 % par rapport aux approches existantes sur FaMTEB. Deux faits doivent figurer à côté de ce résultat : les auteurs de Hakim sont aussi ceux de FaMTEB, et les données d’entraînement de la deuxième étape de Hakim incluent MIRACL_fa. Ni l’un ni l’autre ne rend le résultat faux ; tous deux sont des raisons de tester sur vos propres données.
Ce qu’il faut tester avant de choisir
Vos propres questions en persan et en anglais, formulations familières comprises.
Des paires interlingues : des questions en persan auxquelles répond un texte en anglais, et l’inverse.
Les variantes orthographiques d’une même question : formes du yeh et du kaf, ZWNJ ou espace, chaque jeu de chiffres.
La licence. La carte de modèle de BGE-M3 indique MIT.
La longueur d’entrée maximale au regard de la taille de vos chunks, comptée en tokens. Les auteurs de BGE-M3 décrivent des documents longs allant jusqu’à 8 192 tokens, et un texte persan peut demander plus de tokens que l’anglais pour le même contenu ; la taxe sur les tokens du persan a trouvé ce surcoût faible sur le tokeniseur de BGE-M3 lui-même, et bien plus élevé sur les tokeniseurs des modèles de génération qu’elle a mesurés.
La dimension des vecteurs. Sur ce site, elle est fixée dans le schéma : la changer implique une migration et une réindexation complète.
L’Explorateur d’embeddings du site n’est volontairement pas neuronal : ses vecteurs de n-grammes de caractères regroupent les mots par orthographe, pas par sens, et c’est précisément l’écart qu’un modèle dense doit combler pour le persan familier.
Évaluer un RAG en persan sur un jeu de référence (golden set) bilingue
Un jeu de référence pour un RAG en persan a besoin de questions dans les deux langues : des questions qui ont une réponse, avec les passages qui devraient être retrouvés ; des questions qui doivent être refusées, faute de preuve dans le corpus ; et des questions dont la meilleure preuve est dans l’autre langue. Mesurez le recall@k sur les questions qui ont une réponse, vérifiez que chaque question sans preuve passe sous le seuil de similarité, et ajoutez un test qui échoue si aucune réponse ne s’appuie sur un passage dans l’autre langue. Il détecte un filtre par langue le jour où quelqu’un en réintroduit un.
Sur ce site, le jeu de référence, dans les dix langues du site et composé surtout de questions en anglais et en persan, s’exécute en CI à travers le vrai code de fusion, de regroupement, de tri et de top-k, mais sur un corpus en mémoire et avec un embedder de substitution de type sac de tokens, pas celui de production. Ce substitut alimente aussi une liste creuse que la production n’exécute pas, et l’ensemble des questions sans preuve est petit. La CI détecte les régressions dans la mécanique de classement, pas le rappel de l’assistant en ligne. Une commande qui n’exécute que la recherche, sans jamais appeler le modèle de langue, vérifie le seuil de refus avec l’embedder déployé ; aucune mesure du rappel n’a été faite avec cet embedder, je ne publie donc aucun chiffre de rappel pour ce retriever.
Ce qui se transpose à l’arabe
Je n’ai ni conçu ni évalué de système de recherche pour des textes en arabe. Le retriever de ce site indexe ses contenus en arabe, mais rien en lui n’a été construit ni testé pour l’arabe. Cette section expose ce que la conception persane implique, sur le papier.
L’essentiel se transpose : la fusion par rangs, l’absence de filtre par langue, le découpage selon la structure, le retrait des diacritiques à l’intérieur des comparaisons (la déclaration du W3C couvre l’écriture arabe en général), un parcours exact pour commencer et un jeu de référence bilingue. Trois points diffèrent :
PostgreSQL prend bien en charge l’arabe. Snowball a un stemmer arabe et PostgreSQL un dictionnaire
arabic_stem; c’est à votre jeu de référence de dire si la racinisation aide.La conversion des lettres persanes ne doit pas s’appliquer. En arabe, le yeh (U+064A) et le kaf (U+0643) sont les lettres correctes : un corpus mixte doit donc normaliser langue par langue.
Les chiffres diffèrent. Les chiffres indo-arabes occupent la plage U+0660 à U+0669, et non la plage U+06F0 qu’utilise le persan.
L’arabe est aussi mieux couvert par les benchmarks, dans MIRACL comme dans MKQA. Dans le tableau MKQA de l’article de BGE-M3, les questions en arabe sur Wikipédia en anglais atteignent un Recall@100 de 71,1 avec BGE-M3 dense, contre 18,9 pour le BM25 en sous-mots de l’article ; le Recall@100 compte une question comme trouvée lorsqu’un passage contenant sa réponse figure parmi les 100 premiers résultats. Le persan ne dispose d’aucun chiffre interlingue de ce genre.
Checklist : recherche hybride pour le RAG en persan
Normalisez les formes des lettres persanes à l’écriture, dans les contenus persans uniquement ; retirez les marques bidi de la copie destinée à la recherche.
Appliquez le même normaliseur à chaque question, et conservez le ZWNJ dans le texte stocké.
Gardez le côté lexical sans racinisation, et ajoutez une similarité par trigrammes contre les fautes de frappe.
Fusionnez les rangs dense et lexical par RRF, k = 60, la liste lexicale à mi-poids, en sachant que ce mi-poids ne peut pas repêcher un passage que la liste dense a manqué.
Appliquez le seuil de refus à une similarité, jamais au score RRF.
Ne filtrez jamais par langue ; ne laissez la langue trancher que les égalités exactes et le choix de la traduction affichée d’un passage.
Découpez selon la structure du document, avec le titre dans chaque chunk.
Commencez par un parcours exact, et vérifiez tout index approximatif par rapport à lui.
Évaluez sur un jeu de référence dans chaque langue que vous servez, avec des questions interlingues et des questions sans preuve.
Testez les modèles d’embedding sur vos propres questions en persan, y compris les questions familières.
Si vous préparez un système de recherche sur des contenus en persan ou multilingues, le Diagnostic de projet transforme cinq questions en une première architecture.
Questions fréquentes
BM25 suffit-il pour la recherche en persan ?
Pas à lui seul. BM25 retrouve les noms, nombres et codes exacts mais manque les paraphrases, et une question familière en persan peut n’avoir que peu de mots en commun avec un document formel. Sur le jeu persan de MIRACL, les résultats denses publiés sont nettement supérieurs à BM25, que BM25 utilise des tokens de sous-mots ou les analyseurs linguistiques de Lucene. Utilisez BM25, ou la couverture en mots entiers, comme moitié lexicale d’une recherche hybride.
Faut-il traduire les requêtes persanes en anglais avant la recherche ?
Pas par défaut. Un modèle d’embedding multilingue projette déjà les deux langues dans un même espace vectoriel, et la traduction ajoute un appel de modèle qui peut abîmer les noms et les termes exacts. Le benchmark interlingue utilisé par les auteurs de BGE-M3 inclut l’arabe mais pas le persan ; testez donc les deux approches sur vos propres paires persan–anglais.
Quel est le meilleur modèle d’embedding pour un RAG en persan ?
Les benchmarks donnent une liste restreinte, pas une réponse : les résultats MIRACL couvrent BGE-M3 et E5 multilingue sur le persan, FaMTEB couvre 63 jeux de données, et les auteurs de Hakim font état de gains sur FaMTEB. Testez cette liste restreinte sur vos propres questions familières et interlingues, après avoir vérifié la licence et la dimension des vecteurs.
Faut-il une base de données vectorielle dédiée pour un RAG en persan ?
Pas pour commencer. pgvector, dans le PostgreSQL dont vous disposez déjà, effectue par défaut une recherche exacte des plus proches voisins, avec un rappel parfait, et garde les filtres, comme l’état de publication, à l’intérieur de la requête SQL. N’ajoutez un index approximatif que lorsque la latence l’exige.
Pourquoi ma recherche en persan manque-t-elle un mot qui figure clairement dans le texte ?
Le plus souvent, parce que les chaînes diffèrent d’une manière invisible à l’œil : yeh ou kaf arabe (U+064A, U+0643) au lieu du ی ou du ک persan, un ZWNJ remplacé par un espace ou omis, un autre jeu de chiffres, ou un diacritique égaré. Normalisez les passages stockés et les questions avec la même fonction, et unifiez chiffres et diacritiques des deux côtés de la comparaison.
Sources
G. V. Cormack, C. L. A. Clarke et S. Büttcher, « Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods », SIGIR 2009.
Chen, Xiao, Zhang, Luo, Lian et Liu, « M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation », Findings of ACL 2024.
BAAI, carte de modèle de BGE-M3.
Zhang et al., « MIRACL: A Multilingual Retrieval Dataset Covering 18 Diverse Languages », TACL 2023 (PDF avec le tableau des baselines), et la page du projet MIRACL.
Zinvandi et al., « FaMTEB: Massive Text Embedding Benchmark in Persian Language », Findings of EMNLP 2025.
Sarmadi et al., « Hakim: Farsi Text Embedding Model », prépublication arXiv, 2025.
README de pgvector, code source des vecteurs creux de pgvector et README de pgvector-python.
Documentation de PostgreSQL : introduction à la recherche plein texte, dictionnaires, prise en charge dans psql, analyseurs, test et débogage de la recherche plein texte et pg_trgm.
Code source de PostgreSQL (REL_18_STABLE) : snowball_create.pl et pg_ts_dict.dat.
W3C, Arabic & Persian Layout Requirements, Group Draft Note, 2 octobre 2025.
The Unicode Standard 18.0, Chapter 23: Special Areas and Format Characters, et l’Unicode Character Database.
Tajalli, Shamsfard et Kalantari, « Developing an Informal-Formal Persian Corpus: Highlighting the Differences between Two Writing Styles », AbjadNLP 2025.
Anthropic, « Introducing Contextual Retrieval », 19 septembre 2024.