Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 328 additions & 0 deletions SEARCH_OPTIMIZATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
# GoClub 搜索功能调研与优化方案

更新日期:2026-08-03

## 1. 文档目标
Comment on lines +1 to +5

本文记录 GoClub 当前站内搜索的索引范围、性能实测、主要问题和后续优化路线,为后续搜索实现改造提供决策和验收依据。

本次只整理方案,不直接改动搜索实现。后续实施时建议按“先修现有交互,再验证 Pagefind”的顺序拆分提交,避免一次性替换搜索引擎带来较大的回归范围。

当前检索链路如下:

```mermaid
flowchart LR
A[用户首次聚焦搜索框] --> B[下载完整搜索 JSON]
B --> C[浏览器解析 JSON]
C --> D[创建 Fuse.js 索引]
D --> E[每次 keyup 执行全文检索]
E --> F[完整排序后截取前 10 条]
F --> G[只展示标题和父级栏目]
```

## 2. 当前搜索实现

GoClub 当前使用 Hugo 在构建阶段生成一个完整的 JSON 索引,浏览器首次聚焦搜索框时下载索引,再使用 Fuse.js 7.1.0 在前端执行全文模糊检索。

相关文件:

- `themes/hugo-book/assets/search-data.json`:生成搜索数据。
- `themes/hugo-book/assets/search.js`:下载索引、创建 Fuse 实例并渲染结果。
- `themes/hugo-book/layouts/_partials/docs/search.html`:搜索框和结果容器。
- `i18n/zh-cn.yaml`:中文搜索配置。
- `themes/hugo-book/static/fuse.min.js`:Fuse.js 7.1.0 浏览器包。

当前索引规则:

1. 收录 Hugo 中类型为 `page` 或 `section` 的页面。
2. 排除 `bookSearchExclude: true` 的页面。
3. 排除正文为空的页面。
4. 每条记录只保存链接、标题、直接父级栏目和页面纯文本正文。
5. 搜索只对标题和正文加权,标题权重为 0.7,正文权重为 0.3。
6. 完整计算命中结果后,只展示前 10 条。

## 3. 当前可检索范围

目前能够检索:

- 文章标题;
- Markdown 渲染后的正文纯文本;
- 标题和正文中的中英文技术术语;
- 能够进入 Hugo `.Plain` 内容的代码文本。

目前不能或不能可靠检索:

- 标签、关键词和分类等独立元数据;
- 完整面包屑路径;
- 标题层级和文章内标题锚点;
- 作者、摘要等独立加权字段;
- 命中关键词附近的正文片段;
- 图片中的文字;
- `static/files/epub-books/` 下的 2 个 EPUB 文件正文;
- 设置了 `bookSearchExclude: true` 的 3 个交互学习页面;
- 外部链接指向的网站正文。

仓库中现有 403 个 Markdown 文件,但目前没有文章设置 `tags` 字段,只有 1 篇文章设置了 `description`。因此扩展元数据搜索时,还需要同步建立内容元数据规范。

## 4. 性能实测

测试环境为当前开发电脑和当前网络,数据用于定位本项目瓶颈,不代表所有访问者的固定耗时。

### 4.1 索引和网络

| 指标 | 实测结果 |
| --- | ---: |
| 索引记录 | 402 条 |
| 本地原始 JSON | 3,751,141 字节 |
| 线上 gzip 响应 | 1,506,529 字节 |
| 线上 Brotli 响应 | 1,395,997 字节 |
| 首字节时间 | 约 1.0~2.0 秒 |
| gzip 完整下载 | 13.1~15.1 秒 |
| Brotli 单次下载 | 17.6 秒,受当时网络波动影响 |
| 当前下载速度 | 约 80~115 KB/s |

线上索引响应当前使用 `Cache-Control: max-age=600`,Cloudflare 返回 `cf-cache-status: DYNAMIC`。索引 URL 已包含内容哈希,因此可以使用更长时间的不可变缓存。

### 4.2 浏览器端计算

| 环节或查询词 | 中位耗时 | 命中数 |
| --- | ---: | ---: |
| JSON 解析 | 2.0 ms | - |
| Fuse 索引创建 | 2.5 ms | - |
| `MySQL` | 102.3 ms | 56 |
| `MVCC` | 57.4 ms | 8 |
| `Kubernetes` | 129.9 ms | 117 |
| `分布式锁` | 57.9 ms | 12 |
| `云原生` | 57.3 ms | 50 |

