AI 助手 08:从文件上传到向量入库,企业 RAG 的第一段链路怎么跑通

记录 ai-study V2 的文档入库完整链路:Java 管文件与权限,Python 负责解析、切分、Embedding 和 Milvus 写入,每一步都有明确的状态流转和失败原因。

字数 1959 阅读时长 ≈ 6 分钟 2026-7-28 2026-7-28
所属项目:AI-assistant
AI 助手 08:从文件上传到向量入库,企业 RAG 的第一段链路怎么跑通

企业知识库 RAG 的第一步不是把一段文本丢给 Embedding 接口,而是先弄清楚:文件属于谁、保存在哪里、失败后能否定位、切出的 chunk 能否回到原文档、向量写入后能否按权限检索。ai-study 的 V2 先完整跑通了这条入库链路:上传 → 解析 → 切分 → Embedding → Milvus 写入 → 状态归档。

V2 文档上传、Python 入库、Embedding、Milvus 写入与状态流转

先把文档作为业务对象管理

已登录用户可在 /workspace/knowledge-base 上传 TXT、Markdown、PDF 和 DOCX,单文件上限 10 MB。Java 不直接用原始文件名作为服务器路径,而是用 tenantId/userId/UUID.扩展名 生成 storage key,再写入 Docker 共享卷 /data/uploads。路径会被归一化并校验仍在存储根目录内,避免文件名中的路径片段逃逸到上传目录外。

PostgreSQL 的 ai_document 保存标题、原始文件名、类型、大小、storage key、租户、所有者、状态和失败原因。列表、读取和删除都以当前的 tenantId + userId 为条件;删除采用数据库逻辑删除,同时尝试删除本地文件和 Milvus 中的向量。这样即使后续换成对象存储或增加共享授权,文档元数据仍然有稳定的业务主键和归属边界。

Java 保存文件,Python 负责入库处理

Java 成功写入文件和元数据后,会带着已认证上下文请求 Python 的 /ingest/document。Python 再按 document ID、tenant ID、owner user ID 用 SELECT ... FOR UPDATE 锁定并读取对应记录,而不是相信一个任意的文件路径。这个边界让 Java 保持业务 API、存储和权限职责,Python 可以专注文本处理、模型和检索生态。

整条入库链路有 5 个明确状态,任何一步失败都会停在当前状态并写入 failure_reason

状态含义
uploadedJava 已保存文件和元数据,等待 Python 处理
parsing正在读取文件并提取纯文本
chunking文本切分中,写入 ai_document_chunk
embedding生成 Embedding 并写入 Milvus
indexed入库完成,可被检索

失败时让状态停在原地、原因写清楚,比笼统返回”处理失败”更容易排查——是 PDF 解析库没装、文件编码有问题、Milvus 连不上,还是向量维度不匹配,一眼就能看出来。

四种格式的解析策略

当前支持 TXT、Markdown、PDF 和 DOCX 四种格式,但解析深度不一样:

  • TXT / Markdown:直接按 UTF-8 读取,Markdown 的标题、列表和代码块结构不做特殊处理,当成普通文本切分。
  • PDF:用 pypdf 逐页提取文本,按页顺序拼接。表格和图片中的文字会丢失,排版格式也不会保留。
  • DOCX:用 python-docx 读取段落文本,忽略表格、图片和页眉页脚。

这是一个有意的取舍:先让四种格式都能进库,再根据真实使用场景决定是否引入更重的解析方案(比如 Markdown 按标题分层切分、PDF 用布局分析、DOCX 提取表格)。过早优化解析器的代价是主链路迟迟跑不通,而切分质量最终要靠检索命中率来验证,不是靠直觉判断。

固定窗口切分:先选一个能复盘的基线

切分采用 900 字符窗口 + 120 字符 overlap 的固定窗口。同一文档重跑时会先删除旧 chunk 再重新插入,避免产生两套不一致的分段。每个 chunk 都带 tenant_iddocument_idchunk_no、正文和字符数,后续可以按文档回查、按序号排序。

900/120 不是通用最优参数,而是当前阶段为了让链路可验证、可重复选择的基线。固定窗口的好处是实现简单、结果确定,同一文本必然得到相同顺序和内容的 chunk;120 的重叠能减少一句话刚好被切在边界时的上下文断裂。

