返回首页

从 PDF 到可信回答:MindLink AI 的 RAG 全链路

发布于 2026-08-10 · AI 工具链 · 14 分钟

文章目录

让大模型回答一个问题很简单,但让它只根据用户上传的研究资料回答,并且能指出答案来自哪份文档,就不再是一个 fetch 请求能解决的问题。

MindLink AI 中,我搭建了一条完整的 RAG,也就是检索增强生成链路:用户上传 PDF,系统提取文字、生成摘要和关键词,把正文切成带重叠的文本块,再调用 Embedding 模型将它们写入 PostgreSQL + PGVector。用户提问时,系统用相同的向量空间检索相关片段,将证据连同问题交给大模型,最后通过 SSE 返回来源和流式答案。

这篇文章不把 RAG 简化成“向量数据库 + 提示词”,而是沿着一次真实请求,拆解数据怎样进入系统、怎样被检索、怎样变成可以追溯的回答,以及当前实现还存在哪些工程边界。

RAG 解决的不是知识量,而是回答边界#

普通聊天模型依赖训练数据和当前对话。面对一篇刚上传的论文,它可能没有见过原文;即使给出看似合理的回答,也很难证明答案来自哪里。

RAG 在生成之前增加了一次检索:先从用户自己的资料中找到与问题最相关的片段,再要求模型只根据这些片段作答。它主要改善三个问题:

  • 时效性:资料刚上传就可以参与问答,不需要重新训练模型。
  • 可控性:系统可以把检索范围限制在当前用户或指定文档。
  • 可追溯性:接口可以把命中的文档、分块编号和相似度一起返回。

MindLink AI 将整套流程拆成两条相互衔接的流水线:索引阶段把文档变成可检索数据,问答阶段把问题变成有来源的回答。

索引阶段
 
PDF 上传


文件校验 ─► 文本解析 ─► 摘要与关键词


              文本分块 ─► Embedding ─► PGVector
 
问答阶段
 
用户问题 ─► Query Embedding ─► 相似度检索 Top 6


来源元数据 ◄─ SSE ◄─ RAG Prompt ◄─ 相关文本块

                  └────────► 流式答案

索引与问答使用同一套 Embedding 模型非常重要。如果文档向量和问题向量来自不同模型,维度或语义空间可能不一致,相似度结果也就失去意义。

第一步:在读取内容前守住上传边界#

上传接口运行在 Next.js Route Handler 的 Node.js Runtime,因为 PDF 解析和文件系统操作需要 Node 能力。接口先获取当前登录用户,然后检查上传字段、文件类型与大小。

当前边界包括:

  • 只接受 PDF MIME 类型或 .pdf 扩展名。
  • 单个文件最大 15MB。
  • 文件名只保留字母、数字、下划线、点和连字符。
  • 提取后的文本少于 80 个字符时,视为解析失败。

文件扩展名和 MIME 检查只能挡住明显的错误输入,不能替代真实文件签名检测。生产环境还应验证 PDF 魔数、限制解析时间与页数,并在隔离环境中处理不可信文件。

系统使用 pdf-parse 提取内容,并把连续空白压缩成单个空格。解析器无论成功还是失败都会执行 destroy(),避免底层资源一直占用。由于 PDF Worker 在不同构建目录中的位置可能变化,项目会尝试多个候选路径,并将 Worker 代码转换成 Data URL 后交给解析器。

这个处理看起来像实现细节,却说明 PDF 解析不是纯业务函数:它会受到打包器、运行时和依赖文件布局影响。在 Next.js 中选择正确的 Runtime,是整条 RAG 链路能否启动的第一道条件。

第二步:摘要、关键词和全文索引各司其职#

解析完成后,系统对同一份文档生成三类数据:

数据 用途 是否参与问答检索
摘要 上传历史和文档概览
5 个关键词 生成知识图谱初始节点
正文分块与向量 语义检索和 RAG 上下文

