☰
Apache Doris在千亿级多模态向量检索中的实践
2026/11/4 14:30:21 网站建设 项目流程

几年前刚接触多模态检索的时候,我们内部还在用ES加暴力遍历的方式扛流量,数据量一旦过了十亿级别,性能和资源成本就开始变得非常难看。后来我们把核心检索链路迁移到 Apache Doris 上,逐步支撑起千亿级多模态特征的实时检索服务。这篇文章就把我们在快手业务场景下的完整实践过程做个梳理,包括选型思路、表结构设计、向量索引调优、查询链路优化以及那些踩过的坑,希望能给正在做类似多模态检索或者大规模向量召回的同学一些参考。

1. 多模态检索的业务背景与需求拆解

1.1 多模态检索到底在解决什么问题

多模态检索,简单说就是把文本、图像、音频、视频这类非结构化数据,统一映射到一个高维向量空间里,然后通过向量之间的相似度计算,找到内容相关的条目。以快手为例,用户刷短视频时看到的“相关推荐”“相似内容”“以图搜图”,背后都离不开多模态检索。

它的核心链路通常分两步:离线把各模态的内容用深度模型抽成向量,比如将视频画面抽成视觉特征向量、将标题和评论抽成语义特征向量;在线拿到用户当前的交互行为或者查询条件后,也通过同一个模型抽成查询向量,再到向量索引里做近邻检索,召回相似候选,再交给上层的排序模型做精排。

这里有个容易误解的点:多模态检索不光是做向量的近邻计算,它几乎总是伴随大量结构化过滤条件,比如地域、内容类型、作者ID、发布时间、内容质量分等。如果只有纯向量近邻,在很多业务场景里根本没法用,因为用户要的不是“全世界最相似的”,而是“在特定范围内的最相似”。所以真正的难点在于:向量相似度计算和业务标量过滤如何高效融合,尤其是千亿级数据规模下。

1.2 千亿量级的技术挑战

我先把我们当时面临的数字摆出来,大家感受一下量级:全库的向量特征总量在千亿级别以上,单条向量维度通常是128维到512维不等,每天还有上亿级的新增数据和大量的过期删除。在线服务的查询压力在峰值时能达到每秒数万次,且要求P99延迟控制在百毫秒以内。

这个体量下,几个核心挑战特别突出:

第一,存储成本。千亿条特征向量如果直接以float32存储,存储开销会非常吓人。一个简单的计算,128维float32向量单条占512字节,千亿条裸向量就需要约51.2TB,这还不算其他元数据和倒排索引。而且向量数据不能简单压缩,必须保证检索精度,这对存储引擎提出了很高要求。

第二,写入吞吐与实时性。短视频场景下,内容从上传到可以被检索,延迟要求通常在分钟级甚至秒级。用户的互动行为还会实时改变内容的特征和可见性,比如一条视频被删除、限流或作者主动隐藏,这些状态变化都要在极短时间内反映到检索结果中。对OLAP引擎来说,高频实时更新一直是软肋。

第三,查询延迟与并发。纯粹用暴力计算去扫千亿向量肯定不现实,必须有高效的ANN索引结构。但ANN索引的召回精度、构建成本、过滤能力之间存在天然的三角矛盾:过滤条件越复杂,索引加速效果越差。

第四,易用性和运维成本。如果引一套专用的向量检索数据库,再加一套OLAP引擎做标量过滤,需要维护两套系统,还要解决数据一致性问题。这个成本在业务快速迭代期是不可接受的。

我们当时的结论是:需要找一个能同时胜任海量结构化数据分析和高效向量近邻检索的引擎,并且要具备较强的实时写入能力。最终实践下来的答案就是 Apache Doris。

2. 技术选型:为什么最终选择 Apache Doris

2.1 主流方案对比

选型时我们重点对比了四类方案:Elasticsearch加插件、专用向量数据库(如Milvus)、传统OLAP引擎配合外部向量索引、Apache Doris。

