前面十七篇的检索全靠词汇匹配——查询和文档必须共享相同的 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 超过模型最大输入长度时,有两种选择:
- 截断:只编码前 N 个 token——丢失尾部信息
- 拒绝:报错并要求调整切块参数——安全但需要调用者处理
教学场景用截断 + 警告:
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
练习
- 对 10 篇 fixture 文档做切块,检查每个 chunk 的字符数和段落完整性
- 用 embedding 模型编码所有 chunk,验证向量维度和归一化
- 手工构造 3 对语义相似和 3 对语义不同的文本,比较向量点积
- 对 5 个查询做精确 Top-10 搜索,记录结果作为 ANN 对照基准
- 比较 max score 和 sum score 两种聚合策略在 5 个查询上的排序差异
延伸阅读
- Qwen3-Embedding 模型文档
- Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks (Reimers & Gurevych, 2019)
- Apache Lucene: KnnFloatVectorField Javadoc