我对付条目-分类关系的办法
今天写的一篇知乎专栏 知乎的话题屏蔽功能真是堪忧 里提到了一个「话题」被屏蔽时,其子孙(descendant)话题应该如何处理的问题。
事实上,知乎上存在着「关注一个话题能够看到其子孙话题的内容,屏蔽一个话题,却没有默认屏蔽其子孙话题」这样的不一致。也存在添加话题时的不一致:比如提问「茶党」相关的问题时,有人会选择在加上「茶党」话题的同时,也添加「美国政治」、「共和党」、「保守主义」等话题;也有人考虑到既然「茶党」是后面那些话题的子孙话题,于是关注了那些话题的用户自然会看到「茶党」话题下的问题,也就没有必要再另行添加那些话题。
类似的问题也一直困扰着我。
问题描述
每个话题/标签/分类,称之为一个 tag(分类)。Tag 之间可以互为父子关系,每个 tag 可以有多个父 tag,也可以有多个子 tag。Tag 间通常构成有向无环图。
每个问题/文章/A 片,称之为一个 entry(条目)。每个 entry 可以属于多个 tag。
这是一个很常见的问题,除了知乎的问题-话题外,WordPress 的文章-分类就符合这个问题,Wikipedia 的词条-分类也是。当然还有我的 A 片管理器中的视频-标签。
最简单是实现方式就是用四张数据表,一张存储 tag,一张存储 entry,一张存储 tag 和 entry 的对应关系,一张存储 tag 间的从属关系。
基本的情况
在我写 A 片管理器的时候,也遇到这个问题。最早,我也是在每次查询某个 tag 的 entry 时,递归查询其 descendant tag 的 entry。然而这样使得这个查询的速度慢了很多。
当然,这可以通过合适的索引和缓存来缓解。但由于 A 片管理器并没有一个持续运行的进程,而在硬盘上占用的空间也希望尽可能小,避免冗余,再加上我想让 A 片管理器本身小、简洁而可靠,所以我并不希望折腾这些。
另一方面,如果我要通过 tag 间的重合度来提示「相似的 entry」的话,事情就更加复杂了——比如 entry 1 属于 tag A,entry 2 属于 tag B,而 tag A 和 tag B 都同属 tag C 的 descendant 的情况下,实际上 entry 1 和 entry 2 是有一些相似性的,但实际上计算起来就会比较复杂。
自动添加父 tag
于是我改变了我的策略,采取了一个很独特的做法:把某个 tag 添加到一个 entry 时,自动向上遍历该 tag 的所有 ancestor,并且把他们都加到那个 entry 的 tag 列表里。
这样的话,就避免了对于同一个 entry,是否应该在添加 tag 的同时也添加其父 tag 的可能的不一致性。
代码写起来也轻松很多:查询一个 tag 下所有 entry 时,也快了不少——根本不用递归遍历其子 tag。更棒的是,查询两部 A 片的相似度时,直接计算他们的 tag 列表间的余弦相似度就行。
当添加 tag 间的从属关系的时候,我会启动一个后台线程自动补全每个 entry 的 tag 列表。譬如我把 tag D 设为 tag E 的子 tag 的时候,我会自动把所有属于 tag D 的 entry 也都自动加到 tag E 里面。
当然这样的解决方法是非常有局限性的。在数据量很大、用户可以随意更改 tag 从属关系的情况下,这个「后台线程」性能上很难支撑;同时进行多个 tag 从属关系的修改时如何保持一致性,也是极难解决的。不过对于我的 A 片管理器来说,不用考虑这些事情。
如果移除呢?
如果我此时又把 tag D 从 tag E 的子 tag 里面中移除,我是否该再遍历一遍刚才那些 entry,把 tag E 一一移除呢?或许不该,因为有些 entry 可能一开始就已经属于 tag E,而并非是刚才由后台线程添加进去的。
同样,如果我把 tag D 某个 entry 的 tag 列表中移除,是否也该移除 tag E 呢?
解决这个问题的办法是,追踪 tag E 到底是人为加上的,还是由于 tag 的从属关系而自动加上的。如果是前者,就不移除;如果是后者,则移除。
这有点像是软件包管理器管理依赖的方式——如果某个包是用户手动安装的,就不会被移除。而如果某个包是在安装其它包时由于依赖关系而自动安装的,就可以通过 apt-get autoremove
一类的方式来移除。当然这个标记也可以使用 apt-mark
一类的命令来更改("mark/unmark a package as being automatically-installed")。
这样的话,就完美解决了我 A 片管理器这个 case 里的所有问题。