先说ES。ES在快手内部使用很广泛,我们对它的运维也比较熟,但它本质是倒排索引架构,在标量过滤上虽然表现不错,向量检索却很吃力。虽然ES有dense_vector和HNSW插件支持,但千亿级索引的构建速度和内存占用都不理想。最麻烦的是,当我们把向量字段和大量过滤字段放在同一张索引里时,写入放大问题非常严重,段合并带来的IO压力会让集群频繁抖动。在离线批量导入场景下ES的表现还行,但在秒级实时更新与向量检索混合的负载下,稳定性很难保证。

再看Milvus这类专用向量数据库。它们对纯向量的ANN检索优化得非常好,尤其HNSW和IVF-PQ的实现效率很高,但劣势也很明显:一是在复杂的标量过滤上能力偏弱,复杂的SQL查询支持不够;二是如果要和业务元数据做关联查询,通常需要额外再维护一套OLAP系统;三是不够灵活,想根据业务需求定制存储和索引策略时,限制较多。另外,单独引一套新系统,数据链路、监控体系、权限系统都要重新打通,对中大型团队来说边际成本不低。

至于“传统OLAP加外部向量索引”这种组合方案,最大的问题是架构割裂。我们曾经尝试过用ClickHouse存特征,再用FAISS单独建索引,虽然各自性能都不差,但统一管理、事务一致性问题层出不穷。比如FAISS索引全量重建要几小时,增量更新机制又比较薄弱,同时ClickHouse的实时并发更新能力在千亿级大宽表场景下会成为瓶颈。

最终我们把Apache Doris作为核心候选,原因有三:第一,Doris本身是分布式MPP架构的OLAP引擎,在千亿级数据规模下的结构化过滤、聚合分析能力是经过验证的;第二,Doris从2.0版本开始原生支持向量类型和近似近邻检索能力,能把向量索引和标量索引放到同一个查询计划里做融合优化;第三,Doris对实时数据更新(Unique Key模型)和高并发点查有比较成熟的方案,符合我们业务对秒级可见的需求。

2.2 Doris 向量检索能力的实际边界

这里需要说清楚一个关键点:Apache Doris的向量检索能力,在设计定位上更多是“在数据仓库内完成向量召回与标量过滤融合”,而不是替代独立向量数据库来追求极致的召回性能。在千万级到亿级数据规模下,单表向量检索性能非常优秀;在千亿级规模下,就需要依靠分区、分桶、行存列存策略以及SQL级别的预过滤来协同工作,把向量检索的范围尽量缩小。

实际测试中,我们在同等数据量下对比了Doris和专用向量库的纯向量召回延迟,Doris在HNSW索引的加持下,单机百亿向量规模的召回延迟能做到几十毫秒级别;一旦加上业务过滤条件,Doris因为能把过滤条件下推到存储层和索引扫描阶段,反而比专用向量库“先召回再过滤”的架构更有优势。

下面把我们实践中的整体架构和关键工程决策展开来讲。

3. 系统架构与核心设计

3.1 整体链路设计

我们的检索服务整体上分为离线索引构建和在线检索两部分。

离线链路中,多模态内容经过特征抽取模型(比如视频理解模型、语义向量模型)产出向量,经过校验后写入Doris。在写入之前先要做ID映射和特征归一化,尤其是向量归一化这个细节,直接影响内积和余弦相似度的计算结果,必须在写入前统一完成。

在线链路中,用户请求先经过业务网关做参数校验和降级策略,然后生成查询向量,同时带上各种业务过滤条件,构造一条SQL查询发给Doris集群。Doris完成向量召回和标量过滤后,返回TopK候选,再经过业务后处理(去重、规则过滤、多样性打散),最后送去精排。

这个架构看起来和其他系统没有太大差别,但关键在下面几个设计点上,我们踩了不少坑才逐步打磨稳定。

3.2 表模型选择:Unique Key + 向量字段

Doris目前有Duplicate Key、Aggregate Key和Unique Key三种表模型。对于千亿级别的检索场景,我们强烈建议使用Unique Key模型,并且要开启Merge-on-Write模式(自2.0版本后已经默认支持)。

为什么不用Duplicate Key?多模态特征数据几乎总是需要更新的:一个内容的状态变化(如违规下架、作者删除)、特征向量重算(模型升级后全量刷新)、业务属性变化(发布时间校正、地域修正),都需要对同一主键的记录做更新。Duplicate Key模型下数据只能追加,无法删除和修改,根本没法满足业务需求。

