模态处理器:把图 / 表 / 公式翻译成「带实体的文字」
本章讲
modalprocessors.py:四个处理器是怎么工作的,以及它们最脏最关键的活——把 LLM 吐的自由文本可靠地解析成结构化结果。
这一节讲什么
先看四个处理器的共同骨架(都继承自一个基类,只换 prompt),再深入两个真正难的点:从 LLM 响应里抠出 JSON 的多级容错,和 reasoning 模型的 <think> 标签污染。
4.1 四个处理器,一个基类
所有处理器继承 BaseModalProcessor(modalprocessors.py:366)。差异极小——它们只是换了 prompt 和实体类型:
| 处理器 | 用哪个模型 | 默认 prompt | 默认 entity_type |
|---|---|---|---|
ImageModalProcessor | VLM(vision_model_func) | vision_prompt | image |
TableModalProcessor | LLM | table_prompt | table |
EquationModalProcessor | LLM | equation_prompt | equation |
GenericModalProcessor | LLM | generic_prompt | 传入的 content_type |
选哪个处理器由 get_processor_for_type(utils.py:422)按 type 字段路由,未知类型一律落到 generic。处理器在 RAGAnything._initialize_processors(raganything.py:204)里按配置开关创建——注意图片处理器优先用 vision_model_func,没有才退回 llm_model_func。
每个处理器只需实现两个方法:
generate_description_only:翻译,返回(描述, 实体信息)。批处理用它。process_multimodal_content:翻译 + 入库一体。逐个兜底时用它。
4.2 一次翻译长什么样(以图片为例)
ImageModalProcessor.generate_description_only(modalprocessors.py:860)的核心步骤:
# 示意,浓缩自 modalprocessors.py:880-955
image_path = content_data.get("img_path")
context = self._get_context_for_item(item_info) # 取周边文字做上下文
vision_prompt = PROMPTS["vision_prompt_with_context"].format( # 有上下文就用带上下文版
context=context, section_path=..., image_path=image_path, captions=..., ...)
image_base64 = self._encode_image_to_base64(image_path) # 图片转 base64
response = await self.modal_caption_func( # 调 VLM
vision_prompt, image_data=image_base64,
system_prompt=PROMPTS["IMAGE_ANALYSIS_SYSTEM"])
enhanced_caption, entity_info = self._parse_response(response, entity_name) # 解析
重点看 prompt 怎么要求结构化输出。 vision_prompt(prompt.py:85)明确要 VLM 返回一段 JSON,含两个字段:detailed_description(详细描述)和 entity_info(实体名/类型/摘要)。表和公式的 prompt 结构同理。换句话说,翻译的同时也让 LLM 顺手做了 实体抽取的第一步。
prompt 里还藏了一条经验:"do not return file names or figure numbers such as figure_30_1 unless they are the actual title"——逼模型给图起个语义化的名字(如「实验结果对比图」),而不是 figure_30_1,因为这个名字会变成图谱里的实体节点名。
4.3 难点:从 LLM 自由文本里可靠抠出 JSON
LLM 不一定听话——可能把 JSON 包在 ```json 代码块里、可能加前言、可能用智能引号、可能漏逗号。_robust_json_parse(modalprocessors.py:577)用四级降级对付:
怎么读这张图
从上到下是尝试顺序,任一级成功就返回;全失败才到最后的正则兜底。
候选提取 _extract_all_json_candidates:
先剥 <think>/<thinking> 标签
再用三种方式找 JSON 片段:① ```json 代码块 ② 配平的大括号 ③ 贪婪 {.*}
│
▼
级别 1 直接 json.loads 每个候选
│ 失败
▼
级别 2 基础清理(智能引号→直引号、去尾逗号)后再 loads
│ 失败
▼
级别 3 渐进式修转义(给 \alpha 这种裸反斜杠补转义)后再 loads
│ 失败
▼
级别 4 正则直接抠 detailed_description / entity_name / entity_type / summary 字段
级别 4 是最后防线 _extract_fields_with_regex(modalprocessors.py:687):哪怕整段不是合法 JSON,也用正则把几个关键字段单独捞出来,保证流程不中断。
为什么级别 3 专门修反斜杠? 因为公式场景里 LaTeX 满是 \frac、\alpha,这些裸反斜杠在 JSON 字符串里是非法转义,json.loads 会直接报错。_progressive_quote_fix(modalprocessors.py:672)把 \ 后跟字母的情况补成 \\。这是被公式数据逼出来的针对性补丁。
4.4 难点:reasoning 模型的 标签
DeepSeek-R1、Qwen-think 这类模型会先吐一段 <think>…</think> 的思维链再给答案。如果 JSON 解析失败、直接拿原始响应当描述存进图谱,就会把模型的「内心独白」也存进去,污染知识库。
_strip_thinking_tags(modalprocessors.py:553)用正则把 <think> 和 <thinking> 块整段删掉,只留最终答案。这个清理在两处发生:候选提取前(_extract_all_json_candidates,modalprocessors.py:611)和解析彻底失败的兜底分支里(如 _parse_response,modalprocessors.py:1065)。
4.5 实体名的小处理
解析成功后(如 _parse_response,modalprocessors.py:1054),实体名会被加上类型后缀:
# 示意,见 modalprocessors.py:1054
entity_data["entity_name"] = entity_data["entity_name"] + f" ({entity_data['entity_type']})"
# 例:"实验结果对比图" → "实验结果对比图 (image)"
好处:在知识图谱里一眼能区分「这个节点是图/ 表/公式」,也降低不同模态间同名实体撞车的概率。这个带后缀的名字就是 01-pipeline.md 里说的「模态主实体」名。
4.6 入库:_create_entity_and_chunk
逐个处理路径走 _create_entity_and_chunk(modalprocessors.py:471):建 chunk → 写 text_chunks + chunks_vdb → 建实体节点 → 写 entities_vdb → 调 _process_chunk_for_extraction 抽取实体关系并补 belongs_to。批处理路径则把这些拆成 01-pipeline.md 的 Stage 2-6 分别批量做。两条路最终都落到 LightRAG 的同一套存储。
关键细节 / 坑
- 每个方法都有 fallback 实体:翻译或解析全挂时,处理器不抛异常,而是造一个
f"image_{hash}"之类的兜底实体把流程接住(如modalprocessors.py:959)。宁可降级也不让一张坏图毁掉整篇文档的摄入。 - 表格内容的别名兼容:不同解析器给表的字段名不一(
table_bodyvstable_data),get_table_body(utils.py:27)按优先级兜住;format_table_body(utils.py:36)还能把「行列二维数组」渲染成 markdown 表再喂给 LLM。 - 公式不把描述拼进公式体:
get_equation_text_and_format(utils.py:63)刻意只取公式本体,描述走模板里单独的enhanced_caption槽,避免污染公式文本。