案例 #1785:Excel IMAGE 函数过滤
PR 合入日期:2026-06-24 | 作者:estelledc (Jason) | 改动:2 文件 +111/-1
结论先行
这个案例教会一件事:RAG 管线中的噪声过滤必须在最早的解析层做,而不是期望下游检索或 Rerank 来兜底。
适合谁读:关注 Python docreader 层的贡献者。改动集中在 docreader/parser/excel_parser.py,不涉及 Go 后端或前端 Vue 代码。
与 #1248 的关系:间接关联,方法论层面相通。#1248(知识库问答-点赞点踩)需要将用户反馈归因到具体的知识库片段——本质上也是一种”边界识别”问题:哪些片段对回答有贡献、哪些是噪声?#1785 解决的是更上游的边界识别:哪些 cell 内容是有效文本、哪些是图片函数噪声?两者的共同模式是——在管线的某个节点做归因/过滤决策,需要精确的正则或规则来划定边界。
1. 问题现象(What)
用户在 WeKnora 知识库中上传了包含嵌入图片的 Excel 文件(由 WPS 生成),RAG 问答时 AI 的回复中出现了 DISPIMG("ID_xxx",1) 这样的函数字符串,而不是图片内容或正常文本。
复现步骤:
- 用 WPS 创建一个 Excel 文件,在单元格中插入图片
- 将该文件上传到 WeKnora 知识库
- 对知识库发起问答,观察返回结果
- 发现回答中包含类似
DISPIMG("ID_E2A33949-...",1)或=_xlfn.DISPIMG("ID_xxx",1)的无意义字符串
问题的直观感受:用户期望 AI 基于文档的实际内容回答问题,但 AI 把图片占位符当成了”知识”来引用——就像你问图书管理员一个问题,他翻开书回答你的不是书的内容,而是书页空白处的印刷标记。
修复前 / 修复后
| 观测 | 修复前 | 修复后 |
|---|---|---|
| cell 解析 | DISPIMG("ID_xxx",1) 原样写入文本 |
正则过滤,噪声不入 chunk |
| AI 回答 | 引用无意义函数串 | 基于有效文本或跳过空 cell |
| 测试 | 无 IMAGE 变体覆盖 | 4 种 WPS/Excel 变体单测 |
定位步骤
rg "DISPIMG|IMAGE|_IMAGE_FUNC" docreader/parser/excel_parser.py
rg "ExcelImageFilter|test_excel" docreader/tests/
python -m pytest docreader/tests/test_excel_parser.py -v -k -i image
- Issue #1779 截图中的字符串 → grep
DISPIMG excel_parser.py→parse_into_text()单元格循环- 用 openpyxl 小脚本验证
data_only=True对 WPS cell 的行为
2. 根因分析(Why)
根因可以用一句话概括:WPS 把嵌入图片存储为函数字符串,而 openpyxl 的 data_only=True 对这类伪函数不生效,导致噪声泄入 RAG chunk。
要理解这个问题,需要追踪数据从 Excel 文件到 RAG 回答的完整流向。
flowchart TD
A["用户上传 Excel 文件<br/>(含 WPS 嵌入图片)"] --> B["docreader 服务接收文件"]
B --> C["ExcelParser.parse_into_text()"]
C --> D["openpyxl 打开工作簿<br/>data_only=True"]
D --> E{"遍历每个 cell"}
E -->|"普通文本 cell"| F["cell.value = '正常内容'<br/>✅ 写入解析结果"]
E -->|"真实公式 cell"| G["cell.value = None<br/>(data_only 生效)<br/>✅ 被跳过"]
E -->|"WPS 图片 cell"| H["cell.value = 'DISPIMG(\"ID_xxx\",1)'<br/>(data_only 不生效)<br/>❌ 被当成正常文本写入"]
F --> I["解析结果传入 chunker"]
H --> I
I --> J["向量化写入知识库"]
J --> K["用户提问时检索召回<br/>噪声字符串出现在回答中"]
style H fill:#f96,stroke:#333
style K fill:#f96,stroke:#333
关键文件表:
| 文件 | 角色 | 关键函数/组件 |
|---|---|---|
docreader/parser/excel_parser.py |
Excel 文件解析器 | ExcelParser.parse_into_text() — 遍历单元格提取文本 |
docreader/tests/test_excel_parser.py |
单元测试 | ExcelImageFilterTest — 覆盖所有已知 IMAGE 函数变体 |
问题的技术细节在于 Excel 文件格式和 WPS 行为的差异。正常的 Excel 公式(如 =SUM(A1:A10))存储在 cell 的公式属性中,openpyxl 用 data_only=True 打开时会读取缓存的计算结果而非公式本身,对于没有缓存结果的公式返回 None。但 WPS 嵌入图片时的做法不同——它把 DISPIMG("ID_xxx",1) 作为纯文本值(不是公式)写入 cell 的 value 属性。从 openpyxl 的视角看,这就是一个普通的文本字符串,data_only=True 对它完全没有影响。
3. 解决方案(How)
改动摘要:在 ExcelParser.parse_into_text() 的单元格遍历逻辑中,增加一个正则过滤步骤——检测到 IMAGE 函数模式的字符串直接跳过,不写入解析结果。
核心代码片段(docreader/parser/excel_parser.py):
import re
# 匹配 WPS/Excel 嵌入图片的函数字符串
# 覆盖变体:DISPIMG(...)、=DISPIMG(...)、_xlfn.DISPIMG(...)、=_xlfn.IMAGE(...)
_IMAGE_FUNC_RE = re.compile(r'^=?(_xlfn\.)?(DISPIMG|IMAGE)\(', re.IGNORECASE)
# 在 parse_into_text() 的 cell 遍历循环中:
for cell in row:
value = cell.value
if value is None:
continue
text = str(value).strip()
if not text:
continue
# 新增:过滤图片函数字符串
if _IMAGE_FUNC_RE.match(text):
continue
# ... 正常处理 text ...
正则 ^=?(_xlfn\.)?(DISPIMG|IMAGE)\( 的设计逻辑:
^— 锚定字符串开头,避免误匹配包含”DISPIMG”子串的正常文本=?— 可选的等号前缀,因为有些变体以=开头、有些不以=开头(_xlfn\.)?— 可选的_xlfn.命名空间前缀,这是 Excel 内部函数的标准前缀(DISPIMG|IMAGE)— 两种已知的图片函数名\(— 必须紧跟左括号,确保不会误匹配一个恰好叫”DISPIMG”的普通单词
为什么这样改而不是别的方式?至少有两个备选方案被排除:
第一个备选是在 chunker 层过滤。但 chunker 处理的是已经拼接好的文本块,此时 DISPIMG(...) 已经混在正常文本中间,正则匹配的边界更模糊,误过滤风险更高。在最上游(parser 层)过滤是”垃圾进垃圾出”原则的直接应用——不让噪声进入管线,比在管线中间捞噪声更可靠。
第二个备选是改用 openpyxl 的其他模式读取。但 data_only=True 已经是读取纯值的标准方式,WPS 的行为属于”合规但非预期”的边界情况,不是 openpyxl 能解决的问题。
测试覆盖:新增 ExcelImageFilterTest 类,包含 4 个测试用例:
| 测试用例 | 输入 | 预期行为 |
|---|---|---|
test_dispimg |
DISPIMG("ID_xxx",1) |
被过滤 |
test_equals_dispimg |
=DISPIMG("ID_xxx",1) |
被过滤 |
test_xlfn_dispimg |
_xlfn.DISPIMG("ID_xxx",1) |
被过滤 |
test_xlfn_image |
=_xlfn.IMAGE("https://...") |
被过滤 |
测试用例覆盖了目前已知的所有 WPS 和 Excel 图片函数变体。
4. Review 与合入(Maintainer 视角)
这个 PR 没有收到 review 评论,直接合入。
为什么会直接合入?从 PR 本身的特征可以推断几个原因:
第一,改动范围高度收敛。只改了 1 个解析文件 + 1 个测试文件,不涉及任何跨模块交互,review 者能在 5 分钟内完全理解改动的影响面。
第二,测试覆盖充分。4 个测试用例覆盖了所有已知变体,且测试逻辑直观(输入一个字符串,断言它被过滤),reviewer 不需要猜测”这个修复是不是真的有效”。
第三,PR 描述自带根因分析。PR description 明确说明了”WPS 以纯文本值存储图片函数 → openpyxl data_only=True 不生效 → 噪声泄入”的因果链,reviewer 不需要自己去挖掘 why。
从这个案例可以提炼出几条 WeKnora 贡献规范:
- PR 描述必须包含根因链,不只是”修复了什么”,而是”为什么会出问题 → 怎么修的 → 为什么这样修”。这是 reviewer 快速判断合理性的关键信息。
- 改动范围越小、测试越充分,合入越快。维护者的审核带宽有限,一个 2 文件 +111 行的 PR 比一个 11 文件 +437 行的 PR 更容易被优先处理。
- 正则类修复需要穷举已知变体作为测试用例。正则的”漏网之鱼”是最常见的回归 bug 来源,测试用例就是你对”边界”的承诺。
5. 可复用模式(Pattern)
- 如果你在 docreader 中发现某种文件格式的特殊编码导致无意义字符串泄入解析结果,就在 parser 层用正则过滤,而不是在 chunker 或检索层打补丁。
- 如果你的正则要匹配多个变体(带前缀/不带前缀/带等号/不带等号),就用可选组
(prefix)?而不是写多个独立正则,并为每个变体写一个测试用例。 - 如果你修复的是”某工具的非预期但合规行为”(比如 WPS 的图片存储方式),就在 PR 描述中解释清楚”为什么上游工具不处理这个”,帮助 reviewer 理解问题边界。
6. 常见误区
错误认知:openpyxl 的 data_only=True 会过滤掉所有非数据内容。
正确理解:data_only=True 只影响真正的公式 cell——它读取公式的缓存计算结果而非公式文本。但 WPS 的图片函数字符串是以纯文本值存储的,不是公式,data_only 对它无效。类比:data_only 像一个只认”公式格式”的安检员,WPS 把违禁品伪装成了普通行李,安检员放行了。
错误认知:噪声过滤应该放在 chunker 或 Rerank 层,让上游保持”忠实原文”。
正确理解:RAG 管线的设计原则是”向上游要质量”(GIGO)。DISPIMG("ID_xxx",1) 不是文档的”原始内容”,而是特定软件的实现细节——用户写在 Excel 里的是一张图片,不是这个函数字符串。在 parser 层过滤掉它,恰恰是在还原用户的真实意图。
错误认知:正则匹配用 search 和用 match 效果一样。
正确理解:re.match 只匹配字符串开头,re.search 在整个字符串中搜索。这里用 match(加上 ^ 锚定)是有意为之——一个正常的 cell 内容可能在中间包含 “DISPIMG” 这个子串(虽然概率极低),用 match 可以避免误过滤。
错误认知:WPS 与 Microsoft Excel 的 cell 存储行为完全一致。
正确理解:WPS 将图片嵌入存为 纯文本函数字符串,不走 openpyxl 的公式解析路径;修复必须 针对 WPS 变体 写正则与单测,不能假设 data_only=True 或标准 Excel 行为。
7. 思考点
-
如果 WPS 未来新增了另一种图片嵌入方式(比如用
XLIMAGE(...)函数),现有正则会怎样?你会如何设计一个更具前瞻性的过滤策略? -
画出一个 Excel 文件从上传到最终出现在 AI 回答中的完整数据流图(提示:至少经过 docreader → chunker → 向量化 → 检索 → LLM 生成 5 个阶段)。在每个阶段标注:这个噪声在该阶段有没有机会被拦截?代价是什么?
-
假设你不改 parser 层的代码,而是在 Rerank 阶段加一个”噪声检测 Rerank 信号”——对包含 DISPIMG 模式的 chunk 降权。这种方案的优缺点是什么?在什么场景下它可能比 parser 层过滤更合适?
-
这个 PR 的正则用了
re.IGNORECASE标志。思考:Excel 函数名是否大小写敏感?如果去掉这个标志,会不会有遗漏的变体? -
如果 WeKnora 的 docreader 将来迁移到用 LLM 做 cell 内容理解(而非规则过滤),这个正则过滤还有存在的必要吗?LLM 方案的 trade-off 是什么?
8. 延伸阅读
- WeKnora 贡献指南(犀牛鸟竞赛导向) — 评审标准、PR 写作规范、贡献策略的完整参考
- Ch05: 文档解析与分块——输入质量决定一切 — “垃圾进垃圾出”原则的理论基础,以及各 RAG 系统的解析能力对比
- 精读: WeKnora — WeKnora 完整架构(EventManager / Plugin 管线 / 三路检索),理解 docreader 在全局中的位置
作者视角
这是我提交给 WeKnora 的第一个被合入的 PR。回过头看,从 issue 到 PR 的过程可以拆成几个阶段。
发现问题:在浏览 WeKnora 的 open issues 时看到 #1779,issue 描述很清晰——截图直接展示了 DISPIMG("ID_xxx",1) 出现在解析结果中。这类”有截图、能复现、根因可推断”的 issue 是新贡献者的理想起点。
定位根因:从现象倒推数据流——AI 回答中出现了这个字符串,说明它一定经过了 解析 → 分块 → 向量化 → 检索 → 生成 的完整管线。那么它最早是在哪一步进入管线的?答案显然在 parser 层。打开 excel_parser.py,找到 parse_into_text() 方法,确认它对 cell.value 没有做任何过滤——只要不是 None 就写入结果。接下来搜索 “DISPIMG” 的含义,确认这是 WPS 和 Excel 的图片嵌入函数。再用 openpyxl 写一个小脚本验证 data_only=True 对这类值的行为,确认它不会被过滤。根因链就清楚了。
设计方案:考虑过两个方向——在 parser 层过滤(改 excel_parser.py)还是在 chunker 层过滤。选择 parser 层的理由很直接:这些字符串根本不是文档内容,越早拦截越好。正则的设计花了一些时间——需要覆盖 DISPIMG、=DISPIMG、_xlfn.DISPIMG、=_xlfn.IMAGE 四种已知变体,同时不能误伤正常文本。最终用一个带可选组的正则搞定。
写测试:测试是这个 PR 中行数最多的部分(93 行 vs 代码改动 18 行)。每个已知变体一个测试用例,确保正则不遗漏。这种”测试比代码多”的比例在过滤类修复中是正常的——你对边界的承诺就体现在测试用例里。
与 #1248 的关联思考:#1785 和 #1248 表面上看领域不同(一个是 Excel 解析,一个是问答反馈),但它们同属”边界/归因”这个问题类型。#1785 要回答”这个 cell 的内容是有效知识还是噪声?”,#1248 要回答”这个知识库片段对用户的回答有帮助还是没帮助?”两者都需要在管线的某个节点画一条清晰的线——线内的保留,线外的丢弃或降权。这种”在数据流中做精确归因”的思维模式,是 RAG 系统贡献中反复出现的核心能力。
对新贡献者的建议:
- 优先选有截图、能复现的 bug issue 作为第一个 PR。”能复现”意味着你能验证修复,”有截图”意味着根因通常不会太深。
- PR 描述比代码更重要。维护者的时间有限,一个自带根因分析的 PR 描述能让 reviewer 在 5 分钟内决定合入,而一个只写”fix #1779”的 PR 可能在 review 队列里排一周。
- 测试用例是你和维护者之间的契约。它说明了”我认为这个修复覆盖了这些边界情况”,维护者可以据此判断你的理解是否准确。