Aggregate Key模型的问题在于,它的聚合方式是为数值指标设计的,比如SUM、MAX、MIN,而向量字段的更新逻辑是“覆盖旧向量,保留新向量”,这种语义在Aggregate Key下不好表达。当然可以用REPLACE_IF_NOT_NULL实现部分字段更新,但性能不如Unique Key主键模型来的直接。

在实践中,我们的建表语句大致长这样(做了简化,去掉了内部字段):

CREATE TABLE multimodal_feature ( item_id BIGINT NOT NULL, feature_version INT NOT NULL, vector ARRAY<FLOAT> NOT NULL, category INT NOT NULL, author_id BIGINT NOT NULL, region INT NOT NULL, status TINYINT NOT NULL, content_type TINYINT NOT NULL, quality_score DOUBLE NOT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL ) UNIQUE KEY(item_id, feature_version) DISTRIBUTED BY HASH(item_id) BUCKETS 128 PROPERTIES ( "replication_num" = "3", "enable_unique_key_merge_on_write" = "true", "compression" = "LZ4" );

这里有几个设计细节值得说明。

主键为什么要设计成item_id + feature_version,而不是单独的item_id?因为模型升级后,同一内容会存在新旧两版特征向量,检索时需要按版本过滤。如果只用item_id做主键,新版本写入会直接覆盖旧版本,一旦新版本向量有问题,就无法快速回退。保留版本字段后,可以支持在线“灰度切换特征版本”,这在模型迭代频繁的团队里是救命功能。

向量字段用ARRAY<FLOAT>类型而不是VECTOR类型,多见于Doris早期版本,后续版本提供专门的向量类型支持时可以直接切换。ARRAY类型存储的是变长数组,配合索引使用需要额外声明向量索引。从Doris 2.x的经验看,ARRAY 在序列化和内存布局上对SIMD友好的,性能差距不大,所以老集群不升级也能撑住。

分区策略上,我们按天做RANGE分区,按create_time分区,好处是淘汰过期数据时直接DROP PARTITION即可,效率极高;而数据导入也可以按天批量load,天然与离线特征管道对齐。分桶策略按item_id哈希,保证同一item的多个版本落在同一个桶内,方便主键更新时做局部合并。

3.3 向量索引的构建与管理

Doris的向量索引基于HNSW算法实现,核心参数是M(每个节点的最大连接数)和efConstruction(构建时的动态候选集大小)。M越大,索引精度越高,但内存占用和构建时间也越大;efConstruction越大,构建质量越好,但耗时增加。

我们在一开始采用了一组相对保守的参数:M=32,efConstruction=200,查询时的efSearch=128。这套参数在128维向量、千万级数据规模下,召回率能打到95%以上(Recall@10)。在千亿级规模下,我们把数据按照其他业务维度做了分区,让每个分区的向量数量控制在可管理的量级,再在每个分区内部建HNSW索引,这样单索引体积不会过大,构建和查询都能保持稳定。

创建向量索引的语法比较简单:

CREATE INDEX idx_vector ON multimodal_feature (vector) USING HNSW PROPERTIES ( "dim" = "128", "distance_type" = "COSINE", "m" = "32", "ef_construction" = "200" );

距离类型我们用的COSINE(余弦相似度),而不是L2或IP。原因:多模态模型产出的向量通常没有做归一化,直接算L2距离会受到向量模长影响;IP内积对未归一化向量来说偏向模长大的样本;而余弦相似度是标准化后的方向相似度,更适合表示语义内容的相关性。当然,如果你的上游模型已经对向量做了L2归一化,那么用IP和COSINE是等价的,IP的计算效率通常略高一点。

有一个实操细节:如果你的业务对“相似度阈值”有要求(比如召回结果必须满足相似度>0.8),在建表时就要想清楚距离类型和阈值的关系。COSINE在Doris里返回的是余弦值,范围[-1,1],阈值直接比对即可;如果用L2,返回的是距离,阈值语义就反过来了,应用层很容易出错。

4. 查询优化与性能调优实战

4.1 从暴力扫描到索引过滤的演进

查询链路的优化,基本遵循“先看扫描行数、再看计算效率”的顺序进行。

