多语 RAG 中,语义相关并不等于适合直接生成答案:中文问题可能召回大量英文文档,并进一步导致答案语言漂移。本文用 Node.js 实现一个“相关性 + 查询语言一致性”重排器,同时保留跨语言召回能力,并通过离线指标判断它是否真的改善了检索结果。
相关性足够高,为什么答案仍然不好
在支持多语言的向量模型中,“如何重置密码”和“How to reset a password”通常距离很近。这正是跨语言检索需要的能力,但它也带来一个容易忽略的问题:向量相关性只回答“内容是否相近”,不回答“这份文档是否适合当前语言的用户”。
常见现象包括:
- 用户用中文提问,Top 5 中四篇是英文文档;
- 中文版本和英文版本内容相同,但英文版本因分块或措辞获得更高分;
- 上下文主要是英文,模型最终也切换成英文回答;
- 产品要求优先展示本地化内容,纯向量排序却无法表达这个约束。
这里不应简单过滤所有外语文档。外语文档可能是唯一包含答案的来源,硬过滤会直接降低召回率。更稳妥的做法是扩大候选召回,然后把语言一致性作为一个软信号参与重排。
| 策略 | 优点 | 风险 |
|---|---|---|
| 只按相关性排序 | 简单,跨语言召回能力完整 | 同语言文档可能被外语结果淹没 |
| 按语言硬过滤 | 输出语言稳定 | 可能丢失唯一相关的外语资料 |
| 相关性与语言分数融合 | 可在召回与体验之间调节 | 需要配置权重并进行离线评测 |
流水线:先多召回,再做软重排
一个实用流程可以拆成四步:
- 使用现有向量检索、BM25 或混合检索召回候选;
- 将最终需要的 Top K 扩大到候选 Top N,例如先召回 30 条再选 5 条;
- 识别查询和候选文档的语言;
- 归一化原始相关性分数,与语言一致性分数加权融合。
融合公式可以保持简单:
finalScore = alpha × relevanceScore + (1 - alpha) × languageScore
其中 alpha 越大,排序越接近原始检索结果。生产环境中还要先确认检索系统返回的是“相似度”还是“距离”:本文假设数值越大越相关。如果上游返回距离,应先转换方向,再交给重排器。
语言识别也不必一开始就引入复杂模型。对较长正文,可以使用专门的语言识别库或内部服务;对查询、标题和短片段,则需要特别关注短文本误判。下面先用 Unicode Script 属性实现一个无依赖版本,便于理解和接入。
用 Node.js 实现可配置重排器
下面的 reranker.mjs 可直接在 Node.js 18 及以上版本运行。它识别中文、英文、日文、韩文和混合文本,对相关性做 Min-Max 归一化,并允许通过语言矩阵覆盖默认的跨语言分数。
import { pathToFileURL } from "node:url";
function count(text, pattern) {
return (text.match(pattern) ?? []).length;
}
export function detectLanguage(input) {
const text = String(input ?? "");
const han = count(text, /\p{Script=Han}/gu);
const kana = count(text, /[\p{Script=Hiragana}\p{Script=Katakana}]/gu);
const hangul = count(text, /\p{Script=Hangul}/gu);
const latin = count(text, /\p{Script=Latin}/gu);
const scores = {
zh: kana > 0 ? 0 : han,
ja: kana > 0 ? han + kana : 0,
ko: hangul,
en: latin
};
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const total = ranked.reduce((sum, [, value]) => sum + value, 0);
if (total === 0) return "unknown";
if (ranked[1][1] / total >= 0.25) return "mixed";
return ranked[0][0];
}
function normalizeScores(items) {
const values = items.map(item => item.score);
const min = Math.min(...values);
const max = Math.max(...values);
return items.map(item => ({
...item,
relevanceScore: max === min ? 0.5 : (item.score - min) / (max - min)
}));
}
function languageAffinity(queryLanguage, documentLanguage, matrix) {
const configured = matrix?.[queryLanguage]?.[documentLanguage];
if (Number.isFinite(configured)) return configured;
if (queryLanguage === documentLanguage) return 1;
if (queryLanguage === "unknown" || documentLanguage === "unknown") return 0.5;
if (queryLanguage === "mixed" || documentLanguage === "mixed") return 0.75;
return 0.2;
}
export function rerank(query, candidates, options = {}) {
const alpha = options.alpha ?? 0.8;
if (alpha < 0 || alpha > 1) {
throw new RangeError("alpha must be between 0 and 1");
}
if (!Array.isArray(candidates) || candidates.length === 0) return [];
if (candidates.some(item => !Number.isFinite(item.score))) {
throw new TypeError("Every candidate must have a numeric score");
}
const queryLanguage = detectLanguage(query);
return normalizeScores(candidates)
.map(item => {
const documentLanguage = item.language ?? detectLanguage(
`${item.title ?? ""} ${item.text ?? ""}`
);
const languageScore = languageAffinity(
queryLanguage,
documentLanguage,
options.languageMatrix
);
return {
...item,
queryLanguage,
documentLanguage,
languageScore,
finalScore: alpha * item.relevanceScore + (1 - alpha) * languageScore
};
})
.sort((a, b) => b.finalScore - a.finalScore);
}
const isMain = process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
const candidates = [
{ id: "en-1", title: "Reset password", text: "Open settings and select Reset Password.", score: 0.92 },
{ id: "zh-1", title: "重置密码", text: "打开设置页面,选择重置密码。", score: 0.89 },
{ id: "zh-2", title: "修改登录信息", text: "管理员可以修改用户的登录信息。", score: 0.76 }
];
console.table(rerank("如何重置密码?", candidates, { alpha: 0.75 }).map(item => ({
id: item.id,
language: item.documentLanguage,
relevance: item.relevanceScore.toFixed(3),
languageScore: item.languageScore.toFixed(3),
finalScore: item.finalScore.toFixed(3)
})));
}
保存后执行 node reranker.mjs 即可查看结果。这个识别器是工程基线,不是通用语言识别模型:只有汉字、没有假名的日文可能被识别为中文,代码片段和产品名也会干扰短文本。若文档入库时已有可靠语言元数据,应优先使用元数据,而不是重复推断。
接入现有 RAG 与配置权重
重排器不关心候选来自哪种数据库,只要求每条记录至少包含数值型 score,以及用于识别语言的 title、text 或明确的 language。典型接入方式如下:
const recalled = await retriever.search(query, { limit: 30 });
const ranked = rerank(query, recalled, {
alpha: 0.8,
languageMatrix: {
zh: { zh: 1, en: 0.35, ja: 0.2 },
en: { en: 1, zh: 0.35 }
}
});
const contexts = ranked.slice(0, 5);
这里的 retriever.search 代表项目已有的检索接口,并非某个特定 SDK。接入时应把数据库返回值映射为重排器需要的字段。
权重不建议凭感觉一次定死。可以先从 alpha = 0.8 附近开始做对照实验,再结合业务调整。知识主要以英文维护时,中文到英文的语言分数不应过低;如果中英文文档严格同步,可以提高同语言奖励。语言矩阵最好表达业务事实,而不是假设所有语言之间的关系都相同。
重排只能改善送入模型的上下文,不能完全保证回答语言。生成阶段仍应明确要求“使用与用户问题一致的语言回答;引用外语资料时进行必要翻译”,并保留文档来源,避免模型把翻译后的内容误当成原文。
离线评测:同时观察召回与语言一致性
只检查几条演示查询很容易得到错误结论。建议建立包含查询语言、候选列表和相关文档 ID 的小型评测集,至少比较以下指标:
| 指标 | 用途 |
|---|---|
| Recall@K | 相关文档是否进入前 K,防止语言奖励损害召回 |
| nDCG@K | 相关文档的排序位置是否改善 |
| Language Consistency@K | 前 K 中与查询语言一致或为混合语言的比例 |
| Answer Language Rate | 最终答案是否使用期望语言,需要运行完整生成链路 |
下面的 eval.mjs 可与前面的文件放在同一目录运行。示例数据只用于验证评测代码,不能据此得出生产权重结论。
import { detectLanguage, rerank } from "./reranker.mjs";
const dataset = [
{
query: "如何重置密码?",
relevantIds: ["zh-reset", "en-reset"],
candidates: [
{ id: "en-reset", text: "Reset a password from account settings.", score: 0.94 },
{ id: "zh-reset", text: "在账户设置中重置密码。", score: 0.90 },
{ id: "zh-login", text: "登录失败后的排查方法。", score: 0.82 }
]
},
{
query: "How do I export logs?",
relevantIds: ["en-logs"],
candidates: [
{ id: "zh-logs", text: "从管理后台导出日志。", score: 0.91 },
{ id: "en-logs", text: "Export logs from the admin console.", score: 0.89 },
{ id: "en-audit", text: "Review audit events.", score: 0.80 }
]
}
];
function evaluate(alpha, k = 2) {
let recall = 0;
let languageConsistency = 0;
for (const sample of dataset) {
const top = rerank(sample.query, sample.candidates, { alpha }).slice(0, k);
const relevant = new Set(sample.relevantIds);
const hits = top.filter(item => relevant.has(item.id)).length;
recall += hits / relevant.size;
const queryLanguage = detectLanguage(sample.query);
const languageHits = top.filter(item =>
item.documentLanguage === queryLanguage || item.documentLanguage === "mixed"
).length;
languageConsistency += languageHits / top.length;
}
return {
alpha,
recallAtK: recall / dataset.length,
languageConsistencyAtK: languageConsistency / dataset.length
};
}
console.table([evaluate(1), evaluate(0.8), evaluate(0.6)]);
正式评测时还应按查询语言、是否存在本地化文档、问题类型分别切片。若总体指标改善,但某个低资源语言的 Recall@K 明显下降,就需要单独调整语言矩阵或召回规模,而不是只看平均值。
总结
- 多语向量相关性无法直接表达用户对答案语言和本地化文档的偏好;
- 不应轻易硬过滤外语文档,更稳妥的是扩大候选集并进行软重排;
- 重排前要统一相关性分数方向,再融合语言一致性分数;
- 文档已有语言元数据时优先使用元数据,轻量识别器适合作为回退方案;
- 权重和语言矩阵必须通过 Recall@K、nDCG、语言一致性及最终答案语言共同评估;
- 检索重排与生成提示应配合使用,前者控制上下文,后者约束最终回答语言。
评论