分享 如何实现一个信息架构友好的标签系统

hooopo · 2020年10月14日 · 最后由 363676727 回复于 2021年08月06日 · 6173 次阅读
本帖已被管理员设置为精华贴

信息架构设计是对信息进行结构、组织方式以及归类的设计,好让使用者与用户容易使用与理解的一项艺术与科学。信息架构是内容性网站的基石,包括组织系统,标签系统,导航系统,搜索系统,推荐系统等。下面来谈谈标签系统的信息架构设计。

优选术语和同义词环

举个简单的例子,一个大众标签网站,任何人可以对网页打标签,任何人可以搜索和通过标签过滤网页。这个问题看似很简单,但实现起来并不容易,因为不同用户对标签术语的选择并不相同,比如拿「开源软件」这个标签来说,可选术语包括:opensourceOpen SourceOSS开源开源项目开源软件open source software等。

而我们的需求是,即使不同人使用的术语不同,用户在使用标签过滤或搜索的时候,使用其中任意一个术语,就可以找到相关的网页。

这类需求就要引入信息架构里的同义词环圈(synonym ring)和优选术语(preferred terms)的概念。

下面来简单的写一下使用 Postgres 实现这样的标签系统的方法:

表结构:

# tagging

## bookmarks

name                          | column_type | ext_info                  | ref | default | comment
----------------------------- | ----------- | ------------------------- | --- | ------- | -------
id                            | integer     | [pk, increment, not null] |     |         |        
url                           | integer     | [null]                    |     |         |        
title                         | integer     | [null]                    |     |         |        
user_id                       | integer     | [null]                    |     |         |        
cached_tag_names              | varchar     | [null]                    |     |         |        
cached_tag_ids                | int[]       | [not null]                |     | {}      |        
cached_tag_with_aliases_ids   | int[]       | [not null]                |     |         |        
cached_tag_with_aliases_names | varchar     | [null]                    |     |         |        

## tags

