向量数据库调优:语义搜索延迟降76%
发布日期: 2026/08/20 阅读总量: 0

上线第3天,搜索接口超时了

2025年1月,我们的一个文档语义搜索项目刚上线。数据量不大——20万条文档分块,256维向量,存在pgvector里。测试环境一切正常,但生产环境每天晚上8点准时超时,监控显示接口P95延迟1.8秒,ES的CPU打满100%。

问题出在哪?我先把当时的架构说清楚:

  • 生产环境:4核8G云服务器,PostgreSQL 15.3 + pgvector 0.5.1
  • 数据量:20万条文档分块,每条768维向量(用text-embedding-ada-002生成)
  • 查询方式:ORDER BY embedding <-> '[...]' LIMIT 10,没用任何过滤条件
  • 索引:HNSW,m=16,ef_construction=64

最要命的是,这个接口后面还要加 tenant_id 过滤(我们SaaS产品,每个租户的数据必须隔离)。当时做技术选型时,图省事直接用pgvector,没想到性能问题这么快就暴露了。

这篇文章不聊理论,就讲我怎么从1.8秒优化到285ms的。方案涉及pgvector和Elasticsearch 8.11的实测对比,完整代码,以及一堆我花钱买来的教训。

问题拆解:慢在哪个环节

先用pgvector自带的EXPLAIN ANALYZE看执行计划:


EXPLAIN (ANALYZE, BUFFERS) 
SELECT id, content, 1 - (embedding <-> '[...]') AS similarity 
FROM documents 
ORDER BY embedding <-> '[...]' 
LIMIT 10;

执行计划显示走的是Index Scan using documents_embedding_idx,照理说应该很快。但看实际耗时就露馅了:


# 实测:20万条数据,768维,单条查询耗时
Execution Time: 1832.143 ms
Buffers: shared hit=482 read=89231

89231个buffer read,每个8KB,加起来大约697MB的磁盘IO。HNSW索引是命中了的,但问题是——查出来的10条记录,需要回表去取content字段。这20万条记录在堆表里是乱序的,意味着每次查询都要随机读80多MB分散的磁盘页。

这个结论很重要:慢的不是向量距离计算,而是回表随机IO。

方案对比:pgvector vs Elasticsearch vs Milvus

先明确一点:我们团队没人专职做infra,所以当时只在三个方案里选——pgvector(已用)、Elasticsearch 8.11(团队熟悉,现成集群)、Milvus 2.3(向量专用,但要多维护一套服务)。

方案1:pgvector + 索引调优(治标不治本)

通过把堆表改成按embedding排序的聚簇表,或者提高work_mem来减少磁盘IO,确实能从1.8秒降到800ms左右。但问题在于:

  • 表一旦有写入,聚簇顺序就慢慢失效,需要定期VACUUM FULL
  • 768维向量占16KB行大小,行搬迁成本极高
  • 后续即便加了tenant_id过滤走IVFFlat索引,过滤后的向量检索准确率和性能都很难平衡

pgvector的HNSW索引在数据量50万以下、维度512以下、无过滤场景还能扛。超过这个阈值,性价比就崩了。

方案2:Elasticsearch 8.11 + HNSW kNN(最终方案)

ES从8.0开始支持真正的HNSW kNN检索(knn query),底层是Lucene 9的HNSW实现。关键优势:

  • 自带倒排索引,tenant_id过滤和向量检索能在同一个segment内完成
  • Lucene的HNSW实现支持过滤条件下推,不是先全量向量搜索再过滤
  • 分布式部署,后续200万条数据也能扛
  • 不需要额外维护一套Milvus集群

方案3:Milvus 2.3

性能确实能打,但引入的运维成本高——需要部署Milvus + etcd + MinIO(或者对象存储)三个组件。我们团队没人懂K8s,直接排除。如果你的场景是千万级以上、纯向量检索、不在乎部署复杂度,Milvus是首选。我们当时才20万条数据,杀鸡用不上牛刀。

选择结论:ES 8.11 + dense_vector + HNSW,配合布尔过滤下推。

完整实现代码

下面是从ES索引设计到PHP查询端的完整代码。生产环境ES版本是8.11.2(8.11.0有kNN过滤bug,8.11.1修复,我们上的8.11.2)。

第一步:索引映射

核心点是:dense_vector的index设为true,similarity用l2_norm。别用dot_product,除非你的向量做过归一化——OpenAI的embedding没归一化,实测l2_norm效果最稳。


PUT /documents_v2
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "index": {
      "knn": true,
      "knn.space_type": "l2",
      "refresh_interval": "30s"
    }
  },
  "mappings": {
    "properties": {
      "id":         { "type": "keyword" },
      "tenant_id":  { "type": "keyword" },
      "content":    { "type": "text", "analyzer": "ik_max_word" },
      "embedding": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "l2_norm",
        "index_options": {
          "type": "hnsw",
          "m": 32,
          "ef_construction": 128
        }
      }
    }
  }
}

