波斯语 RAG 的混合检索:实践指南

波斯语 RAG 混合检索:英文方案为何失效、PostgreSQL 缺什么、如何用 RRF 融合稠密与词法检索的排名,以及如何测试。

波斯语(Farsi)RAG 的混合检索,是指并行运行两个检索器,一个做稠密向量检索(dense embedding search),一个做词法检索(lexical search),再用倒数排名融合(reciprocal rank fusion,RRF)合并二者的排名。两者缺一不可,因为波斯语会打破英文方案里那些默认成立的假设:同一个词可能用阿拉伯字母存储,也可能用波斯字母存储;词的内部夹着零宽不连字;而 Snowball 和 PostgreSQL 都没有提供波斯语词干提取器。简而言之:写入时做规范化,词法一侧不做词干提取,融合排名而不是分数,永远不要按语言过滤,沿着文档自身的结构分块,并且在信任其中任何一项之前,先用一套双语问题集测试。

本指南来自两套系统。一套是本站的助手,由我构建并维护。另一套是 Kartez Contract,我曾在那里担任 AI 工程师:它的 AI 副驾(copilot)要在按条、款、附注编号的波斯语合同中找到正确的条款。这里描述的检索设计(稠密加词法、排名融合、pgvector)是本站的;关于 Kartez,我只采用它的案例研究中已经写明的内容。本文不对其中任何一套系统做基准测试。

波斯语混合检索:写入时对文本块做规范化并生成嵌入向量;稠密与词法两份列表用 RRF 融合,语言只用来打破完全相同的平局;调用语言模型之前先检查相似度下限:低于下限时,关于我的问题改为拿到本站的记录,其他问题一律拒答。

为什么英文 RAG 方案在波斯语文本上会失效

典型的失败不是答错,而是答不出来:用户明明能在文档里看到某个词,搜索却找不到它,因为两个看起来一模一样的字符串,底层并不相同。

同一个词,两套字母:阿拉伯语与波斯语的 Yeh 和 Kaf

波斯语和阿拉伯语共用一套文字,但并不共用每一个码位。波斯语的 ی(U+06CC ARABIC LETTER FARSI YEH)和阿拉伯语的 Yeh(U+064A ARABIC LETTER YEH)是两个不同的字符,波斯语的 ک(U+06A9 ARABIC LETTER KEHEH)和阿拉伯语的 Kaf(U+0643 ARABIC LETTER KAF)也是如此。W3C 的 Arabic and Persian Layout Requirements(《阿拉伯文与波斯文排版需求》)直接点名了 Yeh 这一对:这个字母“用两个不同的字符表示”。用一种字形存储的片段,与用另一种字形输入的问题匹配不上;上图的规范化步骤画出的正是这两组字母。

片段进入检索索引之前,本站会把阿拉伯语的 Yeh、Alef Maksura 和 Kaf 等字符折叠为对应的波斯语字形,从这份副本中去掉不可见的双向文本(bidi)标记,并对每个问题执行同样的折叠。

零宽不连字是词的一部分

“می‌شود”“میشود”和“می شود”是三个不同的字符串。第一个用零宽不连字(zero-width non-joiner,ZWNJ,U+200C)把前缀和动词隔开:它阻止字母相连,却不增加空格。Unicode 标准把这种非默认的连接方式描述为“用阿拉伯文字书写波斯语时有时需要的”,W3C 的排版需求则指出,控制字符“在存储和显示文本时应当保留”。

如果像英文处理管线可能做的那样删掉 ZWNJ,“می‌شود”就变成了“میشود”;如果把它换成空格,一个词就变成了两个 token。波斯语 NLP 工具包 hazm 的规范化器包含“ZWNJ 校正”(ZWNJ correction),会把“کتاب های”改成“کتاب‌های”。在本站,存储的文本保留 ZWNJ,只有比较时使用的折叠才把它当作空格。

三套数字

波斯语用东阿拉伯-印度数字(Eastern Arabic-Indic,U+06F0 至 U+06F9)写作 ۱۲۳,阿拉伯语用阿拉伯-印度数字(Arabic-Indic,U+0660 至 U+0669)书写同一个数,而技术文本常常写作 123。

数字只在比较时折叠,绝不改写存储的文本;在本站,改写存储的波斯语数字会改变波斯语简历的显示效果。不过,折叠必须在两侧同时进行:如果只折叠问题中的数字,一个用波斯语数字写的问题,就再也匹配不上它当初复制自的那个片段。关于流式 LLM 护栏的文章对模型输出的数字做了同样的折叠。

