文搜图 + 3DGS:文字描述检索并加载空间资产
商品库里有 3000 多个 3DGS 资产,用户在搜索框敲一句"红色运动鞋",期望不是看到一张平面缩略图,而是直接把对应的高斯点模型加载进空间场景里转着看。这件事拆开看是两段能力拼起来的:前半段是 Core Vision Kit 的文搜图(Text Search),把文字映射到候选图;后半段是 Spatial Recon Kit 的 GSNode 加载,把候选图背后的 3DGS 资产 ID 拉进渲染场景。两段都不难,难的是中间那张"图 → 3DGS 资产 ID"的映射表怎么维护、检索结果排序怎么对齐用户预期。
能力面:文搜图 API 与资产映射
Core Vision Kit 在 API 26 暴露的 Text Search 接口位于@kit.CoreVisionKit,目前仍带 Beta 标记。基本用法是建一个检索会话,喂入查询文本,拿到一组带相似度分数的候选图。
// entry/src/main/ets/textsearch/TextSearchSession.etsimport{textSearch}from'@kit.CoreVisionKit';exportclassAssetTextSearch{privatesession:textSearch.TextSearchSession|null=null;asyncinit(imageDbUri:string):Promise<void>{// imageDbUri 指向预先建好的检索库(一组带索引的图)constopts:textSearch.SessionInitOptions={databaseUri:imageDbUri,// 检索库模式:本地嵌入向量库,离线可用mode:textSearch.Mode.LOCAL};this.session=awaittextSearch.createSession(opts);}asyncsearchByText(query:string,topK:number=5):Promise<textSearch.SearchResult[]>{if(!this.session){thrownewError('session not initialized');}constreq:textSearch.SearchRequest={queryText:query,topK:topK,// 返回字段:图片 uri、相似度分数、自定义元数据fields:['uri','score','meta']};constresp:textSearch.SearchResponse=awaitthis.session.search(req);returnresp.results;}release():void{this.session?.close();this.session=null;}}检索结果给的是图片 uri 和相似度分数,不是 3DGS 资产 ID。中间这张映射表得自己维护。我们的做法是在建检索库的时候,把每个商品图的meta字段塞进assetId,检索回来直接读meta.assetId去查 3DGS 资产表。
// 检索结果到 3DGS 资产的映射interfaceAssetMapping{imageUri:string;assetId:string;// 3DGS 资产 IDgsNodeUri:string;// GSNode 加载 uri,例如 file:///.../sneaker_red.plycategory:string;businessWeight:number;// 业务权重,用于排序调整}asyncfunctionretrieveAndLoad(query:string,searcher:AssetTextSearch,mappingTable:Map<string,AssetMapping>):Promise<spatialRender.GSNode|null>{constresults=awaitsearcher.searchByText(query,5);if(results.length===0){returnnull;}// 取 Top1,实际场景里要做排序调整,见第 44 篇consttop=results[0];constmapping=mappingTable.get(top.uri);if(!mapping){// 检索命中的图没有对应 3DGS 资产,这是常见坑,后面展开console.warn(`hit image${top.uri}has no 3DGS asset`);returnnull;}// 加载 3DGS 资产进场景,scene/root 需要外部传入returnawaitloadGsAsset(mapping.gsNodeUri);}约束面:Beta 标记、召回率、资产库成本
文搜图在 API 26 仍是 Beta。Beta 的含义不是不能用,是接口签名可能在下一个 API 版本调整。我们这边封装了一层AssetTextSearch,把textSearch的所有调用收在内部,真要迁移只改这一个文件。这个隔离层在 Beta 阶段必须有,否则接口一变全局搜替换很痛。
召回率是另一个硬约束。官方在通用数据集上给的召回率数字好看,但商品场景不是通用数据集。我们实测 500 个商品图、50 条 query,整体 Top5 召回率 62%,离宣传的 85% 差一截。差异来源主要是商品描述高度同质化——"红色运动鞋"和"红色跑步鞋"在嵌入空间里不一定近,"红色板鞋"可能更近。第 44 篇专门展开这个测法。
3DGS 资产库的构建成本是第三道坎。文搜图能召回的前提是检索库里要有图,3DGS 能加载的前提是资产表里有对应模型。一个商品要进这个链路,得先拍图、再重建 3DGS、再给图建索引。我们团队测下来一个中等复杂度商品(一双鞋)从采集到 3DGS 重建完成大约 8-12 分钟,3000 个商品全量建库是个不小的工程。实际落地建议先挑高频类目建库,长尾商品按需补建,否则上线节奏会被资产生产拖死。
建库流程里有个容易忽略的细节:检索库的图和 3DGS 重建用的采集图不一定是同一张。检索库要的是能代表商品外观的"封面图",通常是棚拍白底图;3DGS 重建要的是多角度环绕拍摄的视频帧。两张图来自不同工序,但都要挂在同一个assetId下。我们第一版把检索库的图直接拿去重建,结果重建质量很差——单张棚拍图视角单一,3DGS 重建需要多视角覆盖。后来改成两条独立工序:棚拍图进检索库,环绕视频进重建管线,用assetId做关联键。这个分工理清楚后,建库效率高很多。
检索库本身的体积也要关注。500 张商品图的嵌入向量库约 12 MB,3000 张约 72 MB。这个库要随应用打包或首次启动下载,体积太大影响首装体验。我们的做法是库分片按类目切,用户进哪个类目才下载哪个分片,首装只带高频类目的 8 MB。
场景落地:电商完整链路
把链路画出来是这样:
中间那个判断节点C是踩坑踩出来的。第一版我们假设检索命中的图一定有 3DGS 资产,结果线上跑起来一堆mapping not found警告。原因是商品库分两批建的,第一批 800 个有 3DGS,后面补的 2200 个只有平面图。检索库是全量 3000 张图,3DGS 资产表只有 800 条,必然对不上。降级路径不能省。
降级路径的 UI 处理也有讲究。从 3DGS 降级到平面图,视觉上有明显跳变——用户期望看到立体模型,结果给了张平面图,体感是"功能坏了"。我们的做法是降级时加一句解释:“该商品 3D 模型制作中,先看平面图”,把降级包装成"暂时"而非"不可用"。这个文案调整后,降级路径的用户投诉降了一半。技术降级要配合文案降级,不要默默给个差结果。
检索结果的展示顺序也要注意。Top5 结果如果全展示,用户可能不点 Top1 而点 Top3。我们的数据显示 Top1 点击率 61%,Top2 18%,Top3 11%,Top4-5 加起来 10%。如果只展示 Top1 自动加载 3DGS,省了用户选择步骤,但用户失去比较机会。折中做法是 Top1 自动加载,右侧缩略图列 Top2-5,用户想换点缩略图。这个交互比"5 个都让用户选"更顺滑。
完整可运行的页面组件:
这个页面有几个工程要点值得展开。第一是检索库初始化放在aboutToAppear里,首次启动时如果库文件不存在要从远端拉。拉取过程要显示进度,不能让用户对着空白页等。我们的做法是首次启动拉检索库时显示"正在准备商品搜索能力,约 X 秒",拉完缓存到沙箱,后续启动直接读本地。库版本要校验,远端更新后本地旧库要重新拉,否则检索结果用的是旧嵌入向量。
第二是 3DGS 加载完要把status切到ready,触发 UI 刷新显示 Component3D。这个状态切换不能忘,否则场景加载了但 UI 不显示,用户以为卡住了。ArkUI 的声明式 UI 靠@State驱动刷新,状态不切 UI 不动。
第三是搜索按钮的防重入。用户连点搜索按钮,会触发多次检索和加载,浪费资源且可能因为并发加载 GSNode 出错。按钮 onClick 里要加 loading 状态判断,搜索中禁用按钮。这个细节小但不能漏。
// entry/src/main/ets/pages/AssetViewerPage.etsimport{Scene,RenderContext,Node,Component3D}from'@kit.ArkGraphics3D';import{spatialRender}from'@kit.SpatialReconKit';import{AssetTextSearch}from'../textsearch/TextSearchSession';@Entry@Componentstruct AssetViewerPage{privatescene:Scene|null=null;privatesearcher:AssetTextSearch=newAssetTextSearch();@StatequeryText:string='';@Statestatus:string='idle';@StatehasGsAsset:boolean=false;@StatefallbackImageUri:string='';asyncaboutToAppear():Promise<void>{// 检索库放在应用沙箱,首次启动从远端拉取awaitthis.searcher.init('file:///data/storage/el2/base/files/asset_index.db');awaitthis.initScene();}privateasyncinitScene():Promise<void>{constctx=Scene.getDefaultRenderContext();if(!ctx)return;ctx.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);this.scene=awaitScene.load();}asynconSearch():Promise<void>{if(!this.queryText||!this.scene)return;this.status='searching';constt0=Date.now();constresults=awaitthis.searcher.searchByText(this.queryText,5);constsearchMs=Date.now()-t0;console.info(`text search cost:${searchMs}ms, hits:${results.length}`);if(results.length===0){this.status='no_result';return;}consttop=results[0];constmapping=awaitthis.lookupMapping(top.uri);if(!mapping){// 降级到平面图this.hasGsAsset=false;this.fallbackImageUri=top.uri;this.status='fallback_image';return;}this.status='loading_gs';constt1=Date.now();constgsNode=awaitthis.loadGs(mapping.gsNodeUri);console.info(`gs load cost:${Date.now()-t1}ms`);this.hasGsAsset=true;this.status='ready';}privateasynclookupMapping(imageUri:string):Promise<AssetMapping|null>{// 实际从本地 KV 表查,这里省略returnnull;}privateasyncloadGs(uri:string):Promise<spatialRender.GSNode>{constroot=this.scene!.rootasNode;returnawaitspatialRender.GSPlugin.loadGSNode(this.scene!,{uri,offset:0},root);}build(){Column(){TextInput({placeholder:'描述你想看的商品'}).onChange((v:string)=>{this.queryText=v;})Button('搜索').onClick(()=>{voidthis.onSearch();})if(this.status==='searching'||this.status==='loading_gs'){LoadingProgress()}if(this.hasGsAsset&&this.scene){Component3D({scene:this.scene}).width('100%').height('70%')}if(!this.hasGsAsset&&this.fallbackImageUri){Image(this.fallbackImageUri).width('100%').height('50%')Text('该商品暂无 3D 模型,展示平面图').fontSize(14)}}.width('100%').height('100%')}}真机数据
测试设备:麒麟 9030 旗舰机一台、麒麟 9020 一台、中端机(非 9020/9030 系列)一台。检索库 500 张商品图,50 条人工编写 query。
| 指标 | 9030 旗舰 | 9020 旗舰 | 中端机 |
|---|---|---|---|
| 检索耗时(Top5) | 180-240 ms | 220-300 ms | 450-680 ms |
| Top5 召回率 | 62% | 61% | 58% |
| 3DGS 加载耗时(单资产 ~12MB) | 320-410 ms | 380-520 ms | 不保证 |
| 端到端(输入到模型可见) | 580-740 ms | 690-880 ms | 降级路径 |
几个值得说的点。检索耗时中端机比旗舰机慢一倍多,但都能在 1 秒内出结果,这部分体验不是瓶颈。瓶颈在 3DGS 加载——12MB 的资产 9030 上也要 300+ ms,资产越大越明显。端到端 600-900 ms 用户体感是"有一下延迟但能接受",超过 1.5 秒就会有人开始重 tap。
召回率 62% 这个数字我们内部吵了一架。产品同学坚持说官方宣传 85%,是不是我们库建得不对。后来排查发现官方 85% 是在公开通用数据集上测的,商品图高度同质化、query 又偏口语,召回率掉到 60% 出头是正常的。这个差距在第 44 篇有完整测法。
中端机那一列写"不保证"是有原因的。Spatial Recon Kit 仅保证麒麟 9020/9030S/9030/9030 Pro 及后续旗舰芯片的体验,中端机查询 API 返回支持,但实际加载 3DGS 帧率只有 18-22 fps,转着看会卡。我们的策略是中端机直接走降级路径,给平面图,不强行上 3DGS。
设备分档的判断逻辑放在应用启动时跑一次,结果缓存。判断依据不是机型型号而是芯片型号——同一机型不同批次可能换芯片。deviceInfo.getChipSeries()拿到芯片系列,匹配 9020/9030 系列才走 3DGS 路径。这个判断要留升级口子,后续新芯片出了支持列表要更新,别写死成if (chip === '9030')这种。
检索耗时在不同 query 长度下也有差异。短 query(2-4 字)比长 query(8+ 字)快 30-50 ms,原因是短 query 的嵌入计算量小。这个差异对用户体验影响不大,但做耗时基准测试时要覆盖各种 query 长度,别只测中等长度 query 拿个平均数就交差。
踩坑与取舍
坑一:检索命中的图对应 3DGS 资产没建。 前面提过,检索库和资产表两批建的,对不上。修复办法是建库流程改成原子操作:一个商品要么图和 3DGS 都进,要么都不进,不允许只进图不进模型。已经入库的脏数据写个对账脚本扫一遍,把没资产的图从检索库剔除,否则用户搜到了却加载不了,比搜不到还糟。
坑二:检索结果排序和用户预期不一致。 用户搜"红色运动鞋",Top1 召回一张"红色高跟鞋",因为视觉上确实红、确实像鞋。相似度分数上高跟鞋就是比运动鞋高一点。纯靠相似度排序在商品场景里不行,得叠业务权重。简单做法是给每个类目一个 boost,query 命中"运动鞋"关键词时把运动鞋类目的分数乘 1.3。这个排序优化的完整实现放第 44 篇。
坑三:Beta 接口在 minor version 升级后签名变了。 我们在 API 26 minor 2 升到 minor 3 的时候,SearchRequest的fields字段从可选变成必填,没改的话编译过不去。Beta 阶段每次升级都要扫一遍 changelog,别指望接口稳定。
坑四:检索库更新和在线检索冲突。 商品库会增量上新货,检索库要跟着更新。但更新检索库时如果有用户正在检索,会出现检索结果引用了已被替换的图 uri,加载 3DGS 时 404。我们的做法是检索库用版本号管理,更新时新版本写另一个文件,原子切换指针,旧版本等当前所有检索会话结束后才清理。这个读写分离在并发场景不能省。
被放弃的方案: 一开始想用云端多模态大模型做检索,效果确实好,召回率能到 78%。但电商场景要求离线可用(地铁里、弱网环境),且云端调用有成本和延迟(单次 800-1200 ms 网络往返)。最后还是用端侧文搜图,召回率低一点但离线可用、延迟低。这个取舍在弱网场景占比高的用户群体里是对的,如果目标用户全是 WiFi 环境,云端方案可能更合适。
另一个被放弃的方案是"先分类目录再文搜图"。做法是 query 先过一层类目分类器,确定类目后在类目内做文搜图。理论上类目内检索召回率更高(同质化干扰小)。实测确实有效,鞋类类目内召回率从 71% 到 79%。但类目分类器本身有 12% 的误分类率,误分类的 query 直接掉到错误类目里搜,召回率反而更差。综合下来整体召回率没提升,工程复杂度倒是上去了。放弃,留作后续分类器精度上来再试。
小注意哦
- 文搜图接口带 Beta 标记,封装隔离层,禁止业务代码直接调
textSearch - 检索库与 3DGS 资产表必须同进同出,建库流程原子化
- 必须有降级路径:无结果、命中图无 3DGS 资产、设备不支持 3DGS,三段都要兜
- 中端机(非麒麟 9020/9030 系列旗舰)默认走平面图降级,不强行加载 3DGS
- 排序不能纯靠相似度,叠业务权重,具体做法见第 44 篇