多语 RAG 中,语义相关并不等于适合直接生成答案:中文问题可能召回大量英文文档,并进一步导致答案语言漂移。本文用 Node.js 实现一个“相关性 + 查询语言一致性”重排器,同时保留跨语言召回能力,并通过离线指标判断它是否真的改善了检索结果。

相关性足够高,为什么答案仍然不好

在支持多语言的向量模型中,“如何重置密码”和“How to reset a password”通常距离很近。这正是跨语言检索需要的能力,但它也带来一个容易忽略的问题:向量相关性只回答“内容是否相近”,不回答“这份文档是否适合当前语言的用户”。

常见现象包括:

  • 用户用中文提问,Top 5 中四篇是英文文档;
  • 中文版本和英文版本内容相同,但英文版本因分块或措辞获得更高分;
  • 上下文主要是英文,模型最终也切换成英文回答;
  • 产品要求优先展示本地化内容,纯向量排序却无法表达这个约束。

这里不应简单过滤所有外语文档。外语文档可能是唯一包含答案的来源,硬过滤会直接降低召回率。更稳妥的做法是扩大候选召回,然后把语言一致性作为一个软信号参与重排。

策略优点风险
只按相关性排序简单,跨语言召回能力完整同语言文档可能被外语结果淹没
按语言硬过滤输出语言稳定可能丢失唯一相关的外语资料
相关性与语言分数融合可在召回与体验之间调节需要配置权重并进行离线评测

流水线:先多召回,再做软重排

一个实用流程可以拆成四步:

  1. 使用现有向量检索、BM25 或混合检索召回候选;
  2. 将最终需要的 Top K 扩大到候选 Top N,例如先召回 30 条再选 5 条;
  3. 识别查询和候选文档的语言;
  4. 归一化原始相关性分数,与语言一致性分数加权融合。

融合公式可以保持简单:

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,以及用于识别语言的 titletext 或明确的 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、语言一致性及最终答案语言共同评估;
  • 检索重排与生成提示应配合使用,前者控制上下文,后者约束最终回答语言。