时间在说话
← 返回文章列表
认领旧进度——模糊匹配为什么只提议不决定

认领旧进度——模糊匹配为什么只提议不决定

2026-11-01·约 5 分钟·1944 字旦阅数据设计

旦阅诞生记 · 14。BookKey 是全书内容的归一化哈希,它解决了"两台设备认出同一本书"。但它有一个绕不开的软肋:身份绑的是内容。文件换了个来源重新下载——补了两个番外、修了几个错字、换了个排版——哈希就变了。用户看到的现象是:明明读了 62% 的书,重新导入后"未读过",进度凭空消失。这一篇讲为此准备的兜底:模糊匹配。以及它为什么被设计成"只提议、不决定"。

身份越精确,脆弱性越集中

先承认问题的根源在我们自己的选择上。系列第二辑把 BookKey 做成了全书归一化哈希,换来的是跨端识别的零误判:同一段正文,在任何设备、任何时间算出的指纹都一样。但精确识别的代价是近似即失联——内容有任何实质差异,就是另一本书,进度互不相认。

什么时候会踩中这个雷?全是真实场景:

这些场景里书的"语义身份"没变——《诡秘之主》还是《诡秘之主》——变的是字节。用户心智里的"同一本书"和哈希定义的"同一本书"出现了分歧,而同步系统只会认后者。

三种解法摆在面前:硬认 BookKey(进度丢失,雷在用户脚边);书名当身份(跨书误合并,进度会被张冠李戴地覆盖,比丢失更糟);第三条路——模糊匹配出候选,交给用户裁决。选了第三条。

打分:三个证据,两个权重

服务端的 /api/progress/resolve 接口做的事很朴素:拿新书的 书名 / 作者 / 字数 三个证据,在最近 500 条进度里找出最像的旧记录:

double titleScore = wantTitle.Length == 0 ? 0
    : p.Title == wantTitle ? 1.0
    : (p.Title.Contains(wantTitle) || wantTitle.Contains(p.Title)) ? 0.6
    : 0;

double authorScore = wantAuthor.Length == 0 ? 0
    : p.Author == wantAuthor ? 1.0
    : (p.Author.Length > 0 && p.Author.Contains(wantAuthor)) ? 0.5
    : 0;

double charScore = 0;
if (charCount > 0 && p.CharCount > 0)
{
    var diff = Math.Abs(p.CharCount - charCount) / (double)Math.Max(p.CharCount, charCount);
    charScore = Math.Max(0, 1 - diff);
}

var score = titleScore * 0.6 + authorScore * 0.2 + charScore * 0.2;
if (score < 0.5) continue;

几个设计决定值得逐条交代。

书名占 0.6,且是强证据。 阅读场景里书名的区分度远高于作者(一个作者几十本书,一本书几个名字的反而少),所以它是唯一的"主导证据"。包含关系(Contains 双向判定)给 0.6 而非 1.0,是为了覆盖"书名带修饰"的常见变体——《三体》和《三体Ⅱ·黑暗森林》、《盗墓笔记》和《盗墓笔记(精校版)》互相包含,应该是"很像"而不是"就是"。精确相等才给满分。

字数是软证据,用相对差归一。 1 - |差| / max 让它落在 0~1:完全一样 1.0,差一半 0.5,差十倍趋近 0。字数不能当强证据——同一本书不同排版差几百字太正常——但它是最难伪造的证据:两本同名同作者的书,字数几乎不可能接近到难以区分。它在这里的实际作用是否决器:书名作者都对、字数差出一个量级的候选,总分会被拉到阈值之下。

阈值 0.5 是一道有意的缝。 0.6(仅书名包含)就能过线——这是故意的。因为"认错"的后果被 UI 层兜住了(下一节),而"漏认"的后果是用户找不到那条 62% 的进度。召回优先于精确,是"候选列表"这种交互形态才能享受的奢侈。

