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

hooopo · 2020年10月14日 · 最后由 hooopo 回复于 2020年10月23日 · 2093 次阅读
本帖已被设为精华帖!

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

优选术语和同义词环

举个简单的例子,一个大众标签网站,任何人可以对网页打标签,任何人可以搜索和通过标签过滤网页。这个问题看似很简单,但实现起来并不容易,因为不同用户对标签术语的选择并不相同,比如拿「开源软件」这个标签来说,可选术语包括: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楼 已删除
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册