搜索索引与知识维护:让站点搜得到、搜得准、撤得干净
搜索结果旧了,问题通常不在输入框
读者在搜索框输入一个刚改过的术语,结果却仍然指向旧页面;已归档的文章还排在第一位,受限的内部段落被公开索引命中,中文词组拆开后搜不到,或者搜索结果能点击但最终页面已被重命名。表面看是“搜索不好用”,实际是源文档、索引文档、权限过滤、路由重定向和缓存各自有了不同的版本。
一个可维护的搜索链至少包含五个状态:源页面是否可见,索引记录是否允许生成,分词器把字段拆成什么 token,查询时哪些过滤和排序生效,结果链接是否仍能抵达当前页面。搜索服务的在线状态只覆盖其中一部分,不能用“接口返回 200”证明知识入口可靠。
先做架构选择,再决定字段和实现。小型公开站点优先考虑浏览器内索引,部署简单、查询不需要把文章内容发给服务端,离线缓存也容易控制;文档量、并发、权限、租户、审计或增量更新达到一定复杂度后,再考虑服务端搜索。服务端不是自动更准,它只是把索引、查询、权限、容量和运维责任从浏览器移到了一个需要治理的系统里。
本地搜索和服务端搜索如何选
当前站点已经在 VuePress 配置中启用 SlimSearch,并设置 indexContent: true。SlimSearch 官方插件说明了它是客户端搜索插件,默认只索引标题、文章摘要和配置的自定义字段;全文内容需要显式开启,页面也可以通过 frontmatter search: false 排除。官方入口还说明了 customFields、filter、Web Worker API 和默认使用 Intl.Segmenter 做分词,适合直接对照 SlimSearch 插件文档 检查行为。
客户端索引的链路是:构建阶段把页面数据写入静态资源,浏览器加载索引后在 Worker 中查询,结果返回页面路径和摘要。它适合公开内容和中小规模文档库,部署只需要静态文件,故障面小;代价是索引包会随文章和全文增长,更新必须随站点重新构建,浏览器缓存还可能让旧索引继续存活。客户端索引不能承担服务器端的访问控制,不能把不该公开的字段先发到浏览器,再指望 UI 把结果隐藏起来。
服务端搜索的链路则是:构建或事件消费者读取源文档,生成带版本和权限标签的搜索文档,批量写入索引,查询 API 在服务端执行分词、排序、过滤和权限约束,再把当前路由返回给浏览器。它适合需要即时更新、复杂字段过滤、较大数据量或按用户权限返回不同结果的知识库;代价包括服务运行成本、网络延迟、密钥管理、索引备份、权限审计、容量规划和迁移成本。
可以用这条判断快速收敛方案:如果每次发布都能接受随静态站点全量构建,内容公开且数量可控,先用 SlimSearch;如果发布和搜索需要解耦、用户权限会改变结果、需要近实时增量写入,选择受控的服务端索引;如果主要需求是结构化字段过滤而不是全文相关性,先在源数据层解决查询,不要为了一个标签筛选引入完整搜索集群。
从零启用客户端索引并验证入口
在 VuePress 站点里,搜索插件是配置能力而不是 Markdown 语法。配置文件中需要导入插件或由主题组合插件,页面的 frontmatter 再决定该页是否进入索引。主题可以提供搜索框,但搜索框是否显示、使用哪一个插件、索引是否全文化,必须回到实际生成的临时模块和浏览器请求确认。
当前仓库的关键配置形态如下。indexContent 会扩大每条索引的输入,locales 把搜索提示语放在站点语言路径下;如果把全文索引打开,却没有评估构建内存、静态资源大小和敏感字段,搜索体验改善可能换来更大的泄露面。
plugins: {
slimsearch: {
indexContent: true,
locales: {
"/": {
placeholder: "搜索文章、配置和故障证据",
},
},
},
}第一次验证不要先调相关性。先确认依赖和生成物存在,再输入一个确定只出现在测试页面里的词,观察结果是否指向预期路由;随后在临时分支把页面 frontmatter 改为 search: false,重新构建,确认结果消失;最后恢复字段并再次构建。三步分别证明插件装载、索引收录和排除逻辑生效。SlimSearch 的开发服务器默认不会因 Markdown 增删而热更新索引,官方说明要求调试搜索时启用 hotReload 或重新启动、重新构建,因此不能把仍驻留在旧 Worker 中的结果误判成排除字段失效。
node --version
npm ls @vuepress/plugin-slimsearch slimsearch
npm run build
# 生成物中搜索插件临时资源,名称随插件版本变化,先列目录再查看内容
Get-ChildItem docs/.vuepress/.temp/slimsearch -File
rg -n "indexContent|search|route|title" docs/.vuepress/.temp/slimsearch dist | Select-Object -First 40预期构建以成功退出,临时目录中能找到索引入口,浏览器 Network 面板能看到索引资源被加载。不能把 rg 找到一段字符串当成搜索正确;真正的正向证据是输入查询后结果标题、摘要和最终 href 都来自当前页面。若构建输出存在却搜索框空白,优先检查客户端组件是否使用了同一个 Worker store、索引资源是否被 base 路径改写、浏览器控制台是否出现 hydration 或 worker 错误。
客户端索引还可以添加可读的自定义字段,例如系列、状态和版本,但不要把账号、内部路径、原始代码仓库地址、审计内容或未脱敏的正文塞进字段。customFields 的 getter 在构建期读取页面数据,字段一旦进入索引就可能被下载到访客浏览器。需要权限控制的资料应从公开构建输入中删除,而不是仅使用结果过滤。
索引字段先定义知识身份,再谈分词
内部规范记录最少应能回答“它是谁、来自哪里、现在是否有效、谁能看、点击后去哪里”,再按目标索引裁剪公开投影。推荐把字段分成身份、展示、检索、生命周期和权限五组。身份字段包括稳定的 id、sourcePath、route 和 contentHash;展示字段包括 title、summary、section;检索字段包括 titleText、headingsText、bodyText、tags 和可选同义词;生命周期字段包括 state、sourceRevision、indexedRevision 和 redirectTo;权限字段包括 visibility、tenant、readerGroups 和 permissionRevision,但权限标签本身也不能暴露业务敏感信息。权限字段缺失的记录必须进入隔离队列或按不可见处理,不能回退成公开。
索引生成器应从字段允许列表构造新对象,不要先序列化完整页面对象再删除少数字段。公开客户端索引通常只需要稳定 ID、标题、公开摘要、当前路由、可检索正文片段和公开分类;sourcePath、构建机绝对路径、草稿内容、内部标签、读者组名称、原始 frontmatter、组件属性和错误堆栈不得进入公共产物。服务端索引可以保存权限计算需要的标签,但查询响应仍要经过字段允许列表;运维 key 能读到的底层文档不能被当成普通用户可见模型。
这条边界要用构建后反证守住:在受控样本中放入只会出现在草稿、内部 frontmatter 和受限正文里的标记词,生成索引后对全部静态索引文件和服务端候选索引查询这些标记,公共入口必须零命中。随后检查候选记录缺少 visibility、permissionRevision 或最终路由时是否被隔离。只验证搜索 UI 没有显示敏感结果不够,因为索引资源、Worker store、网络响应和缓存仍可能包含数据。
不要把显示 URL 当成身份。文章改名时,route 会变化,稳定 id 和 contentHash 仍然可以帮助判断是更新、移动还是新页面。索引文档最好还携带 sourceRevision,使一次搜索结果能够追溯到源文档和构建版本。删除和归档操作应写入明确的 tombstone 或删除事件,而不是让索引消费者猜“源文件没读到是不是暂时失败”。
字段之间有成本关系。标题通常权重最高,正文用于召回,标签和系列用于过滤,摘要用于结果展示;把所有字段都设置成可搜索、可过滤、可排序会增加索引结构和内存。以 Meilisearch 为例,官方设置文档区分 searchableAttributes、filterableAttributes、displayedAttributes 和 sortableAttributes,而且搜索字段顺序会影响属性排名。可以参考 Meilisearch settings 和 filterable attributes 逐项配置,不要把源 JSON 原样暴露给结果接口。
一份服务端索引字段设置可以这样表达,重点是用 displayedAttributes 限制响应内容,用过滤字段承载状态和权限条件:
{
"searchableAttributes": ["titleText", "headingsText", "bodyText", "tags"],
"filterableAttributes": ["state", "visibility", "series", "readerGroups"],
"sortableAttributes": ["order", "updatedRevision"],
"displayedAttributes": ["id", "title", "summary", "route", "redirectTo", "state"],
"rankingRules": ["words", "typo", "proximity", "attribute", "sort", "exactness"]
}正向实验给同一篇文章写入标题、正文和标签,查询标题中的稀有词,预期标题结果靠前;反向实验把 displayedAttributes 恢复为默认或把正文直接放进返回结果,观察响应是否包含不应展示的字段。若响应已经携带完整正文,前端再用 CSS 隐藏没有安全意义,日志、浏览器缓存和代理都可能保留它。Meilisearch 会按原样返回文档字段,安全文档还要求在 DOM 中转义或净化用户生成内容;displayedAttributes 是响应面和泄露面控制,不等价于索引磁盘压缩。
中文分词必须用 token 证据判断
中文没有像英文那样稳定的空格边界,“构建缓存失效” 可以按词组、二元片段或更细粒度切分。分词器决定倒排索引中的 token,查询端还要用兼容的规则分析输入;索引端和查询端的分词不一致时,文档明明包含词语,查询却无法命中。不要看到“支持中文”就直接上线,应把标题、专有名词、英文缩写、版本号、路径和带标点的故障描述放进一组固定查询样本。
当前 SlimSearch 默认使用 Intl.Segmenter 做 tokenization,官方文档允许通过 indexOptions、indexLocaleOptions 和自定义 tokenize 调整分词,也提供客户端的查询拆分和结果过滤入口。浏览器支持、语言环境和自定义函数会影响结果,尤其是把函数传到 Web Worker 时要遵守插件的可序列化约束。先在目标浏览器和 CI 构建环境中验证,不要只在开发者控制台里调用一个 Node API。
可以先用原生 API 做一个正向和反向分词实验,实验输出只用来观察 token,不是最终相关性评分:
const samples = [
"构建缓存失效",
"VuePress 2 配置",
"frontmatter 与重定向",
"Node.js 20.9.0+",
];
const segmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
for (const sample of samples) {
const tokens = [...segmenter.segment(sample)]
.filter(({ isWordLike }) => isWordLike)
.map(({ segment }) => segment);
console.log(sample, "=>", tokens);
}预期会看到中文词组、英文标识符和数字被以不同方式拆分;具体 token 随运行时实现变化,不能把某一台机器的输出写成永久契约。反向实验把 granularity 改为 grapheme,或把所有字符直接逐个切分,再用同一组查询比较命中数和结果排序。粒度过细会召回大量噪声,粒度过粗会漏掉短词和组合词;生产方案应以查询样本的命中率、无结果率和人工相关性判断为准。
服务端引擎必须用它自己的分析 API 查看 token。OpenSearch 官方将分析拆成字符过滤、tokenizer 和 token filter,并提供 _analyze 验证入口;文本分析文档 还强调索引时分析器和查询时分析器需要协同。复杂中文和多语言场景可以评估 analysis-icu,但插件的安装、镜像构建和版本兼容必须单独锁定,不能在运行容器里临时下载。
POST /knowledge-v1/_analyze
Content-Type: application/json
{
"analyzer": "standard",
"text": "构建缓存失效,页面仍然返回旧索引。"
}预期响应包含 tokens、位置和偏移量。这里如果只得到一串无法解释的字符,先核对索引使用的 analyzer、查询使用的 search analyzer、插件是否加载以及请求字符集。OpenSearch 的官方 language analyzers 明确列出内置语言分析器和 ICU 边界,选择中文方案时要以目标版本实际支持的插件和 token 输出为依据。
构建索引要区分全量、增量和删除
全量构建适合第一次上线、分词器改变、字段结构改变、索引损坏或需要清理历史脏数据的场景。它从源文档重新计算所有字段、权限标签、路由和 content hash,再写入新索引,完成一致性检查后切换读别名或静态索引文件。全量构建成本高,却容易证明结果完整;千万不要为了省一次构建时间而在分析器变化后继续把旧 token 当成新规则的结果。
增量更新适合单篇内容、frontmatter、路由或权限发生变化的场景。它需要一个可靠的变更信号:文件路径和内容 hash、源 revision、事件 ID 或构建 manifest。消费者收到更新后先幂等写入同一个稳定 ID,处理删除事件,再记录成功 revision;事件重复、乱序或失败重试不能造成旧版本覆盖新版本。源文档的 hash 没有变化时可以跳过正文重切分,但权限字段变化不能跳过,因为可见性本身改变了结果集合。
Meilisearch 的索引和设置更新通常会返回异步 task,官方 Create index API 示例会返回排队中的 task。写入后要等待 task 完成并再做查询,否则“接口接受了请求”不等于新内容已经可搜索。OpenSearch Bulk API 使用 NDJSON,每个操作独立处理,响应顶层 errors 可能为真而 HTTP 仍返回成功;官方 Bulk API 要求逐项检查错误和最后的换行符。
下面用 OpenSearch Bulk 的最小样例展示增量写入、更新和删除。真实生产请求应使用受控服务账号、TLS 和索引权限,不要把认证头写进仓库。生产写入最好只允许命中受控写别名,并设置 require_alias=true,避免一次拼写错误自动创建旁路索引。
POST /knowledge-write/_bulk?refresh=wait_for&require_alias=true
Content-Type: application/x-ndjson
{ "index": { "_id": "article-22-draft-navigation" } }
{ "title": "草稿状态、导航与构建门禁", "route": "/article/tool-efficiency/...", "state": "published", "contentHash": "sha256:example" }
{ "update": { "_id": "article-22-search-maintenance" } }
{ "doc": { "state": "archived", "redirectTo": "/article/tool-efficiency/..." } }
{ "delete": { "_id": "article-22-removed" } }预期每个操作都有独立结果,并且 errors 为 false;若某一条 mapping 错误、版本冲突或权限错误,其他操作可能已经成功,消费者必须把失败项分离出来重试或进入人工队列。refresh=wait_for 适合验证即时可见性,批量生产写入不应无界地对每条文档强制 refresh,否则吞吐和段合并成本会明显上升。
归档、重定向和旧索引必须一起变化
归档不是删除文件,也不是把页面标题前面加上“旧”。一篇已公开文章进入归档时,读者可能仍需要旧地址、历史结论或替代文章。源页面状态、搜索记录状态、旧路由、最终路由和缓存策略应在同一个变更清单里更新。搜索结果可以保留一条归档记录,但必须明显标出状态并把点击导向有效页面;如果页面包含敏感内容,则应同时从索引和公开构建中撤下。
路由变化先登记旧路由到最终路由的映射,再切换内容。稳定 ID 保留不变,route 更新,redirectTo 指向最终地址;服务端搜索的查询层可以在返回前过滤 state: archived,或者只在用户主动选择历史资料时返回。客户端 SlimSearch 则要在重新构建后生成带内容哈希的新索引 URL,让新 HTML 只引用新版本。CDN 可以失效旧 URL,Service Worker 可以在激活阶段删除自己管理的旧 cache,但已经离线的浏览器无法被服务器立即清空;安全删除不能依赖“等缓存自然过期”,受限内容一开始就不应进入公共静态索引。
双索引切换比原地改索引更容易回滚:写入 knowledge-v2,全量校验文档数量、hash、权限和查询样本,再切换稳定入口;保留 v1 一段观察窗口,确认没有旧结果和权限异常后再删除。OpenSearch 应通过 Manage Aliases API在一个 _aliases 请求里原子移除旧索引并添加新索引,读别名与写别名还要明确 is_write_index。Meilisearch 没有 OpenSearch 式 alias,应使用官方 /swap-indexes 原子交换稳定索引与候选索引,并等待交换 task 成功。直接在稳定入口上修改 analyzer、mapping 或 settings,失败时很难判断哪些文档已经按新规则写过。
POST /_aliases
Content-Type: application/json
{
"actions": [
{ "remove": { "index": "knowledge-v1", "alias": "knowledge-current" } },
{ "remove": { "index": "knowledge-v1", "alias": "knowledge-write" } },
{ "add": { "index": "knowledge-v2", "alias": "knowledge-current" } },
{ "add": { "index": "knowledge-v2", "alias": "knowledge-write", "is_write_index": true } }
]
}切换前先停止旧 writer 或让它只写 knowledge-write;切换请求成功后再恢复写入并做 smoke。若 producer 把物理索引名写死,即使读别名已经切换,旧索引仍会继续产生新数据,这种双写漂移必须由写入计数和索引 revision 告警发现。
清理动作要区分“停止读取”和“物理删除”。先让所有 writer 使用稳定写入口并原子切换读写别名,再确认新查询只返回当前 revision;随后停止旧索引写入、撤销旧服务凭证、失效 CDN 中旧索引入口,并等待约定的回滚窗口结束,最后删除旧索引和构建临时目录。删除前保存不含正文的 manifest、文档计数、索引 revision 和删除任务结果;若存在法律保留、审计保留或回滚窗口,改为隔离、加密和限权,不要让物理删除破坏必须保留的恢复能力。
删除传播还要覆盖副本和旁路。源页面删除事件先写 tombstone,消费者按稳定 ID 删除服务端主索引和重试队列;静态站生成不再包含该页的新哈希索引;CDN、Service Worker、查询结果缓存和预渲染摘要按版本淘汰;快照、备份、审计日志和导出包按各自保留策略到期删除或执行可证明的密钥销毁。tombstone 的保留期至少覆盖事件最大重放窗口,否则迟到的旧更新可能把已删除页面重新写回。团队应监控从源删除 revision 到公开查询零命中之间的传播延迟,并让删除 SLO 同时覆盖正文、标题、摘要、路由和高亮片段。
POST /_aliases
Content-Type: application/json
{
"actions": [
{ "remove": { "index": "knowledge-v2", "alias": "knowledge-current" } },
{ "remove": { "index": "knowledge-v2", "alias": "knowledge-write" } },
{ "add": { "index": "knowledge-v1", "alias": "knowledge-current" } },
{ "add": { "index": "knowledge-v1", "alias": "knowledge-write", "is_write_index": true } }
]
}这就是 OpenSearch 的回滚动作:在一个请求中把读写入口从 v2 切回仍然保留的 v1,随后立即重放查询样本和权限样本;确认恢复后才能隔离 v2。Meilisearch 的回滚则再次交换稳定索引与保留的旧索引,并等待 task 成功。反向实验应在隔离环境构造一份缺少权限元数据的候选索引,预期切换前检查因结果集合或字段集合不一致而拒绝发布;这个实验验证的不是引擎是否会搜索,而是质量和权限证据是否真的参与切换。
权限和敏感数据必须在索引生成前收紧
公开站点的客户端索引是公开静态资源,任何访问者都可以下载并搜索其中的文本。search: false 能阻止页面进入 SlimSearch 索引,但如果页面正文、摘要或自定义字段已经被另一个插件写入公共产物,单独改搜索字段还不够。权限边界应在页面进入构建和索引流水线之前确定:公开、内部、受限、法务保留和删除中至少要有可计算的状态。
服务端搜索也不能把权限过滤留给浏览器。查询 API 应从认证主体得到租户、用户组和权限版本,再在搜索请求中施加过滤;客户端传来的 tenant、role 或 visibility 只能作为提示,不能作为授权依据。索引记录可以保存权限标签,但结果响应只返回当前主体可读的字段,缓存键也必须包含主体授权范围或 permissionRevision,不能跨角色复用结果。服务账号通常只需要读取源内容、写入指定索引和切换受控 alias,不应获得删除整个集群或读取所有租户的能力。
Meilisearch 的 tenant token可把短期、服务端签发的过滤规则绑定到查询,并且只开放搜索动作;管理 key 和签名材料仍只能留在服务端。OpenSearch 则可组合角色、索引权限、文档级和字段级安全,但仍要在应用入口验证主体并限制返回字段。无论使用哪种引擎,缺少 visibility、租户或权限版本都应失败关闭,不能把“字段不存在”解释为公共文档。
日志、任务队列、失败重试和备份会复制搜索数据。正文、摘要、查询词、点击结果、异常堆栈、索引快照和浏览器缓存都可能含有客户名、内部域名、凭证片段或未公开设计。生产日志使用结构化脱敏字段,查询词按敏感级别采样、截断或用服务端密钥做 HMAC;普通无盐哈希挡不住低熵词典枚举。索引备份和导出包使用更窄的权限与保留周期。不要把真实 API key、Cookie、内网 URL 或用户数据放进分词样本和配置片段。
权限反向实验要有可观察结果:给一条测试文档标记 visibility=restricted,使用无权限主体查询,预期命中数为 0 且响应不带标题、摘要和路由;使用已授权主体查询,预期只返回这条文档。再复制一条缺少 visibility 或 permissionRevision 的文档,预期写入门禁将其隔离,查询层也保持零命中。若缺字段记录被当成公共内容,或更换主体后仍命中上一主体的缓存结果,必须在发布前阻断。
用质量指标管理搜索,而不是只看接口状态码
搜索质量至少分成召回、相关性、新鲜度、可用性、安全和成本六类。召回可以用一组固定查询的命中率、无结果率和 Top-K 覆盖率观察;相关性用人工标注的第一条有效结果位置、查询改写次数和点击后的快速返回比例观察;新鲜度比较源 revision 与索引 revision 的延迟;可用性记录响应错误、超时、worker 启动失败和索引加载失败;安全记录越权命中、敏感字段泄露和删除残留;成本记录构建耗时、索引体积、内存、磁盘、请求量和托管费用。
不要把一个固定的“命中率必须达到某个数字”当成所有站点的标准。先保存一组稳定查询样本,覆盖中文短词、长句、英文缩写、版本号、路径、系列名、归档内容和权限场景;每次分词器、ranking、字段权重或框架升级都重放这组样本,对比差异。新规则若让无结果率下降,却让第一条结果经常是旧文章,仍然不能上线。
一份轻量查询样本可以直接放在受控测试目录,敏感词用占位符:
[
{ "query": "构建缓存失效", "expectedRoutes": ["/article/.../build-checks.html"] },
{ "query": "frontmatter 重定向", "expectedRoutes": ["/article/.../draft-navigation-build-checks.html"] },
{ "query": "中文分词", "expectedRoutes": ["/article/.../search-and-knowledge-maintenance.html"] },
{ "query": "已归档术语", "expectedRoutes": [], "principal": "public" }
]测试器要区分“没有结果符合预期”和“搜索服务报错”。同样要记录结果中的 route 是否可访问、是否发生多跳重定向、页面 title 是否与索引 title 一致、索引的 content hash 是否与源 manifest 相同。指标应按连续构建趋势观察,例如索引延迟没有随文章数量单调增长、构建后资源体积增长有解释、无权限查询始终为零命中,而不是只在某一次构建里取一个漂亮数字。
服务端搜索的安装、容量和成本边界
本地只想验证查询语义时,不必先搭一个多节点集群。Meilisearch 官方提供 Linux、macOS、Windows 和 Docker 等安装入口;本地安装指南适合选择启动方式,官方 Docker 标签页用于选择并固定实际镜像版本。不要使用会漂移的 latest 作为可重复实验输入;生产还应在批准标签后固定镜像 digest。用 Docker 时挂载专用数据卷、设置至少 16 字节的 master key,并把宿主端口只绑定到回环地址;Meilisearch 的配置参考明确说明 development 模式不设置 master key 时 API 路由不受保护,设置 master key 后除健康检查外的路由需要有效 API key。
# 本地实验固定版本并使用命名卷,API 只从宿主回环地址进入
$MeiliImage = "getmeili/meilisearch:v1.49.0"
$MeiliKey = "local-lab-master-key-change-me-123"
docker pull $MeiliImage
docker volume create docs-search-meili-data
docker run -d --rm --name docs-search-lab `
-p 127.0.0.1:7700:7700 `
-e MEILI_ENV=development `
-e MEILI_MASTER_KEY=$MeiliKey `
--mount type=volume,src=docs-search-meili-data,dst=/meili_data `
$MeiliImage容器启动后先读取 /version,确认实际二进制与固定标签一致;创建索引、写文档和更新 settings 都会返回异步 task。下面的 PowerShell 把等待逻辑写成函数,只有 task 进入 succeeded 才执行下一步;failed 或 canceled 会立即抛错,避免把 HTTP 202 误当成数据已经可搜。
$Base = "http://127.0.0.1:7700"
$Headers = @{ Authorization = "Bearer $MeiliKey" }
function Wait-MeiliTask([int]$TaskUid) {
do {
Start-Sleep -Milliseconds 200
$task = Invoke-RestMethod "$Base/tasks/$TaskUid" -Headers $Headers
} while ($task.status -in @("enqueued", "processing"))
if ($task.status -ne "succeeded") {
throw "Meilisearch task $TaskUid ended as $($task.status): $($task.error.message)"
}
$task
}
Invoke-RestMethod "$Base/version" -Headers $Headers
$create = Invoke-RestMethod "$Base/indexes" -Method Post -Headers $Headers `
-ContentType "application/json" -Body '{"uid":"knowledge-lab","primaryKey":"id"}'
Wait-MeiliTask $create.taskUid
$documents = ConvertTo-Json -InputObject @(
@{ id = "a"; title = "构建缓存失效"; route = "/article/example-a.html"; state = "published" },
@{ id = "b"; title = "中文分词证据"; route = "/article/example-b.html"; state = "published" }
)
$write = Invoke-RestMethod "$Base/indexes/knowledge-lab/documents" -Method Post `
-Headers $Headers -ContentType "application/json" -Body $documents
Wait-MeiliTask $write.taskUid
$result = Invoke-RestMethod "$Base/indexes/knowledge-lab/search" -Method Post `
-Headers $Headers -ContentType "application/json" -Body '{"q":"缓存"}'
$result.hits | Select-Object id,title,route,state预期版本接口返回与镜像一致的版本,两个写 task 都为 succeeded,最后查询只命中文档 a。反向实验把第二条文档的 id 删除后重新写入,预期 task 进入 failed 且整个批次不落库;修正后再写入,文档数恢复为 2。实验结束时先停止并删除容器,再明确删除命名卷;--rm 只负责容器,不会删除命名卷:
docker rm --force docs-search-lab
docker volume rm docs-search-meili-data
if (docker volume ls --quiet --filter "name=^docs-search-meili-data$") {
throw "实验卷仍然存在"
}OpenSearch 适合需要更细粒度 analyzer、mapping、Bulk、分片和权限插件的场景,但本地资源要求更高。官方 Docker 文档提醒 Docker Desktop 至少给搜索容器分配足够内存,并说明 OpenSearch 需要设置 vm.max_map_count;它也明确指出示例 Compose 配置适合开发测试,不等于生产集群。可以按 OpenSearch Docker 安装指南 和 安装总览 选择 Docker、Helm、tarball 或其他入口。
容量治理先记录文章数、源文档字节、索引体积、segment 或 task 积压、构建峰值内存、查询 P95、无结果率、磁盘水位和删除传播延迟。全文索引会保存正文和 token 结构,中文分词、同义词、可过滤/可排序字段、向量、副本和快照还会继续放大空间。Meilisearch 的 task 历史也不是零成本,task 数据库维护说明给出了容量上限、过滤、取消和清理机制;队列持续增长时应先找出失败重试和慢任务,不能只定期删历史掩盖积压。
客户端与服务端的成本模型不同,可以先用两个粗略式子建立预算:
客户端月下行量 ≈ 压缩后索引字节 × 发生索引缓存未命中的访问次数
服务端存储量 ≈ 主索引字节 × (1 + 副本数) + 快照增量 + 迁移期双索引客户端还要承担每台设备下载、解压、解析和 Worker 内存,服务端则增加索引 CPU、常驻内存、磁盘 IOPS、查询网络、备份、监控和值守成本。预算不足时优先在进入引擎前删除无用源字段,减少 searchable、filterable、sortable 和 facet 字段,限制正文与向量范围,并缩短不必要的快照和双索引保留窗口;displayedAttributes 主要减少响应与泄露面,不能单独解决底层索引体积。加节点之前先证明瓶颈来自 CPU、内存、磁盘还是队列,否则只是把相同浪费复制到更多机器。
迁移退出要保留可回查的知识入口
从 SlimSearch 迁移到服务端时,不要先删除浏览器索引。先把同一份源 manifest 同时导出为客户端和服务端文档,建立字段映射和路由映射;服务端完成全量构建后,用固定查询样本比较结果标题、路由、状态和权限;再把搜索组件切换到服务端 API,保留一个可关闭的回退开关。回退只能返回公开内容,不能在服务端鉴权失败时退回包含受限资料的客户端索引。迁移期间同时维护两份索引会增加构建、存储和测试成本,但能发现“服务端分词命中、客户端不命中”或“服务端过滤少了一层权限”的差异。
退出旧搜索实现时要按依赖顺序处理:先让客户端停止引用旧静态索引,或让服务端 producer 停止写旧索引;观察旧入口没有新请求后,删除旧搜索组件和配置,失效 CDN 资源并让 Service Worker 淘汰自管 cache,确认旧 API token、抓取凭证和供应商导出任务已撤销,再从 lockfile 和容器镜像中移除不再使用的依赖。外部供应商还要按其删除接口或支持流程清理索引、副本、快照和导出,并保存不含正文的删除任务标识;“停止爬取”不等于供应商已有副本已经消失。浏览器中已离线的旧资源只能依靠版本化入口、缓存策略和下一次 Service Worker 激活收敛,不能声称后台已经远程清空。删除之后仍要保留一份不含敏感正文的迁移 manifest,记录旧字段到新字段、旧路由到新路由和删除 revision,便于审计和恢复。
如果服务端搜索退出,页面本身仍应可读。搜索不可用时,站点可以退回系列侧栏、目录入口或固定的站内索引页;这不是把搜索结果缓存永久保留,而是确保知识入口降级为可理解的静态路径。降级页面不能返回内部内容,也不能在搜索 API 超时后无限重试拖垮页面加载。
长期维护由三件小事组成:每次内容发布更新 manifest,每次搜索变更重放查询样本,每次归档或删除验证旧索引和缓存都不再返回。分词器、字段权重、插件、Node、VuePress、搜索引擎和主题升级都可能改变结果;升级窗口要同时检查构建产物、索引版本、权限过滤、旧路由、资源体积和回滚路径。最终的稳定状态不是“搜索按钮存在”,而是任何结果都能追溯到有效源页面、当前权限和可重复的构建版本。