变音符号很少用,所以一个多余的符号就会破坏精确匹配

W3C 的排版需求说得很直白:“在阿拉伯文字的文本中,用变音符号表示元音信息和辅音延长并不常见。”因此,偶尔有一个片段带着 kasra(短元音符号),或者某个问题粘贴时带上了 tashdid(叠音符号),它与不带符号的拼写做任何精确匹配都会失败。在我参与的为 MCI 开展的波斯语语音合成研究中,同一特性从另一面构成了难点:短元音和 ezāfe(把名词与其修饰语连接起来的那个成分)都不写出来。对检索而言,应在比较时去掉变音符号,在存储的文本中保留它们。

用户用口语提问,文档用书面语写成

Tajalli、Shamsfard 和 Kalantari 在介绍一个包含 50,000 个句对的平行语料库时写道:“波斯语是正式与非正式书写风格之间差异很大的语言之一。”用户输入的是“می‌تونم”,而正式文本写的是“می‌توانم”。我对其后果的理解是:词法匹配器在口语问题和书面条款之间看不到多少重合,所以有机会找到答案的是稠密检索器;而一套全是规范问题的测试集会掩盖这个差距。

对波斯语安全的规范化规则

  • 写入时把阿拉伯语的 Yeh 和 Kaf 折叠为波斯语的 ی 和 ک,且只对波斯语内容这样做。

  • 在存储的文本中保留 ZWNJ。

  • 从用于索引和比较的副本中去掉 bidi 标记,而不是从显示的文本中去掉。

  • 折叠数字、去掉变音符号,都只在比较时进行,并且两侧都要做。

  • 对问题执行的函数,必须与对片段执行的完全相同。

  • 绝不要对阿拉伯语文本做波斯语折叠:在阿拉伯语中,阿拉伯语的 Yeh(U+064A)和 Kaf(U+0643)才是正确的字母。

本站自己的检索器还没有满足这份清单:它对所有语言的内容都做波斯语字母折叠,阿拉伯语也不例外;它的词法列表可能找不到用波斯语数字书写的片段;阿拉伯文字的变音符号会从问题的词项中、以及句子闸门所做的每一次比较中去掉,但不会从关键词检索所扫描的存储文本中去掉。请把这份清单当作目标。

PostgreSQL 中的波斯语全文检索:没有内置配置

全文检索是混合检索中词法那一半的当然之选,PostgreSQL 文档也说它“为许多语言提供了预定义配置”。波斯语不在其中。

Snowball 有阿拉伯语词干提取器,没有波斯语的

Snowball 的词干提取算法列表收录了阿拉伯语,却没有 Persian 或 Farsi 条目。PostgreSQL 的词干提取词典由 Snowball 构建:PostgreSQL 18 源码中的语言列表有 arabic,没有 persian;文档中的词典列表显示了 arabic_stem 和 simple,没有任何波斯语词典。阿拉伯语词干提取器与波斯语共享的是文字,而不是语法。数据库之外,hazm 提供了波斯语的词干提取器和词形还原器;数据库之内,一个也没有。

simple 配置能提供什么,不能提供什么

用 PostgreSQL 词典文档的话说,simple 词典模板的工作方式是“把输入的 token 转为小写,并与一个停用词文件进行比对”。内置的 simple 词典没有停用词文件,因此每个 token 都会保留,而且不做词干提取:“قرارداد”和“قراردادها”仍是两个词位(lexeme),召回率有所损失,可预测性有所提高。

在任何词典运行之前,解析器先决定什么算一个词,而它对“字母”的判定“取决于数据库的 locale 设置,具体来说是 lc_ctype”。所以,不要想当然地认为你的数据库会怎样切分波斯语,尤其是用 ZWNJ 连接的词。用文本检索的测试与调试文档里的 ts_debug 函数问它:

-- How does this database tokenise Persian? Check; do not assume.
SELECT alias, token, lexemes
FROM ts_debug('simple', 'قراردادها را می‌توان فسخ کرد');

本站的关键词搜索对波斯语记录使用 simple,对英文记录使用 english。“语义检索 vs 关键词检索”演示在本站内容上把它和助手的混合检索器并排运行。