结论:当前冷启动的首要瓶颈是下载整个索引。索引加载完成后,桌面端单次查询约为 57~130 ms;低性能移动设备预计会更慢。

## 5. 已确认的问题

### 5.1 首次搜索等待时间长

搜索索引直到输入框第一次获得焦点才开始下载。在当前网络环境下,用户点击输入框后可能等待十几秒才能看到结果。

### 5.2 输入过程中重复执行全文搜索

当前监听 `keyup`,没有防抖。用户每输入一个字符都会扫描全文索引,容易造成主线程卡顿和重复计算。

### 5.3 索引加载期间存在调用风险

索引下载完成前,`window.bookSearchIndex` 尚未创建。如果用户已经开始输入,搜索函数可能调用未初始化的对象并产生控制台错误。

### 5.4 只显示 10 条,但仍计算全部结果

当前先调用 `search()` 计算全部命中,再使用 `.slice(0, 10)` 截断。结果很多时仍会承担完整搜索和排序开销。

### 5.5 中文分词配置未生效

`i18n/zh-cn.yaml` 配置了 `encode` 和 `tokenize`,但项目内置的 Fuse.js 7.1.0 不包含这两个配置的实现,也不包含新版的 `useTokenSearch`。当前中文检索主要依赖字符级模糊匹配,不是真正的中文分词检索。

### 5.6 结果信息不足

结果只展示文章标题和直接父级栏目,没有以下信息:

- 命中总数;
- 命中关键词高亮;
- 正文上下文摘要;
- 加载、空结果和加载失败状态;
- 分类筛选和继续加载;
- 跳转到文章内命中位置的锚点。

## 6. 搜索范围扩展建议

### 6.1 推荐的索引记录结构

在继续使用 Fuse.js 的阶段,建议先统一搜索记录的数据结构:

```json
{
"href": "/docs/example/",
"title": "文章标题",
"description": "人工摘要",
"content": "正文纯文本",
"headings": ["一级标题", "二级标题"],
"keywords": ["Go", "MySQL"],
"category": "技术博客",
"breadcrumb": ["技术博客", "数据库"],
"codeSymbols": ["database/sql", "QueryContext"]
}
```

建议权重由高到低依次为:标题、关键词、标题层级、摘要、代码符号、正文。栏目和面包屑主要用于筛选与结果说明,不宜拥有过高的相关性权重。

### 6.2 优先增加结构化字段

建议为搜索记录增加以下字段,并设置独立权重:

- `description`:人工摘要;
- `keywords` 或 `tags`:技术关键词;
- `headings`:文章标题层级;
- `breadcrumb`:完整栏目路径;
- `category`:顶级栏目;
- `codeSymbols`:函数名、类型名和重要命令。

同时建立 Front Matter 规范,要求新增文章至少包含标题、摘要、栏目和关键词。否则只有索引字段,没有稳定的数据来源。

### 6.3 交互页面

3 个交互学习页面不建议直接收录整段 HTML 和脚本。更稳妥的做法是为每个页面生成一条精简搜索记录,包括页面名称、功能简介、技术关键词和入口链接。

### 6.4 EPUB 和图片

- EPUB 可以在构建阶段提取章节文字,每章生成独立搜索记录,但需要先确认内容授权和期望的搜索粒度。
- 图片不建议默认执行全量 OCR。优先索引图片标题、说明文字和所在文章上下文,只对确有检索价值的图片增加 OCR。

### 6.5 外部资源

不建议由浏览器临时抓取外部页面。若需要搜索外部资源正文,应在构建阶段使用白名单抓取并保存摘要,同时处理版权、更新频率、超时和失效链接问题。

## 7. 分阶段实施方案

### 7.1 方案对比

