前面十七篇的检索全靠词汇匹配——查询和文档必须共享相同的 term 才能产生分数。搜索"如何提高程序运行速度",如果文档里写的是"性能优化",BM25 给出的分数是零。同义词表能覆盖一部分,但不可能穷举所有语义等价关系。

本篇引入向量检索:用 embedding 模型将文本编码为高维向量,在向量空间中用距离衡量语义相似度。先做精确扫描建立基线,下一篇再用 HNSW 加速。

从词汇匹配到语义匹配

BM25 的语义鸿沟

查询 相关文档包含 BM25 匹配
“如何提高程序运行速度” “性能优化最佳实践” 零分——没有共同 term
“machine learning” “机器学习入门” 零分——跨语言
“数据库挂了怎么办” “MySQL 故障恢复指南” 低分——只有"数据库"模糊相关

同义词表(第 16 篇)缓解了一部分问题,但维护成本高,且无法覆盖所有隐含的语义关系。

向量空间的思路

将文本映射为 D 维向量(D 通常是 512 或 1024)。映射由 embedding 模型完成——模型在大量文本对上训练,学习到"语义相近的文本,向量距离近"的映射。

1
2
3
"性能优化" → [0.23, -0.11, 0.45, ...]
"提高运行速度" → [0.21, -0.13, 0.44, ...] // 距离近
"数据库备份" → [-0.31, 0.55, -0.02, ...] // 距离远

查询时,将查询文本编码为向量,找到最近的文档向量,就是语义最相关的文档。

Embedding 模型

Qwen3-Embedding-0.6B

本系列使用 Qwen3-Embedding-0.6B:

属性
参数量 0.6B(6亿)
输出维度 模型配置决定,通常 512
支持语言 中文、英文及多语言
最大输入长度 8192 token
运行环境 GPU 推荐,CPU 可行但慢
许可 开源,可本地部署

选择 0.6B 而非更大模型的原因:教学场景优先考虑可部署性。0.6B 模型在消费级 GPU(8GB 显存)或纯 CPU 上都能运行。

查询与文档的编码差异

多数 embedding 模型对查询和文档使用不同的处理方式:

  • 文档编码:直接编码原文
  • 查询编码:加 instruction prefix
1
2
3
4
5
# 文档
encode("Apache Lucene 是一个开源搜索库")

# 查询
encode("Represent this query for retrieving relevant documents: 什么是 Lucene")

Qwen3-Embedding 的具体 instruction format 以模型发布文档为准。如果不加 instruction,查询向量和文档向量在空间中的分布可能不对齐——同一段文字作为查询和作为文档编码出的向量不一定相近。

封装为 HTTP 服务

将模型推理与搜索引擎解耦。模型服务独立运行,搜索引擎通过 HTTP 调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
record EmbedRequest(List<String> texts, String type) {}
record EmbedResponse(List<float[]> vectors, int dimension) {}

