今天的 HTML 练习只有一个静态页面:上面有知识文档列表、上传按钮、处理状态、问题输入框、回答和引用来源。页面本身没有连接数据库,也没有真正上传文件或调用模型。
但当我开始追问“按钮点击以后发生什么”“处理状态由谁负责”“引用从哪里来”,一个静态页面很快展开成了完整的企业知识库架构。
这篇文章记录的不是已经完成上线的系统,而是从当前 HTML 原型推导出的产品和架构设计。
先定义产品,而不是先堆技术栈
企业知识库服务的目标用户可以是需要查询内部资料的工程师、运营人员和知识库管理员。对普通使用者来说,核心目标不是“体验向量数据库”,而是快速获得可信、可追溯的答案;对管理员来说,核心目标是让资料能够成功进入知识库,失败时知道原因并能够恢复。
一个最小用户故事是:
作为知识库管理员,我希望看到文档处理失败的原因并能重新处理,这样我不需要联系开发人员就能恢复常见的摄取失败。
静态页面因此不能只显示“成功”状态,还需要展示:
已就绪
处理中
处理失败:文件编码无法识别
重新处理
正常流程是:
上传文档
→ 等待处理
→ 文档就绪
→ 提出问题
→ 阅读流式回答
→ 查看引用
失败流程是:
上传文档
→ 处理失败
→ 查看失败原因
→ 重新处理
→ 文档就绪
这里体现了一个基础产品原则:状态反馈不是装饰。没有“处理中”,用户会认为点击上传没有响应;没有明确失败原因和恢复动作,用户只能反复尝试或联系开发人员。
MVP 是最小闭环,不是最低质量
MVP 是 Minimum Viable Product,中文通常译为“最小可行产品”。它不是随便拼出的残缺版本,而是用最小功能范围建立一个真实可用的业务闭环。
企业知识库 MVP 的闭环可以定义为:
登录
→ 上传文档
→ 异步处理
→ 提问
→ 获得带引用回答
→ 失败时能够恢复
高级管理后台、多模型路由、复杂分析大屏和全局文件去重可以推迟,但权限隔离、数据持久化、文件校验、失败反馈和引用验证不能因为“MVP”而省略。可以缩小功能范围,不能删除决定可信性和安全性的底线。
这也意味着当前 35 天项目更适合采用“Next.js 前端 + FastAPI 模块化单体 + 独立 Worker”,而不是一开始拆成大量微服务。模块化单体便于单人开发、测试和部署,Worker 又能隔离耗时的文档处理任务。
DOM 负责展示,PostgreSQL 保存权威状态
我最初认为页面中的 <span> 可以作为文档状态来源:
<span>处理中</span>
但 DOM 只是当前浏览器标签页里的展示结构。刷新后它会重新创建,用户可以通过开发者工具修改它,其他设备和其他用户也看不到这份本地状态。它不能承担持久化、并发控制或审计职责。
更合理的职责分工是:
| 组件 | 职责 |
|---|---|
| DOM | 向当前用户展示状态 |
| FastAPI | 鉴权、校验和业务入口 |
| Redis | 传递异步任务 |
| Worker | 执行解析、切块和 Embedding |
| PostgreSQL | 保存权威、持久化业务状态 |
页面可以先显示“正在提交”改善反馈,但最终必须根据服务端返回的数据库状态校准 UI。
一次 HTTP 请求只能返回一次
文档解析、切块和 Embedding 可能持续几秒甚至几分钟。FastAPI 不应该一直占用原始上传请求等待 Worker 完成,而应在接收并创建任务后立即返回:
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"document_id": "doc_123",
"status": "queued"
}
202 Accepted 表示请求已经接受,但后台工作尚未完成。
这里有一个容易说错的地方:服务端不能先对同一个 HTTP 请求返回“任务已接受”,等 Worker 完成后再返回第二次结果。一次普通 HTTP 请求只有一次响应。后续状态必须通过新的通信获得,例如:
- 前端每隔两三秒轮询状态接口;
- 服务端通过 SSE 推送状态事件;
- 用户刷新页面后重新从 API 查询。
文档状态变化频率不高,MVP 使用轮询通常最简单。AI 回答会持续产生 Token,更适合 SSE 或流式 fetch。架构不应该因为“问答已经用了 SSE”就强迫所有低频状态都复用同一种机制。
完整摄取链路可以设计为:
Browser
→ FastAPI 校验文件、权限和幂等键
→ 原文件写入对象存储
→ PostgreSQL 创建 queued 文档版本
→ Redis 投递摄取任务
→ Worker 读取文件
→ 状态更新为 processing
→ 解析、切块、生成 Embedding
→ chunks 和向量写入 PostgreSQL/pgvector
→ 完整性校验
→ 原子切换当前版本
→ 状态更新为 ready
任何一步失败,都应记录稳定的错误类型和可展示原因,再把状态更新为 failed。
原文件、结构化状态和向量应该放在哪里
PDF、Word 等原始二进制文件更适合保存在 S3 或 MinIO 这样的对象存储中。它们支持大对象读写、生命周期管理和版本策略,也避免 PostgreSQL 备份被大量二进制内容拖累。
PostgreSQL 则保存结构化数据:
documents
document_versions
ingestion_jobs
chunks
conversations
messages
answer_runs
citations
每个文档版本保存对象路径、SHA-256、处理状态和创建时间;每个 chunk 保存文本、页码、章节、文档版本以及 embedding。
浏览器可以提前计算文件哈希,用于提示重复上传,但浏览器属于用户可控制环境,后端或 Worker 必须重新读取实际存储的文件并计算可信 SHA-256。影响权限、计费、完整性和业务状态的结果,都必须由可信后端验证。
在多租户环境中,MVP 最稳妥的是租户内去重:
UNIQUE(tenant_id, sha256)
后端即使在物理存储层发现另一个租户拥有相同文件,也不能对当前用户提示“该文件已存在于另一家公司”。否则,攻击者可以通过上传已知文件并观察响应,推测其他企业是否拥有敏感资料。
文档更新不能先破坏旧版本
文档更新时,如果先删除旧 chunks 和向量,再开始生成新版本,一旦解析或模型调用失败,原本可用的知识会立即消失。
更可靠的方式是先完整构建新版本:
创建 v2,状态 processing
→ 解析并生成 v2 的全部 chunks
→ 生成并写入 v2 的全部向量
→ 检查数量、维度和错误
→ 数据库事务切换 active_version_id:v1 → v2
→ v2 标记 ready
→ v1 延迟退役和清理
如果 v2 失败,v1 继续对外提供问答,v2 标记为 failed 并允许重新处理。这个设计主要保证可用性和一致性:查询不会同时混入新旧 chunks,也不会把只处理了一半的版本当成完整知识。
RAG 有两条链路:入库不是检索
RAG 是 Retrieval-Augmented Generation,检索增强生成。我一开始把“文档切块、向量匹配、文档 ready”写在同一条流程里,实际混淆了离线入库和在线查询。
文档入库负责建立可检索索引:
原始文件
→ 解析文本和结构
→ 切分 chunks
→ 为 chunks 生成 embeddings
→ 保存文本、元数据和向量
→ 文档版本 ready
用户问答才会使用索引:
用户问题
→ FastAPI 鉴权
→ 使用兼容的 Embedding 模型将问题向量化
→ 在租户、权限和 active_version 范围内检索
→ 融合向量与关键词候选
→ reranker 精排
→ 将高质量 chunks 放入 Prompt
→ 大模型生成回答
→ 流式发送给前端
→ 根据 chunk 元数据构造引用
问题向量和 chunk 向量必须处于兼容的向量空间,因此升级 Embedding 模型时不能只切换查询模型而忽略已有索引。
混合检索负责召回候选:向量检索寻找语义相关内容,关键词检索补充专有名词、编号和精确短语。reranker 再对“问题与候选 chunk 的相关性”做更精细排序。
权限必须在检索前生效
企业知识库不能先从全库检索 Top K,再把无权限结果过滤掉。这样不仅效率较低,更重要的是无权限文本可能已经进入 reranker、模型请求或日志,形成数据泄露。
正确顺序是:
解析用户身份、tenant_id 和用户组
→ 在 SQL/向量查询中加入租户、ACL 和 active_version 条件
→ 只对有权限的 chunks 排序
→ reranker
→ Prompt
→ 模型
引用也不能只依赖大模型生成的文字。后端应根据命中 chunk 的元数据构造引用,例如:
chunk_id
document_id
document_version_id
page_number
section_title
tenant_id
permission_scope
用户点击引用时,文档接口仍要再次鉴权。不能因为回答阶段已经检索过一次,就把永久公开的对象存储地址直接交给浏览器。
真流式不是把完整答案切成小段播放
“先生成完整答案,再把字符串切成几段逐字显示”只能制造视觉动画,无法降低首 Token 等待时间。
真正的流式链路是:
模型生成一批 Token
→ Provider 返回增量数据
→ FastAPI 立即转发
→ SSE 或流式 fetch 发送给浏览器
→ 前端增量更新当前回答
如果连接在回答一半时中断,页面不能把部分内容伪装成完整回答。前端应保留已显示内容,标记“回答中断”,并提供重新生成。
后端也不应只保存“问题状态”。一个用户问题可能对应多次回答尝试:
user_message
└── answer_run #1:interrupted
└── answer_run #2:completed
answer_run 可以保存 queued → retrieving → generating → completed/failed/interrupted 状态、部分内容、检索 chunk ID、模型版本和错误类型。重新生成创建新的 run,而不是覆盖旧记录。
重复请求需要幂等键,不需要语义相似度
用户可能连续点击两次“重新生成”,网络库也可能自动重试同一个请求。通过问题文本的 embedding 相似度去重并不可靠:相似问题可能是两次有意操作,相同问题也可能是一次明确的重新生成。
更准确的方法是幂等键:
POST /messages/msg_123/answer-runs
Idempotency-Key: 8b77341d-...
一次用户操作生成一个唯一 key;同一次操作的网络重试继续使用这个 key;用户有意再次生成时使用新 key。数据库通过唯一约束阻止并发重复创建:
UNIQUE(tenant_id, user_id, idempotency_key)
前端禁用正在提交的按钮有助于交互,但不能代替服务端幂等,因为它阻止不了网络重试和并发请求。
指标、Trace 和日志回答不同问题
用户说“点击发送后页面像卡住了”,最直接的体验指标不是完整答案总耗时,而是首 Token 延迟。
P95 是第 95 百分位数。若首 Token P95 为 2.5 秒,表示 95% 的请求能在 2.5 秒内看到第一个 Token,仍有 5% 更慢。平均值可能掩盖少数非常慢的请求,因此通常同时观察:
- P50:典型用户体验;
- P95:较慢用户和服务目标;
- P99:极端长尾问题。
企业知识库 MVP 至少可以关注:
文档处理成功率
文档处理 P95 时延
问答首 Token P95
完整回答 P95
引用可用率
从上传到首次获得带引用回答的成功率
当 P95 变差时,只记录一个总耗时还不够。一次请求应通过同一个 trace_id 拆成权限检查、检索、reranker 和模型首 Token 等阶段。
可观测性的三个概念分别回答:
- Metrics:系统是否出现了问题;
- Traces:问题发生在哪个组件;
- Logs:该组件当时发生了什么。
更多日志不等于更安全。日志可以记录租户 ID、trace ID、耗时、状态码和错误类型,但不应默认记录完整 Prompt、私有文档正文、密码、密钥或访问令牌。
这个项目最终应该有多大
按当前 35 天计划,最终目标是一个可部署、可演示、具备生产意识的中型 SaaS MVP,而不是成熟的大型企业平台。
合理架构是:
Browser
→ Next.js
→ FastAPI
├── PostgreSQL/pgvector
├── Redis → Worker
├── S3/MinIO
└── LLM/Embedding Provider
第一方代码大约会落在数万行,而不是几十万行。几十万行依赖、构建产物或未经审查的 AI 生成代码没有作品价值。更重要的验收是:关键模块能够解释,核心链路有测试,权限和失败边界清楚,系统能够部署、观察和恢复。
SSO/SAML、复杂组织权限、计费、多区域容灾和大型运营平台可以成为后续演进方向,不应在 MVP 阶段挤压核心闭环。
我的 AI SaaS 六维审查清单
1. 产品
- 目标用户是谁,核心任务是什么;
- 正常流程是否闭环;
- 失败原因是否可理解,用户是否能够恢复;
- 当前功能是否真的属于 MVP。
2. 状态
- 哪些状态只用于展示;
- 哪个数据源是权威来源;
- 状态机是否包含处理中、完成、失败、中断和重试;
- 刷新或断线后是否能够恢复。
3. 数据
- 原文件、结构化元数据、chunks 和向量分别存在哪里;
- 文档版本如何切换;
- 入库与查询是否分离;
- 引用是否能追溯到确定版本和位置。
4. 安全
- 鉴权和租户过滤在哪一层生效;
- 无权限内容是否可能进入模型、日志或缓存;
- 引用下载是否再次鉴权;
- 浏览器提交的哈希、身份和业务状态是否由后端验证。
5. 失败与可靠性
- 长任务是否异步执行;
- 重试是否幂等;
- 新版本失败时旧版本是否仍然可用;
- 流式回答中断后是否被错误标记为完成。
6. 指标
- 是否衡量核心产品闭环;
- 是否同时观察成功率、P95 延迟、引用和成本;
- 是否能通过 trace 定位慢阶段;
- 日志是否足够排障且不泄露敏感内容。
总结
今天从一张静态 HTML 页面得到的最大收获,是学会追问页面背后的状态和责任边界。
<span>处理中</span> 只是展示,PostgreSQL 才保存权威状态;上传按钮只是入口,Worker 才执行耗时摄取;对象存储保留原文件,pgvector 保存可检索 chunks;RAG 入库负责建立索引,在线问答负责使用索引;大模型组织回答,引用必须来自检索元数据;流式输出改善首 Token 体验,幂等和状态机负责处理失败与重复请求。
架构设计并不是把更多技术名词画进图里,而是回答六个问题:用户要完成什么、状态由谁负责、数据放在哪里、失败怎样恢复、权限在哪里生效、结果如何衡量。
页面还没有实现这些后端能力,但它已经提供了一个清晰的产品入口。接下来的学习会逐步把这份设计落实为 CSS 布局、JavaScript 交互、React 状态、Next.js 页面、FastAPI API、异步 Worker 和可评估的 RAG 服务。

没有回应