| 方案 | 首次下载 | 中文检索 | 摘要与高亮 | 运维成本 | 扩展能力 | 建议 |
| --- | --- | --- | --- | --- | --- | --- |
| 继续使用当前 Fuse.js | 完整下载约 1.4 MB | 字符级模糊匹配 | 需要自行实现 | 低 | 较低 | 不建议维持原状 |
| 优化并升级 Fuse.js | 仍需完整下载 | 可增加中文分词 | 需要自行实现 | 低 | 中等 | 适合短期修复 |
| Pagefind | 按查询加载分块 | extended 版本支持中文分词 | 原生支持 | 低 | 高 | 推荐中期方案 |
| Meilisearch/Typesense 等服务 | 按 API 返回结果 | 取决于配置 | 通常支持 | 中到高 | 很高 | 当前阶段不需要 |

### 7.2 阶段一:低风险优化现有 Fuse.js

目标是在不更换搜索引擎的情况下改善等待反馈、重复计算和结果可读性。

建议改动:

1. 将监听事件改为 `input`,增加 200~300 ms 防抖。
2. 索引未初始化时不执行搜索,并展示“正在加载搜索索引”。
3. 增加加载失败和重试逻辑。
4. 使用 Fuse 的结果数量限制参数,避免在渲染阶段才截断。
5. 展示命中总数、正文摘要、关键词高亮和空结果状态。
6. 增加顶级栏目筛选和“加载更多”。
7. 为索引文件配置 `public, max-age=31536000, immutable`。
8. 在非省流量模式下使用空闲预加载,减少用户首次聚焦后的等待。
9. 若继续使用 Fuse.js,评估升级到支持 token search 的版本,并使用 `Intl.Segmenter('zh-CN')`;升级后必须回归中文排序和模糊匹配结果。

阶段一可以改善交互,但无法根治首次访问必须下载约 1.4 MB 索引的问题。

### 7.3 阶段二:使用 Pagefind 进行静态分块索引验证

Pagefind 更符合 GoClub 的 Hugo 静态站点形态:

- 在 Hugo 构建完成后扫描生成的 HTML;
- 保持纯静态部署,不增加搜索服务器;
- 按查询按需下载分块索引;
- 支持中文分词;
- 原生提供摘要、高亮、分页、元数据和筛选能力;
- 可以通过 `data-pagefind-*` 属性精确控制正文、导航和重复区域是否进入索引。

建议先建立验证分支,不立即删除 Fuse.js。验证内容包括:

1. Hugo 生产构建后运行 Pagefind extended。
2. 只标记主文章区域为可索引区域,排除菜单、页脚和重复导航。
3. 保留当前页面视觉风格,使用 Pagefind API 自定义结果界面。
4. 对中英文、缩写、代码符号和错别字建立固定测试词表。
5. 比较索引总体积、首次查询下载量、查询耗时和结果相关性。
6. 验证通过后再移除旧 Fuse.js 索引与脚本。

### 7.4 阶段三:仅在需求增长后考虑服务端搜索

只有出现以下需求时,才建议评估 Meilisearch、Typesense、Algolia 或其他托管搜索服务:

- 内容需要实时更新而不能等待静态构建;
- 需要跨多个独立站点统一检索;
- 需要复杂权限控制;
- 需要搜索分析、个性化排序或语义检索;
- 内容规模显著超过纯浏览器静态搜索的合理范围。

当前 GoClub 没有必要为了搜索新增长期运行的后端服务。

## 8. 推荐决策

推荐采用“两步走”:

1. 先完成 Fuse.js 的加载状态、防抖、结果展示和缓存优化,快速改善当前体验。
2. 同时制作 Pagefind 验证版本,用同一组查询词对比后决定是否替换。

不建议直接向当前单体 JSON 持续加入 EPUB 全文、OCR 文本和外部网页正文。这样虽然扩大了范围,但会线性增加下载体积和浏览器端全文扫描成本。

### 8.1 实施优先级

| 优先级 | 工作项 | 目的 |
| --- | --- | --- |
| P0 | 索引未初始化保护、加载状态、失败重试 | 消除报错和不可解释的等待 |
| P0 | 输入防抖 | 避免每个按键触发全文扫描 |
| P1 | 强缓存策略 | 改善重复访问和跨页面访问 |
| P1 | 命中数量、摘要、高亮、空结果 | 提升结果可理解性 |
| P1 | 固定搜索回归词表 | 防止调整排序后出现静默退化 |
| P2 | description、keywords、headings 等元数据 | 扩大可检索范围并改善排序 |
| P2 | Pagefind 验证分支 | 验证分块索引收益和中文相关性 |
| P3 | EPUB、OCR、外部资源索引 | 在确有需求和授权后再扩展 |

