给知识库网站接入AI问答

其实我的需求很简单。原先自己的知识库网站基于 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/streamingstreamSSE(把后端响应包成 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 开发,修行尚浅,望路过大佬们见谅。后面其实还能做进阶优化,这也是我打算在这个专栏接着探索的尝试:

  1. Hybrid Search (混合检索):可叠加 BM25 关键词,解决专业术语丢失的问题。
  2. Re-ranking (重排):引入专门的重排模型(如 BGE-Reranker),精准过滤无效的"相似废话"。
  3. Agentic Flow (智能体):接入 Function Calling,从「问答」走向「任务执行」,比如让其检索其他数据库的内容或者联网搜索外部资源。

拥抱 AI 时代,把手弄脏,我们下篇见。

相关推荐
前端糕手2 小时前
前端面试题大全:JavaScript + Vue3 + React + TypeScript + 工程化 + 性能优化
前端
BestHeaker2 小时前
试用 WorkBuddy 一段时间的个人笔记:它能做什么、积分怎么算、以及我的真实评价
个人开发·学习方法·ai编程
我不叫武3 小时前
一个用 Rust 写的离线编码查询桌面工具
前端
yangshicong4 小时前
第19章:AI安全防护与AI安全
人工智能·python·安全·prompt·ai编程
灵感__idea4 小时前
《AI工程》:构建应用,需要哪些技术?(解惑篇)
aigc·openai·ai编程
Web4Browser4 小时前
指纹浏览器 API 自动化怎么接:启动 Profile、获取 CDP 端点并连接自动化框架
前端·网络·typescript·自动化
Patrick_Wilson4 小时前
从 React 到 Flutter:写给前端的一张跨端知识地图
前端·flutter·react.js
颜酱4 小时前
06 | 把 meta_config 同步进 MySQL(生成阶段)
前端·人工智能·后端
chaors4 小时前
DeepResearchSystem 0x03:HITL
llm·github·ai编程