第一版查询非常朴素,就是全表扫描加ORDER BY距离LIMIT k:

SELECT item_id, score FROM ( SELECT item_id, cosine_distance(vector, [query_vector]) AS score FROM multimodal_feature WHERE feature_version = 2 AND status = 1 AND region IN (1, 2, 3) ) t ORDER BY score ASC LIMIT 100;

这条SQL的问题很明显:即使有HNSW索引,Doris优化器如果判断不出如何利用索引,就会走全分区扫描,然后对每行数据计算余弦距离,再全量排序取TopK。在千万级数据下还能接受,但超百亿后,单次查询扫描成本极高,并发一大直接把CPU打满。

后来我们做了两件事来优化:

第一步,利用Doris的向量索引加速TopK检索。手动指定索引提示(或者依赖优化器选择索引路径),让查询只走HNSW索引的候选集,而不是全表扫描:

SELECT item_id, cosine_distance(vector, [query_vector]) AS score FROM multimodal_feature WHERE feature_version = 2 AND status = 1 AND region IN (1, 2, 3) ORDER BY score LIMIT 100

Doris的向量索引支持在ORDER BY distance LIMIT n场景下自动选择合适的HNSW路径。但要注意:Doris优化器对向量索引的选择,很多时候取决于过滤条件的预估选择性。如果过滤条件非常严格,优化器更倾向先用标量索引过滤,再在结果集上计算距离;如果过滤条件宽松,则走HNSW索引召回候选,再对候选做标量过滤。这两种策略的适用场景完全不同。

第二步,配合业务规则做预裁剪。比如按地域分桶、按内容类型分表,或者把最热的作者特征单独放一个表,查询时先确定数据范围,再进入向量检索。听起来有点“业务脏活”,但在千亿级场景里,这些预裁剪能把检索空间从“千亿”降到“十亿”甚至“亿级”,延迟差异是完全不同的量级。

4.2 分区裁剪与分桶裁剪的正确姿势

分区裁剪是我们在千亿级查询中获益最大的优化手段。

我们最初把所有特征放一张大表,只按天分区。查询时如果只查“最近3天”,分区裁剪能把数据量降一个数量级。但业务上有不少场景需要查全量(比如优质作者召回),分区裁剪就没有用武之地了。

这时我们引入了“二级分区”或者叫“逻辑分桶”,在表内增设一个recall_bucket字段,范围从0到63,写入时根据item_id的哈希值或者业务线ID取模写入对应的bucket。查询时根据请求特征命中某个bucket子集,比如“只查第2、5、8号桶”,这样即使跨全量时间范围,扫描数据量也能稳定控制在全库的十分之一到几十分之一。

有人会问,为什么不用Doris本身的分桶(DISTRIBUTED BY HASH)来做裁剪?因为Doris的分桶是物理数据分布的底层机制,虽然数据是分布式的,BE节点并行扫描后汇聚结果,但SQL执行时如果不带对应分桶键的等值条件,优化器并不会裁剪掉桶的扫描。所以业务层面的逻辑分桶字段,配合SQL里的IN条件,是更可控的裁剪手段。

4.3 向量索引与标量过滤的融合策略

实际查询中,用户几乎总是带着过滤条件来做相似检索。这类查询在向量检索领域叫Filtered ANN Search,剪枝策略直接影响召回精度和性能。

Doris的向量索引和标量过滤的协作,执行层面可以简单理解成两种模式:

模式一是“先过滤后检索”,即先用标量条件(如status、region)缩小数据范围,再对剩下的数据做向量近邻检索。这种模式适合过滤条件选择性高、结果集小的场景。Doris会通过ZoneMap索引、BloomFilter索引或前缀索引来加速标量过滤。

模式二是“先检索后过滤”,即直接在HNSW索引上做近邻召回,拿到TopK候选后再应用标量过滤条件。这种模式适合过滤条件宽松、向量检索本身已经足够精准的场景。风险是:如果过滤条件非常严格,TopK候选大量被过滤掉,最终返回结果不足K个,就会导致召回率下降。