摘要提示词只截取正文前 12000 个字符,要求模型输出一段约 300 字的中文摘要和恰好 5 个关键词。系统会清理模型可能返回的 Markdown 代码围栏,再解析 JSON;摘要为空或关键词数量不等于 5,都会判定为失败。

这种严格解析比“尽量从模型回复里找几个词”更容易暴露问题,但当前仍依赖提示词约束。后续可以优先采用供应商提供的 Structured Output 或 JSON Schema,让模型输出和 TypeScript 类型之间形成更可靠的契约。

摘要不能代替全文索引。它适合快速浏览,却会丢失公式、实验条件、例外情况和局部论证。真正的问答检索仍然使用正文分块。

第三步:为什么分块要保留重叠#

MindLink AI 的默认分块参数是:

chunkSize   = 1200
overlapSize = 180
maxChunks   = 80

算法从当前起点向后取 1200 个字符,并优先在候选终点之前寻找空格作为自然断点。如果最近的空格离起点太近,就直接按长度截断。下一块从上一块结尾向前回退 180 个字符开始。

重叠是为了降低语义被切断的概率。假设一段关键结论从第 1130 个字符开始,解释在第 1280 个字符结束。如果完全无重叠,前一块只有结论开头,后一块只有解释结尾;保留 180 个字符后,至少有一个块能包含相对完整的关系。

Chunk 0: [────────────────────────────]
                              [重叠区]
Chunk 1:                      [────────────────────────────]

不过,1200 和 180 不是普适答案。当前算法把 PDF 文本压成一行,再主要依靠空格寻找断点,对英文论文较友好,对缺少空格的中文长文本则更可能按字符硬切。更成熟的方案应优先识别标题、段落、列表、代码块和句号,再在结构边界上控制块大小。

maxChunks = 80 也意味着单次索引存在明确上限。它保护 Embedding 成本和请求时间,却可能截断超长文档。正式产品应把“文档只索引了一部分”作为可见状态,而不是让用户误以为整份资料都已进入知识库。

第四步:把文本写入 PGVector#

分块完成后,系统批量请求 Embedding。使用 Gemini 时,文档块以 RETRIEVAL_DOCUMENT 任务类型发送,每 20 个一批;查询则使用 RETRIEVAL_QUERY。如果配置了 OpenAI 兼容接口,系统会调用 /embeddings,并按照响应中的 index 恢复向量顺序。

拿到向量后,系统先确认向量数量与文本块数量完全一致。随后删除该文档已有的索引,再逐块写入 document_chunks

id            分块唯一标识
document_id   所属文档
user_id       所属用户
chunk_index   在原文中的顺序
content       原始文本
embedding     PGVector 向量
created_at    创建时间

document_iduser_id 都带有级联删除关系。删除文档或用户时,对应的向量块会一起清理,避免知识库留下孤立数据。

这里有一个有意保留的取舍:embedding 使用未固定维度的 vector,迁移中没有创建 IVFFlat 或 HNSW 近似索引。原因是系统允许不同部署使用不同的 Embedding Provider,向量可能是 768、1536 或其他维度;固定维度索引会降低这种兼容性。

代价是查询只能按精确距离排序。数据量小时简单可靠,分块达到数十万后会明显变慢。生产部署最好固定 Embedding 模型和维度,再建立 HNSW 或 IVFFlat 索引。模型切换时还要进行版本标记与全量重建,不能把不同模型生成的向量混在同一个检索空间。

第五步:问题如何变成六段证据#

问答接口首先验证 JSON 和问题长度,然后获取当前登录用户。问题被转换成 Query Embedding 后,系统执行 PGVector 相似度查询:

1 - (dc.embedding <=> query_vector) AS score

<=> 表示余弦距离,因此用 1 - distance 得到更直观的相似度分数。查询按距离升序排列,默认取最相关的 6 个文本块。

检索条件始终包含 dc.user_id = currentUserId,还可以额外限定 document_id。这不是普通筛选条件,而是多用户 RAG 的数据边界。只在 UI 中隐藏其他用户的文档远远不够,向量查询本身必须带用户条件,否则模型可能把别人的资料当成当前答案的证据。

