搜索系统的各阶段——查询解析、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");

// BM25 召回
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% → 缓存命中率低?提高缓存容量。CPU 推理?考虑 GPU。
├── knn 占比 > 30% → efSearch 过高?降低 efSearch。未量化?启用量化。
├── rerank 占比 > 30% → 候选数过多?减少 rerank 输入。模型太大?用更小模型。
├── bm25 占比 > 30% → 查询过复杂?简化 query。索引未优化?forceMerge。
└── 各阶段均匀 → 总链路过长,考虑并行化或削减阶段

冷热缓存压测

压测脚本

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; // ~5KB/doc 估算
long hnswFloat32 = (long) totalChunks * vectorDim * 4 * 3 / 2; // ×1.5 含图
long hnswInt8 = (long) totalChunks * vectorDim * 1 * 3 / 2;
long storedFields = docCount * 2000L; // ~2KB/doc 估算
long embeddingCache = 10000L * vectorDim * 4; // 1 万条缓存
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 只覆盖单次请求——缺少跨请求的聚合视图
  • 容量账本是估算——实际值需要用 jmapVisualVM 测量
  • JMH 测量的是隔离场景——真实系统有并发、GC、I/O 竞争
  • 没有分布式 tracing——单机系统暂不需要

练习

  1. 在搜索流程中集成 SearchTrace,输出 JSON 格式的阶段计时
  2. 用 30 条评测查询做冷热缓存压测,记录 P50/P95/P99
  3. 计算当前语料的容量账本,对比 float32 和 int8 的内存差异
  4. 用 JMH 对比 BM25 和 HNSW 在不同文档规模下的查询延迟
  5. 从 trace 日志中定位当前系统的最大瓶颈,提出优化方案

延伸阅读

  • JMH: OpenJDK Java Microbenchmark Harness
  • ZGC: A Scalable Low-Latency Garbage Collector
  • Brendan Gregg, “Systems Performance”, 2nd Edition