skillhub/docs/04-search-architecture.md

7.6 KiB
Raw Permalink Blame History

skillhub 搜索架构

1 SPI 接口

public interface SearchIndexService {
    void index(SkillSearchDocument doc);
    void batchIndex(List<SkillSearchDocument> docs);
    void remove(Long skillId);
}

public interface SearchQueryService {
    SearchResult search(SearchQuery query);
}

public interface SearchRebuildService {
    void rebuildAll();
    void rebuildByNamespace(Long namespaceId);
    void rebuildBySkill(Long skillId);
}

2 SearchQuery 模型

public record SearchQuery(
    String keyword,
    Long namespaceId,           // 可选,指定空间搜索
    String namespaceSlug,       // 可选
    SearchVisibilityScope scope, // ACL 投影,由应用服务层计算注入
    SortField sortBy,           // RELEVANCE / DOWNLOADS / RATING / NEWEST
    int page,
    int size
) {}

// 搜索可见范围投影,由应用服务层根据当前用户计算
public record SearchVisibilityScope(
    boolean includeAllPublic,        // 是否包含所有 PUBLIC 技能
    Set<Long> memberNamespaceIds,    // 用户是 MEMBER 的 namespace可见 NAMESPACE_ONLY
    Set<Long> adminNamespaceIds,     // 用户是 ADMIN 的 namespace可见 PRIVATE
    String userId                    // 当前用户 ID可见自己的 PRIVATE skill匿名为 null
) {}

ACL 投影计算规则:

  • 匿名用户:includeAllPublic=true,其余为空集,userId=null
  • 已登录用户:includeAllPublic=truememberNamespaceIds = 用户所属空间,adminNamespaceIds = 用户是 ADMIN 以上的空间,userId = 当前用户 ID

一期 PostgreSQL 实现中,SearchVisibilityScope 转换为 WHERE 条件:

WHERE (visibility = 'PUBLIC')
   OR (visibility = 'NAMESPACE_ONLY' AND namespace_id IN (:memberNamespaceIds))
   OR (visibility = 'PRIVATE' AND (namespace_id IN (:adminNamespaceIds) OR owner_id = :userId))

迁移到 ES 时,SearchVisibilityScope 可直接映射为 bool query 的 should/filter 子句。

3 搜索文档表 skill_search_document

一个 skill 对应一条搜索文档,但文档内容的来源语义应严格收敛为“当前最新已发布版本”。实现上仍可由 latest_version_id 作为缓存指针承载,但它只允许指向 PUBLISHED 版本;搜索层不能再把它当作泛化的“当前版本”。

字段 类型 说明
id bigint
skill_id bigint 唯一,一 skill 一条
namespace_id bigint 用于空间过滤
owner_id VARCHAR(128) 用于 PRIVATE 可见性判定
title varchar(256)
summary varchar(512)
keywords varchar(512)
search_text text displayNameslugsummary,以及 frontmatter 中除 name / description / version 外的字段展开结果
visibility enum 冗余,避免搜索时 join
status enum
updated_at datetime

唯一约束:(skill_id)

PostgreSQL 全文搜索索引:表增加 search_vector tsvector 生成列,基于 titlesummarykeywordssearch_text 自动维护,建立 GIN 索引。详见第 7 节。

4 索引写入时机

以下场景触发搜索文档更新upsert by skill_id

  • 审核通过(PENDING_REVIEW → PUBLISHED):重算“最新已发布版本”指针,并用该发布版本内容更新搜索文档
  • 已发布版本被撤回(PUBLISHED → YANKED):重算“最新已发布版本”指针;若不存在任何已发布版本,则移除搜索文档
  • 技能状态变更(隐藏/归档/恢复):更新搜索文档的 status 字段

5 搜索演进路线

5.1 一期数据建模约束

