其实我的需求很简单。原先自己的知识库网站基于 Algolia 的纯文本检索有点落后了,于是想接入 AI 问答,可以同时检索多处内容并做对话式的总结,而不是简单的返回带有文本字符串的文章列表。(Algolia 后面推出了 NeuralSearch,不过得额外开启还要付费)。
作为前端,我一直想找个切入点探索 AI Agent 相关技术,而给自己的知识库接入基于 RAG(检索增强生成)的 AI 问答,无疑是跑通 Prompt 工程和 RAG 技术最好的练手项目。
怎么交互呢?因为原先 Algolia 的检索窗口在右上角,所以我直接在旁边加上 AI 问答按钮,触发对话窗口。效果如下:

在线体验链接。点击右上角 Ask AI 就可以体验
这有点像客服助手------说起来,客服助手可以算是入门 AI Agent 最好的项目了,根据问题设计多种回复 Prompt,通过 Function Calling 调用 API 方法或查询数据库,我刚开始练手就是搞了一个客服助手,熟悉了 AI Agent 的基本体系。
接下来,我需要一个轻量的后端服务来实现 RAG 服务接口,对接向量数据库。
整体设计与技术选型
做全栈开发,第一步是定技术栈。我的核心诉求是轻量、低成本、免运维:
- 前端:
docusaurus(SSG)。博客原有的基建,负责展示和发起问答。 - 后端:
hono。没有选 Express,Hono 是一个极度轻量的 Web 框架 (12-15kb),原生支持 Web Standard API,不仅能在 Node.js 跑,还能跨运行时支持 Bun/Deno。API 风格跟 Express 很接近,这次主要是尝鲜,服务也不复杂,就用 Hono 来实现。 - 向量数据库:
chroma db。这是一个轻量的向量数据库,基于 SQLite(文件型存储),Node.js 也能快速接入。我的知识库属于个人场景,单用户访问,不需要分布式高 QPS。关于向量,可以先记住这句话:语义相近的文本,向量在空间中的距离就小。 - 大模型:
deepseek-v3。这种场景只是简单的总结和问答,不需要复杂的模型,性价比极高。 - 向量模型:阿里的
text-embedding-3。中文支持好,费用低。
关键工程问题
下面这几个模块,刚好按 RAG 的数据流转顺序铺开:离线数据生产(切片 -> 灌库) -> 在线服务接口(Prompt 拼接 -> LLM 流式透传) -> 前端展示(流式渲染)。
1. 文档切片 (Chunking)
切片策略:根据标题栏和段落,按语义切分文本。
我们需要将 Docusaurus 项目中 docs/ 和 blog/ 目录下的所有 Markdown (.md/.mdx) 文档提取出来,进行合理的切片处理,以便后续进行向量化 (Embedding)。 因为 Markdown 本身就是结构化的,H1/H2/H3 天然是语义边界,切出来的每片都是一个完整的小主题,直接喂给 embedding 模型。每个切片必须保留路由信息 (sourceUrl),以便于前端问答时回显来源链接。
这里使用了几个库来协助处理:
unified+remark-parse:用于解析 Markdown 生成 AST(抽象语法树)。如果不这么做,只能正则匹配#,碰到代码块里的#就误判。remark-frontmatter:用于识别并提取文件头部的 YAML 属性(如title/tags)。切片丢了标题,回显时无法显示"这是哪篇文档"。mdast-util-to-string:用于将 AST 节点转回纯文本进行分析。因为经过上述处理后拿到的还是带结构的对象,没法直接喂给 embedding。
整个 pipeline:
text
.md 源文件
→ unified + remark-parse:解析成 AST(树状结构)
→ remark-frontmatter:把头部的 YAML 抽出来作为 metadata
→ 遍历 AST 找 H2/H3 节点作为切片锚点
→ mdast-util-to-string:把切片节点的文本内容转成纯文本
→ 输出:{ sourceUrl, title, content, ...metadata }
为什么不一步到位?Markdown 看似简单,其实头部的 YAML、代码块、链接、图片都是「结构化信息」,得先把它们都识别出来,才能精准地切、按层级地切、不破坏语境地切。
注意:只切到 H2/H3 比较合适,再细就碎了;遇到特别长的章节,可以按段落再细分。
可以配置 package.json 的 scripts 命令,这样在 GitHub Workflow 流程里就能执行脚本触发文档切片,并自动上传向量数据库:
json
"scripts": {
"rag:parse": "tsx scripts/rag/test-parser.ts"
}
2. 文本向量化与增量灌库
借助 text-embedding-3 模型,把切好的文本转成向量表示(就是把「文字」转成「坐标」,后续在向量空间里找距离最近的那几个)。