用三元组相似度应对拼写错误

pg_trgm 扩展比较由三个字符组成的序列(三元组,trigram),所以拼错的词仍与正确的词共享一些三元组。它“在提取三元组时忽略非单词字符(非字母数字字符)”,因此依赖它之前先检查:SELECT show_trgm('قرارداد'); 应当返回由波斯字母组成的三元组。本站的关键词搜索额外加了一路 word_similarity,它把短查询与长片段中最匹配的那段连续区间做比较。

整词覆盖率:本站检索器中的词法列表

助手的检索器没有用全文检索来生成词法列表,因为没有可用的波斯语配置。它借助正则表达式的词边界,统计问题中有多少个实义词以完整单词的形式出现在片段中。它会去掉本站每种语言的停用词,丢弃很短的词项,让印地语的元音符号留在它所属的词里,也不做词干提取。结果是一个介于 0 和 1 之间的覆盖率。

词边界很重要:对“is”做子串匹配,会命中“his”“this”和“Isfahan”,于是每个片段看起来都像证据。覆盖率有确定的取值范围同样重要,因为相似度下限可以直接拿来与它比较。只有当至少两个不同的实义词匹配时,这个覆盖率才计入拒答下限。

学习型稀疏向量:有文档记载的选项

学习型稀疏检索(learned sparse retrieval)像词法检索一样匹配词项,只是权重由模型学习得到。在 BGE-M3 里,词项就是分词器切出的子词单元(它的模型卡给“De”“fin”和“ation”分别赋了权重),而用提出它的论文的话说,这个向量的“大小等于词表大小”,在该模型的配置中是 250,002。本站生产环境的检索器不使用它:它运行的是稠密加词法检索,而它调用的嵌入 API 不返回稀疏权重。如果你要把它加进来,请先读下文关于差一错误的那一节。

混合检索与倒数排名融合:融合排名,而不是分数

稠密检索器和词法检索器返回的都是排好序的列表,但两者的分数含义不同。倒数排名融合不看分数,只合并位置。

RRF 公式,以及 k = 60 从何而来

Cormack、Clarke 和 Büttcher 把一个文档的融合分数定义为它在各个排名中 1/(k + r(d)) 的总和,其中 r(d) 是它在每个列表中的名次。这个常数没有看上去那么敏感:“k = 60 是在一次试点研究中确定的,在随后的验证中没有改动”,而试点研究“表明 k = 60 接近最优,但这个取值并不关键”。

本站的检索器使用 k = 60,并且每个列表提供的候选都多于回答最终会用到的数量,所以一个在某个列表中排在中间、在另一个列表中接近顶部的片段,仍然有机会浮上来。大致如下:

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]

为什么分数相加需要你去调权重

另一种做法是把分数相加。BGE-M3 背后的论文就是这样做的:s_rank ← w1·s_dense + w2·s_lex + w3·s_mul,“其中 w1、w2 和 w3 的取值取决于下游场景”。在 MIRACL 上组合稠密与稀疏时,作者设定 w1 = 1、w2 = 0.3;在长文档检索基准 MLDR 上,则是 w1 = 0.2、w2 = 0.8。

余弦相似度、稀疏内积和覆盖率处在不同的尺度上,它们之间的“汇率”还会随语料库变化。排名融合绕开了这种校准。但这是一种取舍,而不是白捡的好处:论文用加权求和取得了很好的结果,而 RRF 分不清压倒性领先和险胜。

为什么词法列表只占一半权重

在本站,词法列表的作用是把带有精确字符串的片段提上来,而这些字符串恰恰是稠密模型容易模糊掉的:像 booqai.app 这样的域名、一个缩写、像 SAMPAD 这样的项目名称。保留一份词法列表,通常就是为了这个。Anthropic 关于 Contextual Retrieval 的文章里,嵌入模型“可能找到泛泛讨论错误码的内容,却漏掉与‘TS-999’的精确匹配”。

词法列表以一半权重参与融合,这会把稠密列表同样返回的片段往上抬,又不至于让关键词重合压过稠密排名。代价是实实在在的:在 k = 60 和本站的候选数量下,只有词法列表找到的片段会排在每一个稠密候选之后,所以融合救不回稠密模型完全错过的片段。唯一的例外是相似度最高的那个片段:它总会被返回,下一节会解释原因。如果嵌入调用失败,检索会退回到只用词法列表,相似度下限依然生效。

