# Parser 层 Go ↔ Python 一致性对齐 Handoff 文档 > 用途:多会话并行处理「Go 与 Python 在 Parser 层是否对同一文件产出相同结构」的核对与收敛工作。 > 本文档自包含,新会话只需读本文即可独立开工,无需回看原对话。 > 范围:**排除 PDF 解析器**(PDF 走独立原生后端 `internal/deepdoc/parser/` + `pdf_vision_dispatch.go`,不在此 scope)。 --- ## 0. 目标与验收标准 **目标**:确认 DSL `File → Parser → Chunker → Extractor → Tokenizer` 中「Parser」这一独立阶段,在 Go 与 Python 两端对相同输入文件是否产出**相同结构**。 **验收标准("相同结构"的定义)**:对同一份输入文件, - 两端 Parser 阶段的输出**条目数量(item 数)应一致**; - 每条目的**关键字段与取值应一致**(至少 `text` / `doc_type_kwd` 一致;若带 `ck_type` / `image` 等,约定以某一端为准对齐); - 文本切分边界(段落 / 块 / 表格)应一致。 **重要**:这里的「Parser 阶段输出」指 - Go:`ParseResultProducer.ParseWithResult(...)` 返回的 `ParseResult`(字段 `JSON`/`Markdown`/`Text`/`HTML` + `File`); - Python(flow):`rag/flow/parser/parser.py` 里 `Parser._invoke` → `set_output` 产出的 `sections` 列表(每项形如 `{text, doc_type_kwd, image?, ck_type?}`)。 --- ## 1. 关键定位纠正(务必先读) **正确的 Python 对应物是 `rag/flow/parser/parser.py`,不是 `deepdoc/parser/*` + `rag/app/naive.py`。** - 原因:你给的 DSL 是**分阶段**的,Go 端 `internal/parser/parser/*` 的移植注释明确引用 `rag/flow/parser/parser.py:_invoke` 与 `set_output`(`internal/ingestion/component/parser.go:392` 注释)。Go 的 `ParseResult` 契约移植自 `port-rag-flow-pipeline-to-go.md §4.2/§6.5`。 - `deepdoc/parser/*` + `rag/app/naive.py` 是 **naive 摄取路径**,它把 parser+chunker+tokenizer **融合**在一个 `chunk()` 里,**不是**独立 Parser 阶段的对应物。但注意:**flow 解析器内部又委托了 naive 解析器**(`_markdown`→`naive.Markdown`,`_html`→`HtmlParser`,`_docx`→`naive.Docx`),所以 naive 解析器是底层实现,阶段边界仍以 `rag/flow/parser/parser.py` 为对齐面。 **架构根因(必须在任何收敛方案里承认)**: - Go 把 Parser 当作**干净的「未切块」阶段**:`ParseResult` → 重塑成 `schema.Page`(`internal/ingestion/component/parser_dispatch.go` 的 `jsonItemsToPages`/`buildParserOutputs`)→ 交给下游 Chunker 才做 token 切块。 - Python flow 解析器复用了 naive 解析器,而 naive 解析器(html/txt/epub 尤其)**在 parser 内部就做了 token 切块**。因此即便输出 key 名一致,parser 阶段的粒度已经不同。 --- ## 2. 类型 ↔ 实现映射与一致性总览 | 类型 | Go 实现 | Python(flow) 实现 | 默认输出格式 | 一致性 | 风险等级 | |---|---|---|---|---|---| | markdown | `internal/parser/parser/markdown_parser.go` | `rag/flow/parser/parser.py:_markdown` → `naive.Markdown` | json | ❌ 不一致 | 高 | | html | `internal/parser/parser/html_parser.go` | `rag/flow/parser/parser.py:_html` → `HtmlParser` | json | ❌ 不一致 | 高 | | text&code | `internal/parser/parser/text_parser.go` | `rag/flow/parser/parser.py:_code` → `TxtParser` | json | ❌ 不一致 | 高 | | docx | `internal/parser/parser/docx_parser.go`(office_oxide) | `parser.py:_docx` → `naive.Docx`(python-docx) | json | ⚠️ 结构类似,引擎不同 | 中 | | xlsx/xls/csv | `xlsx_parser.go` / `xls_parser.go` / `csv_parser.go` | `parser.py:_spreadsheet` → `ExcelParser` | html | ⚠️ 默认 html,待细核 | 中 | | pptx/ppt | `parser/pptx_parser.go`(office_oxide) | `parser.py:_slides` → `RAGFlowPptParser` | json | ⚠️ 引擎不同 | 中 | | epub | `parser/epub_parser.go` | `parser.py:_epub` → `EpubParser` | json | ✅ 基本一致 | 低 | | email | `parser/email_parser.go` | `parser.py:_email` | json/text | ✅ 基本一致 | 低 | | doc | `parser/doc_parser.go`(office_oxide) | `parser.py:_doc`(tika) | json | ❌ 格式不同 | 中 | | json | `parser/json_parser.go` | **flow 无对应分支** | json | — Go 独有 | — | --- ## 2.1 Go 端 Parser 的 item 模型(对拍基础) 对拍时反复提到的「item」在 Go 端有精确定义,先统一术语,避免与下游 `schema.Page` 混淆。 ### item 是什么 - **item = `ParseResult.JSON` 里的一个 `map[string]any` 元素**(`internal/parser/parser/parse_result.go:48`),代表从文件解析出的**一个逻辑单元**(块 / 段落 / 表格 / 图片 / 行 / 幻灯片…)。 - 关键字段: - `text`:该单元文本(表格单元里是 HTML 字符串) - `doc_type_kwd`:类型,取值 `text` / `image` / `table` - 可选 `ck_type`:更细分类(heading/paragraph/list/code/quote/table/image) - 可选 `image`:base64 图片;`page_number`:页码 - **仅当 `OutputFormat=="json"` 时才存在 item 列表**。markdown/text/html 输出格式的解析器产出的是**单个字符串**(`ParseResult.Markdown`/`Text`/`HTML`),reshape 后变成 **1 个 `schema.Page`**,**没有 JSON item**。 - 下游 dispatch(`internal/ingestion/component/parser_dispatch.go`):`OutputFormat=="json"` → 每个 item 一个 page(`jsonItemsToPages`);`markdown/text/html` → 整段作 1 个 page;未知 → 按 `\f` 切页。所以「item 数」对比**只对 json 输出格式有意义**。 ### item 数量(因格式与输入而异,无固定值) | 格式 | OutputFormat | item 数量 | |---|---|---| | markdown | json | = 顶层块数;**整篇含表时塌缩成 1 个**(`markdown_parser.go:99-107`);空输入补 1 个占位 | | html | json | = 有文本的块级元素数(`walkHTMLBlocks` 每块 1 项) | | text&code | json | = 非空段数(按 `\n\n` 切;超 8192B 就近再切) | | docx | json | = section/element 数(段落/标题/图/表各自成项) | | xlsx/xls/csv | **默认 html** | **0 个 JSON item**(单个 HTML 字符串);走 json 路径才按行成项 | | pptx/ppt | json | = 幻灯片数(按 `\f` 分页) | | epub | json | = spine 条目数 | | email | json/text | json 时 **1 个**(单个字典项,含 from/to/subject/body…) | | json | json | = 逻辑记录数(数组每元素 / 对象 / JSONL 每行) | ### item 类型(按 `doc_type_kwd`,共 3 类;全 Go 解析器 JSON 输出一致) 1. **`text`** —— 绝大多数:段落、标题、列表、代码块、单元格文本、行文本、幻灯片文本、email 正文等。docx 标题项额外带 `ck_type:"heading"`。 2. **`image`** —— 图片,必带 `image`(base64)。来源:markdown 的 `` 解析(`markdown_parser.go:287-292`,含 HTTP 抓取+SSRF 防护);docx 内嵌图。 3. **`table`** —— 表格,`text` 字段是 HTML 表格字符串。来源:docx 表格(`docx_ir.go`)、markdown 内联表(未整篇塌缩时)。 - 更细一层的 `ck_type` 词汇(仅 markdown 等部分解析器输出):`heading` / `paragraph` / `list` / `code` / `quote` / `table` / `image`。**Python flow 的 `set_output` 不带 `ck_type`**,是字段契约差异点(见 §3.1)。 ### 对照 Python(可比对维度) - Python flow `set_output` 的 json 路径产出同形状 `[{text, doc_type_kwd, image?, ...}]`,因此「**item 数 + 每 item 的 `text`/`doc_type_kwd`**」是两端可直接对拍的维度(即 §5 逐项/计数比对层)。 - 例外:xlsx/csv 在 Go 默认 html(0 个 json item),Python `_spreadsheet` 默认也是 html(`ExcelParser().html`)→ 两端都是单字符串,item 维度均为 0,比对时按 HTML 字符串走。 --- ## 2.2 Schema 模型(契约信封 / 数据结构) §2.1 描述的是 item(JSON 列表里的元素)。本节描述「装着 item 的信封」与组件间契约——即 Parser 输入输出、配置、以及 Parser→Chunker 的桥接结构。两端刻意镜像(Go `schema/parser.go` 注释多处写明 "mirrors rag/flow/parser/schema.py")。 ### Go 端 - **`ParseResult`**(`internal/parser/parser/parse_result.go:48`):解析器统一返回契约。 - 字段:`OutputFormat`(json/markdown/text/html)、`File`(map)、`JSON`(`[]map[string]any`)、`Markdown`/`Text`/`HTML`(string)、`Err`。 - 仅 `OutputFormat=="json"` 时 `JSON` 填充(即 §2.1 的 item 列表)。 - **`schema.Page`**(`internal/ingestion/component/schema/parser.go:58`):`Page = map[string]any`,Parser 把 `JSON` 重塑后交给下游 Chunker 的载体(json→每 item 一 page)。注释明确:它故意保持 Python `dict` 形态,字段形如 `{text, doc_type_kwd, ck_type?, image?, page_number?, positions?}`(`parser.go:52-57`)。 - **`ParserFromUpstream`**(`parser.go:29-41`):Parser 组件输入(name/file/abstract/author/created_time/elapsed_time)。注释写明 mirrors Python `ParserFromUpstream`。 - **`ParserOutputs`**(`parser.go:110-136`):Parser 组件输出契约。字段 `output_format` + 四选一(`json`/`markdown`/`text`/`html`)+ `file` + `_ERROR`。**直接镜像 Python `set_output` 的写入面**。 - **`ParserSetup` / `ParserParam`**(`parser.go:64-94`):每文件类型的配置块 + 静态配置(含 `AllowedOutputFormat` 白名单)。`Defaults()` 注释写明**逐字复制自 Python `ParserParam.__init__`**。 - **`ChunkDoc`**(下游,`internal/ingestion/component/schema/chunk_types.go`):Chunker 产出结构(text/content_with_weight/doc_type_kwd/ck_type/image/page_number/positions…)。属 Parser 之后的阶段,仅作上下文,不在本 scope。 ### Python 端(flow) - **`ParserFromUpstream`**(`rag/flow/parser/schema.py:18-26`,Pydantic):输入契约。字段与 Go 完全一致(created_time/elapsed_time/name/file/abstract/author,`populate_by_name`、`extra="forbid"`)。 - **Parser 输出面**(`rag/flow/parser/parser.py` 内 `set_output`):无单一命名 model,但写入键固定为 `output_format` + 四选一(`json`/`text`/`markdown`/`html`)+ `file` + `_ERROR` → **与 Go `ParserOutputs` 一一对应**。json 路径写 `set_output("json", [...])`(即 §2.1 的 item 列表)。 - **`TokenChunkerFromUpstream`**(`rag/flow/chunker/schema.py:20-35`,Pydantic):**Parser→Chunker 的桥接契约**,Chunker 消费它。 - 字段:`output_format`(json/markdown/text/html/chunks)、`json_result`(别名 `json`)、`markdown_result`/`text_result`/`html_result`、`chunks`、`name`/`file`/`created_time`/`elapsed_time`。 - 即 Chunker 读 `from_upstream.output_format` 与 `from_upstream.json_result`(见 `token_chunker.py:313-345`)。这正是 Go `ParseResult.JSON`/`schema.Page` 在 Python 侧的对应物。 - Chunker 输出:`TokenChunker.set_output("chunks", [{"text", "doc_type_kwd", ...}])`(`token_chunker.py:440` `_finalize_json_chunks`)→ 对应 Go 的 `ChunkDoc`。 ### 跨端结构映射表 | 概念 | Go | Python(flow) | |---|---|---| | 解析器返回 | `ParseResult`(`parse_result.go:48`) | (隐式)`set_output` 写入面 | | 组件输入 | `ParserFromUpstream`(`schema/parser.go:29`) | `ParserFromUpstream`(`parser/schema.py:18`) | | 组件输出 | `ParserOutputs`(`schema/parser.go:110`) | `set_output`:`output_format`+`json/text/markdown/html`+`file`+`_ERROR` | | item 载体 / 页 | `schema.Page` = `map[string]any`(`schema/parser.go:58`) | `TokenChunkerFromUpstream.json_result`(`chunker/schema.py:30`) | | 配置块 | `ParserSetup` / `ParserParam`(`schema/parser.go:64-94`) | `ParserParam.setups` / `allowed_output_format`(`parser.py:71-...`) | | Chunker 输入 | `schema.Page` 列表 | `TokenChunkerFromUpstream`(`chunker/schema.py:20`) | | Chunker 输出 | `ChunkDoc`(`chunk_types.go`) | `chunks: [{"text","doc_type_kwd",...}]`(`token_chunker.py:440`) | **对齐要点**:结构信封两端已高度镜像(`ParserFromUpstream`/`ParserOutputs`/`ParserParam` 是逐字对应),所以「对拍」的重点不在信封,而在信封内的 **item 内容/粒度/字段**(§2.1 + §3)。唯一需留意的是 `TokenChunkerFromUpstream` 还有 `chunks` 字段——当上游已是 chunks 时 Chunker 直接透传,这是 Python 在 Parser 之后、Chunker 之前可能已被预切块的另一证据(关联 §3.2 的切块归属讨论)。 --- ## 2.3 已确认的对齐决策(全局,2026-08-07) 以下为已拍板、跨类型生效的决策,各并行会话直接遵循,无需再议: 1. **切块所有权:保留现状**。不做 pipeline 重构——Go Parser 仍不切块、Python flow Parser 仍内嵌 token 切块(html/epub 等)。不在「把切块挪到 Chunker 统一」上做改动。 2. **对比方法:采用拼接对比(stitch-compare)**。以「拼接后内容等价」作为一级闸门(见 §5),item 数 / 字段比对作为二级。验收重点是「两端 Parser 提取出相同内容」,而非 item 边界一致。 3. **块抽取粒度基准:以 Python 为准**。逐块边界(段落 / 块 / 表 / 列表续行等)的对齐目标是让 **Go 收敛到 Python 的块边界**;Python 现状视为事实基准。 4. **FIXME 保留不动**。`deepdoc/parser/html_parser.py:236` 处 `rag_tokenizer.tokenize` 身份未确认,暂不调查。 5. **doc / docx / pptx / xlsx 引擎差异:保留(接受差异)**。Go 用 `office_oxide`、Python 用 python-docx / pptx / excelize / tika,文本与分段发散属可接受分歧,记录原因即可。 6. **json:Python flow 不补分支**。Go `json_parser.go` 为 Go 独有,Python 侧声明 flow 不支持 `.json`,接受此不对称。 --- ## 3. 已核实的具体分歧(带 file:line,供新会话直接验证) ### 3.1 Markdown(最严重) - **表格塌缩**:Go `markdown_parser.go:99-107` 中 `renderMarkdownTablesInline` 只要文档出现**任意一张 GFM 表格**,就把**整篇**重写为单个 item `{"text": rendered, "doc_type_kwd":"text"}`(函数见 `:136-177`,`changed` 为真即整篇返回)。Python `separate_tables=False`(`parser.py:1080`)只把表格内联成 HTML 块,但**仍按块切成多个 section**。→ 同输入,Go 可能 1 个大 item,Python 是 N 个。 - **ck_type 字段**:Go 输出带 `ck_type`(heading/paragraph/list/code,`markdown_parser.go:278-280`);Python `_markdown` 的 `set_output` 输出**不含** `ck_type`(`parser.py:1089-1101`)。→ 字段形状不同。 - **delimiter 切分**:Go markdown 解析器**不做** delimiter 切分;Python 把 `conf["delimiter"]` 传入 naive 解析器做二次切分(`parser.py:1081`,底层 `deepdoc/parser/markdown_parser.py:167-174, 305-345`)。→ 配置 delimiter 时行为不同。 - 图片:Go `resolveMarkdownImage` 把 `` 内联成 base64 + `doc_type_kwd:"image"`(`:287-292`,含 HTTP 抓取与 SSRF 防护 `:427-457`);Python 走 `return_section_images=True` + vision 增强(`parser.py:1081-1088` 附近),机制不同但目标类似。 ### 3.2 HTML(粒度本质不同) - **切块时机**:Go `html_parser.go` 的 `walkHTMLBlocks`(`:109-136`)每个块级元素产 1 个 item,**不切块**。Python `_html` 调 `HtmlParser()(name, blob, int(conf.get("chunk_token_num", 512)))`(`parser.py:1154`,阈值来自 `self._param.setups["html"]["chunk_token_num"]`,**配置驱动、默认 512**;html 族默认 setup `parser.py:188-193` 未设该 key,默认部署下即 512)。`HtmlParser.chunk_block` 用 `rag_tokenizer.tokenize` 的真实 token 数累加、超阈值即切(`deepdoc/parser/html_parser.py:232-237, 287-313`)。→ Go 是原始块,Python 已切成 token 块。注意 `text&code` 的同类阈值默认是 128(`parser.py:1135`),两族默认值不同。 - **表格表示**:Go 把单元格折叠进文本并标 `ck_type:"table"`(`html_parser.go:169-174`);Python 把 `