存到 ChromaDB 时有几个细节:
- 向量维度 :
text-embedding-3默认输出 1024 维,每片文本对应一个 1024 维的浮点数数组。 - 元数据一起存:除了向量,把原文、文档路径、章节标题作为 metadata 一起入库,召回时靠 metadata 做过滤和展示。
切完的真实数据格式如下:
json
{
"content": "[文档路径: /docs/afreshjs/Node.js/高级核心概念 | 章节: 高级核心概念 > 高级核心概念 > 3. 模块化的底层原理 (CJS vs ESM) > 3.2 循环引用 (Circular Dependency)]\n### 3.2 循环引用 (Circular Dependency)\n\n当 `a.js` 引用 `b.js`,同时 `b.js` 又引用 `a.js` 时:\n\n- **CommonJS 的表现**:\n CJS 在加载模块时,会优先在 `require.cache` 中创建该模块的空对象 `module.exports`。当发生循环引用时,`b.js` 会拿到 `a.js` **还没执行完的、不完整的 `exports` 对象**。这会导致运行时拿到 `undefined` 而报错。\n _(CJS 导出的是值的拷贝/浅拷贝)_\n\n- **ESM (ECMAScript Modules) 的表现**:\n ESM 的加载分为"解析"、"实例化"、"执行"三个阶段。ESM 导出的是**实时绑定 (Live Bindings)**,即导出的变量和原模块内部的变量指向同一块内存地址。\n 因此在处理循环引用时,只要你不立刻去读取那个还没初始化的变量,引擎就能完美处理好模块的依赖图谱。\n\n---",
"metadata": {
"sourceUrl": "/docs/afreshjs/Node.js/高级核心概念",
"title": "高级核心概念",
"h1": "高级核心概念",
"h2": "3. 模块化的底层原理 (CJS vs ESM)",
"h3": "3.2 循环引用 (Circular Dependency)",
"chunkIndex": 5
}
}
增量灌库
第一次全量灌库时有个小插曲:接口携带的数据太大,导致 Nginx 报了 body 体积过大的异常 (413)。除了调整 Nginx 阈值 (client_max_body_size),稳妥起见还用了多个接口分批上传,避免触发接口超时 (504)。
后续更新采用增量灌库:基于 Git Diff 仅对变更文件重新 Embedding,避免每次修改知识库都要全量上传。这部分逻辑写在 GitHub Actions 中。
ChromaDB 的 API 设计非常契合这种场景,底层 HNSW 索引是自维护的,不用手动 rebuild:
typescript
// 新增
await collection.add({ ids, documents, embeddings, metadatas });
// 有则更新、无则新增(按 id)------ 灌库最常用
await collection.upsert({ ids, documents, embeddings, metadatas });
// 更新已有(不存在会报错)
await collection.update({ ids, embeddings, metadatas });
// 删除
await collection.delete({ ids: [...] });
3. Prompt 设计
针对 AI 问答的回复,我设计了如下 Prompt,严格限制 AI 的行为,避免幻觉:
markdown
# 角色
你是一个知识库助手,只能基于「参考文档」回答用户问题,不要编造。
# 回答要求
1. 先用 1-2 句话直接回答用户问题
2. 再补充关键细节(来自参考文档)
3. 最后列出引用来源(仅列文档名 + 章节,不要贴 URL 长链接)
4. 如果参考文档不足以回答,明确说"知识库没有相关内容"
# 参考文档
${retrieved_chunks}
# 用户问题
${user_query}
4. 流式问答 (SSE) 与 Nginx 代理坑点
这就是 SSE 的典型应用场景:用户输入问题,后端检索数据库将最相关的数据喂给模型,然后将模型返回的结果处理成一个流式响应,前端实时接收并展示给用户(打字机效果)。
开启 SSE 接口,三件事缺一不可:响应头 + 后端实现 + Nginx 反代配置。
1. 响应头
http
content-type: text/event-stream
cache-control: no-cache # 防止中间代理缓存
connection: keep-alive # 保持长连接
x-accel-buffering: no # 告诉 Nginx 不要缓冲(关键)
2. 后端实现 (Hono) 用 hono/streaming 的 streamSSE(把后端响应包成 SSE 格式 data: ...\n\n),上游 stream: true 让 DeepSeek 边想边吐,核心是「透传上游流」:
typescript
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
const app = new Hono();
app.get("/rag-api/ask", async (c) => {
return streamSSE(c, async (stream) => {
// 1. 请求上游 LLM,开启流式
const upstream = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
/* ... */
},
body: JSON.stringify({ /* ... */ stream: true }),
});
if (!upstream.body) return;
// 2. 透传上游流:read → 解析 → writeSSE
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// 监听客户端断开,清理上游(不浪费 token)
stream.onAbort(async () => {
await reader.cancel();
});
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // 最后一行可能不完整,留到下轮
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") {
await stream.writeSSE({ data: "[DONE]" });
continue;
}
try {
const json = JSON.parse(data);
const content = json.choices[0]?.delta?.content || "";
if (content)
await stream.writeSSE({ data: JSON.stringify({ content }) });
} catch (e) {
console.error("SSE 解析错误:", e);
}
}
}
});
});
3. Nginx 反代必须关缓冲(最常踩的坑) Nginx 默认会缓冲响应,SSE 会被"卡住"看不到流,等缓冲满了才一次性吐出来。必须加:
nginx
location /rag-api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_buffering off; # 关键!禁用缓冲
proxy_cache off; # 禁用缓存
proxy_http_version 1.1; # SSE 需要 HTTP/1.1
chunked_transfer_encoding on;
proxy_read_timeout 60s; # 防止超时断流
}
总结下易踩的坑:
| 坑 | 症状 | 解法 |
|---|---|---|
| Nginx 默认缓冲 | 流"卡"住,输出一大坨 | proxy_buffering off |
| 客户端关闭但上游还在跑 | Token 继续消耗 | stream.onAbort + reader.cancel |
| chunk 跨行 | JSON.parse 报错 | buffer 累积 + lines.pop() |
EventSource 不支持 POST |
传参不方便 | 用 @microsoft/fetch-event-source |
为啥要用
@microsoft/fetch-event-source这个库来做前端 SSE 请求?
- 原生
EventSource只支持 GET 请求,header 也不能自定义(传个 token 都麻烦)。 fetch+getReader()在微信内置浏览器(X5/WKWebView)会拿到null直接抛异常。
这个库底层用 fetch 发请求,支持 POST 和自定义 Header,内部封装了 ReadableStream 兼容处理,业务代码只关心 onmessage 回调即可。
总结下上述流程:
txt
DeepSeek API (ReadableStream)
│ raw bytes (SSE 格式: data: {...}\n\n)
▼
getReader() 异步迭代
│
▼
TextDecoder → buffer 累积 → 按 \n 切行
│
▼
JSON.parse → 提取 content
│
▼
writeSSE({ data: JSON.stringify({content}) })
│
▼
Hono 内部写到 response.body (writable)
│
▼
Nginx 不缓冲 → 客户端立即可见
评估模型回复的质量
RAG 跑起来之后,怎么知道它"答得好不好"?靠人工抽查显然不靠谱。这里用了一个比较简单的评估流程:
一、建立「黄金评测集」 人工标注一批「问题-标准答案」对,覆盖知识库核心知识点。起步 50~100 条就够,跟着系统一起迭代。
二、给每个回答打个分 省事打法:直接让 LLM 当裁判 A/B 评分,准备一份评分 Prompt,让 LLM 对比「标准答案」和「模型回答」打分。
三、问答日志落库 每次问答记录 query, retrievedChunks, finalAnswer, feedback (用户点赞/踩)。落库后方便每周抽样核对,找出「召回失败」的反例。
四、指标差了怎么调优------先定位再下手
| 现象 | 问题出在 | 调优方向 |
|---|---|---|
| 召回到的 chunk 不对 | 召回阶段 | 换切片策略 / 换 embedding / 调 Top-K |
| 召回到的 chunk 对了,但 LLM 没理解 | 生成阶段 | 改 prompt / 升档模型 |
黄金思路:召回问题比生成问题更常见,先看召回。90% 的"AI 答错"案例其实是"压根没找到对的资料"。
Token 消耗把控
目前只是通过 Prompt 限制输入输出 token,以及增量灌库减少向量模型 token 的消耗。如果后面要用上高级模型的话,肯定得做好更精细的把控,毕竟真的贵。
自动化部署
最近 vibe coding 了几个 Web App,基本都走的这个流程,还挺方便的:
- GitHub Actions 编写 workflow deploy 脚本,push 代码后自动触发镜像构建。
- 编写 Dockerfile,上传 Docker Hub。
- 个人服务器从 Docker Hub 拉取镜像部署。
注意:要先在 GitHub 设置 secrets,以及在服务器相关项目目录里设置环境变量,避免明文存储敏感信息。
最后
坦白说,这只是一个简单的 RAG 服务,仅仅是向量召回和模型总结回复(Naive RAG)。在实际使用中,你会发现搜极度具体的专有名词时,纯向量检索很容易漏召回。
我也是刚接触 ai agent 开发,修行尚浅,望路过大佬们见谅。后面其实还能做进阶优化,这也是我打算在这个专栏接着探索的尝试:
- Hybrid Search (混合检索):可叠加 BM25 关键词,解决专业术语丢失的问题。
- Re-ranking (重排):引入专门的重排模型(如 BGE-Reranker),精准过滤无效的"相似废话"。
- Agentic Flow (智能体):接入 Function Calling,从「问答」走向「任务执行」,比如让其检索其他数据库的内容或者联网搜索外部资源。
拥抱 AI 时代,把手弄脏,我们下篇见。