RRF 分数不是置信度

RRF 分数只由位置构成,所以即使语料库全是噪声,也照样会有一个排第一的片段,带着现有最好的融合分数。因此,本站的拒答下限比较的是每个片段真实的相似度,而绝不是融合分数:取片段与按原样提出的问题之间的余弦相似度(对于追问,还包括与“上一个问题加本问题”之间的余弦相似度;如果追问的措辞是清单中的某个位置,还会在两者之间加上它所指的那一项,读不出那一项时则加上上一个回答的开头),和它的整词覆盖率中较高的那个,而整词覆盖率只有在至少两个实义词匹配时才计入。这项检查在调用语言模型之前进行。

在所有候选中相似度最高的片段,总会出现在返回的片段里,即使融合把它排得太靠后、本来进不了入选范围。低于下限时,与我无关的问题会被拒答,不调用模型;关于我的问题则改为拿到本站的记录。关于 RAG 幻觉护栏的文章对这两点都有介绍。

永远不要按语言过滤检索结果

按语言(locale)过滤看起来很整洁:波斯语问题只搜索波斯语片段。但在双语语料库上,它会删掉证据。

最好的证据可能在另一种语言里

在本站,所有语言的片段并排存放在同一张表里,一个问题会搜索全部片段。波斯语简历是原始文档,英文简历是在它基础上改写的版本,所以一个关于某段任职经历的英文问题,最好的证据可能在波斯语里。Kartez Contract 的情况结构相同,方向相反:它的起草向导要把用波斯语提出的需求,带入一个由英文合同起草文本组成的库中。

语言只用来打破完全相同的平局

在排序中,语言仍然有一个用处:当两个片段的融合分数完全相同时,有问题所用语言版本的那个排在前面,再由一个稳定的 id 决定先后,正如上面的排序键所示。只有一个稠密列表、词法列表又只占一半权重时,完全相同的分数几乎不会出现。但如果一个问题以多个版本检索(见下文),而两个片段在这些版本权重相同的稠密列表中拿到了相同的名次,就可能出现平局;即便如此,也只有当两者中只有一个有问题所用语言的版本时,语言才起决定作用。这是有意为之:语言的优先级绝不能高于分数。语言还有另一项职责:选择读者看到片段的哪一个译本。

给同语言片段加一点小奖励,这不是平局决胜。同一个全权重列表中相邻名次的贡献之差是 1/((k + r)(k + r + 1)),越往列表下方这个差距越小,而来自两个列表的分数之和可能靠得更近。只有当固定奖励小于你的列表深度和权重可能产生的每一个差距时,它才是安全的,而这个界限会随着深度或权重的任何改动而移动。一旦奖励超过这个界限,原本并不相同的结果就会在列表中段换位,而换位的恰恰是融合本来要提上来的那些候选。

一个片段,十种译本

本站的页面最多以十种语言发布,同一个片段的不同语言副本过去可能占据交给模型的八个位置中的好几个。现在,检索把一个片段的所有译本当作一个候选:按其中最好的译本打分,只排一次名;如果有读者所用语言的译本,就以该语言显示。文章按章节而不是按文本块分组,因为同一个较长的章节在每种语言里会切成数量不同的文本块;章节与英文版对不上的译本会单独存放,而不会被错误地归组。读者的语言决定显示哪个译本;除了完全相同的平局之外,它从不决定片段排在第几位。

用波斯语问题检索英文文档

只有稠密一侧能跨越语言;除了 RAG 这类拉丁字母术语,波斯语问题和英文片段之间的词法重合几乎为零。BGE-M3 自己的评测也没有为波斯语给出定论:它的跨语言基准 MKQA 用 25 种非英语语言的查询检索英文维基百科片段,其中有阿拉伯语,没有波斯语。请用你自己的波斯语–英语问答对来测试。

没有关键词的问题

访客问的“他最近在做什么?”里没有任何语料库能匹配的词,而“那之前呢?”则什么内容都没有。问题与我有关时,还会带上我的名字再检索一次;追问还会与上一个问题拼接起来再检索一次;如果追问的措辞是清单中的某个位置,比如“第二个”,两者之间还会加上它所指的那一项,读不出那一项时则加上上一个回答的开头。所有版本在一次调用中完成嵌入,并按排名融合。带我名字的版本只用来给片段排序,从不计入拒答下限,因为单凭我的名字,就可能让一个与我的工作毫无关系的、关于我的问题看起来与我的简历很接近。关于我整体工作的问题并不依赖检索:它们根据本站从我的简历、案例研究、文章和服务中生成的带日期记录来回答,关于 RAG 幻觉护栏的文章对此有介绍。