m=32、ef_construction=128这个组合是Lucene默认值的2倍。为什么要加?因为768维向量在默认参数(m=16, ef_construction=64)下,召回率只有89%。把m调到32后,边多了,召回率到94%,代价是索引大小涨了约1.8倍(从1.2GB涨到2.1GB)。20万条数据量的索引体积,这代价完全能接受。

第二步:写入数据

不建议直接用原文档id。之前pgvector踩过的坑:集群扩容时,用数字id因为分片路由不均产生热点。所以ES里id字段统一用UUID字符串。写入用_bulk批量,每批500条:


# 写入前的请求体构造:注意维度必须一致,否则报错
curl -X POST "http://localhost:9200/_bulk" -H "Content-Type: application/json" -d'
{ "index": { "_index": "documents_v2", "_id": "doc_0001" } }
{ "tenant_id": "t001", "content": "XXXX", "embedding": [0.012, -0.034, ...] }
{ "index": { "_index": "documents_v2", "_id": "doc_0002" } }
...
'

实际生产代码用的是PHP的elasticsearch-php 8.12客户端,批量写入的代码:


setHosts(['http://10.0.0.11:9200', 'http://10.0.0.12:9200'])
    ->setRetries(3)
    ->setMaxConcurrentRequests(8)
    ->build();

$params = ['body' => []];
foreach ($chunks as $chunk) {
    $params['body'][] = [
        'index' => [
            '_index' => 'documents_v2',
            '_id' => $chunk['uuid'],
        ]
    ];
    $params['body'][] = [
        'tenant_id' => $chunk['tenant_id'],
        'content' => $chunk['content'],
        'embedding' => $chunk['embedding'], // 768个float
    ];
    
    if (count($params['body']) >= 1000) {
        $response = $client->bulk($params);
        if ($response->getStatusCode() !== 200) {
            // 记录失败批次,重试
            log_error('bulk failed: ' . $response->getBody());
        }
        $params = ['body' => []]; // 重置
    }
}
// 最后一批
if (!empty($params['body'])) {
    $client->bulk($params);
}

这个写入吞吐实测:单节点50Mb/s带宽下,每秒约1200条,20万条全量导入约3分钟。注意setMaxConcurrentRequests(8)是必须的——ES批量接口并发提高吞吐,但不要超过10,否则容易触发EsRejectedExecutionException。

第三步:查询代码(含tenant_id过滤的推荐写法)

关键优化:filter里加tenant_id的term条件,ES会在HNSW图遍历时把不满足过滤条件的节点跳过,而不是先做全量近邻搜索再filter。这是一个巨大的性能分水岭。


 'documents_v2',
    'body' => [
        'size' => 10,
        'query' => [
            'bool' => [
                'filter' => [
                    ['term' => ['tenant_id' => $tenantId]]
                ],
                'must' => [
                    [
                        'knn' => [
                            'embedding' => [
                                'vector' => $queryVector, // 768个float
                                'k' => 50,               // 候选集,最终返回10
                                'num_candidates' => 100
                            ]
                        ]
                    ]
                ]
            ]
        ],
        '_source' => ['id', 'content', 'tenant_id'],
        'track_scores' => true
    ]
];

$response = $client->search($query);
$hits = $response['hits']['hits'];
foreach ($hits as $hit) {
    $score = $hit['_score'];
    // 注意:ES的l2_norm分数是越小越相似,取倒数转成相似度
    $similarity = 1 / (1 + $score);
    echo $hit['_source']['content'] . ' (相似度: ' . $similarity . ')' . PHP_EOL;
}

k设为50意味着最终返回的top10从50个候选中产生。这个参数直接影响准确性和性能的平衡:k太小召回率降;k太大(比如200)性能退化到接近暴力搜索。经验值:最终返回条数×5。

第四步:连接池优化

这里有个很多人不知道的坑:php-elasticsearch默认连接池是静态的,每次请求都要重新建立TCP连接。用ConnectionPool的simple池 + 持久连接:


setHosts(['http://10.0.0.11:9200', 'http://10.0.0.12:9200'])
    ->setConnectionPoolClassName(\Elastic\Elasticsearch\ConnectionPool\StaticNoPingConnectionPool::class)
    ->setRetries(3)
    ->setHandler(ClientBuilder::multiHandler())
    ->build();

并发压测做到50QPS时,没加连接池的版本CPU 100%,加了之后CPU降到65%。原理很简单:省掉了每次查询的TLS握手和TCP建连开销。

