← 全部文章
RAGAI架构全栈

给博客做一个 AI 助手:从 0 到 1 的 RAG 实战

2026年7月30日 · 16 分钟

起因:让博客「会自己回答问题」

博客文章越写越多,一个朴素的愿望冒了出来:能不能有一个助手,读过我写的每一篇文章, 帮读者快速找到想看的那篇,还能直接回答文章里的知识?

这不是简单的站内搜索——关键词搜索只会匹配字面,而我想要的是语义级的理解: 读者问「怎么让容器数据不丢」,它应该能定位到《数据持久化 Volume 与 Bind Mount》, 并用文章里的内容作答。

这正是 RAG(检索增强生成) 的经典场景。这篇文章完整记录我做这个助手的架构决策与落地过程。

三个必须先想清楚的问题

动手前,我强迫自己回答三个决定架构走向的问题:

问题结论理由
需要后端服务吗?需要服务端,但不需要独立后端LLM 的 Key 不能放前端,必须服务端中转;而博客已是 Node 部署,用 Next.js 的 Route Handler 即可
需要 RAG 吗?需要「找文章」本质是语义检索,「答知识」是基于检索结果生成,二者正是 RAG 的两半
文章要入库吗?上千篇量级——必须入向量数据库几十篇可以内存暴力算相似度,上千篇必须靠向量库的 ANN 索引

关键认知:数据规模决定架构。几十篇和上千篇是两套方案——前者一个 JSON 文件搞定, 后者需要真正的向量数据库、增量更新和重排序。本文按上千篇量级来设计。

整体架构:三层解耦

``` 【离线】Ingestion 管线 Markdown 文章 ─▶ 变更检测(hash) ─▶ 切块 ─▶ Embedding ─▶ 写入向量库(增量)

【存储】向量数据库 (pgvector) 向量 + 元数据 + 全文索引

【在线】请求路径 用户提问 ─▶ /ai/chat ├─ query embedding ├─ 检索 top-k 相关块 ├─ 组 Prompt(检索块 + 防幻觉约束) └─ LLM 流式生成 ─▶ 前端逐字渲染 + 引用文章卡片 ```

离线写、在线读、存储居中,三层各自独立演进——这是能扩展到上千篇的关键。

第一步:内容层——从硬编码到 Markdown

最初文章是写死在 TypeScript 数组里的。上千篇量级下这不可维护,于是第一步就是把内容 迁移成独立的 Markdown 文件(frontmatter 存元数据,正文是 Markdown):

``` content/posts/<slug>.md # 每篇一个文件,可 diff、可版本化 lib/posts/generated.ts # 构建期由 md 自动生成的类型化数据 ```

这么做有三个好处:版本化管理对现有代码零侵入(保持数据接口不变),以及最重要的—— 成为 RAG Ingestion 的标准数据源。后续切块直接读这些 md 文件即可。

第二步:向量数据库——为什么选 pgvector

上千篇会切出几万~几十万个向量块,内存暴力计算不可行。我选了 pgvector(Postgres + 向量扩展), 而不是专用向量库,理由很实在:

  • 一库三用:向量检索 + 元数据过滤 + 全文关键词检索,一个 Postgres 全包了,省一个组件。
  • 运维简单:一个 Docker 容器就能起,和现有部署栈天然契合。

核心表结构:

```sql CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents ( slug TEXT PRIMARY KEY, content_hash TEXT NOT NULL, -- 增量更新的关键 updated_at TIMESTAMPTZ DEFAULT now() );

CREATE TABLE chunks ( id TEXT PRIMARY KEY, slug TEXT REFERENCES documents(slug) ON DELETE CASCADE, heading TEXT, content TEXT, embedding vector(1024), -- 智谱 embedding-3,1024 维 tsv tsvector -- 为混合检索预留的全文索引 );

CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops); -- 向量索引 CREATE INDEX ON chunks USING gin (tsv); -- 关键词索引 ```

