跨语言知识库——中英文混合文档的 RAG 实践
跨语言知识库——中英文混合文档的 RAG 实践
我做的这个项目,是为一家外资制造企业搭建技术知识库。
这家公司比较特殊:核心技术文档全是英文的(来自总部),本土化的操作规程是中文的,还有一些双语对照的质量标准文档,三种情况混在一起。
你大概能猜到问题在哪里:一个中国工厂的操作员,用中文问了一个问题,但答案在一份英文技术文档里。
这不是理论问题。我记得有个工程师问系统:"液压缸的密封件安装顺序是什么?"系统返回了一堆中文操作规程,但那些中文文档里根本没有这个具体问题的答案。而总部发过来的 "Hydraulic Cylinder Maintenance Manual" 里,有完整的密封件安装步骤,附带图示。
系统找不到,不是因为文档不在,是因为中文 Query 的向量和英文文档的向量距离太远,被排除在外了。
这就是跨语言 RAG 的核心挑战:语言不一致导致的语义隔离。
问题的本质
我们先把问题说清楚。向量相似度检索的前提假设是:语义相似的文本,在向量空间里距离近。
但这个假设在跨语言场景下部分失效——"液压缸密封件"和"hydraulic cylinder seal"语义完全相同,但如果用的是单语言 Embedding 模型(只训练了中文,或者只训练了英文),这两个表达会落在向量空间的两个完全不同的区域,相似度极低。
这带来了两个层面的问题:
问题 1:检索层面——中文 Query 无法检索英文文档(或反之)
问题 2:生成层面——即使检索到了英文文档,LLM 需要理解英文内容并用中文回答,这对模型能力有要求
解法对比:跨语言 Embedding vs 翻译前置
面对跨语言检索问题,主要有两条路:
方案 A:跨语言多语言 Embedding 模型
使用专门训练过多语言的 Embedding 模型,让中英文文本在同一个向量空间里具备可比性。
代表模型:
BAAI/bge-m3:支持 100+ 语言,中英文效果都很好multilingual-e5-large:微软出品,跨语言检索能力强text-embedding-3-large(OpenAI):支持多语言,但中文效果相比专门优化的模型略弱
优点:一次 Embedding,支持跨语言检索,运维简单 缺点:通用多语言模型在特定语言上的效果,通常比同语言专用模型差一点
方案 B:翻译前置
在文档入库时,把所有文档都翻译成同一种语言(通常是中文),然后用中文 Embedding 模型统一处理。
优点:使用成熟的单语言 Embedding 模型,效果更可控 缺点:翻译质量影响检索质量;专业术语翻译可能不准确;翻译成本高;原文丢失
在这个制造业项目里,我最终选择的是"双语并行索引 + 多语言 Embedding"的组合方案,下面详细说。
最终方案:双语并行索引
核心思路:
- 使用跨语言 Embedding 模型(bge-m3)统一处理所有文档
- 英文文档索引时,同时生成机器翻译的中文版本,并行写入
- 检索时,对用户的中文 Query 同时在"原始语言索引"和"翻译索引"里检索,合并结果
代码实现
首先是语言检测工具:
@Component
public class LanguageDetector {
/**
* 简单的语言检测
* 实际项目可以用 lingua 或 language-detector 库
*/
public Language detect(String text) {
if (text == null || text.isEmpty()) return Language.UNKNOWN;
// 统计中文字符比例
long chineseChars = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
double chineseRatio = (double) chineseChars / text.length();
if (chineseRatio > 0.3) return Language.CHINESE;
if (chineseRatio < 0.05) return Language.ENGLISH;
return Language.MIXED;
}
public enum Language {
CHINESE, ENGLISH, MIXED, UNKNOWN
}
}翻译服务,封装 DeepL 或者 百度翻译 API:
@Service
@Slf4j
public class TranslationService {
private final RestTemplate restTemplate;
@Value("${translation.api.key}")
private String apiKey;
// 使用 DeepL API(也可以换成百度、有道等)
@Value("${translation.api.url:https://api-free.deepl.com/v2/translate}")
private String apiUrl;
/**
* 英文翻译为中文
* 注意:专业术语的翻译质量需要人工审核
*/
public String translateToChineseSimplified(String englishText) {
if (englishText == null || englishText.trim().isEmpty()) return "";
// 超长文本分段翻译
if (englishText.length() > 4000) {
return translateInChunks(englishText);
}
return callTranslationApi(englishText, "EN", "ZH");
}
private String callTranslationApi(String text, String sourceLang, String targetLang) {
try {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("auth_key", apiKey);
params.add("text", text);
params.add("source_lang", sourceLang);
params.add("target_lang", targetLang);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
Map<String, Object> response = restTemplate.postForObject(
apiUrl, request, Map.class
);
List<Map<String, Object>> translations =
(List<Map<String, Object>>) response.get("translations");
return (String) translations.get(0).get("text");
} catch (Exception e) {
log.error("Translation failed, returning original text", e);
return text; // 翻译失败时返回原文
}
}
private String translateInChunks(String text) {
// 按段落分割
String[] paragraphs = text.split("\n\n");
return Arrays.stream(paragraphs)
.map(p -> callTranslationApi(p, "EN", "ZH"))
.collect(Collectors.joining("\n\n"));
}
}双语文档处理器:
@Service
@Slf4j
public class BilingualDocumentProcessor {
private final LanguageDetector languageDetector;
private final TranslationService translationService;
private final DocumentSplitter splitter;
private final VectorStore vectorStore;
/**
* 处理文档,根据语言决定是否生成翻译版本并行索引
*/
public DocumentIndexingResult processDocument(
String documentKey,
String documentContent,
Map<String, Object> baseMetadata) {
LanguageDetector.Language language = languageDetector.detect(documentContent);
log.info("Document {} detected as: {}", documentKey, language);
List<String> allChunkIds = new ArrayList<>();
// 原始语言内容直接入库
List<Document> originalChunks = splitAndAnnotate(
documentContent, documentKey, baseMetadata, language.name(), false
);
vectorStore.add(originalChunks);
originalChunks.forEach(doc -> allChunkIds.add(doc.getId()));
// 如果是英文,额外生成中文翻译版本
if (language == LanguageDetector.Language.ENGLISH) {
log.info("Generating Chinese translation for document: {}", documentKey);
String translatedContent = translationService.translateToChineseSimplified(documentContent);
if (translatedContent != null && !translatedContent.equals(documentContent)) {
Map<String, Object> translatedMetadata = new HashMap<>(baseMetadata);
translatedMetadata.put("is_translation", true);
translatedMetadata.put("original_language", "ENGLISH");
translatedMetadata.put("original_doc_key", documentKey);
List<Document> translatedChunks = splitAndAnnotate(
translatedContent,
documentKey + "_zh",
translatedMetadata,
"CHINESE",
true
);
vectorStore.add(translatedChunks);
translatedChunks.forEach(doc -> allChunkIds.add(doc.getId()));
log.info("Added {} translated chunks for document: {}",
translatedChunks.size(), documentKey);
}
}
return DocumentIndexingResult.builder()
.documentKey(documentKey)
.originalLanguage(language)
.totalChunks(allChunkIds.size())
.chunkIds(allChunkIds)
.build();
}
private List<Document> splitAndAnnotate(
String content,
String docKey,
Map<String, Object> metadata,
String language,
boolean isTranslation) {
Document rawDoc = new Document(docKey, content, metadata);
List<Document> chunks = splitter.apply(List.of(rawDoc));
return IntStream.range(0, chunks.size())
.mapToObj(i -> {
Map<String, Object> chunkMeta = new HashMap<>(metadata);
chunkMeta.put("language", language);
chunkMeta.put("is_translation", isTranslation);
chunkMeta.put("chunk_index", i);
return new Document(
docKey + "_chunk_" + i,
chunks.get(i).getContent(),
chunkMeta
);
})
.collect(Collectors.toList());
}
}跨语言 Query 处理策略
用户 Query 的处理是关键。对于中文问题,我们同时在原始和翻译索引里检索,但还需要处理一种情况:用户直接用英文提问。
@Service
@Slf4j
public class CrossLanguageQueryProcessor {
private final LanguageDetector languageDetector;
private final TranslationService translationService;
private final VectorStore vectorStore;
private final CohereRerankService rerankService;
// 单语言检索 Top-K
private static final int SINGLE_LANG_TOP_K = 6;
public List<Document> search(String userQuery) {
LanguageDetector.Language queryLang = languageDetector.detect(userQuery);
log.info("Query language: {}", queryLang);
List<Document> allResults = new ArrayList<>();
// 策略 1:用原始语言查
List<Document> originalLangResults = vectorStore.similaritySearch(
SearchRequest.query(userQuery).withTopK(SINGLE_LANG_TOP_K)
);
allResults.addAll(originalLangResults);
// 策略 2:翻译后再查
// 中文 Query -> 翻译成英文 -> 查英文原文
// 英文 Query -> 翻译成中文 -> 查中文文档
if (queryLang == LanguageDetector.Language.CHINESE) {
// 中文 Query,额外用英文检索英文原始文档
String englishQuery = translationService.translateToEnglish(userQuery);
if (!englishQuery.equals(userQuery)) {
List<Document> englishResults = vectorStore.similaritySearch(
SearchRequest.query(englishQuery)
.withTopK(SINGLE_LANG_TOP_K)
.withFilterExpression("language == 'ENGLISH'") // 只搜英文原文
);
allResults.addAll(englishResults);
log.debug("English translation query '{}' returned {} results",
englishQuery, englishResults.size());
}
} else if (queryLang == LanguageDetector.Language.ENGLISH) {
// 英文 Query,额外翻译成中文搜
String chineseQuery = translationService.translateToChineseSimplified(userQuery);
List<Document> chineseResults = vectorStore.similaritySearch(
SearchRequest.query(chineseQuery)
.withTopK(SINGLE_LANG_TOP_K)
.withFilterExpression("language == 'CHINESE'")
);
allResults.addAll(chineseResults);
}
// 去重:翻译版本和原始版本可能都检索到了
// 优先保留原始文档,去掉重复的翻译版本
List<Document> deduped = deduplicatePreferringOriginal(allResults);
// Rerank
return rerankService.rerank(userQuery, deduped, 5);
}
/**
* 去重时优先保留原文,翻译版本作为 fallback
* 判断逻辑:如果原文文档和翻译文档都在结果里,保留原文
*/
private List<Document> deduplicatePreferringOriginal(List<Document> documents) {
Map<String, Document> seenOriginalKeys = new HashMap<>();
List<Document> result = new ArrayList<>();
// 第一遍:收集所有非翻译文档
for (Document doc : documents) {
boolean isTranslation = Boolean.TRUE.equals(doc.getMetadata().get("is_translation"));
if (!isTranslation) {
String key = (String) doc.getMetadata().getOrDefault("document_key", doc.getId());
if (!seenOriginalKeys.containsKey(key)) {
seenOriginalKeys.put(key, doc);
result.add(doc);
}
}
}
// 第二遍:添加翻译文档(但只添加没有对应原文的)
for (Document doc : documents) {
boolean isTranslation = Boolean.TRUE.equals(doc.getMetadata().get("is_translation"));
if (isTranslation) {
String originalKey = (String) doc.getMetadata().get("original_doc_key");
if (originalKey != null && !seenOriginalKeys.containsKey(originalKey)) {
result.add(doc);
}
}
}
return result;
}
}答案生成:中文问题,英文来源
检索到英文文档之后,LLM 需要用中文回答。这里有个实践要点:Prompt 里要明确指示语言。
@Service
@Slf4j
public class BilingualAnswerGenerator {
private final ChatClient chatClient;
private static final String BILINGUAL_RAG_PROMPT = """
你是一个专业的技术支持助手。请基于以下参考文档,用中文回答用户的问题。
参考文档可能是中文或英文,请综合所有有用信息来回答。
要求:
1. 用中文回答
2. 如果原文是英文,翻译关键信息时保留专业术语(括号内附原英文)
3. 如果文档中没有足够信息,请明确说明
4. 在回答末尾标注参考来源(文件名)
用户问题:{question}
参考文档:
{context}
中文回答:
""";
public String generate(String question, List<Document> documents) {
// 构建上下文,标注每个文档的语言
String context = IntStream.range(0, documents.size())
.mapToObj(i -> {
Document doc = documents.get(i);
String lang = (String) doc.getMetadata().getOrDefault("language", "UNKNOWN");
String source = (String) doc.getMetadata().getOrDefault("source_file", "未知来源");
return String.format("[文档%d | 语言:%s | 来源:%s]\n%s",
i + 1, lang, source, doc.getContent());
})
.collect(Collectors.joining("\n\n---\n\n"));
return chatClient.prompt()
.user(u -> u.text(BILINGUAL_RAG_PROMPT)
.param("question", question)
.param("context", context))
.call()
.content();
}
}Embedding 模型选型的实测对比
我们测试了几个主流的多语言 Embedding 模型在中英文混合检索场景下的效果:
| 模型 | 中文检索精度 | 英文检索精度 | 跨语言检索精度 | 推理速度 | 部署成本 |
|---|---|---|---|---|---|
| bge-m3 | 0.84 | 0.81 | 0.79 | 中 | 低(可本地) |
| multilingual-e5-large | 0.80 | 0.83 | 0.77 | 慢 | 低(可本地) |
| text-embedding-3-large | 0.82 | 0.86 | 0.82 | 快 | 高(API) |
| bge-large-zh(仅中文) | 0.91 | 不支持 | 0.31 | 快 | 低 |
结论是:如果你只处理中文,专用模型效果最好(bge-large-zh);如果需要跨语言,bge-m3 是最佳的本地部署选择;如果预算允许,OpenAI text-embedding-3-large 各方面都不错。
我们最终生产用的是 bge-m3,可以本地部署,速度可以接受,效果够用。
翻译质量带来的问题
翻译前置方案有一个很实际的问题:专业术语翻译质量。
比如"hydraulic cylinder"翻译成"液压缸"没问题,但"seal kit installation sequence"有时候会被翻译成"密封件安装顺序",有时候翻译成"密封组件安装步骤"——用的不同词,但意思相同。用户用"密封件安装顺序"查询时,如果文档里用的是"密封组件安装步骤",还是会有 Gap。
这个问题的解法:
- 维护行业专业术语对照表,翻译时先做术语替换
- 双路检索(同时检索翻译版本和原文),减少单一翻译质量的影响
- 定期人工审查翻译质量,发现问题修正
总结
跨语言 RAG 没有银弹。我们的方案是"多语言 Embedding + 双语并行索引 + 翻译辅助检索"的组合,在制造业中英混合场景里效果还不错。
核心原则:
- 用跨语言 Embedding 模型统一向量空间
- 英文文档同时维护翻译版本,增加检索覆盖面
- Query 层做双语扩展,不只搜用户输入的语言
- 答案生成时用 Prompt 控制输出语言
如果你做的是高度专业化的领域,专业术语的处理质量会严重影响最终效果,这块要单独投入精力。