## 9. 验收标准

### 阶段一验收

- 索引加载期间输入不会报错。
- 连续输入只在停止输入后触发实际查询。
- 搜索结果有加载、失败、空结果和命中数量反馈。
- 每条结果至少包含标题、栏目、命中摘要和高亮。
- 桌面端和移动端均不会因结果列表造成布局溢出。
- 原有中英文固定测试词均能返回合理结果。

### Pagefind 验证验收

- 首次查询网络传输量明显低于当前约 1.4 MB。
- 中文、英文缩写和代码术语的结果相关性不低于现有实现。
- 支持文章内标题或段落级定位。
- GitHub Pages 部署不需要新增常驻服务。
- Hugo 生产构建和部署流程可重复执行。

## 10. 回归测试词表

建议将以下查询作为固定回归用例,并为每个查询保存期望出现的前几条结果:

| 类型 | 示例 | 主要验证点 |
| --- | --- | --- |
| 中文术语 | `分布式锁`、`云原生` | 中文分词和正文命中 |
| 英文术语 | `MySQL`、`Kubernetes`、`MVCC` | 大小写、缩写和排序 |
| 中英混合 | `Go GC`、`MySQL 索引` | 多词查询和跨语言分词 |
| 代码符号 | `QueryContext`、`database/sql` | 标点与代码字段 |
| 标题精确匹配 | 完整文章标题 | 标题权重应明显高于正文 |
| 错别字或拼写误差 | `Kubernets` | 模糊匹配能力 |
| 无结果 | `不存在的测试词xyz` | 空状态和无错误反馈 |

回归测试至少覆盖桌面端、移动端、首次冷加载、缓存命中、慢速网络和加载失败六种场景。

## 11. 上线与回滚策略

### 11.1 Fuse.js 优化

每个交互改动应保持小提交。上线前记录索引体积、固定查询耗时和前 10 条结果;如果相关性明显下降,可单独回滚权重或分词配置,不影响内容页面。

### 11.2 Pagefind 切换

Pagefind 验证期间保留旧 Fuse.js 文件和开关。部署配置增加可切换参数,在新索引构建失败或线上结果异常时恢复旧搜索入口。确认连续多次生产构建成功后,再删除旧索引生成逻辑。

### 11.3 监测指标

建议至少记录:

- 首次搜索索引下载字节数;
- 首次结果出现时间;
- 缓存命中后的查询耗时;
- 无结果比例;
- 搜索结果点击率;
- 搜索脚本异常数量。

在没有隐私声明和用户同意前,不记录完整的个人查询内容;如需分析搜索词,应先进行匿名化和聚合处理。

## 12. 参考资料

- Pagefind:https://pagefind.app/
- Pagefind Hugo 构建接入:https://pagefind.app/docs/running-pagefind/
- Pagefind 中文与多语言支持:https://pagefind.app/docs/multilingual/
- Pagefind 索引范围控制:https://pagefind.app/docs/indexing/
- Fuse.js 性能说明:https://www.fusejs.io/performance.html
2 changes: 2 additions & 0 deletions assets/_custom.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1928,10 +1928,12 @@ body.sidebar-collapsed .book-brand-mark {

.section-card-summary {
margin-top: 0.75rem;
min-width: 0;
color: var(--body-font-color);
opacity: 0.74;
line-height: 1.72;
flex-grow: 1;
overflow-wrap: anywhere;
}

.section-card-meta {
Expand Down
6 changes: 3 additions & 3 deletions layouts/baseof.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@
<aside class="book-menu">
<div class="book-menu-content">
{{ template "menu" . }}
<button type="button" class="sidebar-toggle" data-sidebar-toggle aria-expanded="true" aria-label="收起侧边栏">
<span class="sidebar-toggle-icon" aria-hidden="true">‹</span>
</button>
</div>
<button type="button" class="sidebar-toggle" data-sidebar-toggle aria-expanded="true" aria-label="收起侧边栏">
<span class="sidebar-toggle-icon" aria-hidden="true">‹</span>
</button>
</aside>
{{ end }}

Expand Down
Loading