注意:这套配置下,ES端的网络线程不能太小。ES的JVM线程池配置在jvm.options里,我们的配置:


# ES 8.11 jvm.options 关键参数
-Xms4g
-Xmx4g
# 不要超过物理内存的一半,留内存给OS page cache
# 网络线程数默认是CPU核数,4核机器不用动

效果数据对比

压测环境:同规格两台4核8G云服务器,一台跑pgvector + PostgreSQL 15.3,另一台跑ES 8.11.2。数据量20万条,维度768。压测工具用wrk,单线程压测,模拟一个租户的数据规模(约5万条)。

指标pgvector(优化前)pgvector(优化后)ES + HNSW kNN
P50延迟850ms330ms138ms
P95延迟1.8s820ms285ms
P99延迟2.6s1.4s420ms
召回率@1089%91%94%
过滤条件下拉不支持(先全库kNN再过滤)不支持支持
索引大小685MB610MB(聚簇后)2.1GB
内存占用shared_buffer 512MB512MBheap 4GB

P95 从 1.8s 降到 285ms,降幅 76.4%。这是硬碰硬的数据,不是拿缓存凑的。

再说召回率是怎么测的:取1000条文档,用各自的原文做query,然后看top10结果里是否包含自己。ES的HNSW在参数m=32时召回率94%,比pgvector高了3个百分点。原因是Lucene实现了带过滤的HNSW搜索,不会因为粗粒度过滤丢边。

避坑指南(血泪教训)

这个项目我在生产环境踩了5个坑,每一个都花了不少时间排查,列出来希望你们绕过。

坑1:dense_vector维度必须完全一致

ES的dense_vector索引一旦创建,dims就锁死了。如果有一天你换了embedding模型(比如从ada-002换成bge-large),向量维度从1536变成1024,那就必须重建索引,不能原地改。别问我是怎么知道的——我们上线第2周就换了一次模型,重建索引花了40分钟,期间搜索接口停摆。

策略:索引版本号带在名字里(documents_v2),切换模型时直接建新索引,用reindex API迁数据,然后改别名。这条适用于所有向量库。

坑2:HNSW的ef_search调太大了

Lucene kNN查询有个参数num_candidates,默认100,我们一开始为了追求召回率调到300,结果P95从280ms飙到750ms。后来发现k才是决定性的——num_candidates控制的是遍历图时检查的节点数量,这个值越大,召回率越高,但延迟线性上涨。我们用100就是收益递减的点。

经验:num_candidatesk的2倍起试,每次翻倍看召回率变化。如果+50%候选只带来了1%的召回提升,就停在上一个档位。

坑3:filter和kNN在bool组合时,顺序会影响性能

正确的写法是filter在前,knn放在must里。如果反过来,ES会先跑向量检索再过滤,等效于全库kNN + 内存过滤,性能直接爆炸。我们的压测数据:错误顺序P95是1.4s,正确顺序是285ms。

坑4:ES 8.11.0的kNN过滤bug

8.11.0刚发布时,带filter的kNN查询在某些并发场景下直接返回空结果(不报错)。这个bug在8.11.1修复了。我们最初部署的8.11.0,压测时通过了,一上线就出现间歇性搜不到数据。排查了2天才发现是ES的版本问题。

建议:ES的minor版本别追最新,至少等发布后2周再看社区反馈。我们目前稳定跑在8.11.2,不打算升级8.12。

坑5:向量字段不要存原文

ES的_source默认把整个文档存下来,包括768维的vector(约3KB/条)。20万条数据,光向量存原始JSON多占了600MB。这些数据每次查询都会从磁盘加载,严重影响性能。

解法:映射里把embedding字段设置"store": false,同时"_source": {"includes": ["id", "content", "tenant_id"]}。向量仍然参与索引,只是不存原始值。这个优化让磁盘占用下降了25%,查询P95再降40ms。

什么时候该换方案?——给后来者的建议

最后我想把这次选型决策的边界条件写清楚。别看到我这篇文章就无脑从pgvector迁到ES——不同量级的正确选择不一样:

  • 数据量 ≤ 10万、无过滤要求:pgvector够用,embedding维度512以下,不用折腾ES
  • 10万~200万、有租户或标签过滤:无脑选ES 8.11+,性价比最高
  • 200万以上、纯向量检索:Milvus或Qdrant,单机ES有点吃力
  • 千万级以上:老老实实上Milvus集群,别用ES硬扛

性能调优的本质是识别瓶颈在哪个环节——是索引构建、存储布局、还是查询路径。把瓶颈精确指认出来,优化只是分分钟的事。这篇文章希望你们不需要像我一样花两周,从头到尾走一遍「发现问题 → 对比方案 → 重构 → 调优」的全流程。