实战:从 AI 语义搜索到 AI 内容流水线,静态博客如何持续进化(续篇)

几个月前,我写过一篇《实战:基于 Cloudflare Vectorize 与 Gemini 构建全自动 AI 语义搜索》,当时解决的问题很明确:让一个静态博客具备语义检索能力,并把用户搜不到的问题沉淀为 Content Gap。

那套架构跑起来以后,我很快发现:搜索只是内容生命周期的最后一公里。

一篇文章从 Markdown 写完到真正被读者发现,中间还要经过摘要、翻译、相关推荐、内部链接、图片优化、搜索索引、SEO、部署和质量检查。如果这些环节仍然依赖人工逐项处理,AI 搜索再智能,也只是给传统发布流程外挂了一个新入口。

所以这次升级的重点,不是继续往页面上增加 AI 按钮,而是把整个博客改造成一条可重复运行的内容工程流水线:

作者仍然只负责写作和最终审阅;机器负责生成派生内容、建立索引、补齐分发信息,并验证发布结果。

本文是上一篇 AI 搜索文章的续篇,主要复盘这套系统从“一个 Worker + 一个向量库”,演进到“内容控制面 + 搜索数据面 + 静态降级面 + 质量门禁”的过程。


一、架构变化:搜索功能变成了内容平台的一部分

上一篇文章中的核心链路很短:

1
Markdown → Embedding → Vectorize → Worker → 搜索结果

现在的系统多了三个重要部分:

  1. 内容控制面:GitHub Actions 自动加工文章,并把结果写回仓库。
  2. 静态降级面:Worker、Vectorize 或外部模型不可用时,Pagefind 和 PWA 仍能提供基本能力。
  3. 质量门禁:Lighthouse、链接检查、Hugo 构建和部署保留策略持续验证结果。

为了避免把构建期和运行时链路挤在一张图里,下面拆成两个视角。

内容生成与回写

内容流水线以 Git Push 为入口,由 GitHub Actions 依次完成文章加工,并将生成结果回写至 Git 仓库:

%%{init: {"flowchart": {"nodeSpacing": 10, "rankSpacing": 14, "useMaxWidth": false}, "themeVariables": {"fontSize": "16px"}}}%%
flowchart TD
    AUTHOR["作者
Markdown + 图片"] --> GIT["Git Push"] GIT --> CONTENT["内容加工
摘要 / TL;DR / 推荐 / 交叉链接"] CONTENT --> DELIVERY["媒体与多语言
Alt / WebP / OG / 英文翻译"] DELIVERY -->|提交生成内容| REPO["Git Repository"]
%%{init: {"flowchart": {"nodeSpacing": 10, "rankSpacing": 14, "useMaxWidth": false}, "themeVariables": {"fontSize": "16px"}}}%%
flowchart TD
    AUTHOR["作者
Markdown + 图片"] --> GIT["Git Push"] GIT --> CONTENT["内容加工
摘要 / TL;DR / 推荐 / 交叉链接"] CONTENT --> DELIVERY["媒体与多语言
Alt / WebP / OG / 英文翻译"] DELIVERY -->|提交生成内容| REPO["Git Repository"]
%%{init: {"flowchart": {"nodeSpacing": 10, "rankSpacing": 14, "useMaxWidth": false}, "themeVariables": {"fontSize": "16px"}}}%%
flowchart TD
    AUTHOR["作者
Markdown + 图片"] --> GIT["Git Push"] GIT --> CONTENT["内容加工
摘要 / TL;DR / 推荐 / 交叉链接"] CONTENT --> DELIVERY["媒体与多语言
Alt / WebP / OG / 英文翻译"] DELIVERY -->|提交生成内容| REPO["Git Repository"]

发布、搜索与质量检查

以 Git 仓库为事实来源,发布链路进一步连接静态站点构建、AI 语义搜索与独立质量门禁:

%%{init: {"flowchart": {"nodeSpacing": 12, "rankSpacing": 14, "useMaxWidth": false}, "themeVariables": {"fontSize": "16px"}}}%%
flowchart TD
    REPO["Git Repository"] --> CI["构建、索引与质量门禁
Hugo / Pagefind / Vector Sync
Lighthouse / Link Check"] CI --> PAGES["Cloudflare Pages"] PAGES --> STATIC["静态访问
Pagefind / Service Worker"] PAGES -.-> SEARCH["AI 语义搜索
Worker / Workers AI
Vectorize / D1"]
%%{init: {"flowchart": {"nodeSpacing": 12, "rankSpacing": 14, "useMaxWidth": false}, "themeVariables": {"fontSize": "16px"}}}%%
flowchart TD
    REPO["Git Repository"] --> CI["构建、索引与质量门禁
Hugo / Pagefind / Vector Sync
Lighthouse / Link Check"] CI --> PAGES["Cloudflare Pages"] PAGES --> STATIC["静态访问
Pagefind / Service Worker"] PAGES -.-> SEARCH["AI 语义搜索
Worker / Workers AI
Vectorize / D1"]
%%{init: {"flowchart": {"nodeSpacing": 12, "rankSpacing": 14, "useMaxWidth": false}, "themeVariables": {"fontSize": "16px"}}}%%
flowchart TD
    REPO["Git Repository"] --> CI["构建、索引与质量门禁
Hugo / Pagefind / Vector Sync
Lighthouse / Link Check"] CI --> PAGES["Cloudflare Pages"] PAGES --> STATIC["静态访问
Pagefind / Service Worker"] PAGES -.-> SEARCH["AI 语义搜索
Worker / Workers AI
Vectorize / D1"]

实际运行时,一次文章提交会触发多条相互独立的 GitHub Actions 工作流:

一次文章提交触发 Lighthouse CI、Cloudflare Pages 部署保留、IndexNow 通知和 AI 内容加工等 GitHub Actions 工作流

这些工作流分别负责内容加工、质量检查、搜索引擎通知与部署治理。职责拆分后,单条链路的失败不会掩盖其他环节的执行状态,也更便于独立重试和排查。

这里最关键的变化,是把不同职责分开:

  • Worker 负责运行时检索,不负责文章生成。
  • GitHub Actions 负责构建期内容加工,不参与用户请求。
  • Pagefind 和 Service Worker 提供独立于 AI API 的降级能力。
  • Git 仓库继续保存所有可审阅的内容状态。

这样即使某个 AI 服务临时不可用,博客仍然是一个可以正常阅读和搜索的静态站点。

二、搜索层的演进:从 Gemini 单路径到可切换 Embedding

上一篇文章使用 Gemini text-embedding-004 生成 768 维向量。当前实现把默认 Embedding 路径切换到了 Cloudflare Workers AI:

1
2
3
4
5
6
7
8
[ai]
binding = "AI"

[vars]
EMBEDDING_PROVIDER = "cloudflare"
CF_EMBEDDING_MODEL = "@cf/baai/bge-base-en-v1.5"
CF_EMBEDDING_POOLING = "cls"
EMBEDDING_DIMENSIONS = "768"

Gemini 路径没有被删除,而是保留为可切换的备选实现。这样做不是为了追求“模型越多越好”,而是为了把模型选择从业务代码中抽离出来。

真正需要严格保证的是下面这条约束:

写入 Vectorize 的文档向量,与查询时生成的 Query 向量,必须使用相同模型、维度、Pooling 和归一化方式。

只要其中一个参数不一致,即使接口都返回成功,检索质量也会悄悄失效。这类问题比直接报错更危险,因为系统看起来仍然“能搜”,只是结果越来越不相关。

删除文章也要同步删除向量

早期同步脚本只做 Upsert。文章被删除或改名后,旧向量仍可能留在 Vectorize 中,最终出现“搜索结果能看到,但打开是 404”的幽灵文章。

现在的 Workflow 会先通过 Git diff 找出删除或重命名的文章 slug,再调用 Vectorize delete_by_ids

1
2
3
4
5
Git diff
  → 提取删除或重命名前的 slug
  → 写入 VECTOR_DELETE_IDS_JSON
  → 删除旧向量
  → Upsert 当前文章向量

这一步看似只是清理数据,实际解决的是搜索索引与内容事实来源之间的一致性问题:

  • Markdown 仓库仍然是 Source of Truth
  • Vectorize 只是可重建的索引层。
  • 索引不能保留仓库里已经不存在的事实。

阈值从后端判断扩展到前端可调

Worker 目前使用 0.55 判断一次搜索是否真正命中,并把结果写入 D1:

1
2
3
const hasResults =
  matches.matches.length > 0 &&
  matches.matches[0].score > 0.55;

前端则提供默认值为 0.6 的滑块,让读者自行调整展示阈值。

这两个阈值职责不同:

  • Worker 阈值决定这次查询是否记为 Content Gap。
  • 前端阈值决定哪些候选结果展示给当前读者。

这种拆分比把一个固定分数同时用于分析和展示更灵活,但也意味着阈值需要持续根据真实查询校准,而不能把 0.55 当成适用于所有模型的通用常数。

三、十步流水线:一次 Push 如何加工一篇文章

content/**static/image/** 发生变化时,GitHub Actions 会执行一条十步流水线:

步骤处理内容主要输出
1同步 EmbeddingVectorize 索引
2生成中文摘要ai_summary
3生成三条 TL;DRai_tldr
4识别文章系列series_part 等字段
5计算语义相关推荐ai_related
6选择正文首图images / OG Image
7注入内部交叉链接Markdown 链接
8生成图片 Alt Text无障碍与图片 SEO 文本
9转换 WebP图片压缩副本
10中译英index.en.md
%%{init: {"flowchart": {"nodeSpacing": 8, "rankSpacing": 14, "useMaxWidth": false}, "themeVariables": {"fontSize": "16px"}}}%%
flowchart TD
    PUSH["提交与索引
文章 Push · 1. 同步向量"] PUSH --> CONTENT["内容结构化
2. 摘要 · 3. TL;DR
4. 系列 · 5. 相关推荐"] CONTENT --> ENRICH["内容增强与翻译
6. OG 图 · 7. 交叉链接
8. Alt Text · 9. WebP · 10. 中译英"] ENRICH --> COMMIT["提交生成内容"]
%%{init: {"flowchart": {"nodeSpacing": 8, "rankSpacing": 14, "useMaxWidth": false}, "themeVariables": {"fontSize": "16px"}}}%%
flowchart TD
    PUSH["提交与索引
文章 Push · 1. 同步向量"] PUSH --> CONTENT["内容结构化
2. 摘要 · 3. TL;DR
4. 系列 · 5. 相关推荐"] CONTENT --> ENRICH["内容增强与翻译
6. OG 图 · 7. 交叉链接
8. Alt Text · 9. WebP · 10. 中译英"] ENRICH --> COMMIT["提交生成内容"]
%%{init: {"flowchart": {"nodeSpacing": 8, "rankSpacing": 14, "useMaxWidth": false}, "themeVariables": {"fontSize": "16px"}}}%%
flowchart TD
    PUSH["提交与索引
文章 Push · 1. 同步向量"] PUSH --> CONTENT["内容结构化
2. 摘要 · 3. TL;DR
4. 系列 · 5. 相关推荐"] CONTENT --> ENRICH["内容增强与翻译
6. OG 图 · 7. 交叉链接
8. Alt Text · 9. WebP · 10. 中译英"] ENRICH --> COMMIT["提交生成内容"]

为什么把生成结果写回 Git

另一种方案是构建时临时生成所有内容,不写回仓库。它更“干净”,但有一个明显问题:摘要、翻译和内部链接只存在于构建产物中,作者无法像审阅普通代码一样审阅它们。

当前方案选择把结果写回 Markdown:

  • 生成内容可以进入 Git diff。
  • 错误翻译和错误链接可以人工修正。
  • 每次修改都有提交记录。
  • Hugo 构建不依赖运行时调用 LLM。

代价也很直接:CI 获得了修改内容仓库的能力,因此必须控制重复运行和并发写入。

幂等比“自动化”更重要

摘要、TL;DR 和翻译脚本都会记录正文 hash。正文未变化时直接跳过,避免每次 Push 都重新调用模型。

相关推荐会对分数做两位小数舍入,并在新旧数据一致时跳过写入,避免向量检索的细微浮动制造无意义 diff。

AI Workflow 自己生成的提交包含 [skip ai-sync],防止再次触发自己;如果运行期间用户又 Push 了新提交,脚本会尝试 rebase 后再 Push,最多重试三次。

这套机制解决的不是性能问题,而是自动写回系统最容易出现的两个故障:

  1. 工作流递归触发,形成无限提交。
  2. 多次并发运行互相覆盖内容。

四、AI 不只用于生成,还用于组织内容

增加摘要和翻译很容易被理解,但这次改造中更重要的部分,是让已有文章开始形成结构。

TL;DR 与系列导航

ai_tldr 会在文章顶部渲染三条核心结论,让读者在进入长文前快速判断是否值得继续读。

系列识别则不依赖 LLM,而是根据标题中的 Part、序号等规则生成:

1
2
3
series
series_part
series_total

这里刻意使用确定性规则,而不是让模型判断一切。能通过稳定规则解决的问题,不应该额外引入模型的不确定性。

相关推荐从 Tag 匹配升级为语义匹配

传统博客的相关文章通常依赖 Tag。问题是 Tag 很容易漏标,而且两个主题接近的文章不一定共享完全相同的标签。

现在的 ai-related-rebuild.py 会用文章标题查询现有 Worker,排除文章自身后,把 Top-K 结果写入 ai_related

这相当于复用了同一套向量索引:

1
2
读者输入自然语言 → 搜索相关文章
文章标题作为 Query → 生成相关推荐

同一个检索能力,既服务用户,也服务内容组织。

自动交叉链接不是全自动乱加链接

交叉链接分成两个阶段:

  1. LLM 为每篇文章提取 1 到 3 个有辨识度的 Anchor。
  2. 确定性脚本在其他文章中找到首次提及,并注入内部链接。

脚本会跳过代码块、已有链接、标题和 HTML,每篇文章最多增加 5 条链接。

这个上限很重要。内部链接的目标是帮助读者补充上下文,而不是把正文变成 SEO 链接农场。

五、双语系统:翻译只是第一步

生成 index.en.md 之后,英文版还需要解决发现、跳转和搜索结果映射问题。

当前实现增加了四层处理:

  • Hugo 为中文和英文生成独立 URL。
  • 页面输出 hreflangx-default
  • 首页根据浏览器语言做一次自动跳转,并尊重用户的手动选择。
  • 页脚提供显式语言切换链接。

搜索层还有一个额外问题:Worker 返回的 Metadata 不一定是当前页面语言。

因此英文页面在构建时生成一张 slug → English title / URL 映射表。收到 Worker 结果后,用稳定的 slug 替换展示标题和链接:

1
2
3
4
if (USE_EN && item.id && EN_MAP[item.id]) {
  item.metadata.title = EN_MAP[item.id].title;
  item.metadata.url = EN_MAP[item.id].url;
}

这是一种实用的兼容层,但不是最终形态。更完整的设计应该在向量索引中显式保存语言字段,甚至为不同语言使用独立 namespace,避免同一 slug 的多语言文档互相覆盖。

六、AI 服务不可用时,博客仍然要能搜索

系统加入的最重要能力之一,其实不是 AI,而是 Pagefind

AI 搜索依赖 Worker、Embedding 模型和 Vectorize。任何一层异常,都可能让搜索入口失效。Pagefind 则在 Hugo 构建后扫描静态 HTML,生成纯前端全文索引:

1
2
hugo --gc --minify --cleanDestinationDir
npx -y pagefind --site public --silent

两种搜索承担不同任务:

能力AI 语义搜索Pagefind 全文搜索
擅长语义相似、概念关联精确词、标题和正文匹配
运行依赖Worker + Embedding + Vectorize浏览器中的静态索引
网络故障影响可能不可用已加载索引后仍可工作
成本有 API 和边缘计算调用构建期成本

页面不会把两者伪装成同一种搜索,而是明确告诉读者:AI 搜索是首选,全文搜索是独立兜底。

flowchart TD
    USER["User query"] --> AISEARCH["AI semantic search"]
    AISEARCH -->|Available| RESULTS["Semantic results"]
    AISEARCH -->|Unavailable or no useful match| PAGEFIND["Pagefind full-text search"]
    PAGEFIND --> STATIC["Static index results"]

    USER --> ARTICLE["Previously visited article"]
    ARTICLE --> SW["Service Worker cache"]
    SW -->|Offline| CACHED["Cached HTML and assets"]
flowchart TD
    USER["User query"] --> AISEARCH["AI semantic search"]
    AISEARCH -->|Available| RESULTS["Semantic results"]
    AISEARCH -->|Unavailable or no useful match| PAGEFIND["Pagefind full-text search"]
    PAGEFIND --> STATIC["Static index results"]

    USER --> ARTICLE["Previously visited article"]
    ARTICLE --> SW["Service Worker cache"]
    SW -->|Offline| CACHED["Cached HTML and assets"]
flowchart TD
    USER["User query"] --> AISEARCH["AI semantic search"]
    AISEARCH -->|Available| RESULTS["Semantic results"]
    AISEARCH -->|Unavailable or no useful match| PAGEFIND["Pagefind full-text search"]
    PAGEFIND --> STATIC["Static index results"]

    USER --> ARTICLE["Previously visited article"]
    ARTICLE --> SW["Service Worker cache"]
    SW -->|Offline| CACHED["Cached HTML and assets"]

PWA Service Worker 又补了一层离线能力:

  • HTML 使用 stale-while-revalidate。
  • CSS、JavaScript 和图片使用 cache-first。
  • Worker API、Cloudflare Analytics 等动态请求不缓存。

这里的设计原则是:缓存内容,不缓存动态判断。

七、从“能发布”到“可持续维护”

功能变多以后,另一个风险随之出现:页面能构建,不代表体验没有退化。

因此项目增加了几类质量检查。

Lighthouse CI

每次影响渲染的 Push 都会检查中文首页、英文首页、AI 搜索页和代表性文章。

当前门槛是:

  • Performance ≥ 0.85
  • Accessibility ≥ 0.90
  • Best Practices ≥ 0.85
  • SEO ≥ 0.90

这些门槛目前采用 warning,而不是硬性阻断。原因是 Lighthouse 本身存在环境波动,现阶段更适合作为趋势监控和回归提示。

详细报告会作为 GitHub Actions Artifact 保留 7 天,同时上传临时在线报告。

链接检查与搜索引擎通知

Lychee 每周扫描 Markdown 和主要 Layout 中的链接。发现失效链接后自动生成 Issue,而不是等读者反馈。

普通内容 Push 后,IndexNow Workflow 会提取本次变化的中英文 URL,主动通知支持 IndexNow 的搜索引擎。AI 流水线带 [skip ai-sync] 的回写提交则会跳过,避免重复触发。

这两条链路分别解决:

  • 旧内容是否仍然可访问。
  • 新内容是否能尽快被发现。

图片与元数据

流水线还会补齐一组容易被忽略但长期影响体验的细节:

  • 从正文首图生成 Open Graph 图片。
  • 没有正文图片时使用全站默认封面。
  • 使用视觉模型补充弱 Alt Text。
  • 将 PNG/JPG 转成 WebP,并保留原图作为兼容回退。
  • 输出 JSON-LD Publisher 信息。
  • 通过 Cloudflare Web Analytics 观察访问情况。

这些能力单独看都不复杂,但它们决定了一篇文章在社交分享、搜索结果、屏幕阅读器和移动网络中的真实表现。

八、踩坑与取舍

1. 不要把当前浮动按钮写成完整 AI 问答

文章页的浮动入口会把当前文章 slug 作为 ctx 参数传给 Worker,但 Worker 目前尚未消费这个参数,也没有调用生成模型组织最终答案。

它当前更准确的定位是:

带文章入口上下文的全站语义检索 UI,而不是基于本文内容直接回答问题的完整 RAG Agent。

如果后续要升级为真正的文章问答,需要增加 Chunk 级索引、上下文拼装、引用来源和生成答案等能力。

2. 自动生成不等于自动正确

翻译、摘要、Anchor 和 Alt Text 都可能出错。把结果写回 Git 的目的,就是让自动生成内容继续接受代码审阅式的检查。

在技术博客里,模型最容易犯的不是语法错误,而是把“可能”“计划”“当前实现”翻译成已经完成的事实。

3. 构建流水线越长,权限边界越重要

AI Workflow 可以修改仓库,Worker 可以访问 Vectorize、D1 和 Workers AI。这些都不是普通的前端插件,而是具备写权限或资源调用权限的系统主体。

生产化时至少需要继续收紧:

  • GitHub Token 和 Cloudflare Token 的权限范围。
  • Worker 的 CORS Allowed Origin。
  • 搜索接口的限流和滥用防护。
  • 自动提交发生冲突时的人工审阅入口。

4. 静态优先不能只是一句口号

如果首页渲染依赖 Worker、文章打开依赖数据库、搜索依赖生成模型,那么它实际上已经不再是一个可靠的静态博客。

当前系统坚持的边界是:

  • 正文阅读永远不依赖 AI 服务。
  • AI 搜索失败时有 Pagefind。
  • 网络离线时可以读取访问过的页面。
  • 所有 AI 生成结果在部署前落成普通 Markdown 或静态资源。

AI 是增强层,而不是站点存活的前提。

九、下一步

这套系统已经从单点 AI 搜索扩展成了一条内容工程流水线,但仍有几个明确的下一步:

  1. 为向量索引增加语言字段或 namespace,彻底解决多语言文档覆盖问题。
  2. 让 Worker 真正消费文章 ctx,实现 Chunk 级引用和有来源的答案生成。
  3. 为搜索接口增加限流、来源校验和更完整的可观测性。
  4. 把 Mermaid、翻译和内部链接检查加入自动验收,而不只依赖 Hugo 构建成功。
  5. 将 AI 生成内容的 diff 摘要作为明确的人工 Review Gate。

总结

上一篇文章解决的是“如何让静态博客具备 AI 语义搜索”。这次演进解决的是另一个问题:

当文章数量、语言和自动化能力持续增长时,如何让内容从写作到发布、发现、检索和维护形成稳定闭环。

最终形成的不是一个“AI 功能很多”的博客,而是一套职责相对清晰的工程系统:

  • Git 是内容事实来源。
  • GitHub Actions 是内容控制面。
  • Cloudflare Worker、Workers AI、Vectorize 和 D1 是搜索数据面。
  • Pagefind 和 PWA 是静态降级面。
  • Lighthouse、Lychee 和 Hugo Build 是质量门禁。

真正有价值的不是让 AI 替作者写完所有内容,而是让机器承担重复、可验证、可回滚的加工工作,让作者把注意力留给选题、判断和最终审阅。


想跟进更新? RSS


相关内容