只在最近的 500 条里找。 个人书库的活跃进度不会超过这个量级,全表扫描换不来任何收益;OrderByDescending(ClientUpdatedAt) 保证真在追的书排在前面。

为什么必须由人裁决

接口返回的是 ResolveCandidate 列表——注意它带了什么:

public record ResolveCandidate(
    string BookKey,
    string Title,
    string Author,
    long CharCount,
    double Progress,        // 旧记录读到了哪
    long CharOffset,
    int ChapterIndex,
    string ChapterTitle,
    long TotalReadTimeMs,   // 累计读了多久
    long ClientUpdatedAt,
    double Score);          // 相似度,仅排序用

Progress、ChapterTitle、TotalReadTimeMs 都在里面——因为裁决者是人,人需要证据。UI 应该展示的是"《诡秘之主》· 读到 62% · 第 1043 章 · 累计 31 小时 · 相似度 0.86",让用户一眼认出"对,这就是我读的那本",而不是一个干巴巴的书名加分数。

关键的设计红线在这里:模糊匹配的结果永远不允许自动生效。理由是一个不对称性:

一边是不可逆的灾难,一边是可补救的不便,答案不言自明。这也是它和三层裁决(#11)的本质区别:裁决处理的是"同一本书的两份进度谁新谁旧",有客观判据;匹配处理的是"两本书是不是同一本",语义问题没有算法答案——算法可以提供嫌疑人,定罪的必须是用户。

顺带一提,相似度分数只用于排序,绝不展示为"匹配度 86%"之类的精确承诺——0.86 和 0.91 在用户眼里是"很靠谱"和"非常靠谱"的区别,实际上它们只是三个启发式证据的线性组合,担不起这种信任。

诚实的进度条:这条路修到哪了

这篇写的功能,目前的完成度是:服务端接口完整、Win 客户端 API 封装好了,UI 还没接。

/// <summary>按书名 / 作者 / 字数模糊匹配已有进度(换来源重新导入时认领)。</summary>
public async Task<List<ResolveCandidate>> ResolveAsync(string token, string? title,
    string? author, long charCount, int take = 5, CancellationToken ct = default)
{
    var q = $"/api/progress/resolve?title={Uri.EscapeDataString(title ?? "")}" +
            $"&author={Uri.EscapeDataString(author ?? "")}&charCount={charCount}&take={take}";
    return await SendAsync<List<ResolveCandidate>>(HttpMethod.Get, q, null, token, ct)
           ?? new List<ResolveCandidate>();
}

而且"认领"这个动作本身还没有闭环设计:用户在候选里点了"就是它"之后,客户端要做的其实不是把旧记录改名,而是把旧 BookKey 的进度落到本地这本新书(沿用 CharOffset / MaxAbsOffset / 章节与时长),然后走普通同步——新书用自己的新 BookKey 正常上报,服务端自然长出一条新记录,旧的继续留给老文件。这条路能复用全部现有管道,不需要服务端任何改动,但"认领后的本地回填"还没写。诚实记在这里,做完连着 UI 一起验收。

另外还有一个已知的改进方向,先记不先做:三个证据都太"标题党"了。更强的第二证据其实一直躺在数据里——章节表。同书不同版本,章节数和第一章标题几乎必然一致;跨书撞名,章节结构立刻露馅。等真实使用中模糊匹配开始出错,再考虑加第四个证据,不为还没出现的问题写代码。

小结

  1. 精确身份必有近似盲区——BookKey 零误判的另一面是"内容差一个字节就是陌生人",换来源场景必须留兜底;
  2. 多证据线性打分,召回优先——书名主导、字数否决、阈值留缝,因为误判的代价由"人审"这道闸兜着;
  3. 只提议,不决定——不可逆的操作(覆盖进度)必须由用户按门,算法可以递嫌疑人,不能替法官签字。

下一篇:排版内核——阅读器的心脏,分页。