文档检索准确命中了"控制台新增数据源"的操作步骤,回答却只剩"点击下图红框中的按钮"。截图没有被抽出来,Markdown 里还是本地临时路径,用户看到的不是知识,而是一句失去指向的说明。

企业知识库里,截图、拓扑图和错误弹窗经常比旁边文字更关键。参考案例也经历了从 Base64 内嵌转向对象存储与 URL 映射的过程。案例图片数量和性能数据属于原作者;本文不复用这些数字,而是完整设计一条可审计的图片链路:提取二进制、保留文档位置、内容寻址去重、上传 MinIO、生成稳定资产引用、按权限解析访问 URL。
一句话定义:图片不是回答的装饰附件,而是与文本 Chunk 共享来源、顺序、权限和版本的知识资产。
一、只调用 getAllPictures() 为什么不够
Apache POI 的 XWPFDocument 能返回文档引用的图片,XWPFPictureData 能给出图片字节、文件名、类型和建议扩展名。但如果只遍历 getAllPictures(),你得到的是"这份 Word 有哪些图片",没有"图片出现在第几个段落、位于哪条步骤之后"。
RAG 展示需要两类信息:
- Asset Data:字节、MIME、宽高、Hash、对象存储 Key;
- Anchor Data:documentId、paragraph/block、run、顺序、标题路径与附近文本。

XWPF 的访问路径更适合做位置对齐:
text
XWPFDocument
→ IBodyElement
→ XWPFParagraph
→ XWPFRun
→ getEmbeddedPictures()
官方 API 显示 XWPFRun#getEmbeddedPictures() 返回当前 run 的嵌入图片。这样可以在遍历正文 Block 时原地生成图片占位,而不是事后猜测顺序。
二、统一的 AssetRef 数据结构
建议把图片从 Markdown 字符串中抽离为资产记录:
java
public record AssetRef(
String assetId,
String documentId,
String documentVersion,
String contentHash,
String mimeType,
String extension,
long sizeBytes,
Integer width,
Integer height,
String objectKey,
String sourcePart,
String blockId,
int blockOrder,
String sectionPath,
AssetStatus status) {
}
文本中只保存稳定协议:
markdown
请点击"新增数据源"。

asset:// 不依赖 MinIO 域名、bucket 或签名 URL。渲染前由 Asset Resolver 根据当前用户、环境和权限解析成 HTTP 地址。
三、用 Apache POI 提取时要处理五件事
1. 逐 Body Element 保持原序
段落、表格和图片必须进入同一 blockOrder。图片可能出现在表格单元格、页眉页脚或文本框中,主文档遍历只是第一层;对于未被高层 API 完整覆盖的 OOXML 结构,POI 官方也提示有时需要访问底层 XMLBeans。
2. 从 Run 获取嵌入图片
java
for (XWPFParagraph paragraph : document.getParagraphs()) {
String blockId = nextBlockId();
appendParagraph(blockId, paragraph.getText());
for (XWPFRun run : paragraph.getRuns()) {
for (XWPFPicture picture : run.getEmbeddedPictures()) {
XWPFPictureData data = picture.getPictureData();
extractAsset(documentId, blockId, data);
appendAssetPlaceholder(blockId, data);
}
}
}
这段只展示核心路径。真实实现还要遍历 tables、headers、footers,并根据业务决定是否读取浮动 drawing 与 VML 图片。
3. 大图优先流式读取
XWPFPictureData#getData() 会复制图片数据到临时 byte array,官方文档明确提醒调用可能昂贵。处理大图或批量文档时,可以从底层 package part 获取 InputStream,一边算 Hash 一边上传,避免重复复制。
4. 不信任原文件名
Word 包里的名称可能只是 image7.png,原名不一定可用。对象 Key 应使用 content hash 和安全扩展名,展示标题来自图片说明、附近段落或人工标注,而不是包内文件名。
5. 校验真实媒体类型
不要只根据扩展名判断。检测 magic bytes、限制最大尺寸、像素和解压后大小,拒绝损坏文件与伪装内容;EMF/WMF 等浏览器不直接支持的格式需要转码任务,并保留原始对象。
四、内容寻址:同一张图只存一次
企业模板、Logo 和重复截图可能出现在多份 Word 中。使用 SHA-256 内容 Hash 作为对象命名的一部分:
text
rag-assets/
sha256/7f/3c/7f3c...a9.png
上传前 statObject 或查资产表:Hash 已存在时复用物理对象,只新增文档锚点关系。
java
String hash = sha256(inputStream);
String objectKey = objectKey(hash, extension);
AssetObject object = assetRepository.findByHash(hash)
.orElseGet(() -> uploadAndSave(hash, objectKey, mimeType));
anchorRepository.bind(documentId, blockId, object.assetId(), blockOrder);
注意:对象去重不等于权限合并。物理字节可复用,但每个 documentId → assetId 关系仍要保留自己的 tenant、ACL 和版本。
五、MinIO 上传与元数据
MinIO Java SDK 是 S3 兼容客户端。上传对象时至少设置:
Content-Type:让浏览器按图片展示;Cache-Control:内容 Hash Key 可使用长缓存;- 自定义 metadata:contentHash、documentVersion、pipelineVersion;
- 可选 server-side encryption 与对象标签。
java
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucket)
.object(objectKey)
.stream(stream, size, multipartPartSize)
.contentType(mimeType)
.headers(Map.of(
"Cache-Control", "public, max-age=31536000, immutable",
"x-amz-meta-content-sha256", contentHash))
.build());
具体 headers 与 SDK 方法应按项目锁定版本验证。只有内容 Hash 不变的对象才适合 immutable;如果对象 Key 会原地覆盖,就不能使用同样缓存策略。
六、不要把签名 URL 永久写进 Markdown
Presigned URL 有有效期。MinIO Java SDK 的 getPresignedObjectUrl 可以为 GET 生成带过期时间的 URL,但它是访问凭证,不是资产 ID。
错误做法:
markdown