第三步:Ingestion——增量才是命脉

上千篇绝不能每次全量重算。我的做法是基于内容 hash 的增量更新

  1. 每篇文章算 `content_hash`,存进 `documents` 表。
  2. 每次 ingest 时对比:
    • hash 变了或新文章 → 重新切块、embedding、写入
    • 文章被删了 → 从库里删除对应块
    • 没变 → 直接跳过

这样改 1 篇文章,只重算这 1 篇的向量,成本几乎为零。

切块策略:按 Markdown 的标题(`##`/`###`)分节,节内过长再按 ~600 字滑窗切分并保留少量重叠, 每块都带上所属标题,方便后续引用定位。

Embedding:用智谱 `embedding-3`(1024 维),它兼容 OpenAI 格式,批量调用即可。

第四步:问答 API——检索 + 流式生成

后端是一个 Next.js Route Handler(`/ai/chat`,Node 运行时),流程很直接:

  1. 把用户问题转成向量,用 pgvector 的余弦距离检索 top-k 相关块。
  2. 把检索块拼进 Prompt,配上强约束的系统提示

只依据【检索片段】回答,无相关内容就直说「博客里暂时没有找到」,绝不编造,并标注来源文章。

  1. 调用智谱 GLM(兼容 OpenAI 的流式接口),把返回的 SSE 流转成前端易解析的 NDJSON 逐行下发。

这里我特意没用重型 SDK,而是手写流式转发——既避开了 SDK 的版本兼容问题,又完全可控:

```ts // 先下发引用来源,再逐字下发生成内容 controller.enqueue(encodeLine({ type: "sources", sources })); // ... 解析上游 SSE 的 delta ... controller.enqueue(encodeLine({ type: "delta", text: delta })); controller.enqueue(encodeLine({ type: "done" })); ```

第五步:前端——会打字的悬浮助手

前端是一个右下角的悬浮球,点开是对话面板:

  • 用 `fetch` + `ReadableStream` 读取后端流,逐字渲染答案(体感即时)。
  • 答案下方渲染引用文章卡片,点击直接跳转到对应文章——这是"帮读者找到文章"的直接兑现。
  • 配色跟随博客的氛围主题,视觉统一。

引用做了双保险:不光靠模型在文字里标注,后端还会单独返回去重的来源列表,前端渲染成卡片, 即使模型没提,读者也能看到相关文章。

防幻觉:这是 RAG 的生命线

一个会一本正经胡说的助手比没有更糟。我的三道防线:

  1. 系统提示强约束:只依据检索内容作答,无据可依就承认不知道。
  2. 引用可追溯:每个答案都附来源文章,读者可自行核对。
  3. 优雅降级:向量库没就绪、Key 无效、检索为空,都返回明确提示而非硬编答案。

演进路线:现在与未来

架构做了接口化,规模增长时不用推翻:

规模检索方案
几十篇内存 JSON + 余弦相似度
上千篇(当前)pgvector + HNSW 向量索引
更大 / 更高精度混合检索(向量 + 关键词 RRF 融合)+ Rerank 重排

混合检索和 Rerank 是下一步——上千篇里靠它们把"召回"和"精排"分开,答案质量会有明显提升。

复盘:几条经验

  • 规模决定架构,先问清数据量级再动手,别一上来就上重型向量库,也别在上千篇时还用 JSON。
  • 内容层先行:把数据源规整成标准格式(Markdown),是后续一切的地基。
  • 增量更新从第一天就要设计,否则文章一多,每次 ingest 都是灾难。
  • 防幻觉不是可选项,引用可追溯 + 强约束 prompt + 优雅降级,缺一不可。
  • 能手写就别急着上 SDK:流式转发几十行代码,可控性远胜和 SDK 版本较劲。

这个助手已经在这个博客上跑起来了——右下角那个发光的 ✦ 就是它。欢迎点开,问它任何关于这些文章的问题。

相关水晶