skillhub/docs/04-search-architecture.md

163 lines
7.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# skillhub 搜索架构
## 1 SPI 接口
```java
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 模型
```java
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 条件:
```sql
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 时:
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 索引:
```sql
-- 增加 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。