AI 助手 08:从文件上传到向量入库,企业 RAG 的第一段链路怎么跑通
记录 ai-study V2 的文档入库完整链路:Java 管文件与权限,Python 负责解析、切分、Embedding 和 Milvus 写入,每一步都有明确的状态流转和失败原因。
所属项目:AI-assistant企业知识库 RAG 的第一步不是把一段文本丢给 Embedding 接口,而是先弄清楚:文件属于谁、保存在哪里、失败后能否定位、切出的 chunk 能否回到原文档、向量写入后能否按权限检索。ai-study 的 V2 先完整跑通了这条入库链路:上传 → 解析 → 切分 → 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:
| 状态 | 含义 |
|---|---|
uploaded | Java 已保存文件和元数据,等待 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_id、document_id、chunk_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 一样:
- 本地可复现:不依赖网络、配额和余额,CI 和本地开发都能跑
- 成本为零:调试入库链路时不会产生 Embedding API 费用
- 接口稳定:
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 一一对应 |
vector | Embedding 向量,用于相似度检索 |
tenant_id | 标量过滤:按租户隔离检索范围 |
document_id | 标量过滤:按文档缩小候选集 |
chunk_id | 冗余存储,便于调试和回查 |
chunk_no | 块序号,展示时排序用 |
owner_id | 文档所有者,后续细粒度权限过滤用 |
content_hash | chunk 内容哈希,判断是否需要重新向量化 |
content_preview | 前 1000 字符预览,调试时直接看 Milvus 就能知道大概内容 |
这里有一个关键设计决策:向量只放在 Milvus,文本正文以 PostgreSQL 为准。Milvus 里只存 content_preview(前 1000 字)用于调试,完整正文始终从 ai_document_chunk 读取。原因有三个:
- PostgreSQL 是业务数据的权威存储,支持事务、备份和权限控制
- Milvus 存全文会显著增加存储成本和索引构建时间
- 检索命中后根据主键回查 PostgreSQL 是毫秒级操作,代价很低
同一文档重新入库时,先按 document_id 删除 Milvus 中所有旧向量,再批量插入新向量,保证不会出现新旧混杂。
状态流转到 indexed 之后才算入库完成
Embedding 生成并写入 Milvus 后,Python 会回填 ai_document_chunk 的 milvus_collection、milvus_pk 和 embedding_model 字段,再把 ai_document.document_status 更新为 indexed。到这一步,文档才算真正可被检索。
整个入库过程在一次数据库连接中完成文本解析和 chunk 写入,Embedding 和 Milvus 操作放在事务外。这样即使 Milvus 写入失败,PostgreSQL 里的 chunk 文本仍然完整,可以根据 embedding 状态重试向量化,而不是从头重新解析文件。
这离完整的”知识库问答”还差什么
当前 V2 已经跑通了入库侧的完整链路,但 RAG 是”存 + 检 + 答”三件事:
- ✅ 存:上传、解析、切分、Embedding、Milvus 写入、状态流转
- ⚠️ 检:向量检索 + 权限过滤已实现,但还没有重排、hybrid search 和检索评测
- ⚠️ 答:能生成带引用的回答,但 Prompt 策略、引用格式和”不知道”的边界还需要打磨
下一篇会写检索侧的设计:如何在 Milvus 向量检索中强制带上租户和用户权限、为什么检索结果还要回查 PostgreSQL、引用来源怎么组织才能让用户点回去看原文。