按条、款、附注切分波斯语合同

波斯语合同有自己的结构:条(ماده)、款(بند)和附注(تبصره)。在 Kartez Contract 的 AI 副驾中,用户自己的合同按这一结构切分,回答某个问题时只使用与之相关的部分;回答“只依据选定的条款”。Kartez Contract 案例研究介绍了这个项目的其余部分,《法律 AI 幻觉:该构建什么》则介绍了这个领域。

本站的分块器也由同一个原则驱动:一个文本块就是一条语义记录。简历中的一段任职经历绝不会与它的日期分开,案例研究按作者撰写的章节切分,文章按标题切分,较长的章节只在段落边界处断开。每个章节文本块都带着文档标题,所以单独被检索出来的文本块仍能说明自己出自哪里。

对波斯语合同来说,这引出了几条规则。它们是通用建议,而不是对 Kartez 构建方式的描述:

  • 编号和正文放在一起。“ماده ۵”和它的正文应当在同一个文本块里。

  • 把所属条的标题带入每一个款和附注的文本块。

  • **绝不按 token 数切断一个款。**附注一旦与它所属的款分开,就可能改变这个款表面上的意思。

  • 存储结构路径(条 › 款 › 附注),这样回答就能把专业人员指向确切的位置。

pgvector:精确扫描还是 HNSW?

pgvector 自己就回答了一半:“默认情况下,pgvector 执行精确最近邻搜索,召回率是完美的。”近似索引则“用一部分召回率换取速度”。

过滤是不那么显眼的代价。“使用近似索引时,过滤是在扫描索引之后才进行的。如果某个条件匹配 10% 的行,在使用 HNSW 且 hnsw.ef_search 为默认值 40 的情况下,平均只有 4 行会匹配。”pgvector 0.8.0 及以后的版本可以迭代扫描索引,直到匹配到足够多的行,前提是你启用了这项功能。

本站的检索表没有近似索引:向量搜索是精确扫描。发布状态是 SQL WHERE 子句的一部分,而绝不是在 LIMIT 之后才应用的过滤;若在那之后过滤,结果集会悄无声息地缩水。先从精确扫描开始,等延迟需要时再加索引,并在信任它之前,用你自己的问题把它的 top-k 结果与精确扫描的结果做比较。

pgvector sparsevec 的差一错误

只有当你要把学习型稀疏向量加进 PostgreSQL 时,这一节才有意义;而它出错时悄无声息。

pgvector 中稀疏向量的文本格式是 {index1:value1,index2:value2}/dimensions,“索引和 SQL 数组一样从 1 开始”。分词器的 id 从 0 开始。Python 客户端接受从 0 开始的索引(“Indices start at 0”),并在写出文本形式时自行加 1。

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"

只偏移一次,就在手工拼接 SQL 字面量的地方。pgvector 的文本输入会把每个索引转换为从 0 开始,并且只有当结果小于 0、或者大于等于维度时,才会抛出“sparsevec index out of bounds”。于是,偏移两次只会在词表顶端报错,漏掉偏移只会在 token id 为 0 时报错,其余每个权重都悄悄落到了相邻的 token 上。

如何为波斯语(Farsi)选择嵌入模型

MIRACL 对波斯语和阿拉伯语说明了什么

MIRACL 是一个覆盖 18 种语言的检索基准,其中包括波斯语和阿拉伯语。BGE-M3 背后的论文报告了它在开发集上的 nDCG@10(衡量前十个结果排序质量的指标;下表取自 ACL 版本,作者在 2024 年 7 月更新过这批结果):

检索器

波斯语(fa)

阿拉伯语(ar)

基于 XLM-RoBERTa token 的 BM25

28.7

39.5

mE5-large(多语言 E5)

59.0

76.0

BGE-M3 稀疏

45.1

67.1

BGE-M3 稠密

60.9

78.4

BGE-M3 稠密 + 稀疏 + 多向量

63.4

80.2

