如何从零实现一个可用的文档问答系统
文档问答是最常见的 RAG 场景。
它看起来很简单:用户上传文档,然后向 AI 提问,AI 根据文档回答。
但“能演示”和“可用”之间差得很远。一个可用的文档问答系统至少要回答这些问题:
- 文档上传后什么时候可以提问?
- PDF、Markdown、网页、表格和代码块如何解析?
- 用户问的问题应该检索哪些文档?
- 如果文档里没有答案,系统如何拒答?
- 答案里的引用能不能点回原文?
- 用户无权访问的文档会不会进入上下文?
- 文档更新后,旧索引如何失效?
- 回答错了以后,团队如何定位是检索错还是生成错?
这篇文章不从某个具体向量数据库 API 开始,而是从工程边界设计一个可用的文档问答系统。
先定义“可用”
可用的文档问答系统,不是每个问题都能答出来,而是能稳定处理以下情况:
- 有答案时,基于文档给出答案。
- 没有答案时,明确说明资料不足。
- 回答中提供可追溯引用。
- 只使用用户有权限访问的文档。
- 文档更新、删除、重建索引时状态一致。
- 失败时有可理解的错误状态。
- 团队能通过日志和评估定位质量问题。
这个定义很重要。RAG 系统最危险的不是“不知道”,而是在证据不足时编一个看似可信的答案。
RAG 工程流程
一个基础文档问答系统可以拆成三层:文档层、检索层、回答层。
前端不是只负责最后的 chat UI。上传状态、知识范围、引用定位、失败反馈和调试入口,都会决定用户是否信任这个系统。
第一步:设计数据模型
不要从“chunk 表”开始。文档问答首先需要保存文档本身。
一个最小数据模型可以包含四类对象。
Document 表示用户上传或同步来的原始文档。
Chunk 表示可检索的知识片段。
Embedding 可以和 chunk 放在同一张表,也可以拆开,取决于数据库和索引方案。
Permission 决定谁能检索哪些文档。
这些字段看起来不像 AI,但它们决定 RAG 是否能上线。没有 document、version、position 和 permission,后面很难做引用、更新、删除和权限控制。
第二步:文档入库流程
文档入库不是“读文件内容”这么简单。
它应该是一条异步流水线:
前端应该把这些状态展示出来。用户上传完成不代表索引完成。如果界面显示“上传成功”后立刻允许提问,系统却还没建好索引,用户会得到错误的“没有找到资料”。
入库流程还要保证幂等。Worker 失败后重试,不应该产生重复 chunk 或旧版本残留。常见做法是给每次文档版本生成独立的 documentVersion,索引成功后再把 ready 版本切过去。
第三步:解析和清洗文档
解析质量决定 RAG 上限。
不同文档源要用不同策略:
清洗时不要把所有结构都抹掉。标题、页码、表格和代码块对引用非常重要。
好的解析结果应该像一个结构化文档树,而不是一整段纯文本。即使最终存入 chunk,也要保留来源位置和标题路径。
第四步:Chunking 策略
chunk 是 RAG 的基本召回单位。
一个实用策略是“结构优先,长度兜底”:
- 先按标题层级、FAQ 问答、段落、列表、表格、代码块切分。
- 如果某段太长,再递归按子段落或句子切分。
- 给相邻 chunk 加少量 overlap。
- 每个 chunk 保存标题路径和原文位置。
- 对表格和代码块避免粗暴截断。
不要只按固定字符数切所有文档。固定长度切分很容易把“问题”和“答案”拆开,也容易把表格的表头和数据拆开。
对于文档问答,还有一种常见做法是 parent-child chunk:
- child chunk 较小,用于精确检索。
- parent chunk 较大,用于生成上下文。
这样既能提高召回精度,又能给模型更完整的段落背景。
第五步:建立索引
文档问答至少需要三类索引。
向量索引 用于语义召回。它适合“表达不同但含义相近”的问题。
关键词索引 用于精确匹配。它适合错误码、接口名、产品名、条款号、人名、订单号等场景。
元数据索引 用于过滤。它负责 tenant、权限、文档类型、语言、版本、状态、时间范围等条件。
只做向量索引会让系统在精确查询上变弱。只做关键词索引又会错过大量自然语言表达。文档问答通常应该做 hybrid retrieval。
索引记录至少要能回答:
- 这个 chunk 属于哪个文档?
- 属于哪个版本?
- 用户是否有权限访问?
- 原文位置在哪里?
- 使用哪个 embedding model 生成?
- 文档是否仍然有效?
第六步:查询处理
用户问题不一定适合直接拿去检索。
例如:
- “这个怎么配置?”需要结合当前页面或选中文档。
- “第二点是什么意思?”需要结合对话历史。
- “退款多久到账?”可能需要改写成“退款到账时间”。
- “能不能导出?”需要知道用户问的是哪个功能。
查询处理可以包括:
- 合并当前页面状态和用户选择的知识范围。
- 从历史对话中补全指代。
- 生成更适合检索的 search query。
- 判断是否需要追问。
- 提取 metadata filter,例如文档范围、语言、时间、产品线。
对前端来说,最重要的是不要只传一个字符串。应该把用户所在页面、选中文档、知识库范围、当前对象 ID 等结构化信息一起传给后端。
第七步:检索、过滤和重排
检索通常分为粗召回和精排序。
顺序上有一个安全原则:无论粗召回在哪里做,最终进入模型上下文之前必须完成权限过滤。
理想情况下,权限和租户条件应该尽早进入数据库查询,减少无权内容被系统处理的机会。至少在 context assembly 前,必须确保没有越权 chunk。
Rerank 的作用是从召回候选中挑出更能回答问题的片段。向量检索可以返回 30 到 100 个候选,rerank 再选出最相关的 5 到 10 个进入上下文。
第八步:上下文组装
上下文组装决定模型最终看见什么。
一个好的 RAG Prompt 通常会明确区分系统指令、用户问题和引用资料:
上下文组装要处理:
- token budget,给答案预留输出空间。
- chunk 顺序,把最关键证据放在更容易被模型利用的位置。
- 去重,避免同一段内容重复出现。
- 引用编号,让答案可以追溯。
- 冲突文档,提醒模型资料存在不一致。
- 不可信内容边界,防止文档中的 Prompt Injection 影响系统指令。
不要把检索结果简单 join('\n')。这会让模型难以分辨来源、位置和优先级。
第九步:生成答案和引用
生成阶段的目标不是“回答得像人”,而是“基于证据回答得清楚”。
模型应该遵守这些规则:
- 只基于给定引用资料回答。
- 证据不足时明确拒答。
- 每个关键结论都附引用。
- 不输出未出现在资料中的数字、政策和承诺。
- 如果资料冲突,说明冲突而不是强行合并。
前端展示引用时,不要只显示文档标题。更好的体验是:
- 答案句子旁边有引用编号。
- 引用卡片显示文档标题、章节、页码或位置。
- 点击引用能打开原文并高亮对应段落。
- 引用失效或无权限时有明确状态。
引用是 RAG 用户信任的核心。没有引用的文档问答,很容易变成普通聊天。
第十步:记录日志和做评估
每次问答都应该记录 trace。
至少包括:
- user id、tenant id、document scope。
- 原始问题和改写后的 query。
- metadata filter。
- 召回 chunk ids、分数、来源。
- rerank 后的顺序。
- 最终进入上下文的引用资料。
- model、prompt version、token usage、latency。
- 最终答案和引用。
- 用户反馈。
这些日志用于调试,也用于 RAG Eval。
文档问答的评估要拆成两层:
如果答案错了,但检索没有召回正确资料,应该优化文档解析、chunking、query rewrite 或 retrieval。
如果检索找到了正确资料,但答案仍然错,应该优化上下文组装、Prompt、模型选择或引用约束。
这两个问题必须分开,否则很难定位。
常见坑
第一个坑:上传成功就允许提问。
上传只是文件到达系统。只有解析、切分、embedding 和索引完成后,文档才真正可问。前端需要展示索引状态。
第二个坑:没有文档版本。
文档更新后,如果旧 chunk 没有失效,系统可能同时引用新旧政策。每次更新都应该产生新版本,并有清晰的索引切换策略。
第三个坑:引用只能到文档,不能到片段。
用户点击引用后如果只能打开整篇文档,仍然要自己找答案。chunk 应该保存页码、标题路径、字符位置或块 ID。
第四个坑:把权限放在 Prompt 里。
Prompt 不能替代权限系统。无权文档不应该进入上下文。
第五个坑:没有“资料不足”的产品状态。
很多系统只设计了成功回答,没有设计无法回答。结果模型被迫生成。界面和 Prompt 都应该支持证据不足、需要追问、权限不足、索引未完成等状态。
第六个坑:只记录最终答案。
只保存答案无法调试 RAG。必须记录检索、过滤、重排、上下文和引用。
第七个坑:忽略前端上下文。
用户经常用“这个”“上面那个”“当前文件”提问。没有页面状态和选择范围,后端很难构造好 query。
小结
从零实现文档问答系统,核心不是先选一个向量数据库,而是设计一条完整 RAG 工程链路。
文档要能被解析、清洗、切分、索引和版本管理;检索要结合语义、关键词、权限和 metadata;上下文要有引用、顺序、预算和安全边界;答案要能拒答、能追溯、能被评估;前端要展示上传状态、知识范围、引用定位、失败原因和反馈入口。
一个可用的文档问答系统,最终拼的是系统性。向量检索只是开始,真正的质量来自每个环节都可控、可观察、可迭代。