给博客做一个 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 的增量更新:
- 每篇文章算 `content_hash`,存进 `documents` 表。
- 每次 ingest 时对比:
- hash 变了或新文章 → 重新切块、embedding、写入
- 文章被删了 → 从库里删除对应块
- 没变 → 直接跳过
这样改 1 篇文章,只重算这 1 篇的向量,成本几乎为零。
切块策略:按 Markdown 的标题(`##`/`###`)分节,节内过长再按 ~600 字滑窗切分并保留少量重叠, 每块都带上所属标题,方便后续引用定位。
Embedding:用智谱 `embedding-3`(1024 维),它兼容 OpenAI 格式,批量调用即可。
第四步:问答 API——检索 + 流式生成
后端是一个 Next.js Route Handler(`/ai/chat`,Node 运行时),流程很直接:
- 把用户问题转成向量,用 pgvector 的余弦距离检索 top-k 相关块。
- 把检索块拼进 Prompt,配上强约束的系统提示:
只依据【检索片段】回答,无相关内容就直说「博客里暂时没有找到」,绝不编造,并标注来源文章。
- 调用智谱 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 的生命线
一个会一本正经胡说的助手比没有更糟。我的三道防线:
- 系统提示强约束:只依据检索内容作答,无据可依就承认不知道。
- 引用可追溯:每个答案都附来源文章,读者可自行核对。
- 优雅降级:向量库没就绪、Key 无效、检索为空,都返回明确提示而非硬编答案。
演进路线:现在与未来
架构做了接口化,规模增长时不用推翻:
| 规模 | 检索方案 |
|---|---|
| 几十篇 | 内存 JSON + 余弦相似度 |
| 上千篇(当前) | pgvector + HNSW 向量索引 |
| 更大 / 更高精度 | 加混合检索(向量 + 关键词 RRF 融合)+ Rerank 重排 |
混合检索和 Rerank 是下一步——上千篇里靠它们把"召回"和"精排"分开,答案质量会有明显提升。
复盘:几条经验
- 规模决定架构,先问清数据量级再动手,别一上来就上重型向量库,也别在上千篇时还用 JSON。
- 内容层先行:把数据源规整成标准格式(Markdown),是后续一切的地基。
- 增量更新从第一天就要设计,否则文章一多,每次 ingest 都是灾难。
- 防幻觉不是可选项,引用可追溯 + 强约束 prompt + 优雅降级,缺一不可。
- 能手写就别急着上 SDK:流式转发几十行代码,可控性远胜和 SDK 版本较劲。
这个助手已经在这个博客上跑起来了——右下角那个发光的 ✦ 就是它。欢迎点开,问它任何关于这些文章的问题。