在波斯语上,稠密检索的得分远高于这里的 BM25,学习型稀疏检索介于两者之间,三种模式组合起来又比单用稠密检索略高一点。这些数字带着四点说明:

  • **这里的 BM25 用的是子词 token。**为了与模型保持一致,作者用 XLM-RoBERTa 的分词器运行 BM25;他们的附录显示,换成 Lucene 的分析器后,MIRACL 各语言的平均分从 31.9 升到 38.5。MIRACL 自己的 BM25 基线在有特定语言分析器的地方就用它,在波斯语上达到 0.333 的 nDCG@10,在阿拉伯语上是 0.481,尺度为 0 到 1。

  • **MIRACL 是单语基准。**它“旨在支持单语检索任务,即查询和语料使用同一种语言”,所以它无法说明用波斯语问题检索英文文档的效果。

  • **波斯语和阿拉伯语之间不是受控比较。**两者使用不同的问题和不同的维基百科语料;项目页面列出的开发集查询数,波斯语为 632 条,阿拉伯语为 2,896 条。这个差距是一个信号,而不是衡量哪种语言更难的尺度。

  • **BGE-M3 在 MIRACL 的训练数据上微调过,**所以它那几行是域内分数。

FaMTEB 与 Hakim

FaMTEB 发表于 Findings of EMNLP 2025,是一个基于 MTEB 构建的波斯语基准,包含“横跨七类不同任务的 63 个数据集”,检索是其中之一。

Hakim 是一个波斯语嵌入模型,见于一篇 arXiv 预印本,该预印本于 2025 年 5 月首次发布,2025 年 10 月修订。作者报告,它在 FaMTEB 上比现有方法有 8.5% 的提升。这个结果旁边应当放上两个事实:Hakim 的作者同时也是 FaMTEB 的作者;Hakim 第二阶段的训练数据中列有 MIRACL_fa。这两点都不说明结果有错,但都是你应该用自己的数据来测试的理由。

选型之前要测试什么

  • 你自己的波斯语和英文问题,包括口语化的说法。

  • 跨语言问答对:由英文文本回答的波斯语问题,以及反过来的情形。

  • 同一个问题的各种拼写变体:Yeh 和 Kaf 的不同字形、ZWNJ 或空格、每一套数字。

  • 许可证。BGE-M3 的模型卡标明的是 MIT。

  • 最大输入长度与文本块大小的对比,按 token 计算。BGE-M3 的作者描述的长文档最多可达 8,192 个 token;而且表达同样的内容,波斯语可能比英文需要更多 token,波斯语的 token 税一文发现,这笔溢价在 BGE-M3 自己的分词器上很小,在它测量过的那些生成模型的分词器上则大得多。

  • 向量维度。在本站,它固定在数据库 schema 里,所以改动它意味着一次迁移加上全量重建索引。

本站的嵌入向量浏览器刻意没有使用神经网络:它的字符 n-gram 向量按拼写而不是含义给词分组,而这正是稠密模型在处理口语化波斯语时必须弥合的差距。

用双语黄金测试集评估波斯语 RAG

波斯语 RAG 的黄金测试集需要两种语言的问题:可回答的问题,连同它们应当检索到的片段;语料库中没有证据、必须拒答的问题;以及最好的证据在另一种语言中的问题。在可回答的问题上测量 recall@k,检查每个无证据问题都落在相似度下限以下,再加一项测试:如果没有任何回答用到另一种语言的片段,测试就失败。哪天有人把语言过滤加回来,当天就会被它发现。

在本站,黄金测试集覆盖本站全部十种语言,其中大部分问题是英语和波斯语;它在 CI 中跑的是真实的融合、分组、排序和 top-k 代码,但用的是内存中的语料库和一个词袋式(bag-of-tokens)替身嵌入器,而不是生产环境的那个。这个替身还会额外提供一份生产环境并不运行的稀疏列表,无证据问题集也很小。CI 捕捉的是排序机制里的回归,而不是线上助手的召回率。有一条只运行检索、从不调用语言模型的命令,会用已部署的嵌入器检查拒答下限;针对这个嵌入器的召回率测试还没有跑过,所以我不为这个检索器发布任何召回率数字。

哪些做法适用于阿拉伯语

我没有为阿拉伯语文本设计或评估过检索。本站的检索器会索引站内的阿拉伯语内容,但其中没有任何部分是为阿拉伯语构建或测试的。本节只是波斯语设计在纸面上推导出的结论。

