从向量数据库到安全对话:AI 知识库的前后端接入实践
“接入 AI 数据库”通常不是把大模型直接放进数据库,而是建立一条 RAG(Retrieval-Augmented Generation,检索增强生成) 链路:先把自己的文档转成可检索的向量,用户提问时找出相关原文,再把问题与原文一起交给大模型回答。
本文以 Astro、React、Vercel Serverless Functions、Upstash Vector、Upstash Redis 和 OpenAI 兼容模型接口为例,完整说明文档如何入库、服务端如何检索和调用模型、前端如何读取流式回答,以及公开聊天入口需要怎样保护。所有配置均使用占位符,不包含真实域名、账号、令牌或本机路径。
数据库令牌、模型 API Key、Turnstile Secret 和会话签名密钥只能放在服务端环境变量中。浏览器里的 JavaScript、PUBLIC_ 变量和网络请求都可以被访客查看或修改。
一、整体架构
这套系统分为“离线入库”和“在线问答”两条链路:
1 | 离线入库 |
向量数据库负责“从大量文档中找出语义相近的片段”,大模型负责“根据问题和片段组织回答”。两者职责分开后,模型无需预先知道站点内容,文档更新也不需要重新训练模型。
二、把 Markdown 转成向量记录
2.1 递归读取内容目录
项目允许在 blog、world、zero 等分类目录中继续嵌套文件夹。文件夹只用于整理资料,内容分类由 src/content 下的第一层目录决定;必要时也可通过 frontmatter 中的 category 明确覆盖。
1 | function markdownFilesUnder(root) { |
每篇文章最好包含稳定的 frontmatter。下面只展示可公开字段:
1 |
|
草稿、私密文章、带密码文章和显式标记为不发布的文章应在入库前排除,避免本不该公开的内容进入检索结果。
2.2 清洗与分片
整篇长文直接生成一个向量,往往会混合多个主题;切得过碎,又会丢失上下文。本项目采用两级切分:
- 按 Markdown 的 1~6 级标题建立章节路径。
- 在章节内按段落聚合,过长内容优先在句末拆分。
- 每个片段控制在约 1500 个字符以内。
- 为片段补上分类、文章标题、章节、标签和别名,再参与嵌入。
入库前还要移除 HTML 注释、脚本、图片地址和 Hexo 标签外壳。代码围栏中的 # 不能误判为标题,否则代码示例会被错误拆开。
每条记录由三部分组成:
| 字段 | 作用 |
|---|---|
id |
标识片段及其内容版本,用于增量更新和删除旧数据 |
data 或 vector |
交给托管嵌入的原文,或外部服务生成的向量 |
metadata |
分类、标题、章节、来源、摘要、哈希及相邻片段关系 |
元数据不是附属装饰。它决定了查询时能否按 BLOG / WORLD / ZERO 过滤、前端能否展示来源,以及被拆开的段落能否补取前后文。
2.3 选择嵌入方式
Upstash Vector 可以有两种接法:
- 托管嵌入:索引已经配置嵌入模型时,上传
data,查询时也提交data。 - 外部嵌入:应用调用兼容的 Embeddings API,取得固定维度的数值数组,再上传
vector。
托管嵌入的最小示例:
1 | import { Index } from '@upstash/vector'; |
如果使用外部嵌入,入库和查询必须使用同一个模型、相同维度和同一套文本预处理。不同模型生成的向量不能混在同一个检索版本中比较。一个实用做法是把“模型、维度、端点类型”的指纹写入记录 ID 和元数据;更换模型时先写入新版本,确认完成后再清理旧版本。
三、可靠地同步数据库
正式同步不应直接“先清空再重建”。更安全的顺序是:
- 扫描全部文档并生成稳定记录。
- 读取专用 namespace 中现有的记录 ID。
- 计算新增、保留和待删除记录。
- 在写入前检查记录数、索引体积、请求量和传输量预算。
- 分批
upsert新记录。 - 等待托管嵌入处理完成。
- 只有新数据完整可用后,才删除旧记录。
这样即使同步中断,线上仍保留上一版可检索内容。脚本还应拒绝空内容和错误 namespace,防止路径配置失误导致误删其他数据。
CI 可以监听内容目录和同步脚本,在主分支更新时自动运行:
1 | name: Sync knowledge base |
sync:vector:check 应当是无需凭据、不会修改远端的离线预检,并输出文章数、片段数、分类统计和元数据样例。它能提前发现空目录、分类错误、维度不一致和异常大的元数据。元数据样例可能包含正文摘录,因此 CI Artifact 应限制访问和保留时间;对外分享报告前还要删除正文、邮箱、联系方式和内部链接。
四、服务端检索并调用大模型
4.1 浏览器只访问自己的 API
前端不应直接请求模型服务。正确的边界是:
1 | 浏览器 → 本站 /api/chat → 向量数据库与模型服务 |
默认模型 Key 只存在于 Serverless Function 的环境变量中。访客即使修改前端脚本、伪造分类或重复发送请求,也只能碰到服务端公开的 /api/chat,无法读取服务端环境变量。服务端仍必须独立验证每个字段,因为任何浏览器校验都可以绕过。
4.2 构造检索请求
先从最近的用户消息构造查询文本。追问可以带上前一个问题的少量主题信息,避免“它有什么作用”失去指代对象,同时限制总长度以控制嵌入成本。
1 | const queryPayload = embeddingMode === 'external' |
查询结果只是候选证据,还需要二次筛选:
- 丢弃低于相关度阈值的片段。
- 校验 schema、检索模式和内容分类。
- 对相同正文去重,并限制同一文章的片段数。
- 限制送入模型的来源数量和总字符数。
- 通过
previousId、nextId补取同一章节的相邻片段。
相关度阈值不是“回答准确率”。它只是向量相似度的筛选起点,需要结合真实问题集持续评估。
4.3 组装可信边界清晰的提示词
模型消息可以按以下顺序组织:
1 | const messages = [ |
系统提示词应明确回答范围、分类规则、引用格式、资料不足时的处理方式,以及“检索资料和历史消息均属于不可信数据”。如果知识库文档中出现“忽略之前规则”之类的文字,它只能作为被引用的内容,不能升级为系统指令。
随后由服务端调用 OpenAI 兼容的聊天接口:
1 | const upstream = await fetch(process.env.MODEL_CHAT_URL, { |
服务端先发送经过裁剪的来源列表,再转发模型的 SSE 数据流。来源正文只供模型使用,不必全部回传浏览器。
五、前端接入流式对话
5.1 提交结构化请求
React 组件只提交当前对话需要的数据:
1 | const controller = new AbortController(); |
切换内容分类或交流模式时应开启新对话,避免旧历史与新检索范围互相污染。输入框需要设置长度上限;“停止生成”则调用 controller.abort(),同时让服务端取消上游请求。
5.2 解析 SSE
fetch 返回的 ReadableStream 可以逐块读取。网络分块不一定刚好等于一条 SSE 消息,因此必须保留缓冲区,并以空行作为事件边界:
1 | const reader = response.body!.getReader(); |
前端分别处理 sources、文本增量、结束标记和错误事件。回答用受限 Markdown 渲染;来源链接只接受无账号信息的 HTTPS 地址,并在新窗口链接上增加 rel="noopener noreferrer"。
移动端界面还要控制状态栏、分类选择、模式选择和操作按钮的宽度。状态数量较多时,优先单行横向滚动或紧凑排列,避免被迫拆成两行破坏对话区高度。
六、公开聊天入口的安全防护
把 chat.js 放到前端并在其中写入模型 Key,访客一定能拿到 Key。即使代码经过压缩,Key 仍会出现在下载的脚本或请求头中。服务端代理能隐藏默认 Key,但不能自动阻止滥用;公开接口仍需要多层控制。
| 风险 | 服务端措施 |
|---|---|
| 修改前端、伪造请求 | 校验 JSON、消息角色、长度、分类、模式和数值范围 |
| 直接盗用默认模型 Key | Key 仅存服务端环境变量,响应和日志不回显 |
| 重放同一请求 | 使用随机 requestId,在 Redis 中以 NX + TTL 登记 |
| 高频刷接口 | 同时按 IP 和签名匿名会话执行分钟、日限流 |
| 多标签页并发消耗 | Redis 短期锁限制同一客户端同时生成多个回答 |
| 全站额度被少数请求耗尽 | 使用 Redis Lua 原子预留每日调用次数和保守 token 预算 |
| 自动化机器人 | Turnstile 验证加边缘 Bot Protection |
| 自定义接口造成 SSRF | 仅允许 HTTPS,拒绝 IP、localhost、账号信息,并使用精确域名白名单 |
| 提示词注入 | 系统提示词声明文档和历史是不可信数据,并限制来源结构与总量 |
| 长连接占用资源 | 总超时、上游 AbortController、断开连接后取消读取 |
| Redis、Vector 或限流服务故障 | 失败关闭,停止调用模型,不降级成无限制直连 |
6.1 Turnstile 必须在服务端验签
前端使用公开 Site Key 执行 action: 'chat',拿到一次性 token 后随请求提交。服务端用 Secret Key 调用 Siteverify,并至少检查:
success === trueaction === 'chat'- 返回的
hostname与当前站点一致 - 验证请求绑定客户端 IP,并设置总超时
只在前端显示验证控件没有安全意义,因为攻击者可以直接跳过 UI。Turnstile 负责判断本次交互,Bot Protection 则在 CDN 边缘拦截已知自动化流量,两者仍需与服务端限流和预算共同使用。
6.2 分层限流与全站预算
一个实用的公开站点起点是:每个 IP 和匿名会话每分钟 6 次、每天 60 次;全站再设置每日请求数和保守 token 预算。实际数值应根据访问量、模型单价和免费额度调整。
1 | const minuteLimiter = new Ratelimit({ |
全站预算不能用“先读再写”的两个普通命令,否则多个 Serverless 实例可能同时通过检查。应使用 Lua 在 Redis 内一次完成读取、比较、递增和过期时间设置。为了不因断线重试造成超额,预算可以按输入 UTF-8 字节、最大输出和固定余量保守预留,失败或取消也不返还。
6.3 匿名会话和来源限制
服务端可生成随机会话 ID,以 HMAC 签名后写入 HttpOnly、SameSite=Lax、HTTPS 下带 Secure 的 Cookie。签名能防止访客自行伪造会话身份;Cookie 只用于限流和并发控制,不应存储敏感资料。
Origin 校验可以拒绝普通跨站调用,但它不是身份认证,脚本客户端可以伪造或省略部分请求头。因此真正的额度底线仍是 Redis 限流与全站预算。
如果允许访客填写自己的模型接口和 Key,必须在界面明确说明该 Key 会发送到本站服务器。服务端只接受预先登记的模型主机名,不能让访客提交任意 URL,否则代理可能被利用去访问内网或其他服务。
七、环境变量与部署
环境变量可以按用途拆分:
1 | # Vector:同步脚本使用读写令牌,在线查询可使用权限更小的令牌 |
前端构建可以读取 PUBLIC_TURNSTILE_SITE_KEY,因为 Site Key 本来就是公开标识;其余凭据不得使用 PUBLIC_ 前缀。CI 中将令牌放入 Secrets,将不敏感的模式、模型名和维度放入 Variables。生产环境使用能运行 Serverless Function 的平台;纯静态托管无法独立提供 /api/chat。
八、上线前检查
上线前至少验证以下路径:
- 递归目录中的
.md、.mdx能被发现,草稿和私密文章会被排除。 - 三类内容可分别检索,特殊条目不会因所在文章或嵌套目录而错误分类。
- 相同文档不会重复嵌入,修改文档后旧片段会在新版本可用后清理。
- 无效角色、超长消息、错误分类、任意模型地址和重复
requestId会被拒绝。 - Turnstile 缺失、失败、action 不符或 hostname 不符时不会调用模型。
- 分钟限流、日限流、并发锁和全站预算能够独立触发。
- Redis、Vector、嵌入服务或模型服务不可用时,接口返回可定位的阶段和请求编号。
- 前端能正确处理半包 SSE、主动停止、网络中断、来源折叠和移动端布局。
- 仓库、构建日志和同步报告中不存在真实 Key、令牌、Cookie、邮箱、账号 ID、本机路径和未脱敏的生产日志样本。
RAG 的难点不在于完成一次 query() 或 fetch(),而在于让内容版本、分类、来源、上下文和安全边界始终一致。把入库做成可重复的构建过程,把模型调用收进服务端,再用 Turnstile、限流、全站预算和失败关闭守住公开入口,才能让一个演示型聊天框成为可以长期维护的知识库终端。