我们的经验是:优先把强过滤条件(如status、feature_version)设计成前缀条件或分区键,让引擎在存储层就过滤掉大量无关数据;弱过滤条件(如region IN多个值)放在向量检索后的过滤阶段。SQL层面等价于先窄化数据集,再做向量距离排序。

一个常见误区是过度依赖WHERE条件让Doris自动优化。Doris的优化器虽然做了向量化执行和谓词下推,但对HNSW索引和过滤条件的最优执行顺序判断并不总是准的。必要时用Hint或者调整SQL结构来控制执行计划。我们在生产环境中有时候会刻意把SQL拆成两步:第一步用普通SQL查候选ID集合,第二步再用向量索引从候选集合中精确召回。

4.4 查询并发与缓存设计

检索服务是典型的高并发低延迟场景,Doris集群承担的查询压力波动很大。我们的做法分三层:

第一层是接入层,用本地缓存。对于相同或相似请求(比如热门视频的相似推荐),直接在应用层做短TTL缓存,缓存时间通常是5到30秒,能挡住相当比例的重复查询。这里要注意缓存键的设计,不能只按“视频ID”缓存,还要带上特征版本号;模型升级期间,新旧版本并存的请求如果不区分版本,就会拿到错误结果。

第二层是集群内部的查询并发管理。Doris BE节点默认允许的查询并发有限,我们通过划分查询队列,把检索类查询和报表类查询分开,避免慢查询拖垮快查询。同时,对查询语句做了严格的资源限制,每条SQL强制限定LIMIT,禁止无LIMIT的跨全表扫描语句在生产流量中执行。

第三层是结果集缓存,在Doris侧对相同SQL做结果缓存。这一步收益不大,因为向量检索的输入向量几乎都是独立的,重复度不高,但在部分热门请求上确实能看到延迟下降。需要注意的是,结果缓存必须感知数据更新频率,如果索引或数据变化很快,缓存命中率反而低,还会增加一致性维护成本。

还有一个小优化:对向量做量化压缩。Doris支持将向量从float32转成float16或者int8存储,存储体积能压到原来的1/2或1/4。代价是向量精度损失。我们在粗排阶段采用int8量化,精排阶段用原始float向量,整体效果没有明显回退,但资源消耗大幅下降。这个方案对内存型和IO型瓶颈特别有效。

5. 踩坑记录与问题排查方法

5.1 向量索引构建时的内存爆炸

我们第一次在千亿级表上创建HNSW索引时,直接遇到了BE节点内存溢出的问题。HNSW索引构建时,需要把所有向量的图结构加载到内存中,M=32时,单条128维向量在索引中额外的边表开销可能超过向量本身好几倍。千万条向量的索引构建内存消耗就是几个GB,几亿条直接吃掉上百GB。

这个问题的排查思路是这样的:首先确认内存主要消耗在索引构建而不是查询;然后查看Doris BE日志中索引构建相关告警;再通过调整构建参数降低内存峰值,比如降低M值、减少ef_construction、分批构建。遇到一批构建就OOM的极端情况,可以用“先删除索引、分批导入数据、最后统一构建”的方式缓解。

我们最终的参数调整为M=24、efSearch=128、efConstruction=160,内存峰值下降约30%,召回率从95%降到93%左右,对业务影响很小。在内存资源有限的情况下,牺牲一点精度换稳定性是划算的。

5.2 命中率低:是索引没走对,还是向量本身的问题

有时候线上反馈召回结果不准,优先怀疑索引参数,结果调了半天没变化。经验是:先排查是不是执行计划没走上向量索引。用EXPLAIN查看执行计划,如果看到的是全表扫描而不是HNSW索引路径,那召回慢或者不准就不是索引参数的问题,而是SQL引导或优化器选择问题。

比如下面的EXPLAIN输出片段(示意)里,如果TABLE SCAN节点显示的是全分区扫描,说明没有正确裁剪或没有走索引:

EXPLAIN SELECT item_id, cosine_distance(vector, [query_vector]) AS score FROM multimodal_feature WHERE feature_version = 2 AND status = 1 ORDER BY score LIMIT 100;