一期“每个 skill 一条搜索文档、内容永远取最新已发布版本”是有意的简化。当前实现仍使用 latest_version_id 作为持久化指针,但这里的语义已经收敛为 latest published pointer。这个模型在以下场景下会不够用

  • 版本级检索(搜索某个旧版本的内容)
  • 自定义标签/通道检索(搜索 @beta 标签指向的版本内容)
  • 向量 chunk 索引(一个 skill 的 SKILL.md 拆成多个 embedding chunk

这些场景不是简单换 provider 能解决的,需要改表结构和索引写入逻辑。

一期搜索能力边界(产品限制):

  • 搜索只基于“最新已发布版本”的内容
  • 不支持按 version 或 tag 搜索内容
  • 搜索结果不区分 channelbetastable 等标签通道)
  • 用户通过 tag 安装的技能内容可能与搜索结果展示的内容不一致(搜索展示 latest安装的是 tag 指向的版本)
  • 若要支持 channel-aware 搜索,必须升级到 version 级索引(二期 ES 实现)

5.2 演进阶段

阶段 实现 索引粒度 切换方式
一期 PostgreSQL Full-Text (tsvector + GIN) 每 skill 一条latest published 默认
一点五期 PostgreSQL Full-Text + 语义向量重排 每 skill 一条latest published 配置 skillhub.search.semantic.enabled=true
二期 ES / OpenSearch 每 skill_version 一条 + skill 聚合文档 配置 search.provider=elasticsearch
三期 向量检索 每 skill_version 多条chunk 级) 配置 search.provider=vector
四期 混合排序 关键词 + 向量混合 配置 search.provider=hybrid

当前代码实现已落在“一点五期”:

  • 仍然使用 PostgreSQL 全文搜索作为主召回
  • 搜索文档表新增 semantic_vector 缓存字段
  • relevance 排序下,对全文候选集追加语义向量重排
  • 语义向量不可用时自动降级为现有全文相关度排序

5.3 SPI 演进策略

一期 SPI 接口(SearchIndexService / SearchQueryService)的入参是 SkillSearchDocumentskill 粒度)。二期切换到 ES 时:

  1. 新增 SkillVersionSearchDocument 模型version 粒度)
  2. SearchIndexService 新增 indexVersion() 方法(向下兼容,一期实现空方法)
  3. ES 实现同时写入 skill 聚合文档 + version 文档
  4. SearchQueryService.search() 的返回结果不变(仍返回 skill 级摘要),内部实现切换为 ES 查询

这意味着二期切换不是零成本的——需要新增模型、扩展 SPI、重建索引。但一期不为此过度设计SPI 抽象保证了切换时不需要改业务层代码。

通过 @ConditionalOnProperty 或自定义 SPI 加载机制切换。

6 分布式安全

rebuildAll() / rebuildByNamespace() 执行前获取 Redis 分布式锁key: search:rebuild:{scope}TTL: 10min获取失败则跳过。

7 PostgreSQL 全文搜索中文支持

PostgreSQL 全文搜索使用 tsvector + tsquery + GIN 索引:

-- 增加 tsvector 生成列
ALTER TABLE skill_search_document
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
    setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('simple', coalesce(summary, '')), 'B') ||
    setweight(to_tsvector('simple', coalesce(keywords, '')), 'B') ||
    setweight(to_tsvector('simple', coalesce(search_text, '')), 'C')
) STORED;

-- 建立 GIN 索引
CREATE INDEX idx_search_vector ON skill_search_document USING GIN (search_vector);

中文支持方案:

  • 一期使用 simple 分词配置(按空格和标点分词),对中文支持有限但零依赖
  • 如需更好的中文分词,可安装 zhparserpg_jieba 扩展,替换为对应的 text search configuration
  • PostgreSQL 的 tsvector 支持权重A/B/C/D可对 title 赋予更高权重,提升搜索相关性

已知局限:simple 分词对中文的精度不如专业搜索引擎。建议 Phase 2 完成后评估搜索效果,如不满足需求则在 Phase 3 提前引入 ES。