每个检索结果都会保留:

  • 文档 ID 与标题
  • 文本块序号
  • 原始内容
  • 相似度分数

如果一个块都没有命中,接口返回 NO_INDEXED_CHUNKS,而不是让模型离开资料自由发挥。

第六步:提示词必须允许模型说“不知道”#

检索结果会被格式化为带编号的上下文:

[Context 1] Doc="paper-a.pdf" Chunk=7 Score=0.842
这里是检索出的原文片段……
 
[Context 2] Doc="paper-b.pdf" Chunk=3 Score=0.791
这里是另一段相关证据……

系统提示模型只根据这些上下文回答;如果证据不足,需要明确说明缺少什么,而不是补全一个听起来合理的答案。回答采用 Markdown,方便前端展示代码块和结构化内容。

这条约束能降低幻觉,却不能完全消除幻觉。当前来源是接口根据检索结果单独回传的,并不代表答案中的每个句子都与某个片段完成了精确对齐。下一步可以要求模型使用 [1][2] 形式引用上下文编号,再由服务端校验引用是否存在,从“返回了来源”进一步走向“观点与证据绑定”。

第七步:先发送来源,再流式发送答案#

问答接口使用 SSE,并定义四种事件:

meta   检索来源和相似度
token  当前生成的文本
done   生成完成
error  流内错误及是否可重试

接口先发送 meta,因此用户不必等整段回答生成完才看到参考文档;随后逐个转发模型 Token,最后发出 done

OpenAI 兼容服务通常返回 data: {...} 格式的流。系统用 TextDecoder 维护跨网络分片的缓冲区,逐行解析 JSON,并跳过格式损坏的片段。Gemini 则通过 SDK 的异步流直接读取文本。

还有一层实用的降级:如果自定义服务的流式请求失败,系统会重新发起普通非流式请求,得到完整答案后按每 18 个字符切片输出。用户仍然能获得相同的 SSE 事件协议,前端不需要为每种供应商实现不同分支。

这种退化流不是真正的实时生成,首字等待时间仍然等于完整模型请求时间,但它守住了前后端接口契约。供应商差异被限制在服务端,UI 只处理 metatokendoneerror

多供应商配置为什么要拆分文本和向量模型#

MindLink AI 支持两种运行模式:

  • 配置 API_URL 时使用 OpenAI 兼容的聊天接口。
  • 未配置自定义接口时回退到 Gemini SDK。

Embedding 还可以单独配置 EMBEDDING_API_URLEMBEDDING_API_KEY。这是必要的,因为有些聊天服务只实现 /chat/completions,并不提供 /embeddings;也有些场景希望聊天使用推理能力更强的模型,而向量化使用更便宜、稳定的专用模型。

接口地址也做了兼容处理:系统会尝试当前基础地址,并在缺少 /v1 时追加 /v1/chat/completions/v1/embeddings。404 会继续尝试下一个候选地址,其他错误则直接进入统一错误分类。

部分成功比全有或全无更符合文档工作流#

上传过程并非只有“成功”和“失败”。PDF 可能已经解析、摘要也已经保存,但 Embedding 服务暂时不可用。如果因为向量接口 404 就丢弃整份文档,用户只能重新上传并再次支付摘要成本。

当前实现允许一种受控的部分成功:文档和摘要正常保存,向量索引不可用时返回 indexingWarning。用户能在上传历史中看到资料,但问答功能需要配置 Embedding 服务并重新索引。

这比静默跳过索引可靠,因为客户端知道系统处于降级状态。不过,当前流程仍然在一个 HTTP 请求中串行完成解析、摘要、文件写入、数据库写入、Embedding 和知识节点生成。文档较大或模型较慢时,容易触发请求超时。

更成熟的形态应该是异步任务:

上传完成


Document: pending

   ├── parse ─► summarize ─► index ─► ready

   └── 任一步失败 ─► failed + 可重试阶段