文档入库几小时后链接就失效,还可能被日志、Prompt 或前端缓存泄露。
正确链路:

text
Ingestion:
Word → POI → AssetRef → Hash → MinIO → asset://id
Serving:
Answer Markdown → Asset Resolver → ACL → short-lived URL / proxy URL
Resolver 接口可以这样设计:
java
public interface AssetUrlResolver {
ResolvedAsset resolve(
String assetId,
UserPrincipal user,
Duration ttl,
RenderPurpose purpose);
}
私有知识库常见两种返回方式:
- 短期 Presigned URL:后端鉴权后生成,前端直接访问 MinIO;
- 应用代理 URL:前端访问业务域名,后端每次鉴权并流式转发,控制更强但消耗应用带宽。
选择取决于安全、缓存、审计和网络拓扑,不存在绝对最佳。
七、图片怎么与 Chunk、引用和答案对齐
在生成 Chunk 时,图片占位属于 Block 序列。若一张截图位于步骤 3 后,最好和该步骤进入同一 parent section,并在 metadata 中保存:
text
assetIds: [a_7f3c]
sourceBlockId: b_104
sourceParagraph: 27
sectionPath: 安装 > 新增数据源
caption: 新增数据源按钮位置
检索阶段可以先检索文字;命中 child 后,父文档扩展会把相邻 asset:// 占位带入上下文。生成模型若无需视觉理解,只要保留占位与 caption,前端仍能展示原图;若使用多模态模型,则由服务端在权限检查后加载图片内容。
引用跳转也应指向人读版文档的 block/paragraph,而不是直接把对象 URL 当文档来源。
八、版本、删除与生命周期
文档更新时可能出现三种情况:
- 图片字节未变:复用 assetId,只更新 anchor;
- 图片变化:生成新 contentHash 与 assetId;
- 图片被删除:删除 anchor,物理对象进入引用计数或延迟回收。
MinIO bucket versioning 可以保留同名对象多个版本,但内容寻址 Key 本身已经避免原地覆盖。版本功能仍可保护误删;旧版本和 delete markers 需要生命周期规则,否则容量会持续增长。
建议把删除分两阶段:
text
UNREFERENCED → grace period → GC candidate → delete / retain by policy
不要在单个文档删除时立即物理删除共享图片。
九、七个高频坑

- 只取图片列表,不记录段落位置:展示时无法恢复图文顺序;
- Base64 写进 JSON/Markdown:响应膨胀、缓存差、无法独立授权;
- Presigned URL 永久入库:链接过期且可能泄露;
- 对象名使用原文件名:重复、冲突与不安全字符难治理;
- 只按扩展名判断 MIME:伪装内容和损坏文件进入系统;
- Hash 去重后合并 ACL:物理复用不代表访问范围相同;
- 删除文档立即删对象:共享图片被其他文档引用时会断链。
十、面试时怎么讲
我会在 Apache POI 遍历 body element、paragraph、run 时原地提取 embedded picture,生成带 blockOrder 和 sectionPath 的 AssetRef。图片按 SHA-256 内容寻址上传 MinIO,Markdown 只保存
asset://id,不保存 Base64 或长期签名 URL。回答返回前由 Asset Resolver 根据 tenant/ACL 生成短期 URL或代理地址。图片和文本共享 documentVersion,删除走 anchor 解绑、引用计数和延迟 GC,并对 MIME、尺寸、缓存与版本进行校验。
上线检查清单

- 图片与 paragraph/run/blockOrder 对齐,不只保存全局列表;
- magic bytes、MIME、扩展名、尺寸和像素均校验;
- 大图使用流式 Hash/上传,限制内存与单图大小;
- objectKey 基于内容 Hash,原文件名只作为展示信息;
- Markdown 保存
asset://id,不保存 Base64 与长期签名 URL; - Asset Resolver 在生成 URL 前再次检查 tenant/ACL;
- Content-Type、Cache-Control、版本和加密策略明确;
- 文档版本、anchor、共享引用、延迟 GC 与生命周期可追踪。
总结
Word 图片进入 RAG 的难点不在"能否导出一张 PNG",而在图片能否继续保持所在位置、文本语义、来源、权限和版本。二进制抽取只是第一步,AssetRef、内容寻址、对象存储和访问解析器才组成完整链路。
当 Markdown 中只留下稳定 asset:// 引用,存储地址、域名、签名策略和 CDN 都可以独立演进;当 anchor 与 ACL 被保存下来,系统返回的才是一条可解释、不会过期、不会越权的图文证据。