Apache Doris在千亿级多模态向量检索中的实践 几年前刚接触多模态检索的时候我们内部还在用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 Doris2.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 ARRAYFLOAT 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做主键新版本写入会直接覆盖旧版本一旦新版本向量有问题就无法快速回退。保留版本字段后可以支持在线“灰度切换特征版本”这在模型迭代频繁的团队里是救命功能。向量字段用ARRAYFLOAT类型而不是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越大构建质量越好但耗时增加。我们在一开始采用了一组相对保守的参数M32efConstruction200查询时的efSearch128。这套参数在128维向量、千万级数据规模下召回率能打到95%以上Recall10。在千亿级规模下我们把数据按照其他业务维度做了分区让每个分区的向量数量控制在可管理的量级再在每个分区内部建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 kSELECT 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 100Doris的向量索引支持在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索引构建时需要把所有向量的图结构加载到内存中M32时单条128维向量在索引中额外的边表开销可能超过向量本身好几倍。千万条向量的索引构建内存消耗就是几个GB几亿条直接吃掉上百GB。这个问题的排查思路是这样的首先确认内存主要消耗在索引构建而不是查询然后查看Doris BE日志中索引构建相关告警再通过调整构建参数降低内存峰值比如降低M值、减少ef_construction、分批构建。遇到一批构建就OOM的极端情况可以用“先删除索引、分批导入数据、最后统一构建”的方式缓解。我们最终的参数调整为M24、efSearch128、efConstruction160内存峰值下降约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/HintBE节点内存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毕竟生产环境的复杂度只有自己踩过了才知道。