第1836篇:跨语言检索——多语言Embedding模型在中英文混合场景的表现
第1836篇:跨语言检索——多语言Embedding模型在中英文混合场景的表现
去年做一个知识库项目,客户的文档库里大概是 40% 中文、30% 英文、30% 中英混排,用户的查询也是这三种情况都有。
最开始用的是 text-embedding-ada-002,英文效果不错,但中文的 Recall@10 只有 0.76,更别说用中文查询找英文文档了。后来换成专门的多语言模型,调整了检索策略,最终把多语言场景的 Recall@10 提到了 0.91。
这篇把我整个探索过程梳理一遍,主要讲多语言 Embedding 模型的选择、跨语言对齐的原理,以及中英文混合场景下的工程方案。
一、跨语言检索的本质难题
普通人理解的跨语言检索:"用中文查英文,用英文查中文"——看起来只是个翻译问题。
但实际上难在:两种语言的语义空间本来不共享,模型需要学会把不同语言的相似含义映射到同一个向量空间位置。
用一个直观的例子:
中文:"人工智能的发展现状"
英文:"Current state of artificial intelligence development"这两个句子在语义上高度相关,理想的多语言模型应该把它们映射到向量空间里相近的位置。但如果用单语言模型分别处理,两个向量在各自的语义空间里,没有任何共同参照系。
1.1 跨语言对齐的三种方案
方案1(查询时翻译):增加翻译延迟(100-300ms),翻译错误会直接影响检索;翻译 API 成本高。
方案2(索引时翻译):离线翻译成本可控,但翻译后的语义可能损失,且中英混排文档翻译后语义一致性差。
方案3(多语言 Embedding):无翻译延迟,能直接处理混排文本,但依赖模型质量。
工程上方案3是首选,但不是所有多语言模型效果都一样。
二、主流多语言 Embedding 模型横评
2.1 模型概览
| 模型 | 参数量 | 支持语言 | 向量维度 | 中文质量 | 跨语言对齐 |
|---|---|---|---|---|---|
| multilingual-e5-large | 560M | 100+ | 1024 | ★★★★ | ★★★★ |
| paraphrase-multilingual-mpnet | 278M | 50+ | 768 | ★★★ | ★★★ |
| BGE-M3 | 568M | 100+ | 1024 | ★★★★★ | ★★★★★ |
| LaBSE | 470M | 109 | 768 | ★★★★ | ★★★★★ |
| text-embedding-ada-002 | - | 多语言 | 1536 | ★★★ | ★★★ |
| text-embedding-3-large | - | 多语言 | 3072 | ★★★★ | ★★★★ |
这里重点说 BGE-M3,这是 FlagAI 团队(BGE 系列作者)2024 年发布的,专门针对多语言、多粒度、多功能做了优化,目前在 MTEB 中文和多语言榜单上都是顶级水平。
2.2 BGE-M3 的特点
BGE-M3 同时支持三种检索模式:
- Dense Retrieval(密集检索):单向量,适合语义检索
- Sparse Retrieval(稀疏检索):类似 BM25,输出 token 权重,适合关键词检索
- Multi-Vector Retrieval(多向量检索):ColBERT 风格的 token 级别向量
这三种模式可以组合使用(混合检索),这是它最大的工程价值。
三、BGE-M3 的 Java 集成
3.1 通过 ONNX 本地推理
// Maven 依赖
// <dependency>
// <groupId>com.microsoft.onnxruntime</groupId>
// <artifactId>onnxruntime</artifactId>
// <version>1.16.3</version>
// </dependency>
import ai.onnxruntime.*;
import java.util.*;
import java.nio.FloatBuffer;
import java.nio.LongBuffer;
/**
* BGE-M3 多语言 Embedding 服务
* 使用 ONNX Runtime 本地推理,无需调用外部 API
*/
public class BGEM3EmbeddingService {
private final OrtEnvironment env;
private final OrtSession session;
private final TokenizerService tokenizer; // 需要自行实现或用 HuggingFace tokenizer
// BGE-M3 配置
private static final int MAX_LENGTH = 8192; // 支持超长文档
private static final int DENSE_DIM = 1024;
public BGEM3EmbeddingService(String modelPath, String tokenizerPath) throws OrtException {
this.env = OrtEnvironment.getEnvironment();
var sessionOptions = new OrtSession.SessionOptions();
sessionOptions.setIntraOpNumThreads(4);
// 如果有 GPU,启用 CUDA
// sessionOptions.addCUDA(0);
this.session = env.createSession(modelPath, sessionOptions);
this.tokenizer = new TokenizerService(tokenizerPath);
System.out.println("BGE-M3 模型加载完成");
}
/**
* 单文本 dense embedding
*/
public float[] embedDense(String text) throws OrtException {
return embedBatchDense(List.of(text)).get(0);
}
/**
* 批量 dense embedding
*/
public List<float[]> embedBatchDense(List<String> texts) throws OrtException {
// 分词
var tokenized = tokenizer.tokenize(texts, MAX_LENGTH);
// 构建 ONNX 输入
long batchSize = texts.size();
long seqLen = tokenized.maxSeqLen();
var inputIds = OnnxTensor.createTensor(env,
LongBuffer.wrap(tokenized.inputIds()),
new long[]{batchSize, seqLen});
var attentionMask = OnnxTensor.createTensor(env,
LongBuffer.wrap(tokenized.attentionMask()),
new long[]{batchSize, seqLen});
var inputs = Map.of(
"input_ids", inputIds,
"attention_mask", attentionMask
);
// 推理
try (var outputs = session.run(inputs)) {
// BGE-M3 dense 输出是 [CLS] token 的向量
var output = (OnnxTensor) outputs.get("last_hidden_state").get();
float[][][] hiddenStates = (float[][][]) output.getValue();
List<float[]> embeddings = new ArrayList<>();
for (int i = 0; i < texts.size(); i++) {
// 取 [CLS] token(位置0)的向量
float[] clsVec = hiddenStates[i][0];
embeddings.add(normalize(clsVec));
}
return embeddings;
}
}
/**
* 获取稀疏表示(类 BM25 的 token 权重)
* 用于混合检索
*/
public Map<Integer, Float> embedSparse(String text) throws OrtException {
var tokenized = tokenizer.tokenize(List.of(text), MAX_LENGTH);
var inputIds = OnnxTensor.createTensor(env,
LongBuffer.wrap(tokenized.inputIds()),
new long[]{1, tokenized.maxSeqLen()});
var attentionMask = OnnxTensor.createTensor(env,
LongBuffer.wrap(tokenized.attentionMask()),
new long[]{1, tokenized.maxSeqLen()});
try (var outputs = session.run(Map.of(
"input_ids", inputIds,
"attention_mask", attentionMask))) {
// 稀疏权重输出:每个 token ID 对应一个权重
var sparseOutput = (OnnxTensor) outputs.get("sparse_weights").get();
float[][] weights = (float[][]) sparseOutput.getValue();
Map<Integer, Float> sparseVec = new HashMap<>();
long[] ids = tokenized.inputIdsArray();
float[] mask = tokenized.attentionMaskArray();
for (int j = 0; j < ids.length; j++) {
if (mask[j] > 0 && ids[j] > 2) { // 跳过特殊token
float weight = weights[0][j];
if (weight > 0) {
sparseVec.merge((int) ids[j], weight, Math::max);
}
}
}
return sparseVec;
}
}
private float[] normalize(float[] v) {
float norm = 0;
for (float x : v) norm += x * x;
norm = (float) Math.sqrt(norm);
if (norm < 1e-12f) return v;
float[] r = new float[v.length];
for (int i = 0; i < v.length; i++) r[i] = v[i] / norm;
return r;
}
}3.2 混合检索服务
/**
* 混合检索:Dense + Sparse 融合
* 中英文混合场景下比单纯 Dense 效果提升明显
*/
public class HybridSearchService {
private final BGEM3EmbeddingService embeddingService;
private final MilvusClientV2 milvusClient;
private final String collectionName;
// RRF(Reciprocal Rank Fusion)融合参数
private static final int RRF_K = 60;
/**
* 执行混合检索
*
* @param query 用户查询(支持中英文及混排)
* @param topK 返回数量
*/
public List<HybridSearchResult> search(String query, int topK) throws Exception {
// 同时获取 dense 和 sparse 表示
float[] denseVec = embeddingService.embedDense(query);
Map<Integer, Float> sparseVec = embeddingService.embedSparse(query);
// Dense 检索
List<RankedResult> denseResults = denseSearch(denseVec, topK * 3);
// Sparse 检索(通过倒排索引)
List<RankedResult> sparseResults = sparseSearch(sparseVec, topK * 3);
// RRF 融合
return rrfFusion(denseResults, sparseResults, topK);
}
private List<RankedResult> denseSearch(float[] queryVec, int k) {
var response = milvusClient.search(SearchReq.newBuilder()
.collectionName(collectionName)
.data(List.of(floatArrayToList(queryVec)))
.annsField("dense_embedding")
.topK(k)
.outputFields(List.of("doc_id", "text", "language"))
.searchParams(Map.of("ef", 64))
.build());
List<RankedResult> results = new ArrayList<>();
int rank = 1;
for (var r : response.getSearchResults().get(0)) {
results.add(new RankedResult(
(String) r.getEntity().get("doc_id"),
(String) r.getEntity().get("text"),
r.getScore(), rank++
));
}
return results;
}
/**
* RRF(倒数排名融合)
* 将 dense 和 sparse 的排名列表融合成一个
*/
private List<HybridSearchResult> rrfFusion(
List<RankedResult> denseList,
List<RankedResult> sparseList,
int topK) {
Map<String, Double> fusedScores = new HashMap<>();
// Dense 贡献
for (var r : denseList) {
double rrfScore = 1.0 / (RRF_K + r.rank());
fusedScores.merge(r.docId(), rrfScore, Double::sum);
}
// Sparse 贡献
for (var r : sparseList) {
double rrfScore = 1.0 / (RRF_K + r.rank());
fusedScores.merge(r.docId(), rrfScore, Double::sum);
}
// 构建最终结果
Map<String, RankedResult> docMap = new HashMap<>();
denseList.forEach(r -> docMap.put(r.docId(), r));
sparseList.forEach(r -> docMap.putIfAbsent(r.docId(), r));
return fusedScores.entrySet().stream()
.sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
.limit(topK)
.map(e -> new HybridSearchResult(
e.getKey(),
docMap.get(e.getKey()).text(),
e.getValue()
))
.collect(Collectors.toList());
}
public record RankedResult(String docId, String text, float score, int rank) {}
public record HybridSearchResult(String docId, String text, double hybridScore) {}
private List<Float> floatArrayToList(float[] arr) {
List<Float> list = new ArrayList<>(arr.length);
for (float v : arr) list.add(v);
return list;
}
}四、中英文混合场景的特殊处理
4.1 语言检测与路由
有时候需要根据查询语言做差异化处理:
import com.optimaize.langdetect.*;
import com.optimaize.langdetect.i18n.LdLocale;
import com.optimaize.langdetect.ngram.NgramExtractors;
import com.optimaize.langdetect.profiles.LanguageProfileReader;
import com.optimaize.langdetect.text.CommonTextObjectFactories;
/**
* 语言检测服务
* 用于判断查询语言,决定检索策略
*/
public class LanguageDetector {
private final com.optimaize.langdetect.LanguageDetector detector;
private final TextObjectFactory textObjectFactory;
public LanguageDetector() throws Exception {
var languageProfiles = new LanguageProfileReader().readAllBuiltIn();
this.detector = LanguageDetectorBuilder.create(NgramExtractors.standard())
.withProfiles(languageProfiles)
.build();
this.textObjectFactory = CommonTextObjectFactories.forDetectingOnLargeText();
}
public enum Language {
CHINESE, ENGLISH, MIXED, UNKNOWN
}
/**
* 检测查询语言
*/
public Language detect(String text) {
// 简单规则:统计中文字符占比
long chineseCount = text.chars()
.filter(c -> Character.UnicodeBlock.of(c) ==
Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS)
.count();
long asciiCount = text.chars()
.filter(c -> c < 128 && Character.isLetterOrDigit(c))
.count();
long total = chineseCount + asciiCount;
if (total == 0) return Language.UNKNOWN;
double chineseRatio = (double) chineseCount / total;
if (chineseRatio > 0.7) return Language.CHINESE;
if (chineseRatio < 0.3) return Language.ENGLISH;
return Language.MIXED;
}
/**
* 根据检测结果调整检索策略
*/
public SearchStrategy decideStrategy(String query) {
Language lang = detect(query);
return switch (lang) {
case CHINESE -> SearchStrategy.CHINESE_FIRST;
case ENGLISH -> SearchStrategy.ENGLISH_FIRST;
case MIXED -> SearchStrategy.MULTILINGUAL_HYBRID;
default -> SearchStrategy.MULTILINGUAL_HYBRID;
};
}
public enum SearchStrategy {
CHINESE_FIRST, // 优先搜中文库,结果不足时扩展到英文
ENGLISH_FIRST, // 优先搜英文库
MULTILINGUAL_HYBRID // 同时搜两个库,RRF融合
}
}4.2 分语言索引 vs 统一索引
这是一个经常被讨论的架构问题。两种方案的权衡:
统一索引:所有语言的文档存在同一个向量库里,查询不区分语言。
优点:
- 架构简单,只维护一个库
- 天然支持跨语言检索(中文查询可以找到英文相关文档)
缺点:
- 如果模型的跨语言对齐不完美,中文查询会"被迫"和大量英文文档竞争排名
分语言索引:中文一个库,英文一个库,查询时根据语言路由。
优点:
- 同语言内检索精度高
- 可以针对不同语言用不同的 Embedding 模型
缺点:
- 不支持跨语言检索(除非加翻译层)
- 架构复杂,维护两套索引
我的实践建议:
/**
* 自适应检索路由
* 根据用户查询语言和文档库语言分布,决定是否分库检索
*/
public class AdaptiveSearchRouter {
private final HybridSearchService unifiedSearch;
private final HybridSearchService chineseSearch;
private final HybridSearchService englishSearch;
private final LanguageDetector langDetector;
/**
* 智能路由检索
*/
public List<SearchResult> route(String query, int topK) throws Exception {
var strategy = langDetector.decideStrategy(query);
return switch (strategy) {
case CHINESE_FIRST -> {
// 先搜中文库
var chResult = chineseSearch.search(query, topK);
if (chResult.size() >= topK) {
yield chResult.stream()
.map(r -> new SearchResult(r.docId(), r.text(),
r.hybridScore(), "zh"))
.collect(Collectors.toList());
}
// 不够时补充英文库结果
var enResult = englishSearch.search(query, topK - chResult.size());
var combined = new ArrayList<SearchResult>();
chResult.forEach(r -> combined.add(new SearchResult(
r.docId(), r.text(), r.hybridScore(), "zh")));
enResult.forEach(r -> combined.add(new SearchResult(
r.docId(), r.text(), r.hybridScore(), "en")));
yield combined;
}
case ENGLISH_FIRST -> {
var enResult = englishSearch.search(query, topK);
yield enResult.stream()
.map(r -> new SearchResult(r.docId(), r.text(),
r.hybridScore(), "en"))
.collect(Collectors.toList());
}
case MULTILINGUAL_HYBRID -> {
// 两个库都搜,然后 RRF 融合
var chResult = chineseSearch.search(query, topK * 2);
var enResult = englishSearch.search(query, topK * 2);
yield mergeAndRerank(chResult, enResult, topK);
}
};
}
private List<SearchResult> mergeAndRerank(
List<HybridSearchService.HybridSearchResult> chResults,
List<HybridSearchService.HybridSearchResult> enResults,
int topK) {
Map<String, Double> mergedScores = new LinkedHashMap<>();
Map<String, String> docLangs = new HashMap<>();
int rank = 1;
for (var r : chResults) {
mergedScores.merge(r.docId(), 1.0 / (60 + rank++), Double::sum);
docLangs.put(r.docId(), "zh");
}
rank = 1;
for (var r : enResults) {
mergedScores.merge(r.docId(), 1.0 / (60 + rank++), Double::sum);
docLangs.putIfAbsent(r.docId(), "en");
}
// 构建结果文本映射
Map<String, String> docTexts = new HashMap<>();
chResults.forEach(r -> docTexts.put(r.docId(), r.text()));
enResults.forEach(r -> docTexts.putIfAbsent(r.docId(), r.text()));
return mergedScores.entrySet().stream()
.sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
.limit(topK)
.map(e -> new SearchResult(
e.getKey(),
docTexts.getOrDefault(e.getKey(), ""),
e.getValue(),
docLangs.getOrDefault(e.getKey(), "unknown")
))
.collect(Collectors.toList());
}
public record SearchResult(String docId, String text,
double score, String language) {}
}五、中英文混排文档的处理
实际业务中,很多文档是中英文混排的(比如技术文档里夹杂着英文术语、代码、参数名)。这类文档的 Embedding 策略需要特别注意。
5.1 分块策略对跨语言检索的影响
/**
* 语言感知的文档分块策略
*/
public class LanguageAwareChunker {
private final LanguageDetector langDetector;
/**
* 对中英文混排文档做语义感知分块
* 不在语言切换点(中英文交界处)生硬截断
*/
public List<Chunk> chunk(String document, int chunkSize, int overlap) {
// 检测整体语言
var docLang = langDetector.detect(document);
// 按段落预分割
String[] paragraphs = document.split("\n\n+");
List<Chunk> chunks = new ArrayList<>();
StringBuilder current = new StringBuilder();
String currentLang = null;
for (String para : paragraphs) {
var paraLang = langDetector.detect(para).name();
// 如果语言切换且当前块足够大,在此断开
if (currentLang != null &&
!paraLang.equals(currentLang) &&
current.length() > chunkSize / 2) {
chunks.add(new Chunk(current.toString().trim(), currentLang));
current = new StringBuilder();
}
if (current.length() + para.length() > chunkSize) {
if (current.length() > 0) {
chunks.add(new Chunk(current.toString().trim(), currentLang));
// overlap: 保留最后 overlap 字符
int overlapStart = Math.max(0,
current.length() - overlap);
current = new StringBuilder(
current.substring(overlapStart));
}
}
current.append(para).append("\n\n");
currentLang = paraLang;
}
if (current.length() > 0) {
chunks.add(new Chunk(current.toString().trim(), currentLang));
}
return chunks;
}
/**
* 为混排 chunk 生成增强后的文本
* 在技术术语前后添加语言标记,帮助模型更好地理解
*/
public String enhanceChunkForEmbedding(Chunk chunk) {
// 对纯英文 chunk,在前面加中文摘要标记
if ("ENGLISH".equals(chunk.language())) {
return chunk.text(); // 英文文档直接输入效果最好
}
// 对中英混排,标准化一些常见格式
String text = chunk.text()
// 把代码块标记统一
.replaceAll("```[a-z]*\n", "[代码开始]\n")
.replaceAll("```", "[代码结束]");
return text;
}
public record Chunk(String text, String language) {}
}5.2 跨语言专有名词对齐
中英文混合场景里一个隐蔽的问题:专业术语在不同语言里写法不同,但应该被视为相同概念。
比如:
- "机器学习" 和 "machine learning"
- "向量数据库" 和 "vector database"
模型通常能处理这类对齐,但可以通过术语词典做增强:
/**
* 双语术语增强
* 在文本中添加对应的另一种语言翻译,帮助模型做跨语言对齐
*/
public class BilingualTermEnhancer {
// 术语对照表
private static final Map<String, String> TERM_MAP = Map.of(
"机器学习", "machine learning",
"深度学习", "deep learning",
"自然语言处理", "NLP",
"向量数据库", "vector database",
"检索增强生成", "RAG",
"大语言模型", "LLM",
"嵌入向量", "embedding"
);
private static final Map<String, String> REVERSE_TERM_MAP;
static {
REVERSE_TERM_MAP = new HashMap<>();
TERM_MAP.forEach((k, v) -> REVERSE_TERM_MAP.put(v.toLowerCase(), k));
}
/**
* 对中文文本,在专业术语后附加英文对照
* 输入: "机器学习在自然语言处理中的应用"
* 输出: "机器学习(machine learning)在自然语言处理(NLP)中的应用"
*/
public String enhanceChinese(String text) {
StringBuilder result = new StringBuilder(text);
int offset = 0;
// 按长度降序处理,避免短词覆盖长词
var sortedTerms = new ArrayList<>(TERM_MAP.entrySet());
sortedTerms.sort((a, b) ->
Integer.compare(b.getKey().length(), a.getKey().length()));
for (var entry : sortedTerms) {
String term = entry.getKey();
String translation = entry.getValue();
String replacement = term + "(" + translation + ")";
int idx = result.indexOf(term, 0);
while (idx >= 0) {
// 检查是否已经增强过
int nextIdx = idx + term.length();
if (nextIdx >= result.length() ||
result.charAt(nextIdx) != '(') {
result.replace(idx, nextIdx, replacement);
idx = result.indexOf(term, idx + replacement.length());
} else {
idx = result.indexOf(term, nextIdx);
}
}
}
return result.toString();
}
/**
* 对英文查询,扩展中文近义词
* 帮助跨语言召回中文文档
*/
public String expandEnglishQuery(String query) {
String lower = query.toLowerCase();
StringBuilder expansion = new StringBuilder(query);
for (var entry : REVERSE_TERM_MAP.entrySet()) {
if (lower.contains(entry.getKey())) {
expansion.append(" ").append(entry.getValue());
}
}
return expansion.toString();
}
}六、实测:几种方案的跨语言检索效果
用一个自建的中英文双语知识库测试(共 5000 篇文档,一半中文一半英文),查询分四种类型:中查中、英查英、中查英、英查中。
测试指标是 Recall@5。
| 方案 | 中查中 | 英查英 | 中查英 | 英查中 |
|---|---|---|---|---|
| Ada-002(单语言) | 0.84 | 0.91 | 0.63 | 0.58 |
| mE5-large(多语言) | 0.87 | 0.89 | 0.78 | 0.74 |
| BGE-M3 Dense | 0.91 | 0.92 | 0.83 | 0.81 |
| BGE-M3 Hybrid | 0.93 | 0.94 | 0.85 | 0.83 |
| BGE-M3 + 术语增强 | 0.94 | 0.94 | 0.88 | 0.86 |
几个结论:
- Ada-002 的跨语言(中查英、英查中)能力弱,用在中英混合场景需要谨慎
- BGE-M3 的跨语言对齐显著优于 mE5-large
- 混合检索(Dense + Sparse)在各种场景下都比纯 Dense 有提升
- 术语增强对跨语言场景提升最显著(5个百分点)
七、踩坑总结
坑1:Embedding 维度和语言无关,但模型大小很相关
不要被"多语言模型就要比单语言大"的直觉误导。BGE-M3(568M参数)比 Ada-002 的实际效果更好,而且支持本地部署,省掉了 API 费用。
坑2:中英文混排时要特别注意文本编码
Java 读取中英文混排文件时,如果文件编码不一致(比如有的用 GBK,有的用 UTF-8),会出现乱码,Embedding 就完全错了。统一用 UTF-8 是最稳的选择。
// 正确做法:明确指定 UTF-8
String text = Files.readString(path, StandardCharsets.UTF_8);
// 而不是
String text = Files.readString(path); // 依赖系统默认编码,危险坑3:跨语言检索的相似度阈值需要分语言校准
同一个 BGE-M3 模型,中查中的相似度分布和中查英的分布不同。如果用统一阈值(比如"相似度 > 0.75 才返回"),会导致跨语言查询的召回率很低。需要对不同语言组合分别统计阈值分布,或者改用只返回 Top-K 而不做阈值过滤。
八、总结
中英文混合场景的核心建议:
- 模型选择:优先用 BGE-M3,跨语言对齐质量目前最好,且支持本地部署
- 检索策略:Dense + Sparse 混合检索,所有语言场景都有提升
- 术语增强:维护双语术语词典,在索引阶段做一次文本增强,对跨语言检索提升明显
- 架构选择:数据量不大(< 500 万)且跨语言需求强时用统一索引;数据量大且以单语言查询为主时考虑分库