class EmbeddingClient {
private final String endpoint;
private final HttpClient client = HttpClient.newHttpClient();

float[][] embed(List<String> texts, String type) throws IOException {
String json = toJson(new EmbedRequest(texts, type));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(endpoint + "/embed"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();

HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
EmbedResponse resp = parseResponse(response.body());
return resp.vectors().toArray(float[][]::new);
}
}

type 参数区分 “query” 和 “document”——服务端据此决定是否加 instruction prefix。

文档切块

为什么需要切块

Embedding 模型有输入长度限制。即使模型支持 8192 token,长文档编码为单个向量后,向量需要"压缩"整篇文档的语义——大量细节被平均掉。

更根本的问题:一篇长文档可能涵盖多个主题。将它编码为单个向量,这个向量代表的是所有主题的"平均"语义,对任何单个主题的匹配度都不高。

切块后,每个 chunk 对应一个向量,表示一段内聚的语义片段。

按段落切块 + 长度上限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
record Chunk(String docId, int chunkIndex, String text, int startOffset) {}

List<Chunk> chunkDocument(SearchDocument doc, int maxChars) {
String content = doc.content();
String[] paragraphs = content.split("\n\n+");
List<Chunk> chunks = new ArrayList<>();
StringBuilder current = new StringBuilder();
int chunkIdx = 0;
int offset = 0;

for (String para : paragraphs) {
if (current.length() + para.length() > maxChars && !current.isEmpty()) {
chunks.add(new Chunk(doc.id(), chunkIdx++,
current.toString().trim(), offset));
offset += current.length();
current = new StringBuilder();
}
if (!current.isEmpty()) current.append("\n\n");
current.append(para);
}
if (!current.isEmpty()) {
chunks.add(new Chunk(doc.id(), chunkIdx,
current.toString().trim(), offset));
}
return chunks;
}

maxChars 设为 1500-2000 字符(约 500-800 中文 token)。段落自然边界优先;段落超长时在句号处截断。

切块的元数据

每个 chunk 保留:

字段 用途
docId 关联回原始文档
chunkIndex chunk 在文档中的序号
text chunk 的原始文本
startOffset 在原文中的字符偏移,用于定位和高亮

索引时将 docId 存为 StringField,查询时用于 chunk → document 聚合。

向量归一化与相似度

余弦相似度

衡量两个向量方向的相似程度(忽略长度):

1
cos(a, b) = (a · b) / (|a| × |b|)

值域 [-1, 1]。1 表示方向完全相同,0 表示正交(无关),-1 表示完全相反。

L2 归一化

如果向量预先归一化为单位长度(|v| = 1),余弦相似度退化为点积:

1
cos(a, b) = a · b    (当 |a| = |b| = 1)

Lucene 的向量搜索使用 DOT_PRODUCT 相似度函数,要求向量预先 L2 归一化。

1
2
3
4
5
6
7
8
9
10
11
float[] normalize(float[] vec) {
double norm = 0;
for (float v : vec) norm += (double) v * v;
norm = Math.sqrt(norm);
if (norm == 0) return vec;
float[] result = new float[vec.length];
for (int i = 0; i < vec.length; i++) {
result[i] = (float) (vec[i] / norm);
}
return result;
}

归一化后,点积计算只需一次乘加循环——比每次都算余弦(两次范数 + 一次点积)更快。

验证归一化

1
2
3
4
5
6
7
8
void verifyNormalization(float[] vec) {
double norm = 0;
for (float v : vec) norm += (double) v * v;
norm = Math.sqrt(norm);
if (Math.abs(norm - 1.0) > 1e-5) {
throw new IllegalStateException("向量未归一化: norm=" + norm);
}
}

模型输出的向量不一定已经归一化——必须在入库前显式检查和归一化。

精确向量扫描

实现

在引入 ANN 之前,用暴力扫描建立基线。对每个查询向量,遍历所有文档向量,取 Top-K:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
record ScoredChunk(String docId, int chunkIndex, float score) {}

List<ScoredChunk> exactKnn(float[] queryVec, float[][] docVecs,
List<Chunk> chunks, int topK) {
PriorityQueue<ScoredChunk> heap = new PriorityQueue<>(
Comparator.comparingDouble(ScoredChunk::score));

for (int i = 0; i < chunks.size(); i++) {
float score = dotProduct(queryVec, docVecs[i]);
heap.offer(new ScoredChunk(
chunks.get(i).docId(), chunks.get(i).chunkIndex(), score));
if (heap.size() > topK) heap.poll();
}

return heap.stream()
.sorted(Comparator.comparingDouble(ScoredChunk::score).reversed())
.toList();
}

float dotProduct(float[] a, float[] b) {
float sum = 0;
for (int i = 0; i < a.length; i++) {
sum += a[i] * b[i];
}
return sum;
}

复杂度与适用范围

精确扫描是 O(N × D):N 个向量,每个 D 维点积。

向量数 维度 预估延迟
1,000 512 < 1 ms
10,000 512 ~5 ms
100,000 512 ~50 ms
1,000,000 512 ~500 ms

教学语料通常在 1 万 chunk 以内,精确扫描完全可行。但为了学习 ANN 的原理和 Lucene 的集成方式,下一篇仍然要引入 HNSW。

Chunk 到 Document 聚合

向量搜索返回的是 chunk 级别的结果,但搜索 API 返回的是 document。需要聚合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
List<DocumentResult> aggregateChunks(List<ScoredChunk> chunks, int topDocs) {
Map<String, List<ScoredChunk>> byDoc = chunks.stream()
.collect(Collectors.groupingBy(ScoredChunk::docId));

return byDoc.entrySet().stream()
.map(entry -> {
String docId = entry.getKey();
double maxScore = entry.getValue().stream()
.mapToDouble(ScoredChunk::score).max().orElse(0);
ScoredChunk bestChunk = entry.getValue().stream()
.max(Comparator.comparingDouble(ScoredChunk::score))
.orElseThrow();
return new DocumentResult(docId, maxScore, bestChunk);
})
.sorted(Comparator.comparingDouble(DocumentResult::score).reversed())
.limit(topDocs)
.toList();
}

聚合策略用 max score——文档中只要有一段高度相关就够了。sum score 会偏好长文档(chunk 多 → 总分高),在教学场景中不合适。

长文本截断处理

当 chunk 超过模型最大输入长度时,有两种选择:

  1. 截断:只编码前 N 个 token——丢失尾部信息
  2. 拒绝:报错并要求调整切块参数——安全但需要调用者处理

教学场景用截断 + 警告:

1
2
3
4
5
6
7
float[] embedChunk(String text, int maxTokens) {
if (estimateTokens(text) > maxTokens) {
text = truncateToTokens(text, maxTokens);
log.warn("Chunk 超长,已截断到 {} token", maxTokens);
}
return embeddingClient.embed(List.of(text), "document")[0];
}

切块阶段已经控制了长度,这里是防御性截断——确保模型服务不会因为超长输入崩溃。

验证

场景 操作 预期
向量维度 检查模型输出 所有向量维度一致(如 512)
归一化 检查每个向量的 L2 范数 |v| ≈ 1.0(误差 < 1e-5)
语义相似 编码 “性能优化” 和 “提高运行速度” 点积 > 0.7
语义不同 编码 “性能优化” 和 “数据库备份” 点积 < 0.3
精确扫描 对 10 个查询做精确 Top-10 结果作为 ANN 的对照基准
切块 一篇 5000 字文档 切为 3-4 个 chunk,段落不被截断
chunk→doc 同一文档 3 个 chunk 都在 Top-20 聚合后该文档只出现一次

当前局限

  • 模型推理在 CPU 上很慢——1000 个 chunk 的编码可能需要几分钟
  • 向量全部在内存中——10 万个 512 维向量占 200 MB
  • 精确扫描不可扩展——下一篇引入 HNSW
  • 切块策略是启发式的——切分质量直接影响检索质量
  • 还没有与 BM25 融合——单独的向量检索在精确标识符查询上不如 BM25

练习

  1. 对 10 篇 fixture 文档做切块,检查每个 chunk 的字符数和段落完整性
  2. 用 embedding 模型编码所有 chunk,验证向量维度和归一化
  3. 手工构造 3 对语义相似和 3 对语义不同的文本,比较向量点积
  4. 对 5 个查询做精确 Top-10 搜索,记录结果作为 ANN 对照基准
  5. 比较 max score 和 sum score 两种聚合策略在 5 个查询上的排序差异

延伸阅读

  • Qwen3-Embedding 模型文档
  • Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks (Reimers & Gurevych, 2019)
  • Apache Lucene: KnnFloatVectorField Javadoc