另一个容易被忽略的因素是向量本身的质量。如果上游向量模型升级了,新向量分布与旧向量差异较大,而索引还是按旧向量构建的,召回效果会断崖式下跌。多模态检索系统中,特征版本管理和索引灰度重建是必须提前设计好的能力。我们后来给每个特征版本独立分区,并在索引名中带上版本号,切换时先建新索引,在线验证回流指标后再切换流量,遇到问题能秒级回滚。

5.3 实时写入与查询的相互影响

在线导入的实时数据写Unique Key表时,会在BE上触发Merge-on-Write的合并操作。如果写入量和查询并发同时冲高,容易造成BE CPU打满、查询延迟抖动。

我们的应对措施包括:控制导入批次大小,避免超大事务阻塞合并;把高频更新的字段和低频更新的字段拆分到不同表,用宽表加窄表的Join方式,减少合并压力;对于状态变化类更新(比如删除、下架),优先用删除标记代替物理删除,在查询时补充一个过滤条件,把合并负担延迟到低峰期。

比较典型的例子:内容违规下架,如果直接DELETE掉记录,会触发主键删除的Merge操作,代价很高。我们改为增加一个is_valid字段,下架时更新该字段为0,查询时加WHERE is_valid = 1。这样虽然稍微增加了查询时的过滤成本,但极大地降低了写入链路的压力。

5.4 常见问题速查表

这里把我们在实践中频率较高的几个问题和对应解法整理一下,方便大家快速定位。

现象可能原因排查方法/解决方案
召回结果明显变少过滤条件过于严格,TopK候选被大量过滤放宽弱过滤条件,或把强过滤前置到存储层
查询延迟飙升,CPU高SQL走了全表扫描,未利用索引用EXPLAIN确认执行计划,调整SQL/Hint
BE节点内存OOMHNSW构建参数过大,或数据导入并发太高降低M/efConstruction,分批构建索引
更新后查询结果未变化未开启Merge-on-Write,或分区裁剪没匹配到新数据检查Unique Key模型配置和导入事务隔离级别
相似度阈值失真距离类型与向量归一化方式不匹配统一向量归一化,确认distance_type语义
索引构建时间过长数据量太大或分区不合理按天/业务线分区,缩小单索引规模,错峰构建
查询结果排序不稳efSearch过小,HNSW图搜索范围不足增大efSearch值(如128调到200)
实时导入阻塞查询写入合并事务与查询争抢资源启用导入限流,或基于低峰期调度批量导入

6. 从千亿到万亿:我们的一些后续规划

多模态检索上线稳定后,我们自然被问到“能不能支持万亿级”。说实话,万亿级别靠单表堆存储和计算是不够的,至少需要三个层面的改造:

第一是存储分层。把热数据放在Doris本地或SSD存储,冷数据放到OSS或S3对象存储,查询时按需加载IO。Doris的多存储介质支持可以用来做冷热分层。

第二是特征压缩和混合精度。把向量从float32降到float16或int8,加上维度裁剪,能在精度影响可控的情况下大幅压缩存储和内存。这块我们已经初见成效,后续计划在更多场景推广。

第三是计算下推。把向量近邻检索和下推过滤更加深度地融合到Doris存储层,减少中间数据传输。这就依赖社区版本新能力的演进。从Doris团队的开源路线看,向量检索方向的优化一直在推进,我们也跟着升级了好几个版本,每轮升级在向量场景都有明显收益。

回到工程实践本身,我的体会是:多模态检索的难点从来不只在算法或索引上,而是在于把检索能力嵌入到真实业务链路里,处理好数据更新、版本管理、资源隔离和降级预案。Apache Doris帮我们把“检索”和“分析”统一到了同一个系统里,让我们少维护了一套技术栈,这在大规模的工程实践中,本身就是一种巨大的收益。

最后再说说我们在稳定性上做的两个小细节,供大家参考。一是线上查询全部走预编译SQL,避免字符串拼接带来的解析开销和不安全性;二是所有慢查询都做采样和审计,每周复盘一次,把不合理的查询模式优化掉。这两个习惯坚持下来,集群稳定性和资源利用率明显提升。如果你们也在考虑用Doris做类似的多模态检索场景,建议先拿真实业务数据和查询模式跑一遍压测,别只看官方benchmark,毕竟生产环境的复杂度,只有自己踩过了才知道。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询