7.6 KiB
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=true,memberNamespaceIds= 用户所属空间,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 | displayName、slug、summary,以及 frontmatter 中除 name / description / version 外的字段展开结果 |
| visibility | enum | 冗余,避免搜索时 join |
| status | enum | |
| updated_at | datetime |
唯一约束:(skill_id)
PostgreSQL 全文搜索索引:表增加 search_vector tsvector 生成列,基于 title、summary、keywords、search_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 搜索内容
- 搜索结果不区分 channel(
beta、stable等标签通道) - 用户通过 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)的入参是 SkillSearchDocument(skill 粒度)。二期切换到 ES 时:
- 新增
SkillVersionSearchDocument模型(version 粒度) SearchIndexService新增indexVersion()方法(向下兼容,一期实现空方法)- ES 实现同时写入 skill 聚合文档 + version 文档
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分词配置(按空格和标点分词),对中文支持有限但零依赖 - 如需更好的中文分词,可安装
zhparser或pg_jieba扩展,替换为对应的 text search configuration - PostgreSQL 的
tsvector支持权重(A/B/C/D),可对 title 赋予更高权重,提升搜索相关性
已知局限:simple 分词对中文的精度不如专业搜索引擎。建议 Phase 2 完成后评估搜索效果,如不满足需求则在 Phase 3 提前引入 ES。