它的限制也很明确:

  • 字符数不等于 token,中英文混合时实际 token 数波动很大
  • Markdown 标题、代码块、表格结构没有被感知
  • PDF 提取的文本可能出现断行、乱码和页码噪声
  • 切分质量还没有评测数据支撑

后续进入检索评测阶段后,应以命中率、引用可读性和回答正确性来反向调整 chunk size、overlap 与分隔符,而不是仅凭感觉把数字改大或改小。

Embedding 为什么先用 local-hash

V2 的 Embedding 默认使用 local-hash 实现:对每个分词做 SHA-256,取前 4 字节映射到向量维度,再根据第 5 字节的奇偶决定正负方向,最后做 L2 归一化。384 维的向量完全在本地生成,不需要 API Key,也不依赖外部服务。

这显然不是生产级方案。它的语义表达能力远不如真正的 Embedding 模型,检索质量只能作为链路验证。选择它的原因和当初选 demo LLM provider 一样:

  1. 本地可复现:不依赖网络、配额和余额,CI 和本地开发都能跑
  2. 成本为零:调试入库链路时不会产生 Embedding API 费用
  3. 接口稳定EmbeddingProvider 抽象了 embed(texts) -> vectors,后续换成 OpenAI、BGE、M3E 或其他模型,只需要改配置

真正的 Embedding 模型接入会在 V2 检索跑通后作为独立步骤替换。到那时,同一份评测数据在不同 Embedding 上的命中率差异,才是选模型的依据。

Milvus 写入:向量和标量字段各管各的

Milvus collection 名为 ai_study_document_chunks_v1,使用 COSINE 相似度度量。写入的字段分为两类:

字段用途
id主键,与 PostgreSQL ai_document_chunk.id 一一对应
vectorEmbedding 向量,用于相似度检索
tenant_id标量过滤:按租户隔离检索范围
document_id标量过滤:按文档缩小候选集
chunk_id冗余存储,便于调试和回查
chunk_no块序号,展示时排序用
owner_id文档所有者,后续细粒度权限过滤用
content_hashchunk 内容哈希,判断是否需要重新向量化
content_preview前 1000 字符预览,调试时直接看 Milvus 就能知道大概内容

这里有一个关键设计决策:向量只放在 Milvus,文本正文以 PostgreSQL 为准。Milvus 里只存 content_preview(前 1000 字)用于调试,完整正文始终从 ai_document_chunk 读取。原因有三个:

  1. PostgreSQL 是业务数据的权威存储,支持事务、备份和权限控制
  2. Milvus 存全文会显著增加存储成本和索引构建时间
  3. 检索命中后根据主键回查 PostgreSQL 是毫秒级操作,代价很低

同一文档重新入库时,先按 document_id 删除 Milvus 中所有旧向量,再批量插入新向量,保证不会出现新旧混杂。

状态流转到 indexed 之后才算入库完成

Embedding 生成并写入 Milvus 后,Python 会回填 ai_document_chunkmilvus_collectionmilvus_pkembedding_model 字段,再把 ai_document.document_status 更新为 indexed。到这一步,文档才算真正可被检索。

整个入库过程在一次数据库连接中完成文本解析和 chunk 写入,Embedding 和 Milvus 操作放在事务外。这样即使 Milvus 写入失败,PostgreSQL 里的 chunk 文本仍然完整,可以根据 embedding 状态重试向量化,而不是从头重新解析文件。

这离完整的”知识库问答”还差什么

当前 V2 已经跑通了入库侧的完整链路,但 RAG 是”存 + 检 + 答”三件事:

  • ✅ 存:上传、解析、切分、Embedding、Milvus 写入、状态流转
  • ⚠️ 检:向量检索 + 权限过滤已实现,但还没有重排、hybrid search 和检索评测
  • ⚠️ 答:能生成带引用的回答,但 Prompt 策略、引用格式和”不知道”的边界还需要打磨

下一篇会写检索侧的设计:如何在 Milvus 向量检索中强制带上租户和用户权限、为什么检索结果还要回查 PostgreSQL、引用来源怎么组织才能让用户点回去看原文。