name         | column_type | ext_info                  | ref              | default | comment
------------ | ----------- | ------------------------- | ---------------- | ------- | -------
id           | integer     | [pk, increment, not null] |                  |         |        
name         | varchar     | [null]                    |                  |         |        
preferred_id | integer     | [null]                    | [tags.id](#tags) |         |        
auto_extract | boolean     | [not null]                |                  |         |        

## taggings

name        | column_type | ext_info | ref                        | default | comment
----------- | ----------- | -------- | -------------------------- | ------- | -------
id          | integer     | [null]   |                            |         |        
tag_id      | integer     | [null]   | [tags.id](#tags)           |         |        
bookmark_id | integer     | [null]   | [bookmarks.id](#bookmarks) |         |        

ERD:

Ruby:

def sync_cached_tag_ids
    last_tags = tags.preload(:aliases).reload
    update(
      cached_tag_with_aliases_ids: last_tags.map { |t| [t, t.aliases.to_a] }.flatten.map(&:id).uniq,
      cached_tag_with_aliases_names: last_tags.map { |t| [t, t.aliases.to_a] }.flatten.map(&:name).uniq.join(", "),
      cached_tag_ids: last_tags.map(&:id),
      cached_tag_names: last_tags.map(&:name).join(", ")
    )
  end

原理就是每次 tagging model 有更新的时候,把 aliases ids 和 names 一起同步到缓存字段里。过滤的时候使用 Postgres 的数组&&操作符:

def self.tag_filter(scope, tag_name)
  tag = Tag.find_by!(name: tag_name)
  tag_ids = tag.self_with_aliases_ids
  scope.where("cached_tag_with_aliases_ids && ?", Util.to_pg_array(tag_ids))
end

再加个 GIN index:

add_index :bookmarks, :cached_tag_with_aliases_ids, using: :gin

后台管理优选术语和同义词环

后台 Admin 管理同义词标签:

还可以增强的一个功能是,可以基于文本相似和协同过滤的方式,把可能是同义词的标签列出来,便于管理员管理。

标签自动提取

标签自动提取非常有意思,目前使用基于白名单的方案,如果网页上有和已有标签匹配上的内容,我们就打上标签。不过也需要参考词频和权重,还有黑名单。比如像 HTML,HTTP,HTTPS 这些常见的词,打上标签没有任何意义。 这个过程和全文检索是一个相反的过程,拿文档去匹配关键词,然后按相关度打分取 TopN。

class ExtractTag
  prepend SimpleCommand
  include ActiveModel::Validations

  attr_reader :bookmark

  def initialize(bookmark)
    @bookmark = bookmark
  end

  def call
    tags = Tag.find_by_sql(<<~SQL)
      SELECT DISTINCT 
             tags.*, 
             bookmarks.tsv <=> plainto_tsquery('zh', name) AS rev_score
        FROM bookmarks, tags 
       WHERE bookmarks.id = #{bookmark.id} 
             AND plainto_tsquery('zh', tags.name) @@ bookmarks.tsv
             AND tags.name not IN (#{Util.stop_words_for_where})
             AND length(tags.name) >= 3
             AND tags.auto_extract = 't'
    ORDER BY rev_score ASC
       LIMIT 10
    SQL
    tags = tags.map(&:preferred_or_self)
    tags = tags.group_by do |tag|
      tag.name.downcase.gsub(/-\s/, "")
    end.map { |name, records| records.sort_by { |record| record.preferred_id || 0 }[0] }
    tags.flatten.uniq[0, 3]
  end
end

基于标签的推荐

如果标签打的很准确,基于标签的相似效果其实也会很好,效果并不一定比协同过滤或文本相似度差。下面实现一个基于标签的相似推荐,使用 RUM 索引:

CREATE INDEX idx_similar_by_tag ON bookmarks USING rum (cached_tag_with_aliases_ids rum_anyarray_ops)

测试一下效率还是非常高的,对于没有标签的网页,使用文本相似度,即:使用标题去做全文检索,不过是 OR 规则的匹配。

class SimilarByTag
  prepend SimpleCommand
  include ActiveModel::Validations

  attr_reader :bookmark, :limit

  def initialize(bookmark, limit = 6)
    @bookmark = bookmark
    @limit    = limit
  end

  def call
    pg_ids = Util.to_pg_array(bookmark.cached_tag_with_aliases_ids)
    return Bookmark
      .original
      .where("cached_tag_with_aliases_ids && ?", pg_ids)
      .where.not(id: bookmark.id)
      .order("cached_tag_with_aliases_ids <=> '#{pg_ids}'")
      .limit(limit) if bookmark.cached_tag_with_aliases_ids.present?

    return Bookmark
      .original
      .where("bookmarks.tsv @@ replace(plainto_tsquery('zh', E'#{Util.escape_quote bookmark.title}')::text, '&', '|')::tsquery")
      .where.not(id: bookmark.id)
      .select("bookmarks.*, bookmarks.tsv <=> replace(plainto_tsquery('zh', E'#{Util.escape_quote bookmark.title}')::text, '&', '|')::tsquery AS relevance")
      .order("relevance ASC")
      .limit(limit) if bookmark.title.present?
    []
  end
end

效果

Rei 将本帖设为了精华贴。 10月14日 21:18

请问下 PostgreSQL 中文全文检索用的是哪个插件?

PS:看源码看到了,zhparser

你这样,似乎 taggings 表没用处了


早期我做电影网站的时候,在 MongoDB 里面也做过类似这样的 Tag 存储方式,不过我是直接把 Tag 名称到业务表字段里面

现在回头看,直接存储 tag_name 似乎有些不妥,应该转成 tag_id 来存的

bookmark.countries = %w[中国 美国]

https://github.com/huacnlee/mongoid_taggable_on

好文章,已支持!

huacnlee 回复

中间表还是有用的,一个很有用的原则就是先满足范式再冗余。如果直接去掉业务有新需求的时候弄不好还得加回去,比如:

  • 验证 bookmark 和 tag 组合唯一性
  • 记录标记人
  • 记录标记时间
  • 自定义标记顺序
  • 修改标签名

还有一个好处可能就是有了中间表,两边做 counter cache 方便一些

gakki 回复

感谢

有种感觉:ruby+postgresql 就是魔法 + 魔法。打算学习一波。

wych42 回复

欢迎加入魔法师

这个可以搞一个 gem 出来么。。

我转载了下,如果不合适转载的话,麻烦您和我说下哈,我就删掉。 https://www.udask.net/articles/23

Sylor-huang 回复

可以的

lihuazhang 回复

应该可以,不过最近没时间啊

正需要做这个,抄袭了。

eux 回复
group_by(&method(:clean_name))

&method 这个用法学习了!

回头也学习一下集成到我们的系统里面去。

信息架构是啥话题?有相关的文章或书推荐么

hooopo 回复

我也是搜到这本书,但翻了下发现是个 web 年代的书了,说的主要是网页里的信息如何组织。

u1440247613 回复

信息结构的组织和是否 web 没有关系哇 网页只是一个 ui 层面的东西 信息组织是内容性网站的核心 应该所有内容性网站/app都离不开类目、搜索、标签、推荐、导航、信息管理这些基本需求 但大部分产品经理其实并不懂信息架构…

eux 回复

赞,我在做一个类似豆瓣的小说推荐网站 推书君,里面的小说标签是很重要的一块功能。 不过我的实现比较简单,就是 book, tag, book_tag 表,也做了标签的自动提取和同义词匹配。

不过发现楼主的更加完善。我自己实现的有些地方不是特别好。

我的同义词匹配是直接保存了一个同义词指向的词的文本字符串,而不是通过 tag.prefered_id 来做。

还有就是我的 book 表没有缓存对应的标签和标签 id,因为同步比较麻烦,导致联合查询同时包含复数热门标签的小说时会很慢。

比较好奇的是 可能是同义词的标签列出来这个是怎么做的,我目前都是人肉管理,发现有类似的标签就后台手动设置下。

在这方面不知道有没有什么书籍推荐?

同义词发现 这种需求就是推荐系统协同过滤之类算法来解决的了 相关书籍不少

我目前只实现了简单的基于文本相似的发现 fuzzywuzzy https://ruby-china.org/topics/38133

29 楼 已删除
31 楼 已删除

写得很好 学习到了

hooopo 回复

先满足范式再冗余?

整片都没看懂的就我么。。。。

35 楼 已删除

我尝试起来项目学习,发生错误

我的操作步骤

  1. cp .docker.env .env
  2. docker-compose build
  3. docker-compose run --rm rails bundle exec rails db:create
  4. docker-compose run --rm rails bundle exec rails db:migrate
  5. docker-compose run --rm rails bundle exec rails db:seed_fu

在第五步时,报错

363676727 回复

migration 成功没

hooopo 回复

我之前的记录是下面图片

我应该要发现 migrate 执行过程没显示,这是我本来应该要发现的问题,但是我的经验不足忽略了。

但是我刚刚重新执行了下 migrate, 它成功了,有点疑惑...

363676727 回复

可能是 yarn 安装太久了

hooopo 回复

我是 clone 项目后,按 readme 里的操作执行,第一次 migrate 出错,下面是我之前的报错历史截图,经过你的提醒我第二次执行 migrate 成功了,我这边如实反馈下,谢谢你😃

hooopo 回复

仔细看了源码,查找了好久,没发现 tag 表 preferred_id 不为空的 记录是怎么生成的,请问是我哪里看遗漏了吗?

zhugexinxin 回复

admin 里

hooopo 回复

标签自动提取 是我很感兴趣的功能

我从标签开始创建路径跟踪,跟我预想的一样应该是 job 跑的,然后发现 RemoteFetchJob,ExtractTag 这个类是标签自动提取

bookmarks_controller => Bookmark(after_create_commit: RemoteFetchJob) => bookmark.save => ExtractTag.call(bookmark)

这个流程上我有个疑惑,理论上应该是文档和标签进行匹配筛选,但是我没发现文档是何时在 ExtractTag.call(bookmark) 之前就保存了

是我遗漏了哪里了吗

363676727 回复

没看懂

hooopo 回复

应该是我描述的有问题,或者说是

bookmarks.tsv 的 tsv 是啥时候更新的?

hooopo 回复

感觉我好像是哪个知识点还不知道😂

363676727 回复

自动更新的

hooopo 回复

原来是这样,我明白了,太感谢了。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号