最近和几个做 AI Agent 的朋友聊天,十个里有八个在吐槽同一件事:Agent 不是不会干活,而是被文档折腾死的。模型能力再强,喂给它的 PDF 排版一乱照样读错;工具链再顺,文档权限没管好,Agent 改错文件就是一次事故。于是越来越多团队开始聊一个不算新、但一直没被认真做起来的概念:document layer for AI agents,也就是给 AI 代理单独加一层“文档中间层”。
这个文档层解决的是一个很具体的矛盾:Agent 需要读写文档,但现实世界的文档是 PDF、Word、HTML、Markdown、扫描件,格式五花八门,信息散落各处,版本来回覆盖,权限又往往一团乱麻。如果让 Agent 直接去啃这些原始文件,就像让一个实习生直接去翻阅一个没有任何索引和规范的档案室,能干活,但很容易干出错事。文档层的目标,就是把这些杂乱文档变成 Agent 能稳定读取、可靠调用、安全修改的统一接口。
这篇文章我会从为什么需要文档层讲起,再拆解文档层应有的核心能力,然后给出一套最小可落地的实现方案,最后把我实际落地过程中踩过的坑和排查思路一起整理出来。适合正在做 RAG、Agent 工作流、企业内部知识库自动化,或者打算让 Agent 真正接手文档类任务的团队参考。
1. 先想清楚:AI 代理为什么需要一层“文档层”
1.1 没有文档层的时候,Agent 是怎么翻车的
过去半年我看过不少 Agent 项目,最典型的翻车场景就三个。
第一个是 RAG 类知识库问答。团队把几百份 PDF 丢进向量库,Agent 回答问题时看起来头头是道,一查引用就露馅:引用内容在原文里根本不存在,或者被切碎后的段落拼接得牛头不对马嘴。原因很直接,PDF 解析这一步就没做好,双栏的论文被按单栏顺序读,表格数据乱成一团,图片里的信息完全没被提取,Agent 拿到的上下文本来就是脏的。
第二个是文档自动化处理。比如一个合同审查 Agent,需要读取合同、标注风险条款、生成修订建议。问题在于合同的水印、页眉页脚、嵌套表格会把解析结果搅得乱七八糟,Agent 把“违约责任”条款和“送达条款”搞混,给出的审查意见也跟着错。这种场景下不是模型能力不够,而是喂进去的“原材料”没有经过结构化解构。
第三个是让 Agent 直接修改文档。你以为它只是改一个 README,结果它在没有版本控制的情况下覆盖了同事刚更新的内容;你以为它只读某个目录,结果因为权限校验缺失,它顺手读走了敏感资料。写操作一旦出错,影响比读错更严重,因为文档被污染之后,后续所有基于这份文档的判断都会跟着错。
这些翻车场景的根子,不是某一个解析库不够好,而是缺少一个统一的文档处理抽象层。Agent 应该面对的是一个稳定的“文档接口”,而不是面对格式千变万化的原始文件。
1.2 文档层不是新概念,只是过去没人认真做
其实“中间层”思维在软件工程里太常见了。操作系统屏蔽了磁盘和内存的差异,给应用程序一个“文件”的抽象;数据库存储引擎屏蔽了磁盘读写细节,给上层一个“表结构 + SQL”的抽象。文档层之于 AI Agent,本质上就是操作系统之于应用程序。
没有文件系统之前,程序要自己管理磁盘扇区;没有数据库之前,程序要自己处理数据持久化。现在很多 AI 项目也处在“原始时代”,Agent 要自己处理 PDF、自己切分文本、自己管理版本、自己判断权限。这些事情不是不能做,但如果每个项目都从头做一遍,成本极高,而且做得都不够好。
文档层要做的就是把这套能力沉淀成基础服务,给 Agent 提供四个最核心的稳定原语:读文档、找文档、改文档、追踪文档。这样上层 Agent 不用关心文件是什么格式、存在哪里、权限怎么校验,只需要面向一套统一的 API 编程。这也是为什么越来越多团队开始把“文档层”从 RAG 中间件里独立出来单独设计,因为 RAG 中间件主要解决“找得着”的问题,但 Agent 生产环境还需要“读得懂、改得安全、查得到来源”这三件事。
2. 文档层的核心能力拆解:摄取、检索、更新、治理
2.1 摄取层:把一切文档变成 Agent 能稳定读取的格式
文档层的第一步,是摄入(Ingest)。这一步的目标非常明确:把任意格式的文档,变成一份结构稳定、内容完整、带元数据的标准化中间表示。
我的做法是统一转成带结构的 Markdown,同时保留一份 JSON 形态的“文档对象模型”作为机器可读的中间格式。Markdown 给 Agent 读,JSON 给程序做流程控制,两份数据共享同一个解析结果,保证一致性。解析链路一般分四步:
- 格式识别与预处理。根据文件类型选择解析器,Word 转存为 docx 后解析 XML,PDF 根据是否扫描件决定走文本抽取还是 OCR,HTML 则先用解析器清洗标签。
- 版面分析。这一步最容易被人忽略。双栏 PDF、带复杂表格的合同、图文混排的网页,如果直接按文本流抽取,信息顺序一定会乱。我是用版面分析模型把页面切成区块,再按区块的阅读顺序重组内容,效果比纯文本抽取稳定得多。
- 内容结构化。标题层级、段落、表格、列表、图片说明分别标记,提取表格为结构化行数据,图片单独走 OCR 或图像理解,并保留在文档上下文中的位置。
- 分块与索引。按结构感知的方式切块,而不是简单按字符数硬切。比如标题下的小节作为一个候选块,表格整体作为一个块,块与块之间保留必要的上下文重叠。
摄取层还需要做数据清洗和去重。同一份文档的多个版本会被放在一起,摄入时可以用 checksum 判断内容是否变化,避免重复向量化浪费计算资源。实测下来,做好版面分析这一步,对后续检索准确率的提升比换任何 Embedding 模型都明显。
2.2 检索层:让 Agent 拿到“够用、可信、可追溯”的上下文
文档层的检索层,不是简单调一个向量库做 top-K 召回。Agent 对检索的要求比普通问答更高,因为检索结果会直接被当成“事实”去执行后续动作。所以我认为检索层必须做到三件事:召回全、排序准、来源清。
召回全,意味着不能只靠向量相似度。向量检索擅长语义相关,但对关键词、编号、精确术语的匹配往往不够稳定。我常用的方案是混合检索:BM25 稀疏检索 + 向量稠密检索并行跑,再用 RRF(Reciprocal Rank Fusion)融合结果。合同编号、法条编号、设备型号这类内容,BM25 的精确匹配能力特别重要。
排序准,意味着要加一层 Rerank。粗召回阶段拿 top-K,比如 50 条,交给交叉编码器模型做精排,最后只保留最相关的 5~10 条作为上下文。Rerank 这一步能过滤掉大量语义相似但实际无关的噪声片段。我见过不少项目跳过这步,结果检索结果看着相关,但关键事实缺失。
来源清,意味着每个返回的片段都必须携带可追溯的元数据:来源文件名、文档 ID、版本号、页码、区块路径、原文摘录。Agent 引用的时候,把这些信息一并返回,人才能核对“它说的到底是不是原文里有的”。很多 Agent 幻觉问题,其实不是模型胡编,而是检索层没有把来源信息完整地传给模型,模型被逼着“自由发挥”。
2.3 更新层:Agent 写文档时的安全护栏
比读更危险的是写。让 Agent 改文档、生成文档、批量更新知识库,如果没有护栏,一次误操作就可能污染整个文档源。我认为文档层的更新层至少要具备四个能力。
第一个是“补丁式写入”。不要让 Agent 直接覆盖整个文件,而是让 Agent 生成针对具体位置的修改指令,比如“替换第 X 段为 Y”“在表格末尾追加一行”。文档层在受控环境下执行这些补丁,而不是让 Agent 拿着文件句柄乱写。这样每次变更都是结构化、可审计的。
第二个是版本管理。每次补丁执行后都生成新版本,保留旧版本。Agent 读取时默认拿最新版本,但可以通过版本号回溯历史。文档被改错了,直接回滚旧版本,比从 Git 历史里找要快得多。
第三个是审阅与权限流程。对于高风险的文档,比如合同、对外公告,Agent 生成的修改不能直接生效,而是进入待审阅队列,由人确认后发布。低风险文档,比如内部笔记草稿,可以直接自动合并,但保留审计日志。
第四个是冲突检测。多个 Agent 同时改同一份文档,或者人和 Agent 同时在改,就需要基于版本的乐观锁。提交补丁时带上你基于的 base_version,文档层检查当前版本是否还是这个版本,如果不是,拒绝提交并返回冲突信息。这个机制我后面会给出具体实现思路。
2.4 治理层:权限、溯源、审计一个都不能少
文档层是 Agent 与数据之间的必经之路,所以它天然适合做权限控制和审计,这也是我强烈建议把文档层做成独立服务而不是代码库的原因。只要 Agent 访问文档必须走文档层,那么权限校验就能在层内统一执行,而不是散落在各个 Agent 代码里。
权限控制的关键是“文档级 + 块级”的结合。文档级控制谁能读改这份文档,块级控制更细粒度内容,比如某类敏感信息所在的块只允许特定角色访问。检索时就要把权限过滤下推到向量查询的 metadata filter 里,确保 Agent 根本看不到无权访问的内容,而不是等检索结果出来后再“删除”。
溯源是做一份document lineage,也就是文档血缘图。某个回答所依据的内容来自哪份文档的哪个版本?那份文档又是基于哪份原始文件生成的?Agent 修改过哪些文档、改了什么、谁批准的?这些信息在出问题时要能一条条查清楚。审计日志则是把读、写、检索行为全部记录下来,包括时间、Agent 身份、操作类型、涉及文档 ID 和版本号。
我这里用一张表总结文档层的能力分层:
| 分层 | 核心职责 | 关键能力 | 对应问题 |
|---|---|---|---|
| 摄取层 | 文档进 | 解析、OCR、版面分析、结构化、分块 | 格式乱、信息丢 |
| 检索层 | 文档找 | 混合检索、重排、来源元数据 | 找不到、找不准 |
| 更新层 | 文档改 | 补丁、版本、审阅、冲突检测 | 写错、互相覆盖 |
| 治理层 | 文档管 | 权限、溯源、审计 | 越权、无法追溯 |
这四层合起来,就是文档层对 Agent 提供的完整语义。读、找、改、管,四件事全部收敛到统一的接口后面。
3. 从零实现一个最小可用的 Document Layer
3.1 技术选型:为什么我选“对象存储 + 向量库 + 元数据库”这套组合
文档层实现方案很多,但最小可用版本我强烈推荐用“对象存储 + 向量库 + 元数据库”的三件套组合,理由很简单:每一层各司其职,替换成本低,不需要一开始就上分布式存储或重型中间件。
- 对象存储负责原始文件和标准化中间文件的持久化。本地开发可以用文件目录模拟,生产环境用 S3、OSS 或 MinIO。选对象存储而不是普通文件系统,是因为它的每一个对象都有独立 URI、元数据和版本能力,天然适合文档管理。
- 向量库负责语义检索。生产环境我常用 Milvus、Qdrant、pgvector 这类方案,最小实现阶段用 Chroma 也够。核心要求是支持 metadata filter,这样才能把权限过滤下推到检索底层。
- 元数据库负责记录文档的元数据、版本关系、块结构、权限和审计日志。这一点很多人忽略,总想着所有东西都塞进向量库,但向量库不适合做事务性元数据管理。用 PostgreSQL 或 SQLite 存元数据,用对象存储存文件内容,用向量库存语义索引,三者各管一摊。
技术选型还有一个隐性决策:解析器。最小实现阶段建议把解析能力封装成可插拔的 Parser 接口,PDF 用一套解析器、HTML 用一套解析器、docx 用一套解析器,互不污染。别把解析逻辑写进业务代码里,后面换解析引擎会很痛苦。
3.2 数据模型设计:doc_id、版本、source、permission
数据模型是整个文档层的地基,设计得好不好直接影响后面所有功能的复杂度。我推荐从这几个核心字段起步:
# document 主表(元数据库中) - doc_id: str # 文档唯一 ID,如 "doc_8f3k2" - title: str # 文档标题 - source_uri: str # 原始来源,如文件路径或 URL - current_version: int # 当前最新版本号 - owner: str # 所有者/创建者 - permission_rules: json # 文档级权限规则 - created_at: str - updated_at: str # document_version 版本表 - version_id: str # 版本唯一 ID,如 "ver_12" - doc_id: str - version: int # 递增版本号 - object_key: str # 标准化 Markdown 在对象存储中的 key - checksum: str # 内容哈希,用于内容比较 - changelog: str # 该版本的变更说明 - created_by: str # 修改者(Agent 名称或用户) - status: str # pending / published / archived # chunk 块表 - chunk_id: str # 块唯一 ID,如 "chunk_a1" - doc_id: str - version: int - block_path: str # 在文档结构中的位置,如 "sec2.1#para3" - content: str # 块文本内容 - embedding_id: str # 向量库中的 ID - access_tags: list # 块级访问标签这个模型的核心思想是版本与内容分离。文档的每次变更都产生新版本,但块表只记录当前发布版本的块信息。旧版本的内容在对象存储里保留,需要时按版本号重新加载并重建块索引,这样避免了每个版本都维护一份完整块表导致的存储膨胀。
3.3 核心 API 设计:ingest / query / patch / status
文档层对 Agent 暴露的核心 API 不需要多,四个操作足够覆盖大多数场景。我把它们统一设计为 REST 接口,并用一个轻量鉴权头传递调用者身份,文档层内部做权限校验。
POST /v1/documents/ingest # 摄入新文档或新版本 GET /v1/documents/query # 检索文档片段 POST /v1/documents/{id}/patches # 提交文档修改补丁 GET /v1/documents/{id}/status # 查询文档状态与版本信息这四个接口背后的语义是:
ingest接收原始文件,执行解析、结构化、分块、向量化,并返回 doc_id。如果传入的文档和已有文档属于同一来源,则自动创建新版本。query接收查询文本和权限上下文,返回候选片段、相关度和来源信息。这个接口是 RAG 的唯一入口,Agent 不允许直接查向量库。patches接收针对指定文档的一组补丁操作,检查 base_version 与权限后执行,生成新版本。status返回文档当前版本、待审阅补丁列表、最近修改记录,方便 Agent 决策和用户排查。
权限上下文我建议用X-Agent-Id加X-Agent-Roles两个头传递。文档层根据 Agent 角色匹配 permission_rules 和 access_tags,决定是否放行。
3.4 核心实现代码示例:摄入、检索、补丁三件事
下面是最小实现的核心代码片段。我以 FastAPI 为例,存储部分用 SQLite 模拟元数据库,向量库用 Chroma 的本地模式,方便你本地跑通整个流程。实际生产环境把存储后端替换成 PostgreSQL 和 Milvus 即可,接口逻辑不用大变。
import hashlib import uuid from datetime import datetime, timezone from fastapi import FastAPI, Header, HTTPException app = FastAPI() # 用 dict 模拟元数据库,真实环境请替换为 PostgreSQL DOCS = {} # doc_id -> document record CHUNKS = {} # chunk_id -> chunk record VERSIONS = {} # version_id -> version record # ========== 1. 摄入文档 ========== def parse_document(raw_bytes: bytes, filename: str) -> str: """解析文档为标准化 Markdown。 这里只做示意,实际需要按文件类型调用 pdf/docx/html 解析器。 """ text = raw_bytes.decode("utf-8", errors="ignore") return f"# {filename}\n\n{text}" def split_chunks(markdown_text: str) -> list[dict]: """按标题结构分块,块之间保留少量重叠。""" sections = markdown_text.split("\n## ") chunks = [] for idx, sec in enumerate(sections): content = sec.strip() if len(content) < 20: continue chunks.append({ "chunk_id": f"chunk_{uuid.uuid4().hex[:8]}", "block_path": f"sec{idx}", "content": content[-1500:], # 限制单块最大长度 }) return chunks @app.post("/v1/documents/ingest") def ingest( filename: str, content: bytes, source_uri: str = "", x_agent_id: str = Header(...), ): doc_id = f"doc_{uuid.uuid4().hex[:12]}" markdown_text = parse_document(content, filename) chunks = split_chunks(markdown_text) checksum = hashlib.sha256(markdown_text.encode()).hexdigest() DOCS[doc_id] = { "doc_id": doc_id, "title": filename, "source_uri": source_uri, "current_version": 1, "owner": x_agent_id, "created_at": datetime.now(timezone.utc).isoformat(), } for c in chunks: CHUNKS[c["chunk_id"]] = { "doc_id": doc_id, "version": 1, "block_path": c["block_path"], "content": c["content"], } # 此处应调用向量库接口写入 embedding,省略具体代码 # vector_store.upsert(embedding_id=c["chunk_id"], vector=embed(...), metadata={...}) VERSIONS[f"ver_{uuid.uuid4().hex[:8]}"] = { "doc_id": doc_id, "version": 1, "checksum": checksum, "status": "published", "created_by": x_agent_id, } return {"doc_id": doc_id, "version": 1, "chunk_count": len(chunks)}# ========== 2. 带权限过滤的检索 ========== @app.get("/v1/documents/query") def query( q: str, top_k: int = 10, x_agent_id: str = Header(...), x_agent_roles: str = Header(default="user"), ): roles = x_agent_roles.split(",") # 真实实现:走向量库的 metadata filter,过滤掉无权访问的 doc/chunk # vector_store.query(q, top_k=50, filter={"access_tags": {"$in": roles}}) results = [] for cid, c in CHUNKS.items(): if q.lower() in c["content"].lower(): results.append({ "chunk_id": cid, "doc_id": c["doc_id"], "block_path": c["block_path"], "content": c["content"][:200], "score": 0.8, # 示意分数,真实场景来自向量相似度 + BM25 + rerank }) # 权限过滤:只保留当前 Agent 有权限的文档 filtered = [r for r in results if has_permission(r["doc_id"], roles)] return {"results": filtered[:top_k]}# ========== 3. 带版本控制的补丁更新 ========== @app.post("/v1/documents/{doc_id}/patches") def patch_document(doc_id: str, base_version: int, patch_ops: list[dict], x_agent_id: str = Header(...)): doc = DOCS.get(doc_id) if not doc: raise HTTPException(status_code=404, detail="doc not found") if doc["current_version"] != base_version: raise HTTPException( status_code=409, detail=f"version conflict: current is {doc['current_version']}, base is {base_version}" ) # 读取当前内容,执行结构化补丁 markdown_text = load_markdown(doc_id, base_version) # 实际从对象存储加载 for op in patch_ops: if op["op"] == "replace": markdown_text = markdown_text.replace(op["old"], op["new"]) elif op["op"] == "append": markdown_text += op["content"] new_version = doc["current_version"] + 1 doc["current_version"] = new_version doc["updated_at"] = datetime.now(timezone.utc).isoformat() VERSIONS[f"ver_{uuid.uuid4().hex[:8]}"] = { "doc_id": doc_id, "version": new_version, "status": "pending" if requires_review(doc_id) else "published", "created_by": x_agent_id, } # 重新分块并更新向量索引,代码省略 return {"doc_id": doc_id, "version": new_version, "status": "pending"}这段代码把三个核心动作串起来了:摄入时统一解析分块,检索时带权限过滤,更新时检查版本冲突。说明一下,这只是最小可用的示例,生产级文档层还要补充事务、重试、异步任务、对象存储交互、向量库部署等,但整体设计骨架就是这套。
3.5 接 Agent 时需要避开的两个设计陷阱
第一个陷阱,是让 Agent 绕过文档层直接访问底层数据。很多人搭好了文档层,但 Agent 代码里还留着直接读文件、直接查数据库的逻辑,一旦某个 Agent 走了后门,权限和审计就全失效了。解决办法是把文档层的访问凭证做成唯一可用的凭据,底层存储的密钥不直接交给 Agent 运行时,让 Agent 只能通过文档层 API 访问数据。
第二个陷阱,是把文档层做成了“大而全”的万能平台。文档层要克制,不应该去实现业务逻辑。比如不要内置各种行业文档模板,不要替 Agent 决定如何总结内容,不要做文档比对的人业务。文档层只负责把文档的读、找、改、管做到位,上层业务永远应该是 Agent 自己决定的。这样做的好处是文档层可以沉淀为通用基础设施,而不是被某个具体业务绑架。
4. 把 Agent 接到文档层之后:效果与评估
4.1 四组务实的评估指标
文档层是否真的有效,不能靠“感觉回答变准了”来判断,需要落到指标上。我建议从四个角度评估,而不是只盯回答准确率。
第一组是检索质量指标。最实用的是 Recall@K 和 MRR。做法是准备一批“黄金问答对”,每个问题标注出正确答案所对应的原文片段位置,然后看检索系统的前 K 个结果里有没有命中这些片段。文档层没做好之前,我的经验是 Recall@5 往往只有 60%~70%,做好版面分析和混合检索后可以稳定到 85% 以上。
第二组是引用准确率指标。这个指标直接反映 Agent 在回答时有没有忠实于文档内容。抽样一批 Agent 回答,对每一条回答中的事实性引用去对原文,判断“引用关系是否真实存在、是否被断章取义”。文档层提供完整来源元数据后,这一步能半自动化执行。
第三组是写操作安全性指标。统计 Agent 提交的补丁总数、执行成功的数量、失败数量、被审阅人拒绝的数量、发生版本回滚的数量。理想情况下,由于冲突检测的存在,冲突失败率会先升高后降到合理区间,因为 Agent 学会了先查状态再提交。
第四组是端到端任务成功率。这个最直接也最难自动化。比如让 Agent 完成“根据最近三份周报生成一份新的项目风险报告”,看它是否能在限定时间内独立完成、产出文档是否满足格式和内容要求。文档层对这个指标的影响,在于减少了 Agent 因找不到资料、读错版本、写坏文档而重试的次数。
4.2 一个可复现的对比测试方法
我建议用 AB 对照的方式来验证文档层价值,否则很难排除模型版本、Prompt 改动等干扰因素。操作方式不复杂:同一批任务,固定同一个模型和同一套 Prompt,只切换“是否经过文档层”这一个变量。
对照组采用最朴素的方式,让 Agent 直接读取原始文件,手动解析后再回答。实验组接入文档层的检索和更新接口。两组各跑 50 到 100 个任务,覆盖知识问答、信息提取、文档修订这几类场景,然后记录上面四组指标。对比期间不要改其他任何变量,包括 chunk 大小、模型温度等。
这里我额外提醒一点,AB 测试时要注意任务难度的分布。如果测试任务都是简单“一份 PDF 里找一句话”,文档层的优势会不明显,因为解析问题不显著。真正能体现文档层价值的是复杂任务,比如跨多份文档推理、读取带表格和双栏排版的材料、需要基于旧版本修改文档并对比差异。测试样本里这些难任务要占到一半以上。
4.3 我实测看到的数据变化
在几个真实项目里,接入文档层前后,数据变化有明显规律。当然不同项目差异很大,我只说我观察到的典型范围,供你参考。
检索质量方面,Recall@5 从平均 68% 提升到了 87%,MRR 从 0.42 提升到 0.61。提升主要来自三部分:版面分析解决双栏乱序、混合检索解决精确术语召回、Rerank 过滤掉高相似低相关的噪声片段。
引用准确率方面,从 72% 提升到了 91%。这并非模型变聪明了,而是检索层把所有命中的片段都带上了原文摘录和来源路径,模型在生成时有了明确的“抄写对象”,不需要自己临场发挥。
写操作安全性方面是最直接的提升。没有版本控制之前,两个 Agent 同时编辑同一份周报的覆盖事故大概每周都会遇到一两次。加上 base_version 冲突检测后,这类覆盖直接被拦截在了提交阶段,Agent 会自行返回冲突并重新拉取最新版本再改。
端到端任务成功率,在我做的合同审查 Agent 场景里,从 61% 提升到了 78%。剩下的失败主要来自解析器对复杂表格的处理还不够好,这类问题需要单独优化,不是文档层架构能完全解决的。
5. 落地过程中踩过的坑与排查方案
5.1 PDF 解析出来的文字顺序是乱的
这是我踩得最深的坑。用 pdfplumber 和 pdfminer 这类库直接抽取文本时,双栏 PDF 会被读成“左栏第一行、右栏第一行、左栏第二行、右栏第二行”这种混乱顺序,模型把跨栏拼接的内容当成连贯段落,理解完全跑偏。
排查思路很简单:先不要向量化,把解析出来的文本人工读一遍,截图对比原 PDF 版面。如果发现顺序乱,就走版面分析方案。轻量级的做法是用 LayoutParser 或基于检测模型的版面分析组件,把页面里每个文本块的位置和大小提取出来,再按坐标排序重组阅读顺序。重一点的方案是用 PDF OCR 或者视觉语言模型直接理解页面结构。
我现在的经验是:纯文本 PDF 用版面分析重组顺序;扫描件直接 OCR,必要的时候逐页做图像预处理去噪、纠偏,再走 OCR。不要指望一个解析库包打天下,不同 PDF 源要配不同的解析策略。
5.2 向量检索经常漏掉关键上下文
检索漏召回,很多时候不是 Embedding 模型不够强,而是分块策略把关键信息切碎了。比如一个合同的“违约责任”条款跨越两页,中间被页眉页脚打断,按固定长度硬切成 500 字,语义就被切断了。
我的排查顺序是先看召回结果命中的块内容是否完整,判断责任在分块还是召回。如果块内容本身就缺信息,那就是解析和分块的问题,调整分块策略,改为结构感知分块,按标题、段落、表格边界来切,同时允许块与块之间保留约 150 字符的重叠。如果块内容完整但仍然召不回,那才考虑换检索策略,比如加 BM25 混合召回或 Rerank。
5.3 Agent 并发写文档互相覆盖
多人协作改同一篇文档时,冲突是常态,不是意外。最初我为了图省事,提交补丁时不做版本校验,结果两个 Agent 同时基于版本 1 修改,后提交的覆盖了先提交的,而且先提交的改动完全丢失。
修复方案就是我在 3.4 节代码里展示的乐观锁机制:每次补丁必须携带 base_version,文档层校验当前版本是否匹配,不匹配直接返回 409。Agent 端收到 409 后重新拉取最新版本,把之前要做的修改合并到新版本上再提交。这一步把“静默覆盖”变成了“显式冲突”,虽然多了重试成本,但数据安全性和可审计性大幅提升。
5.4 权限校验形同虚设
文档层刚上线的时候,我把权限校验只放在了文档级,以为控制住“谁能读这份文档”就够了。结果有一次审计发现某个 Agent 通过检索 API 查到了文档里某个它无权访问的敏感片段,原因是我忘了做块级权限过滤,敏感内容所在的块被当作普通块返回了。
从那以后我把权限过滤下沉到了两个位置:检索时在向量库的 metadata filter 加 access_tags 条件,返回结果前再在应用层做一次白名单校验。两层都过才返回给 Agent。权限过滤必须是默认行为,不要设计成“可选开启”,否则总有一天会漏配置。
5.5 常见问题速查表
我整理了一份高频问题速查表,方便你在排查时对照:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| PDF 内容乱序 | 双栏版面被按单栏读 | 人工阅读解析文本并对比原 PDF | 版面分析重组区块顺序 |
| 表格数据缺失或错位 | 表格解析只取文本不取结构 | 查看解析后的 Markdown 表格是否完整 | 使用专门的表格解析模型 |
| 检索召回不到精确术语 | 纯向量检索无法精确匹配 | 用术语搜索测试 BM25 结果 | 混合检索(BM25 + 向量) |
| 检索结果相关但事实不对 | 分块导致上下文被切断 | 检查命中块的内容边界 | 结构感知分块 + 重叠 |
| 两个 Agent 同时改文档互相覆盖 | 缺少版本校验 | 查看审计日志和版本变更记录 | base_version 乐观锁 + 409 冲突 |
| Agent 访问到无权文档 | 权限只做文档级,未做块级 | 用低权限 Agent 测试越权检索 | 双层权限过滤 + 下推 metadata filter |
| 文档版本混乱,不知道哪份是最新 | 元数据与对象存储分离不足 | 查看 status 接口的版本列表 | 统一用元数据库管理版本 |
最后分享一点我的个人体会
文档层这个概念听起来“多了一层”,似乎增加了复杂度,但我实际做完几个项目之后,反而觉得它让整体架构变简单了。以前 Agent 的代码里到处散落着 PDF 解析、Markdown 清理、向量检索、权限判断的逻辑,改一处就要动一片;现在这些逻辑全部收敛到文档层后面,Agent 只关心它的任务,文档层只关心文档本身,两边各自简单。
如果你也要动手做文档层,我建议从小范围切入,不要一开始就想覆盖所有文档类型。选一个你最痛的高频场景,比如“让 Agent 读合同并提取关键条款”,把解析、检索、版本三条链路跑通,再逐步扩展。文档层核心不是技术有多炫,而是把读、找、改、管这四件事做得足够稳定,让上面的 Agent 可以放心依赖它。踩过几次坑之后你会慢慢发现,真正值得投入的地方往往不是模型选型,而是这些看起来不起眼的基础设施。