大部分做法都适用:排名融合、不按语言过滤、按结构分块、在比较时去掉变音符号(W3C 的那段表述适用于整个阿拉伯文字)、先用精确扫描,以及双语黄金测试集。有三点不同:

  • PostgreSQL 确实带了阿拉伯语。 Snowball 有阿拉伯语词干提取器,PostgreSQL 有 arabic_stem 词典;词干提取有没有帮助,要由你的黄金测试集来判断。

  • **不能运行波斯语字母折叠。**在阿拉伯语中,Yeh(U+064A)和 Kaf(U+0643)才是正确的字母,所以混合语料库必须按语言分别规范化。

  • **数字不同。**阿拉伯-印度数字是 U+0660 至 U+0669,而不是波斯语使用的 U+06F0 区段。

阿拉伯语的基准覆盖也更多,MIRACL 和 MKQA 都包含它。在 BGE-M3 论文的 MKQA 表格中,用阿拉伯语问题检索英文维基百科,BGE-M3 稠密检索的 Recall@100 达到 71.1,而论文中基于子词的 BM25 为 18.9;Recall@100 的含义是:只要包含答案的片段出现在前 100 个结果中,这个问题就算找到了。波斯语没有这样的跨语言数字。

检查清单:波斯语 RAG 的混合检索

  • 写入时折叠波斯语字形,且只对波斯语内容这样做;从检索副本中去掉 bidi 标记。

  • 对每个问题运行同一个规范化器,并在存储的文本中保留 ZWNJ。

  • 词法一侧不做词干提取,并加上三元组相似度来应对拼写错误。

  • 用 RRF 合并稠密与词法的排名,k = 60,词法列表取一半权重,并且记住,一半权重救不回稠密列表错过的片段。

  • 拒答下限要设在相似度上,绝不能设在 RRF 分数上。

  • 永远不要按语言过滤;语言只用来决定完全相同的平局,以及显示片段的哪一个译本。

  • 沿文档结构分块,每个文本块都带上标题。

  • 从精确扫描开始,任何近似索引都要拿它来对照检查。

  • 用覆盖你所支持的每一种语言、并包含跨语言问题和无证据问题的黄金测试集做评估。

  • 用你自己的波斯语问题测试嵌入模型,包括口语化的问题。

如果你正在规划针对波斯语或多语言混合内容的检索,项目诊断会用五个问题给出一份初步架构。

常见问题

波斯语搜索只用 BM25 够吗?

单靠它不够。BM25 能抓住精确的名称、数字和代码,却会漏掉换了说法的表达;而一个口语化的波斯语问题,与书面文档可能没有多少相同的措辞。在 MIRACL 的波斯语数据集上,已发表的稠密检索结果明显高于 BM25,无论 BM25 用的是子词 token 还是 Lucene 的语言分析器。应当把 BM25 或整词覆盖率用作混合检索中词法的那一半。

检索前应该先把波斯语查询翻译成英文吗?

默认不需要。多语言嵌入模型本来就会把两种语言映射到同一个向量空间,而翻译会多一次模型调用,可能把名称和精确术语弄乱。BGE-M3 作者使用的跨语言基准包含阿拉伯语,但不包含波斯语,所以请在你自己的波斯语–英语问答对上测试这两种做法。

哪个嵌入模型最适合波斯语 RAG?

基准给你的是一份候选名单,而不是答案:MIRACL 的结果涵盖了 BGE-M3 和多语言 E5 在波斯语上的表现,FaMTEB 横跨 63 个数据集,Hakim 的作者则报告了在 FaMTEB 上的提升。用你自己的口语化问题和跨语言问题测试候选名单,并先检查许可证和向量维度。

波斯语 RAG 需要单独的向量数据库吗?

起步阶段不需要。在你现有的 PostgreSQL 中,pgvector 默认执行精确最近邻搜索,召回率完美,还能把发布状态之类的过滤条件保留在 SQL 查询内部。只有在延迟确实需要时,才添加近似索引。

为什么波斯语搜索找不到文本里明明有的词?

通常是因为字符串之间有肉眼看不出的差异:阿拉伯语的 Yeh 或 Kaf(U+064A、U+0643)对上波斯语的 ی 或 ک,ZWNJ 被写成空格或被漏掉,数字用的是另一套,或者多了一个变音符号。用同一个函数规范化存储的片段和问题,并在比较的两侧都折叠数字和变音符号。

参考来源