每个阶段记录状态和错误,任务能够幂等重试,前端通过轮询或事件订阅展示进度。这样既能控制长任务,也能避免重复上传产生多份相同文档。

错误也需要成为稳定协议#

项目把服务端错误归一为四个字段:HTTP 状态、错误码、是否可重试和面向用户的消息。例如:

场景 错误码 可重试
未登录 UNAUTHORIZED
模型密钥无效 AI_AUTH_FAILED
模型限流或 503 AI_PROVIDER_BUSY
数据库暂时不可用 DB_UNAVAILABLE
网络超时 NETWORK_UNSTABLE

客户端请求层再用 AbortController 实现默认 30 秒超时,并把断网、超时、非法响应格式转换为统一的 ClientRequestError。这使界面能够决定展示“重试”还是“检查配置”,而不是把所有异常都压成一句“请求失败”。

对于 SSE,HTTP 响应开始后不能再改变状态码,因此生成阶段的异常要通过 error 事件发送。这也是为什么流式 API 必须同时设计连接前错误和连接后错误两套通道。

当前架构还需要补齐什么#

这条 RAG 链路已经可以工作,但距离大规模、可信研究助手还有几个关键步骤:

  • 为分块记录字符区间、页码和段落位置,让来源可以跳回 PDF 原文。
  • 使用结构感知分块,改善中文、表格、公式和代码的完整性。
  • 固定向量模型与维度,增加模型版本字段和 HNSW 索引。
  • 增加相似度阈值、关键词混合检索和 Reranker,减少“Top 6 中最相关但仍不相关”的情况。
  • 让答案显式引用上下文编号,并校验每个引用。
  • 将上传索引改为可恢复的异步任务,记录每个阶段状态。
  • 把文件从 public/uploads 迁移到私有对象存储,通过鉴权接口签发临时下载地址。
  • 增加文档去重、配额、删除审计和数据保留策略。
  • 为分块、检索排序、权限过滤和 SSE 解析补充自动化测试。

其中,私有文件存储和页码级来源定位应该优先于更多 AI 功能。研究协作平台首先要让资料边界可信,其次才是回答看起来多聪明。

结语#

RAG 的价值不在于给大模型外挂一个数据库,而在于建立一条可检查的数据路径:文档如何进入系统,哪些片段被检索,模型看到了什么,用户最后能追溯到哪里。

MindLink AI 目前用 Next.js 承载上传与问答接口,用 pdf-parse 提取文本,用可重叠分块保持局部语义,用 Gemini 或 OpenAI 兼容接口生成向量,再通过 PostgreSQL + PGVector 完成用户范围内的相似度检索。来源先于答案通过 SSE 返回,模型服务异常则进入统一的降级和错误协议。

这套实现并不意味着 RAG 已经完成。恰恰相反,它把下一步问题暴露得更清楚:结构化分块、引用对齐、向量版本、异步索引和私有文件存储,才是从“可以问文档”走向“可以信任回答”的关键。

完整代码可以在 MindLink AI GitHub 仓库 查看。

继续阅读

AI 工具链 · 2026-08-10 · 12 分钟

AI 小说编辑器架构拆解:从富文本工作台到上下文感知的流式生成

以 AI Novel Copilot 为例,拆解 Next.js、Zustand、Tiptap、Hono、SSE、Cloudflare D1 与 Drizzle 如何组成一套可持续演进的小说创作系统。

阅读全文 →

AI 工具链 · 2026-05-23 · 4 分钟

AI 内容导出器:把 AI 回复变成可编辑、可分享、可归档的成品

小工具将 AI 输出快速预览并导出为 Word、PDF、PNG 或 JPG,降低人工排版成本。

阅读全文 →

AI 工具链 · 2026-03-15 · 8 分钟

我如何用 Prompt 模板 + 前端脚本,把重复工作压缩到 20%

从模板抽象、参数命名到自动注入上下文,这篇文章给出一套可迁移的方法。

阅读全文 →