单机索引有容量天花板。内存装不下全部 HNSW 向量时,要么做量化压缩(第 22 篇),要么把索引拆到多个进程上。量化只能压缩 4 倍,而索引拆分没有理论上限。
本篇把索引拆成多个分片,用 scatter/gather 模式分发查询,在 coordinator 上合并全局 Top-K。重点处理分片内 BM25 统计偏差和跨分片评分可比性。
为什么要分片
单机瓶颈
| 文档数 |
倒排索引 |
HNSW(int8) |
Stored fields |
合计 |
| 10K |
50 MB |
23 MB |
20 MB |
~93 MB |
| 100K |
500 MB |
230 MB |
200 MB |
~930 MB |
| 1M |
5 GB |
2.3 GB |
2 GB |
~9.3 GB |
| 10M |
50 GB |
23 GB |
20 GB |
~93 GB |
10M 文档需要 93 GB 内存——超出大多数单机配置。即使装得下,查询延迟也随文档数增长。
分片的收益
将 10M 文档拆成 10 个分片,每个分片 1M 文档:
- 每个分片只需 ~9.3 GB 内存
- 查询延迟接近 1M 文档的水平(各分片并行执行)
- 单个分片故障不影响其他分片
固定路由
文档到分片的映射
1 2 3 4 5 6 7 8 9 10 11 12
| class ShardRouter { private final int numShards;
ShardRouter(int numShards) { this.numShards = numShards; }
int route(String docId) { int hash = docId.hashCode() & 0x7FFFFFFF; return hash % numShards; } }
|
hashCode() & 0x7FFFFFFF 确保哈希值非负——Java 的 hashCode() 可能返回负数。
固定路由的性质
- 确定性:同一 docId 始终路由到同一分片
- 均匀性:好的哈希函数使各分片文档数接近
- 无状态:不需要维护映射表,任何节点都能计算路由
写入时:router.route(docId) → 发送到对应分片。
更新/删除时:router.route(docId) → 同一分片,不需要广播。
Scatter/Gather
架构
1 2 3 4 5 6 7
| Client │ ▼ Coordinator ╱ │ ╲ ▼ ▼ ▼ Shard0 Shard1 Shard2
|
Coordinator 不持有索引数据,只负责分发查询和合并结果。
Coordinator 实现
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 27 28 29
| class SearchCoordinator { private final List<ShardClient> shards;
SearchCoordinator(List<ShardClient> shards) { this.shards = shards; }
SearchResult search(String queryText, int topK) { List<CompletableFuture<ShardResult>> futures = shards.stream() .map(shard -> CompletableFuture.supplyAsync( () -> shard.search(queryText, topK))) .toList();
List<ShardResult> results = futures.stream() .map(f -> { try { return f.get(200, TimeUnit.MILLISECONDS); } catch (Exception e) { return ShardResult.EMPTY; } }) .toList();
return mergeResults(results, topK); } }
|
ShardClient
每个分片对外提供搜索接口。单机多分片用方法调用,跨机器用 HTTP/gRPC:
1 2 3 4 5 6 7 8 9 10 11 12
| interface ShardClient { ShardResult search(String queryText, int topK); }
record ShardResult( int shardId, List<DocScore> bm25Hits, List<DocScore> vectorHits, boolean partial ) { static final ShardResult EMPTY = new ShardResult(-1, List.of(), List.of(), true); }
|
每个分片分别返回 BM25 和向量召回的结果——融合在 coordinator 做。
全局 Top-K 合并
K-way Merge
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| SearchResult mergeResults(List<ShardResult> shardResults, int topK) { List<DocScore> allBm25 = new ArrayList<>(); for (ShardResult sr : shardResults) { allBm25.addAll(sr.bm25Hits()); }
List<DocScore> allVector = new ArrayList<>(); for (ShardResult sr : shardResults) { allVector.addAll(sr.vectorHits()); }
List<DocScore> globalBm25 = topK(allBm25, topK); List<DocScore> globalVector = topK(allVector, topK);
List<DocScore> fused = rrfFusion(globalBm25, globalVector, 60);
List<DocScore> reranked = rerank(fused, topK);
boolean partial = shardResults.stream().anyMatch(ShardResult::partial);
return new SearchResult(reranked, partial); }
List<DocScore> topK(List<DocScore> docs, int k) { PriorityQueue<DocScore> heap = new PriorityQueue<>( Comparator.comparingDouble(DocScore::score)); for (DocScore doc : docs) { heap.offer(doc); if (heap.size() > k) heap.poll(); } return heap.stream() .sorted(Comparator.comparingDouble(DocScore::score).reversed()) .toList(); }
|
稳定排序
多个文档 score 相同时,排序结果不确定。用 docId 做 tiebreaker:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| List<DocScore> topKStable(List<DocScore> docs, int k) { PriorityQueue<DocScore> heap = new PriorityQueue<>((a, b) -> { int cmp = Double.compare(a.score(), b.score()); if (cmp != 0) return cmp; return a.docId().compareTo(b.docId()); }); for (DocScore doc : docs) { heap.offer(doc); if (heap.size() > k) heap.poll(); } return heap.stream() .sorted(Comparator.comparingDouble(DocScore::score).reversed() .thenComparing(DocScore::docId)) .toList(); }
|
没有 tiebreaker 时,分页请求的结果可能不一致——翻到第二页时,第一页的文档排序变了,导致某些文档既不在第一页也不在第二页。
分片内统计偏差
IDF 偏差
BM25 的 IDF 公式:log((N - df + 0.5) / (df + 0.5))
N 是文档总数,df 是包含该 term 的文档数。分片内的 N 和 df 是局部值。
假设 3 个分片,term “搜索” 的分布:
|
分片 0 |
分片 1 |
分片 2 |
全局 |
| N |
10000 |
10000 |
10000 |
30000 |
| df |
5000 |
100 |
50 |
5150 |
| IDF |
0.69 |
4.60 |
5.30 |
1.76 |
分片 0 认为 “搜索” 很常见(IDF=0.69),分片 2 认为很罕见(IDF=5.30)。同一个 term 在不同分片中的权重差 7.7 倍——跨分片的 BM25 score 不可比。
实践中的处理
方案 1:忽略(默认选择)
如果文档按 docId 哈希路由,term 分布大致均匀,IDF 偏差很小。只有当文档分布严重倾斜时(如按语言分片、按时间分片)才需要处理。
方案 2:两阶段查询
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| SearchResult searchWithGlobalStats(String queryText, int topK) { List<CompletableFuture<TermStats>> statsFutures = shards.stream() .map(shard -> CompletableFuture.supplyAsync( () -> shard.getTermStats(queryText))) .toList();
TermStats globalStats = mergeTermStats( statsFutures.stream().map(CompletableFuture::join).toList());
List<CompletableFuture<ShardResult>> searchFutures = shards.stream() .map(shard -> CompletableFuture.supplyAsync( () -> shard.searchWithStats(queryText, topK, globalStats))) .toList();
return mergeResults( searchFutures.stream().map(CompletableFuture::join).toList(), topK); }
|
代价:多一次网络往返。Elasticsearch 的 dfs_query_then_fetch 就是这个方案。
向量搜索无此问题
余弦相似度和内积是绝对值——不依赖分片内统计。分片 0 的 0.85 和分片 1 的 0.82 可以直接比较。
分片上的完整搜索流程
每个分片的处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class ShardSearchService { private final IndexSearcher searcher; private final EmbeddingClient embeddingClient; private final int shardId;
ShardResult search(String queryText, int topK) { Query bm25Query = parseQuery(queryText); TopDocs bm25Docs = searcher.search(bm25Query, topK); List<DocScore> bm25Hits = toDocScores(bm25Docs);
float[] queryVec = embeddingClient.embed( List.of(queryText), "query")[0]; KnnFloatVectorQuery knnQuery = new KnnFloatVectorQuery( "embedding", queryVec, topK); TopDocs knnDocs = searcher.search(knnQuery, topK); List<DocScore> vectorHits = toDocScores(knnDocs);
return new ShardResult(shardId, bm25Hits, vectorHits, false); } }
|
融合和重排不在分片上做——coordinator 需要看到所有分片的结果才能正确融合。
单分片/多分片对照
验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| void compareShardingImpact(List<String> queries, int topK) { for (String query : queries) { SearchResult singleShard = singleShardSearch(query, topK);
SearchResult multiShard = coordinator.search(query, topK);
Set<String> singleIds = singleShard.docIds(); Set<String> multiIds = multiShard.docIds(); double overlap = intersection(singleIds, multiIds).size() / (double) topK;
System.out.printf("query='%s' overlap=%.2f\n", query, overlap); } }
|
预期:文档均匀分布时,overlap > 0.95——分片对结果影响很小。
当前局限
- 固定分片数——增减分片需要全量 rehash
- Coordinator 是单点——需要多个 coordinator 做负载均衡
- 分片间不共享 embedding 缓存——相同查询在每个分片都要编码一次
- 没有实现跨分片的全局统计——依赖文档均匀分布
练习
- 将索引拆成 3 个分片,验证文档分布是否均匀
- 实现 scatter/gather 搜索,测量多分片 vs 单分片的延迟差异
- 构造 term 分布不均的场景,观察 IDF 偏差对排序的影响
- 实现稳定排序,验证分页结果的一致性
- 模拟一个分片超时,验证部分失败的降级行为
延伸阅读
- Elasticsearch: Index Sharding and Routing
- Apache Lucene: ParallelLeafReader(单机多线程搜索)
- Jeff Dean, “Achieving Rapid Response Times in Large Online Services”