几百条以内的内容站,搜索不需要 Elasticsearch,不需要 Pagefind,甚至不需要后端接口:一份构建期生成的 JSON + 浏览器里的字符串匹配就是完整答案。本文讲这个极简方案的设计与它的失效边界。
索引结构:为“匹配”而非“检索”设计
{
"generatedAt": "2026-09-02T12:00:00Z",
"items": [
{
"type": "posts",
"title": "构建期搜索索引",
"href": "/posts/static-search-index-design",
"excerpt": "讲清构建期生成扁平 JSON 索引…",
"tags": ["搜索", "架构"],
"date": "2026-09-02"
}
]
}
字段取舍的原则:
- 只放“会被匹配”和“会被展示”的字段——标题、摘要、标签是匹配面;正文全文不进索引(体积爆炸且浏览器过滤无收益),需要正文检索再考虑分片或换方案;
type支撑分组展示(文章/项目/踩坑…);href是唯一出口,不存内部 ID;generatedAt用于调试“索引是不是旧的”。
一个 20 条目、全中文字段的索引约 10–20KB gzip 后 3KB 以内——比一张缩略图小,这就是“够用”的量化依据。
生成链路:挂在 prebuild,构建即更新
{
"prebuild": "npm run content:check && npm run search:index && npm run graph:data",
"build": "astro build"
}
关键决策链:
- 数据源是内容集合本身(frontmatter + 摘要),不是渲染后的 HTML——生成脚本直接读 markdown,零抓取;
- 草稿过滤在生成器里做(
draft !== true),而不是“全量生成、前端隐藏”——搜索索引是公开产物,任何非公开内容都不该出现在字节里; content:check排在search:index之前:坏 frontmatter 先失败,不产出半截索引;- 索引写入
public/,随构建发布,天然带内容级更新(下次部署即最新)。
浏览器端:两级消费
命令面板(全局):fetch 一次、缓存到模块变量、includes + 逐字符模糊打分,输入防抖 120ms 内完成几百条的过滤毫无感知。
搜索页(页面):更进一步,把索引在服务端渲染时直接内联进 HTML(<script type="application/json">),省掉首屏一次网络往返——静态索引 + 静态页面,本可以是一个请求。
内联与外置的选择标准:该页专属的数据(本页图谱、本页索引)内联;跨页复用的(命令面板索引)外置 + HTTP 缓存。
失效边界:什么时候这个方案会崩
诚实地列出适用上限:
| 信号 | 阈值经验 | 应对 |
|---|---|---|
| 条目数 | > 2,000–5,000 | 索引体积 > 500KB,分片(按类型/首字母)或上专用索引 |
| 需要正文全文 | 任何规模 | 构建期分词(Pagefind 类)或服务端检索 |
| 需要相关性排序/拼写纠错 | 视体验要求 | 换专用方案,别手写打分 |
| 多语言词形变化 | — | 简单 includes 天然不支持,需分词器 |
中小站常年碰不到这些边界,而达到之前的每一分复杂度都是纯负债——这就是“20KB JSON 够用”的真正含义:它不是将就,是这个规模下的正确架构。
一个必做的护栏
索引是“第二份事实”,会和渲染产物漂移(改了 frontmatter 没重跑生成)。防线两条:prebuild 强制重跑(构建即最新);release 门禁里校验“索引条目的每个 href 都存在于站点可达路由”——本项目正是这条断言抓出过索引残留。