搜索系统的各阶段——查询解析、BM25 召回、向量召回、融合、重排——性能特征各不相同。不做测量就不知道瓶颈在哪里,不知道瓶颈就无法有效优化。
本篇为搜索请求建立端到端的性能剖面,用冷热缓存压测暴露真实延迟,用容量账本规划资源预算。
阶段计时
SearchTrace
一个贯穿请求生命周期的计时器,记录每个阶段的耗时:
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
| class SearchTrace { private final long requestStartNanos = System.nanoTime(); private final Map<String, Long> stageStartNanos = new LinkedHashMap<>(); private final Map<String, Long> stageDurationMicros = new LinkedHashMap<>();
void stageStart(String stage) { stageStartNanos.put(stage, System.nanoTime()); }
void stageEnd(String stage) { Long start = stageStartNanos.get(stage); if (start != null) { long durationMicros = (System.nanoTime() - start) / 1000; stageDurationMicros.put(stage, durationMicros); } }
long totalMicros() { return (System.nanoTime() - requestStartNanos) / 1000; }
Map<String, Long> stageDurations() { return Collections.unmodifiableMap(stageDurationMicros); } }
|
用 System.nanoTime() 计时,不用 System.currentTimeMillis()。后者的精度只有毫秒级,且受 NTP 时钟调整影响——可能出现负值差。
嵌入搜索流程
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 41
| SearchResult search(String queryText, int topK) { SearchTrace trace = new SearchTrace();
trace.stageStart("parse"); Query bm25Query = parseQuery(queryText); trace.stageEnd("parse");
trace.stageStart("bm25"); List<DocScore> bm25Results = bm25Search(bm25Query, topK * 5); trace.stageEnd("bm25");
trace.stageStart("embed"); float[] queryVec = embeddingCache.getOrEmbed(queryText, embeddingClient); trace.stageEnd("embed");
trace.stageStart("knn"); List<DocScore> knnResults = hnswSearch(queryVec, topK * 5); trace.stageEnd("knn");
trace.stageStart("fusion"); List<DocScore> fused = rrfFusion(bm25Results, knnResults, 60); trace.stageEnd("fusion");
trace.stageStart("rerank"); List<DocScore> reranked = rerank(queryText, fused, topK); trace.stageEnd("rerank");
trace.stageStart("assemble"); SearchResult result = assembleResult(reranked); trace.stageEnd("assemble");
logTrace(queryText, trace); return result; }
|
结构化日志
1 2 3 4 5 6 7 8 9 10 11
| void logTrace(String query, SearchTrace trace) { StringJoiner stages = new StringJoiner(", "); trace.stageDurations().forEach((stage, micros) -> stages.add("\"%s\": %d".formatted(stage, micros)));
System.out.printf( "{\"query\": \"%s\", \"total_us\": %d, \"stages\": {%s}}\n", query.replace("\"", "\\\""), trace.totalMicros(), stages); }
|
输出示例:
1 2 3 4 5 6 7 8 9 10 11 12 13
| { "query": "Java 并发编程", "total_us": 45230, "stages": { "parse": 120, "bm25": 3400, "embed": 28000, "knn": 8500, "fusion": 80, "rerank": 4800, "assemble": 330 } }
|
这条日志说明:embedding 编码占了总延迟的 62%,是最大瓶颈。
各阶段性能特征
典型延迟分布
| 阶段 |
10K 文档 |
100K 文档 |
主要影响因素 |
| parse |
< 1 ms |
< 1 ms |
查询长度、分词器复杂度 |
| bm25 |
1-5 ms |
5-20 ms |
索引大小、query term 数 |
| embed |
5-50 ms |
5-50 ms |
模型大小、GPU/CPU、缓存命中 |
| knn |
2-10 ms |
5-30 ms |
efSearch、向量维度、量化 |
| fusion |
< 0.1 ms |
< 0.1 ms |
候选数量 |
| rerank |
5-50 ms |
5-50 ms |
候选数量、模型大小 |
| assemble |
0.5-2 ms |
0.5-2 ms |
stored fields 大小 |
embed 和 rerank 的延迟与文档数量无关,与模型推理速度有关。bm25 和 knn 的延迟随文档数量增长。
瓶颈定位决策树
1 2 3 4 5 6
| 总延迟 > 预算(300ms)? ├── embed 占比 > 50 ├── knn 占比 > 30 ├── rerank 占比 > 30 ├── bm25 占比 > 30 └── 各阶段均匀 → 总链路过长,考虑并行化或削减阶段
|
冷热缓存压测
压测脚本
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
| void benchmark(List<String> queries, int warmupRounds, int testRounds) { System.out.println("=== 冷缓存 ==="); long[] coldLatencies = runQueries(queries); printPercentiles("cold", coldLatencies);
for (int i = 0; i < warmupRounds; i++) { runQueries(queries); }
System.out.println("=== 热缓存 ==="); long[] hotLatencies = runQueries(queries); printPercentiles("hot", hotLatencies); }
long[] runQueries(List<String> queries) { long[] latencies = new long[queries.size()]; for (int i = 0; i < queries.size(); i++) { long start = System.nanoTime(); search(queries.get(i), 10); latencies[i] = (System.nanoTime() - start) / 1000; } return latencies; }
void printPercentiles(String label, long[] latencies) { Arrays.sort(latencies); int n = latencies.length; System.out.printf("[%s] P50=%d us, P95=%d us, P99=%d us\n", label, latencies[n / 2], latencies[(int)(n * 0.95)], latencies[(int)(n * 0.99)]); }
|
预期结果
1 2 3 4 5
| === 冷缓存 === [cold] P50=85000 us, P95=210000 us, P99=350000 us
=== 热缓存 === [hot] P50=32000 us, P95=65000 us, P99=120000 us
|
冷缓存的 P50 通常是热缓存的 2-3 倍。差距来自:
- OS page cache 未命中 → 磁盘 I/O
- JIT 编译未完成 → 解释执行
- embedding 缓存为空 → 每次查询都调用模型
- Lucene segment 的 term dictionary 未加载
GC 影响
1
| JVM 参数:-Xlog:gc*:file=gc.log:time,uptime,level,tags
|
分析 GC 日志,关联延迟尖峰:
1 2 3 4 5 6 7 8 9
| void detectGcImpact(long[] latencies, double p99) { int spikes = 0; for (long lat : latencies) { if (lat > p99 * 2) spikes++; } System.out.printf("延迟尖峰(>2×P99):%d/%d (%.1f%%)\n", spikes, latencies.length, 100.0 * spikes / latencies.length); }
|
如果尖峰与 GC 暂停时间吻合,考虑:
- 切换到 ZGC(
-XX:+UseZGC)——暂停时间 < 1ms
- 增加 heap(减少 GC 频率)
- 减少对象分配(对象池、预分配缓冲区)
容量账本
内存预算
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
| void printCapacityLedger(int docCount, int avgChunksPerDoc, int vectorDim) { int totalChunks = docCount * avgChunksPerDoc;
long invertedIndex = docCount * 5000L; long hnswFloat32 = (long) totalChunks * vectorDim * 4 * 3 / 2; long hnswInt8 = (long) totalChunks * vectorDim * 1 * 3 / 2; long storedFields = docCount * 2000L; long embeddingCache = 10000L * vectorDim * 4; long jvmOverhead = 256 * 1024 * 1024L;
System.out.printf(""" === 容量账本 (%,d 文档, %,d chunks) === 倒排索引: %,d MB HNSW (float32): %,d MB HNSW (int8): %,d MB Stored fields: %,d MB Embedding cache: %,d MB JVM overhead: %,d MB ────────────────────── 总计 (float32): %,d MB 总计 (int8): %,d MB """, docCount, totalChunks, invertedIndex / 1_000_000, hnswFloat32 / 1_000_000, hnswInt8 / 1_000_000, storedFields / 1_000_000, embeddingCache / 1_000_000, jvmOverhead / 1_000_000, (invertedIndex + hnswFloat32 + storedFields + embeddingCache + jvmOverhead) / 1_000_000, (invertedIndex + hnswInt8 + storedFields + embeddingCache + jvmOverhead) / 1_000_000); }
|
示例输出
1 2 3 4 5 6 7 8 9 10
| === 容量账本 (10,000 文档, 30,000 chunks) === 倒排索引: 50 MB HNSW (float32): 92 MB HNSW (int8): 23 MB Stored fields: 20 MB Embedding cache: 20 MB JVM overhead: 256 MB ────────────────────── 总计 (float32): 438 MB 总计 (int8): 369 MB
|
int8 量化将 HNSW 从 92 MB 降到 23 MB——总内存减少 16%。向量占比越大,量化的收益越大。
JMH 微基准测试
示例:BM25 vs HNSW 延迟对比
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
| @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Benchmark) @Warmup(iterations = 3, time = 1) @Measurement(iterations = 5, time = 1) @Fork(1) public class SearchBenchmark {
private IndexSearcher searcher; private String[] queries; private float[][] queryVectors;
@Setup public void setup() throws Exception { }
@Benchmark public TopDocs bm25Search() throws Exception { Query query = parser.parse(queries[0]); return searcher.search(query, 10); }
@Benchmark public TopDocs hnswSearch() throws Exception { KnnFloatVectorQuery knnQuery = new KnnFloatVectorQuery( "embedding", queryVectors[0], 10); return searcher.search(knnQuery, 10); } }
|
JMH 处理 JIT 预热、死代码消除、循环展开等 microbenchmark 陷阱。直接用 System.nanoTime() 写循环测量容易得到误导性结果。
基于实测定位一个问题
场景
trace 日志显示 embed 阶段占 60% 以上延迟。缓存命中率只有 30%——大量查询是长尾查询,不会重复出现。
分析
embedding 缓存对高频查询有效,对长尾查询无效。10000 条缓存空间不够覆盖用户查询的多样性。
优化选项
| 方案 |
效果 |
代价 |
| 增大缓存 |
命中率提升到 50-60% |
内存增加 |
| GPU 推理 |
单次编码从 50ms 降到 5ms |
需要 GPU 硬件 |
| 查询归一化 |
合并相似查询(去停用词、同义词归并) |
实现复杂 |
| 异步预编码 |
预测热门查询提前编码 |
预测不准则浪费 |
选择 GPU 推理——效果最直接,将 embed 从 50ms 降到 5ms,总延迟从 85ms 降到 40ms。
当前局限
- trace 只覆盖单次请求——缺少跨请求的聚合视图
- 容量账本是估算——实际值需要用
jmap 或 VisualVM 测量
- JMH 测量的是隔离场景——真实系统有并发、GC、I/O 竞争
- 没有分布式 tracing——单机系统暂不需要
练习
- 在搜索流程中集成 SearchTrace,输出 JSON 格式的阶段计时
- 用 30 条评测查询做冷热缓存压测,记录 P50/P95/P99
- 计算当前语料的容量账本,对比 float32 和 int8 的内存差异
- 用 JMH 对比 BM25 和 HNSW 在不同文档规模下的查询延迟
- 从 trace 日志中定位当前系统的最大瓶颈,提出优化方案
延伸阅读
- JMH: OpenJDK Java Microbenchmark Harness
- ZGC: A Scalable Low-Latency Garbage Collector
- Brendan Gregg, “Systems Performance”, 2nd Edition