资讯动态

甲骨碎片图像深度缀合:双分支检索与难负样本实践

发布时间:2026/10/23 1:35:56 来源:尧图企业网站定制
去年有段时间,我几乎天天盯着拓片照片看,眼睛都快看出重影了。项目起因很朴素:某文博单位想把一批甲骨碎片做自动缀合——同一版卜辞被时间打散成几十片残片,希望用算法把可能拼得上的碎片找出来。刚接需求时组里几个做视觉的都挺乐观,觉得不就是拼图吗,轮廓匹配加特征点配准,一周能出demo。结果第一周就翻车了:真实碎片的断裂面被风化啃掉一圈,有的地方旧伤叠新伤,轮廓根本对不上;更要命的是,绝大多数碎片之间压根没有配对关系,真正能拼上的比例低到个位数。这篇文章我就把从零做甲骨碎片图像深度缀合模型、攒数据集这一路的实况摊开写——数据怎么采、标注规范怎么定、模型为什么最后走检索式而不是分类、评估怎么做才不被漂亮指标骗到,还有那些只有真跑起来才会撞上的坑。做图像检索、细粒度匹配、跨模态特征的朋友可以直接把这套思路迁走;文博方向想把算法塞进整理流程的同行也能看清边界在哪里。1. 先搞清楚缀合任务的真面目:它和拼图差得远1.1 断裂面的信息缺失率直接决定任务上限普通拼图游戏里,两块拼图的接缝是完美互补的,你只要找形状凸凹正好的那一片。真实的甲骨碎片不是这样。我们在做标注的时候,让标注同学专门统计过断口的保存情况:一条完整的断口线上,还能保持原始几何形态、曲率和法向都可信的部分,平均只占三到六成,余下的要么被土蚀磨圆了,要么被后来的磕碰打出了二次缺损,要么就是表面有一层附着物把那一段形状糊住了。这个数字对整个技术路线的影响是决定性的。它意味着你不能指望精确的几何配准这条路,比如用ICP迭代最近点做点云精配准,或者用RANSAC做轮廓线段对齐。这些方法在理想断口上确实能跑出亚毫米级的结果,但只要有四成断口是坏的,配准就会立刻发散,你会得到一堆看起来很像、实际完全错的解。所以我们后来的做法是:把精确配准降级为候选生成之后的验证手段,而不是主路径。主路径必须是鲁棒的、容忍部分缺失的相似度学习——让网络去学这两块碎片大概率是同一版的关系这种整体判断,而不是这两条曲线数值上能不能重合。这是整篇文章里最重要的一次方向性取舍,后面所有的模型设计和数据构造都从这个判断推出来。另外还有一个容易被忽略的物理事实:甲骨是薄片,厚度往往只有两到五毫米。断裂面在三维上是窄长条,在二维照片上更是退化成了一条线。二维图像丢失了断裂面的朝向信息,你只能看到投影后的轮廓。这也是为什么我们最后把几何分支做成轮廓序列而不是断裂面网格——不是不想用更好的表示,是二维素材给不了。1.2 一对多歧义:一万片碎片里可能有一百个像的缀合任务的搜索空间有多大?假设手上有两万片碎片,两两组合就是接近两亿对。如果只考虑存在真实缀合关系的对,可能只有几千对,占比不到万分之一。这是一个极端稀疏的正样本问题。更麻烦的是,在这个巨大的负样本池里,存在大量看起来很像的干扰项。同一位刻手的字风接近,同一批出土的碎片材质颜色接近,同一版卜辞的残片往往尺寸也接近。这些碎片在特征空间里天然就靠得近,但它们并不相连。模型如果只学整体长得像不像,在这些干扰项上会反复出错,而且错得很自信。这就引出了一个很实际的设计要求:模型输出的不能是一个二分类概率,必须是一个可排序的相似度。因为使用者真正需要的不是这张和这张是一对,而是给我看这块碎片的二十个最有可能的搭档,我来判断。排序能力比判定能力更重要,这个认知也直接决定了我们放弃端到端配对分类器的选择,后面 3.4 节会详细算这笔账。1.3 几何与文字是两条必须并行的证据链做这行的人都知道,缀合判断在传统上是三条证据:断口是否连续、字口是否连贯、文例是否通顺。算法能碰的只有前两条,第三条涉及释读和语义推理,目前不在我们能力范围内,也不该硬去碰。断口连续是几何证据,字口连贯是文本/图像证据。这两条证据链的特点完全不同:几何证据在残片边缘,信息密度低但覆盖全;文字证据集中在笔画区域,信息密度高但大多数碎片上字很少,有的残片只剩半个字甚至空白。如果只做几何,一半以上的错误案例是中文字风的干扰;如果只做文字,大量无字碎片直接失效。所以我们的模型从第一天起就是双分支的,而且两个分支在训练中是各自受监督、最后再融合,而不是让网络自己去学怎么分配权重。原因很简单:数据量不够让端到端的注意力机制学到稳定的分配策略。两万片碎片能构造出的有效正样本对也就是万把对,这个量级支撑不起一个复杂融合模块的端到端学习。手工设计融合规则反而是更稳的选择。1.4 缀合的最小单元不是对,而是组这一点我们踩过坑。最初模型只关注两两配对,评估也只算两两的Recall。上线之后专家反馈说:你给我的候选里,同一块碎片同时和A、B、C三块都排第一,但这三块彼此矛盾,拼不成一个整体。问题出在逻辑层面。缀合关系是一个等价关系的闭包:如果A片和B片在位置P相连,A片和C片在位置Q相连,那B和C的空间关系就被确定了,必须自洽。而我们的模型输出的是一张松散的有向图,根本没有这种一致性约束。后来的处理分两层。离线评估阶段加了组一致性检查:把模型输出的高置信度对拿去做传递闭包,如果出现三片两两都连但无法构成一个平面排布的组合,就整组降权。在线展示阶段则把结果组织成候选组,而不是孤立的候选对。这个改动看起来很小,但专家接受度提升非常明显——因为人的判断方式本来就是整体性的,你给他一堆散对,他要自己在大脑里做闭包运算,很累。2. 数据集从零攒起:我在采集与标注上花的时间是写模型的五倍2.1 素材来源:拓片、实物照、三维点云三者的取舍做这个项目最花时间的不是写代码,是搞清楚能用什么素材。常见的三类来源我逐一说说实测感受。素材类型优势致命短板我们的取舍历史拓片扫描件数量大、笔画对比度高、有现成整理成果只有单面、无断裂面几何、扫描质量参差、不同拓本源背景不一致作为文字分支的主训练源实物高清翻拍有色彩和材质信息、可见边缘细节光照不均、需标定、断口仍无深度信息作为几何分支的补充三维点云扫描有真实断裂面几何、可计算法向与曲率设备与时长成本高、薄片扫描易失真、数据量小只在少量高价值碎片上采,做验证集这个表背后是一笔很现实的账:三维扫描一片碎片,从装夹、扫描、去噪到对齐,顺利的话二十分钟,不顺利要重扫。两万片全扫完是不可能的。所以我们的最终方案是二维为主、三维为辅:二维数据撑起训练规模,三维数据用来做几何分支的真值校验,看模型学到的东西到底有没有落在真实断口上。拓片源还有一个隐蔽的坑——不同拓本源。同一本书里扫描出来的拓片,纸张纹理、背景灰度、印刷网点都是同源的,模型很容易把这些当成特征学进去。我们有一次把训练集和测试集按随机划分切,Recall10 虚高到 0.8 多,后来按拓本源重新切,直接掉到 0.5 出头。这个教训后面 2.5 节还会展开。2.2 标注规范怎么定:碎片级、断口级、关系级三层没有现成的甲骨碎片缀合标注规范可以直接拿来用,我们是自己从零设计的。前后改了四版,核心是把标注拆成三层,每层的粒度和用途都不一样。碎片级标注最简单:每片一个唯一编号,记录来源编号、藏地(用内部代号)、尺寸、是否有三维数据、是否已缀合。这一层的价值是索引,没有它后面所有东西都对不上号。断口级标注是重头戏:在碎片轮廓上把原始边缘和断裂边缘分开。原始边缘是甲骨成型时就有的边,表面经过修整,曲率变化平缓;断裂边缘是新形成的,锯齿状、有毛刺、曲率突变频繁。这两类边缘在匹配时权重完全不一样——原始边缘不应该参与配对(它本来就不是断口),如果混进去,模型会去匹配两片碎片的外沿,那全是错的。实际操作上我们做了一个半自动工具:先用曲率阈值自动切分,标注员再手工修正。自动切分的准确率大概七成多,剩下的靠人。平均一片碎片的修正时间在两分钟左右,两万片就是六百多个小时,这块是整个项目里最硬的人力成本。关系级标注记录缀合关系三元组:碎片A、碎片B、连接位置。连接位置用断口段编号表示,不用坐标——坐标在不同成像之间对不齐,段编号是稳定的。已缀合的成果本身就是最好的标注来源,不需要专家从零判断,只需要把既有成果数字化,这一点省了大量时间。2.3 正样本从哪来:已缀合成果的反向拆解正样本是这个项目里最稀缺的资源,也是我们唯一不需要凭空创造的东西。历史积累的缀合成果就是标注——既然专家已经确认这十片是同一版的,那它们两两之间就存在正关系。一个 n 片的缀合组能拆出 n(n-1)/2 个正对。听起来很划算,十片就能出45对。但这里有个必须刹车的点:不能把所有拆出来的对都放进训练集。原因有两个。一是分布偏差。同一个组里拆出来的对,共享同一个拓本源、同一批成像条件、同一种字体风格,冗余度极高。你在一个20片的组里挖出190对,信息量可能还不如190个来自不同组的对。我们的做法是每个组最多采样固定数量(通常是组内碎片数),并且强制组间均衡。二是泄漏。同一组拆出的对如果一部分进训练、一部分进测试,模型等于在测试时见过答案。所以划分必须以缀合组为单位整体划分,不能以对为单位。这一点看起来是常识,但真做起来,尤其是数据流水线写得比较随意的时候,特别容易漏。2.4 难负样本的挖掘:三类最容易骗过模型的干扰项随机采的负样本对训练价值很低,因为大部分随机对连人眼都觉得不像,模型学不到东西。难负样本才有用。我们实际用到的难负样本有三类,来源和挖掘方式都不一样。第一类是同字风负样本。同一时期、同一刻手风格的碎片,笔画形态相近。这类负样本的挖掘方式是:用文字分支的中间特征向量做检索,取每块碎片的 top-30 近邻里那些被标注确认为无缀合关系的,收进难负样本池。第二类是同形负样本。轮廓长度、宽高比接近的碎片。挖法是拿几何分支的轮廓描述子(等弧长重采样后的特征)做最近邻。这类的干扰特别强,因为很多甲骨碎片就是大小差不多的长条形。第三类是同源负样本。这一类比前两类更隐蔽,指的是出土位置接近、材质颜色接近、甚至原本可能就是同一块,但已经确认不直接相连的碎片。这类靠图像特征挖不出来,得靠元数据。我们的做法是在 batch 内构造时,对同源碎片做额外的负对采样。三类难负样本的比例我们调过几轮,最后稳定在 1:1:0.5 左右(同字风 : 同形 : 同源)。同源比例低不是因为它不重要,是因为元数据缺失严重,能配上的样本本来就少。2.5 划分数据集时最容易犯的泄漏错误前面提了按拓本源划分,这里把三个泄漏源一次说清。拓本源泄漏:同一个扫描批次的图像有相同的纸张底纹和噪点分布,模型会把它当捷径。必须按源分组划分。碎片泄漏:同一片碎片往往有多张成像(不同角度、不同光照),如果一张在训练一张在测试,等于送分。按碎片ID分组划分。缀合组泄漏:同一缀合组内的正对共享信息,必须整体划分。这个是最容易漏的,因为数据结构上对是平的,代码里一个 random_split 就把组打散了。正确的划分方式是三层分组:先按源分,再在源内按缀合组聚类,最后整体分配到 train/val/test。我们用的比例是 7:1.5:1.5,验证集偏大的原因是这个任务的指标方差很大,验证集小了看不住。3. 模型架构选型:为什么最后走的是双分支编码 全局检索 精排3.1 轮廓分支:把不规则边界变成等弧长序列几何分支的输入不是整张图,而是提取出来的断裂边缘。整条流水线是这样走的。第一步做前景分割,拿到碎片的二值掩码。这一步在拓片上比较麻烦,因为拓片是黑白反相的,笔画是黑的、纸是白的,和实物照片的逻辑反了。我们的处理是先判类型再分别二值化,别指望一个自适应阈值走天下。第二步用轮廓追踪算法(OpenCV 的 findContours,取最大连通域的外轮廓)拿到闭合轮廓点序。第三步是关键:把轮廓切成原始边缘段和断裂边缘段。做法是计算每个点的离散曲率,曲率绝对值超过阈值的连续区段标记为候选断裂段,再做长度过滤和形态学平滑。这一步的效果直接决定几何分支的上限,前面 2.2 节说的标注工具就是干这个的。第四步做等弧长重采样。原始轮廓点的间距不均匀(转角处密、直边处疏),直接送网络会有严重的尺度偏差。我们按累计弧长均匀取 N128 个点,然后对每个点计算三个量:切向角、法向、曲率。这三个量组成一个 3 维向量,再拼接相邻点的差分,得到一个 128×6 的序列。import numpy as np def resample_contour(contour, num_points128): 等弧长重采样轮廓点 pts np.asarray(contour, dtypenp.float64) diffs np.diff(pts, axis0) seg_len np.linalg.norm(diffs, axis1) cum np.concatenate([[0.0], np.cumsum(seg_len)]) total cum[-1] targets np.linspace(0, total, num_points, endpointFalse) idx np.searchsorted(cum, targets, sideright) - 1 idx np.clip(idx, 0, len(pts) - 2) ratio (targets - cum[idx]) / (seg_len[idx] 1e-8) new_pts pts[idx] ratio[:, None] * diffs[idx] return new_pts def contour_features(pts): 计算切向、法向、曲率三类特征 tang np.roll(pts, -1, axis0) - np.roll(pts, 1, axis0) tang / (np.linalg.norm(tang, axis1, keepdimsTrue) 1e-8) norm np.stack([-tang[:, 1], tang[:, 0]], axis1) curve np.arctan2(tang[:, 1], tang[:, 0]) curv np.abs(np.diff(np.unwrap(curve), appendcurve[-1])) curv np.convolve(curv, np.ones(5) / 5, modesame) return np.concatenate([tang, norm, curv[:, None]], axis1) # [128, 5]这个序列送一个 1D 卷积网络,五层,通道数 32→64→128→128→256,每层后接 BatchNorm 和 ReLU,最后做一次沿序列维度的注意力池化,得到 256 维向量。为什么用注意力池化而不是全局平均?因为断裂边缘的不同区段信息量差别很大,缺损段基本没有信息,平均池化会被稀释。注意力池化让网络自己学哪些区段更可信。3.2 拓片分支:让网络学会看跨碎片的笔画衔接文字分支的建模思路和几何完全不同。我们要找的不是整体风格像,而是边缘附近的笔画能不能接上。具体做法:BFS 从断裂边缘往碎片内部扩张一个固定宽度的环带(我们取碎片短边的 15%),只在这个环带内裁 patch,送进一个轻量 CNN(ResNet-18 改输入通道为1,去掉最后的全连接),输出 128 维特征。整块碎片用所有环带 patch 特征的注意力加权汇聚,再经过一层投影到 256 维,和几何分支对齐。这里有个细节值得说:为什么不直接对整块碎片做全局特征?因为全局特征会被这块碎片上有什么字主导,而不是边缘附近能不能接。我们做过对比实验,全局特征的同字风混淆率比环带特征高出一大截——两块完全无关的碎片,只要上面都有王字,全局特征的余弦相似度就能上 0.8,而环带特征在同样情况下只有 0.3 左右。环带宽度这个参数也值得调。太窄了裁不到完整笔画,太宽了引入碎片中心区域的内容把信号冲淡。我们试过 8%、15%、25% 三档,15% 在验证集上最好,8% 次之,25% 明显掉。这个结论跟碎片尺寸有关,如果你的碎片平均尺寸差别很大,建议按相对尺寸而不是绝对像素来定。3.3 两个分支怎么融合才不打架融合方式我们试了三种,结论比较明确。融合方式验证集 Recall10问题早融合(特征拼接后接FC)0.41两个分支量纲差异大,几何分支主导,文字分支学不动晚融合(相似度加权求和)0.58稳定,但权重需要手工调,泛化到新批次要重调交叉注意力融合0.53参数多、过拟合,小样本下不如晚融合最后选的是晚融合,但做了一点改进:权重不是常数,而是根据该碎片是否有足够文字信息动态调整。判断依据很简单,统计环带区域的笔画像素占比,占比低于阈值(我们用的 2%)就把文字分支权重压到 0.2,几何分支提到 0.8;占比高的时候反过来。这个规则土,但它有效,而且可解释——专家问起来你能说清楚为什么这块碎片主要是靠形状匹配的。用可学习温度做相似度校准:import torch import torch.nn as nn import torch.nn.functional as F class DualBranchMatcher(nn.Module): def __init__(self, dim256, init_temp0.07): super().__init__() self.log_temp nn.Parameter(torch.tensor(np.log(1.0 / init_temp))) self.w_geo nn.Parameter(torch.tensor(0.0)) self.w_txt nn.Parameter(torch.tensor(0.0)) def forward(self, geo_emb, txt_emb, txt_ratio): geo_emb F.normalize(geo_emb, dim-1) txt_emb F.normalize(txt_emb, dim-1) sim_geo geo_emb geo_emb.t() sim_txt txt_emb txt_emb.t() # 无字碎片压文字分支权重 w_txt torch.sigmoid(self.w_txt) * (txt_ratio 0.02).float() w_geo torch.sigmoid(self.w_geo) sim (w_geo * sim_geo w_txt * sim_txt) / (w_geo w_txt 1e-8) return sim * torch.exp(self.log_temp)3.4 检索式框架 vs 端到端配对分类:我为什么放弃后者一开始我们真的做了一版配对分类器:把两块碎片的特征拼接,过一个 MLP,输出是否缀合的概率。训出来感觉还行,验证集 AUC 0.87,但一上线就崩。崩的原因有三个,我觉得值得展开说。第一是封闭世界假设失效。分类器是在固定碎片集合上训的,新入库的碎片它没见过。理论上特征提取是共享的,新碎片也能提特征,但分类器的决策边界是在旧样本分布上拟合的,新样本一来就偏。检索式框架没有这个问题,新碎片提完特征直接入库,和全库比,天然支持开放集。第二是负样本极不平衡没法在分类框架里解决。两亿对里几千对正样本,分类器会迅速学到全判负的退化解。你可以用 focal loss 或者重采样缓解,但本质问题没解决——真正的困难负样本在总负样本里占比太小,focal loss 也压不住。第三是输出形式不对。使用者要的是排序列表,分类器给的是概率。虽然概率也能排序,但概率的校准很差(因为训练分布和实际分布差太多),排出来的顺序不可靠。直接优化排序目标(度量学习)比优化分类再排序要直接得多。检索式框架的实现就是标准三段:离线全量提特征建索引、在线查 top-K、对 top-K 做精排。两万片、256 维、用 FAISS 的 IVF-Flat 索引,单次查询在毫秒级,完全够用。精排这一步是后加的,原因是 top-K 里位置靠前的候选往往整体像但局部不接。精排用的是更细的几何验证:把候选对的断裂段做局部点序列对齐,算一个断口连续度分数,和主相似度加权。这个模块计算量大,只对 top-50 做,不影响整体速度。3.5 损失函数与难样本调度策略损失函数最后用的是 ArcFace 加 triplet 的组合。ArcFace 负责把类内收紧(同一缀合组视为同一类),triplet 负责把难负样本推开。为什么两个都要?只用 ArcFace,类间边界是均匀的,但我们的负样本难度差异极大,均匀边界对付不了难负样本。只用 triplet,训练容易震荡,因为 triplet 的采样策略对结果影响非常大,而我们的正样本又少得可怜。两个一起用,ArcFace 提供一个稳定的全局结构,triplet 在上面做局部微调。难样本调度我做了个比较土但有效的策略:每个 epoch 结束后,用当前模型给训练集里的负样本对打分,取分数最高的前 20% 作为下一轮的重点采样池,但每轮只从池里抽一部分(避免过拟合到固定的困难集合)。这个策略在第三到第五个 epoch 效果最明显,Recall10 一轮能涨 3 个点;再往后收益就平了。4. 训练过程中的真实曲线与调参笔记4.1 第一轮就遇到的表征塌缩第一次完整训练跑完,loss 确实在降,从 2.3 降到 0.4,看起来很美。但我们随手看了一眼 embedding 的平均余弦相似度,发现是 0.94。这意味着所有碎片在特征空间里挤成一团,模型学到的几乎是个常数映射。表征塌缩在小样本度量学习里非常常见。诊断方法很简单,不用看 loss:算随机抽取的一千对嵌入的余弦相似度均值,如果超过 0.85,基本可以判定塌了。算正样本对和随机负样本对的相似度差值,如果小于 0.05,排序能力等于没有。看 top-1 检索命中率是否和随机基线持平。原因排查下来是三个叠加:batch 太小(当时用 16)、负样本不够难、还有一处 BN 用错了位置(在相似度头之前又加了一层 BN,把特征压得更平)。三个一起改之后,平均相似度回到 0.3 左右,检索才开始有信号。4.2 batch内负样本比例对Recall的直接影响这个参数我们认真扫过一遍,结论比较清晰:batch 内的正负比例比 batch 的绝对大小更重要。batch 大小正对数量负对比例Recall1Recall10328隐性0.090.316416隐性0.170.466432隐性0.210.5212832隐性0.200.5012864隐性0.230.55可以看到,batch 从 64 加到 128 但正对数量不变(32),提升非常有限;把正对数量从 16 提到 32,提升明显。原因在于 triplet/ArcFace 的有效监督信号来自正对,负对是从整个 batch 里自然组合出来的,batch 越大负样本越多,没有正对撑着也白搭。最后定的是 batch128,正对 64,在单卡上刚好不爆显存。4.3 学习率、warmup和嵌入维度学习率我们用 3e-4,配合 2 个 epoch 的线性 warmup,余弦退火到 1e-6。这个配置没什么特别的,重训的话基本可以直接抄。嵌入维度倒是值得说一句。我们试了 128、256、512 三档,256 最好,512 反而略差。原因应该是数据量不够,高维空间里正样本对太稀疏,负样本采样根本覆盖不到。这一点和常规认知有点反,但在小样本检索任务里很常见——维度不是越高越好,得和数据规模匹配。经验法则是:每个类别的样本数乘以类别数,再除以 16,大致是嵌入维度的合理上限。还有一个容易忘的点:学习率对两个分支要分开设。几何分支是从零训的,可以用 3e-4;文字分支是 ImageNet 预训练权重微调的,用 3e-5 就够,用大了会直接把它训崩(第一版我们统一用 3e-4,文字分支三个 epoch 后输出就退化成常数了)。4.4 数据增强里那些绝对不能做的操作这一段我认为是整个项目里最值得单独拎出来的经验,因为很多常规的视觉增强手段在这类数据上不仅无效,还会直接损坏语义。水平翻转和垂直翻转绝对不能用。文字是镜像的,翻转之后模型会学到镜像的字也是字,然后在真实数据上疯狂误判。这条是红线。大角度旋转要限制。甲骨碎片有明确的正方向(文字朝向),虽然实际摆放可能有偏差,但不会转 90 度。我们把旋转限制在 ±8 度,超过这个范围就开始掉点。色彩抖动要极弱。拓片基本是灰度的,稍微抖动一点还能接受(模拟不同扫描仪),但饱和度、色调的抖动是无意义的,反而会破坏笔画边缘的锐度。Random Erasing / CutMix 禁用。这两种增强会把断裂边缘区域随机抹掉,而断裂边缘正是我们唯一关心的区域。抹掉之后模型被迫去看碎片内部,学到的是全局风格特征,正好是我们前面说要避免的东西。有效的增强有哪些?我们实测下来真正有用的是三类:小范围仿射变换(旋转±8、平移±5%、缩放±10%)、轻度高斯噪声和模糊(模拟不同成像质量)、以及断裂边缘的小段遮挡(模拟缺损)。第三类效果最明显,单独加上去就能让 Recall10 涨将近 4 个点,因为它直接对应了真实场景里的断口缺失。5. 评估这件事:离线指标漂亮不代表专家认可5.1 RecallK、mAP与缀合可用率的区别我们一开始只用 RecallK 评估,模型在测试集上 Recall10 到了 0.71,当时挺高兴。结果把 top-10 候选列表发给专家看,专家的评价是这里面大概有一半是瞎猜。问题在于指标定义和目标不一致。Recall10 只关心正确的那个有没有进前十,它完全不管第十一名和第一名之间的差距,也不管列表里其他九个有多离谱。专家看列表的时候,是在扫一眼判断有没有值得细看的,如果前十里有八个明显荒谬,他会直接放弃这个工具。所以我们加了一个新指标,叫缀合可用率:在 top-K 里,至少存在一个被专家判定为值得进一步核验的候选的比例。这个指标需要人工标注,成本高,但只有它能反映真实可用性。我们是在验证集上抽了 800 个查询做的,每个查询看 top-20,总工作量大概四十个工时。两个指标的差距很说明问题:Recall10 0.71,缀合可用率 0.54。中间那 17 个点,全是模型蒙对的——正确项在列表里,但排序位置很靠后,前面的全是垃圾。5.2 候选列表怎么排才让人愿意用这 17 个点的差距逼着我们重新想排序策略。最后做了三件事。第一件是分段呈现,不再给一条平铺的列表。top-3 作为高置信推荐,3 到 10 作为建议核验,10 到 20 作为参考。分段的依据不是绝对分数,是分数分布的间隙——用一维聚类找最大的两个跳变点当切分位置。这样专家看到的结构和他自己的判断习惯对得上。第二件是加解释。每个候选旁边显示三个小指标:几何相似度、文字相似度、断口连续度。数值不需要精确,条形图就够。有了解释,专家能快速判断这条为什么排这么前,也能一眼看出这条是靠字风蒙的。第三件是去重。同一缀合组内的多个碎片可能同时出现在候选里,而这些候选彼此高度相关。我们把同组候选合并成一个条目,展开才看细节。这个改动纯粹是减少视觉负担,但效果意外地好。三件事做完,缀合可用率从 0.54 提到了 0.68,而且专家愿意用了——这一点比指标本身更重要。5.3 人工复核反馈怎么回流成训练信号专家每次复核都是免费的标注。关键是把这个信号结构化地收回来。我们的做法是在界面上给三个按钮:确认相关、确认无关、不确定。确认相关的样本进正样本池(但要注意采样平衡,不能全收),确认无关的进难负样本池,不确定的先搁置,攒够一定数量再人工过一遍。有个细节必须处理:专家判断也可能错。人看走眼是常事,尤其在碎片特别小、字迹特别模糊的时候。所以我们对反馈做了置信度分级——如果一条确认相关和模型的高分预测一致,权重高;如果和模型预测严重冲突(模型给0.02,专家说相关),先不急着收进训练集,标记出来等第二个人复核。这个机制挡掉了一批噪声,虽然增加了流程复杂度。反馈回流的另一个价值是能监测漂移。我们统计每条反馈相对模型预测的偏差,如果某个月偏差均值明显上升,说明最近入库的碎片分布变了(比如换了一个来源地),该重训了。这个信号比看离线指标敏感得多。6. 部署与协同:算法只做候选生成,判断权必须留给人6.1 检索服务的工程结构线上结构不复杂,但要跑稳需要注意几个点。整体是离线建索引、在线查、精排三步。离线侧:新碎片入库触发特征提取(两个分支各提一次),写入特征库,定期重建 FAISS 索引。索引我们用的 IVF-Flat,nlist 取 sqrt(N) 的近似值,两万片大概 140。为什么不量化压缩?因为两万片的规模下,Flat 的检索延迟已经在一毫秒级别,压不压缩对速度没意义,但压缩会损失精度,而精度在这个任务里直接对应可用性。在线侧:查询请求进来,先查几何分支的 top-200,再查文字分支的 top-200,取并集(通常 300 左右),用融合规则算综合分,取 top-50 送精排,精排后取 top-20 返回。整个链路最慢的是精排,单次查询大概 40 毫秒,主要耗在局部轮廓对齐上。我们把精排做成了可配置项,如果用户只要快速浏览,可以关掉精排,延迟降到 5 毫秒以内。import faiss import numpy as np # 离线:建索引 dim 256 nlist 140 quantizer faiss.IndexFlatIP(dim) index faiss.IndexIVFFlat(quantizer, dim, nlist, faiss.METRIC_INNER_PRODUCT) index.train(geo_features.astype(np.float32)) index.add(geo_features.astype(np.float32)) index.nprobe 16 # 在线:查询并做候选并集 def retrieve(query_geo, query_txt, k200): _, geo_ids index.search(query_geo[None].astype(np.float32), k) _, txt_ids txt_index.search(query_txt[None].astype(np.float32), k) merged np.unique(np.concatenate([geo_ids[0], txt_ids[0]])) merged merged[merged 0] return merged # 送给融合与精排6.2 增量入库与索引更新新碎片是持续进来的,不能每次都全量重建。我们的策略是双缓冲:主索引只读,新碎片先写进一个小的 Flat 索引(增量池),查询时两边都查,结果合并。增量池攒到一定规模(比如 500 片)或者距离上次重建超过一周,就做一次全量重建,建好之后原子切换。这个方案有个小坑:增量池和主索引里的特征必须来自同一个模型版本。如果这期间模型更新了,新老特征的空间不一致,合并结果会很难看。所以我们的模型版本和索引版本是绑定的,模型一更新,全量重建是强制的,不能偷懒。为此每次重训我们都安排在一个低峰时段,重建两万片的索引大概十分钟,可以接受。6.3 一个可复用的经验清单最后把这一路攒下的、我认为换到别的细粒度匹配任务里也能用的经验列一下。数据侧:标注成本永远被低估,预留至少三倍时间;划分必须按组,不能按样本;正样本的冗余度要主动控制,不是越多越好;增强要针对任务的关键区域设计,通用增强套路往往起反作用。模型侧:样本稀少时优先考虑检索式而不是分类式;双分支比单分支稳,但融合规则宁可手工设计也别全交给网络学;嵌入维度和数据规模要匹配,小数据别上高维;两个分支的学习率一定要分开设。评估侧:离线指标和真实可用性之间一定有落差,越早用人工评估校准越好;候选列表的呈现方式对可用性的影响,不亚于模型本身;人工反馈要结构化回收,但也要防噪声。工程侧:薄片类物体的三维扫描成本远超预期,能二维解决就别上三维;索引和模型版本必须绑定;精排这种重计算模块要能开关,给用户自己选延迟和精度的平衡。有一点是我做完这个项目之后才真正想明白的:这个任务的价值不在于让算法去决定哪两片能缀合,而在于把专家从海量无意义的比对里解放出来,让他只在几十个有希望的候选上花判断力。模型做到 0.7 的 Recall 已经很能干活了,剩下的 0.3 交给人的眼睛和经验。把工具定位成助手而不是裁判,整个系统的设计思路、指标设计、交互方式都会跟着变,而且变得更实用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