向量嵌入技术深度解析:为什么文字能变成数字还保留语义
向量嵌入技术深度解析:为什么文字能变成数字还保留语义
一、那个让人困惑的下午
2024年春天,杭州某互联网公司,后端工程师陈杰在接手一个向量数据库项目后,连续三天没睡好觉。
他工作3年,Java扎实,最近公司要做RAG(检索增强生成)系统,用Milvus存储文档的向量,用户问题来了先去Milvus检索相似文档,再喂给大模型。
架构很清晰,代码也写完了,能跑通。但陈杰有一个问题始终没搞明白,让他寝食难安:
Milvus里存的那个向量,到底是什么?
他知道用text-embedding-3-small这个API,传进去一段文字,出来一个1536维的浮点数组。他也知道用余弦相似度来找最相关的向量。但:
- 这1536个数字代表什么含义?
- 为什么"苹果"和"香蕉"的向量距离近,"苹果"和"汽车"距离远?
- 为什么"王"减去"男"加上"女",结果接近"后"这个词的向量?
- 同一个语义,中文和英文的嵌入在同一个空间里吗?
他在代码注释里写了这么一行:// TODO: 搞清楚Embedding到底是什么
这个TODO放了三个月没动。
这篇文章就是为这个TODO写的。
二、从"无法计算"到"可以计算"
在理解Embedding之前,先想想计算机处理文字的根本挑战。
数学和代数,本质上是在数字的世界里操作。两个数字可以相加、相减、求距离。但两个字符串怎么"求距离"?
// 这两个词,哪两个更"近"?
String a = "猫";
String b = "狗";
String c = "汽车";
// 常识上:猫和狗更近(都是动物)
// 但在字符串层面:三个都是一个字,距离一样
// 就算用编辑距离(Levenshtein distance):
// "猫" -> "狗":1步(替换)
// "猫" -> "汽车":2步(替换+插入)
// 但这是字形距离,不是语义距离我们需要一种方式,把文字映射到一个向量空间,使得语义相似的词,在这个空间里的位置也相近。
这就是Embedding(嵌入)。
三、One-Hot:最原始的失败尝试
最直观的想法是什么?给每个词一个唯一的编号。
假设词汇表里有10000个词,"猫"是第42号词:
// One-Hot编码
// "猫"的One-Hot向量:第42位是1,其余都是0
int[] cat = new int[10000];
cat[42] = 1;
// "狗"的One-Hot向量:第43位是1,其余都是0
int[] dog = new int[10000];
dog[43] = 1;
// "汽车"的One-Hot向量:第892位是1,其余都是0
int[] car = new int[10000];
car[892] = 1;问题是什么?
// 计算余弦相似度
double sim_cat_dog = cosineSimilarity(cat, dog); // = 0(完全不相关)
double sim_cat_car = cosineSimilarity(cat, car); // = 0(完全不相关)所有词对之间的距离都是一样的。 One-Hot只是给每个词一个唯一ID,完全没有语义信息。
更严重的问题:词汇表10000个词,就需要10000维的向量。词汇表越大,维度越高,计算越慢(维度灾难)。
四、Word2Vec:天才的转变——用上下文预测含义
2013年,Google的Tomas Mikolov提出了Word2Vec,这是词嵌入领域真正的革命。
核心直觉只有一句话:
"一个词的含义,由经常和它一起出现的词决定。"(分布假说,Distributional Hypothesis)
换句话说:如果你经常在同样的上下文中看到"猫"和"狗",那么它们应该有相似的含义(至少在某些方面)。
语料库片段:
"我家养了一只猫,每天晚上给它喂猫粮..."
"邻居家养了一只狗,每天晚上给它喂狗粮..."
"路上有只流浪猫,我看它很可怜..."
"公园里有只流浪狗,它在找食物...""猫"出现的上下文:养、喂猫粮、流浪、可怜 "狗"出现的上下文:养、喂狗粮、流浪、找食物
上下文几乎一样!所以Word2Vec会把"猫"和"狗"的向量训练得很接近。
4.1 Word2Vec的训练思路
Word2Vec有两种训练方式,我们看最直觉的一种:Skip-gram
目标:给定中心词,预测它周围的词(滑动窗口)
句子:"今天 天气 很好 适合 出门"
窗口大小=2时:
中心词="天气",需要预测:["今天", "很好"]
中心词="很好",需要预测:["天气", "适合"]
中心词="适合",需要预测:["很好", "出门"]训练一个神经网络:输入是中心词的One-Hot,输出是预测周围词的概率。
// 简化版Skip-gram训练思路(伪代码)
class Word2VecSkipGram {
// 嵌入矩阵:每个词一个向量,这是我们要学的参数
double[][] embeddings; // 大小:[词汇量, 嵌入维度],如[10000, 300]
void train(String centerWord, String contextWord) {
// 前向传播:从中心词的嵌入预测上下文词
double[] centerVec = embeddings[wordIndex(centerWord)];
double[] scores = matMul(centerVec, embeddings.T); // 与所有词计算分数
double[] probs = softmax(scores);
// 损失:正确上下文词的负对数似然
double loss = -Math.log(probs[wordIndex(contextWord)]);
// 反向传播:更新嵌入矩阵,使得正确预测的概率更高
// 经过大量训练后,同样上下文的词的向量会越来越接近
backprop(loss);
}
}训练结束后,embeddings矩阵就是词嵌入——每个词对应一个密集的低维向量(如300维),语义相似的词向量距离近。
4.2 和Java并发的类比
Word2Vec的训练方式很像Java里的ConcurrentHashMap的使用模式:
// 词共现统计(类比)
ConcurrentHashMap<String, Map<String, Integer>> coOccurrence = new ConcurrentHashMap<>();
// 统计"猫"和"狗"的共现(在同一个10词窗口内)
// 大量文本训练后,"猫"和"狗"的共现词表几乎相同
// Word2Vec从这种共现模式中提炼出"相似性"信息五、嵌入空间的神奇性质
理解了Word2Vec之后,让我们来看那个最著名的例子:
王(King) - 男(Man) + 女(Woman) ≈ 后(Queen)
这到底意味着什么?
5.1 向量差表示关系
double[] king = embedding("王");
double[] man = embedding("男");
double[] woman = embedding("女");
double[] queen = embedding("后");
// 向量减法
double[] diff = subtract(king, man);
// diff捕捉了"王"比"男"多出来的那部分含义:皇权地位、统治者身份
double[] result = add(diff, woman);
// 把这个"皇权"的差加到"女"上,得到的向量非常接近"后"
double similarity = cosineSimilarity(result, queen); // 约0.8-0.9这说明嵌入空间里存在语义方向(Semantic Direction):
- "男→女"方向:性别方向
- "普通人→统治者"方向:地位方向
- "单数→复数"方向:词形变化方向
这些方向是模型自动从大量文本中学到的,没有人告诉模型"这个方向是性别"。
5.2 更多例子
巴黎 - 法国 + 中国 ≈ 北京 (首都关系)
快速 - 快 + 慢 ≈ 缓慢 (程度关系)
医生 - 男 + 女 ≈ 护士 (警告:这里反映了训练数据中的性别偏见!)最后一个例子很重要:嵌入会放大训练数据中的偏见。如果训练数据中"医生"更多与"男性"共现,"护士"更多与"女性"共现,嵌入就会编码这种偏见。
5.3 Java数据库类比
嵌入空间就像一个多维的"语义坐标系":
// 假设我们能直接读出嵌入的语义维度(实际上不能,维度没有人可解释的标签)
// 但可以想象:
double[] cat = {
// 维度1:生物性(0.9表示"是生物")
0.9,
// 维度2:家养性(0.8表示"很可能是宠物")
0.8,
// 维度3:大小(0.2表示"小型动物")
0.2,
// 维度4:凶猛性(0.3表示"不太凶猛")
0.3,
// ... 共300个维度
};
double[] dog = {0.9, 0.85, 0.3, 0.5, ...}; // 和猫很接近
double[] car = {0.05, 0.5, 0.7, 0.1, ...}; // 和猫差很多(注意:实际嵌入的维度没有这种人类可解释的标签,上面只是帮助理解的类比)
六、词嵌入 vs 句子嵌入:本质差别
Word2Vec是词级别的嵌入,一个词一个固定的向量。但这有个大问题:
"苹果很甜" 和 "苹果发布了新iPhone""苹果"在这两句话里意思完全不同(水果 vs 公司),但Word2Vec给的是同一个向量。
这就是多义词问题(Polysemy Problem)。
6.1 BERT如何解决多义词问题
BERT(双向Transformer)使用上下文嵌入(Contextual Embeddings)。
关键区别:词的嵌入不再是固定的,而是根据上下文动态生成的。
// Word2Vec:静态嵌入
double[] apple1 = word2vec.embed("苹果"); // 水果语境
double[] apple2 = word2vec.embed("苹果"); // 公司语境
// apple1 == apple2,完全一样!
// BERT:上下文嵌入
double[] apple1 = bert.embed("苹果很甜,我喜欢吃苹果", tokenIndex=0);
double[] apple2 = bert.embed("苹果发布了新iPhone,股价上涨", tokenIndex=0);
// apple1 != apple2,根据上下文不同,嵌入也不同!BERT把整个句子输入进去,每个词的输出嵌入都融合了整个句子的上下文(因为Self-Attention机制),所以同一个词在不同语境下有不同的向量表示。
6.2 句子嵌入(Sentence Embedding)
做RAG系统时,你不只是嵌入单个词,而是嵌入整个句子或段落。
// 用OpenAI的嵌入API
OpenAIClient client = new OpenAIClient(apiKey);
// 单个句子嵌入
EmbeddingResponse response = client.embeddings()
.model("text-embedding-3-small")
.input("Java中的HashMap是如何实现的?")
.create();
double[] sentenceEmbedding = response.getData().get(0).getEmbedding();
// 返回一个1536维的浮点数组,代表这个句子的语义句子嵌入不是词嵌入的简单平均,而是经过更复杂的训练(对比学习,下面会讲),让整个句子的语义信息被压缩进一个固定长度的向量里。
七、余弦相似度:为什么不用欧氏距离
你可能注意到,向量数据库里衡量相似度通常用余弦相似度,而不是欧氏距离。为什么?
7.1 直觉解释
想象两篇文章,一篇100字,一篇1000字,内容完全一样(后者是前者的扩展版)。
如果用欧氏距离,长文章的词出现更多次,词频向量的数值更大,欧氏距离会认为两篇文章"距离很远"。但实际上它们语义相同。
余弦相似度计算的是两个向量之间的角度,不关心向量的长度,只关心方向。
double cosineSimilarity(double[] a, double[] b) {
double dotProduct = 0;
double normA = 0;
double normB = 0;
for (int i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
// 返回值范围:-1(完全相反)到 1(完全相同)
}7.2 几何直觉
在高维空间里,想象所有向量都从原点出发,像射线一样往各个方向延伸。
- 余弦相似度:看两条射线之间的夹角。夹角越小(余弦值越大),越相似。
- 欧氏距离:看两条射线末端点之间的直线距离。受向量长度影响很大。
对嵌入来说,语义信息编码在方向上,向量的模长(L2范数)主要受词频等无关因素影响,所以用余弦相似度更合适。
7.3 内积(Dot Product)相似度
另一个常见选项:内积 = 向量长度 × 余弦相似度。
如果你先对所有向量做L2归一化(使每个向量的模长为1),那么内积和余弦相似度的结果完全一样。
现代嵌入API(如OpenAI的)返回的向量默认已经L2归一化,所以直接用内积就等于余弦相似度,而且内积可以用更快的矩阵乘法实现。
// 如果向量已经L2归一化
double dotProduct = 0;
for (int i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
}
// dotProduct 等价于 cosineSimilarity(a, b)
// 而且更快(不需要计算向量模长)八、1536维是什么意思?维度与语义容量
OpenAI的text-embedding-3-small输出1536维,text-embedding-3-large输出3072维,国内的BGE-large输出1024维。
这些维度数字意味着什么?
8.1 维度就是语义的"自由度"
想象用2个数字描述一个点的位置(2D空间),用3个数字描述3D空间的位置。维度越高,能描述的"信息量"越大。
对嵌入来说:
- 低维(100维):只能捕捉粗粒度的语义("这是一个积极的词"、"这是一个关于技术的概念")
- 高维(1536维):能捕捉细粒度的语义(微妙的情感差异、领域特定的含义、上下文关系)
8.2 维度和性能的权衡
// 不同维度对RAG系统的影响
// 假设你有1000万个文档块,每个块嵌入成向量
// text-embedding-3-small(1536维,float32)
long memorySmall = 10_000_000L * 1536 * 4; // = 61.4 GB
// text-embedding-3-large(3072维,float32)
long memoryLarge = 10_000_000L * 3072 * 4; // = 122.9 GB
// BGE-large(1024维,float32)
long memoryBGE = 10_000_000L * 1024 * 4; // = 40.9 GB
// 近似最近邻搜索的时间复杂度也和维度成正比更高的维度:
- 优点:语义表达能力更强,细粒度相似度更准确
- 缺点:存储成本高,检索速度慢,API调用更贵
8.3 维度削减技术
OpenAI的text-embedding-3系列支持Matryoshka Representation Learning(MRL,套娃表示学习)。
原理是在训练时,要求前N维的向量就已经是一个有意义的嵌入,前2N维的向量是更精确的嵌入,以此类推。
// 使用1536维嵌入,但只取前256维用于粗粒度检索
EmbeddingResponse response = client.embeddings()
.model("text-embedding-3-small")
.input(text)
.dimensions(256) // 只取前256维!
.create();
// 节省了6倍的存储,检索速度快6倍,精度有所下降但通常可以接受
// 可以用于"粗排",然后用全维度做"精排"这对RAG系统的实践启示:对于海量数据的粗粒度检索,用256维嵌入;对于精确语义匹配,用1536维或更高。
九、多语言嵌入:中文和英文在同一个空间
一个让人惊叹的发现是:好的多语言嵌入模型,会把不同语言中语义相同的词放在空间中相近的位置。
// 多语言嵌入示例(使用multilingual-e5-large或text-embedding-3-small)
EmbeddingClient client = new EmbeddingClient();
double[] chinese = client.embed("人工智能");
double[] english = client.embed("Artificial Intelligence");
double[] japanese = client.embed("人工知能");
double[] french = client.embed("Intelligence Artificielle");
// 四个向量之间的余弦相似度:
// chinese-english: ~0.92
// chinese-japanese: ~0.94(日语借用了大量汉字,更接近)
// english-french: ~0.91
// 和不相关词的相似度:
double[] car = client.embed("汽车");
// chinese-car: ~0.45(同为中文,但语义不同)9.1 为什么不同语言能在同一空间
这依赖于跨语言训练数据。
训练过程中,模型见到大量平行语料(同一内容的多语言版本),通过对比学习(下一节讲),让语义相同但语言不同的句子在嵌入空间中靠近。
Java类比:这就像设计一个国际化的数据库,不同语言的数据通过语义ID而不是字符串ID相互关联。
9.2 实践意义
对Java工程师的RAG系统来说:
// 用户用中文问问题,文档是英文的,也能正确检索
String chineseQuery = "Java中的垃圾回收是如何工作的";
double[] queryEmbedding = embed(chineseQuery);
// 英文文档的嵌入:
String englishDoc = "Java Garbage Collection: The JVM automatically manages...";
double[] docEmbedding = embed(englishDoc);
double similarity = cosineSimilarity(queryEmbedding, docEmbedding);
// similarity ≈ 0.85,依然能找到相关文档但注意:并非所有嵌入模型都有同样好的跨语言能力。中文场景建议使用:
- OpenAI的
text-embedding-3-small/large(跨语言能力强) - BAAI的
bge-m3(开源,多语言,中文效果出色) - 阿里的
text-embedding-v3(中文优化)
十、对比学习:嵌入模型是怎么训练的
现代句子嵌入模型(Sentence Transformers)的训练核心是对比学习(Contrastive Learning)。
10.1 对比学习的思想
目标:让语义相似的句子嵌入距离近,语义不相关的句子嵌入距离远。
// 对比学习的训练数据格式(三元组)
TrainingExample example = TrainingExample.builder()
.anchor("Java中HashMap的时间复杂度是多少")
.positive("HashMap的get和put操作平均时间复杂度是O(1)") // 语义正相关
.negative("Python的字典是用什么实现的") // 语义不相关
.build();
// 训练目标:
// cosine(anchor, positive) 尽可能大(接近1)
// cosine(anchor, negative) 尽可能小(接近0或负值)10.2 InfoNCE损失函数(直觉理解)
对比学习用的损失函数叫InfoNCE,直觉上是:
给定anchor,在一个batch里有1个positive和N个negative,模型要能从这N+1个候选中识别出positive。
// 伪代码:一个batch的训练
void trainBatch(List<TrainingExample> batch) {
for (TrainingExample example : batch) {
double[] anchorEmb = model.embed(example.anchor);
double[] posEmb = model.embed(example.positive);
// 收集这个batch里其他样本作为in-batch negatives
List<double[]> negEmbs = batch.stream()
.filter(e -> e != example)
.map(e -> model.embed(e.positive)) // 别人的positive对当前样本就是negative
.collect(toList());
// 计算损失:positive的相似度要比所有negative都高
double posScore = cosineSimilarity(anchorEmb, posEmb);
double[] negScores = negEmbs.stream()
.mapToDouble(neg -> cosineSimilarity(anchorEmb, neg))
.toArray();
double loss = -log(softmax([posScore] + negScores)[0]);
// 梯度更新:让positive更近,negative更远
model.backward(loss);
}
}10.3 硬负例挖掘(Hard Negative Mining)
普通负例太容易分辨了("Java HashMap"和"中午吃什么"显然不相关),模型学不到有价值的东西。
硬负例:语义上看起来相关但实际不匹配的例子。
// 硬负例示例
TrainingExample hardExample = TrainingExample.builder()
.anchor("Java中HashMap和Hashtable的区别")
.positive("HashMap是非线程安全的,Hashtable是线程安全的,但性能较差")
.hardNegative("ConcurrentHashMap使用分段锁来实现线程安全") // 主题相关但不是最佳答案
.build();
// 硬负例训练出来的嵌入对语义细微差别更敏感这也是为什么BGE模型、E5模型效果好——它们用了大量精心构建的硬负例训练数据。
十一、理解嵌入如何帮你选更好的模型
现在把原理知识转化为实战决策能力。
11.1 评估嵌入模型的核心指标
MTEB(Massive Text Embedding Benchmark)是嵌入模型的主流评估框架:
评估维度(简化):
- 检索任务(Retrieval):给定查询,从大量候选中找最相关的文档
- 语义相似度(STS):判断两个句子的语义相似程度
- 分类任务(Classification):用嵌入做下游分类
- 聚类任务(Clustering):把相似文档聚在一起对RAG场景,最关键的是检索任务的得分。
11.2 嵌入模型选型对比
| 模型 | 维度 | 中文检索NDCG@10 | 每1K token成本 | 适用场景 |
|---|---|---|---|---|
| text-embedding-3-small | 1536 | 约0.72 | $0.02 | 通用,多语言 |
| text-embedding-3-large | 3072 | 约0.78 | $0.13 | 高精度要求 |
| bge-large-zh-v1.5 | 1024 | 约0.80 | 免费(本地) | 中文优化,本地部署 |
| bge-m3 | 1024 | 约0.82 | 免费(本地) | 多语言,SOTA中文 |
| text-embedding-v3(阿里) | 1024 | 约0.83 | $0.007 | 中文场景,低成本 |
11.3 实战Java代码:模型对比测试框架
@Service
public class EmbeddingModelEvaluator {
// 测试数据:查询-文档相关性对
private final List<EvalPair> evalPairs = List.of(
new EvalPair("Java HashMap线程安全吗",
"HashMap不是线程安全的,多线程下应使用ConcurrentHashMap",
1.0), // 完全相关
new EvalPair("Java HashMap线程安全吗",
"HashSet是基于HashMap实现的,不允许重复元素",
0.3), // 相关但不是最佳
new EvalPair("Java HashMap线程安全吗",
"Python的GIL保证了基础数据类型的线程安全",
0.0) // 不相关
);
/**
* 评估嵌入模型在检索任务上的质量
* 指标:NDCG@K(归一化折损累积增益)
*/
public double evaluateNDCG(EmbeddingClient client, int k) {
double totalNDCG = 0;
// 按查询分组
Map<String, List<EvalPair>> byQuery = evalPairs.stream()
.collect(groupingBy(EvalPair::getQuery));
for (Map.Entry<String, List<EvalPair>> entry : byQuery.entrySet()) {
String query = entry.getKey();
List<EvalPair> pairs = entry.getValue();
// 嵌入查询
double[] queryEmb = client.embed(query);
// 嵌入所有候选文档,计算相似度
List<ScoredDoc> scored = pairs.stream()
.map(pair -> {
double[] docEmb = client.embed(pair.getDocument());
double similarity = cosineSimilarity(queryEmb, docEmb);
return new ScoredDoc(pair, similarity);
})
.sorted(Comparator.comparingDouble(ScoredDoc::getSimilarity).reversed())
.collect(toList());
// 计算NDCG@k
double dcg = 0;
double idealDCG = 0;
for (int i = 0; i < Math.min(k, scored.size()); i++) {
dcg += scored.get(i).getPair().getRelevance() / (Math.log(i + 2) / Math.log(2));
idealDCG += 1.0 / (Math.log(i + 2) / Math.log(2)); // 假设理想排序
}
totalNDCG += (idealDCG > 0) ? dcg / idealDCG : 0;
}
return totalNDCG / byQuery.size();
}
/**
* 批量评估多个模型,选出最适合你场景的
*/
public void benchmarkModels() {
Map<String, EmbeddingClient> models = Map.of(
"text-embedding-3-small", new OpenAIEmbeddingClient("text-embedding-3-small"),
"bge-large-zh", new BGEEmbeddingClient("bge-large-zh-v1.5"),
"bge-m3", new BGEEmbeddingClient("bge-m3")
);
models.forEach((name, client) -> {
long start = System.currentTimeMillis();
double ndcg = evaluateNDCG(client, 3);
long elapsed = System.currentTimeMillis() - start;
System.out.printf("模型: %-25s | NDCG@3: %.4f | 耗时: %dms%n",
name, ndcg, elapsed);
});
}
}11.4 嵌入的"冷启动"问题
刚开始构建RAG系统时,不知道哪个嵌入模型最适合自己的业务,这是正常的。
推荐的选型流程:
第一步:先用 bge-m3(免费,效果好,中文友好)建立baseline
↓ 如果对外API调用,切换到
第二步:text-embedding-3-small(成本低,效果不差)
↓ 如果对精度要求极高
第三步:用自己的业务数据构建评估集(50-100个查询-文档对)
↓ 在评估集上对比2-3个模型
第四步:选NDCG最高的,或者成本/精度最优的十二、嵌入的常见陷阱
12.1 不要用嵌入模型嵌入太长的文本
嵌入模型都有最大输入长度限制(通常512-8192 tokens)。
超出限制怎么处理?大多数API会截断,不会报错。你可能不知道自己的文档被截断了,然后困惑于为什么检索效果差。
// 检查文本长度
int tokenCount = tokenizer.countTokens(text);
if (tokenCount > MODEL_MAX_TOKENS) {
log.warn("文本长度{}超过模型限制{},将被截断!", tokenCount, MODEL_MAX_TOKENS);
// 正确做法:分块,每块分别嵌入
List<String> chunks = splitter.split(text, MAX_CHUNK_SIZE);
List<double[]> chunkEmbeddings = chunks.stream()
.map(client::embed)
.collect(toList());
}12.2 嵌入的"淡化"问题
如果你把查询和文档用同一个嵌入模型,但一个很短(10个词)一个很长(200个词),它们的嵌入方向可能差很多,导致相似度低。
原因:嵌入模型倾向于把长文本的嵌入"摊薄"到更多维度。短查询和长文档的信息密度不同,嵌入空间里的位置会有偏移。
解决方案:HyDE(Hypothetical Document Embedding)
// HyDE:先用大模型生成一段假设性的答案,然后嵌入这个假设答案做检索
String query = "Java中HashMap线程安全吗";
// 第一步:让大模型生成假设答案(不需要准确)
String hypotheticalAnswer = llm.generate(
"请用几句话回答:" + query + "(回答不需要完全准确)"
);
// 假设答案可能是:"HashMap不是线程安全的,在多线程场景下如果并发修改会有问题..."
// 第二步:嵌入这个假设答案去检索,而不是嵌入原始查询
double[] searchVector = embed(hypotheticalAnswer);
// 效果:假设答案的长度和风格与真实文档更接近,检索更准确
List<Document> results = vectorDB.search(searchVector, 5);12.3 不要把嵌入向量直接存到关系型数据库
很多人一开始把向量存到MySQL的BLOB字段,每次查询要读出所有向量,然后在Java里做余弦相似度计算,这在10万条数据以上就会慢到不可用。
// 错误做法(小规模可以,大规模不可行)
List<EmbeddingRecord> allEmbeddings = jdbcTemplate.query(
"SELECT id, embedding FROM documents",
embeddingRowMapper
);
// 在内存里计算所有相似度 - O(N) 时间,O(N) 内存
List<ScoredResult> ranked = allEmbeddings.stream()
.map(e -> new ScoredResult(e.getId(), cosineSimilarity(query, e.getEmbedding())))
.sorted(Comparator.reverseOrder())
.limit(10)
.collect(toList());
// 正确做法:使用向量数据库的HNSW索引
// Milvus、Weaviate、Qdrant、pgvector都支持近似最近邻搜索
// O(log N) 时间,千万级数据毫秒级响应
MilvusClient milvus = new MilvusClient();
List<SearchResult> results = milvus.search(
collection, queryEmbedding, topK=10,
SearchParams.builder().indexType(HNSW).build()
);十三、FAQ
Q1:我的RAG系统检索结果不准,一定是嵌入模型的问题吗?
A:不一定,问题可能出在任何环节:
- 分块策略:文档分块太大(语义稀释)或太小(上下文缺失)
- 嵌入模型:没有针对你的领域优化
- 向量数据库:索引参数设置不合理(HNSW的ef_search太小)
- 重排序:没有用Cross-Encoder做精排
建议用评估集量化每个环节的效果,找到瓶颈再优化。
十五、深入:嵌入模型的内部结构
15.1 Sentence Transformer的架构
句子嵌入模型(如bge-large-zh, text-embedding-3-small)的内部通常包含:
输入文本
↓
分词器(Tokenizer)
↓
Token Embeddings(词嵌入)
↓
多层 Transformer Encoder(双向注意力)
↓
Pooling层(把序列压缩成单个向量)
↓
可选的线性投影层(调整维度)
↓
L2归一化(使向量模长为1)
↓
最终的固定维度嵌入向量15.2 Pooling策略:如何从序列得到单个向量
这是嵌入模型的关键设计决策之一。
策略一:[CLS] Token Pooling
BERT系列模型在输入开头插入一个特殊的[CLS](classification)token,训练结束后,这个token的隐状态会自然地聚合整个序列的语义信息。
// 取第一个token([CLS])的向量作为整个序列的嵌入
double[] clsEmbedding = bertOutput[0]; // 第0个位置是[CLS]策略二:Mean Pooling
对序列中所有非填充token的向量取均值:
public double[] meanPooling(double[][] tokenVectors, int[] attentionMask) {
double[] pooled = new double[tokenVectors[0].length];
int count = 0;
for (int i = 0; i < tokenVectors.length; i++) {
if (attentionMask[i] == 1) { // 不是填充token
for (int j = 0; j < tokenVectors[i].length; j++) {
pooled[j] += tokenVectors[i][j];
}
count++;
}
}
for (int j = 0; j < pooled.length; j++) {
pooled[j] /= count;
}
return pooled;
}研究表明,Mean Pooling通常比CLS Pooling效果更好,因为它利用了所有token的信息,而不只是一个特殊token。这也是现代嵌入模型(bge系列、e5系列)普遍采用Mean Pooling的原因。
策略三:加权Mean Pooling
对靠近句子中心位置的token给更高权重,对首尾padding给更低权重。一些模型的研究发现,对特定任务加权策略有微弱的提升,但差异不大。
15.3 对比学习的变体:SimCSE
SimCSE(Simple Contrastive Learning of Sentence Embeddings)是嵌入模型训练的里程碑方法,2021年提出后被广泛应用。
核心思路非常巧妙:
// SimCSE的训练方式:同一个句子,丢给模型两次(用不同的Dropout掩码)
// 这样产生了两个"微弱扰动版本",训练让它们互相靠近
String sentence = "Java是一种面向对象的编程语言";
// 第一次前向传播(Dropout随机丢弃一些神经元)
double[] embedding1 = model.encode(sentence, dropout=0.1, dropoutSeed=42);
// 第二次前向传播(不同的Dropout随机掩码)
double[] embedding2 = model.encode(sentence, dropout=0.1, dropoutSeed=99);
// 目标:embedding1和embedding2之间的余弦相似度应该很高
// 同时,它们和batch中其他句子的嵌入之间的相似度应该很低
// 这叫"同句对比"(Unsupervised SimCSE)这个方法之所以有效:Dropout的随机性相当于对句子的"视角"做了轻微变化,训练模型从不同角度看同一句话,提炼出稳定的语义核心。
十六、RAG系统中嵌入的完整工程实践
16.1 分块策略对嵌入质量的影响
分块(Chunking)是RAG中最被低估的工程决策之一。
@Service
public class AdaptiveChunkingService {
/**
* 固定大小分块(最简单,但不一定最好)
*/
public List<String> fixedSizeChunk(String text, int chunkSize, int overlap) {
List<String> chunks = new ArrayList<>();
int start = 0;
while (start < text.length()) {
int end = Math.min(start + chunkSize, text.length());
chunks.add(text.substring(start, end));
start += chunkSize - overlap; // overlap确保边界不会被截断
}
return chunks;
}
/**
* 语义分块(更智能,按自然边界分割)
* 在句子结束、段落结束处分割,而不是任意截断
*/
public List<String> semanticChunk(String text, int maxChunkSize) {
// 按段落分割
String[] paragraphs = text.split("\\n\\n+");
List<String> chunks = new ArrayList<>();
StringBuilder currentChunk = new StringBuilder();
for (String paragraph : paragraphs) {
// 估算加入这段后的token数
int newLength = currentChunk.length() + paragraph.length();
if (newLength > maxChunkSize && currentChunk.length() > 0) {
chunks.add(currentChunk.toString().trim());
currentChunk = new StringBuilder();
}
currentChunk.append(paragraph).append("\n\n");
}
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString().trim());
}
return chunks;
}
/**
* 层级分块(Small-to-Big Retrieval)
* 嵌入小块用于精确检索,返回包含小块的大块作为上下文
*/
public HierarchicalChunks hierarchicalChunk(String text) {
// 父块:较大的上下文单元(段落级)
List<String> parentChunks = semanticChunk(text, 1500);
// 子块:较小的精确检索单元(句子级)
List<ChildChunk> childChunks = new ArrayList<>();
for (int i = 0; i < parentChunks.size(); i++) {
List<String> sentences = splitIntoSentences(parentChunks.get(i));
for (String sentence : sentences) {
childChunks.add(new ChildChunk(sentence, i)); // 记录归属的父块
}
}
return new HierarchicalChunks(parentChunks, childChunks);
}
}分块大小的经验值:
- 太小(<100 tokens):每块信息稀疏,单块缺乏上下文
- 适中(200-500 tokens):适合大多数场景的黄金区间
- 较大(500-1000 tokens):信息密度高,但可能稀释相关性
16.2 Reranking(精排):嵌入检索的补充
向量检索(粗排)找到Top-K相似文档,但"语义相似"不完全等于"最相关"。精排(Reranking)用更精确的Cross-Encoder模型对检索结果重新排序。
@Service
public class TwoStageRAGService {
private final VectorStore vectorStore; // 用于粗排(双编码器)
private final CrossEncoderReranker reranker; // 用于精排(交叉编码器)
private final ChatClient chatClient;
public String answer(String question) {
// 阶段1:向量检索(速度快,召回率高)
// 检索更多候选,比最终需要的多3-5倍
List<Document> candidates = vectorStore.similaritySearch(
SearchRequest.query(question).withTopK(20)
);
// 阶段2:精排(速度慢但精度高)
// Cross-Encoder同时处理查询和文档,能更好地理解相关性
List<RankedDocument> reranked = reranker.rerank(question, candidates);
// 只取精排后的Top-3作为最终上下文
List<Document> topDocs = reranked.stream()
.limit(3)
.map(RankedDocument::getDocument)
.collect(toList());
// 生成答案
String contextPrompt = buildContextPrompt(topDocs, question);
return chatClient.prompt().user(contextPrompt).call().content();
}
}为什么需要两阶段:
- 双编码器(嵌入模型):查询和文档独立编码,速度快,适合大规模检索
- 交叉编码器(Reranker):同时编码查询+文档,能捕捉交互关系,精度高但慢,只适合小规模精排
常用的开源Reranker:
BAAI/bge-reranker-large:中英文效果好cross-encoder/ms-marco-MiniLM-L-6-v2:英文效果好,速度快
十七、嵌入的高级应用:不只是RAG检索
向量嵌入的用途远不止RAG检索。
17.1 语义缓存(Semantic Cache)
传统缓存用精确字符串匹配(Redis的key-value)。语义缓存用嵌入相似度匹配,捕捉同义问题:
@Service
public class SemanticCacheService {
private final VectorStore semanticCache;
private final double SIMILARITY_THRESHOLD = 0.95;
/**
* 查找语义上相同的历史问题的缓存答案
*/
public Optional<String> findCachedAnswer(String question) {
List<Document> similar = semanticCache.similaritySearch(
SearchRequest.query(question)
.withTopK(1)
.withSimilarityThreshold(SIMILARITY_THRESHOLD)
);
if (!similar.isEmpty()) {
log.info("语义缓存命中!原问题:{}", similar.get(0).getContent());
return Optional.of(similar.get(0).getMetadata().get("answer").toString());
}
return Optional.empty();
}
public void cacheAnswer(String question, String answer) {
Document doc = new Document(
question,
Map.of(
"answer", answer,
"cached_at", LocalDateTime.now().toString()
)
);
semanticCache.add(List.of(doc));
}
}效果:"Java中HashMap怎么用"和"如何在Java里使用HashMap"会命中同一个缓存条目,节省LLM调用成本。
17.2 文档聚类和自动归类
@Service
public class DocumentClusteringService {
/**
* 对文档做K-Means聚类,自动发现主题
*/
public ClusteringResult clusterDocuments(List<String> documents, int numClusters) {
// 1. 批量嵌入所有文档
List<double[]> embeddings = documents.stream()
.map(embeddingClient::embed)
.collect(toList());
// 2. K-Means聚类
KMeansResult kmeans = kMeansClustering(embeddings, numClusters);
// 3. 对每个聚类生成主题标签(用LLM总结这类文档的共同主题)
List<String> clusterLabels = new ArrayList<>();
for (int c = 0; c < numClusters; c++) {
final int clusterIdx = c;
List<String> clusterDocs = IntStream.range(0, documents.size())
.filter(i -> kmeans.getAssignment(i) == clusterIdx)
.mapToObj(documents::get)
.limit(5) // 每类取5个代表性文档
.collect(toList());
String label = chatClient.prompt()
.user("以下是一组主题相似的文档片段,请用3-5个词总结它们的共同主题:\n" +
String.join("\n---\n", clusterDocs))
.call()
.content();
clusterLabels.add(label);
}
return new ClusteringResult(kmeans.getAssignments(), clusterLabels);
}
}17.3 异常检测
嵌入可以用于检测异常文本(垃圾邮件、异常日志、欺诈文本):
@Service
public class AnomalyDetectionService {
// 正常文本的嵌入中心(从历史数据学习)
private double[] normalCenter;
private double normalRadius;
/**
* 检测一段文本是否异常
* 原理:正常文本的嵌入聚集在某个区域,异常文本的嵌入偏离这个区域
*/
public AnomalyResult detectAnomaly(String text) {
double[] embedding = embeddingClient.embed(text);
double distance = cosineSimilarity(embedding, normalCenter);
// 距离正常中心越远,越可能是异常
boolean isAnomaly = (1 - distance) > normalRadius * 1.5;
double anomalyScore = 1 - distance;
return new AnomalyResult(isAnomaly, anomalyScore);
}
}Q2:嵌入向量能反映内容的真实准确性吗?
A:不能。嵌入捕捉的是语义相似性,不是事实正确性。一篇关于"地球是平的"的文章和一篇关于"地球是球形的"的文章,如果内容都涉及地球形状,它们的嵌入可能相当接近(因为主题相关),但内容真实性完全相反。
Q3:为什么有时候明明相关的文档检索不出来?
A:常见原因:
- 词汇鸿沟:查询用"心肌梗死",文档里写的是"心脏病发作",虽然语义相关但嵌入差距可能超出阈值
- 领域特化:通用嵌入模型对专业术语的理解不如领域专用模型
- 相似度阈值设置过高:建议先不设阈值,看Top-10的结果质量
Q4:代码嵌入和文本嵌入应该用同一个模型吗?
A:不应该。代码有自己的语义结构(函数名、变量名、控制流)。对代码嵌入推荐使用:
code-search-code(OpenAI,专门用于代码检索)CodeBERT/GraphCodeBERT(微软开源)Jina Code Embedding(支持30+编程语言)
Q5:嵌入能用于实时推荐系统吗?
A:可以,这就是现代推荐系统的主流方案(如YouTube、Netflix的做法)。用用户行为数据训练Item2Vec或User2Vec,然后用近似最近邻搜索实时检索候选集。延迟通常在10ms以内。
十四、总结
向量嵌入是当代AI系统中把非结构化数据(文本、图片、代码)带入"可计算"世界的关键桥梁。
| 概念 | 本质 | Java类比 |
|---|---|---|
| One-Hot | 每个词唯一ID | 枚举值 |
| Word2Vec | 上下文决定含义 | 通过使用场景来定义接口 |
| 上下文嵌入(BERT) | 同词不同义 | 方法重载 |
| 句子嵌入 | 整段话压缩成向量 | 对象序列化 |
| 余弦相似度 | 角度距离 | 不考虑大小,只比较比例 |
| 嵌入维度 | 语义自由度 | 对象的字段数量 |
| 多语言嵌入 | 跨语言语义对齐 | 国际化接口 |
| 对比学习 | 拉近正例,推远负例 | 训练分类器时的样本平衡 |
理解这些原理,你才能真正懂得为什么RAG系统有时准有时不准,才能系统性地优化,而不是盲目调参。
十八、写给还在困惑的陈杰
当初那个在代码注释里写// TODO: 搞清楚Embedding到底是什么的陈杰,三个月后在知识星球给我发了一条消息。
他说他把这篇文章来回看了四遍,然后做了一件事:在白板上把嵌入空间画出来,把几个测试词的"大概位置"标在上面,然后给团队做了一次15分钟的技术分享。
他说当他能在白板上画出来,能用中文解释给同事听的时候,他才感觉"真的懂了"。
理解嵌入的本质,不是要你掌握所有的数学细节,而是要你建立一个清晰的"心智模型":
嵌入是一种把语义"编码"进数字的方式,使得语义相似的内容,在多维数字空间里的位置也相近。
这个心智模型一旦建立,你就能:
- 直觉地判断为什么某个检索结果不好(嵌入模型没有捕捉到你的领域语义)
- 知道为什么分块策略影响检索效果(太短的块嵌入缺乏上下文,太长的块嵌入语义稀释)
- 理解为什么不同语言能在同一空间检索(多语言嵌入让语义对齐跨越了语言边界)
- 判断什么时候应该换更好的嵌入模型(业务领域和通用模型的训练分布差距太大)
从理解Embedding这件事上,我们能得到一个更通用的启示:AI系统的优化,永远从"理解这个组件在做什么"开始。 不理解原理就调参,是在黑盒里摸索;理解了原理,才能做有依据的工程决策。
十九、嵌入技术的演进方向
19.1 稀疏-密集混合检索(Hybrid Search)
纯向量(密集)检索的弱点:对关键词精确匹配不敏感。比如用户搜索"BM25算法",向量检索可能返回关于"文本检索算法"的通用文章,而不是专门讲BM25的内容。
混合检索方案:同时用关键词(BM25)和向量(嵌入)检索,合并排序:
@Service
public class HybridSearchService {
private final VectorStore vectorStore;
private final ElasticsearchClient esClient; // 用于BM25
public List<Document> hybridSearch(String query, int topK) {
// 向量检索(语义相关性)
List<Document> semanticResults = vectorStore.similaritySearch(
SearchRequest.query(query).withTopK(topK * 2)
);
// 关键词检索(精确匹配)
List<Document> keywordResults = esClient.search(query, topK * 2);
// RRF(Reciprocal Rank Fusion)合并排序
Map<String, Double> fusedScores = new HashMap<>();
int k = 60; // RRF超参数,通常用60
for (int i = 0; i < semanticResults.size(); i++) {
String docId = semanticResults.get(i).getId();
fusedScores.merge(docId, 1.0 / (k + i + 1), Double::sum);
}
for (int i = 0; i < keywordResults.size(); i++) {
String docId = keywordResults.get(i).getId();
fusedScores.merge(docId, 1.0 / (k + i + 1), Double::sum);
}
// 按融合分数排序
return fusedScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(e -> findDocument(e.getKey(), semanticResults, keywordResults))
.collect(toList());
}
}在生产环境的测试数据显示,混合检索的精度通常比纯向量检索高10-15%,特别是对于含有专有名词、技术术语的查询。
19.2 晚期交互(Late Interaction):ColBERT
ColBERT是一种介于双编码器和交叉编码器之间的架构:
- 双编码器(嵌入):快,但独立编码查询和文档
- 交叉编码器(Reranker):准,但必须同时处理查询+文档对,无法预计算
- ColBERT:预计算文档的token级向量,查询时做token级交互,兼具速度和精度
在Ragatouille等库的支持下,Java工程师也可以方便地使用ColBERT进行高质量检索:
// ColBERT检索(伪代码,使用Ragatouille或类似库)
ColBERTRetriever colbert = new ColBERTRetriever("colbert-ir/colbertv2.0");
// 离线:预计算所有文档的token向量(可以存储和复用)
colbert.index(documentCollection, "my_index");
// 在线:查询时高效匹配
List<SearchResult> results = colbert.search("my_index", query, topK=10);
// MaxSim操作:查询的每个token找文档中最相似的token,取最大值求和
// 效果接近交叉编码器,速度接近双编码器