第1833篇:高维向量的降维技术——PCA、UMAP在Embedding可视化中的应用
第1833篇:高维向量的降维技术——PCA、UMAP在Embedding可视化中的应用
做 RAG 项目的时候,遇到过这样一个困惑期:明明 Recall@10 看着挺好,但实际用户反馈检索结果不对味。那时候我特别想直接"看到"这些向量长什么样,在空间里是怎么分布的。
但向量动辄 768、1536 维,怎么看?
这就是降维技术的价值所在。把高维向量压缩到 2D 或 3D,可视化之后,很多原来只能靠猜的问题,肉眼就能看出来。
当然,降维不只是用来可视化的。工程上,降维还能用于:压缩存储、加速检索、去除噪声维度、跨模型空间对齐。今天从可视化切入,但会顺带讲清楚降维在 Embedding 工程上的更多应用。
一、为什么高维向量需要降维
1.1 维度灾难的直觉理解
高维空间有一个反直觉的性质:随着维度 D 增加,空间里的点会越来越"均匀"地分布,彼此之间的距离趋于相等。
用数学来说:对于随机分布在 D 维单位球上的两点,它们的 L2 距离期望是 ,随着 D 增大,相对距离差异(max_dist - min_dist)/ min_dist 趋近于 0。
D=2: 距离范围 [0, 1.41],相对差异大
D=10: 距离集中在 [1.2, 1.5],差异缩小
D=100: 距离集中在 [1.38, 1.45],几乎一样
D=768: 距离集中在 [1.39, 1.41],近乎恒定这就是为什么直接看高维向量的距离数字没什么感觉——因为它们真的差不多。
1.2 Embedding 维度的有效信息量
模型输出的 768 维向量,不代表有 768 个独立的信息维度。实际上,由于语言本身的规律性,这些维度之间存在高度相关性,有效信息量(秩)可能只有几十到几百个独立方向。
这是 PCA 降维有效的理论基础。
二、PCA——线性降维的标准方法
2.1 原理
PCA(Principal Component Analysis,主成分分析)的思路:找到数据方差最大的方向,作为第一主成分;在垂直于第一主成分的空间里,再找方差最大的方向,作为第二主成分;以此类推。
最终得到的主成分是原始特征的线性组合,互相正交。
数学上,PCA 就是对数据矩阵的协方差矩阵做特征值分解,取前 K 个最大特征值对应的特征向量。
2.2 Java实现(用于理解原理)
import org.apache.commons.math3.linear.*;
import java.util.*;
/**
* PCA降维实现
* 依赖:commons-math3
*/
public class PCAReducer {
private RealMatrix components; // 主成分矩阵 [k, d]
private double[] mean; // 各维度均值
private double[] explainedVarianceRatio; // 各主成分解释的方差比例
/**
* 拟合PCA变换矩阵
*
* @param data 数据矩阵,shape [n, d],n个样本,每个d维
* @param k 目标维度
*/
public void fit(double[][] data, int k) {
int n = data.length;
int d = data[0].length;
// Step 1: 中心化(减去各维度均值)
mean = new double[d];
for (double[] row : data) {
for (int j = 0; j < d; j++) mean[j] += row[j];
}
for (int j = 0; j < d; j++) mean[j] /= n;
double[][] centered = new double[n][d];
for (int i = 0; i < n; i++) {
for (int j = 0; j < d; j++) {
centered[i][j] = data[i][j] - mean[j];
}
}
// Step 2: 计算协方差矩阵 [d, d]
// cov = X^T * X / (n-1)
RealMatrix centeredMatrix = MatrixUtils.createRealMatrix(centered);
RealMatrix covMatrix = centeredMatrix.transpose()
.multiply(centeredMatrix)
.scalarMultiply(1.0 / (n - 1));
// Step 3: 特征值分解
EigenDecomposition eigen = new EigenDecomposition(covMatrix);
double[] eigenValues = eigen.getRealEigenvalues();
RealMatrix eigenVectors = eigen.getV();
// Step 4: 按特征值降序排列,取前 k 个
Integer[] idx = new Integer[d];
for (int i = 0; i < d; i++) idx[i] = i;
Arrays.sort(idx, (a, b) -> Double.compare(eigenValues[b], eigenValues[a]));
double totalVariance = Arrays.stream(eigenValues).sum();
explainedVarianceRatio = new double[k];
double[][] componentData = new double[k][d];
for (int i = 0; i < k; i++) {
explainedVarianceRatio[i] = eigenValues[idx[i]] / totalVariance;
double[] eigVec = eigenVectors.getColumn(idx[i]);
componentData[i] = eigVec;
}
components = MatrixUtils.createRealMatrix(componentData);
System.out.printf("PCA拟合完成,前%d个主成分解释方差: %.4f%n",
k, Arrays.stream(explainedVarianceRatio).sum());
}
/**
* 将高维数据投影到低维空间
*
* @param data 输入数据 [n, d]
* @return 降维后的数据 [n, k]
*/
public double[][] transform(double[][] data) {
int n = data.length;
int d = data[0].length;
// 中心化
double[][] centered = new double[n][d];
for (int i = 0; i < n; i++) {
for (int j = 0; j < d; j++) {
centered[i][j] = data[i][j] - mean[j];
}
}
// 投影:X_centered * W^T,W 是 [k, d] 的主成分矩阵
RealMatrix centeredMatrix = MatrixUtils.createRealMatrix(centered);
RealMatrix projected = centeredMatrix.multiply(components.transpose());
return projected.getData();
}
/**
* 分析需要多少维度才能保留足够的方差
*/
public static void analyzeVarianceRetained(double[][] data, int maxK) {
PCAReducer pca = new PCAReducer();
pca.fit(data, Math.min(maxK, data[0].length));
double cumulative = 0;
System.out.println("主成分数量 → 累计解释方差比例:");
for (int i = 0; i < pca.explainedVarianceRatio.length; i++) {
cumulative += pca.explainedVarianceRatio[i];
if (i < 20 || (i + 1) % 10 == 0) {
System.out.printf(" PC%3d: %.4f%n", i + 1, cumulative);
}
if (cumulative >= 0.95) {
System.out.printf(">>> 保留 95%% 方差需要 %d 个主成分%n", i + 1);
break;
}
}
}
}2.3 PCA的局限性
PCA 是线性变换,对线性结构很有效,但对非线性流形数据(比如文本 Embedding 中的语义聚类)效果有限。
这就是 UMAP 出现的原因。
三、UMAP——非线性降维的现代方案
3.1 UMAP vs t-SNE
在 UMAP 之前,t-SNE 是可视化高维数据的主流方案,但 t-SNE 有两个问题:
- 慢:O(N² log N),万级别数据要跑几分钟
- 不保持全局结构:t-SNE 擅长展示局部聚类,但全局距离意义不大,不能用于降维后再做检索
UMAP 解决了这两个问题:
- 时间复杂度 O(N^1.14),比 t-SNE 快很多
- 同时保持局部和全局结构
- 支持 transform(即先 fit,再对新数据 transform),t-SNE 不支持
3.2 UMAP 原理概述
UMAP 的理论基础是黎曼几何和代数拓扑,实现上分两步:
第一步:在高维空间构建模糊拓扑表示
- 对每个点,找 n_neighbors 个近邻
- 以该点为中心,构建一个"模糊"的近邻图,每条边的权重代表连接的概率
- 权重按局部密度归一化(这是保持局部结构的关键)
第二步:在低维空间优化一个等价的模糊拓扑
- 随机初始化低维坐标
- 用梯度下降最小化高维和低维模糊图之间的交叉熵
- 用负采样加速优化
3.3 在 Java 项目中集成 UMAP
Java 原生的 UMAP 实现不多,工程上一般有三种方案:
方案一:通过 Python 子进程调用(最简单)
import java.io.*;
import java.nio.file.*;
public class UMAPBridge {
private final String pythonPath;
private final String scriptPath;
public UMAPBridge(String pythonPath) {
this.pythonPath = pythonPath;
this.scriptPath = createPythonScript();
}
private String createPythonScript() {
String script = """
import sys
import json
import numpy as np
import umap
# 从stdin读取JSON格式的向量数组
data = json.loads(sys.stdin.read())
vectors = np.array(data['vectors'], dtype=np.float32)
n_components = data.get('n_components', 2)
n_neighbors = data.get('n_neighbors', 15)
min_dist = data.get('min_dist', 0.1)
reducer = umap.UMAP(
n_components=n_components,
n_neighbors=n_neighbors,
min_dist=min_dist,
random_state=42
)
embedding = reducer.fit_transform(vectors)
# 输出JSON格式的结果
print(json.dumps(embedding.tolist()))
""";
try {
Path scriptFile = Files.createTempFile("umap_script_", ".py");
Files.writeString(scriptFile, script);
return scriptFile.toString();
} catch (IOException e) {
throw new RuntimeException("创建UMAP脚本失败", e);
}
}
/**
* 对向量列表执行UMAP降维
*
* @param vectors 输入向量列表
* @param nComponents 目标维度(2或3)
* @param nNeighbors 近邻数量(影响局部/全局结构平衡)
* @param minDist 最小距离(越小点越聚集)
*/
public float[][] reduce(List<float[]> vectors, int nComponents,
int nNeighbors, float minDist) throws Exception {
// 构建输入JSON
var inputMap = Map.of(
"vectors", vectors.stream()
.map(v -> {
var list = new ArrayList<Float>();
for (float f : v) list.add(f);
return list;
})
.collect(Collectors.toList()),
"n_components", nComponents,
"n_neighbors", nNeighbors,
"min_dist", minDist
);
String inputJson = new com.fasterxml.jackson.databind.ObjectMapper()
.writeValueAsString(inputMap);
// 调用Python脚本
Process process = new ProcessBuilder(pythonPath, scriptPath)
.redirectErrorStream(false)
.start();
// 写入输入
try (var writer = new BufferedWriter(
new OutputStreamWriter(process.getOutputStream()))) {
writer.write(inputJson);
}
// 读取输出
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
if (exitCode != 0) {
String error = new String(process.getErrorStream().readAllBytes());
throw new RuntimeException("UMAP执行失败: " + error);
}
// 解析输出
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var resultList = mapper.readValue(output,
mapper.getTypeFactory().constructCollectionType(
List.class,
mapper.getTypeFactory().constructCollectionType(List.class, Double.class)
));
float[][] result = new float[resultList.size()][nComponents];
for (int i = 0; i < resultList.size(); i++) {
var row = (List<Double>) resultList.get(i);
for (int j = 0; j < nComponents; j++) {
result[i][j] = row.get(j).floatValue();
}
}
return result;
}
}方案二:使用 smile-nlp Java库(有UMAP实现)
// Maven 依赖
// <dependency>
// <groupId>com.github.haifengl</groupId>
// <artifactId>smile-nlp</artifactId>
// <version>3.0.1</version>
// </dependency>
import smile.manifold.UMAP;
public class SmileUMAPExample {
public static double[][] reduceWithSmile(double[][] data,
int nComponents,
int nNeighbors) {
// Smile的UMAP接口
UMAP umap = UMAP.of(data,
nComponents, // 目标维度
nNeighbors, // 近邻数
200, // epochs
0.1 // minDist
);
return umap.coordinates;
}
}方案三:先用 Python 离线批量降维,结果持久化
对于可视化场景,通常不需要实时降维,可以离线跑完存到 JSON 或数据库:
# 离线降维脚本(Python)
import umap
import numpy as np
import json
def offline_reduce(embeddings_path: str, output_path: str,
n_components: int = 2):
"""
离线批量降维,结果保存为JSON
"""
data = np.load(embeddings_path) # shape [N, D]
reducer = umap.UMAP(
n_components=n_components,
n_neighbors=15,
min_dist=0.1,
metric='cosine', # 与向量库保持一致
random_state=42,
verbose=True
)
print(f"开始UMAP降维: {data.shape} -> {n_components}D")
embedding_2d = reducer.fit_transform(data)
# 保存结果
result = {
"coordinates": embedding_2d.tolist(),
"original_dim": int(data.shape[1]),
"reduced_dim": n_components
}
with open(output_path, 'w') as f:
json.dump(result, f)
print(f"降维完成,保存到: {output_path}")四、可视化诊断——用降维发现 Embedding 问题
这才是我最想分享的部分。降维可视化是诊断 Embedding 质量的利器,能发现很多靠指标看不出来的问题。
4.1 诊断1:聚类是否符合预期
/**
* 生成带标签的可视化数据
* 输出格式供前端或 Python matplotlib 使用
*/
public class EmbeddingVisualizer {
public record VisualizationPoint(
float x, float y, // 2D坐标
String label, // 分类标签
String text, // 原始文本(hover显示)
String color // 颜色(根据标签)
) {}
public List<VisualizationPoint> prepare(
List<float[]> embeddings,
List<String> labels,
List<String> texts,
UMAPBridge umapBridge) throws Exception {
float[][] coords = umapBridge.reduce(embeddings, 2, 15, 0.1f);
// 生成颜色映射
Set<String> uniqueLabels = new LinkedHashSet<>(labels);
String[] colors = {"#e41a1c", "#377eb8", "#4daf4a", "#984ea3",
"#ff7f00", "#a65628", "#f781bf", "#999999"};
Map<String, String> labelColors = new HashMap<>();
int colorIdx = 0;
for (String label : uniqueLabels) {
labelColors.put(label, colors[colorIdx % colors.length]);
colorIdx++;
}
List<VisualizationPoint> points = new ArrayList<>();
for (int i = 0; i < embeddings.size(); i++) {
points.add(new VisualizationPoint(
coords[i][0], coords[i][1],
labels.get(i),
texts.get(i),
labelColors.getOrDefault(labels.get(i), "#999999")
));
}
return points;
}
/**
* 输出为 ECharts/D3 可用的 JSON 格式
*/
public String toEChartsJson(List<VisualizationPoint> points) {
// 按标签分组
Map<String, List<VisualizationPoint>> grouped = points.stream()
.collect(Collectors.groupingBy(VisualizationPoint::label));
StringBuilder sb = new StringBuilder();
sb.append("{\"series\": [");
boolean firstSeries = true;
for (var entry : grouped.entrySet()) {
if (!firstSeries) sb.append(",");
firstSeries = false;
sb.append("{\"name\":\"").append(entry.getKey()).append("\",");
sb.append("\"type\":\"scatter\",\"data\":[");
boolean firstPoint = true;
for (var p : entry.getValue()) {
if (!firstPoint) sb.append(",");
firstPoint = false;
sb.append(String.format("[%.4f,%.4f,\"%s\"]",
p.x(), p.y(), p.text().replace("\"", "\\\"")));
}
sb.append("]}");
}
sb.append("]}");
return sb.toString();
}
}4.2 典型问题及诊断方法
问题1:明显的"模式坍塌"
表现:所有点聚成一团,没有明显的类别分离。
原因可能是:
- Embedding 模型没有 fine-tune,对领域文本泛化差
- 数据预处理有问题,文本被截断过短
- 使用了错误的模型(如用翻译模型做语义检索)
问题2:某些类别散落在多处
表现:同一标签的点散布在图的多个不同区域,没有凝聚在一起。
原因可能是:
- 该类别语义本身就多元(正常现象,如"金融"既包含股票、保险、银行)
- 需要进一步细分子类别
问题3:边界模糊,类别间混杂
表现:相邻类别之间大量重叠,没有清晰分界。
这通常是硬边界检索任务的噩梦,需要:
- 换更适合领域的 Embedding 模型
- 增加监督信号 fine-tune
- 引入 Reranker 做二次排序
4.3 用 Silhouette Score 量化聚类质量
光靠肉眼看还不够客观,Silhouette Score 可以定量评估 Embedding 的聚类质量:
/**
* 计算轮廓系数(Silhouette Score)
* 值域 [-1, 1],越接近1说明聚类越清晰
*/
public class SilhouetteEvaluator {
/**
* @param embeddings 向量列表
* @param labels 每个向量的类别标签
* @return 平均轮廓系数
*/
public static double compute(List<float[]> embeddings, List<String> labels) {
int n = embeddings.size();
if (n < 2) return 0;
// 预计算所有距离(小数据集可以这样做)
float[][] distances = new float[n][n];
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
float d = cosineDist(embeddings.get(i), embeddings.get(j));
distances[i][j] = d;
distances[j][i] = d;
}
}
double totalScore = 0;
for (int i = 0; i < n; i++) {
String labelI = labels.get(i);
// a(i):i 到同类别其他点的平均距离
double intraSum = 0;
int intraCount = 0;
for (int j = 0; j < n; j++) {
if (j != i && labels.get(j).equals(labelI)) {
intraSum += distances[i][j];
intraCount++;
}
}
double a = intraCount == 0 ? 0 : intraSum / intraCount;
// b(i):i 到最近的其他类别所有点的平均距离(取最小)
Map<String, Double> clusterDists = new HashMap<>();
Map<String, Integer> clusterCounts = new HashMap<>();
for (int j = 0; j < n; j++) {
if (!labels.get(j).equals(labelI)) {
clusterDists.merge(labels.get(j),
(double) distances[i][j], Double::sum);
clusterCounts.merge(labels.get(j), 1, Integer::sum);
}
}
double b = Double.MAX_VALUE;
for (var entry : clusterDists.entrySet()) {
double avg = entry.getValue() / clusterCounts.get(entry.getKey());
b = Math.min(b, avg);
}
if (b == Double.MAX_VALUE) b = 0;
// s(i) = (b - a) / max(a, b)
double s = (Math.max(a, b) == 0) ? 0 : (b - a) / Math.max(a, b);
totalScore += s;
}
return totalScore / n;
}
private static float cosineDist(float[] a, float[] b) {
float dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA == 0 || normB == 0) return 1.0f;
return 1.0f - dot / (float)(Math.sqrt(normA) * Math.sqrt(normB));
}
}五、降维用于工程压缩(不只是可视化)
5.1 用PCA压缩Embedding维度
从 768 维压缩到 256 维可以减少 3x 存储,查询速度提升接近 3x,但召回率会有一定损失。
/**
* 工程级PCA压缩流水线
* 场景:离线计算PCA变换矩阵,在线对新向量做低延迟降维
*/
public class EmbeddingCompressor {
private final PCAReducer pca;
private final int targetDim;
public EmbeddingCompressor(int targetDim) {
this.pca = new PCAReducer();
this.targetDim = targetDim;
}
/**
* 用大批量数据拟合PCA变换矩阵
* 注意:只需要做一次,结果序列化保存
*/
public void fitFromDatabase(List<float[]> sampleVectors) {
System.out.printf("开始拟合PCA,样本量: %d,原始维度: %d,目标维度: %d%n",
sampleVectors.size(), sampleVectors.get(0).length, targetDim);
double[][] data = sampleVectors.stream()
.map(v -> {
double[] d = new double[v.length];
for (int i = 0; i < v.length; i++) d[i] = v[i];
return d;
})
.toArray(double[][]::new);
pca.fit(data, targetDim);
// 分析方差保留情况
System.out.printf("维度压缩比: %.1fx%n",
(double) sampleVectors.get(0).length / targetDim);
}
/**
* 对单个向量做在线降维(低延迟)
*/
public float[] compress(float[] vector) {
double[][] input = new double[1][vector.length];
for (int i = 0; i < vector.length; i++) input[0][i] = vector[i];
double[][] output = pca.transform(input);
float[] result = new float[targetDim];
for (int i = 0; i < targetDim; i++) result[i] = (float) output[0][i];
return result;
}
/**
* 评估压缩对检索召回率的影响
* 建议在上线前跑一次
*/
public void evaluateRecallImpact(List<float[]> testQueries,
List<List<float[]>> groundTruthCandidates,
int k) {
int totalHits = 0;
int totalQueries = testQueries.size();
for (int q = 0; q < totalQueries; q++) {
float[] compressedQuery = compress(testQueries.get(q));
List<float[]> candidates = groundTruthCandidates.get(q);
// 在压缩后的空间里排序
List<float[]> compressedCandidates = candidates.stream()
.map(this::compress)
.collect(Collectors.toList());
// 找压缩空间里最近的k个(简化版,实际用向量库)
List<Integer> compressedTopK = findTopK(compressedQuery,
compressedCandidates, k);
List<Integer> originalTopK = findTopK(testQueries.get(q), candidates, k);
Set<Integer> originalSet = new HashSet<>(originalTopK);
for (int idx : compressedTopK) {
if (originalSet.contains(idx)) totalHits++;
}
}
double recall = (double) totalHits / (totalQueries * k);
System.out.printf("PCA压缩后Recall@%d: %.4f (原始维度->%d维)%n",
k, recall, targetDim);
}
private List<Integer> findTopK(float[] query, List<float[]> candidates, int k) {
var pq = new PriorityQueue<int[]>((a, b) ->
Float.compare(Float.intBitsToFloat(b[1]), Float.intBitsToFloat(a[1])));
for (int i = 0; i < candidates.size(); i++) {
float sim = cosineSim(query, candidates.get(i));
pq.offer(new int[]{i, Float.floatToIntBits(sim)});
if (pq.size() > k) pq.poll();
}
var result = new ArrayList<Integer>();
while (!pq.isEmpty()) result.add(pq.poll()[0]);
return result;
}
private float cosineSim(float[] a, float[] b) {
float dot = 0, na = 0, nb = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i];
}
if (na == 0 || nb == 0) return 0;
return dot / (float)(Math.sqrt(na) * Math.sqrt(nb));
}
}六、实战踩坑
坑1:UMAP 不确定性
UMAP 有随机性,同样的数据两次运行结果不同(虽然结构相似)。诊断用途时设置 random_state=42,比较不同批次的结论才有意义。
坑2:PCA 降维后要重新归一化
PCA 变换后的向量不再是归一化的(即使输入是归一化的)。如果降维后的向量要进向量库做余弦检索,记得重新归一化:
// PCA 降维后必须重新归一化!
float[] compressed = compressor.compress(original);
float[] normalizedCompressed = PCAReducer.normalize(compressed);
// 然后再入库坑3:用可视化结果做决策的边界
UMAP 可视化反映的是局部和全局的相对位置关系,但它有失真。不能从2D图上直接读出向量距离的绝对值,也不能认为"图上看起来很近"就意味着"检索一定会召回"。
可视化是帮助定性判断方向的,定量分析还是要靠 Recall@K、Silhouette Score 等指标。
七、总结
降维技术在 Embedding 工程中有两类核心用途:
- 可视化诊断:UMAP 更适合(非线性,保持全局结构),用于发现 Embedding 质量问题、类别分布异常
- 工程压缩:PCA 更适合(线性,快速,可增量);压缩后需要评估召回率损失,通常保留95%以上方差可以接受
工程建议:每次引入新的 Embedding 模型,先做一次 UMAP 可视化,对数据分布有直觉认知,能省掉大量试错成本。
