first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
陈辅元
2026-07-16 11:12:17 +08:00
co-authored by Cursor
commit 4003624b8c
80 changed files with 9990 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.venv/
venv/
.env
.env.*
# Runtime / generated
storage/uploads/
storage/results/
storage/assets/
output/
# IDE / local agent
.idea/
.vscode/
.claude/
.learnings/
# OS
Thumbs.db
Desktop.ini
.DS_Store
+48
View File
@@ -0,0 +1,48 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project status
This is a **greenfield project** — as of 2026-07-06 there is no code, no build system, and no git history yet. The only artifact is the requirements document `docx/自研搭建AI助手知识库.pdf` ("Self-built AI Assistant Knowledge Base"). Treat this file as the source of truth for requirements until code supersedes it.
## What this project is
A self-hosted **AI assistant knowledge base demo** (RAG system) whose primary goal is **high-quality document chunking/slicing**; recall quality is secondary. The driving problem: an existing knowledge-base feature ("AFE") slices poorly — it drops images and tables from word/pdf/excel documents, and its rules forcibly split a single passage across separate chunks. This project rebuilds the ingestion/slicing pipeline and exposes a demo UI to inspect the results.
## Requirements (from the PDF)
### Demo capabilities
- Upload files and **view the chunking/slicing result**.
- **Test recall** and inspect recall input parameters and return parameters.
### Supported document types
- **Office docs:** pdf, doc, docx, ppt, pptx, wps, ppsx
- **Tabular / structured:** xlsx, xls, csv, md, txt, html, json, xml, log
- **Images:** jpg, png, jpeg, bmp, gif
### Slicing rules
- **Table documents:** default split **or** split-by-row.
- **Other documents:** default split **or** universal-identifier split.
- **Tables inside a slice:** rendered as **Markdown**.
### Expected effects — basic
1. Images stay in their original position in the rendered text — not lost or relocated to another chunk.
2. Table content is extracted correctly and converted to Markdown.
3. Slicing is structurally aware: for Word, all content under the same heading stays within one chunk.
### Expected effects — advanced
1. Images/graphics are extracted as images **and** their text is OCR-recognized and converted to text.
2. PDF text laid out in horizontal columns is read back in the correct reading order (not jumbled).
## Environment notes
- **OS:** Windows 11. Shell is **bash** (Git Bash / MSYS2) — use Unix syntax (`/dev/null`, forward slashes), not PowerShell/CMD.
- **Python:** available at `/d/conda/python` (a conda environment). Relevant libraries **already installed** and useful for this project: `PyMuPDF` (fitz), `pdfplumber`, `pdfminer.six`, `pypdf`/`PyPDF2`, `pypdfium2`, `pikepdf`, `pdf2image`.
- **Reading the requirements PDF:** the file is image-heavy. `pdftotext -enc UTF-8` (at `/mingw64/bin/pdftotext`) extracts the little body text present but **misses the embedded diagrams** that the PDF references with "如下图" ("as shown below"). `pdftoppm` (image rendering) is **not** installed, so to view the diagrams use PyMuPDF from Python instead, e.g. `fitz.open(path)[page].get_pixmap()`.
- **Not a git repository.** Do not assume `git` workflows; initialize one only if asked.
## Working in this repo
- Before adding a chunking/slicing behavior, re-check the PDF's "期望效果" section above — the heading-awareness rule for Word and the image-position-preservation rule are the two most likely to be violated by naive splitters.
- The folder is named "RAG-cut" — chunking quality is the headline deliverable, recall is secondary. Prioritize ingestion/parsing fidelity over retrieval sophistication.
+48
View File
@@ -0,0 +1,48 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project status
This is a **greenfield project** — as of 2026-07-06 there is no code, no build system, and no git history yet. The only artifact is the requirements document `docx/自研搭建AI助手知识库.pdf` ("Self-built AI Assistant Knowledge Base"). Treat this file as the source of truth for requirements until code supersedes it.
## What this project is
A self-hosted **AI assistant knowledge base demo** (RAG system) whose primary goal is **high-quality document chunking/slicing**; recall quality is secondary. The driving problem: an existing knowledge-base feature ("AFE") slices poorly — it drops images and tables from word/pdf/excel documents, and its rules forcibly split a single passage across separate chunks. This project rebuilds the ingestion/slicing pipeline and exposes a demo UI to inspect the results.
## Requirements (from the PDF)
### Demo capabilities
- Upload files and **view the chunking/slicing result**.
- **Test recall** and inspect recall input parameters and return parameters.
### Supported document types
- **Office docs:** pdf, doc, docx, ppt, pptx, wps, ppsx
- **Tabular / structured:** xlsx, xls, csv, md, txt, html, json, xml, log
- **Images:** jpg, png, jpeg, bmp, gif
### Slicing rules
- **Table documents:** default split **or** split-by-row.
- **Other documents:** default split **or** universal-identifier split.
- **Tables inside a slice:** rendered as **Markdown**.
### Expected effects — basic
1. Images stay in their original position in the rendered text — not lost or relocated to another chunk.
2. Table content is extracted correctly and converted to Markdown.
3. Slicing is structurally aware: for Word, all content under the same heading stays within one chunk.
### Expected effects — advanced
1. Images/graphics are extracted as images **and** their text is OCR-recognized and converted to text.
2. PDF text laid out in horizontal columns is read back in the correct reading order (not jumbled).
## Environment notes
- **OS:** Windows 11. Shell is **bash** (Git Bash / MSYS2) — use Unix syntax (`/dev/null`, forward slashes), not PowerShell/CMD.
- **Python:** available at `/d/conda/python` (a conda environment). Relevant libraries **already installed** and useful for this project: `PyMuPDF` (fitz), `pdfplumber`, `pdfminer.six`, `pypdf`/`PyPDF2`, `pypdfium2`, `pikepdf`, `pdf2image`.
- **Reading the requirements PDF:** the file is image-heavy. `pdftotext -enc UTF-8` (at `/mingw64/bin/pdftotext`) extracts the little body text present but **misses the embedded diagrams** that the PDF references with "如下图" ("as shown below"). `pdftoppm` (image rendering) is **not** installed, so to view the diagrams use PyMuPDF from Python instead, e.g. `fitz.open(path)[page].get_pixmap()`.
- **Not a git repository.** Do not assume `git` workflows; initialize one only if asked.
## Working in this repo
- Before adding a chunking/slicing behavior, re-check the PDF's "期望效果" section above — the heading-awareness rule for Word and the image-position-preservation rule are the two most likely to be violated by naive splitters.
- The folder is named "RAG-cut" — chunking quality is the headline deliverable, recall is secondary. Prioritize ingestion/parsing fidelity over retrieval sophistication.
+31
View File
@@ -0,0 +1,31 @@
# Impact Analysis Report — 目录不解析、不切片
## 1. 改动概览
- **背景与目标**:所有文件跳过文档目录(目录 / Contents / TOC),不进入解析结果与切片。
- **涉及模块**:`noise_filter.py`、`pipeline.py`、`pdf_semantic.py`、`mineru_adapter.py`、相关测试、`README.md`
- **改动类型**:功能策略 / 缺陷修复(噪声过滤增强)
## 2. 方法级改动分析
| 位置 | 差异 |
| --- | --- |
| `is_toc_title_text` / `is_toc_entry_line` / `is_toc_noise_text` | 统一识别目录标题与带页码/引导线条目 |
| `filter_toc_blocks` | 删除目录页/目录区;保留无页码的正文内清单(如形態指標) |
| `filter_noise_blocks` | 串联 TOC 过滤 |
| `pipeline.chunk_document` | 全格式解析后强制 `filter_toc_blocks` |
| MinerU `_SKIP_MINERU_TYPES` | 增加 `toc` / `contents` 等类型 |
## 3. 调用方与影响范围
- PDF/Office(MinerU / PyMuPDF)及 md/txt 等所有走 `chunk_document` 的格式
- **破坏性变更:否**(目录内容不再出现在切片中,属预期)
## 4. 风险与回滚
- **风险级别**:低~中(极少数非目录但带引导线+页码的行可能被误删)
- **回滚方式是否简单:是**
## 5. 验证与测试
- 新增/更新噪声与语义切分单测;请对含目录的样本重新切分确认
+507
View File
@@ -0,0 +1,507 @@
# RAG-cut
自研 AI 助手知识库 Demo —— 以**高质量文档切片**为核心,召回为次要目标。
重建文档 ingestion / 切片流水线,解决现有 AFE 知识库的两类问题:
1. **文档解析不足**:Word / PDF / Excel 中的图片、表格无法有效提取
2. **切片规则不佳**:同一逻辑段落被硬切到不同 chunk,图片丢失或错位
需求来源:`[docx/自研搭建AI助手知识库.pdf](docx/自研搭建AI助手知识库.pdf)`
---
## 功能概览
| 能力 | 说明 |
| --------- | --------------------------------------------------------------------------------------------- |
| 多格式解析 | pdf / doc / docx / ppt / pptx / ppsx / xlsx / csv / md / txt / html / json / xml / log / 常见图片 |
| PDF 双引擎 | MinerU(优先)+ PyMuPDF 回退;统一噪声过滤 |
| 自动切分策略 | 前端可选自动,或手动指定 mode |
| 语义切分 | PDF / Word 走 `heading_layout_multimodal`;超长章节仅按长度/子标题拆分(不再产父片) |
| 父子标识符 | 独立模式 `parent_child`:父/子标识符双层切分,子片检索、父片上下文 |
| 表格切分 | 自动识别表头 / 说明行;Q&A 表按行 1 片;普通表按行数自适应 |
| Demo UI | 原文预览 · 切片列表 · Markdown 预览 · **历史切片回看** · BM25 召回测试 |
| CLI / API | `scripts/chunk_cli.py` · FastAPI `/api/chunk` · `/api/results` · `/api/recall` |
未支持:`.wps`(可先转为 docx/pdf)。独立图片暂不做 OCR。
---
## 技术架构
```
上传文件
│
▼
格式路由 (registry)
│
▼
解析器 (parsers) ──► 有序 Block 流(heading / paragraph / table / image …)
│ PDF:MinerU(优先)或 PyMuPDF → 噪声过滤 → 跨页表合并
▼
版面增强 (layout_meta) ──► 标题层级、图片/表格上下文、order_index
│
▼
切分策略 (split_policy / pdf_strategy)
│
▼
切分器 (splitters) ──► Block 分组(含父子切片关联)
│
▼
渲染器 (renderer) ──► Markdown Chunk + embedding_text / retrieval 标记
```
**核心设计**:先解析为不可拆分的 Block,再按结构切分。`image` / `table` 为原子块,不会被拆到不同 chunk。
### 目录结构
```
RAG-cut/
├── backend/
│ ├── rag_cut/
│ │ ├── models.py / pipeline.py / renderer.py / layout_meta.py
│ │ ├── split_policy.py / retrieval.py
│ │ ├── parsers/ # word / ppt / pdf / xlsx / text / image + MinerU
│ │ │ └── pdf/ # PyMuPDF 流水线、噪声过滤、跨页表合并
│ │ └── splitters/ # default / heading / pdf_semantic / by_row / delimiter
│ ├── api/main.py # FastAPI:/api/chunk · /api/results · /api/recall · 静态前端
│ ├── tests/
│ └── run.py
├── frontend/ # Demo(index.html + css + js)
├── scripts/ # chunk_cli · run_all_samples
├── storage/ # uploads / assets / results(运行时生成)
├── docx/ # 需求 PDF、MinerU 说明
└── requirements.txt
```
---
## 快速开始
```bash
pip install -r requirements.txt
cd backend
python run.py
```
浏览器打开:**[http://127.0.0.1:8000](http://127.0.0.1:8000)**
前后端分离开发时,另开终端:
```bash
cd frontend
python run.py # http://127.0.0.1:5173 ,API 需自行指向 :8000
```
可选参数:`--host 0.0.0.0 --port 8000 --no-reload`(后端)· `--port 5173`(前端)
### 环境变量(PDF)
| 变量 | 默认 | 说明 |
| -------------------- | ------ | ------------------------------------------------------ |
| `RAG_CUT_PDF_ENGINE` | `auto` | `auto` 优先 MinerU,失败回退 PyMuPDF;可强制 `mineru` / `pymupdf` |
| `RAG_CUT_MINERU_CMD` | — | MinerU 可执行文件路径(不在 PATH 时) |
| `RAG_CUT_MINERU_API_URL` | — | 复用已启动的 MinerU API,避免每次切分重复加载模型 |
| `RAG_CUT_MINERU_TIMEOUT` | `540` | 单次 MinerU 解析超时秒数;超时后会清理其临时服务和子进程 |
| `RAG_CUT_MINERU_OCR` | `1` | MinerU 图片无 OCR 时用 Tesseract 回填;`0` 关闭以加快切分 |
详见 `[docx/mineru-integration.md](docx/mineru-integration.md)`。
### 依赖与运行环境
| 类别 | 选型 |
| ------------ | -------------------------------------------------------------- |
| Python | 3.12+ |
| Web | FastAPI + Uvicorn |
| PDF | MinerU(`requirements.txt` 已含)/ PyMuPDF + pdfplumber |
| Excel | openpyxl |
| Office 转 PDF | 本机 **LibreOffice** 或 **Microsoft Word/PowerPoint**(doc/ppt 必需) |
| 召回 | 内置 BM25 词法评分(无向量库) |
---
## 各格式处理
所有格式统一:**解析 → Block → 切分 → Markdown Chunk**。
| 扩展名 | 解析器 | 要点 | 默认切分 |
| ---------------------- | ------------------- | ------------------------------- | ------------------------------ |
| `.pdf` | `PdfParser` | MinerU / PyMuPDF → 噪声过滤 → 跨页表合并 | 语义切分(`max=2600`) |
| `.doc` `.docx` | `WordParser` | 转 PDF 后复用 PDF 流水线 | 同上 |
| `.ppt` `.pptx` `.ppsx` | `PptParser` | 转 PDF;页码 ≈ 幻灯片序号 | `default`(`max=2600`) |
| `.xlsx` `.xls` `.csv` | `SpreadsheetParser` | 自动检测 preamble / 表头 / 数据起始行 | `by_row`(Q&A 表 1 行/片,否则按行数自适应) |
| `.md` `.html` | `TextParser` | 按标题 / 段落拆块 | `default`(`max=1800`) |
| `.json` | `TextParser` | 顶层 key / 数组元素 → `code` 块 | `default` |
| `.txt` `.xml` `.log` | `TextParser` | 按空行分段(不解析 XML 结构) | `default` |
| 图片 | `ImageParser` | 整图 1 个 Block | 整图 1 片 |
### 统一产出
| 项目 | 说明 |
| -------- | ---------------------------------------------------------------------------------------------- |
| Block 类型 | `heading` / `paragraph` / `table` / `image` / `list` / `code` |
| Chunk | Markdown + `meta`(`heading` / `pages` / `embedding_text` / `retrieval` / 可选 `parent_chunk_id`) |
| 图片 | `storage/assets/{doc_id}/`;JSON 内为 `assets/{doc_id}/…` |
| 结果缓存 | `storage/results/{doc_id}.json`(供召回) |
| 中间产物 | `_conversion/`(Office→PDF)、`_mineru/`(MinerU 输出) |
### Word / PPT 转 PDF
优先 LibreOffice(`soffice --headless`),Windows 可回退 Office COM。转换失败时 API 返回 500 并附带各后端错误信息。
### Excel / CSV 布局检测
`detect_spreadsheet_layout` 会识别常见模板(如第 1 行字段说明、第 2 行列表头、第 3 行起数据),跳过 preamble;Q&A 评测表(列名含 query/answer 等)自动 `rows_per_chunk=1`。
> `.xls` 依赖 openpyxl,老二进制格式可能失败,建议先转 xlsx。
---
## PDF 切割方法
`.pdf` / `.doc` / `.docx` 在 `mode=default` 时走专用语义切分器
`splitters/pdf_semantic.py`(策略名:`heading_layout_multimodal`)。
Word 先转 PDF,再与 PDF 共用同一套规则。默认 `max_chunk_size=2600`、`overlap=120`。
### 流水线
```
PDF/Word
│
├─ 解析:MinerU(优先)或 PyMuPDF → Block 流
├─ 噪声过滤:页眉/页脚/页码、**整段文档目录**、装饰小图
├─ 跨页表格合并
├─ 版面增强:标题层级、图片/表格上下文、order_index
├─ pdf_strategy:打标签(操作手册 vs 年报,见下)
└─ heading_layout_multimodal 切分 → Chunk
```
### 核心规则:`heading_layout_multimodal`
目标:同一逻辑小节(标题 + 正文 + 同节截图/表格)尽量落在同一 chunk,避免把步骤截图和说明文字拆开。
| 步骤 | 行为 |
|------|------|
| 1. 再过滤噪声 | 跳过页码、running header、**文档目录(目录/Contents 及条目)**、过小装饰图 |
| 2. 识别标题 | 解析器已标的 `heading`,或正则/字号启发式补识别 |
| 3. 维护标题路径 | 用标题栈记录章节层级,如 `1 Getting Started → A. Log in` |
| 4. 按标题开新片 | 遇到同级或更高级标题时 flush 当前组,开新 chunk |
| 5. 图文同节 | 正文、image、table 跟在当前标题路径下;绑定前后文到 `meta` |
| 6. 超长二次切 | 超过 `max_chunk_size` 时按子标题/内容标记/长度拆成多片;**不单独保留父片** |
**可识别的标题形态**(示例):
- 编号:`1.` / `1.2.3` / `A.` / `Chapter 2`(操作步骤 `Step N …` 作为正文保留,不单独开章)
- 中文章节:`一、` / `(一)` 等
- 字号显著大于正文的短文本(PyMuPDF 路径)
封面 Logo 等无语义组会标 `retrieval=false`,不进召回候选。
### 父子标识符切分(`mode=parent_child`)
适合「细粒度检索、粗粒度召回」:子片用于 BM25 检索,命中后通过 `parent_chunk_id` 关联父片上下文。
> 默认切分**不会**再因超长章节自动产出父片+子片;需要父子结构时请显式选择本模式。
流程:
1. 按 **父级标识符**(`parent_delimiter`)切开,得到父片;超过父级最大长度时再按长度二次切
2. 每个父片再按 **子级标识符**(`child_delimiter`,可选)切开,得到子片;超过子级最大长度时再按长度二次切
3. 父片:`is_section_parent=true`,`retrieval=false`
4. 子片:`is_sub_chunk=true`,`retrieval=true`,`parent_chunk_id` 指向父片
参数约束:
| 参数 | 说明 |
|------|------|
| `parent_delimiter` | 必填;父级切开标记(不会写入切片正文) |
| `child_delimiter` | 可选;缺省则仅按子级最大长度拆子片 |
| `max_chunk_size` | 父级最大长度 |
| `child_max_size` | 子级最大长度,且 ≤ 父级、硬上限 1500 |
| `overlap` | 超长二次切分时的重叠 |
Demo / CLI / API 均可显式选择此模式。
### 文档类型标签:`pdf_strategy`
`splitters/pdf_strategy.py` 根据文件名与正文特征打标签,写入
`split_config.pdf_chunk_strategy`(Demo 自动策略面板会显示):
| 标签 | 典型特征 | 说明 |
|------|----------|------|
| `pdf_feature_step_screenshot` | 步骤标题多、「点击/输入/选择」等操作词多、截图密度高 | 操作手册型 |
| `pdf_outline_report` | 「年报/财务报表/董事会」等词多,或文件名含 annual/report/年报 | 年报/报告型 |
> 当前两类**切分算法相同**(都走 `heading_layout_multimodal`);标签用于结果标注,并为后续分叉策略预留。
### 其他切分模式(PDF 也可用)
| 模式 | 何时用 | 行为 |
|------|--------|------|
| `default` | Demo / 自动策略(推荐) | 上文语义切分 |
| `delimiter` | CLI/API/前端显式指定 | 按自定义标识符切,再按长度二次切 |
| `parent_child` | CLI/API/前端显式指定 | 父子标识符切分(检索用子片、父片作上下文) |
| `by_row` | 一般不用于 PDF | 面向表格文档 |
相关代码:`pdf_semantic.py` · `pdf_strategy.py` · `parsers/pdf/noise_filter.py` · `pipeline.py`。
---
## 切分策略(全格式)
后端共 **4** 种切分模式(`SplitMode`):`default` / `delimiter` / `parent_child` / `by_row`。
前端「自动」(`auto`) 不是独立模式:不传 `mode` 时由 `split_policy.choose_split_config` 按扩展名与表结构选型。
API **不会按扩展名拦截 mode**——凡解析器支持的格式均可手动指定任意模式;下表区分设计意图与有效行为。
### 可解析格式(21 种)
| 类别 | 扩展名 |
| ---- | ------ |
| Office | `.pdf` `.doc` `.docx` `.ppt` `.pptx` `.ppsx` |
| 表格 | `.xlsx` `.xls` `.csv` |
| 文本 | `.md` `.txt` `.html` `.htm` `.json` `.xml` `.log` |
| 图片 | `.jpg` `.jpeg` `.png` `.bmp` `.gif` |
> 需求中的 `.wps` 尚未接入解析器。
### 四种模式与文档格式
| 模式 | 适用(设计意图) | 实际覆盖的格式 | 行为 |
| ---- | ---------------- | -------------- | ---- |
| `default` | 非表格 | 除表格类外全部;表格也可手动指定 | 结构/标题感知切分;`.pdf`/`.doc`/`.docx` 走语义切分(`heading_layout_multimodal`);单章超长时按子标题或长度拆开(**不产父片**) |
| `delimiter` | 非表格(正文含可匹配标识符) | 全部可解析格式 | 按自定义标识符(如 `###`)切开;超长片段再按 `max_chunk_size` + `overlap`;无标识符时效果差 |
| `parent_child` | 非表格(层级标识符) | 全部可解析格式 | 先按父标识符切父片,再按子标识符切子片;检索用子片,父片作上下文;典型如 `.md`/文本 |
| `by_row` | 表格 | **有效**:`.xlsx` `.xls` `.csv`,或解析后含带 `rows` 的 TABLE block | 每片 = 表头 + N 行 Markdown;无表格行数据时退化为整篇一片;一般不用于 PDF/Word |
### 自动策略(不传 mode)
| 文档 | 自动选的模式 |
| ---- | ------------ |
| `.xlsx` `.xls` `.csv`,或整篇就一张表(含 `rows`) | `by_row` |
| `.pdf` `.ppt` `.pptx` `.ppsx` | `default` |
| 图片类 | `default` |
| 文本类(`.md` `.txt` `.html` `.htm` `.json` `.xml` `.log`) | `default` |
| `.doc` `.docx` 等其余 | `default` |
**一句话**:`by_row` 专吃表格;另外三种面向正文结构,其中 `default` 对 PDF/Word 有专用路径,`delimiter` / `parent_child` 对有标识符的文本最有用,但格式本身不限制。
### `default` 决策树
```
.pdf / .doc / .docx
└─ 见上方「PDF 切割方法」(heading_layout_multimodal)
单 table 且含 rows
└─ 自动 by_row
其他 + 足够标题
└─ 标题大纲切;若某章超过 max_chunk_size
→ 优先按子标题 / 内容标记拆分
→ 否则按长度拆分(不保留整章父片)
有 page 元数据、标题不足
└─ 按页;单页超限再按长度
否则
└─ 按 max_chunk_size 打包;image/table 不拆
```
`meta.retrieval = false` 的切片(如封面 Logo,或 `parent_child` 模式的父片)不参与召回。
### SplitConfig
| 参数 | 默认 | 说明 |
| ------------------------------------- | --------- | ------------------------------ |
| `mode` | `default` | `default` / `delimiter` / `parent_child` / `by_row` |
| `delimiter` | — | `delimiter` 模式必填 |
| `parent_delimiter` / `child_delimiter` | — | `parent_child` 模式:父标识符必填,子标识符可选 |
| `max_chunk_size` | 1500 | 单 chunk 上限(父级长度;自动策略常覆盖为 1800–2600) |
| `child_max_size` | 512 | `parent_child` 子级最大长度(≤ 父级,且 ≤ 1500) |
| `overlap` | 150 | 超长二次切分重叠 |
| `header_row_start` / `header_row_end` | 1 | 表头行(1-based) |
| `start_row` | 2 | 数据起始行 |
| `rows_per_chunk` | 1 | 每片数据行数 |
---
## Demo 前端
三栏工作区:**原文** · **切片列表** · **切片预览**;左侧可选手动切分模式,或保持「自动」由系统选型。
| 区域 | 说明 |
| ------ | ---------------------------------------- |
| 上传 | 拖拽 / 选择文件 |
| 切分模式 | 自动 / 默认(结构感知)/ 通用标识符 / **父子标识符** / 按行;手动模式可调对应参数 |
| 策略面板 | 切分后回显 mode / max_size / overlap / PDF 策略 |
| 原文 | PDF iframe / docx(mammoth)/ 文本 / 图片 |
| 切片列表 | 类型标签、父子切片标记、字符数 |
| 召回测试 | query + top_k;展示得分与命中切片 |
| API 面板 | 可折叠请求/响应 JSON |
---
## CLI / API
### CLI
```bash
# 显式传 SplitConfig(不会走前端那种「全自动」空配置)
python scripts/chunk_cli.py "path/to/file.pdf" --preview 3
python scripts/chunk_cli.py "path/to/file.xlsx" --mode by_row --rows-per-chunk 5 -o storage/result.json
python scripts/chunk_cli.py "readme.md" --mode delimiter --delimiter "##"
python scripts/chunk_cli.py "readme.md" --mode parent_child \
--parent-delimiter "##" --child-delimiter "###" \
--max-chunk-size 2000 --child-max-size 512
```
批量样例(若有 `data/`):`python scripts/run_all_samples.py`
### API
```bash
# 健康检查
curl http://127.0.0.1:8000/health
# 切分(不传 mode 等参数 → 自动策略,与 Demo 一致)
curl -X POST http://127.0.0.1:8000/api/chunk -F "file=@./your.pdf"
# 切分(显式参数)
curl -X POST http://127.0.0.1:8000/api/chunk \
-F "file=@./your.xlsx" \
-F "mode=by_row" \
-F "rows_per_chunk=5"
# 召回(先切分拿到 doc_id)
curl -X POST http://127.0.0.1:8000/api/recall \
-H "Content-Type: application/json" \
-d '{"doc_id":"abcdef123456","query":"如何修改交易密码","top_k":5}'
```
也可直接:`cd backend && uvicorn api.main:app --reload --port 8000`
### Python
```python
from pathlib import Path
from rag_cut import chunk_document, SplitConfig, SplitMode
from rag_cut.retrieval import recall_chunks
# 自动策略
result = chunk_document(Path("doc.pdf"))
# 显式配置
result = chunk_document(
Path("doc.docx"),
config=SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=1500),
)
hits, n = recall_chunks("login password", result.chunks, top_k=5)
```
将 `backend/` 加入 `PYTHONPATH`,或通过 `scripts/chunk_cli.py` 调用。
### 输出字段(节选)
```json
{
"filename": "guide.docx",
"doc_id": "61c2a152cd5e",
"split_mode": "default",
"split_config": {
"mode": "default",
"max_chunk_size": 2600,
"pdf_chunk_strategy": "pdf_feature_step_screenshot",
"chunk_strategy": "heading_layout_multimodal"
},
"block_count": 269,
"chunk_count": 48,
"chunks": [
{
"index": 0,
"content": "# …\n\n![image3](assets/61c2a152cd5e/image3.png)",
"char_count": 431,
"block_types": ["heading", "paragraph", "image"],
"meta": {
"heading": "1 Getting Started",
"pages": [3],
"embedding_text": "…",
"retrieval": true
}
}
],
"assets_dir": "storage/assets/61c2a152cd5e"
}
```
---
## 期望效果对照
| 需求 | 状态 |
| ----------------- | ------------------------------------------- |
| 图片保留原文位置 | ✅ PDF / Word / PPT(转 PDF) |
| 表格提取为 Markdown | ✅ PDF / Excel / CSV |
| Word 同标题内容同 chunk | ✅ 语义切分 |
| PDF 多栏阅读顺序 | ✅ 双栏检测 + 行合并 |
| 页眉页脚 / 页码噪声过滤 | ✅ MinerU 类型跳过 + 位置启发式 |
| 手册截图文字不混入正文 | ✅ 大图区域过滤 |
| 父子切片(超长章节) | ❌ 已从默认切分移除;超长仅按长度/子标题拆分 |
| 父子标识符切分 | ✅ 独立模式 `parent_child`(父/子标识符 + 双长度) |
| 召回入参/出参可检视 | ✅ `/api/recall` + Demo |
| wps | ⏳ 未实现 |
---
## 后续计划
- [ ] PDF OCR / 视觉描述增强
- [ ] 向量召回(Embedding)
- [ ] wps 格式支持
---
## 参考
- `[docx/自研搭建AI助手知识库.pdf](docx/自研搭建AI助手知识库.pdf)` — 需求
- `[docx/mineru-integration.md](docx/mineru-integration.md)` — MinerU 接入
- `[docx/tencent-cloud-document-splitting-settings.md](docx/tencent-cloud-document-splitting-settings.md)` — 腾讯云切分参考
- `[CLAUDE.md](CLAUDE.md)` / `[AGENTS.md](AGENTS.md)` — AI 协作说明
+22
View File
@@ -0,0 +1,22 @@
# TASK_SUMMARY — 目录不解析、不切片
## 1. 任务基本信息
- **任务名称**:所有文件跳过文档目录
- **相关项目**:RAG-cut
## 2. 改动说明
解析与切分链路统一剔除「目录 / Contents / TOC」标题及带页码引导线的目录条目;正文内无页码清单(如形態指標列表)保留。
## 3. 影响与风险
- 见 `IMPACT_ANALYSIS.md`;**破坏性变更:否**
## 4. 测试与验证
- 运行 `test_pdf_noise_filter`、`test_heading_layout_multimodal` 等相关单测
## 5. 后续事项
- 若遇无引导线、无页码的「伪目录」仍进切片,可再扩展 TOC 区检测
+1
View File
@@ -0,0 +1 @@
"""RAG-cut FastAPI app. Start with: python backend/run.py"""
+288
View File
@@ -0,0 +1,288 @@
"""FastAPI endpoints for document chunking + demo frontend."""
from __future__ import annotations
import asyncio
import json
import mimetypes
import re
import shutil
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from rag_cut.models import ChunkResult, SplitConfig, SplitMode
from rag_cut.pipeline import chunk_document
from rag_cut.retrieval import recall_chunks
ROOT = Path(__file__).resolve().parent.parent.parent
FRONTEND_DIR = ROOT / "frontend"
STORAGE_DIR = ROOT / "storage"
RESULTS_DIR = STORAGE_DIR / "results"
UPLOADS_DIR = STORAGE_DIR / "uploads"
DOC_ID_RE = re.compile(r"^[a-f0-9]{12}$")
WORD_EXTS = {".doc", ".docx"}
app = FastAPI(title="RAG-cut Chunking API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
(STORAGE_DIR / "assets").mkdir(parents=True, exist_ok=True)
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/assets", StaticFiles(directory=STORAGE_DIR / "assets"), name="assets")
class RecallRequest(BaseModel):
doc_id: str
query: str = Field(min_length=1, max_length=500)
top_k: int = Field(default=5, ge=1, le=20)
def _result_path(doc_id: str) -> Path:
if not DOC_ID_RE.fullmatch(doc_id):
raise HTTPException(status_code=400, detail="Invalid doc_id")
return RESULTS_DIR / f"{doc_id}.json"
def _save_result(result: ChunkResult) -> None:
_result_path(result.doc_id).write_text(result.model_dump_json(indent=2), encoding="utf-8")
def _find_original(doc_id: str) -> Path | None:
uploads_dir = UPLOADS_DIR / doc_id
if not uploads_dir.is_dir():
return None
files = sorted(p for p in uploads_dir.iterdir() if p.is_file())
return files[0] if files else None
def _find_preview(doc_id: str) -> Path | None:
"""Return a browser-previewable source, preferring converted Word PDFs."""
original = _find_original(doc_id)
if original is None:
return None
if original.suffix.lower() == ".pdf":
return original
if original.suffix.lower() not in WORD_EXTS:
return None
conversion_dir = STORAGE_DIR / "assets" / doc_id / "_conversion"
exact = conversion_dir / f"{original.stem}.pdf"
if exact.is_file():
return exact
if conversion_dir.is_dir():
return next(iter(sorted(conversion_dir.glob("*.pdf"))), None)
return None
def _load_result(doc_id: str) -> ChunkResult:
result_path = _result_path(doc_id)
if not result_path.exists():
raise HTTPException(status_code=404, detail="Chunk result not found")
try:
return ChunkResult.model_validate_json(result_path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Invalid result file: {exc}") from exc
def _result_summary(path: Path) -> dict | None:
"""Build list summary without loading the full chunks array."""
try:
with path.open("r", encoding="utf-8") as fh:
head = fh.read(32768)
idx = head.find('\n "chunks"')
if idx == -1:
idx = head.find('"chunks"')
if idx != -1:
data = json.loads(head[:idx].rstrip().rstrip(",") + "\n}")
else:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
doc_id = data.get("doc_id") or path.stem
if not DOC_ID_RE.fullmatch(str(doc_id)):
return None
mtime = path.stat().st_mtime
return {
"doc_id": doc_id,
"filename": data.get("filename") or path.name,
"split_mode": data.get("split_mode"),
"chunk_count": data.get("chunk_count", 0),
"block_count": data.get("block_count", 0),
"has_original": _find_original(doc_id) is not None,
"saved_at": datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat(),
"saved_ts": mtime,
}
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/api/results")
def list_results():
"""List persisted chunk results (newest first)."""
items: list[dict] = []
for path in RESULTS_DIR.glob("*.json"):
summary = _result_summary(path)
if summary:
items.append(summary)
items.sort(key=lambda item: item["saved_ts"], reverse=True)
for item in items:
item.pop("saved_ts", None)
return {"count": len(items), "results": items}
@app.get("/api/results/{doc_id}")
def get_result(doc_id: str):
"""Load a full persisted chunk result by doc_id."""
chunk_result = _load_result(doc_id)
payload = json.loads(chunk_result.model_dump_json())
payload["has_original"] = _find_original(doc_id) is not None
return JSONResponse(content=payload)
@app.get("/api/results/{doc_id}/original")
def get_original(doc_id: str):
"""Serve the original uploaded file for a historical result (if still on disk)."""
if not DOC_ID_RE.fullmatch(doc_id):
raise HTTPException(status_code=400, detail="Invalid doc_id")
original = _find_original(doc_id)
if original is None:
raise HTTPException(status_code=404, detail="Original file not found")
media_type, _ = mimetypes.guess_type(original.name)
return FileResponse(
path=original,
media_type=media_type or "application/octet-stream",
filename=original.name,
)
@app.get("/api/results/{doc_id}/preview")
def get_preview(doc_id: str):
"""Serve an inline PDF preview for PDF and converted Word documents."""
if not DOC_ID_RE.fullmatch(doc_id):
raise HTTPException(status_code=400, detail="Invalid doc_id")
preview = _find_preview(doc_id)
if preview is None:
raise HTTPException(status_code=404, detail="PDF preview not found")
return FileResponse(path=preview, media_type="application/pdf")
def _delete_tree(path: Path) -> bool:
if not path.exists():
return False
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
return True
@app.delete("/api/results/{doc_id}")
def delete_result(doc_id: str):
"""Delete a persisted chunk result and its uploads/assets."""
result_path = _result_path(doc_id)
if not result_path.exists():
raise HTTPException(status_code=404, detail="Chunk result not found")
removed = {
"result": _delete_tree(result_path),
"uploads": _delete_tree(UPLOADS_DIR / doc_id),
"assets": _delete_tree(STORAGE_DIR / "assets" / doc_id),
}
return {"doc_id": doc_id, "deleted": True, "removed": removed}
@app.post("/api/chunk")
async def chunk_file(
file: UploadFile = File(...),
mode: str | None = Form(None),
delimiter: str | None = Form(None),
parent_delimiter: str | None = Form(None),
child_delimiter: str | None = Form(None),
max_chunk_size: int | None = Form(None),
child_max_size: int | None = Form(None),
overlap: int | None = Form(None),
header_row_start: int | None = Form(None),
header_row_end: int | None = Form(None),
start_row: int | None = Form(None),
rows_per_chunk: int | None = Form(None),
):
config = None
if mode is not None:
try:
split_mode = SplitMode(mode)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid mode: {mode}") from exc
config = SplitConfig(
mode=split_mode,
delimiter=delimiter,
parent_delimiter=parent_delimiter,
child_delimiter=child_delimiter,
max_chunk_size=1500 if max_chunk_size is None else max_chunk_size,
child_max_size=512 if child_max_size is None else child_max_size,
overlap=150 if overlap is None else overlap,
header_row_start=1 if header_row_start is None else header_row_start,
header_row_end=1 if header_row_end is None else header_row_end,
start_row=2 if start_row is None else start_row,
rows_per_chunk=1 if rows_per_chunk is None else rows_per_chunk,
)
filename = Path(file.filename or "upload").name
payload = await file.read()
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp) / filename
tmp_path.write_bytes(payload)
try:
# Heavy parsing (MinerU / LibreOffice / OCR) must not block the event loop,
# otherwise /health and other uploads hang while one document is processing.
result = await asyncio.to_thread(chunk_document, tmp_path, config)
_save_result(result)
return JSONResponse(content=json.loads(result.model_dump_json()))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/api/recall")
def recall(request: RecallRequest):
result_path = _result_path(request.doc_id)
if not result_path.exists():
raise HTTPException(status_code=404, detail="Chunk result not found; upload and split the document again")
started = time.perf_counter()
chunk_result = ChunkResult.model_validate_json(result_path.read_text(encoding="utf-8"))
results, candidate_count = recall_chunks(request.query, chunk_result.chunks, request.top_k)
return {
"doc_id": request.doc_id,
"query": request.query,
"top_k": request.top_k,
"candidate_count": candidate_count,
"returned_count": len(results),
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
"results": results,
}
if FRONTEND_DIR.exists():
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
+6
View File
@@ -0,0 +1,6 @@
"""RAG-cut document chunking pipeline."""
from rag_cut.models import Block, Chunk, SplitConfig, SplitMode
from rag_cut.pipeline import chunk_document
__all__ = ["Block", "Chunk", "SplitConfig", "SplitMode", "chunk_document"]
+244
View File
@@ -0,0 +1,244 @@
"""Layout metadata: reading order, heading binding, image context."""
from __future__ import annotations
from rag_cut.models import Block, BlockType
from rag_cut.parsers.pdf.tables import build_table_embedding_text, extract_table_keywords, guess_table_title
from rag_cut.splitters.heading_splitter import is_heading_block, normalize_heading_block
def assign_order_index(blocks: list[Block], start: int = 0) -> list[Block]:
"""Assign a global document-order index to every block."""
result: list[Block] = []
for idx, block in enumerate(blocks):
meta = dict(block.meta)
meta["order_index"] = start + idx
result.append(block.model_copy(update={"meta": meta}))
return result
def sort_blocks_reading_order(blocks: list[Block]) -> list[Block]:
"""Sort by page then top-to-bottom / left-to-right; stable for missing bboxes."""
def sort_key(item: tuple[int, Block]) -> tuple[int, float, float, int]:
idx, block = item
page = block.meta.get("page")
try:
page_key = int(page) if page is not None else 10**9
except (TypeError, ValueError):
page_key = 10**9
bbox = block.meta.get("bbox")
if isinstance(bbox, (list, tuple)) and len(bbox) >= 4:
try:
return (page_key, float(bbox[1]), float(bbox[0]), idx)
except (TypeError, ValueError):
pass
return (page_key, float(idx), 0.0, idx)
return [block for _, block in sorted(enumerate(blocks), key=sort_key)]
def bind_heading_context(blocks: list[Block]) -> list[Block]:
"""
Propagate heading hierarchy and bind images to nearest heading and adjacent text.
Chunk content should follow: heading → body → image → OCR/caption → subsequent body.
"""
heading_stack: list[tuple[int, str]] = []
result: list[Block] = []
for i, block in enumerate(blocks):
meta = dict(block.meta)
candidate = normalize_heading_block(block)
if is_heading_block(candidate):
level = candidate.level or 1
while heading_stack and heading_stack[-1][0] >= level:
heading_stack.pop()
heading_stack.append((level, candidate.text))
meta["section_boundary"] = True
if block.type != BlockType.HEADING or candidate is not block:
block = candidate.model_copy(update={"meta": {**dict(candidate.meta), **meta}})
meta = dict(block.meta)
else:
meta["section_boundary"] = False
# Demote overlong HEADING blobs (title+body merge) back to paragraph.
if block.type == BlockType.HEADING and candidate.type != BlockType.HEADING:
block = candidate.model_copy(update={"meta": {**dict(candidate.meta), **meta}})
meta = dict(block.meta)
if heading_stack:
meta["parent_heading"] = heading_stack[-1][1]
meta["heading_path"] = [text for _, text in heading_stack]
meta["nearest_heading"] = heading_stack[-1][1]
if (block.level or 1) <= 2 and block.type == BlockType.HEADING:
meta["chapter"] = block.text
elif "chapter" not in meta and len(heading_stack) >= 1:
# Keep chapter as nearest level-1/2 ancestor
for lvl, text in reversed(heading_stack):
if lvl <= 2:
meta["chapter"] = text
break
if block.type == BlockType.IMAGE:
_bind_image_context(blocks, i, meta)
elif block.type == BlockType.TABLE:
_bind_table_context(blocks, i, meta)
result.append(block.model_copy(update={"meta": meta}))
return result
def _heading_text(block: Block) -> str | None:
candidate = normalize_heading_block(block)
if is_heading_block(candidate):
return (candidate.text or "").strip() or None
return None
def _spatial_heading_above(
blocks: list[Block],
index: int,
page: object,
bbox: list[float] | None,
) -> str | None:
"""Pick same-page heading whose bottom edge is nearest above the image top."""
if page is None or not bbox or len(bbox) < 4:
return None
try:
page_key = int(page)
img_y0 = float(bbox[1])
except (TypeError, ValueError):
return None
best_text: str | None = None
best_dist = float("inf")
for j, block in enumerate(blocks):
if j == index:
continue
text = _heading_text(block)
if not text:
continue
try:
if int(block.meta.get("page")) != page_key:
continue
except (TypeError, ValueError):
continue
hb = block.meta.get("bbox")
if not isinstance(hb, (list, tuple)) or len(hb) < 4:
continue
try:
heading_y1 = float(hb[3])
except (TypeError, ValueError):
continue
if heading_y1 > img_y0 + 2:
continue
dist = img_y0 - heading_y1
if dist < best_dist:
best_dist = dist
best_text = text
return best_text
def _list_heading_above(blocks: list[Block], index: int) -> str | None:
for j in range(index - 1, -1, -1):
text = _heading_text(blocks[j])
if text:
return text
return None
def _bind_image_context(blocks: list[Block], index: int, meta: dict) -> None:
"""Bind image to nearest heading above and adjacent body text on the same page."""
page = meta.get("page")
bbox = meta.get("bbox") if isinstance(meta.get("bbox"), list) else None
bound = _spatial_heading_above(blocks, index, page, bbox) or _list_heading_above(blocks, index)
if bound:
meta["bound_heading"] = bound
for j in range(index - 1, -1, -1):
prev = blocks[j]
if _heading_text(prev):
break
if prev.type == BlockType.PARAGRAPH and prev.meta.get("page") == page:
meta["preceding_text"] = (prev.text or "")[:300]
break
if not meta.get("bound_heading") and meta.get("nearest_heading"):
meta["bound_heading"] = meta["nearest_heading"]
for j in range(index + 1, len(blocks)):
nxt = blocks[j]
if nxt.type == BlockType.IMAGE:
break
if nxt.type == BlockType.PARAGRAPH and nxt.meta.get("page") == page:
meta["following_text"] = (nxt.text or "")[:300]
break
if _heading_text(nxt):
break
def _bind_table_context(blocks: list[Block], index: int, meta: dict) -> None:
"""Bind table title, surrounding text and retrieval fields."""
page = meta.get("page")
block = blocks[index]
for j in range(index - 1, -1, -1):
prev = blocks[j]
if prev.type == BlockType.TABLE:
break
heading = _heading_text(prev)
if heading:
if not meta.get("table_title"):
meta["table_title"] = heading
meta.setdefault("bound_heading", heading)
break
if prev.type == BlockType.PARAGRAPH and prev.meta.get("page") == page:
title = guess_table_title(prev.text or "")
if title:
meta["table_title"] = title
meta["preceding_text"] = (prev.text or "")[:400]
break
if not meta.get("table_title") and meta.get("nearest_heading"):
meta.setdefault("table_title", meta["nearest_heading"])
for j in range(index + 1, len(blocks)):
nxt = blocks[j]
if nxt.type == BlockType.TABLE:
break
if nxt.type == BlockType.PARAGRAPH and nxt.meta.get("page") == page:
text = (nxt.text or "").strip()
if text and len(text) <= 300:
meta["following_text"] = text
if not meta.get("footnotes") and any(k in text for k in ("注", "备注", "说明", "Note")):
meta["table_description"] = text
break
if _heading_text(nxt):
break
if not meta.get("keywords"):
meta["keywords"] = extract_table_keywords(
meta.get("table_title") or "",
block.markdown or "",
meta.get("table_description") or meta.get("footnotes") or "",
block.ocr_text or "",
)
meta["embedding_text"] = build_table_embedding_text(
chapter=meta.get("chapter") or meta.get("nearest_heading") or "",
table_title=meta.get("table_title") or "",
markdown=block.markdown or "",
description=meta.get("table_description") or meta.get("preceding_text") or "",
footnotes=meta.get("footnotes") or meta.get("following_text") or "",
keywords=meta.get("keywords") or [],
ocr_text=block.ocr_text or "",
)
def enrich_layout_metadata(blocks: list[Block]) -> list[Block]:
"""Full post-parse enrichment: spatial order + order index + heading/image context."""
blocks = sort_blocks_reading_order(blocks)
blocks = assign_order_index(blocks)
return bind_heading_context(blocks)
+100
View File
@@ -0,0 +1,100 @@
"""Core data models for document chunking."""
from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
class BlockType(str, Enum):
HEADING = "heading"
PARAGRAPH = "paragraph"
TABLE = "table"
IMAGE = "image"
LIST = "list"
CODE = "code"
class SplitMode(str, Enum):
DEFAULT = "default"
DELIMITER = "delimiter"
PARENT_CHILD = "parent_child"
BY_ROW = "by_row"
class Block(BaseModel):
"""Atomic document unit; images and tables must not be split across chunks."""
type: BlockType
text: str = ""
markdown: str = ""
level: int = 0
image_id: str | None = None
image_path: str | None = None
ocr_text: str = ""
meta: dict[str, Any] = Field(default_factory=dict)
def render(self) -> str:
if self.type == BlockType.TABLE:
parts: list[str] = []
title = self.meta.get("table_title")
if title:
parts.append(f"### {title}")
image_ref = self.image_path or self.meta.get("crop_path")
if image_ref:
alt = (title or self.image_id or "table").replace("\n", " ").strip()
parts.append(f"![{alt}]({image_ref})")
parts.append(self.markdown or self.text)
footnotes = self.meta.get("footnotes")
if footnotes and footnotes not in (self.markdown or ""):
parts.append(f"*{footnotes}*")
if self.ocr_text:
parts.append(f"*[表格 OCR]* {self.ocr_text}")
return "\n\n".join(p for p in parts if p.strip())
if self.type == BlockType.IMAGE:
alt = (self.image_id or self.text or "image").replace("\n", " ").strip()
path = self.image_path or self.image_id or ""
body = f"![{alt}]({path})"
if self.ocr_text:
body += f"\n\n*[OCR]* {self.ocr_text}"
return body
if self.type == BlockType.HEADING:
prefix = "#" * max(1, min(self.level, 6))
return f"{prefix} {self.text}".strip()
return self.text or self.markdown
class SplitConfig(BaseModel):
mode: SplitMode = SplitMode.DEFAULT
delimiter: str | None = None
parent_delimiter: str | None = None
child_delimiter: str | None = None
max_chunk_size: int = 1500
child_max_size: int = 512
overlap: int = 150
header_row_start: int = 1
header_row_end: int = 1
start_row: int = 2
rows_per_chunk: int = 1
table_format: str = "markdown"
class Chunk(BaseModel):
index: int
content: str
char_count: int
block_types: list[str]
meta: dict[str, Any] = Field(default_factory=dict)
class ChunkResult(BaseModel):
filename: str
doc_id: str
split_mode: SplitMode
split_config: dict[str, Any] = Field(default_factory=dict)
block_count: int
chunk_count: int
chunks: list[Chunk]
assets_dir: str | None = None
+5
View File
@@ -0,0 +1,5 @@
"""Document parsers."""
from rag_cut.parsers.registry import get_parser
__all__ = ["get_parser"]
+14
View File
@@ -0,0 +1,14 @@
"""Parser base class."""
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from rag_cut.models import Block
class BaseParser(ABC):
@abstractmethod
def parse(self, path: Path, assets_dir: Path) -> list[Block]:
raise NotImplementedError
+24
View File
@@ -0,0 +1,24 @@
"""Standalone image parser."""
from __future__ import annotations
import shutil
from pathlib import Path
from rag_cut.models import Block, BlockType
from rag_cut.parsers.base import BaseParser
class ImageParser(BaseParser):
def parse(self, path: Path, assets_dir: Path) -> list[Block]:
assets_dir.mkdir(parents=True, exist_ok=True)
target = assets_dir / path.name
shutil.copy2(path, target)
return [
Block(
type=BlockType.IMAGE,
text=path.stem,
image_id=path.name,
image_path=str(target),
)
]
+421
View File
@@ -0,0 +1,421 @@
"""Optional MinerU parser adapter.
MinerU improves the parsing layer when installed, while RAG-cut keeps owning
chunking strategy and retrieval metadata.
"""
from __future__ import annotations
import json
import os
import signal
import shutil
import subprocess
from pathlib import Path
from typing import Any
from rag_cut.models import Block, BlockType
from rag_cut.parsers.pdf.ocr import describe_visual, ocr_image_file
def _mineru_command() -> str | None:
configured = os.getenv("RAG_CUT_MINERU_CMD")
if configured:
return configured
return shutil.which("mineru") or shutil.which("magic-pdf")
def _mineru_timeout_sec() -> float:
raw = os.getenv("RAG_CUT_MINERU_TIMEOUT", "540").strip()
try:
return max(30.0, float(raw))
except ValueError:
return 540.0
def _mineru_ocr_enabled() -> bool:
"""Fill empty OCR from image assets when pytesseract is available."""
raw = os.getenv("RAG_CUT_MINERU_OCR", "1").strip().lower()
return raw not in {"0", "false", "off", "no"}
def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
"""Stop MinerU and any temporary API/model workers it started."""
try:
if os.name == "nt":
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
check=False,
capture_output=True,
timeout=10,
)
else:
os.killpg(process.pid, signal.SIGKILL)
except (OSError, subprocess.SubprocessError):
pass
if process.poll() is None:
process.kill()
try:
process.wait(timeout=5)
except (OSError, subprocess.SubprocessError):
pass
def _run_mineru(path: Path, output_dir: Path) -> bool:
cmd = _mineru_command()
if not cmd:
return False
output_dir.mkdir(parents=True, exist_ok=True)
if Path(cmd).name.lower() == "magic-pdf":
args = [cmd, "-p", str(path), "-o", str(output_dir)]
else:
args = [cmd, "-p", str(path), "-o", str(output_dir), "-b", "pipeline"]
api_url = os.getenv("RAG_CUT_MINERU_API_URL", "").strip()
if api_url:
args.extend(["--api-url", api_url])
try:
popen_kwargs: dict[str, Any] = {}
if os.name == "nt":
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
else:
popen_kwargs["start_new_session"] = True
process = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
**popen_kwargs,
)
process.communicate(timeout=_mineru_timeout_sec())
except subprocess.TimeoutExpired:
_terminate_process_tree(process)
return False
except (OSError, subprocess.SubprocessError):
return False
return process.returncode == 0
def _find_content_list(output_dir: Path) -> Path | None:
"""Prefer stable content_list.json over content_list_v2.json."""
candidates = list(output_dir.rglob("*content_list*.json"))
if not candidates:
return None
def rank(path: Path) -> tuple[int, int, str]:
name = path.name.lower()
is_v2 = 1 if "v2" in name else 0
return (is_v2, len(path.parts), str(path).lower())
return sorted(candidates, key=rank)[0]
def _read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _resolve_asset(path_text: str, base_dir: Path) -> Path | None:
if not path_text:
return None
raw = Path(path_text)
if raw.is_absolute() and raw.exists():
return raw
candidate = base_dir / raw
if candidate.exists():
return candidate
matches = list(base_dir.rglob(raw.name))
return matches[0] if matches else None
def _copy_asset(src: Path | None, assets_dir: Path) -> tuple[str | None, str | None]:
if not src or not src.exists():
return None, None
assets_dir.mkdir(parents=True, exist_ok=True)
target = assets_dir / src.name
if src.resolve() != target.resolve():
shutil.copy2(src, target)
return target.name, str(target)
def _page(item: dict[str, Any]) -> int | None:
for key in ("page", "page_no", "page_num"):
if item.get(key) is not None:
try:
return int(item[key])
except (TypeError, ValueError):
return None
if item.get("page_idx") is not None:
try:
return int(item["page_idx"]) + 1
except (TypeError, ValueError):
return None
return None
def _bbox(item: dict[str, Any]) -> list[float]:
raw = item.get("bbox") or item.get("poly") or []
if isinstance(raw, list) and len(raw) >= 4:
try:
if all(isinstance(v, (int, float)) for v in raw[:4]):
return [float(v) for v in raw[:4]]
if all(isinstance(p, list) and len(p) >= 2 for p in raw):
xs = [float(p[0]) for p in raw]
ys = [float(p[1]) for p in raw]
return [min(xs), min(ys), max(xs), max(ys)]
except (TypeError, ValueError):
return []
return []
def _meta(item: dict[str, Any]) -> dict[str, Any]:
meta: dict[str, Any] = {"parser": "mineru"}
page = _page(item)
bbox = _bbox(item)
kind = _item_type(item)
if page:
meta["page"] = page
if bbox:
meta["bbox"] = bbox
if kind:
meta["mineru_type"] = kind
return meta
def _text_value(item: dict[str, Any]) -> str:
for key in (
"text",
"content",
"table_caption",
"image_caption",
"code_body",
"code",
"equation",
"latex",
):
value = item.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
if isinstance(value, list):
joined = " ".join(str(v).strip() for v in value if str(v).strip())
if joined:
return joined
list_items = item.get("list_items")
if isinstance(list_items, list) and list_items:
parts: list[str] = []
for entry in list_items:
if isinstance(entry, str) and entry.strip():
parts.append(entry.strip())
elif isinstance(entry, dict):
piece = str(entry.get("text") or entry.get("content") or "").strip()
if piece:
parts.append(piece)
if parts:
return "\n".join(parts)
return ""
def _table_markdown(item: dict[str, Any]) -> str:
for key in ("table_body", "html", "text", "content"):
value = item.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _image_path_text(item: dict[str, Any]) -> str:
for key in ("img_path", "image_path", "path"):
value = item.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _item_type(item: dict[str, Any]) -> str:
return str(item.get("type") or item.get("category") or "").lower()
def _ocr_from_item(item: dict[str, Any], image_path: str | None) -> str:
ocr_text = str(item.get("ocr_text") or item.get("image_ocr") or item.get("img_caption") or "").strip()
if not ocr_text:
caption = item.get("image_caption")
if isinstance(caption, list):
ocr_text = " ".join(str(v).strip() for v in caption if str(v).strip())
elif isinstance(caption, str):
ocr_text = caption.strip()
if ocr_text or not image_path or not _mineru_ocr_enabled():
return ocr_text
return ocr_image_file(Path(image_path))
_SKIP_MINERU_TYPES = frozenset(
{
"header",
"page_header",
"footer",
"page_footer",
"page_number",
"page_num",
"header_image",
"footer_image",
"aside_text",
"toc",
"contents",
"table_of_contents",
}
)
# Visual regions MinerU may label separately from plain "image".
_IMAGE_KINDS = frozenset(
{
"image",
"figure",
"chart",
"diagram",
"graphic",
"photo",
"screenshot",
"equation",
"formula",
}
)
def _build_image_block(
item: dict[str, Any],
base_dir: Path,
assets_dir: Path,
*,
visual_kind: str = "图片",
) -> Block | None:
src = _resolve_asset(_image_path_text(item), base_dir)
image_id, image_path = _copy_asset(src, assets_dir)
if not image_id and not image_path:
return None
caption = _text_value(item)
ocr_text = _ocr_from_item(item, image_path)
meta = _meta(item)
meta.update(
{
"caption": caption,
"source_image_path": str(src) if src else None,
}
)
return Block(
type=BlockType.IMAGE,
text=caption or describe_visual(ocr_text, visual_kind, image_id or "image"),
image_id=image_id,
image_path=image_path,
ocr_text=ocr_text,
meta=meta,
)
def _blocks_from_content_list(content_list: list[dict[str, Any]], json_path: Path, assets_dir: Path) -> list[Block]:
blocks: list[Block] = []
base_dir = json_path.parent
for item in content_list:
kind = _item_type(item)
if kind in _SKIP_MINERU_TYPES:
continue
meta = _meta(item)
img_path = _image_path_text(item)
if kind != "table" and (
kind in _IMAGE_KINDS or (img_path and kind not in {"text", "title", "heading", "list"})
):
visual = "图表" if kind in {"chart", "diagram"} else "图片"
image_block = _build_image_block(item, base_dir, assets_dir, visual_kind=visual)
if image_block:
blocks.append(image_block)
continue
# Chart/image without a resolvable asset: fall through if there is caption text.
if kind == "table":
markdown = _table_markdown(item)
src = _resolve_asset(img_path, base_dir) if img_path else None
image_id, image_path = _copy_asset(src, assets_dir)
ocr_text = _ocr_from_item(item, image_path) if image_path else ""
if not markdown and not image_path:
continue
caption = _text_value(item)
if caption:
meta["caption"] = caption
if src:
meta["source_image_path"] = str(src)
if image_path:
meta["crop_path"] = image_path
blocks.append(
Block(
type=BlockType.TABLE,
markdown=markdown,
text=caption,
image_id=image_id,
image_path=image_path,
ocr_text=ocr_text,
meta=meta,
)
)
continue
if kind == "code":
image_block = _build_image_block(item, base_dir, assets_dir, visual_kind="代码")
if image_block:
blocks.append(image_block)
continue
code_text = _text_value(item)
if code_text:
blocks.append(Block(type=BlockType.PARAGRAPH, text=code_text, meta=meta))
continue
if kind in {"page_footnote", "footnote", "ref_text"}:
note = _text_value(item)
if note:
meta["is_footnote"] = True
blocks.append(Block(type=BlockType.PARAGRAPH, text=note, meta=meta))
continue
text = _text_value(item)
if not text:
# Last resort: unknown typed asset with an image should not be dropped.
if img_path:
image_block = _build_image_block(item, base_dir, assets_dir)
if image_block:
blocks.append(image_block)
continue
level = item.get("text_level") or item.get("level")
try:
level_int = int(level)
except (TypeError, ValueError):
level_int = 0
if kind in {"title", "heading"} or level_int > 0:
meta["source_heading"] = True
meta["source_heading_level"] = max(level_int, 1)
blocks.append(Block(type=BlockType.HEADING, text=text, level=max(level_int, 1), meta=meta))
else:
blocks.append(Block(type=BlockType.PARAGRAPH, text=text, meta=meta))
return blocks
def parse_pdf_with_mineru(path: Path, assets_dir: Path) -> list[Block] | None:
"""Return MinerU blocks when available; otherwise None for fallback."""
output_dir = assets_dir / "_mineru"
if not _run_mineru(path, output_dir):
return None
content_json = _find_content_list(output_dir)
if not content_json:
return None
data = _read_json(content_json)
if not isinstance(data, list):
return None
blocks = _blocks_from_content_list(data, content_json, assets_dir)
return blocks or None
+164
View File
@@ -0,0 +1,164 @@
"""Convert Office documents to PDF for downstream parsing."""
from __future__ import annotations
import platform
import shutil
import subprocess
from pathlib import Path
_PPT_EXTENSIONS = {".ppt", ".pptx", ".ppsx"}
_WORD_EXTENSIONS = {".doc", ".docx"}
def _find_soffice() -> Path | None:
for candidate in (
"soffice",
r"C:\Program Files\LibreOffice\program\soffice.exe",
r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
"/usr/bin/libreoffice",
"/usr/bin/soffice",
):
if candidate in ("soffice", "libreoffice"):
found = shutil.which(candidate)
if found:
return Path(found)
elif Path(candidate).is_file():
return Path(candidate)
return None
def _convert_via_libreoffice(source: Path, out_dir: Path) -> Path:
soffice = _find_soffice()
if soffice is None:
raise RuntimeError("LibreOffice (soffice) not found")
out_dir.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
str(soffice),
"--headless",
"--convert-to",
"pdf",
"--outdir",
str(out_dir),
str(source.resolve()),
],
check=True,
capture_output=True,
text=True,
timeout=180,
)
pdf_path = out_dir / f"{source.stem}.pdf"
if not pdf_path.is_file():
raise RuntimeError(f"LibreOffice did not produce PDF: {pdf_path}")
return pdf_path
def _convert_via_powerpoint(source: Path, out_dir: Path) -> Path:
if platform.system() != "Windows":
raise RuntimeError("PowerPoint COM conversion is only available on Windows")
out_dir.mkdir(parents=True, exist_ok=True)
pdf_path = out_dir / f"{source.stem}.pdf"
src = str(source.resolve())
dst = str(pdf_path.resolve())
script = f"""
$ErrorActionPreference = 'Stop'
$pp = New-Object -ComObject PowerPoint.Application
try {{
$pres = $pp.Presentations.Open('{src.replace("'", "''")}', $true, $true, $false)
try {{
$pres.SaveAs('{dst.replace("'", "''")}', 32)
}} finally {{
$pres.Close()
}}
}} finally {{
$pp.Quit()
}}
"""
subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
check=True,
capture_output=True,
text=True,
timeout=180,
)
if not pdf_path.is_file():
raise RuntimeError(f"PowerPoint did not produce PDF: {pdf_path}")
return pdf_path
def _convert_via_word(source: Path, out_dir: Path) -> Path:
if platform.system() != "Windows":
raise RuntimeError("Word COM conversion is only available on Windows")
out_dir.mkdir(parents=True, exist_ok=True)
pdf_path = out_dir / f"{source.stem}.pdf"
src = str(source.resolve())
dst = str(pdf_path.resolve())
script = f"""
$ErrorActionPreference = 'Stop'
$word = New-Object -ComObject Word.Application
$word.Visible = $false
try {{
$doc = $word.Documents.Open('{src.replace("'", "''")}', $false, $true)
try {{
$doc.SaveAs2('{dst.replace("'", "''")}', 17)
}} finally {{
$doc.Close([ref]$false)
}}
}} finally {{
$word.Quit()
}}
"""
subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
check=True,
capture_output=True,
text=True,
timeout=180,
)
if not pdf_path.is_file():
raise RuntimeError(f"Word did not produce PDF: {pdf_path}")
return pdf_path
def convert_to_pdf(source: Path, out_dir: Path) -> Path:
"""Convert an Office file to PDF. Tries LibreOffice, then format-specific COM on Windows."""
source = source.resolve()
if not source.is_file():
raise FileNotFoundError(source)
errors: list[str] = []
try:
return _convert_via_libreoffice(source, out_dir)
except Exception as exc: # noqa: BLE001 — collect and try next backend
errors.append(f"libreoffice: {exc}")
ext = source.suffix.lower()
fallbacks: list = []
if ext in _PPT_EXTENSIONS:
fallbacks.append(_convert_via_powerpoint)
elif ext in _WORD_EXTENSIONS:
fallbacks.append(_convert_via_word)
for converter in fallbacks:
try:
return converter(source, out_dir)
except Exception as exc: # noqa: BLE001
errors.append(f"{converter.__name__}: {exc}")
detail = "; ".join(errors) if errors else "no converter available"
raise RuntimeError(
f"Failed to convert {source.name} to PDF. "
f"Install LibreOffice or Microsoft Office. Details: {detail}"
)
def is_presentation(path: Path) -> bool:
return path.suffix.lower() in _PPT_EXTENSIONS
def is_word_document(path: Path) -> bool:
return path.suffix.lower() in _WORD_EXTENSIONS
+5
View File
@@ -0,0 +1,5 @@
"""PyMuPDF-based PDF parsing pipeline."""
from rag_cut.parsers.pdf.pipeline import parse_pdf
__all__ = ["parse_pdf"]
+349
View File
@@ -0,0 +1,349 @@
"""Page rendering and layout analysis."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
import fitz
from rag_cut.parsers.pdf.tables import looks_like_table
_NUMBERED_LINE_RE = re.compile(
r"^\s*(\d+(?:\.\d+)*)(?:\.|.)?\s*([A-Za-z0-9\u4e00-\u9fff].{2,})$"
)
@dataclass
class TextLine:
x0: float
y0: float
x1: float
y1: float
text: str
font_size: float
@dataclass
class LayoutRegion:
x0: float
y0: float
x1: float
y1: float
kind: str # text | table | image
data: dict[str, Any] = field(default_factory=dict)
@dataclass
class PageLayout:
page_index: int
page_width: float
page_height: float
body_font_size: float
regions: list[LayoutRegion] = field(default_factory=list)
def render_page(page: fitz.Page, zoom: float = 2.0) -> fitz.Pixmap:
"""Render page to pixmap for region cropping and OCR."""
return page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
def _median_body_size(text_dict: dict) -> float:
sizes: list[float] = []
for block in text_dict.get("blocks", []):
if block.get("type") != 0:
continue
for line in block.get("lines", []):
for span in line.get("spans", []):
sizes.append(span.get("size", 12))
return sorted(sizes)[len(sizes) // 2] if sizes else 12
def _collect_text_lines(page: fitz.Page, body_size: float) -> list[TextLine]:
text_dict = page.get_text("dict")
lines: list[TextLine] = []
for block in text_dict.get("blocks", []):
if block.get("type") != 0:
continue
for line in block.get("lines", []):
text = "".join(span.get("text", "") for span in line.get("spans", [])).strip()
if not text:
continue
x0, y0, x1, y1 = line["bbox"]
max_size = body_size
for span in line.get("spans", []):
max_size = max(max_size, span.get("size", body_size))
lines.append(TextLine(x0=x0, y0=y0, x1=x1, y1=y1, text=text, font_size=max_size))
return lines
def _detect_columns(
items: list[tuple[float, float, float, float]],
page_width: float,
) -> list[tuple[float, float, float, float]]:
if not items:
return items
mid = page_width / 2
left = [b for b in items if (b[0] + b[2]) / 2 < mid]
right = [b for b in items if (b[0] + b[2]) / 2 >= mid]
if len(left) >= 2 and len(right) >= 2:
left.sort(key=lambda b: (b[1], b[0]))
right.sort(key=lambda b: (b[1], b[0]))
return left + right
return sorted(items, key=lambda b: (b[1], b[0]))
def _sort_reading_order(
items: list[tuple[float, float, float, float]],
page_width: float,
) -> list[tuple[float, float, float, float]]:
"""Sort page boxes in a human reading order, preserving two-column flows."""
if not items:
return []
mid = page_width / 2
full_width: list[tuple[float, float, float, float]] = []
column_items: list[tuple[float, float, float, float]] = []
for box in items:
width = box[2] - box[0]
spans_mid = box[0] < mid < box[2]
if width >= page_width * 0.60 or (spans_mid and width >= page_width * 0.35):
full_width.append(box)
else:
column_items.append(box)
left = [b for b in column_items if (b[0] + b[2]) / 2 < mid]
right = [b for b in column_items if (b[0] + b[2]) / 2 >= mid]
if len(left) < 2 or len(right) < 2:
return sorted(items, key=lambda b: (b[1], b[0]))
full_width.sort(key=lambda b: (b[1], b[0]))
ordered: list[tuple[float, float, float, float]] = []
segment_top = float("-inf")
def add_columns_between(top: float, bottom: float) -> None:
segment = [b for b in column_items if b[1] >= top and b[1] < bottom]
segment_left = sorted([b for b in segment if (b[0] + b[2]) / 2 < mid], key=lambda b: (b[1], b[0]))
segment_right = sorted([b for b in segment if (b[0] + b[2]) / 2 >= mid], key=lambda b: (b[1], b[0]))
ordered.extend(segment_left)
ordered.extend(segment_right)
for box in full_width:
add_columns_between(segment_top, box[1])
ordered.append(box)
segment_top = box[3]
add_columns_between(segment_top, float("inf"))
seen: set[tuple[float, float, float, float]] = set(ordered)
ordered.extend(b for b in sorted(items, key=lambda b: (b[1], b[0])) if b not in seen)
return ordered
def _rect_overlap(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
x0 = max(a[0], b[0])
y0 = max(a[1], b[1])
x1 = min(a[2], b[2])
y1 = min(a[3], b[3])
if x1 <= x0 or y1 <= y0:
return 0.0
inter = (x1 - x0) * (y1 - y0)
area_a = max((a[2] - a[0]) * (a[3] - a[1]), 1e-6)
return inter / area_a
def _center_inside(inner: tuple[float, float, float, float], outer: tuple[float, float, float, float]) -> bool:
cx = (inner[0] + inner[2]) / 2
cy = (inner[1] + inner[3]) / 2
return outer[0] <= cx <= outer[2] and outer[1] <= cy <= outer[3]
def _horizontal_overlap_ratio(a: TextLine, b: TextLine) -> float:
overlap = min(a.x1, b.x1) - max(a.x0, b.x0)
if overlap <= 0:
return 0.0
return overlap / max(min(a.x1 - a.x0, b.x1 - b.x0), 1e-6)
def _line_on_image(
line: TextLine,
image_boxes: list[tuple[float, float, float, float]],
large_image_boxes: list[tuple[float, float, float, float]],
) -> bool:
# Never drop numbered section titles even if they sit near a screenshot.
if _NUMBERED_LINE_RE.match(line.text.strip()):
return False
bbox = (line.x0, line.y0, line.x1, line.y1)
# Large background/screenshot: require near-full coverage of the line, not
# merely center-inside (which wiped text sitting in margins of wide figures).
if large_image_boxes and any(
_center_inside(bbox, box) and _rect_overlap(bbox, box) >= 0.85 for box in large_image_boxes
):
return True
return any(_rect_overlap(bbox, box) >= 0.55 for box in image_boxes)
def _area(box: tuple[float, float, float, float]) -> float:
return max(box[2] - box[0], 0.0) * max(box[3] - box[1], 0.0)
def _filter_nested_image_regions(regions: list[LayoutRegion]) -> list[LayoutRegion]:
"""Drop image fragments that are already contained in a larger screenshot."""
result: list[LayoutRegion] = []
boxes = [(r.x0, r.y0, r.x1, r.y1) for r in regions]
for region, box in zip(regions, boxes):
box_area = _area(box)
nested = False
for other in boxes:
other_area = _area(other)
if other == box or other_area <= box_area * 1.5:
continue
if _center_inside(box, other) and _rect_overlap(box, other) >= 0.85:
nested = True
break
if not nested:
result.append(region)
return result
def _merge_lines_to_paragraphs(lines: list[TextLine], page_width: float) -> list[TextLine]:
if not lines:
return []
ordered_boxes = _sort_reading_order([(ln.x0, ln.y0, ln.x1, ln.y1) for ln in lines], page_width)
order = {(b[0], b[1], b[2], b[3]): i for i, b in enumerate(ordered_boxes)}
ordered = sorted(lines, key=lambda ln: order.get((ln.x0, ln.y0, ln.x1, ln.y1), (ln.y0, ln.x0)))
paragraphs: list[TextLine] = []
current = ordered[0]
for nxt in ordered[1:]:
vgap = nxt.y0 - current.y1
line_h = max(current.y1 - current.y0, nxt.y1 - nxt.y0, 8.0)
size_gap = abs(current.font_size - nxt.font_size)
size_ratio = max(current.font_size, nxt.font_size) / max(
min(current.font_size, nxt.font_size), 1e-6
)
# Keep title vs body separate: small absolute gap is enough when ratio is large.
same_style = size_gap <= 1.0 and size_ratio <= 1.15
either_numbered = bool(
_NUMBERED_LINE_RE.match(current.text.strip()) or _NUMBERED_LINE_RE.match(nxt.text.strip())
)
if (
same_style
and not either_numbered
and vgap <= line_h * 2.2
and _horizontal_overlap_ratio(current, nxt) >= 0.25
):
joiner = "" if current.text.endswith("-") or current.text.endswith(" ") else " "
current = TextLine(
x0=min(current.x0, nxt.x0),
y0=current.y0,
x1=max(current.x1, nxt.x1),
y1=nxt.y1,
text=f"{current.text}{joiner}{nxt.text}",
font_size=max(current.font_size, nxt.font_size),
)
else:
paragraphs.append(current)
current = nxt
paragraphs.append(current)
return paragraphs
def analyze_page_layout(page: fitz.Page, page_index: int) -> PageLayout:
"""Layout analysis: text paragraphs, table regions, image regions."""
text_dict = page.get_text("dict")
body_size = _median_body_size(text_dict)
page_width = page.rect.width
page_height = page.rect.height
page_area = page_width * page_height
table_regions: list[LayoutRegion] = []
try:
table_finder = page.find_tables()
for idx, table in enumerate(table_finder.tables):
bbox = table.bbox
rows = table.extract() or []
if not looks_like_table(rows):
continue
table_regions.append(
LayoutRegion(
x0=bbox[0],
y0=bbox[1],
x1=bbox[2],
y1=bbox[3],
kind="table",
data={"rows": rows, "table_index": idx, "source": "pymupdf"},
)
)
except Exception:
pass
image_regions: list[LayoutRegion] = []
for img_info in page.get_images(full=True):
xref = img_info[0]
try:
rects = page.get_image_rects(xref)
except Exception:
rects = []
if not rects:
continue
for rect_idx, rect in enumerate(rects):
image_regions.append(
LayoutRegion(
x0=rect.x0,
y0=rect.y0,
x1=rect.x1,
y1=rect.y1,
kind="image",
data={"xref": xref, "rect_index": rect_idx},
)
)
image_regions = _filter_nested_image_regions(image_regions)
image_boxes = [(r.x0, r.y0, r.x1, r.y1) for r in image_regions]
large_image_boxes = [
box
for box in image_boxes
if (box[2] - box[0]) * (box[3] - box[1]) >= page_area * 0.12
]
table_boxes = [(r.x0, r.y0, r.x1, r.y1) for r in table_regions]
raw_lines = _collect_text_lines(page, body_size)
filtered_lines = [
ln
for ln in raw_lines
if not _line_on_image(ln, image_boxes, large_image_boxes)
and not any(_rect_overlap((ln.x0, ln.y0, ln.x1, ln.y1), box) >= 0.55 for box in table_boxes)
]
paragraphs = _merge_lines_to_paragraphs(filtered_lines, page_width)
text_regions: list[LayoutRegion] = []
for para in paragraphs:
text_regions.append(
LayoutRegion(
x0=para.x0,
y0=para.y0,
x1=para.x1,
y1=para.y1,
kind="text",
data={"text": para.text, "font_size": para.font_size},
)
)
regions = text_regions + table_regions + image_regions
ordered_boxes = _sort_reading_order(
[(r.x0, r.y0, r.x1, r.y1) for r in regions],
page_width,
)
order = {box: i for i, box in enumerate(ordered_boxes)}
regions.sort(key=lambda r: order.get((r.x0, r.y0, r.x1, r.y1), len(order)))
return PageLayout(
page_index=page_index,
page_width=page_width,
page_height=page_height,
body_font_size=body_size,
regions=regions,
)
+446
View File
@@ -0,0 +1,446 @@
"""Filter page headers, footers, page numbers and other margin noise from PDF blocks."""
from __future__ import annotations
import re
from collections import defaultdict
from rag_cut.models import Block, BlockType
HEADER_ZONE_RATIO = 0.12
FOOTER_ZONE_RATIO = 0.10
RUNNING_HEADER_MIN_PAGES = 3
RUNNING_HEADER_PAGE_RATIO = 0.5
MAX_RUNNING_HEADER_LEN = 48
MIN_RUNNING_HEADER_LEN = 3
# Drop decorative fragments (icons/logos) from layout detectors like MinerU.
MIN_IMAGE_SIDE = 40.0
MIN_IMAGE_AREA = 1600.0
# Body paragraphs shorter than this can still exit a TOC zone when they
# clearly are not directory entries (keeps in-section catalogs like 形態指標).
TOC_BODY_EXIT_CHARS = 48
TOC_PAGE_ENTRY_RATIO = 0.55
TOC_PAGE_MIN_ENTRIES = 3
_PAGE_NUM_RE = re.compile(r"^\d{1,4}$")
_NUMBERED_SECTION_RE = re.compile(
r"^\s*(\d+(?:\.\d+)*)(?:\.|.)?\s*([A-Za-z0-9\u4e00-\u9fff][A-Za-z0-9\u4e00-\u9fff&/ \-_::]{2,})\s*$"
)
# Cover-page / front-matter directory headings only (exact-ish).
_TOC_TITLE_RE = re.compile(
r"^\s*(?:"
r"contents|table\s+of\s+contents|toc|"
r"\u76ee\u5f55|\u76ee\u9304|\u76ee\u6b21|" # 目录 / 目錄 / 目次
r"\u7ae0\u8282\u76ee\u5f55|\u7ae0\u7bc0\u76ee\u9304|" # 章节目录 / 章節目錄
r"list\s+of\s+(?:figures|tables|contents)"
r")[\s.::·•…-]*$",
re.I,
)
_DOT_LEADER_RE = re.compile(r"(?:\.{2,}|\u2026{2,}|\u00b7{2,}|\u2022{2,})")
# Classic TOC line: title …… 12 / 1. Login ..... 3
_TOC_ENTRY_LINE_RE = re.compile(
r"^\s*.{1,120}?"
r"(?:"
r"(?:\.{2,}|\u2026{2,}|\u00b7{2,}|\s{2,})"
r"\s*\d{1,4}"
r"|"
r"(?:\.{2,}|\u2026+)\s*\d{1,4}"
r")"
r"\s*$"
)
# Numbered entry with trailing page: "1.1 Account Status 12"
_TOC_NUMBERED_PAGE_RE = re.compile(
r"^\s*\d+(?:\.\d+)*(?:[\..]\s*|\s+)"
r".{1,100}?"
r"(?:\s{2,}|\s+)"
r"\d{1,4}\s*$"
)
_MD_TOC_LINK_RE = re.compile(r"^\s*[-*+]\s+\[[^\]]+\]\([^)]+\)\s*$")
def _looks_like_numbered_section(text: str) -> bool:
match = _NUMBERED_SECTION_RE.match(text.strip())
return bool(match and len(match.group(2).strip()) >= 3)
def _norm_text(text: str) -> str:
return " ".join((text or "").split())
def is_toc_title_text(text: str) -> bool:
"""True for standalone 目录 / Contents / TOC headings."""
return bool(_TOC_TITLE_RE.match(_norm_text(text)))
def is_toc_entry_line(text: str) -> bool:
"""True for a single TOC row (leaders / trailing page number / md link)."""
stripped = (text or "").strip()
if not stripped or len(stripped) > 200:
return False
if is_toc_title_text(stripped):
return False
if _MD_TOC_LINK_RE.match(stripped):
return True
if _DOT_LEADER_RE.search(stripped) and re.search(r"\d\s*$", stripped):
return True
if _TOC_ENTRY_LINE_RE.match(stripped):
return True
if _TOC_NUMBERED_PAGE_RE.match(stripped):
return True
return False
def is_toc_noise_text(text: str) -> bool:
"""True if the whole block text is a TOC title or TOC entries."""
stripped = (text or "").strip()
if not stripped:
return False
if is_toc_title_text(stripped):
return True
lines = [ln.strip() for ln in stripped.splitlines() if ln.strip()]
if not lines:
return False
if len(lines) == 1:
return is_toc_entry_line(lines[0])
hits = sum(1 for ln in lines if is_toc_entry_line(ln) or is_toc_title_text(ln))
return hits >= max(2, int(len(lines) * 0.6))
def _is_toc_zone_exit_block(block: Block) -> bool:
"""Substantial body / media ends a front-matter TOC stretch."""
if block.type in {BlockType.IMAGE, BlockType.TABLE}:
return True
text = (block.text or block.markdown or "").strip()
if not text or is_toc_noise_text(text):
return False
# Real section start right after TOC (no page-number trailer).
if _looks_like_numbered_section(text) and not is_toc_entry_line(text):
return True
if len(text) >= TOC_BODY_EXIT_CHARS and not is_toc_entry_line(text):
return True
return False
def _toc_heavy_pages(blocks: list[Block]) -> set[int]:
"""Pages dominated by directory lines are dropped wholesale (text only)."""
by_page: dict[int, list[Block]] = defaultdict(list)
for block in blocks:
if block.type in {BlockType.IMAGE, BlockType.TABLE}:
continue
page = block.meta.get("page")
if page is None:
continue
try:
page_key = int(page)
except (TypeError, ValueError):
continue
by_page[page_key].append(block)
heavy: set[int] = set()
for page_key, page_blocks in by_page.items():
texts = [(b.text or b.markdown or "").strip() for b in page_blocks]
texts = [t for t in texts if t]
if not texts:
continue
entry_hits = sum(1 for t in texts if is_toc_noise_text(t))
has_title = any(is_toc_title_text(t) for t in texts)
if entry_hits >= TOC_PAGE_MIN_ENTRIES and entry_hits / len(texts) >= TOC_PAGE_ENTRY_RATIO:
heavy.add(page_key)
elif has_title and entry_hits >= 1 and entry_hits / len(texts) >= 0.4:
heavy.add(page_key)
return heavy
def _toc_noise_bottoms(blocks: list[Block]) -> dict[int, float]:
"""Lowest TOC text position per page, so real content below it survives."""
bottoms: dict[int, float] = {}
for block in blocks:
if block.type in {BlockType.IMAGE, BlockType.TABLE}:
continue
text = (block.text or block.markdown or "").strip()
if not is_toc_noise_text(text):
continue
page = block.meta.get("page")
bbox = block.meta.get("bbox")
if page is None or not isinstance(bbox, (list, tuple)) or len(bbox) < 4:
continue
try:
page_key = int(page)
bottom = float(bbox[3])
except (TypeError, ValueError):
continue
bottoms[page_key] = max(bottoms.get(page_key, 0.0), bottom)
return bottoms
def filter_toc_blocks(blocks: list[Block]) -> list[Block]:
"""Drop document directories (目录 / Contents) — never chunk them.
Keeps in-section numbered catalogs without page leaders (e.g. 形態指標 list).
"""
if not blocks:
return blocks
toc_pages = _toc_heavy_pages(blocks)
toc_bottoms = _toc_noise_bottoms(blocks)
filtered: list[Block] = []
in_toc = False
for block in blocks:
page = block.meta.get("page")
try:
page_key = int(page) if page is not None else None
except (TypeError, ValueError):
page_key = None
text = (block.text or block.markdown or "").strip()
if page_key is not None and page_key in toc_pages and block.type not in {
BlockType.IMAGE,
BlockType.TABLE,
}:
bbox = block.meta.get("bbox")
toc_bottom = toc_bottoms.get(page_key)
if (
toc_bottom is None
or not isinstance(bbox, (list, tuple))
or len(bbox) < 4
or float(bbox[1]) <= toc_bottom
):
continue
if is_toc_title_text(text):
in_toc = True
continue
if in_toc:
if _is_toc_zone_exit_block(block):
in_toc = False
filtered.append(block)
continue
if is_toc_noise_text(text) or is_toc_entry_line(text):
continue
# Ambiguous short line right after TOC: treat as first real section.
in_toc = False
filtered.append(block)
continue
if is_toc_noise_text(text):
continue
filtered.append(block)
return filtered
def _bbox4(block: Block) -> list[float] | None:
bbox = block.meta.get("bbox")
if not isinstance(bbox, (list, tuple)) or len(bbox) < 4:
return None
try:
return [float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])]
except (TypeError, ValueError):
return None
def _bbox_area(bbox: list[float]) -> float:
return max(bbox[2] - bbox[0], 0.0) * max(bbox[3] - bbox[1], 0.0)
def _center_inside(inner: list[float], outer: list[float]) -> bool:
cx = (inner[0] + inner[2]) / 2
cy = (inner[1] + inner[3]) / 2
return outer[0] <= cx <= outer[2] and outer[1] <= cy <= outer[3]
def _overlap_ratio(inner: list[float], outer: list[float]) -> float:
x0 = max(inner[0], outer[0])
y0 = max(inner[1], outer[1])
x1 = min(inner[2], outer[2])
y1 = min(inner[3], outer[3])
if x1 <= x0 or y1 <= y0:
return 0.0
inter = (x1 - x0) * (y1 - y0)
return inter / max(_bbox_area(inner), 1e-6)
def is_tiny_image_block(block: Block) -> bool:
"""True for tiny image fragments that are usually logos/icons, not content figures."""
if block.type != BlockType.IMAGE:
return False
bbox = _bbox4(block)
if not bbox:
return False
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
if width <= 0 or height <= 0:
return True
if width < MIN_IMAGE_SIDE and height < MIN_IMAGE_SIDE:
return True
return width * height < MIN_IMAGE_AREA
def filter_nested_image_blocks(blocks: list[Block]) -> list[Block]:
"""Drop image fragments whose center lies inside a larger same-page image."""
image_boxes: list[tuple[int, int, list[float], float]] = []
for idx, block in enumerate(blocks):
if block.type != BlockType.IMAGE:
continue
bbox = _bbox4(block)
page = block.meta.get("page")
if bbox is None or page is None:
continue
try:
page_key = int(page)
except (TypeError, ValueError):
continue
image_boxes.append((idx, page_key, bbox, _bbox_area(bbox)))
drop: set[int] = set()
for idx, page_key, bbox, area in image_boxes:
for other_idx, other_page, other_bbox, other_area in image_boxes:
if idx == other_idx or page_key != other_page or other_area <= area * 1.5:
continue
if _center_inside(bbox, other_bbox) and _overlap_ratio(bbox, other_bbox) >= 0.85:
drop.add(idx)
break
if not drop:
return blocks
return [block for idx, block in enumerate(blocks) if idx not in drop]
def _infer_page_heights(blocks: list[Block]) -> dict[int, float]:
heights: dict[int, float] = {}
for block in blocks:
page = block.meta.get("page")
if page is None:
continue
try:
page_key = int(page)
except (TypeError, ValueError):
continue
explicit = block.meta.get("page_height")
if explicit:
heights[page_key] = max(heights.get(page_key, 0), float(explicit))
bbox = block.meta.get("bbox")
if bbox and len(bbox) >= 4:
heights[page_key] = max(heights.get(page_key, 0), float(bbox[3]))
return {page: max(height, 1.0) for page, height in heights.items()}
def _page_height(block: Block, heights: dict[int, float]) -> float | None:
page = block.meta.get("page")
if page is None:
explicit = block.meta.get("page_height")
return float(explicit) if explicit else None
try:
page_key = int(page)
except (TypeError, ValueError):
return block.meta.get("page_height")
return heights.get(page_key) or block.meta.get("page_height")
def _in_margin_zone(bbox: list[float], page_height: float, *, header: bool) -> bool:
y_mid = (float(bbox[1]) + float(bbox[3])) / 2
if header:
return y_mid < page_height * HEADER_ZONE_RATIO
return y_mid > page_height * (1 - FOOTER_ZONE_RATIO)
def is_margin_noise_block(block: Block, page_height: float | None) -> bool:
"""Heuristic margin noise filter for blocks missing explicit MinerU region types."""
if block.type == BlockType.IMAGE:
return is_tiny_image_block(block)
if block.type == BlockType.TABLE:
return False
text = (block.text or "").strip()
if not text:
return False
bbox = block.meta.get("bbox")
if not page_height or not bbox or len(bbox) < 4:
return block.type == BlockType.PARAGRAPH and bool(_PAGE_NUM_RE.match(text))
in_header = _in_margin_zone(bbox, page_height, header=True)
in_footer = _in_margin_zone(bbox, page_height, header=False)
if not in_header and not in_footer:
return False
if _PAGE_NUM_RE.match(text):
return True
if block.type != BlockType.PARAGRAPH:
return False
# Numbered section titles often sit at the top of a continued page — keep them.
if _looks_like_numbered_section(text):
return False
return len(text) <= MAX_RUNNING_HEADER_LEN
def detect_running_header_texts(blocks: list[Block]) -> set[str]:
"""Texts that repeat across many pages are likely running headers/footers.
Only paragraphs in the header/footer margin bands are considered, so
real section headings that happen to repeat are not wiped document-wide.
"""
pages_by_text: dict[str, set[int]] = defaultdict(set)
all_pages: set[int] = set()
heights = _infer_page_heights(blocks)
for block in blocks:
if block.type != BlockType.PARAGRAPH:
continue
text = (block.text or "").strip()
if not (MIN_RUNNING_HEADER_LEN <= len(text) <= MAX_RUNNING_HEADER_LEN):
continue
if _looks_like_numbered_section(text):
continue
page = block.meta.get("page")
if page is None:
continue
try:
page_key = int(page)
except (TypeError, ValueError):
continue
page_height = _page_height(block, heights)
bbox = _bbox4(block)
if page_height and bbox:
in_margin = _in_margin_zone(bbox, float(page_height), header=True) or _in_margin_zone(
bbox, float(page_height), header=False
)
if not in_margin:
continue
all_pages.add(page_key)
pages_by_text[text].add(page_key)
if len(all_pages) < RUNNING_HEADER_MIN_PAGES:
return set()
threshold = max(RUNNING_HEADER_MIN_PAGES, int(len(all_pages) * RUNNING_HEADER_PAGE_RATIO))
return {text for text, pages in pages_by_text.items() if len(pages) >= threshold}
def filter_noise_blocks(blocks: list[Block]) -> list[Block]:
"""Remove headers, footers, page numbers, TOC and other repeated margin noise."""
if not blocks:
return blocks
heights = _infer_page_heights(blocks)
running_headers = detect_running_header_texts(blocks)
filtered: list[Block] = []
for block in blocks:
text = (block.text or "").strip()
# Never wipe real section headings via running-header equality.
if text and text in running_headers and block.type == BlockType.PARAGRAPH:
continue
page_height = _page_height(block, heights)
if is_margin_noise_block(block, float(page_height) if page_height else None):
continue
filtered.append(block)
return filter_nested_image_blocks(filter_toc_blocks(filtered))
+57
View File
@@ -0,0 +1,57 @@
"""Optional OCR for cropped image/table regions."""
from __future__ import annotations
import io
from pathlib import Path
import fitz
def ocr_pixmap(pix: fitz.Pixmap) -> str:
"""Run OCR on a pixmap; returns empty string when OCR is unavailable."""
try:
import pytesseract
from PIL import Image
except ImportError:
return ""
try:
image = Image.open(io.BytesIO(pix.tobytes("png")))
return _ocr_pil(image)
except Exception:
return ""
def ocr_image_file(path: Path) -> str:
try:
import pytesseract # noqa: F401
from PIL import Image
except ImportError:
return ""
try:
return _ocr_pil(Image.open(path))
except Exception:
return ""
def _ocr_pil(image) -> str:
import pytesseract
for lang in ("chi_tra+eng", "chi_sim+eng", "eng"):
try:
text = pytesseract.image_to_string(image, lang=lang)
cleaned = " ".join(text.split())
if cleaned:
return cleaned
except Exception:
continue
return ""
def describe_visual(ocr_text: str, kind: str, label: str) -> str:
"""Lightweight image/table description without an external vision model."""
if ocr_text:
return f"{kind}:{ocr_text[:300]}"
return f"{kind}:{label}"
+213
View File
@@ -0,0 +1,213 @@
"""End-to-end PyMuPDF PDF pipeline matching the architecture diagram."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import pdfplumber
from rag_cut.models import Block
from rag_cut.parsers.pdf.layout import (
LayoutRegion,
PageLayout,
_rect_overlap,
_sort_reading_order,
analyze_page_layout,
render_page,
)
from rag_cut.parsers.pdf.standardize import open_document
from rag_cut.parsers.pdf.tables import rows_to_markdown
from rag_cut.parsers.pdf.text_extract import extract_text_blocks
from rag_cut.parsers.pdf.visual_extract import extract_visual_blocks
@dataclass
class _PageItem:
y0: float
x0: float
kind: str
block: Block
def _pdfplumber_tables(path: Path) -> dict[int, list[dict]]:
"""Extract tables per page with bbox from pdfplumber."""
tables_by_page: dict[int, list[dict]] = {}
try:
with pdfplumber.open(path) as pdf:
for i, page in enumerate(pdf.pages):
found = []
try:
for idx, table in enumerate(page.find_tables()):
rows = table.extract() or []
if not rows:
continue
bbox = table.bbox
found.append(
{
"rows": rows,
"table_index": idx,
"source": "pdfplumber",
"bbox": list(bbox) if bbox else None,
}
)
except Exception:
rows_list = page.extract_tables() or []
for idx, rows in enumerate(rows_list):
found.append({"rows": rows, "table_index": idx, "source": "pdfplumber", "bbox": None})
if found:
tables_by_page[i] = found
except Exception:
pass
return tables_by_page
def _inject_pdfplumber_tables(layout: PageLayout, plumber_tables: list[dict]) -> PageLayout:
if not plumber_tables:
return layout
if any(r.kind == "table" for r in layout.regions):
return layout
for item in plumber_tables:
rows = item.get("rows") or []
md = rows_to_markdown(rows)
if not md:
continue
bbox = item.get("bbox")
if bbox and len(bbox) == 4:
x0, y0, x1, y1 = bbox
else:
idx = int(item.get("table_index", 0))
x0, y0 = 0, layout.page_height * (idx + 1) / (len(plumber_tables) + 1)
x1, y1 = layout.page_width, layout.page_height * (idx + 2) / (len(plumber_tables) + 1)
layout.regions.append(
LayoutRegion(
x0=x0,
y0=y0,
x1=x1,
y1=y1,
kind="table",
data={
"rows": rows,
"table_index": item.get("table_index", 0),
"source": item.get("source", "pdfplumber"),
},
)
)
ordered_boxes = _sort_reading_order(
[(r.x0, r.y0, r.x1, r.y1) for r in layout.regions],
layout.page_width,
)
order = {box: i for i, box in enumerate(ordered_boxes)}
layout.regions.sort(key=lambda r: order.get((r.x0, r.y0, r.x1, r.y1), len(order)))
return layout
def _bbox_key(bbox: list[float] | tuple[float, ...]) -> tuple[float, ...]:
return tuple(round(v, 1) for v in bbox)
def _region_box(region: LayoutRegion) -> tuple[float, float, float, float]:
return (region.x0, region.y0, region.x1, region.y1)
def _match_block_to_region(
region: LayoutRegion,
candidates: list[Block],
used: set[int],
) -> Block | None:
"""Match a layout region to a parsed block via exact bbox key or overlap."""
region_box = _region_box(region)
key = _bbox_key(region_box)
for block in candidates:
if id(block) in used:
continue
bbox = block.meta.get("bbox")
if bbox and _bbox_key(bbox) == key:
return block
best: Block | None = None
best_overlap = 0.35
for block in candidates:
if id(block) in used:
continue
bbox = block.meta.get("bbox")
if not bbox:
continue
overlap = _rect_overlap(region_box, tuple(bbox))
if overlap > best_overlap:
best_overlap = overlap
best = block
return best
def _merge_page_blocks(
text_blocks: list[Block],
visual_blocks: list[Block],
layout: PageLayout,
) -> list[Block]:
"""Merge text and visual branches in page reading order."""
used: set[int] = set()
merged: list[_PageItem] = []
for region in layout.regions:
if region.kind == "text":
candidates = text_blocks
elif region.kind in {"table", "image"}:
candidates = visual_blocks
else:
continue
block = _match_block_to_region(region, candidates, used)
if block is not None:
merged.append(_PageItem(y0=region.y0, x0=region.x0, kind=region.kind, block=block))
used.add(id(block))
leftovers: list[_PageItem] = []
for block in text_blocks + visual_blocks:
if id(block) not in used:
bbox = block.meta.get("bbox", [0, 0, 0, 0])
leftovers.append(_PageItem(y0=bbox[1], x0=bbox[0], kind=block.type.value, block=block))
merged.extend(sorted(leftovers, key=lambda it: (it.y0, it.x0)))
return [it.block for it in merged]
def parse_pdf(path: Path, assets_dir: Path) -> list[Block]:
"""
PDF pipeline:
1. 文档标准化
2. 页面渲染 + 版面分析
3. 文本块提取 ∥ 图片/表格区域裁剪 + OCR
4. 章节级语义切片(合并两路结果,标注章节上下文)
"""
assets_dir.mkdir(parents=True, exist_ok=True)
renders_dir = assets_dir / "pages"
renders_dir.mkdir(parents=True, exist_ok=True)
doc = open_document(path)
plumber_tables = _pdfplumber_tables(path)
all_blocks: list[Block] = []
chapter_title: str | None = None
img_counter = 0
try:
for page_index, page in enumerate(doc):
layout = analyze_page_layout(page, page_index)
layout = _inject_pdfplumber_tables(layout, plumber_tables.get(page_index, []))
page_render = render_page(page)
render_path = renders_dir / f"page{page_index + 1}.png"
page_render.save(str(render_path))
text_blocks, chapter_title = extract_text_blocks(layout, chapter_title)
visual_blocks, img_counter = extract_visual_blocks(
page, layout, doc, assets_dir, chapter_title, img_counter
)
page_blocks = _merge_page_blocks(text_blocks, visual_blocks, layout)
all_blocks.extend(page_blocks)
finally:
doc.close()
return all_blocks
@@ -0,0 +1,16 @@
"""Document standardization for PDF input."""
from __future__ import annotations
from pathlib import Path
import fitz
def open_document(path: Path) -> fitz.Document:
"""Open and normalize a PDF for downstream layout processing."""
doc = fitz.open(path)
if doc.is_encrypted and not doc.authenticate(""):
doc.close()
raise ValueError(f"Encrypted PDF cannot be opened: {path.name}")
return doc
@@ -0,0 +1,104 @@
"""Build rich table blocks from PDF layout regions."""
from __future__ import annotations
from pathlib import Path
import fitz
from rag_cut.models import Block, BlockType
from rag_cut.parsers.pdf.layout import LayoutRegion, PageLayout
from rag_cut.parsers.pdf.ocr import describe_visual, ocr_pixmap
from rag_cut.parsers.pdf.tables import (
detect_header_row_count,
extract_table_keywords,
header_signature,
normalize_rows,
rows_to_markdown,
split_body_and_footnotes,
)
def _crop_region(page: fitz.Page, region: LayoutRegion, zoom: float = 2.0) -> fitz.Pixmap:
clip = fitz.Rect(region.x0, region.y0, region.x1, region.y1)
return page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), clip=clip, alpha=False)
def _save_pixmap(pix: fitz.Pixmap, path: Path) -> None:
if pix.n - pix.alpha > 3:
pix = fitz.Pixmap(fitz.csRGB, pix)
pix.save(str(path))
def build_table_block(
page: fitz.Page,
region: LayoutRegion,
layout: PageLayout,
assets_dir: Path,
chapter_title: str | None,
) -> Block | None:
"""Extract a table block with rows, screenshot, OCR and structural metadata."""
raw_rows = region.data.get("rows") or []
normalized = normalize_rows(raw_rows)
if not normalized:
return None
header_rows = detect_header_row_count(normalized)
body_rows, footnote_rows, footnotes = split_body_and_footnotes(normalized, header_rows)
data_rows = normalized[:header_rows] + body_rows
if not data_rows:
return None
page_no = layout.page_index + 1
table_index = int(region.data.get("table_index", 0)) + 1
crop_id = f"page{page_no}_table{table_index}.png"
crops_dir = assets_dir / "crops"
crops_dir.mkdir(parents=True, exist_ok=True)
crop_path = crops_dir / crop_id
ocr_text = ""
try:
crop_pix = _crop_region(page, region)
_save_pixmap(crop_pix, crop_path)
ocr_text = ocr_pixmap(crop_pix)
except Exception:
crop_path_str = ""
else:
crop_path_str = str(crop_path)
sig = header_signature(normalized, header_rows)
md = rows_to_markdown(normalized, header_rows=header_rows, include_footnotes=footnotes)
if not md:
return None
header_text = " ".join(" ".join(r for r in row if r) for row in normalized[:header_rows])
keywords = extract_table_keywords(header_text, md, footnotes, ocr_text)
meta: dict = {
"page": page_no,
"pages": [page_no],
"bbox": [region.x0, region.y0, region.x1, region.y1],
"bboxes": [{"page": page_no, "bbox": [region.x0, region.y0, region.x1, region.y1]}],
"table_source": region.data.get("source", "pymupdf"),
"crop_path": crop_path_str,
"rows": normalized,
"row_count": len(normalized),
"col_count": max((len(r) for r in normalized), default=0),
"header_rows": header_rows,
"header_signature": [list(r) for r in sig],
"footnotes": footnotes,
"keywords": keywords,
"cross_page": False,
}
if chapter_title:
meta["chapter"] = chapter_title
return Block(
type=BlockType.TABLE,
markdown=md,
ocr_text=ocr_text,
text=describe_visual(ocr_text, "表格", crop_id) if ocr_text else "",
image_id=crop_id,
image_path=crop_path_str or None,
meta=meta,
)
+177
View File
@@ -0,0 +1,177 @@
"""Cross-page table detection and merging."""
from __future__ import annotations
from rag_cut.models import Block, BlockType
from rag_cut.parsers.pdf.tables import (
detect_header_row_count,
header_signature,
normalize_rows,
rows_to_markdown,
split_body_and_footnotes,
)
def _page(block: Block) -> int | None:
page = block.meta.get("page")
return int(page) if page is not None else None
def _header_rows(block: Block) -> int:
rows = block.meta.get("rows") or []
return int(block.meta.get("header_rows") or detect_header_row_count(normalize_rows(rows)) or 1)
def _header_sig(block: Block) -> tuple[tuple[str, ...], ...]:
if block.meta.get("header_signature"):
raw = block.meta["header_signature"]
return tuple(tuple(r) for r in raw)
rows = normalize_rows(block.meta.get("rows") or [])
return header_signature(rows, _header_rows(block))
def _column_count(block: Block) -> int:
rows = normalize_rows(block.meta.get("rows") or [])
return max((len(r) for r in rows), default=0)
def _similar_columns(a: Block, b: Block) -> bool:
ca, cb = _column_count(a), _column_count(b)
if ca == 0 or cb == 0:
return False
return ca == cb or abs(ca - cb) <= 1
def _repeated_header(rows: list[list[str]], sig: tuple[tuple[str, ...], ...]) -> int:
"""Return number of leading rows in `rows` that repeat the header signature."""
if not sig:
return 0
n = len(sig)
if len(rows) < n:
return 0
if header_signature(rows, n) == sig:
return n
if n == 1 and rows and tuple(rows[0]) == sig[0]:
return 1
return 0
def _can_merge_continuation(prev: Block, nxt: Block) -> bool:
if prev.type != BlockType.TABLE or nxt.type != BlockType.TABLE:
return False
prev_page = _page(prev)
nxt_page = _page(nxt)
if prev_page is None or nxt_page is None or nxt_page != prev_page + 1:
return False
if not _similar_columns(prev, nxt):
return False
sig = _header_sig(prev)
if not sig:
return False
rows_b = normalize_rows(nxt.meta.get("rows") or [])
if not rows_b:
return False
if _repeated_header(rows_b, sig) > 0:
return True
# Continuation without repeated header: similar width and no title on next table
if nxt.meta.get("table_title"):
return False
prev_bbox = prev.meta.get("bbox") or []
nxt_bbox = nxt.meta.get("bbox") or []
if len(prev_bbox) == 4 and len(nxt_bbox) == 4:
prev_width = prev_bbox[2] - prev_bbox[0]
nxt_width = nxt_bbox[2] - nxt_bbox[0]
if prev_width > 0 and abs(prev_width - nxt_width) / prev_width <= 0.15:
return True
return _column_count(prev) == _column_count(nxt)
def _merge_two_tables(prev: Block, nxt: Block) -> Block:
rows_a = normalize_rows(prev.meta.get("rows") or [])
rows_b = normalize_rows(nxt.meta.get("rows") or [])
header_rows = _header_rows(prev)
sig = _header_sig(prev)
skip = _repeated_header(rows_b, sig)
merged_rows = rows_a + rows_b[skip:]
body_rows, _, foot_a = split_body_and_footnotes(rows_a, header_rows)
_, _, foot_b = split_body_and_footnotes(rows_b, skip or header_rows)
footnotes = " ".join(x for x in (prev.meta.get("footnotes") or foot_a, foot_b) if x).strip()
pages = sorted(set((prev.meta.get("pages") or [_page(prev)]) + [_page(nxt)]))
pages = [p for p in pages if p is not None]
bboxes = list(prev.meta.get("bboxes") or [])
if prev.meta.get("bbox"):
bboxes.append({"page": _page(prev), "bbox": prev.meta["bbox"]})
if nxt.meta.get("bbox"):
bboxes.append({"page": _page(nxt), "bbox": nxt.meta["bbox"]})
prev_bbox = prev.meta.get("bbox") or [0, 0, 0, 0]
nxt_bbox = nxt.meta.get("bbox") or prev_bbox
merged_bbox = [
min(prev_bbox[0], nxt_bbox[0]),
min(prev_bbox[1], nxt_bbox[1]),
max(prev_bbox[2], nxt_bbox[2]),
max(prev_bbox[3], nxt_bbox[3]),
]
md = rows_to_markdown(merged_rows, header_rows=header_rows, include_footnotes=footnotes)
meta = dict(prev.meta)
meta.update(
{
"rows": merged_rows,
"row_count": len(merged_rows),
"col_count": max((len(r) for r in merged_rows), default=0),
"header_rows": header_rows,
"header_signature": [list(r) for r in sig],
"footnotes": footnotes,
"pages": pages,
"page": pages[0] if pages else prev.meta.get("page"),
"bbox": merged_bbox,
"bboxes": bboxes,
"cross_page": len(pages) > 1,
"merged_table_count": int(prev.meta.get("merged_table_count") or 1) + 1,
}
)
if nxt.meta.get("following_text") and not meta.get("following_text"):
meta["following_text"] = nxt.meta.get("following_text")
return prev.model_copy(update={"markdown": md, "meta": meta})
def merge_cross_page_tables(blocks: list[Block]) -> list[Block]:
"""Merge consecutive cross-page table blocks that share headers/structure."""
if not blocks:
return []
result: list[Block] = []
i = 0
while i < len(blocks):
current = blocks[i]
if current.type != BlockType.TABLE:
result.append(current)
i += 1
continue
merged = current
j = i + 1
while j < len(blocks):
nxt = blocks[j]
if nxt.type == BlockType.TABLE and _can_merge_continuation(merged, nxt):
merged = _merge_two_tables(merged, nxt)
j += 1
continue
break
result.append(merged)
i = j
return result
+313
View File
@@ -0,0 +1,313 @@
"""Table helpers: normalization, header detection, Markdown export, cross-page merge."""
from __future__ import annotations
import re
from typing import Any
FOOTNOTE_ROW_RE = re.compile(r"^[\s*※①②③④⑤]*(?:注[::]?|备注[::]?|说明[::]?|Note[::]?)", re.I)
DATA_FIRST_CELL_RE = re.compile(r"^[a-z_][a-z0-9_.-]*$", re.I)
TABLE_TITLE_RE = re.compile(
r"^(?:表\s*\d+[::.]?|Table\s*\d+[::.]?|图\s*\d+[::.]?)?\s*.{2,80}$",
re.I,
)
KEYWORD_TERMS = (
"字段", "参数", "必填", "选填", "状态", "类型", "说明", "含义", "取值",
"field", "parameter", "required", "optional", "status", "description",
)
def normalize_cell(value: str | None) -> str:
"""Merge in-cell line breaks; escape pipe chars for Markdown tables."""
if not value:
return ""
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
parts = [p.strip() for p in text.split("\n") if p.strip()]
merged = " ".join(parts) if parts else ""
return merged.replace("|", "\\|")
def normalize_rows(rows: list[list[str | None]]) -> list[list[str]]:
"""Pad columns and normalize every cell without losing row alignment."""
if not rows:
return []
cleaned = [[normalize_cell(c) for c in row] for row in rows]
col_count = max((len(r) for r in cleaned), default=0)
return [row + [""] * (col_count - len(row)) for row in cleaned]
def looks_like_table(rows: list[list[str | None]]) -> bool:
"""Return True only for rows that have a real table-like grid."""
if len(rows) < 2:
return False
cleaned = normalize_rows(rows)
col_count = max((len(r) for r in cleaned), default=0)
if col_count < 2:
return False
non_empty_cells = sum(1 for row in cleaned for cell in row if cell)
rows_with_two_cells = sum(1 for row in cleaned if sum(1 for cell in row if cell) >= 2)
return non_empty_cells >= 4 and rows_with_two_cells >= 2
DESCRIPTION_HINTS = ("必填", "选填", "格式要求", "required", "optional", "格式", "用于标识")
QA_HEADER_TERMS = ("query", "question", "用户输入", "reference_output", "answer", "标准答案", "session")
def _row_fill(row: list[str]) -> int:
return sum(1 for c in row if c)
def looks_like_column_header_row(row: list[str]) -> bool:
"""True when a row looks like short spreadsheet column names."""
filled = [c.strip() for c in row if c and c.strip()]
if len(filled) < 2:
return False
if any(len(c) > 40 for c in filled):
return False
identifier_like = sum(
1
for c in filled
if DATA_FIRST_CELL_RE.match(c) or re.match(r"^[a-z][a-z0-9_]*$", c, re.I)
)
return identifier_like >= max(2, (len(filled) + 1) // 2)
def looks_like_description_row(row: list[str]) -> bool:
"""True when a row is a template field-description line (not data/header)."""
filled = [c.strip() for c in row if c and c.strip()]
if not filled:
return False
if max(len(c) for c in filled) >= 48:
return True
return sum(1 for c in filled if any(h in c for h in DESCRIPTION_HINTS)) >= 2
def detect_header_row_count(rows: list[list[str]]) -> int:
"""Detect 1-2 header rows from content patterns."""
if len(rows) < 2:
return 1 if rows else 0
first_fill = _row_fill(rows[0])
second_fill = _row_fill(rows[1]) if len(rows) > 1 else 0
if first_fill < 2:
return 0
if len(rows) > 2 and second_fill >= 2:
first_short = all(len(c) <= 24 for c in rows[0] if c)
second_short = all(len(c) <= 24 for c in rows[1] if c)
second_is_data = bool(rows[1][0]) and DATA_FIRST_CELL_RE.match(rows[1][0])
third_data_like = _row_fill(rows[2]) >= max(1, first_fill - 1)
if first_short and second_short and third_data_like and not second_is_data:
return 2
return 1
def detect_spreadsheet_layout(rows: list[list[str]]) -> dict[str, int]:
"""
Detect spreadsheet preamble/header/data boundaries (1-based row numbers).
Common template: row 1 = field descriptions, row 2 = column names, row 3+ = data.
"""
normalized = normalize_rows(rows)
if not normalized:
return {
"preamble_rows": 0,
"header_rows": 1,
"header_row_start": 1,
"header_row_end": 1,
"data_start_row": 2,
}
preamble = 0
header_index = 0
if (
len(normalized) >= 3
and looks_like_description_row(normalized[0])
and looks_like_column_header_row(normalized[1])
):
preamble = 1
header_index = 1
header_rows = 1
else:
header_rows = detect_header_row_count(normalized)
header_index = preamble
header_end_index = header_index + header_rows - 1
data_start_index = header_end_index + 1
return {
"preamble_rows": preamble,
"header_rows": header_rows,
"header_row_start": header_index + 1,
"header_row_end": header_end_index + 1,
"data_start_row": data_start_index + 1,
}
def is_qa_style_table(rows: list[list[str]], layout: dict[str, int] | None = None) -> bool:
"""True for evaluation/Q&A sheets where each row should become one chunk."""
if not rows:
return False
layout = layout or detect_spreadsheet_layout(rows)
h_start = layout["header_row_start"] - 1
h_end = layout["header_row_end"]
header_text = " ".join(
(cell or "").lower() for row in rows[h_start:h_end] for cell in row if cell
)
return sum(1 for term in QA_HEADER_TERMS if term in header_text) >= 2
def split_body_and_footnotes(rows: list[list[str]], header_rows: int) -> tuple[list[list[str]], list[list[str]], str]:
"""Separate data rows from trailing footnote rows."""
if header_rows >= len(rows):
return [], [], ""
body = rows[header_rows:]
footnote_rows: list[list[str]] = []
while body:
first_cell = (body[-1][0] if body[-1] else "") or ""
joined = " ".join(c for c in body[-1] if c)
if FOOTNOTE_ROW_RE.match(first_cell) or FOOTNOTE_ROW_RE.match(joined):
footnote_rows.insert(0, body.pop())
elif len(joined) <= 80 and any(k in joined for k in ("注", "备注", "说明", "Note")):
footnote_rows.insert(0, body.pop())
else:
break
footnotes = " ".join(" ".join(c for c in row if c) for row in footnote_rows).strip()
return body, footnote_rows, footnotes
def header_signature(rows: list[list[str]], header_rows: int) -> tuple[tuple[str, ...], ...]:
if header_rows <= 0:
return ()
return tuple(tuple(row) for row in rows[:header_rows])
def rows_to_markdown(
rows: list[list[str | None]],
header_rows: int | None = None,
include_footnotes: str = "",
) -> str:
"""Render rows as a standard Markdown table."""
if not looks_like_table(rows):
return ""
normalized = normalize_rows(rows)
if header_rows is None:
header_rows = detect_header_row_count(normalized)
body_rows, _, inline_footnotes = split_body_and_footnotes(normalized, header_rows)
data_rows = normalized[:header_rows] + body_rows
if not data_rows:
return ""
col_count = max(len(r) for r in data_rows)
lines: list[str] = []
for i, row in enumerate(data_rows):
padded = row + [""] * (col_count - len(row))
lines.append("| " + " | ".join(padded) + " |")
if i == header_rows - 1:
lines.append("| " + " | ".join(["---"] * col_count) + " |")
md = "\n".join(lines)
footnotes = include_footnotes or inline_footnotes
if footnotes:
md += f"\n\n*{footnotes}*"
return md
def guess_table_title(text: str) -> str | None:
"""Guess table title from a short preceding line."""
cleaned = normalize_cell(text)
if not cleaned or len(cleaned) > 120:
return None
if TABLE_TITLE_RE.match(cleaned):
return cleaned
if cleaned.endswith("表") or cleaned.endswith("列表") or cleaned.endswith("说明"):
return cleaned
if re.match(r"^表\s*\d+", cleaned):
return cleaned
return None
def extract_table_keywords(*texts: str, limit: int = 20) -> list[str]:
"""Extract retrieval keywords from table title, headers and body."""
source = " ".join(t for t in texts if t)
words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}|[\u4e00-\u9fff]{2,}", source)
seen: set[str] = set()
result: list[str] = []
for word in words:
if word in seen:
continue
seen.add(word)
result.append(word)
if len(result) >= limit:
break
for term in KEYWORD_TERMS:
if term.lower() in source.lower() and term not in seen:
result.append(term)
seen.add(term)
if len(result) >= limit:
break
return result[:limit]
def build_table_embedding_text(
*,
chapter: str = "",
table_title: str = "",
markdown: str = "",
description: str = "",
footnotes: str = "",
keywords: list[str] | None = None,
ocr_text: str = "",
) -> str:
"""Compose embedding text: chapter + title + markdown + description + keywords."""
parts: list[str] = []
if chapter:
parts.append(f"章节标题:{chapter}")
if table_title:
parts.append(f"表格标题:{table_title}")
if markdown:
parts.append(markdown)
if description:
parts.append(f"表格说明:{description}")
if footnotes:
parts.append(f"脚注说明:{footnotes}")
if ocr_text:
parts.append(f"表格 OCR:{ocr_text}")
if keywords:
parts.append(f"关键词:{','.join(keywords)}")
return "\n".join(parts)
def table_meta_summary(meta: dict[str, Any]) -> dict[str, Any]:
"""Pick table-specific fields for chunk metadata."""
keys = (
"table_title",
"table_description",
"header_rows",
"header_signature",
"footnotes",
"keywords",
"chapter",
"pages",
"page",
"bbox",
"bboxes",
"crop_path",
"image_path",
"row_count",
"col_count",
"cross_page",
"table_source",
"preceding_text",
"following_text",
"nearest_heading",
"parent_heading",
)
return {k: meta[k] for k in keys if k in meta and meta[k] not in (None, "", [], {})}
+104
View File
@@ -0,0 +1,104 @@
"""Text block extraction from analyzed layout."""
from __future__ import annotations
import re
from rag_cut.models import Block, BlockType
from rag_cut.parsers.pdf.layout import LayoutRegion, PageLayout
_UI_LABEL_RE = re.compile(r"^[\d\s\W]{0,6}[\u4e00-\u9fff]{1,6}$")
# "10.上升三角形態" / "1.2 ACCOUNT STATUS" (space after number optional)
_NUMBERED_HEADING_RE = re.compile(
r"^\s*(\d+(?:\.\d+)*)(?:\.|.)?\s*([A-Za-z0-9\u4e00-\u9fff][A-Za-z0-9\u4e00-\u9fff&/ \-_::]{2,})\s*$"
)
# Title+body merges must not become headings; real section titles stay shorter.
MAX_HEADING_CHARS = 100
def _numbered_heading_level(text: str) -> int | None:
match = _NUMBERED_HEADING_RE.match(text.strip())
if not match or len(match.group(2).strip()) < 3:
return None
return match.group(1).count(".") + 1
def _looks_like_ui_label(text: str) -> bool:
"""True for short UI chips; exclude numbered / CJK section titles."""
stripped = text.strip()
if _numbered_heading_level(stripped) is not None:
return False
if re.match(r"^\d+(?:\.\d+)*(?:\.|.)", stripped):
return False
# Short Chinese section banners like 「形態指標」are not toolbar labels.
if re.fullmatch(r"[\u4e00-\u9fff]{2,12}", stripped):
return False
return bool(_UI_LABEL_RE.match(stripped))
def _font_heading_level(size: float, body_size: float, text: str) -> int | None:
stripped = text.strip()
numbered = _numbered_heading_level(stripped)
if numbered and len(stripped) <= MAX_HEADING_CHARS:
return numbered
if len(stripped) < 4 or len(stripped) > MAX_HEADING_CHARS:
return None
if _looks_like_ui_label(stripped):
return None
# Compact CJK section titles such as 「形態指標」.
if (
size >= body_size + 3
and re.fullmatch(r"[\u4e00-\u9fff]{2,12}", stripped)
and not _numbered_heading_level(stripped)
):
return 1
if size >= body_size + 6:
return 1 if len(stripped) >= 10 else 2
if size >= body_size + 3:
return 2 if len(stripped) >= 8 else 3
if size >= body_size + 1.5:
return 3
return None
def extract_text_blocks(layout: PageLayout, chapter_title: str | None = None) -> tuple[list[Block], str | None]:
"""Extract heading/paragraph blocks; update chapter title when headings appear."""
blocks: list[Block] = []
current_chapter = chapter_title
for region in layout.regions:
if region.kind != "text":
continue
text = region.data.get("text", "").strip()
if not text:
continue
font_size = float(region.data.get("font_size", layout.body_font_size))
level = _font_heading_level(font_size, layout.body_font_size, text)
page_no = layout.page_index + 1
meta = {
"page": page_no,
"bbox": [region.x0, region.y0, region.x1, region.y1],
"font_size": font_size,
"body_font_size": layout.body_font_size,
"page_height": layout.page_height,
}
if current_chapter:
meta["chapter"] = current_chapter
if level:
if level <= 2:
current_chapter = text
if current_chapter:
meta["chapter"] = current_chapter
blk = Block(type=BlockType.HEADING, text=text, level=level, meta=meta)
else:
blk = Block(type=BlockType.PARAGRAPH, text=text, meta=meta)
blocks.append(blk)
return blocks, current_chapter
@@ -0,0 +1,91 @@
"""Image/table region cropping with OCR and descriptions."""
from __future__ import annotations
from pathlib import Path
import fitz
from rag_cut.models import Block, BlockType
from rag_cut.parsers.pdf.layout import LayoutRegion, PageLayout
from rag_cut.parsers.pdf.ocr import describe_visual, ocr_image_file, ocr_pixmap
from rag_cut.parsers.pdf.table_extract import build_table_block
def _crop_region(page: fitz.Page, region: LayoutRegion, zoom: float = 2.0) -> fitz.Pixmap:
clip = fitz.Rect(region.x0, region.y0, region.x1, region.y1)
return page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), clip=clip, alpha=False)
def _save_pixmap(pix: fitz.Pixmap, path: Path) -> None:
if pix.n - pix.alpha > 3:
pix = fitz.Pixmap(fitz.csRGB, pix)
pix.save(str(path))
def extract_visual_blocks(
page: fitz.Page,
layout: PageLayout,
doc: fitz.Document,
assets_dir: Path,
chapter_title: str | None,
img_counter: int,
) -> tuple[list[Block], int]:
blocks: list[Block] = []
page_no = layout.page_index + 1
for region in layout.regions:
if region.kind == "table":
table_block = build_table_block(page, region, layout, assets_dir, chapter_title)
if table_block:
blocks.append(table_block)
continue
if region.kind != "image":
continue
img_counter += 1
xref = region.data.get("xref")
img_id = f"page{page_no}_img{img_counter}.png"
img_path = assets_dir / img_id
try:
# Prefer on-page crop so scaled/clipped placements OCR the visible region.
crop_pix = _crop_region(page, region)
_save_pixmap(crop_pix, img_path)
except Exception:
try:
pix = fitz.Pixmap(doc, xref)
if pix.n - pix.alpha > 3:
pix = fitz.Pixmap(fitz.csRGB, pix)
pix.save(str(img_path))
except Exception:
continue
ocr_text = ocr_image_file(img_path)
if not ocr_text:
try:
ocr_text = ocr_pixmap(_crop_region(page, region))
except Exception:
ocr_text = ""
meta = {
"page": page_no,
"bbox": [region.x0, region.y0, region.x1, region.y1],
"xref": xref,
}
if chapter_title:
meta["chapter"] = chapter_title
blocks.append(
Block(
type=BlockType.IMAGE,
image_id=img_id,
image_path=str(img_path),
ocr_text=ocr_text,
text=describe_visual(ocr_text, "图片", img_id),
meta=meta,
)
)
return blocks, img_counter
+31
View File
@@ -0,0 +1,31 @@
"""PDF parser entry point — delegates to the PyMuPDF pipeline."""
from __future__ import annotations
import os
from pathlib import Path
from rag_cut.models import Block
from rag_cut.parsers.base import BaseParser
from rag_cut.parsers.mineru_adapter import parse_pdf_with_mineru
from rag_cut.parsers.pdf.noise_filter import filter_noise_blocks
from rag_cut.parsers.pdf.pipeline import parse_pdf
class PdfParser(BaseParser):
def parse(self, path: Path, assets_dir: Path) -> list[Block]:
engine = os.getenv("RAG_CUT_PDF_ENGINE", "auto").strip().lower()
if engine not in {"auto", "mineru", "pymupdf"}:
engine = "auto"
blocks: list[Block] | None = None
if engine in {"auto", "mineru"}:
blocks = parse_pdf_with_mineru(path, assets_dir)
if blocks is None and engine == "mineru":
raise RuntimeError("MinerU parsing failed or MinerU is not installed")
if blocks is None:
blocks = parse_pdf(path, assets_dir)
# filter_noise_blocks already includes TOC removal.
return filter_noise_blocks(blocks)
+30
View File
@@ -0,0 +1,30 @@
"""PPT/PPTX parser: convert to PDF, then reuse PdfParser."""
from __future__ import annotations
from pathlib import Path
from rag_cut.models import Block
from rag_cut.parsers.base import BaseParser
from rag_cut.parsers.office_to_pdf import convert_to_pdf
from rag_cut.parsers.pdf_parser import PdfParser
class PptParser(BaseParser):
"""Parse presentations by converting to PDF and delegating to PdfParser."""
def __init__(self) -> None:
self._pdf_parser = PdfParser()
def parse(self, path: Path, assets_dir: Path) -> list[Block]:
assets_dir.mkdir(parents=True, exist_ok=True)
pdf_dir = assets_dir / "_conversion"
pdf_path = convert_to_pdf(path, pdf_dir)
blocks = self._pdf_parser.parse(pdf_path, assets_dir)
source_fmt = path.suffix.lower().lstrip(".")
for block in blocks:
block.meta.setdefault("source_format", source_fmt)
if page := block.meta.get("page"):
block.meta["slide"] = page
return blocks
+44
View File
@@ -0,0 +1,44 @@
"""Route file extensions to parsers."""
from __future__ import annotations
from pathlib import Path
from rag_cut.parsers.base import BaseParser
from rag_cut.parsers.word_parser import WordParser
from rag_cut.parsers.image_parser import ImageParser
from rag_cut.parsers.pdf_parser import PdfParser
from rag_cut.parsers.ppt_parser import PptParser
from rag_cut.parsers.text_parser import TextParser
from rag_cut.parsers.xlsx_parser import SpreadsheetParser
PARSERS: dict[str, BaseParser] = {
".docx": WordParser(),
".doc": WordParser(),
".pdf": PdfParser(),
".ppt": PptParser(),
".pptx": PptParser(),
".ppsx": PptParser(),
".xlsx": SpreadsheetParser(),
".xls": SpreadsheetParser(),
".csv": SpreadsheetParser(),
".md": TextParser(),
".txt": TextParser(),
".html": TextParser(),
".htm": TextParser(),
".json": TextParser(),
".xml": TextParser(),
".log": TextParser(),
".jpg": ImageParser(),
".jpeg": ImageParser(),
".png": ImageParser(),
".bmp": ImageParser(),
".gif": ImageParser(),
}
def get_parser(path: Path) -> BaseParser:
ext = path.suffix.lower()
if ext not in PARSERS:
raise ValueError(f"Unsupported file type: {ext}")
return PARSERS[ext]
+123
View File
@@ -0,0 +1,123 @@
"""Plain-text and markup parsers."""
from __future__ import annotations
import json
import re
from html.parser import HTMLParser
from pathlib import Path
from rag_cut.models import Block, BlockType
from rag_cut.parsers.base import BaseParser
class _HTMLTextExtractor(HTMLParser):
def __init__(self) -> None:
super().__init__()
self._parts: list[str] = []
self._heading: tuple[int, str] | None = None
self._blocks: list[Block] = []
self._current: list[str] = []
self._in_heading = False
def handle_starttag(self, tag: str, attrs) -> None:
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
self._flush_paragraph()
self._in_heading = True
self._heading_level = int(tag[1])
def handle_endtag(self, tag: str) -> None:
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
text = "".join(self._current).strip()
self._current = []
self._in_heading = False
if text:
self._blocks.append(
Block(type=BlockType.HEADING, text=text, level=self._heading_level)
)
elif tag in ("p", "div", "br", "li"):
self._flush_paragraph()
def handle_data(self, data: str) -> None:
self._current.append(data)
def _flush_paragraph(self) -> None:
text = "".join(self._current).strip()
self._current = []
if text:
self._blocks.append(Block(type=BlockType.PARAGRAPH, text=text))
def get_blocks(self) -> list[Block]:
self._flush_paragraph()
return self._blocks
def _parse_markdown(text: str) -> list[Block]:
blocks: list[Block] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
m = re.match(r"^(#{1,6})\s+(.+)$", stripped)
if m:
blocks.append(
Block(type=BlockType.HEADING, text=m.group(2).strip(), level=len(m.group(1)))
)
else:
blocks.append(Block(type=BlockType.PARAGRAPH, text=stripped))
return blocks
def _parse_json(text: str) -> list[Block]:
data = json.loads(text)
blocks: list[Block] = []
if isinstance(data, list):
for i, item in enumerate(data):
blocks.append(
Block(
type=BlockType.CODE,
text=json.dumps(item, ensure_ascii=False, indent=2),
meta={"json_index": i},
)
)
elif isinstance(data, dict):
for key, value in data.items():
blocks.append(
Block(
type=BlockType.CODE,
text=json.dumps({key: value}, ensure_ascii=False, indent=2),
meta={"json_key": key},
)
)
else:
blocks.append(Block(type=BlockType.PARAGRAPH, text=str(data)))
return blocks
class TextParser(BaseParser):
def parse(self, path: Path, assets_dir: Path) -> list[Block]:
for encoding in ("utf-8-sig", "utf-8", "gbk", "latin-1"):
try:
text = path.read_text(encoding=encoding)
break
except UnicodeDecodeError:
continue
else:
raise ValueError(f"Cannot decode text file: {path}")
ext = path.suffix.lower()
if ext == ".md":
return _parse_markdown(text)
if ext in (".html", ".htm"):
parser = _HTMLTextExtractor()
parser.feed(text)
return parser.get_blocks()
if ext == ".json":
return _parse_json(text)
# txt, xml, log — paragraph split on blank lines
blocks: list[Block] = []
for para in re.split(r"\n\s*\n", text):
para = para.strip()
if para:
blocks.append(Block(type=BlockType.PARAGRAPH, text=para))
return blocks
+29
View File
@@ -0,0 +1,29 @@
"""DOC/DOCX parser: convert to PDF, then reuse PdfParser."""
from __future__ import annotations
from pathlib import Path
from rag_cut.models import Block
from rag_cut.parsers.base import BaseParser
from rag_cut.parsers.office_to_pdf import convert_to_pdf
from rag_cut.parsers.pdf_parser import PdfParser
class WordParser(BaseParser):
"""Parse Word documents by converting to PDF and delegating to PdfParser."""
def __init__(self) -> None:
self._pdf_parser = PdfParser()
def parse(self, path: Path, assets_dir: Path) -> list[Block]:
assets_dir.mkdir(parents=True, exist_ok=True)
pdf_dir = assets_dir / "_conversion"
pdf_path = convert_to_pdf(path, pdf_dir)
blocks = self._pdf_parser.parse(pdf_path, assets_dir)
source_fmt = path.suffix.lower().lstrip(".")
for block in blocks:
block.meta.setdefault("source_format", source_fmt)
block.meta.setdefault("converted_from", source_fmt)
return blocks
+89
View File
@@ -0,0 +1,89 @@
"""Spreadsheet parser (xlsx/xls/csv) producing row-oriented table blocks."""
from __future__ import annotations
import csv
from pathlib import Path
from rag_cut.models import Block, BlockType
from rag_cut.parsers.base import BaseParser
from rag_cut.parsers.pdf.tables import (
build_table_embedding_text,
detect_spreadsheet_layout,
extract_table_keywords,
normalize_rows,
rows_to_markdown,
)
def _read_csv(path: Path) -> list[list[str]]:
for encoding in ("utf-8-sig", "utf-8", "gbk", "latin-1"):
try:
with open(path, newline="", encoding=encoding) as f:
return [list(row) for row in csv.reader(f)]
except UnicodeDecodeError:
continue
raise ValueError(f"Cannot decode CSV: {path}")
def _read_xlsx(path: Path, sheet_name: str | None = None) -> tuple[str, list[list[str]]]:
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
name = sheet_name or wb.sheetnames[0]
ws = wb[name]
rows: list[list[str]] = []
for row in ws.iter_rows(values_only=True):
rows.append(["" if v is None else str(v) for v in row])
wb.close()
# trim trailing empty rows/cols
while rows and all(not c for c in rows[-1]):
rows.pop()
return name, rows
class SpreadsheetParser(BaseParser):
def parse(self, path: Path, assets_dir: Path) -> list[Block]:
ext = path.suffix.lower()
if ext == ".csv":
rows = _read_csv(path)
sheet_name = path.stem
else:
sheet_name, rows = _read_xlsx(path)
if not rows:
return []
normalized = normalize_rows(rows)
layout = detect_spreadsheet_layout(normalized)
preamble = layout["preamble_rows"]
header_rows = layout["header_rows"]
display_rows = normalized[preamble:]
md = rows_to_markdown(display_rows, header_rows=header_rows)
description = ""
if preamble:
description = " ".join(cell for cell in normalized[0] if cell)[:500]
keywords = extract_table_keywords(sheet_name, md, description)
embedding_text = build_table_embedding_text(
table_title=sheet_name,
markdown=md,
description=description,
keywords=keywords,
)
return [
Block(
type=BlockType.TABLE,
markdown=md,
meta={
"sheet": sheet_name,
"table_title": sheet_name,
"row_count": len(normalized),
"col_count": max(len(r) for r in normalized),
"rows": normalized,
"keywords": keywords,
"embedding_text": embedding_text,
"table_description": description or None,
**layout,
},
)
]
+120
View File
@@ -0,0 +1,120 @@
"""End-to-end document chunking pipeline."""
from __future__ import annotations
import hashlib
import shutil
import uuid
from pathlib import Path
from rag_cut.layout_meta import enrich_layout_metadata
from rag_cut.models import Chunk, ChunkResult, SplitConfig, SplitMode
from rag_cut.parsers.pdf.noise_filter import filter_toc_blocks
from rag_cut.parsers.pdf.table_merge import merge_cross_page_tables
from rag_cut.parsers.registry import get_parser
from rag_cut.renderer import render_blocks
from rag_cut.split_policy import choose_split_config, split_config_summary
from rag_cut.splitters import split_by_delimiter, split_by_row
from rag_cut.splitters.default_splitter import split_default_with_meta
from rag_cut.splitters.heading_splitter import assign_parent_chunk_ids, chunk_groups_to_block_groups
from rag_cut.splitters.parent_child import CHUNK_STRATEGY as PARENT_CHILD_STRATEGY
from rag_cut.splitters.parent_child import split_by_parent_child
from rag_cut.splitters.pdf_semantic import CHUNK_STRATEGY, split_pdf_semantic
from rag_cut.splitters.pdf_strategy import choose_pdf_chunk_strategy
STORAGE_ROOT = Path(__file__).resolve().parent.parent.parent / "storage"
PDF_SEMANTIC_EXTS = {".pdf", ".doc", ".docx"}
def _doc_id(path: Path) -> str:
digest = hashlib.md5(f"{path.name}-{path.stat().st_mtime}".encode()).hexdigest()[:12]
return digest
def chunk_document(
path: Path | str,
config: SplitConfig | None = None,
storage_root: Path | None = None,
) -> ChunkResult:
path = Path(path)
if not path.exists():
raise FileNotFoundError(path)
root = storage_root or STORAGE_ROOT
doc_id = _doc_id(path)
assets_dir = root / "assets" / doc_id
uploads_dir = root / "uploads" / doc_id
uploads_dir.mkdir(parents=True, exist_ok=True)
stored = uploads_dir / path.name
if path.resolve() != stored.resolve():
shutil.copy2(path, stored)
parser = get_parser(path)
blocks = merge_cross_page_tables(parser.parse(stored, assets_dir))
blocks = enrich_layout_metadata(blocks)
# All formats: never parse/chunk document directories (目录 / Contents).
blocks = filter_toc_blocks(blocks)
config = config or choose_split_config(path, blocks)
for block in blocks:
if block.image_path:
rel = Path(block.image_path)
if "crops" in rel.parts:
block.image_path = f"assets/{doc_id}/crops/{rel.name}"
else:
block.image_path = f"assets/{doc_id}/{rel.name}"
crop = block.meta.get("crop_path")
if crop:
crop_name = Path(crop).name
url = f"assets/{doc_id}/crops/{crop_name}"
block.meta["crop_path"] = url
if block.type.value == "table" and not block.image_path:
block.image_path = url
ext = path.suffix.lower()
pdf_strategy = None
if ext in PDF_SEMANTIC_EXTS and config.mode == SplitMode.DEFAULT:
pdf_strategy = choose_pdf_chunk_strategy(path, blocks)
group_metas: list[dict] = []
if ext in PDF_SEMANTIC_EXTS and config.mode == SplitMode.DEFAULT:
chunks = split_pdf_semantic(blocks, config)
elif config.mode == SplitMode.BY_ROW:
groups = split_by_row(blocks, config)
chunks = []
elif config.mode == SplitMode.DELIMITER:
groups = split_by_delimiter(blocks, config)
chunks = []
elif config.mode == SplitMode.PARENT_CHILD:
parent_child_groups = split_by_parent_child(blocks, config)
groups, group_metas = chunk_groups_to_block_groups(parent_child_groups)
chunks = []
else:
groups, group_metas = split_default_with_meta(blocks, config)
chunks = []
if not chunks:
for i, group in enumerate(groups):
meta: dict = dict(group_metas[i]) if i < len(group_metas) else {}
chunks.append(render_blocks(group, index=i, meta=meta))
chunks = assign_parent_chunk_ids(chunks)
split_config = split_config_summary(config)
if pdf_strategy:
split_config["pdf_chunk_strategy"] = pdf_strategy
split_config["chunk_strategy"] = CHUNK_STRATEGY
elif config.mode == SplitMode.PARENT_CHILD:
split_config["chunk_strategy"] = PARENT_CHILD_STRATEGY
return ChunkResult(
filename=path.name,
doc_id=doc_id,
split_mode=config.mode,
split_config=split_config,
block_count=len(blocks),
chunk_count=len(chunks),
chunks=chunks,
assets_dir=str(assets_dir) if assets_dir.exists() else None,
)
+164
View File
@@ -0,0 +1,164 @@
"""Render block groups into chunk markdown strings with layout metadata."""
from __future__ import annotations
from rag_cut.models import Block, BlockType, Chunk
from rag_cut.parsers.pdf.tables import table_meta_summary
def block_to_layout_dict(block: Block) -> dict:
"""Serialize a block for chunk metadata and frontend positional rendering."""
entry: dict = {
"type": block.type.value,
"order_index": block.meta.get("order_index"),
"page": block.meta.get("page"),
"pages": block.meta.get("pages"),
"bbox": block.meta.get("bbox"),
"bboxes": block.meta.get("bboxes"),
"parent_heading": block.meta.get("parent_heading"),
"nearest_heading": block.meta.get("nearest_heading"),
"bound_heading": block.meta.get("bound_heading"),
"preceding_text": block.meta.get("preceding_text"),
"following_text": block.meta.get("following_text"),
}
if block.type == BlockType.HEADING:
entry["text"] = block.text
entry["level"] = block.level
elif block.type == BlockType.IMAGE:
entry["text"] = block.text
entry["image_path"] = block.image_path
entry["image_id"] = block.image_id
entry["ocr_text"] = block.ocr_text
elif block.type == BlockType.TABLE:
entry["text"] = block.markdown or block.text
entry["markdown"] = block.markdown or block.text
entry["ocr_text"] = block.ocr_text
entry["image_path"] = block.image_path
entry["image_id"] = block.image_id
entry["crop_path"] = block.meta.get("crop_path")
entry.update(table_meta_summary(block.meta))
entry["embedding_text"] = block.meta.get("embedding_text")
else:
entry["text"] = block.text or block.markdown
return entry
def collect_chunk_layout_meta(blocks: list[Block]) -> dict:
"""Aggregate page/bbox/image/table metadata for a chunk group."""
pages = sorted({b.meta.get("page") for b in blocks if b.meta.get("page") is not None})
for b in blocks:
for p in b.meta.get("pages") or []:
if p is not None:
pages.append(p)
pages = sorted(set(pages))
bboxes = [
{
"order_index": b.meta.get("order_index"),
"page": b.meta.get("page"),
"bbox": b.meta.get("bbox"),
"type": b.type.value,
}
for b in blocks
if b.meta.get("bbox")
]
images = [
{
"order_index": b.meta.get("order_index"),
"image_id": b.image_id,
"image_path": b.image_path,
"page": b.meta.get("page"),
"bbox": b.meta.get("bbox"),
"ocr_text": b.ocr_text,
"bound_heading": b.meta.get("bound_heading"),
"preceding_text": b.meta.get("preceding_text"),
"following_text": b.meta.get("following_text"),
}
for b in blocks
if b.type == BlockType.IMAGE
]
tables = [
{
"order_index": b.meta.get("order_index"),
"table_title": b.meta.get("table_title"),
"image_path": b.image_path or b.meta.get("crop_path"),
"crop_path": b.meta.get("crop_path"),
"page": b.meta.get("page"),
"pages": b.meta.get("pages"),
"bbox": b.meta.get("bbox"),
"bboxes": b.meta.get("bboxes"),
"markdown": b.markdown,
"ocr_text": b.ocr_text,
"footnotes": b.meta.get("footnotes"),
"keywords": b.meta.get("keywords"),
"nearest_heading": b.meta.get("nearest_heading"),
"chapter": b.meta.get("chapter"),
"row_count": b.meta.get("row_count"),
"col_count": b.meta.get("col_count"),
"header_rows": b.meta.get("header_rows"),
"table_source": b.meta.get("table_source"),
"preceding_text": b.meta.get("preceding_text"),
"following_text": b.meta.get("following_text"),
"cross_page": b.meta.get("cross_page"),
"embedding_text": b.meta.get("embedding_text"),
}
for b in blocks
if b.type == BlockType.TABLE
]
meta: dict = {
"blocks": [block_to_layout_dict(b) for b in blocks],
"bboxes": bboxes,
"images": images,
"tables": tables,
}
if pages:
meta["pages"] = pages
if len(pages) == 1:
meta["page"] = pages[0]
headings = [b.text for b in blocks if b.type == BlockType.HEADING]
if headings:
meta["heading"] = headings[0]
meta["nearest_heading"] = headings[0]
elif blocks and blocks[0].meta.get("nearest_heading"):
meta["nearest_heading"] = blocks[0].meta.get("nearest_heading")
table_blocks = [b for b in blocks if b.type == BlockType.TABLE]
if table_blocks:
primary = table_blocks[0]
meta["table_title"] = primary.meta.get("table_title") or meta.get("nearest_heading")
if not meta.get("chunk_strategy"):
meta["chunk_strategy"] = "table_with_context"
meta["retrieval"] = meta.get("retrieval", True)
if primary.meta.get("embedding_text"):
meta["embedding_text"] = primary.meta["embedding_text"]
if primary.meta.get("keywords"):
meta["keywords"] = primary.meta["keywords"]
order_indices = [b.meta.get("order_index") for b in blocks if b.meta.get("order_index") is not None]
if order_indices:
meta["order_range"] = meta.get("order_range") or [min(order_indices), max(order_indices)]
return meta
def render_blocks(blocks: list[Block], index: int, meta: dict | None = None) -> Chunk:
"""Render blocks in reading order: heading → body → table/image → notes."""
parts: list[str] = []
for block in blocks:
rendered = block.render().strip()
if rendered:
parts.append(rendered)
content = "\n\n".join(parts)
layout_meta = collect_chunk_layout_meta(blocks)
chunk_meta = {**(meta or {}), **layout_meta}
return Chunk(
index=index,
content=content,
char_count=len(content),
block_types=[b.type.value for b in blocks],
meta=chunk_meta,
)
+78
View File
@@ -0,0 +1,78 @@
"""Small dependency-free lexical retriever for the recall demo."""
from __future__ import annotations
import math
import re
from collections import Counter
from rag_cut.models import Chunk
LATIN_TOKEN_RE = re.compile(r"[a-z0-9_]+", re.I)
CJK_RE = re.compile(r"[\u3400-\u9fff]")
def _tokens(text: str) -> list[str]:
normalized = text.lower()
tokens = LATIN_TOKEN_RE.findall(normalized)
cjk = CJK_RE.findall(normalized)
tokens.extend(cjk)
tokens.extend("".join(cjk[i : i + 2]) for i in range(len(cjk) - 1))
return tokens
def _search_text(chunk: Chunk) -> str:
meta = chunk.meta
fields = [
meta.get("heading") or "",
meta.get("nearest_heading") or "",
meta.get("embedding_text") or "",
" ".join(meta.get("keywords") or []),
chunk.content,
]
return "\n".join(str(value) for value in fields if value)
def recall_chunks(query: str, chunks: list[Chunk], top_k: int = 5) -> tuple[list[dict], int]:
"""Rank retrievable chunks with a compact BM25-style lexical score."""
candidates = [chunk for chunk in chunks if chunk.meta.get("retrieval", True)]
query_tokens = _tokens(query)
if not candidates or not query_tokens:
return [], len(candidates)
documents = [_tokens(_search_text(chunk)) for chunk in candidates]
document_frequency = Counter(token for tokens in documents for token in set(tokens))
average_length = sum(len(tokens) for tokens in documents) / len(documents)
query_frequency = Counter(query_tokens)
scored: list[tuple[float, Chunk]] = []
for chunk, tokens in zip(candidates, documents):
frequency = Counter(tokens)
length_normalizer = 1.2 * (0.25 + 0.75 * len(tokens) / max(average_length, 1))
score = 0.0
for token, query_count in query_frequency.items():
term_count = frequency[token]
if not term_count:
continue
inverse_frequency = math.log(1 + (len(documents) - document_frequency[token] + 0.5) / (document_frequency[token] + 0.5))
score += inverse_frequency * ((term_count * 2.2) / (term_count + length_normalizer)) * min(query_count, 2)
if score > 0:
scored.append((score, chunk))
scored.sort(key=lambda item: (-item[0], item[1].index))
results = []
for rank, (score, chunk) in enumerate(scored[:top_k], start=1):
results.append(
{
"rank": rank,
"chunk_index": chunk.index,
"score": round(score, 6),
"content": chunk.content,
"heading": chunk.meta.get("heading") or chunk.meta.get("nearest_heading"),
"pages": chunk.meta.get("pages") or ([chunk.meta["page"]] if chunk.meta.get("page") is not None else []),
"block_types": chunk.block_types,
"parent_chunk_id": chunk.meta.get("parent_chunk_id"),
"is_sub_chunk": bool(chunk.meta.get("is_sub_chunk")),
}
)
return results, len(candidates)
+82
View File
@@ -0,0 +1,82 @@
"""Automatic split policy selection based on parsed document shape."""
from __future__ import annotations
from pathlib import Path
from rag_cut.models import Block, BlockType, SplitConfig, SplitMode
from rag_cut.parsers.pdf.tables import detect_spreadsheet_layout, is_qa_style_table
SPREADSHEET_EXTS = {".xlsx", ".xls", ".csv"}
PRESENTATION_EXTS = {".ppt", ".pptx", ".ppsx"}
TEXT_EXTS = {".md", ".txt", ".html", ".htm", ".json", ".xml", ".log"}
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".gif"}
def _table_rows(blocks: list[Block]) -> int:
if len(blocks) != 1 or blocks[0].type != BlockType.TABLE:
return 0
rows = blocks[0].meta.get("rows") or []
return len(rows)
def _rows_per_chunk(data_row_count: int) -> int:
if data_row_count <= 20:
return max(1, data_row_count)
if data_row_count <= 80:
return 10
if data_row_count <= 300:
return 20
return 40
def choose_split_config(path: Path, blocks: list[Block]) -> SplitConfig:
"""Choose conservative defaults that preserve document structure first."""
ext = path.suffix.lower()
row_count = _table_rows(blocks)
if ext in SPREADSHEET_EXTS or row_count:
rows = blocks[0].meta.get("rows") or [] if blocks else []
layout = detect_spreadsheet_layout(rows) if rows else {
"header_row_start": 1,
"header_row_end": 1,
"data_start_row": 2,
}
data_row_count = max(0, row_count - layout["data_start_row"] + 1)
rows_per = 1 if is_qa_style_table(rows, layout) else _rows_per_chunk(data_row_count)
return SplitConfig(
mode=SplitMode.BY_ROW,
max_chunk_size=2400,
overlap=0,
header_row_start=layout["header_row_start"],
header_row_end=layout["header_row_end"],
start_row=layout["data_start_row"],
rows_per_chunk=rows_per,
)
if ext == ".pdf" or ext in PRESENTATION_EXTS:
return SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2600, overlap=120)
if ext in IMAGE_EXTS:
return SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2200, overlap=0)
if ext in TEXT_EXTS:
return SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=1800, overlap=120)
return SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2200, overlap=120)
def split_config_summary(config: SplitConfig) -> dict[str, int | str | None]:
return {
"mode": config.mode.value,
"delimiter": config.delimiter,
"parent_delimiter": config.parent_delimiter,
"child_delimiter": config.child_delimiter,
"max_chunk_size": config.max_chunk_size,
"child_max_size": config.child_max_size,
"overlap": config.overlap,
"header_row_start": config.header_row_start,
"header_row_end": config.header_row_end,
"start_row": config.start_row,
"rows_per_chunk": config.rows_per_chunk,
}
+8
View File
@@ -0,0 +1,8 @@
"""Chunk splitting strategies."""
from rag_cut.splitters.by_row import split_by_row
from rag_cut.splitters.default_splitter import split_default
from rag_cut.splitters.delimiter import split_by_delimiter
from rag_cut.splitters.parent_child import split_by_parent_child
__all__ = ["split_default", "split_by_delimiter", "split_by_parent_child", "split_by_row"]
+87
View File
@@ -0,0 +1,87 @@
"""Row-based splitting for spreadsheet documents."""
from __future__ import annotations
from rag_cut.models import Block, BlockType, SplitConfig
from rag_cut.parsers.pdf.tables import (
build_table_embedding_text,
detect_spreadsheet_layout,
extract_table_keywords,
rows_to_markdown,
)
def _chunk_meta(table_block: Block, chunk_rows: list[list[str]], md: str, row_range: list[int]) -> dict:
"""Build per-chunk metadata without leaking the full source table."""
header_rows = table_block.meta.get("header_rows") or 1
keywords = extract_table_keywords(
table_block.meta.get("table_title") or "",
md,
table_block.meta.get("table_description") or "",
)
embedding_text = build_table_embedding_text(
table_title=table_block.meta.get("table_title") or "",
markdown=md,
description=table_block.meta.get("table_description") or "",
keywords=keywords,
)
return {
"sheet": table_block.meta.get("sheet"),
"table_title": table_block.meta.get("table_title"),
"table_description": table_block.meta.get("table_description"),
"header_rows": header_rows,
"header_row_start": table_block.meta.get("header_row_start"),
"header_row_end": table_block.meta.get("header_row_end"),
"data_start_row": table_block.meta.get("data_start_row"),
"preamble_rows": table_block.meta.get("preamble_rows", 0),
"row_count": len(chunk_rows),
"col_count": max((len(r) for r in chunk_rows), default=0),
"chunk_rows": chunk_rows,
"row_range": row_range,
"keywords": keywords,
"embedding_text": embedding_text,
}
def split_by_row(blocks: list[Block], config: SplitConfig) -> list[list[Block]]:
table_block = next((b for b in blocks if b.type == BlockType.TABLE and b.meta.get("rows")), None)
if not table_block:
return [blocks] if blocks else []
rows: list[list[str]] = table_block.meta["rows"]
layout = {
"header_row_start": table_block.meta.get("header_row_start"),
"header_row_end": table_block.meta.get("header_row_end"),
"data_start_row": table_block.meta.get("data_start_row"),
"header_rows": table_block.meta.get("header_rows"),
}
if not layout["header_row_start"]:
layout = detect_spreadsheet_layout(rows)
h_start = max(1, config.header_row_start) - 1
h_end = max(h_start + 1, config.header_row_end)
header = rows[h_start:h_end]
header_rows = layout.get("header_rows") or len(header)
data_start = max(config.start_row - 1, h_end)
data_rows = rows[data_start:]
rows_per = max(1, config.rows_per_chunk)
groups: list[list[Block]] = []
for i in range(0, len(data_rows), rows_per):
slice_rows = data_rows[i : i + rows_per]
chunk_rows = header + slice_rows
md = rows_to_markdown(chunk_rows, header_rows=header_rows)
row_range = [
data_start + i + 1,
data_start + i + len(slice_rows),
]
groups.append(
[
Block(
type=BlockType.TABLE,
markdown=md,
meta=_chunk_meta(table_block, chunk_rows, md, row_range),
)
]
)
return groups
@@ -0,0 +1,210 @@
"""Default structure-aware splitting."""
from __future__ import annotations
from rag_cut.models import Block, BlockType, SplitConfig
from rag_cut.splitters.heading_splitter import (
chunk_groups_to_block_groups,
has_meaningful_headings,
split_by_heading_hierarchy,
)
ATOMIC_TYPES = {BlockType.TABLE, BlockType.IMAGE}
def _same_layout_context(prev: Block, curr: Block) -> bool:
"""True when blocks should stay together to preserve image/text position."""
if prev.meta.get("page") != curr.meta.get("page"):
return False
if prev.type == BlockType.PARAGRAPH and curr.type in {BlockType.IMAGE, BlockType.TABLE}:
return True
if prev.type == BlockType.HEADING and curr.type in {BlockType.PARAGRAPH, BlockType.IMAGE, BlockType.TABLE}:
return prev.meta.get("parent_heading") == curr.meta.get("parent_heading") or not prev.meta.get("parent_heading")
if prev.type == BlockType.IMAGE and curr.type == BlockType.PARAGRAPH:
return True
if prev.type == BlockType.TABLE and curr.type == BlockType.PARAGRAPH:
return True
if prev.type == BlockType.IMAGE and curr.type == BlockType.IMAGE:
return True
return False
def _is_heading(block: Block) -> bool:
return block.type == BlockType.HEADING
def _meaningful_headings(blocks: list[Block]) -> bool:
headings = [b for b in blocks if _is_heading(b)]
if len(headings) < 2:
return False
substantial = [h for h in headings if len((h.text or "").strip()) >= 8]
return len(substantial) >= 2
def _split_by_headings(blocks: list[Block]) -> list[list[Block]]:
"""Outline-aware: split when a heading of same-or-higher level appears."""
if not any(_is_heading(b) for b in blocks):
return []
sections: list[list[Block]] = []
current: list[Block] = []
stack: list[int] = []
for block in blocks:
if _is_heading(block):
level = block.level or 1
while stack and stack[-1] >= level:
stack.pop()
if current:
sections.append(current)
current = []
stack.append(level)
current.append(block)
else:
current.append(block)
if current:
sections.append(current)
return sections
def _split_by_page(blocks: list[Block], config: SplitConfig) -> list[list[Block]]:
"""Prefer page boundaries for PDF/manual style documents."""
if not any(b.meta.get("page") for b in blocks):
return []
groups: list[list[Block]] = []
current: list[Block] = []
current_page: int | None = None
for block in blocks:
page = block.meta.get("page")
if current and page != current_page:
groups.append(current)
current = []
current_page = page
current.append(block)
if current:
groups.append(current)
return _merge_oversized_sections(groups, config)
def _split_by_size(blocks: list[Block], config: SplitConfig) -> list[list[Block]]:
"""Fallback: pack blocks up to max_chunk_size without splitting atomic blocks."""
groups: list[list[Block]] = []
current: list[Block] = []
current_len = 0
def flush() -> None:
nonlocal current, current_len
if current:
groups.append(current)
current = []
current_len = 0
for block in blocks:
rendered = block.render()
block_len = len(rendered) + 2
page = block.meta.get("page")
if block.type in ATOMIC_TYPES and current_len + block_len > config.max_chunk_size and current:
if not _same_layout_context(current[-1], block):
flush()
if block.type not in ATOMIC_TYPES and block_len > config.max_chunk_size:
if current:
flush()
text = block.text or block.markdown
start = 0
while start < len(text):
end = min(start + config.max_chunk_size, len(text))
piece = Block(type=block.type, text=text[start:end], level=block.level, meta=block.meta)
groups.append([piece])
if end >= len(text):
break
start = max(end - config.overlap, start + 1)
continue
if current_len + block_len > config.max_chunk_size and current:
# Keep image with preceding heading/body on the same page
if _same_layout_context(current[-1], block):
pass
else:
flush()
elif (
current
and page is not None
and current[-1].meta.get("page") != page
and current_len >= min(400, config.max_chunk_size // 3)
):
flush()
current.append(block)
current_len += block_len
flush()
return groups
def _merge_oversized_sections(sections: list[list[Block]], config: SplitConfig) -> list[list[Block]]:
result: list[list[Block]] = []
for section in sections:
rendered_len = sum(len(b.render()) + 2 for b in section)
if rendered_len <= config.max_chunk_size:
result.append(section)
else:
result.extend(_split_by_size(section, config))
return result
def split_default(blocks: list[Block], config: SplitConfig) -> list[list[Block]]:
if not blocks:
return []
# Spreadsheet: single table block — default = chunk by groups of rows with header
if len(blocks) == 1 and blocks[0].type == BlockType.TABLE and blocks[0].meta.get("rows"):
from rag_cut.splitters.by_row import split_by_row
row_config = SplitConfig(
mode=config.mode,
header_row_start=1,
header_row_end=1,
start_row=2,
rows_per_chunk=max(1, min(10, len(blocks[0].meta["rows"]) // 5 or 1)),
max_chunk_size=config.max_chunk_size,
overlap=config.overlap,
)
return split_by_row(blocks, row_config)
# Heading hierarchy first: same section keeps body/images/tables/captions together
if has_meaningful_headings(blocks):
heading_groups = split_by_heading_hierarchy(blocks, config)
if heading_groups:
block_groups, _ = chunk_groups_to_block_groups(heading_groups)
return block_groups
page_sections = _split_by_page(blocks, config)
if len(page_sections) > 1:
return page_sections
return _split_by_size(blocks, config)
def split_default_with_meta(blocks: list[Block], config: SplitConfig) -> tuple[list[list[Block]], list[dict]]:
"""Like split_default but also returns per-group metadata (heading sections)."""
if not blocks:
return [], []
if len(blocks) == 1 and blocks[0].type == BlockType.TABLE and blocks[0].meta.get("rows"):
groups = split_default(blocks, config)
return groups, [{} for _ in groups]
if has_meaningful_headings(blocks):
heading_groups = split_by_heading_hierarchy(blocks, config)
if heading_groups:
return chunk_groups_to_block_groups(heading_groups)
groups = split_default(blocks, config)
return groups, [{} for _ in groups]
+63
View File
@@ -0,0 +1,63 @@
"""Delimiter-based splitting for non-tabular documents."""
from __future__ import annotations
from rag_cut.models import Block, BlockType, SplitConfig
from rag_cut.splitters.default_splitter import _split_by_size
ATOMIC_TYPES = {BlockType.TABLE, BlockType.IMAGE}
def partition_blocks_by_delimiter(blocks: list[Block], delimiter: str) -> list[list[Block]]:
"""Split block stream on delimiter; delimiter text is discarded from chunks."""
if not delimiter:
return [blocks] if blocks else []
groups: list[list[Block]] = []
current: list[Block] = []
def flush() -> None:
nonlocal current
if current:
groups.append(current)
current = []
for block in blocks:
if block.type in ATOMIC_TYPES:
current.append(block)
continue
text = block.text or block.markdown
if delimiter not in text:
current.append(block)
continue
parts = text.split(delimiter)
for i, part in enumerate(parts):
part = part.strip()
if part:
piece = Block(type=block.type, text=part, level=block.level, meta=dict(block.meta))
current.append(piece)
if i < len(parts) - 1:
flush()
flush()
return groups
def split_by_delimiter(blocks: list[Block], config: SplitConfig) -> list[list[Block]]:
if not config.delimiter:
raise ValueError("delimiter is required for delimiter split mode")
groups = partition_blocks_by_delimiter(blocks, config.delimiter)
if not groups:
return _split_by_size(blocks, config)
sized: list[list[Block]] = []
for group in groups:
rendered_len = sum(len(b.render()) + 2 for b in group)
if rendered_len <= config.max_chunk_size:
sized.append(group)
else:
sized.extend(_split_by_size(group, config))
return sized
@@ -0,0 +1,344 @@
"""Heading-hierarchy-first document splitting."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from rag_cut.models import Block, BlockType, SplitConfig
# e.g. "1.2 ACCOUNT STATUS CODE MASTER", "10.上升三角形態" (space after '.' optional)
NUMBERED_HEADING_RE = re.compile(
r"^\s*(\d+(?:\.\d+)*)(?:\.|.)?\s*([A-Za-z0-9\u4e00-\u9fff][A-Za-z0-9\u4e00-\u9fff&/ \-_::]{2,})\s*$"
)
FIGURE_TABLE_RE = re.compile(
r"^\s*(?:Figure|Fig\.|图|表|Table)\s*[\d.]+",
re.I,
)
STEP_RE = re.compile(
r"^\s*(?:Step\s*\d+|步骤\s*\d+|\d{1,2}[..、]\s*(?:点击|點擊|选择|選擇|输入|輸入))",
re.I,
)
ATOMIC_TYPES = {BlockType.TABLE, BlockType.IMAGE}
MAX_HEADING_CHARS = 100
@dataclass
class ChunkGroup:
blocks: list[Block]
meta: dict = field(default_factory=dict)
def _text(block: Block) -> str:
return (block.text or block.markdown or "").strip()
def _rendered_len(blocks: list[Block]) -> int:
return sum(len(b.render()) + 2 for b in blocks)
def numbered_heading_level(text: str) -> int | None:
match = NUMBERED_HEADING_RE.match(text.strip())
if not match:
return None
return match.group(1).count(".") + 1
def infer_heading_level(block: Block) -> int:
if block.type == BlockType.HEADING and block.level:
numbered = numbered_heading_level(block.text)
if numbered:
return numbered
return block.level
numbered = numbered_heading_level(_text(block))
if numbered:
return numbered
return block.level or 1
def is_heading_block(block: Block) -> bool:
text = _text(block)
if not text or len(text) > MAX_HEADING_CHARS:
return False
if block.type == BlockType.HEADING:
return True
if NUMBERED_HEADING_RE.match(text):
return True
if block.meta.get("font_size") and block.meta.get("body_font_size"):
return block.meta["font_size"] >= block.meta["body_font_size"] + 1.5
return False
def normalize_heading_block(block: Block) -> Block:
text = _text(block)
if block.type == BlockType.HEADING and len(text) > MAX_HEADING_CHARS:
# Parser sometimes merges title+body then marks the blob as heading.
return Block(type=BlockType.PARAGRAPH, text=text, meta=dict(block.meta))
numbered = numbered_heading_level(text)
if block.type == BlockType.HEADING:
level = numbered or block.level or 1
return block.model_copy(update={"level": level})
if numbered and NUMBERED_HEADING_RE.match(text):
return Block(
type=BlockType.HEADING,
text=text,
level=numbered,
meta=dict(block.meta),
)
return block
def has_meaningful_headings(blocks: list[Block]) -> bool:
headings = [normalize_heading_block(b) for b in blocks]
count = sum(1 for b in headings if is_heading_block(b) or b.type == BlockType.HEADING)
return count >= 2
def _section_key(block: Block | None, fallback: int) -> str:
if block is None:
return f"section-{fallback}"
oi = block.meta.get("order_index", fallback)
title = re.sub(r"\W+", "-", (_text(block) or "heading"))[:48]
return f"h-{oi}-{title}"
@dataclass
class _OpenSection:
level: int
start_index: int
blocks: list[Block]
def _split_primary_sections(blocks: list[Block]) -> list[list[Block]]:
"""
Split so each heading owns its content until the next same-or-higher-level heading.
Example: 1.2 section runs until 1.3 (same level) or 2.0 (higher level).
Nested sub-headings (1.2.1) stay inside the 1.2 section.
"""
if not blocks:
return []
open_sections: list[_OpenSection] = []
finished: list[tuple[int, list[Block]]] = []
for raw in blocks:
block = normalize_heading_block(raw)
if is_heading_block(block) or block.type == BlockType.HEADING:
level = infer_heading_level(block)
while open_sections and open_sections[-1].level >= level:
sec = open_sections.pop()
finished.append((sec.start_index, sec.blocks))
start = int(block.meta.get("order_index", len(finished)))
open_sections.append(_OpenSection(level=level, start_index=start, blocks=[block]))
elif open_sections:
open_sections[-1].blocks.append(block)
while open_sections:
sec = open_sections.pop()
finished.append((sec.start_index, sec.blocks))
finished.sort(key=lambda item: item[0])
return [sec_blocks for _, sec_blocks in finished]
def _section_heading(section: list[Block]) -> Block | None:
for block in section:
nb = normalize_heading_block(block)
if nb.type == BlockType.HEADING or is_heading_block(nb):
return nb
return None
def _split_by_child_headings(section: list[Block], parent_level: int) -> list[list[Block]]:
"""Split an oversized section by deeper sub-headings."""
child_sections: list[list[Block]] = []
current: list[Block] = []
parent_heading = _section_heading(section)
for block in section:
nb = normalize_heading_block(block)
if (
block is not parent_heading
and (nb.type == BlockType.HEADING or is_heading_block(nb))
and infer_heading_level(nb) > parent_level
):
if current:
child_sections.append(current)
current = [block]
else:
current.append(block)
if current:
child_sections.append(current)
return child_sections if len(child_sections) > 1 else [section]
def _is_split_marker(block: Block) -> bool:
if block.type in ATOMIC_TYPES:
return False
text = _text(block)
if not text:
return False
return bool(FIGURE_TABLE_RE.match(text) or STEP_RE.match(text))
def _split_by_content_markers(section: list[Block], config: SplitConfig) -> list[list[Block]]:
"""Fallback: split at figure/table/step markers while keeping atomic blocks intact."""
if _rendered_len(section) <= config.max_chunk_size:
return [section]
groups: list[list[Block]] = []
current: list[Block] = []
current_len = 0
def flush() -> None:
nonlocal current, current_len
if current:
groups.append(current)
current = []
current_len = 0
for block in section:
blen = len(block.render()) + 2
if (
current
and _is_split_marker(block)
and current_len >= min(500, config.max_chunk_size // 4)
and current_len + blen > config.max_chunk_size
):
flush()
elif current_len + blen > config.max_chunk_size and current:
if block.type in ATOMIC_TYPES:
flush()
elif not _is_split_marker(block):
flush()
current.append(block)
current_len += blen
flush()
return groups if groups else [section]
def _prepend_parent_heading(section: list[Block], parent: Block | None) -> list[Block]:
if not parent:
return section
parent_text = _text(parent)
if section and _text(normalize_heading_block(section[0])) == parent_text:
return section
return [parent] + section
def _split_oversized_section(
section: list[Block],
config: SplitConfig,
section_id: str,
) -> list[ChunkGroup]:
"""Split an oversized heading section by sub-headings/markers/size — no parent chunk."""
parent_heading = _section_heading(section)
parent_level = infer_heading_level(parent_heading) if parent_heading else 1
parent_title = _text(parent_heading) if parent_heading else ""
child_sections = _split_by_child_headings(section, parent_level)
if len(child_sections) == 1:
child_sections = _split_by_content_markers(section, config)
if len(child_sections) == 1:
from rag_cut.splitters.default_splitter import _split_by_size
child_sections = _split_by_size(section, config)
parts: list[ChunkGroup] = []
for idx, child in enumerate(child_sections):
child_heading = _section_heading(child)
blocks = _prepend_parent_heading(child, parent_heading)
parts.append(
ChunkGroup(
blocks=blocks,
meta={
"section_id": f"{section_id}-part-{idx}",
"heading": _text(child_heading) if child_heading else parent_title,
"heading_level": infer_heading_level(child_heading) if child_heading else parent_level,
"parent_heading": parent_title,
"chunk_strategy": "heading_hierarchy_part",
"part_index": idx,
"is_sub_chunk": False,
"retrieval": True,
},
)
)
return parts
def split_by_heading_hierarchy(blocks: list[Block], config: SplitConfig) -> list[ChunkGroup]:
"""
Heading-first splitting:
- Same heading section stays together (body, images, tables, captions).
- Boundaries at same/higher-level headings.
- Oversized sections are split by sub-headings / markers / length (no parent+child pair).
"""
if not blocks:
return []
if not has_meaningful_headings(blocks):
return []
groups: list[ChunkGroup] = []
sections = _split_primary_sections(blocks)
for i, section in enumerate(sections):
heading = _section_heading(section)
sid = _section_key(heading, i)
title = _text(heading) if heading else ""
level = infer_heading_level(heading) if heading else 1
if _rendered_len(section) <= config.max_chunk_size:
groups.append(
ChunkGroup(
blocks=section,
meta={
"section_id": sid,
"heading": title,
"heading_level": level,
"is_sub_chunk": False,
"chunk_strategy": "heading_hierarchy",
"retrieval": True,
"order_range": [
section[0].meta.get("order_index"),
section[-1].meta.get("order_index"),
],
},
)
)
continue
groups.extend(_split_oversized_section(section, config, sid))
return groups
def chunk_groups_to_block_groups(groups: list[ChunkGroup]) -> tuple[list[list[Block]], list[dict]]:
"""Convert ChunkGroups to block groups + per-chunk meta for renderer."""
block_groups: list[list[Block]] = []
metas: list[dict] = []
for group in groups:
block_groups.append(group.blocks)
metas.append(group.meta)
return block_groups, metas
def assign_parent_chunk_ids(chunks: list) -> list:
"""Resolve parent_section_id -> parent_chunk_id (chunk index)."""
section_index: dict[str, int] = {}
for i, chunk in enumerate(chunks):
sid = chunk.meta.get("section_id")
if sid and chunk.meta.get("is_section_parent"):
section_index[sid] = i
updated = []
for chunk in chunks:
meta = dict(chunk.meta)
parent_sid = meta.get("parent_section_id")
if parent_sid and parent_sid in section_index:
meta["parent_chunk_id"] = section_index[parent_sid]
updated.append(chunk.model_copy(update={"meta": meta}))
return updated
+103
View File
@@ -0,0 +1,103 @@
"""Parent/child delimiter splitting for fine retrieval + coarse recall."""
from __future__ import annotations
from rag_cut.models import Block, SplitConfig
from rag_cut.splitters.default_splitter import _split_by_size
from rag_cut.splitters.delimiter import partition_blocks_by_delimiter
from rag_cut.splitters.heading_splitter import ChunkGroup
CHUNK_STRATEGY = "parent_child_delimiter"
CHILD_MAX_HARD_LIMIT = 1500
def _rendered_len(blocks: list[Block]) -> int:
return sum(len(b.render()) + 2 for b in blocks)
def _validate(config: SplitConfig) -> tuple[str, str | None, int, int]:
parent_delimiter = (config.parent_delimiter or config.delimiter or "").strip()
if not parent_delimiter:
raise ValueError("parent_delimiter is required for parent_child split mode")
child_delimiter = (config.child_delimiter or "").strip() or None
parent_max = max(200, int(config.max_chunk_size or 1500))
child_max = int(config.child_max_size or 512)
child_max = max(50, min(child_max, CHILD_MAX_HARD_LIMIT, parent_max))
return parent_delimiter, child_delimiter, parent_max, child_max
def _size_cap(groups: list[list[Block]], max_size: int, overlap: int) -> list[list[Block]]:
sized: list[list[Block]] = []
size_config = SplitConfig(max_chunk_size=max_size, overlap=overlap)
for group in groups:
if _rendered_len(group) <= max_size:
sized.append(group)
else:
sized.extend(_split_by_size(group, size_config))
return sized or groups
def split_by_parent_child(blocks: list[Block], config: SplitConfig) -> list[ChunkGroup]:
"""
Split into parent chunks (context) and child chunks (retrieval).
1. Partition by parent_delimiter (then cap by parent max length).
2. For each parent: keep a full parent chunk (retrieval=false).
3. Partition parent by child_delimiter (or by length) into children
capped by child_max_size (retrieval=true, linked via parent_section_id).
"""
if not blocks:
return []
parent_delimiter, child_delimiter, parent_max, child_max = _validate(config)
overlap = max(0, int(config.overlap or 0))
parents = partition_blocks_by_delimiter(blocks, parent_delimiter)
if not parents:
parents = [blocks]
parents = _size_cap(parents, parent_max, overlap)
groups: list[ChunkGroup] = []
for parent_idx, parent_blocks in enumerate(parents):
section_id = f"pc-{parent_idx}"
groups.append(
ChunkGroup(
blocks=list(parent_blocks),
meta={
"section_id": section_id,
"is_section_parent": True,
"is_sub_chunk": False,
"retrieval": False,
"chunk_strategy": f"{CHUNK_STRATEGY}_parent",
"parent_index": parent_idx,
},
)
)
if child_delimiter:
children = partition_blocks_by_delimiter(parent_blocks, child_delimiter)
else:
children = [parent_blocks]
if not children:
children = [parent_blocks]
children = _size_cap(children, child_max, overlap)
for child_idx, child_blocks in enumerate(children):
groups.append(
ChunkGroup(
blocks=list(child_blocks),
meta={
"section_id": f"{section_id}-sub-{child_idx}",
"parent_section_id": section_id,
"is_section_parent": False,
"is_sub_chunk": True,
"retrieval": True,
"chunk_strategy": f"{CHUNK_STRATEGY}_child",
"parent_index": parent_idx,
"sub_chunk_index": child_idx,
},
)
)
return groups
+427
View File
@@ -0,0 +1,427 @@
"""Generic heading/layout multimodal splitter for PDF-derived documents."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from rag_cut.models import Block, BlockType, Chunk, SplitConfig
from rag_cut.parsers.pdf.noise_filter import is_toc_noise_text, is_toc_title_text
from rag_cut.renderer import collect_chunk_layout_meta, render_blocks
CHUNK_STRATEGY = "heading_layout_multimodal"
PAGE_NUMBER_RE = re.compile(r"^\s*(?:[-\u2013\u2014]?\s*)?\d{1,4}(?:\s*/\s*\d{1,4})?\s*$")
NUMBERED_HEADING_RE = re.compile(
r"^\s*(?P<num>\d+(?:\.\d+)*)(?:\.|.)?\s*(?P<title>[A-Za-z0-9\u4e00-\u9fff][^\n]{1,120})\s*$"
)
LETTER_HEADING_RE = re.compile(
r"^\s*(?P<letter>[A-Z])[\.)]\s+(?P<title>[A-Za-z0-9\u4e00-\u9fff][^\n]{1,100})\s*$"
)
# Procedural "Step N …" lines are body content, not section boundaries.
# Promoting them to headings caused heading-only groups to be dropped on flush
# (e.g. Steps 1–3 under 5.7.1 vanished while only Step 4 with following body survived).
STEP_INSTRUCTION_RE = re.compile(
r"^\s*(?:Step\s*\d+|STEP\s*\d+|步骤\s*\d+)(?:\s*[:.:)\-]?\s+\S|\s*$)",
re.I,
)
CHAPTER_HEADING_RE = re.compile(r"^\s*Chapter\s+\d+(?:\s*[:.-]?\s+[^\n]{1,100})?\s*$", re.I)
CN_HEADING_RE = re.compile(
r"^\s*(?:[\u4e00-\u9fff]{1,3}[、..]|[((][\u4e00-\u9fff]{1,3}[))])\s*[^\n]{1,100}$"
)
@dataclass
class _Group:
heading_path: list[str] = field(default_factory=list)
blocks: list[Block] = field(default_factory=list)
@dataclass
class _HeadingSignal:
text: str
level: int
def _text(block: Block) -> str:
return (block.text or block.markdown or "").strip()
def _page(block: Block) -> int | None:
page = block.meta.get("page")
try:
return int(page) if page is not None else None
except (TypeError, ValueError):
return None
def _bbox(block: Block) -> list[float]:
bbox = block.meta.get("bbox")
return list(bbox) if isinstance(bbox, (list, tuple)) else []
def _copy_with_meta(block: Block, **meta_updates) -> Block:
meta = dict(block.meta)
meta.update({k: v for k, v in meta_updates.items() if v not in (None, [], {})})
return block.model_copy(update={"meta": meta})
def _is_empty_text_block(block: Block) -> bool:
return block.type not in {BlockType.IMAGE, BlockType.TABLE} and not _text(block)
def _is_toc_text(text: str) -> bool:
return is_toc_noise_text(text) or is_toc_title_text(text)
def _is_decorative_image(block: Block) -> bool:
bbox = _bbox(block)
if len(bbox) != 4:
return False
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
if width <= 0 or height <= 0:
return True
if width < 28 or height < 16:
return True
page_height = block.meta.get("page_height") or 0
if page_height and bbox[3] <= page_height * 0.10 and width * height < 12000:
return True
return False
def _is_margin_noise(block: Block) -> bool:
if block.type == BlockType.HEADING:
return False
text = _text(block)
bbox = _bbox(block)
page_height = block.meta.get("page_height") or 0
if not bbox or not page_height:
return False
y0, y1 = bbox[1], bbox[3]
in_top = y1 <= page_height * 0.08
in_bottom = y0 >= page_height * 0.92
if PAGE_NUMBER_RE.match(text) and (in_top or in_bottom):
return True
return len(text) <= 80 and (in_top or in_bottom) and block.meta.get("running_header")
def _running_header_texts(blocks: list[Block]) -> set[str]:
page_count = len({_page(b) for b in blocks if _page(b) is not None})
if page_count < 3:
return set()
locations: dict[str, set[int]] = {}
for block in blocks:
if block.type in {BlockType.IMAGE, BlockType.TABLE, BlockType.HEADING}:
continue
text = " ".join(_text(block).split())
page = _page(block)
bbox = _bbox(block)
page_height = block.meta.get("page_height") or 0
if not text or page is None or not bbox or not page_height or len(text) > 100:
continue
if bbox[3] <= page_height * 0.10 or bbox[1] >= page_height * 0.90:
locations.setdefault(text, set()).add(page)
threshold = max(3, int(page_count * 0.5))
return {text for text, pages in locations.items() if len(pages) >= threshold}
def _is_noise(block: Block, running_headers: set[str]) -> bool:
if _is_empty_text_block(block):
return True
text = " ".join(_text(block).split())
if block.type == BlockType.IMAGE:
return _is_decorative_image(block)
if PAGE_NUMBER_RE.match(text):
return True
if _is_toc_text(text):
return True
if text in running_headers:
return True
return _is_margin_noise(block)
def _heading_signal(block: Block, current_top_level: bool = False) -> _HeadingSignal | None:
text = _text(block)
if not text or len(text) > 180 or _is_toc_text(text):
return None
# Keep procedural steps inside the parent section; do not open a new group.
if STEP_INSTRUCTION_RE.match(text):
return None
if block.type == BlockType.HEADING:
level = block.level or 1
numbered = NUMBERED_HEADING_RE.match(text)
if numbered:
level = numbered.group("num").count(".") + 1
elif LETTER_HEADING_RE.match(text):
level = 2 if current_top_level else max(2, level)
return _HeadingSignal(text=" ".join(text.split()), level=max(1, min(level, 6)))
numbered = NUMBERED_HEADING_RE.match(text)
if numbered:
return _HeadingSignal(text=" ".join(text.split()), level=numbered.group("num").count(".") + 1)
if LETTER_HEADING_RE.match(text):
return _HeadingSignal(text=" ".join(text.split()), level=2)
if CHAPTER_HEADING_RE.match(text):
return _HeadingSignal(text=" ".join(text.split()), level=1)
if CN_HEADING_RE.match(text):
return _HeadingSignal(text=" ".join(text.split()), level=2 if current_top_level else 1)
font_size = block.meta.get("font_size")
body_size = block.meta.get("body_font_size")
if font_size and body_size and font_size >= body_size + 1.5 and len(text) <= 100:
level = block.level or 2
# Short CJK section banners (形態指標 / 策略指標) are chapter peers, not
# subsections of the preceding numbered person/indicator entry.
if (
font_size >= body_size + 3
and re.fullmatch(r"[\u4e00-\u9fffA-Za-z0-9//\s]{2,24}", text)
and not NUMBERED_HEADING_RE.match(text)
):
level = 1
return _HeadingSignal(text=" ".join(text.split()), level=max(1, min(level, 6)))
return None
def _heading_block(block: Block, signal: _HeadingSignal) -> Block:
meta = dict(block.meta)
meta["section_boundary"] = True
meta["heading_level"] = signal.level
return Block(type=BlockType.HEADING, text=signal.text, level=signal.level, meta=meta)
def _enrich_block(block: Block, heading_path: list[str], group_blocks: list[Block]) -> Block:
meta = dict(block.meta)
meta["heading_path"] = list(heading_path)
if heading_path:
meta["nearest_heading"] = heading_path[-1]
meta["parent_heading"] = heading_path[-1]
meta["chapter"] = heading_path[0]
meta.setdefault("section_boundary", block.type == BlockType.HEADING)
if block.type == BlockType.HEADING:
meta["heading_level"] = block.level or len(heading_path) or 1
if block.type in {BlockType.IMAGE, BlockType.TABLE}:
meta.setdefault("bound_heading", heading_path[-1] if heading_path else "")
_bind_adjacent_text(meta, block, group_blocks)
return block.model_copy(update={"meta": meta})
def _bind_adjacent_text(meta: dict, block: Block, group_blocks: list[Block]) -> None:
page = _page(block)
for prev in reversed(group_blocks):
if prev.type == BlockType.PARAGRAPH and _page(prev) == page and _text(prev):
meta.setdefault("preceding_text", _text(prev)[:400])
break
if prev.type == BlockType.HEADING:
break
if block.type == BlockType.TABLE and not meta.get("table_title"):
for prev in reversed(group_blocks):
if prev.type == BlockType.HEADING:
meta["table_title"] = _text(prev)
break
if prev.type == BlockType.PARAGRAPH and _page(prev) == page and 0 < len(_text(prev)) <= 120:
meta["table_title"] = _text(prev)
break
def _fill_following_text(group: _Group) -> _Group:
blocks = list(group.blocks)
for i, block in enumerate(blocks):
if block.type not in {BlockType.IMAGE, BlockType.TABLE}:
continue
meta = dict(block.meta)
page = _page(block)
for nxt in blocks[i + 1 :]:
if nxt.type == BlockType.HEADING:
break
if nxt.type == BlockType.PARAGRAPH and _page(nxt) == page and _text(nxt):
meta.setdefault("following_text", _text(nxt)[:400])
break
blocks[i] = block.model_copy(update={"meta": meta})
return _Group(heading_path=group.heading_path, blocks=blocks)
def _group_has_body(group: _Group) -> bool:
for block in group.blocks:
if block.type in {BlockType.IMAGE, BlockType.TABLE}:
return True
if block.type != BlockType.HEADING and _text(block):
return True
# Safety net: long instructional "headings" are themselves searchable content.
return any(block.type == BlockType.HEADING and len(_text(block)) >= 48 for block in group.blocks)
def _attach_heading_only_to_previous(groups: list[_Group], orphan: _Group) -> None:
"""Keep catalog-style heading-only groups instead of deleting them.
Example: after \"共包含以下11個形態指標說明:\", lines like \"1.頭肩頂形態\"
briefly open a group with no body before the next number arrives. Flush used
to drop them; fold those leaf titles into the previous section as paragraphs.
"""
if not groups or not orphan.blocks:
return
prev = groups[-1]
prev_keys = {(b.type, _text(b), tuple(b.meta.get("bbox") or [])) for b in prev.blocks}
path_set = set(prev.heading_path)
for block in orphan.blocks:
text = _text(block)
if not text:
continue
# Skip copies of ancestor headings already injected into the orphan group.
if text in path_set:
continue
key = (block.type, text, tuple(block.meta.get("bbox") or []))
if key in prev_keys:
continue
if block.type == BlockType.HEADING:
meta = dict(block.meta)
meta["catalog_item"] = True
meta["section_boundary"] = False
demoted = Block(type=BlockType.PARAGRAPH, text=text, meta=meta)
prev.blocks.append(_enrich_block(demoted, prev.heading_path, prev.blocks))
else:
prev.blocks.append(_enrich_block(block, prev.heading_path, prev.blocks))
prev_keys.add(key)
def _group_is_retrievable(group: _Group) -> bool:
"""Return whether a rendered group contains searchable semantic context."""
if group.heading_path:
return True
for block in group.blocks:
if block.type == BlockType.TABLE:
return True
if block.type == BlockType.IMAGE:
if block.ocr_text.strip():
return True
continue
if block.type != BlockType.HEADING and _text(block):
return True
return False
def _render_group(group: _Group, index: int, config: SplitConfig, meta: dict | None = None) -> Chunk:
group = _fill_following_text(group)
layout_meta = collect_chunk_layout_meta(group.blocks)
heading_path = group.heading_path or layout_meta.get("heading_path") or []
heading = heading_path[-1] if heading_path else layout_meta.get("heading") or "Untitled section"
extra_meta = {
**layout_meta,
"chunk_strategy": CHUNK_STRATEGY,
"heading": heading,
"heading_path": heading_path,
"retrieval": _group_is_retrievable(group),
**(meta or {}),
}
if "order_range" not in extra_meta:
order_indices = [b.meta.get("order_index") for b in group.blocks if b.meta.get("order_index") is not None]
if order_indices:
extra_meta["order_range"] = [min(order_indices), max(order_indices)]
chunk = render_blocks(group.blocks, index=index, meta=extra_meta)
chunk_meta = dict(chunk.meta)
chunk_meta.update(extra_meta)
return chunk.model_copy(update={"meta": chunk_meta})
def _split_oversized_group(group: _Group, start_index: int, config: SplitConfig) -> list[Chunk]:
"""Split an oversized section by length only (no parent+child pair)."""
rendered_len = sum(len(b.render()) + 2 for b in group.blocks)
if rendered_len <= config.max_chunk_size or len(group.blocks) <= 3:
return [_render_group(group, start_index, config)]
heading_blocks = [b for b in group.blocks if b.type == BlockType.HEADING]
prefix = heading_blocks[:1]
chunks: list[Chunk] = []
current: list[Block] = list(prefix)
current_len = sum(len(b.render()) + 2 for b in current)
part_index = 0
def flush() -> None:
nonlocal current, current_len, part_index
body = [b for b in current if b not in prefix]
if not body:
return
part_group = _Group(group.heading_path, list(current))
chunks.append(
_render_group(
part_group,
start_index + len(chunks),
config,
meta={
"chunk_strategy": f"{CHUNK_STRATEGY}_part",
"part_index": part_index,
"retrieval": True,
},
)
)
part_index += 1
current = list(prefix)
current_len = sum(len(b.render()) + 2 for b in current)
for block in group.blocks[len(prefix) :]:
block_len = len(block.render()) + 2
if current_len + block_len > config.max_chunk_size and len(current) > len(prefix):
flush()
current.append(block)
current_len += block_len
flush()
return chunks or [_render_group(group, start_index, config)]
def _build_groups(blocks: list[Block]) -> list[_Group]:
running_headers = _running_header_texts(blocks)
heading_stack: list[tuple[int, str, Block]] = []
groups: list[_Group] = []
current = _Group()
def flush() -> None:
nonlocal current
if _group_has_body(current):
groups.append(current)
elif current.blocks:
# Explicit parser headings with no body are empty document sections,
# not catalog items to fold into the preceding section.
if not any(block.meta.get("source_heading") for block in current.blocks):
_attach_heading_only_to_previous(groups, current)
current = _Group()
for raw in blocks:
if _is_noise(raw, running_headers):
continue
signal = _heading_signal(raw, current_top_level=bool(heading_stack))
if signal:
flush()
while heading_stack and heading_stack[-1][0] >= signal.level:
heading_stack.pop()
heading = _heading_block(raw, signal)
heading_stack.append((signal.level, signal.text, heading))
path = [item[1] for item in heading_stack]
current = _Group(heading_path=path)
for _, _, h_block in heading_stack:
current.blocks.append(_enrich_block(h_block, path, current.blocks))
continue
path = [item[1] for item in heading_stack]
if not current.blocks and heading_stack:
current.heading_path = path
for _, _, h_block in heading_stack:
current.blocks.append(_enrich_block(h_block, path, current.blocks))
elif not current.heading_path:
current.heading_path = path
current.blocks.append(_enrich_block(raw, current.heading_path, current.blocks))
flush()
return groups
def split_pdf_semantic(blocks: list[Block], config: SplitConfig) -> list[Chunk]:
"""Split PDF/Word-derived blocks into heading/layout-preserving multimodal chunks."""
groups = _build_groups(blocks)
chunks: list[Chunk] = []
for group in groups:
for chunk in _split_oversized_group(group, len(chunks), config):
chunks.append(chunk.model_copy(update={"index": len(chunks)}))
return chunks
+82
View File
@@ -0,0 +1,82 @@
"""Choose a PDF chunking strategy from parsed document signals."""
from __future__ import annotations
import re
from pathlib import Path
from rag_cut.models import Block, BlockType
FEATURE_TITLE_RE = re.compile(r"^\s*\d{1,2}[..、]\s*[A-Za-z0-9\u4e00-\u9fff&/ -]{2,24}")
OPERATION_TERMS = (
"点击",
"點擊",
"选择",
"選擇",
"输入",
"輸入",
"打开",
"打開",
"登入",
"用戶可",
"用户可",
"按<",
"點撃",
)
REPORT_TERMS = (
"年度报告",
"年报",
"財務報表",
"财务报表",
"公司治理",
"董事会",
"董事會",
"审计报告",
"審計報告",
"合并资产负债表",
"合併資產負債表",
"经营情况",
"經營情況",
"营业收入",
"營業收入",
"现金流量",
"現金流量",
"股东",
"股東",
)
REPORT_NAME_TERMS = ("annual", "report", "年度", "年报", "年報", "研报", "研報")
PDF_STRATEGY_FEATURE_STEPS = "pdf_feature_step_screenshot"
PDF_STRATEGY_OUTLINE_REPORT = "pdf_outline_report"
def _text(block: Block) -> str:
return (block.text or block.markdown or "").strip()
def _term_count(text: str, terms: tuple[str, ...]) -> int:
return sum(text.count(term) for term in terms)
def choose_pdf_chunk_strategy(path: Path, blocks: list[Block]) -> str:
"""Classify PDFs as operation manuals or report-like documents."""
text_blocks = [block for block in blocks if block.type != BlockType.IMAGE and _text(block)]
image_count = sum(1 for block in blocks if block.type == BlockType.IMAGE)
sample = "\n".join(_text(block) for block in text_blocks[:240])
filename = path.name.lower()
report_score = _term_count(sample, REPORT_TERMS)
if any(term in filename for term in REPORT_NAME_TERMS):
report_score += 3
feature_title_count = sum(1 for block in text_blocks if FEATURE_TITLE_RE.match(_text(block)))
operation_score = _term_count(sample, OPERATION_TERMS)
screenshot_density = image_count / max(len(text_blocks), 1)
if report_score >= 3 and operation_score < 18:
return PDF_STRATEGY_OUTLINE_REPORT
if feature_title_count >= 3 and operation_score >= 6 and image_count >= 3 and screenshot_density >= 0.12:
return PDF_STRATEGY_FEATURE_STEPS
return PDF_STRATEGY_OUTLINE_REPORT
+53
View File
@@ -0,0 +1,53 @@
"""Table-aware chunk grouping helpers."""
from __future__ import annotations
from rag_cut.models import Block, BlockType
def group_tables_with_context(blocks: list[Block]) -> list[list[Block]]:
"""
Group each table with its contextual title paragraph and footnote.
Keeps: heading/intro → table → footnote as one atomic group when adjacent.
"""
if not blocks:
return []
groups: list[list[Block]] = []
current: list[Block] = []
i = 0
while i < len(blocks):
block = blocks[i]
if block.type != BlockType.TABLE:
current.append(block)
i += 1
continue
group: list[Block] = []
if current:
tail = current[-1]
if tail.type in {BlockType.HEADING, BlockType.PARAGRAPH}:
group.append(tail)
current = current[:-1]
if current:
groups.append(current)
current = []
group.append(block)
j = i + 1
while j < len(blocks):
nxt = blocks[j]
if nxt.type == BlockType.PARAGRAPH and len((nxt.text or "")) <= 320:
if any(k in (nxt.text or "") for k in ("注", "备注", "说明", "Note", "※")):
group.append(nxt)
j += 1
break
break
groups.append(group)
i = j
if current:
groups.append(current)
return groups
+40
View File
@@ -0,0 +1,40 @@
"""Start the RAG-cut backend API server."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parent
def main() -> None:
parser = argparse.ArgumentParser(description="Start RAG-cut backend API")
parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
parser.add_argument(
"--reload",
action=argparse.BooleanOptionalAction,
default=True,
help="Auto-reload on code changes (default: on)",
)
args = parser.parse_args()
backend = str(BACKEND_DIR)
if backend not in sys.path:
sys.path.insert(0, backend)
import uvicorn
uvicorn.run(
"api.main:app",
host=args.host,
port=args.port,
reload=args.reload,
reload_dirs=[backend] if args.reload else None,
)
if __name__ == "__main__":
main()
View File
+244
View File
@@ -0,0 +1,244 @@
"""API integration tests for chunk persistence and recall."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from api.main import app
from rag_cut.models import Chunk, ChunkResult, SplitMode
def result(filename: str = "manual.txt") -> ChunkResult:
content = "交易密码可在账户安全页面修改"
return ChunkResult(
filename=filename,
doc_id="abcdef123456",
split_mode=SplitMode.DEFAULT,
block_count=2,
chunk_count=2,
chunks=[
Chunk(index=0, content="![logo](logo.png)", char_count=18, block_types=["image"], meta={"retrieval": False}),
Chunk(index=1, content=content, char_count=len(content), block_types=["paragraph"], meta={"retrieval": True}),
],
)
class ApiTest(unittest.TestCase):
def setUp(self) -> None:
self.client = TestClient(app)
def test_chunk_preserves_upload_filename_and_persists_result(self) -> None:
with tempfile.TemporaryDirectory() as tmp, patch("api.main.RESULTS_DIR", Path(tmp)):
with patch("api.main.chunk_document", side_effect=lambda path, config=None: result(path.name)):
response = self.client.post("/api/chunk", files={"file": ("用户手册.txt", b"body", "text/plain")})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["filename"], "用户手册.txt")
self.assertTrue((Path(tmp) / "abcdef123456.json").exists())
def test_recall_endpoint_excludes_preview_only_chunk(self) -> None:
with tempfile.TemporaryDirectory() as tmp, patch("api.main.RESULTS_DIR", Path(tmp)):
(Path(tmp) / "abcdef123456.json").write_text(result().model_dump_json(), encoding="utf-8")
response = self.client.post(
"/api/recall",
json={"doc_id": "abcdef123456", "query": "交易密码", "top_k": 5},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["candidate_count"], 1)
self.assertEqual(payload["results"][0]["chunk_index"], 1)
def test_recall_rejects_invalid_doc_id(self) -> None:
response = self.client.post("/api/recall", json={"doc_id": "../secret", "query": "test"})
self.assertEqual(response.status_code, 400)
def test_list_and_get_historical_results(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
results_dir = Path(tmp) / "results"
uploads_dir = Path(tmp) / "uploads"
results_dir.mkdir()
uploads_dir.mkdir()
(results_dir / "abcdef123456.json").write_text(result().model_dump_json(), encoding="utf-8")
upload_doc = uploads_dir / "abcdef123456"
upload_doc.mkdir()
(upload_doc / "manual.txt").write_text("hello original", encoding="utf-8")
with (
patch("api.main.RESULTS_DIR", results_dir),
patch("api.main.UPLOADS_DIR", uploads_dir),
):
listed = self.client.get("/api/results")
self.assertEqual(listed.status_code, 200)
body = listed.json()
self.assertEqual(body["count"], 1)
self.assertEqual(body["results"][0]["doc_id"], "abcdef123456")
self.assertEqual(body["results"][0]["filename"], "manual.txt")
self.assertTrue(body["results"][0]["has_original"])
detail = self.client.get("/api/results/abcdef123456")
self.assertEqual(detail.status_code, 200)
self.assertEqual(detail.json()["chunk_count"], 2)
self.assertTrue(detail.json()["has_original"])
original = self.client.get("/api/results/abcdef123456/original")
self.assertEqual(original.status_code, 200)
self.assertEqual(original.content, b"hello original")
def test_get_result_not_found(self) -> None:
with tempfile.TemporaryDirectory() as tmp, patch("api.main.RESULTS_DIR", Path(tmp)):
response = self.client.get("/api/results/abcdef123456")
self.assertEqual(response.status_code, 404)
def test_word_preview_serves_converted_pdf(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
uploads_dir = root / "uploads"
doc_dir = uploads_dir / "abcdef123456"
conversion_dir = root / "assets" / "abcdef123456" / "_conversion"
doc_dir.mkdir(parents=True)
conversion_dir.mkdir(parents=True)
(doc_dir / "manual.docx").write_bytes(b"word")
preview = conversion_dir / "manual.pdf"
preview.write_bytes(b"%PDF-1.7 preview")
with (
patch("api.main.UPLOADS_DIR", uploads_dir),
patch("api.main.STORAGE_DIR", root),
):
response = self.client.get("/api/results/abcdef123456/preview")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["content-type"], "application/pdf")
self.assertEqual(response.content, b"%PDF-1.7 preview")
def test_word_preview_returns_not_found_without_conversion(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
uploads_dir = root / "uploads"
doc_dir = uploads_dir / "abcdef123456"
doc_dir.mkdir(parents=True)
(doc_dir / "manual.doc").write_bytes(b"word")
with (
patch("api.main.UPLOADS_DIR", uploads_dir),
patch("api.main.STORAGE_DIR", root),
):
response = self.client.get("/api/results/abcdef123456/preview")
self.assertEqual(response.status_code, 404)
def test_delete_historical_result_removes_files(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
results_dir = root / "results"
uploads_dir = root / "uploads"
assets_dir = root / "assets" / "abcdef123456"
results_dir.mkdir()
uploads_dir.mkdir()
assets_dir.mkdir(parents=True)
(results_dir / "abcdef123456.json").write_text(result().model_dump_json(), encoding="utf-8")
upload_doc = uploads_dir / "abcdef123456"
upload_doc.mkdir()
(upload_doc / "manual.txt").write_text("hello", encoding="utf-8")
(assets_dir / "img.png").write_bytes(b"png")
with (
patch("api.main.RESULTS_DIR", results_dir),
patch("api.main.UPLOADS_DIR", uploads_dir),
patch("api.main.STORAGE_DIR", root),
):
deleted = self.client.delete("/api/results/abcdef123456")
self.assertEqual(deleted.status_code, 200)
body = deleted.json()
self.assertTrue(body["deleted"])
self.assertTrue(body["removed"]["result"])
self.assertTrue(body["removed"]["uploads"])
self.assertTrue(body["removed"]["assets"])
self.assertFalse((results_dir / "abcdef123456.json").exists())
self.assertFalse(upload_doc.exists())
self.assertFalse(assets_dir.exists())
listed = self.client.get("/api/results")
self.assertEqual(listed.json()["count"], 0)
missing = self.client.delete("/api/results/abcdef123456")
self.assertEqual(missing.status_code, 404)
def test_delete_rejects_invalid_doc_id(self) -> None:
response = self.client.delete("/api/results/not-a-valid")
self.assertEqual(response.status_code, 400)
def test_get_original_rejects_invalid_doc_id(self) -> None:
response = self.client.get("/api/results/../secret/original")
self.assertIn(response.status_code, (400, 404))
def test_chunk_accepts_manual_mode_and_preserves_zero_overlap(self) -> None:
captured: dict = {}
def fake_chunk(path, config=None):
captured["config"] = config
return result(path.name)
with tempfile.TemporaryDirectory() as tmp, patch("api.main.RESULTS_DIR", Path(tmp)):
with patch("api.main.chunk_document", side_effect=fake_chunk):
response = self.client.post(
"/api/chunk",
files={"file": ("sheet.csv", b"a,b\n1,2\n", "text/csv")},
data={
"mode": "by_row",
"overlap": "0",
"max_chunk_size": "2400",
"header_row_start": "1",
"header_row_end": "1",
"start_row": "2",
"rows_per_chunk": "5",
},
)
self.assertEqual(response.status_code, 200)
config = captured["config"]
self.assertIsNotNone(config)
self.assertEqual(config.mode, SplitMode.BY_ROW)
self.assertEqual(config.overlap, 0)
self.assertEqual(config.rows_per_chunk, 5)
def test_chunk_accepts_parent_child_mode_fields(self) -> None:
captured: dict = {}
def fake_chunk(path, config=None):
captured["config"] = config
return result(path.name)
with tempfile.TemporaryDirectory() as tmp, patch("api.main.RESULTS_DIR", Path(tmp)):
with patch("api.main.chunk_document", side_effect=fake_chunk):
response = self.client.post(
"/api/chunk",
files={"file": ("guide.md", b"a##b###c", "text/markdown")},
data={
"mode": "parent_child",
"parent_delimiter": "##",
"child_delimiter": "###",
"max_chunk_size": "2000",
"child_max_size": "512",
"overlap": "0",
},
)
self.assertEqual(response.status_code, 200)
config = captured["config"]
self.assertEqual(config.mode, SplitMode.PARENT_CHILD)
self.assertEqual(config.parent_delimiter, "##")
self.assertEqual(config.child_delimiter, "###")
self.assertEqual(config.child_max_size, 512)
self.assertEqual(config.overlap, 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,290 @@
"""Tests for generic heading/layout multimodal splitting."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from rag_cut.models import Block, BlockType, SplitConfig, SplitMode
from rag_cut.pipeline import chunk_document
from rag_cut.splitters.pdf_semantic import CHUNK_STRATEGY, split_pdf_semantic
def h(text: str, page: int = 1, level: int = 0, y: int = 100) -> Block:
return Block(
type=BlockType.HEADING if level else BlockType.PARAGRAPH,
text=text,
level=level,
meta={"page": page, "bbox": [72, y, 520, y + 20], "page_height": 800},
)
def p(text: str, page: int = 1, y: int = 130) -> Block:
return Block(
type=BlockType.PARAGRAPH,
text=text,
meta={"page": page, "bbox": [72, y, 520, y + 20], "page_height": 800},
)
def img(name: str, page: int = 1, y: int = 180) -> Block:
return Block(
type=BlockType.IMAGE,
image_id=name,
image_path=f"assets/test/{name}.png",
ocr_text="Login Submit",
meta={"page": page, "bbox": [100, y, 500, y + 180], "page_height": 800},
)
def table(page: int = 1, y: int = 220) -> Block:
return Block(
type=BlockType.TABLE,
markdown="| Field | Description |\n| --- | --- |\n| status | Account status |",
image_id="table1",
image_path="assets/test/table1.png",
meta={
"page": page,
"pages": [page],
"bbox": [72, y, 520, y + 120],
"bboxes": [{"page": page, "bbox": [72, y, 520, y + 120]}],
"row_count": 2,
"col_count": 2,
"cross_page": False,
"crop_path": "assets/test/table1.png",
},
)
class HeadingLayoutMultimodalTest(unittest.TestCase):
def test_numbered_headings_create_same_level_boundaries(self) -> None:
chunks = split_pdf_semantic(
[
h("1 Overview"),
p("Intro text."),
h("1.1 Account Status", y=180),
p("Account status details.", y=210),
h("1.2 Trade Detail", y=260),
p("Trade detail body.", y=290),
],
SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2000),
)
headings = [c.meta["heading"] for c in chunks]
self.assertEqual(headings, ["1 Overview", "1.1 Account Status", "1.2 Trade Detail"])
self.assertTrue(all(c.meta["chunk_strategy"] == CHUNK_STRATEGY for c in chunks))
def test_letter_subheadings_bind_nearest_images(self) -> None:
chunks = split_pdf_semantic(
[
h("1 Getting Started"),
h("A. Log in", y=140),
p("Open the login page.", y=170),
img("login", y=210),
h("B) Authentication", y=430),
p("Approve the authentication request.", y=460),
img("auth", y=500),
],
SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2000),
)
self.assertEqual([c.meta["heading"] for c in chunks], ["A. Log in", "B) Authentication"])
self.assertEqual(chunks[0].meta["heading_path"], ["1 Getting Started", "A. Log in"])
self.assertEqual(chunks[0].meta["images"][0]["image_id"], "login")
self.assertEqual(chunks[1].meta["images"][0]["image_id"], "auth")
def test_table_stays_atomic_with_markdown_and_position(self) -> None:
chunks = split_pdf_semantic(
[h("2 Settings"), p("The fields are listed below."), table(y=180)],
SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2000),
)
self.assertEqual(len(chunks), 1)
self.assertIn("| Field | Description |", chunks[0].content)
self.assertEqual(chunks[0].meta["tables"][0]["row_count"], 2)
self.assertEqual(chunks[0].meta["tables"][0]["bbox"], [72, 180, 520, 300])
def test_unlabelled_image_only_chunk_is_preview_only(self) -> None:
cover_logo = img("cover-logo")
cover_logo.ocr_text = ""
chunks = split_pdf_semantic(
[cover_logo, h("1 Overview", page=2), p("Useful body.", page=2)],
SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2000),
)
self.assertEqual(len(chunks), 2)
self.assertFalse(chunks[0].meta["retrieval"])
self.assertTrue(chunks[1].meta["retrieval"])
self.assertIn("cover-logo", chunks[0].content)
def test_image_only_chunk_with_ocr_remains_retrievable(self) -> None:
chunks = split_pdf_semantic(
[img("workflow-screenshot")],
SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2000),
)
self.assertEqual(len(chunks), 1)
self.assertTrue(chunks[0].meta["retrieval"])
def test_noise_blocks_are_skipped(self) -> None:
blocks = []
for page in range(1, 5):
blocks.append(p("Product Manual", page=page, y=20))
blocks.append(p(str(page), page=page, y=760))
blocks.extend(
[
p("Contents"),
p("1 Intro ........ 3"),
p("2 Setup ........ 8"),
h("1 Intro", page=2),
p("Useful body.", page=2),
]
)
chunks = split_pdf_semantic(blocks, SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2000))
content = "\n".join(c.content for c in chunks)
self.assertIn("Useful body.", content)
self.assertNotIn("Product Manual", content)
self.assertNotIn("Contents", content)
self.assertNotIn("........", content)
self.assertNotIn("2 Setup", content)
def test_cross_page_same_heading_is_one_chunk(self) -> None:
chunks = split_pdf_semantic(
[h("3 Client"), p("Page one text.", page=1), p("Page two continuation.", page=2)],
SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2000),
)
self.assertEqual(len(chunks), 1)
self.assertEqual(chunks[0].meta["pages"], [1, 2])
self.assertIn("Page two continuation.", chunks[0].content)
def test_instructional_steps_stay_in_parent_section(self) -> None:
"""Step 1/2/3 lines must not become headings that get flushed away."""
chunks = split_pdf_semantic(
[
h("5.7 CRS SUBMISSION TO IRD", level=2),
h("5.7.1 Submission at 1st time", level=3, y=140),
p(
"For CRS submission at first time by G3SB, prior consent has to be obtained "
"from IRD by submitting test data file to the AEOI Portal for validation.",
y=170,
),
img("workflow", y=210),
p("Step 1 Users prepare the XML as per sections 5.1– 5.5.", y=420),
p('Step 2 Set parameter "Export in test data format?" value to "Y".', y=450),
p("Step 3 Check XML file tag DocTypeIndic was using OECD11.", y=480),
p(
"Step 4 Go to Registration and login page of AEOI Portal: "
"https://aeoi1.ird.gov.hk/portal/landing/",
y=510,
),
p("Download the encryption tools to encrypt the XML file.", y=540),
],
SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=4000),
)
content = "\n".join(c.content for c in chunks)
self.assertIn("Step 1 Users prepare the XML", content)
self.assertIn("Export in test data format", content)
self.assertIn("OECD11", content)
self.assertIn("Step 4 Go to Registration", content)
self.assertTrue(any(c.meta["heading"] == "5.7.1 Submission at 1st time" for c in chunks))
def test_oversized_section_splits_by_length_without_parent(self) -> None:
blocks = [h("4 Reports")] + [p(f"Long paragraph {i} " + "x" * 80, y=130 + i) for i in range(8)]
chunks = split_pdf_semantic(blocks, SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=260))
self.assertGreater(len(chunks), 1)
self.assertTrue(all(c.meta.get("retrieval", True) for c in chunks))
self.assertFalse(any(c.meta.get("is_section_parent") for c in chunks))
self.assertFalse(any(c.meta.get("is_sub_chunk") for c in chunks))
self.assertFalse(any(c.meta.get("parent_chunk_id") is not None for c in chunks))
def test_catalog_titles_fold_into_section_not_dropped(self) -> None:
"""TOC-style '1.xxx' lines between catalog headings must not vanish on flush."""
chunks = split_pdf_semantic(
[
h("14.W. Whitney George", level=1, y=100),
p("Famous fund manager bio.", y=130),
Block(
type=BlockType.HEADING,
text="形態指標",
level=1,
meta={
"page": 1,
"bbox": [72, 200, 200, 220],
"page_height": 800,
"font_size": 16,
"body_font_size": 12,
},
),
p("共包含以下11個形態指標說明:", y=240),
h("1.頭肩頂形態", level=1, y=260),
h("2.頭肩底形態", level=1, y=280),
h("1.頭肩頂形態", level=1, page=2, y=120),
p("整體介紹:頂部反轉形態說明。", page=2, y=150),
],
SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=4000),
)
morph = next(c for c in chunks if c.meta.get("heading") == "形態指標")
self.assertEqual(morph.meta.get("heading_path"), ["形態指標"])
self.assertIn("共包含以下11個形態指標說明:", morph.content)
self.assertIn("1.頭肩頂形態", morph.content)
self.assertIn("2.頭肩底形態", morph.content)
detail = next(
c for c in chunks if c.meta.get("heading") == "1.頭肩頂形態" and "整體介紹" in c.content
)
self.assertIn("整體介紹", detail.content)
def test_empty_explicit_section_does_not_pollute_previous_chunk(self) -> None:
empty_heading = h("2 REFERENCES", level=1, page=3, y=100)
empty_heading.meta["source_heading"] = True
chunks = split_pdf_semantic(
[
h("1 GLOSSARY", level=1, page=2, y=100),
table(page=2, y=140),
empty_heading,
h("3 DOCUMENT HISTORY", level=1, page=3, y=180),
p("History body.", page=3, y=220),
],
SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=4000),
)
glossary = next(c for c in chunks if c.meta.get("heading") == "1 GLOSSARY")
self.assertNotIn("2 REFERENCES", glossary.content)
self.assertFalse(any("2 REFERENCES" in c.content for c in chunks))
class DummyParser:
def parse(self, path: Path, assets_dir: Path) -> list[Block]:
return [h("1 Overview"), p("Pipeline body.")]
class PipelineSemanticIntegrationTest(unittest.TestCase):
def test_pdf_default_mode_uses_heading_layout_multimodal(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
tmp_root = Path(tmp)
source = tmp_root / "sample.pdf"
source.write_bytes(b"%PDF-1.4\n")
with patch("rag_cut.pipeline.get_parser", return_value=DummyParser()):
result = chunk_document(
source,
config=SplitConfig(mode=SplitMode.DEFAULT, max_chunk_size=2000),
storage_root=tmp_root / "storage",
)
self.assertEqual(result.chunk_count, 1)
self.assertEqual(result.split_config["chunk_strategy"], CHUNK_STRATEGY)
self.assertEqual(result.chunks[0].meta["chunk_strategy"], CHUNK_STRATEGY)
if __name__ == "__main__":
unittest.main()
+98
View File
@@ -0,0 +1,98 @@
"""Tests for PDF text merge and heading classification."""
from __future__ import annotations
import unittest
from rag_cut.layout_meta import enrich_layout_metadata
from rag_cut.models import Block, BlockType
from rag_cut.parsers.pdf.layout import TextLine, _merge_lines_to_paragraphs
from rag_cut.parsers.pdf.text_extract import MAX_HEADING_CHARS, _font_heading_level, _looks_like_ui_label
from rag_cut.parsers.pdf.noise_filter import is_margin_noise_block
from rag_cut.splitters.heading_splitter import is_heading_block, normalize_heading_block, numbered_heading_level
class ParagraphMergeTest(unittest.TestCase):
def test_does_not_merge_title_and_body_with_different_font_sizes(self) -> None:
lines = [
TextLine(x0=90, y0=90, x1=400, y1=110, text="Master Collection/Pattern Scanning", font_size=18),
TextLine(x0=90, y0=115, x1=480, y1=132, text="及所有大師/形態/策略指標的位置", font_size=17.5),
TextLine(
x0=90,
y0=145,
x1=500,
y1=200,
text="頁面左側【篩選】欄下方即是【Master Collection】文件夾。點擊此檔夾即可出現下拉頁面。",
font_size=11,
),
]
merged = _merge_lines_to_paragraphs(lines, page_width=600)
texts = [ln.text for ln in merged]
self.assertEqual(len(merged), 2)
self.assertIn("Master Collection", texts[0])
self.assertTrue(texts[1].startswith("頁面左側"))
self.assertNotIn("頁面左側", texts[0])
class FontHeadingTest(unittest.TestCase):
def test_short_large_text_is_heading(self) -> None:
level = _font_heading_level(18, 11, "Master Collection 位置說明")
self.assertIsNotNone(level)
def test_title_plus_body_blob_is_not_heading(self) -> None:
blob = (
"Master Collection/Pattern Scanning/Strategy Scanning 及所有大師/形態/策略指標的位置 "
"頁面左側【篩選】欄下方即是【Master Collection】文件夾。點擊此檔夾即可出現下拉頁面,"
"其中包含了十五個大師的選股策略。如圖所示:"
)
self.assertGreater(len(blob), MAX_HEADING_CHARS)
self.assertIsNone(_font_heading_level(18, 11, blob))
def test_compact_numbered_chinese_title_is_heading(self) -> None:
# FAQ style: "10.上升三角形態" with no space after the dot.
level = _font_heading_level(16, 12, "10.上升三角形態")
self.assertEqual(level, 1)
self.assertFalse(_looks_like_ui_label("10.上升三角形態"))
def test_cjk_section_banner_is_level1_not_ui_label(self) -> None:
self.assertFalse(_looks_like_ui_label("形態指標"))
self.assertEqual(_font_heading_level(16, 12, "形態指標"), 1)
class HeadingDemoteTest(unittest.TestCase):
def test_normalize_demotes_overlong_heading(self) -> None:
blob = "T" * (MAX_HEADING_CHARS + 20)
block = Block(type=BlockType.HEADING, text=blob, level=1, meta={"page": 1})
normalized = normalize_heading_block(block)
self.assertEqual(normalized.type, BlockType.PARAGRAPH)
self.assertFalse(is_heading_block(normalized))
def test_enrich_demotes_overlong_heading_block(self) -> None:
blob = (
"Master Collection/Pattern Scanning/Strategy Scanning Master Collection/"
"Pattern Scanning/Strategy Scanning 及所有大師 /形態/策略指標的位置 "
"頁面左側【篩選】欄下方即是【Master Collection】文件夾。點擊此檔夾即可出 "
"現下拉頁面,其中包含了十五個大師的選股策略。"
)
blocks = [
Block(type=BlockType.HEADING, text=blob, level=1, meta={"page": 1, "bbox": [90, 90, 500, 240]}),
Block(type=BlockType.IMAGE, image_id="a.png", image_path="a.png", meta={"page": 1, "bbox": [90, 250, 500, 480]}),
]
enriched = enrich_layout_metadata(blocks)
self.assertEqual(enriched[0].type, BlockType.PARAGRAPH)
self.assertNotEqual(enriched[1].meta.get("bound_heading"), blob)
def test_top_of_page_numbered_title_not_margin_noise(self) -> None:
block = Block(
type=BlockType.PARAGRAPH,
text="10.上升三角形態",
meta={"page": 50, "bbox": [90, 74, 209, 90], "page_height": 842},
)
self.assertFalse(is_margin_noise_block(block, 842))
self.assertEqual(numbered_heading_level("10.上升三角形態"), 1)
self.assertTrue(is_heading_block(normalize_heading_block(block)))
if __name__ == "__main__":
unittest.main()
+166
View File
@@ -0,0 +1,166 @@
"""Tests for MinerU content_list → Block mapping."""
from __future__ import annotations
import tempfile
import subprocess
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from rag_cut.models import BlockType
from rag_cut.parsers.mineru_adapter import (
_blocks_from_content_list,
_find_content_list,
_run_mineru,
)
class MineruAdapterProcessTest(unittest.TestCase):
def test_default_timeout_allows_large_manual_to_finish(self) -> None:
with patch.dict("os.environ", {}, clear=True):
from rag_cut.parsers.mineru_adapter import _mineru_timeout_sec
self.assertEqual(_mineru_timeout_sec(), 540.0)
def test_configured_api_url_is_passed_to_mineru(self) -> None:
process = Mock()
process.communicate.return_value = ("", "")
process.returncode = 0
with (
tempfile.TemporaryDirectory() as tmp,
patch("rag_cut.parsers.mineru_adapter._mineru_command", return_value="mineru"),
patch.dict("os.environ", {"RAG_CUT_MINERU_API_URL": "http://127.0.0.1:30000"}),
patch("rag_cut.parsers.mineru_adapter.subprocess.Popen", return_value=process) as popen,
):
ok = _run_mineru(Path(tmp) / "manual.pdf", Path(tmp) / "output")
self.assertTrue(ok)
args = popen.call_args.args[0]
self.assertEqual(args[-2:], ["--api-url", "http://127.0.0.1:30000"])
def test_timeout_terminates_windows_process_tree(self) -> None:
process = Mock()
process.pid = 4321
process.communicate.side_effect = subprocess.TimeoutExpired("mineru", 30)
process.poll.return_value = None
with (
tempfile.TemporaryDirectory() as tmp,
patch("rag_cut.parsers.mineru_adapter._mineru_command", return_value="mineru"),
patch("rag_cut.parsers.mineru_adapter._mineru_timeout_sec", return_value=30),
patch("rag_cut.parsers.mineru_adapter.os.name", "nt"),
patch("rag_cut.parsers.mineru_adapter.subprocess.Popen", return_value=process),
patch("rag_cut.parsers.mineru_adapter.subprocess.run") as run,
):
ok = _run_mineru(Path(tmp) / "manual.pdf", Path(tmp) / "output")
self.assertFalse(ok)
run.assert_called_once_with(
["taskkill", "/PID", "4321", "/T", "/F"],
check=False,
capture_output=True,
timeout=10,
)
class MineruAdapterMappingTest(unittest.TestCase):
def test_chart_becomes_image_block(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
assets = Path(tmp)
img = assets / "chart.jpg"
img.write_bytes(b"fake")
items = [
{
"type": "text",
"text": "第二十節 TradingView",
"text_level": 2,
"page_idx": 30,
"bbox": [127, 86, 363, 104],
},
{
"type": "chart",
"img_path": "chart.jpg",
"page_idx": 30,
"bbox": [129, 171, 878, 428],
"image_caption": [],
},
]
with patch("rag_cut.parsers.mineru_adapter._mineru_ocr_enabled", return_value=False):
blocks = _blocks_from_content_list(items, assets / "content_list.json", assets)
types = [b.type for b in blocks]
self.assertEqual(types, [BlockType.HEADING, BlockType.IMAGE])
self.assertEqual(blocks[1].image_id, "chart.jpg")
self.assertEqual(blocks[1].meta.get("mineru_type"), "chart")
self.assertEqual(blocks[1].meta.get("page"), 31)
def test_table_keeps_markdown_and_screenshot(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
assets = Path(tmp)
img = assets / "table.png"
img.write_bytes(b"png")
items = [
{
"type": "table",
"img_path": "table.png",
"page_idx": 1,
"bbox": [100, 100, 400, 300],
"table_body": "<table><tr><td>A</td><td>B</td></tr></table>",
}
]
with patch("rag_cut.parsers.mineru_adapter._mineru_ocr_enabled", return_value=False):
blocks = _blocks_from_content_list(items, assets / "content_list.json", assets)
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0].type, BlockType.TABLE)
self.assertIn("<table>", blocks[0].markdown or "")
self.assertEqual(blocks[0].image_id, "table.png")
self.assertTrue(blocks[0].image_path)
def test_page_footnote_kept_as_paragraph(self) -> None:
items = [
{
"type": "page_footnote",
"text": "<sup>1</sup> See Market Master.",
"page_idx": 8,
"bbox": [85, 889, 912, 916],
}
]
with tempfile.TemporaryDirectory() as tmp:
blocks = _blocks_from_content_list(items, Path(tmp) / "content_list.json", Path(tmp))
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0].type, BlockType.PARAGRAPH)
self.assertTrue(blocks[0].meta.get("is_footnote"))
def test_list_items_become_paragraph(self) -> None:
items = [
{
"type": "list",
"list_items": ["第一點說明", {"text": "第二點說明"}],
"page_idx": 2,
"bbox": [100, 200, 400, 260],
}
]
with tempfile.TemporaryDirectory() as tmp:
blocks = _blocks_from_content_list(items, Path(tmp) / "content_list.json", Path(tmp))
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0].type, BlockType.PARAGRAPH)
self.assertIn("第一點說明", blocks[0].text or "")
self.assertIn("第二點說明", blocks[0].text or "")
def test_find_content_list_prefers_non_v2(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "doc" / "auto"
root.mkdir(parents=True)
v1 = root / "doc_content_list.json"
v2 = root / "doc_content_list_v2.json"
v1.write_text("[]", encoding="utf-8")
v2.write_text("[]", encoding="utf-8")
found = _find_content_list(Path(tmp))
self.assertEqual(found, v1)
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
"""Tests for parent/child delimiter splitting."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from rag_cut.models import Block, BlockType, SplitConfig, SplitMode
from rag_cut.pipeline import chunk_document
from rag_cut.splitters.parent_child import split_by_parent_child
def p(text: str) -> Block:
return Block(type=BlockType.PARAGRAPH, text=text)
class ParentChildSplitTest(unittest.TestCase):
def test_parent_and_child_chunks_are_linked(self) -> None:
blocks = [p("Intro A##Detail A1###Detail A2##Intro B###Detail B1")]
groups = split_by_parent_child(
blocks,
SplitConfig(
mode=SplitMode.PARENT_CHILD,
parent_delimiter="##",
child_delimiter="###",
max_chunk_size=2000,
child_max_size=500,
overlap=0,
),
)
parents = [g for g in groups if g.meta.get("is_section_parent")]
children = [g for g in groups if g.meta.get("is_sub_chunk")]
self.assertGreaterEqual(len(parents), 2)
self.assertGreaterEqual(len(children), 2)
self.assertTrue(all(g.meta.get("retrieval") is False for g in parents))
self.assertTrue(all(g.meta.get("retrieval") is True for g in children))
self.assertTrue(all(g.meta.get("parent_section_id") for g in children))
def test_requires_parent_delimiter(self) -> None:
with self.assertRaises(ValueError):
split_by_parent_child(
[p("hello")],
SplitConfig(mode=SplitMode.PARENT_CHILD, parent_delimiter=None),
)
def test_pipeline_persists_parent_chunk_ids(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "pc.md"
source.write_text(
"Alpha parent##A1 child###A2 child##Beta parent###B1 child",
encoding="utf-8",
)
result = chunk_document(
source,
config=SplitConfig(
mode=SplitMode.PARENT_CHILD,
parent_delimiter="##",
child_delimiter="###",
max_chunk_size=2000,
child_max_size=400,
overlap=0,
),
storage_root=Path(tmp) / "storage",
)
self.assertEqual(result.split_mode, SplitMode.PARENT_CHILD)
self.assertEqual(result.split_config.get("chunk_strategy"), "parent_child_delimiter")
parents = [c for c in result.chunks if c.meta.get("is_section_parent")]
children = [c for c in result.chunks if c.meta.get("is_sub_chunk")]
self.assertTrue(parents)
self.assertTrue(children)
for child in children:
self.assertIn("parent_chunk_id", child.meta)
self.assertIsInstance(child.meta["parent_chunk_id"], int)
if __name__ == "__main__":
unittest.main()
+315
View File
@@ -0,0 +1,315 @@
"""Tests for PDF margin noise filtering."""
from __future__ import annotations
import unittest
from pathlib import Path
from rag_cut.layout_meta import enrich_layout_metadata, sort_blocks_reading_order
from rag_cut.models import Block, BlockType
from rag_cut.parsers.mineru_adapter import _blocks_from_content_list
from rag_cut.parsers.pdf.noise_filter import (
detect_running_header_texts,
filter_noise_blocks,
filter_toc_blocks,
is_margin_noise_block,
is_tiny_image_block,
is_toc_entry_line,
is_toc_title_text,
)
class MineruNoiseFilterTest(unittest.TestCase):
def test_skips_header_and_page_number_types(self) -> None:
items = [
{"type": "text", "text": "目錄", "text_level": 2, "page_idx": 1},
{
"type": "text",
"text": "1. 登入 ...3\n2. 股票報價. ....4",
"page_idx": 1,
"bbox": [114, 190, 883, 517],
},
{"type": "header", "text": "H5 i-Trade 用戶使用手冊", "page_idx": 1},
{"type": "header", "text": "NEN WAFE:SOLUTIONS", "page_idx": 1},
{"type": "page_number", "text": "2", "page_idx": 1},
]
blocks = _blocks_from_content_list(items, Path("content_list.json"), Path("assets"))
# MinerU adapter may still emit 目录 text; pipeline noise filter removes it.
texts = [block.text for block in blocks]
self.assertIn("目錄", texts)
self.assertEqual(texts.count("H5 i-Trade 用戶使用手冊"), 0)
self.assertEqual(texts.count("2"), 0)
filtered = filter_noise_blocks(blocks)
filtered_texts = [block.text for block in filtered]
self.assertNotIn("目錄", filtered_texts)
self.assertFalse(any("登入" in (t or "") for t in filtered_texts))
class NoiseFilterHeuristicTest(unittest.TestCase):
def test_margin_page_number_is_noise(self) -> None:
block = Block(
type=BlockType.PARAGRAPH,
text="14",
meta={"page": 8, "bbox": [867, 942, 882, 954], "page_height": 1000},
)
self.assertTrue(is_margin_noise_block(block, 1000))
def test_body_paragraph_is_kept(self) -> None:
block = Block(
type=BlockType.PARAGRAPH,
text="開啟AFEH5 i-Trade網站,用戶無需登入即可查閱各項延遲15分鐘資訊。",
meta={"page": 3, "bbox": [116, 200, 880, 260], "page_height": 1000},
)
self.assertFalse(is_margin_noise_block(block, 1000))
def test_running_header_detection(self) -> None:
blocks = []
for page in range(1, 11):
blocks.append(
Block(
type=BlockType.PARAGRAPH,
text="H5 i-Trade 用戶使用手冊",
meta={"page": page, "bbox": [651, 79, 882, 97], "page_height": 1000},
)
)
blocks.append(
Block(
type=BlockType.HEADING,
text=f"{page}. 章節",
level=2,
meta={"page": page, "bbox": [116, 122, 191, 146], "page_height": 1000},
)
)
running = detect_running_header_texts(blocks)
self.assertIn("H5 i-Trade 用戶使用手冊", running)
self.assertNotIn("1. 章節", running)
def test_filter_noise_blocks_removes_margin_and_running_text(self) -> None:
blocks = [
Block(
type=BlockType.HEADING,
text="目錄",
level=2,
meta={"page": 2, "bbox": [119, 123, 176, 146], "page_height": 1000},
),
Block(
type=BlockType.PARAGRAPH,
text="1. 登入 ...3",
meta={"page": 2, "bbox": [114, 190, 883, 517], "page_height": 1000},
),
Block(
type=BlockType.PARAGRAPH,
text="2",
meta={"page": 2, "bbox": [868, 942, 880, 954], "page_height": 1000},
),
]
for page in range(1, 6):
blocks.append(
Block(
type=BlockType.PARAGRAPH,
text="H5 i-Trade 用戶使用手冊",
meta={"page": page, "bbox": [651, 79, 882, 97], "page_height": 1000},
)
)
filtered = filter_noise_blocks(blocks)
texts = [block.text for block in filtered]
self.assertNotIn("目錄", texts)
self.assertNotIn("1. 登入 ...3", texts)
self.assertNotIn("2", texts)
self.assertNotIn("H5 i-Trade 用戶使用手冊", texts)
def test_filter_toc_drops_contents_keeps_in_section_catalog(self) -> None:
blocks = [
Block(type=BlockType.HEADING, text="Contents", level=1, meta={"page": 1, "bbox": [72, 80, 200, 100]}),
Block(
type=BlockType.PARAGRAPH,
text="1 Intro ........ 3",
meta={"page": 1, "bbox": [72, 120, 400, 140]},
),
Block(
type=BlockType.PARAGRAPH,
text="2 Setup ........ 8",
meta={"page": 1, "bbox": [72, 150, 400, 170]},
),
Block(
type=BlockType.HEADING,
text="1 Intro",
level=1,
meta={"page": 2, "bbox": [72, 100, 200, 120]},
),
Block(
type=BlockType.PARAGRAPH,
text="Useful body paragraph describing the product workflow in detail.",
meta={"page": 2, "bbox": [72, 140, 500, 200]},
),
Block(
type=BlockType.HEADING,
text="形態指標",
level=1,
meta={"page": 45, "bbox": [72, 200, 200, 220]},
),
Block(
type=BlockType.PARAGRAPH,
text="共包含以下11個形態指標說明:",
meta={"page": 45, "bbox": [72, 240, 400, 260]},
),
Block(
type=BlockType.HEADING,
text="1.頭肩頂形態",
level=1,
meta={"page": 45, "bbox": [72, 270, 220, 290]},
),
]
self.assertTrue(is_toc_title_text("目錄"))
self.assertTrue(is_toc_entry_line("1 Intro ........ 3"))
self.assertFalse(is_toc_entry_line("1.頭肩頂形態"))
filtered = filter_toc_blocks(blocks)
texts = [b.text for b in filtered]
self.assertNotIn("Contents", texts)
self.assertNotIn("1 Intro ........ 3", texts)
self.assertIn("1 Intro", texts)
self.assertIn("Useful body paragraph describing the product workflow in detail.", texts)
self.assertIn("形態指標", texts)
self.assertIn("1.頭肩頂形態", texts)
def test_filter_toc_drops_mineru_compacted_contents_block(self) -> None:
contents = Block(
type=BlockType.PARAGRAPH,
text=(
"CONTENTS..\n"
"1 GLOSSARY ..2\n"
"5.1 AEOI ID SETTING.. 4"
),
meta={"page": 2, "mineru_type": "text"},
)
self.assertTrue(is_toc_title_text("CONTENTS.."))
self.assertTrue(is_toc_entry_line("1 GLOSSARY ..2"))
self.assertTrue(is_toc_entry_line("5.1 AEOI ID SETTING.. 4"))
self.assertEqual(filter_toc_blocks([contents]), [])
def test_filter_toc_keeps_real_section_below_directory_on_same_page(self) -> None:
blocks = [
Block(
type=BlockType.HEADING,
text="CONTENTS",
level=2,
meta={"page": 2, "bbox": [115, 129, 284, 152]},
),
Block(
type=BlockType.PARAGRAPH,
text="CONTENTS..\n1 GLOSSARY ..2\n2 REFERENCES 3\n3 DOCUMENT HISTORY.. .3",
meta={"page": 2, "bbox": [117, 167, 884, 718]},
),
Block(
type=BlockType.HEADING,
text="1 GLOSSARY",
level=2,
meta={"page": 2, "bbox": [117, 771, 321, 793]},
),
Block(
type=BlockType.TABLE,
markdown="| Abbreviation | Description |\n| --- | --- |",
meta={"page": 2, "bbox": [127, 809, 885, 891]},
),
]
filtered = filter_toc_blocks(blocks)
self.assertEqual([block.type for block in filtered], [BlockType.HEADING, BlockType.TABLE])
self.assertEqual(filtered[0].text, "1 GLOSSARY")
def test_tiny_logo_image_is_filtered_wide_flowchart_kept(self) -> None:
logo = Block(
type=BlockType.IMAGE,
image_id="logo",
image_path="assets/logo.png",
meta={"page": 14, "bbox": [121, 420, 156, 445], "page_height": 1000},
)
flowchart = Block(
type=BlockType.IMAGE,
image_id="flow",
image_path="assets/flow.png",
meta={"page": 13, "bbox": [119, 614, 949, 689], "page_height": 1000},
)
self.assertTrue(is_tiny_image_block(logo))
self.assertFalse(is_tiny_image_block(flowchart))
filtered = filter_noise_blocks([logo, flowchart])
ids = [block.image_id for block in filtered]
self.assertEqual(ids, ["flow"])
def test_nested_image_fragment_dropped(self) -> None:
outer = Block(
type=BlockType.IMAGE,
image_id="screenshot",
image_path="assets/shot.png",
meta={"page": 14, "bbox": [100, 400, 500, 700], "page_height": 1000},
)
nested = Block(
type=BlockType.IMAGE,
image_id="icon",
image_path="assets/icon.png",
meta={"page": 14, "bbox": [200, 450, 280, 530], "page_height": 1000},
)
filtered = filter_noise_blocks([outer, nested])
ids = [block.image_id for block in filtered]
self.assertEqual(ids, ["screenshot"])
class LayoutOrderAndBindTest(unittest.TestCase):
def test_sort_puts_page_top_before_lower_fragment(self) -> None:
misordered = [
Block(
type=BlockType.IMAGE,
image_id="logo",
image_path="assets/logo.png",
meta={"page": 14, "bbox": [121, 420, 156, 445]},
),
Block(
type=BlockType.PARAGRAPH,
text="5.8 APPENDIX",
meta={"page": 14, "bbox": [114, 85, 324, 107]},
),
Block(
type=BlockType.PARAGRAPH,
text="5.9 FAQ",
meta={"page": 14, "bbox": [114, 334, 231, 355]},
),
]
ordered = sort_blocks_reading_order(misordered)
texts = [(b.image_id or b.text) for b in ordered]
self.assertEqual(texts, ["5.8 APPENDIX", "5.9 FAQ", "logo"])
def test_enrich_binds_image_to_spatial_heading(self) -> None:
blocks = [
Block(
type=BlockType.HEADING,
text="5.7.2Subsequent submissions",
level=3,
meta={"page": 13, "bbox": [115, 468, 465, 488]},
),
Block(
type=BlockType.IMAGE,
image_id="wrong_early",
image_path="assets/x.png",
meta={"page": 14, "bbox": [341, 513, 431, 648]},
),
Block(
type=BlockType.PARAGRAPH,
text="5.9 FAQ",
meta={"page": 14, "bbox": [114, 334, 231, 355]},
),
]
enriched = enrich_layout_metadata(blocks)
faq_img = next(b for b in enriched if b.type == BlockType.IMAGE)
self.assertEqual(faq_img.meta.get("bound_heading"), "5.9 FAQ")
self.assertLess(
next(b for b in enriched if b.text == "5.9 FAQ").meta["order_index"],
faq_img.meta["order_index"],
)
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
"""Tests for recall ranking and retrieval eligibility."""
from __future__ import annotations
import unittest
from rag_cut.models import Chunk
from rag_cut.retrieval import recall_chunks
def chunk(index: int, content: str, *, retrieval: bool = True, heading: str = "") -> Chunk:
return Chunk(
index=index,
content=content,
char_count=len(content),
block_types=["paragraph"],
meta={"retrieval": retrieval, "heading": heading},
)
class RecallChunksTest(unittest.TestCase):
def test_relevant_chunk_ranks_first(self) -> None:
results, candidate_count = recall_chunks(
"股票交易密码",
[
chunk(0, "登录后可以修改股票交易密码", heading="账户安全"),
chunk(1, "年度报告及公司治理", heading="公司资料"),
],
top_k=2,
)
self.assertEqual(candidate_count, 2)
self.assertEqual(results[0]["chunk_index"], 0)
self.assertGreater(results[0]["score"], 0)
def test_preview_only_chunks_are_not_candidates(self) -> None:
results, candidate_count = recall_chunks(
"logo",
[chunk(0, "![logo](logo.png)", retrieval=False), chunk(1, "Useful body")],
)
self.assertEqual(candidate_count, 1)
self.assertEqual(results, [])
def test_top_k_is_respected(self) -> None:
results, _ = recall_chunks("account", [chunk(i, f"account details {i}") for i in range(5)], top_k=2)
self.assertEqual(len(results), 2)
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
"""Tests for spreadsheet layout detection and row splitting."""
from __future__ import annotations
import unittest
from rag_cut.parsers.pdf.tables import detect_spreadsheet_layout, is_qa_style_table
from rag_cut.pipeline import chunk_document
from rag_cut.splitters.by_row import split_by_row
from rag_cut.models import Block, BlockType, SplitConfig, SplitMode
BACKMAN_HEADER = [
[
"Session会话(必填):用于标识1个对话",
"query 用户输入(必填):消息内容",
"用户ID(必填)",
"使用的大语言模型(必填)",
"要求AI回复的语言(必填)",
"reference_output 标准答案(可选)",
],
["session", "query", "userid", "model", "lang", "reference_output"],
["1", "账户余额是多少?", "SUPPORT", "gpt-4o-mini", "zh", "SELECT 1"],
["2", "今日有哪些账户透支?", "SUPPORT", "gpt-4o-mini", "zh", "SELECT 2"],
]
class SpreadsheetLayoutTest(unittest.TestCase):
def test_detect_template_description_and_header_rows(self) -> None:
layout = detect_spreadsheet_layout(BACKMAN_HEADER)
self.assertEqual(layout["preamble_rows"], 1)
self.assertEqual(layout["header_row_start"], 2)
self.assertEqual(layout["header_row_end"], 2)
self.assertEqual(layout["data_start_row"], 3)
self.assertTrue(is_qa_style_table(BACKMAN_HEADER, layout))
def test_split_by_row_uses_real_header_and_one_row_per_chunk(self) -> None:
layout = detect_spreadsheet_layout(BACKMAN_HEADER)
block = Block(
type=BlockType.TABLE,
markdown="",
meta={"rows": BACKMAN_HEADER, "table_title": "testset", **layout},
)
groups = split_by_row(
[block],
SplitConfig(
mode=SplitMode.BY_ROW,
header_row_start=layout["header_row_start"],
header_row_end=layout["header_row_end"],
start_row=layout["data_start_row"],
rows_per_chunk=1,
),
)
self.assertEqual(len(groups), 2)
first_md = groups[0][0].markdown
self.assertIn("| session | query | userid | model | lang | reference_output |", first_md)
self.assertIn("账户余额是多少?", first_md)
self.assertNotIn("Session会话(必填)", first_md)
class BackmanFixtureTest(unittest.TestCase):
def test_backman_upload_chunks_one_row_each(self) -> None:
from pathlib import Path
uploads = list((Path(__file__).resolve().parents[2] / "storage" / "uploads").rglob("BackmanAI*.xlsx"))
if not uploads:
self.skipTest("BackmanAI fixture not uploaded")
result = chunk_document(uploads[0])
self.assertEqual(result.split_config["rows_per_chunk"], 1)
self.assertEqual(result.split_config["header_row_start"], 2)
self.assertEqual(result.split_config["start_row"], 3)
self.assertGreaterEqual(result.chunk_count, 40)
first = result.chunks[0].content
self.assertIn("| session | query |", first)
self.assertNotIn("Session会话(必填)", first)
self.assertLess(result.chunks[0].char_count, 1200)
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -0,0 +1,40 @@
Pages: 3
Path: C:\Users\24019\Desktop\RAG-cut\docx\自研搭建AI助手知识库.pdf
============================================================
PAGE 1 | images=1 text_blocks=17
============================================================
自研搭建AI助手知识库​
背景​
AFE有自建自有知识库功能,但知识库的切片效果不佳,存在两个问题:​
1、文档处理能力不足:包括word/pdf/excel文档中的图片、表格无法有效提取处理;​
2、切片规则不佳:如下图一段文本被强硬拆分在两个不同的片段;​
任务​
搭建一个AI助手知识库demo,能支持上传文件,查看切片效果;可以测试召回效果,查看召回入参和
回参;​
目标​
目标:首要做好切片,其次是召回。​
支持文档类型:​
pdf、doc、docx、ppt、pptx、wps、ppsx​
xlsx、xls、csv、md、txt、html、json、xml、log​
jpg、png、jpeg、bmp、gif​
切片规则:​
表格文档:默认切分/按行切分​
其他文档:默认切分/通用标识符切分​
============================================================
PAGE 2 | images=2 text_blocks=6
============================================================
切片内表格:Markdown格式​
期望​
期望效果(基础):​
1. 图片展示在原文本展示的问题,不会丢失或者移动到其他位置​
2. 表格内容正确提取,并整理为markdown文本​
3. 切片规则合理,对于word至少同一个标题下的内容在一个切片中​
============================================================
PAGE 3 | images=4 text_blocks=3
============================================================
期望效果(高级):​
1. 对图片图形能够提取为图片,且识别文本信息转为文字​
2. 对pdf水平分布的栏目文本能够识别为正常顺序​
+103
View File
@@ -0,0 +1,103 @@
# MinerU 解析层接入说明
RAG-cut 将 MinerU 作为可选的高质量文档解析后端,用于提升 PDF 的版面分析、OCR、图片/表格提取质量;切割策略仍由 RAG-cut 负责。
## 启用方式
默认模式:
```bash
RAG_CUT_PDF_ENGINE=auto
```
含义:
- 如果本机可用 `mineru` 或 `magic-pdf` 命令,优先调用 MinerU。
- 如果 MinerU 未安装或解析失败,自动回退到现有 PyMuPDF/pdfplumber 管线。
强制使用 MinerU:
```bash
RAG_CUT_PDF_ENGINE=mineru
```
强制使用原 PyMuPDF 管线:
```bash
RAG_CUT_PDF_ENGINE=pymupdf
```
如果命令不在 PATH 中,可指定:
```bash
RAG_CUT_MINERU_CMD=/path/to/mineru
```
## 大文档推荐配置
`mineru` CLI 默认会为每次调用临时启动 API 和模型进程。处理页数较多的 Office/PDF
文档时,建议单独启动一个常驻 MinerU API,避免重复加载模型。
先在一个终端启动服务:
```powershell
python -m mineru.cli.fast_api --host 127.0.0.1 --port 30000
```
再在启动 RAG-cut 后端的终端中配置并启动:
```powershell
$env:RAG_CUT_MINERU_API_URL = "http://127.0.0.1:30000"
$env:RAG_CUT_MINERU_TIMEOUT = "540"
python backend/run.py
```
未配置常驻 API 时,RAG-cut 仍可使用临时服务;若解析超时,会结束 MinerU 的完整
进程树,避免模型子进程残留并拖慢后续请求。
## 数据流
```text
PDF
-> MinerU 解析 content_list.json / 图片资产
-> 转换为 RAG-cut Block
-> RAG-cut 自动判断 PDF 策略
-> feature_step_screenshot 或 outline_report 切割
-> Chunk + metadata + embedding_text
```
## 统一 Block 映射
| MinerU 内容 | RAG-cut Block |
| --- | --- |
| title / heading / text_level | heading |
| text | paragraph |
| image / figure / **chart** / diagram / equation… | image(复制 img 资产) |
| table(含可选截图) | table(markdown + 可选 image_path) |
| code(有图则按图,否则按正文) | image / paragraph |
| page_footnote | paragraph(`is_footnote`) |
| header / footer / page_number | 丢弃 |
图片若无 MinerU OCR,默认用本地 Tesseract 回填(繁体优先):
```bash
RAG_CUT_MINERU_OCR=1 # 默认开启;设为 0 可关闭以加快大批量切分
```
保留字段:
- page
- bbox
- image_path
- ocr_text
- caption
- mineru_type
- parser=mineru
## 设计原则
MinerU 只提升解析质量,不决定 chunk 语义边界。不同文档类型的切割仍由 RAG-cut 策略层处理:
- 操作手册:功能章节 + 操作步骤 + 截图区域
- 年报/研报:章节报告
- 表格:表头 + 行组
Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

@@ -0,0 +1,189 @@
# 腾讯云智能体开发平台:文档切分设置
> 来源页面:[腾讯云文档 - 文档切分设置](https://cloud.tencent.com/document/product/1759/122551)
> 页面最近更新时间:2025-12-31 16:49:32
> 整理说明:本文为根据页面内容整理的 Markdown 版,保留核心概念、功能入口、规则对比和关键参数说明,并非网页原文逐字复制。
## 1. 功能概述
文档切分是指系统按照一定规则,将上传到知识库的文档拆分为多个独立切片。切片会被索引并存储,是 RAG(Retrieval-Augmented Generation,检索增强生成)流程中的关键环节。
在问答场景中,系统通常会先根据用户问题检索相关切片,再把命中的切片作为外部知识放入大模型上下文,从而辅助模型生成答案。
切分粒度会直接影响检索质量和回答效果:
- 切片过大:容易包含无关内容,降低检索精度,也会增加上下文和计算资源消耗。
- 切片过小:上下文不完整,知识片段更碎片化,可能导致回答不够全面。
- 合理切分:需要在检索效率、上下文完整性和生成质量之间取得平衡。
## 2. 切分规则类型
平台支持两类文档切分方式:
- 默认切分规则:由平台使用模型能力自动切分,用户不能直接干预具体规则。
- 自定义切分规则:用户可根据业务需求设置切分方式,主要包括通用标识符切分、父子标识符切分和按行切分。
## 3. 切分规则对比
| 切分方式 | 适用文档类型 | 典型使用场景 | 核心逻辑 |
|---|---|---|---|
| 默认切分 | 平台支持导入的全部文档类型 | 对切分无特殊要求的知识库文档 | 基于切分模型处理,关注语义完整性、复杂元素解析和表格处理 |
| 通用标识符切分 | 非表格类文档,不包括 xlsx、xls、csv | 需要按页码、自定义符号或业务段落进行切分 | 用户设置标识符、最大长度和重叠长度,切片同时用于检索和召回 |
| 父子标识符切分 | 非表格类文档,不包括 xlsx、xls、csv | 检索粒度和召回粒度需要分离的场景 | 子级切片用于检索,命中后召回对应父级切片给大模型 |
| 按行切分 | 表格类文档,包括 xlsx、xls、csv | 表格每行或每几行相对独立,如商品 SKU 文档 | 用户设置表头范围、起始行和每个切片包含的行数 |
## 4. 默认切分能力
默认切分由平台模型完成,适合大多数不需要精细控制切片边界的文档。
平台默认切分能力包括:
- 支持按语义完整性切分。
- 支持跨页表格合并。
- 支持解析表格中的图片信息。
- 支持解析有线表格和无线表格。
- 支持解析数据图、流程图、架构图、思维导图。
- 支持处理多栏、公式、子图等复杂版式元素。
## 5. 功能入口
### 入口一:上传文档时设置
在知识库中上传文档时,流程通常包括:
1. 上传文档。
2. 设置文档切片。
3. 按文档类型对同一批上传文档生效。
图示来源:
![上传文档时设置切片](https://qcloudimg.tencent-cloud.cn/image/document/850228edf4f928244908151ebf3def0c.png)
### 入口二:已导入文档重新设置
对已经导入知识库的文档,可以重新设置切分规则:
1. 进入知识库。
2. 找到指定文档。
3. 点击文档右侧的“更多”。
4. 选择“解析切分干预”。
5. 查看解析切分结果。
6. 点击“文档切分设置”重新配置切分规则。
图示来源:
![已导入文档解析切分干预入口](https://qcloudimg.tencent-cloud.cn/image/document/66374f90956aff7df5f2432374de93fe.png)
![文档切分设置入口](https://qcloudimg.tencent-cloud.cn/image/document/ad265a826b49fdce18a9d2df579d6aa3.jpeg)
注意:重新设置文档切分规则后,系统会按照原文档重新切分,并覆盖之前干预过的切分结果。
## 6. 表格文档切分
表格类文档包括 xlsx、xls、csv。平台对表格文档支持默认切分和按行切分。
### 6.1 默认切分
默认切分会使用平台切分模型,根据表格行数、语义完整性等因素自动处理。
### 6.2 按行切分
按行切分适合每行或每几行数据相对独立的表格,例如商品 SKU 表、配置清单、结构化数据表等。
关键参数如下:
| 参数 | 说明 |
|---|---|
| 表头范围 | 选择表格中的表头行,每个切片都会包含表头数据;区间最大支持 5 行 |
| 切分起始行 | 指定从哪一行开始切分;起始行不能与表头范围重复 |
| 切分行数 | 指定每个切片从起始行开始包含多少行数据 |
示例:如果表头为第 1 行,切分起始行为第 2 行,切分行数为 1,则第一个切片为“表头 + 第 2 行”,第二个切片为“表头 + 第 3 行”,依此类推。
图示来源:
![表格按行切分设置](https://qcloudimg.tencent-cloud.cn/image/document/b95c810800d7527cf4949836022cd931.png)
## 7. 非表格文档切分
非表格类文档指知识库中除 xlsx、xls、csv 之外的文档类型。平台支持默认切分、通用标识符切分和父子级标识符切分。
### 7.1 默认切分
默认切分会使用平台切分模型,根据语义完整性进行自动切分。
### 7.2 通用标识符切分
通用标识符切分适合需要按特定符号或业务结构拆分文档的场景,例如按照 `###`、`***`、章节分隔符或自定义标记切分。
关键参数如下:
| 参数 | 说明 |
|---|---|
| 标识符 | 系统按照用户设置的标识符切分文档;切分标识符不会出现在最终切片中 |
| 切分最大长度 | 每个切片允许的最大字符数;如果按标识符切出的片段超过最大长度,会继续按长度拆分;最大不超过 4800 字符 |
| 切分重叠长度 | 当片段超过最大长度需要继续拆分时,保留相邻切片之间的重叠字符,用于维持语义连续性 |
设置建议:
- 切分重叠长度可设置为切分最大长度的约 10%。
- 切分重叠长度最高可设置为切分最大长度的 25%。
图示来源:
![通用标识符切分设置](https://qcloudimg.tencent-cloud.cn/image/document/0b906b04a6113b01ef0d0bb1fa03ebe1.png)
### 7.3 父子级标识符切分
父子级标识符切分适合需要“细粒度检索、粗粒度召回”的知识库场景。系统会先把文档拆成父级切片,再把父级切片拆成一个或多个子级切片。
工作方式:
1. 用户问题先检索子级切片。
2. 命中子级切片后,系统找到对应父级切片。
3. 父级切片被召回给大模型用于答案生成。
这种方式的优点是:
- 子级切片更短,有利于提升检索精度。
- 父级切片保留更多上下文,有利于提高生成答案的完整性。
约束条件:
- 子级切片最大长度不能超过父级切片最大长度。
- 子级切片最大可设置为 1500 字符。
- 子级切片与父级切片是一对一或多对一关系。
图示来源:
![父子级标识符切分说明](https://qcloudimg.tencent-cloud.cn/image/document/e4da303ed682cfb54af8f6965feb09fe.png)
![父子级切片关系](https://qcloudimg.tencent-cloud.cn/image/document/23608a42caddd6d221f53fa6103df1d3.png)
## 8. 切分内表格格式
平台支持设置文档中表格内容的切片格式:
| 格式 | 特点 |
|---|---|
| Markdown 格式 | 默认格式,通常效果更好,便于模型理解表格结构 |
| HTML 格式 | token 消耗相对更少 |
该设置对普通文档中的表格内容,以及表格文档中的内容均可生效。
## 9. 使用建议
| 场景 | 推荐切分方式 |
|---|---|
| 普通知识库文档,无特殊切分要求 | 默认切分 |
| 文档结构清晰,有固定章节、页码或分隔符 | 通用标识符切分 |
| 希望检索更精准,同时召回更完整上下文 | 父子级标识符切分 |
| 表格每行是独立数据,如商品、配置、SKU | 按行切分 |
| 文档表格较多,且希望模型更好理解结构 | Markdown 表格格式 |
| token 成本敏感,表格结构不复杂 | HTML 表格格式 |
## 10. 相关页面
- 上一篇:文档概述
- 下一篇:解析切分干预
- 原始页面:[文档切分设置](https://cloud.tencent.com/document/product/1759/122551)
Binary file not shown.
+807
View File
@@ -0,0 +1,807 @@
/* RAG-cut Design System — ui-ux-pro-max: Accessible & Ethical + Tech SaaS */
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-secondary: #3b82f6;
--color-cta: #f97316;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-border: #e2e8f0;
--color-border-strong: #cbd5e1;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-text-subtle: #94a3b8;
--color-success: #16a34a;
--color-error: #dc2626;
--color-focus: #2563eb;
--color-chunk-active: #eff6ff;
--color-chunk-border: #93c5fd;
--font-heading: "Space Grotesk", system-ui, sans-serif;
--font-body: "DM Sans", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08);
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--transition: 180ms ease;
--header-h: 64px;
--sidebar-w: 300px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
*, *::before, *::after { box-sizing: border-box; }
html { font-size: 16px; scroll-behavior: smooth; }
body {
margin: 0;
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-bg);
line-height: 1.5;
min-height: 100dvh;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
top: -100%;
left: var(--space-4);
z-index: 1000;
padding: var(--space-2) var(--space-4);
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-weight: 600;
}
.skip-link:focus { top: var(--space-2); }
/* Header */
.app-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-h);
padding: 0 var(--space-5);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
}
.brand-icon { color: var(--color-primary); flex-shrink: 0; }
.brand h1 {
margin: 0;
font-family: var(--font-heading);
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.brand-sub {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 500;
}
.badge-idle { background: #f1f5f9; color: var(--color-text-muted); }
.badge-loading { background: #fef3c7; color: #92400e; }
.badge-success { background: #dcfce7; color: #166534; }
.badge-error { background: #fee2e2; color: var(--color-error); }
/* Layout */
.app-layout {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
min-height: calc(100dvh - var(--header-h));
}
.config-panel {
background: var(--color-surface);
border-right: 1px solid var(--color-border);
padding: var(--space-5);
overflow-y: auto;
max-height: calc(100dvh - var(--header-h));
}
.panel-section { margin-bottom: var(--space-5); }
.panel-section h2 {
margin: 0 0 var(--space-3);
font-family: var(--font-heading);
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
/* Dropzone */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 140px;
padding: var(--space-5);
border: 2px dashed var(--color-border-strong);
border-radius: var(--radius-lg);
background: var(--color-bg);
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
text-align: center;
}
.dropzone:hover, .dropzone:focus-visible, .dropzone.dragover {
border-color: var(--color-primary);
background: #eff6ff;
outline: none;
}
.dropzone:focus-visible { box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.35); }
.dropzone svg { color: var(--color-text-subtle); }
.dropzone-title { margin: 0; font-weight: 600; font-size: 0.9375rem; }
.dropzone-hint { margin: 0; font-size: 0.8125rem; color: var(--color-text-muted); }
.dropzone-file { margin: var(--space-2) 0 0; font-size: 0.8125rem; color: var(--color-primary); font-weight: 500; word-break: break-all; }
/* Form */
.config-form { display: flex; flex-direction: column; gap: var(--space-3); }
.field { display: flex; flex-direction: column; gap: var(--space-1); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.field label { font-size: 0.8125rem; font-weight: 500; color: var(--color-text); }
.field input, .field select {
height: 44px;
padding: 0 var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
font-family: var(--font-body);
font-size: 0.9375rem;
color: var(--color-text);
background: var(--color-surface);
transition: border-color var(--transition), box-shadow var(--transition);
}
.field input:focus, .field select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
.helper { font-size: 0.75rem; color: var(--color-text-muted); }
.mode-options {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.hidden { display: none !important; }
.auto-policy {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: #f8fafc;
}
.auto-policy-kicker {
display: inline-flex;
width: fit-content;
padding: 2px 6px;
border-radius: 4px;
background: #dbeafe;
color: #1e40af;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0;
}
.auto-policy-title {
margin: var(--space-2) 0 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.auto-policy-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
margin: 0;
}
.auto-policy-grid div {
min-width: 0;
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.auto-policy-grid dt {
margin: 0;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.auto-policy-grid dd {
margin: 2px 0 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
word-break: break-word;
}
/* Button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 44px;
padding: 0 var(--space-4);
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-body);
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), opacity var(--transition), transform 80ms ease;
}
.btn:active:not(:disabled) { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--color-primary); color: #fff; width: 100%; }
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.4); }
.btn-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
margin: 0;
}
.stats-grid div {
padding: var(--space-3);
background: var(--color-bg);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
.stats-grid dt { margin: 0; font-size: 0.75rem; color: var(--color-text-muted); }
.stats-grid dd { margin: var(--space-1) 0 0; font-family: var(--font-heading); font-size: 1.125rem; font-weight: 600; }
.mono { font-family: var(--font-mono); font-size: 0.75rem !important; word-break: break-all; }
/* History */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.history-header h2 { margin: 0; }
.btn-ghost {
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
width: auto;
min-height: 32px;
padding: 0 var(--space-3);
font-size: 0.8125rem;
font-weight: 500;
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-bg);
color: var(--color-text);
border-color: var(--color-border-strong);
}
.btn-sm { min-height: 32px; }
.history-hint {
margin: 0 0 var(--space-2);
font-size: 0.75rem;
color: var(--color-text-muted);
}
.history-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 280px;
overflow-y: auto;
}
.history-item {
display: flex;
align-items: stretch;
gap: var(--space-1);
width: 100%;
padding: var(--space-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
transition: border-color var(--transition), background var(--transition);
}
.history-item:hover {
border-color: var(--color-chunk-border);
background: var(--color-chunk-active);
}
.history-item.active {
border-color: var(--color-primary);
background: var(--color-chunk-active);
box-shadow: inset 3px 0 0 var(--color-primary);
}
.history-item-load {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: var(--space-2);
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
font-family: var(--font-body);
color: inherit;
}
.history-item-delete {
flex-shrink: 0;
align-self: center;
margin-right: var(--space-1);
min-height: 28px;
padding: 0 var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
font-family: var(--font-body);
font-size: 0.75rem;
cursor: pointer;
transition: color var(--transition), border-color var(--transition), background var(--transition);
}
.history-item-delete:hover {
color: #b42318;
border-color: #fecdca;
background: #fef3f2;
}
.history-item-name {
font-size: 0.8125rem;
font-weight: 600;
word-break: break-all;
line-height: 1.35;
}
.history-item-meta {
font-size: 0.6875rem;
color: var(--color-text-muted);
font-family: var(--font-mono);
}
/* Workspace */
.workspace {
padding: var(--space-5);
overflow-y: auto;
max-height: calc(100dvh - var(--header-h));
display: flex;
flex-direction: column;
gap: var(--space-4);
min-height: 0;
}
.workspace:has(#workspacePane:not(.hidden)) {
overflow: hidden;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-6);
color: var(--color-text-muted);
}
.empty-state svg { color: var(--color-border-strong); margin-bottom: var(--space-4); }
.empty-state h2 { margin: 0 0 var(--space-2); font-family: var(--font-heading); color: var(--color-text); }
.empty-state p { max-width: 420px; margin: 0; }
/* Workspace panes: original | chunk list | preview */
.workspace-pane {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr) minmax(0, 1.15fr);
gap: var(--space-4);
flex: 1;
min-height: 0;
}
.pane {
display: flex;
flex-direction: column;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
min-height: 0;
max-height: 100%;
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
.pane-header h2 {
margin: 0;
font-family: var(--font-heading);
font-size: 0.9375rem;
font-weight: 600;
}
.pane-tag {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-muted);
background: var(--color-surface);
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--color-border);
}
.pane-body {
flex: 1;
overflow: auto;
padding: var(--space-4);
min-height: 0;
}
.placeholder { color: var(--color-text-muted); font-size: 0.9375rem; }
/* Original preview */
.original-docx { font-size: 0.9375rem; line-height: 1.6; }
.original-docx img { max-width: 100%; height: auto; border-radius: var(--radius-sm); }
.original-pdf { width: 100%; height: 100%; min-height: 240px; border: none; }
.original-text {
font-family: var(--font-mono);
font-size: 0.8125rem;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text);
}
.original-image { max-width: 100%; border-radius: var(--radius-sm); }
/* Chunk list */
.chunk-list { padding: var(--space-2); display: flex; flex-direction: column; gap: var(--space-2); }
.chunk-card {
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
cursor: pointer;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
text-align: left;
width: 100%;
font-family: inherit;
}
.chunk-card:hover {
border-color: var(--color-chunk-border);
background: #f8fafc;
}
.chunk-card:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
}
.chunk-card.active {
border-color: var(--color-primary);
background: var(--color-chunk-active);
box-shadow: var(--shadow-sm);
}
.chunk-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.chunk-index {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.875rem;
color: var(--color-primary);
}
.chunk-meta { font-size: 0.75rem; color: var(--color-text-muted); }
.chunk-types {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.type-tag {
font-size: 0.6875rem;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: #f1f5f9;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.type-tag-heading { background: #dbeafe; color: #1e40af; }
.type-tag-image { background: #fce7f3; color: #9d174d; }
.type-tag-table { background: #d1fae5; color: #065f46; }
.type-tag-preview-only { background: #fef3c7; color: #92400e; }
.chunk-snippet {
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Markdown preview (right pane) */
.pane-preview .markdown-body {
font-size: 0.9375rem;
line-height: 1.65;
max-width: 75ch;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
font-family: var(--font-heading);
margin: 1.25em 0 0.5em;
line-height: 1.3;
}
.markdown-body h1 { font-size: 1.375rem; }
.markdown-body h2 { font-size: 1.125rem; }
.markdown-body h3 { font-size: 1rem; }
.markdown-body p { margin: 0.75em 0; }
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
margin: var(--space-3) 0;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
margin: var(--space-3) 0;
}
.markdown-body th, .markdown-body td {
border: 1px solid var(--color-border);
padding: var(--space-2) var(--space-3);
text-align: left;
}
.markdown-body th { background: var(--color-bg); font-weight: 600; }
.markdown-body code {
font-family: var(--font-mono);
font-size: 0.85em;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
}
/* Structured chunk blocks — images at original layout positions */
.chunk-blocks {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.chunk-block-image {
margin: 0;
padding: var(--space-3);
background: #f8fafc;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.chunk-block-image img {
display: block;
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
margin: var(--space-2) 0;
}
.chunk-block-image .block-context {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: var(--space-2);
}
.chunk-block-image .ocr-caption {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-top: var(--space-2);
line-height: 1.45;
}
.block-meta {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-subtle);
margin-top: var(--space-1);
}
.chunk-block-heading {
margin: var(--space-2) 0 var(--space-1);
font-family: var(--font-heading);
}
.chunk-block-paragraph p {
margin: 0;
line-height: 1.6;
}
.chunk-block-table {
overflow-x: auto;
padding: var(--space-3);
background: #f8fafc;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.chunk-block-table .table-screenshot {
margin: 0 0 var(--space-3);
}
.chunk-block-table .table-screenshot img {
display: block;
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
.chunk-block-table .table-markdown {
overflow-x: auto;
}
.chunk-block-table .table-footnote {
margin-top: var(--space-2);
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.api-panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.recall-panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.recall-panel summary {
cursor: pointer;
font-weight: 600;
font-family: var(--font-heading);
}
.recall-form {
display: grid;
grid-template-columns: minmax(240px, 1fr) 100px 140px;
align-items: end;
gap: var(--space-3);
margin-top: var(--space-4);
}
.recall-form .field span { font-size: 0.8125rem; font-weight: 500; }
.recall-submit { width: auto; }
.recall-hint { margin: var(--space-3) 0; font-size: 0.8125rem; color: var(--color-text-muted); }
.recall-results { display: flex; flex-direction: column; gap: var(--space-2); }
.recall-result {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) 64px;
gap: var(--space-3);
align-items: start;
width: 100%;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
text-align: left;
cursor: pointer;
}
.recall-result:hover { border-color: var(--color-primary); background: #f8fafc; }
.recall-result-main { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.recall-result-main span { color: var(--color-text-muted); font-size: 0.8125rem; line-height: 1.4; }
.recall-rank, .recall-score { font-family: var(--font-mono); font-size: 0.75rem; color: var(--color-primary); }
.recall-score { text-align: right; }
.api-panel summary {
cursor: pointer;
font-weight: 600;
font-family: var(--font-heading);
padding: var(--space-1) 0;
}
.api-panel summary:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.api-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
margin-top: var(--space-4);
}
.api-grid h3 { margin: 0 0 var(--space-2); font-size: 0.8125rem; color: var(--color-text-muted); text-transform: uppercase; }
.code-block {
margin: 0;
padding: var(--space-3);
background: #0f172a;
color: #e2e8f0;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
overflow: auto;
max-height: 280px;
white-space: pre-wrap;
word-break: break-word;
}
/* Responsive */
@media (max-width: 1024px) {
.app-layout { grid-template-columns: 1fr; }
.config-panel {
max-height: none;
border-right: none;
border-bottom: 1px solid var(--color-border);
}
.workspace-pane { grid-template-columns: 1fr; height: auto; }
.workspace-pane .pane { max-height: 420px; }
.api-grid { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.app-header { padding: 0 var(--space-4); }
.workspace { padding: var(--space-4); }
.field-row { grid-template-columns: 1fr; }
.recall-form { grid-template-columns: 1fr; }
}
+268
View File
@@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="api-base" content="http://127.0.0.1:8000" />
<title>RAG-cut — 文档切片演示</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mammoth@1.8.0/mammoth.browser.min.js"></script>
</head>
<body>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<header class="app-header">
<div class="brand">
<svg class="brand-icon" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/>
<line x1="8" y1="17" x2="13" y2="17"/>
</svg>
<div>
<h1>RAG-cut</h1>
<p class="brand-sub">AI 知识库 · 文档切片演示</p>
</div>
</div>
<div class="header-meta" id="statusBadge" aria-live="polite">
<span class="badge badge-idle">等待上传</span>
</div>
</header>
<div class="app-layout">
<aside class="config-panel" aria-label="切分配置">
<section class="panel-section">
<h2>上传文档</h2>
<div
class="dropzone"
id="dropzone"
tabindex="0"
role="button"
aria-label="点击或拖拽上传文档"
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p class="dropzone-title">拖拽文件到此处</p>
<p class="dropzone-hint">或点击选择 · pdf docx ppt pptx xlsx md txt 等</p>
<p class="dropzone-file" id="fileName"></p>
</div>
<input type="file" id="fileInput" class="sr-only" accept=".pdf,.doc,.docx,.ppt,.pptx,.ppsx,.xlsx,.xls,.csv,.md,.txt,.html,.json,.xml,.log,.jpg,.jpeg,.png,.bmp,.gif" />
</section>
<section class="panel-section">
<h2>切分设置</h2>
<form id="configForm" class="config-form" novalidate>
<div class="field">
<label for="splitMode">切分模式</label>
<select id="splitMode" name="mode">
<option value="default" selected>默认切分(结构感知)</option>
<option value="delimiter">通用标识符切分</option>
<option value="parent_child">父子标识符切分</option>
<option value="by_row">按行切分(表格)</option>
</select>
<p class="helper" id="modeHint">按标题/结构切分,适合 PDF、Word、PPT 等</p>
</div>
<div class="mode-options" id="modeOptionsDefault">
<div class="field-row">
<div class="field">
<label for="maxChunkSize">最大长度</label>
<input type="number" id="maxChunkSize" name="max_chunk_size" min="200" max="8000" step="100" value="2200" />
</div>
<div class="field">
<label for="overlap">重叠长度</label>
<input type="number" id="overlap" name="overlap" min="0" max="2000" step="10" value="120" />
</div>
</div>
</div>
<div class="mode-options hidden" id="modeOptionsDelimiter">
<div class="field">
<label for="delimiter">标识符</label>
<input type="text" id="delimiter" name="delimiter" value="###" placeholder="例如 ### 或 \\n\\n" />
<p class="helper">按该标识符切开;标识符本身不会写入切片</p>
</div>
<div class="field-row">
<div class="field">
<label for="delimiterMaxSize">最大长度</label>
<input type="number" id="delimiterMaxSize" name="delimiter_max_chunk_size" min="200" max="8000" step="100" value="1500" />
</div>
<div class="field">
<label for="delimiterOverlap">重叠长度</label>
<input type="number" id="delimiterOverlap" name="delimiter_overlap" min="0" max="2000" step="10" value="150" />
</div>
</div>
</div>
<div class="mode-options hidden" id="modeOptionsParentChild">
<div class="field">
<label for="parentDelimiter">父级标识符</label>
<input type="text" id="parentDelimiter" name="parent_delimiter" value="##" placeholder="例如 ##" />
</div>
<div class="field">
<label for="childDelimiter">子级标识符</label>
<input type="text" id="childDelimiter" name="child_delimiter" value="###" placeholder="例如 ### 或 \\n\\n" />
<p class="helper">先按父标识符切父片,再按子标识符切子片;检索用子片,父片作上下文</p>
</div>
<div class="field-row">
<div class="field">
<label for="parentMaxSize">父级最大长度</label>
<input type="number" id="parentMaxSize" name="parent_max_chunk_size" min="200" max="8000" step="100" value="2000" />
</div>
<div class="field">
<label for="childMaxSize">子级最大长度</label>
<input type="number" id="childMaxSize" name="child_max_size" min="50" max="1500" step="50" value="512" />
</div>
</div>
<div class="field">
<label for="parentChildOverlap">重叠长度</label>
<input type="number" id="parentChildOverlap" name="parent_child_overlap" min="0" max="2000" step="10" value="80" />
</div>
</div>
<div class="mode-options hidden" id="modeOptionsByRow">
<div class="field-row">
<div class="field">
<label for="headerRowStart">表头起始行</label>
<input type="number" id="headerRowStart" name="header_row_start" min="1" value="1" />
</div>
<div class="field">
<label for="headerRowEnd">表头结束行</label>
<input type="number" id="headerRowEnd" name="header_row_end" min="1" value="1" />
</div>
</div>
<div class="field-row">
<div class="field">
<label for="startRow">数据起始行</label>
<input type="number" id="startRow" name="start_row" min="1" value="2" />
</div>
<div class="field">
<label for="rowsPerChunk">每片行数</label>
<input type="number" id="rowsPerChunk" name="rows_per_chunk" min="1" max="500" value="10" />
</div>
</div>
</div>
<div class="auto-policy" id="autoPolicy">
<div>
<span class="auto-policy-kicker" id="policyKicker">AUTO</span>
<p class="auto-policy-title" id="policyTitle">系统根据上传文档自动选择切分参数</p>
</div>
<dl class="auto-policy-grid" id="autoPolicyGrid">
<div><dt>模式</dt><dd>待上传后判断</dd></div>
<div><dt>长度</dt><dd>自动</dd></div>
<div><dt>重叠</dt><dd>自动</dd></div>
<div><dt>策略</dt><dd>自动判断</dd></div>
</dl>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn" disabled title="请先上传文档">
<span class="btn-label">开始切分</span>
<span class="btn-spinner hidden" aria-hidden="true"></span>
</button>
</form>
</section>
<section class="panel-section history-section" aria-label="历史切片">
<div class="history-header">
<h2>历史切片</h2>
<button type="button" class="btn btn-ghost btn-sm" id="historyRefreshBtn" title="刷新列表">刷新</button>
</div>
<p class="history-hint" id="historyHint">加载中…</p>
<div class="history-list" id="historyList" role="listbox" aria-label="历史切片列表"></div>
</section>
<section class="panel-section stats-section hidden" id="statsSection">
<h2>切分概览</h2>
<dl class="stats-grid">
<div><dt>Blocks</dt><dd id="statBlocks">—</dd></div>
<div><dt>Chunks</dt><dd id="statChunks">—</dd></div>
<div><dt>模式</dt><dd id="statMode">—</dd></div>
<div><dt>Doc ID</dt><dd id="statDocId" class="mono">—</dd></div>
</dl>
</section>
</aside>
<main class="workspace" id="main-content">
<div class="empty-state" id="emptyState">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18M9 21V9"/>
</svg>
<h2>上传文档查看切片效果</h2>
<p>支持 Word / PDF / Excel / Markdown 等格式。左侧原文、中间切片列表、右侧切片预览。</p>
</div>
<div class="workspace-pane hidden" id="workspacePane">
<section class="pane pane-original" aria-label="原文预览">
<div class="pane-header">
<h2>查看原文</h2>
<span class="pane-tag" id="originalTag"></span>
</div>
<div class="pane-body" id="originalPreview">
<p class="placeholder">原文加载中…</p>
</div>
</section>
<section class="pane pane-chunks" aria-label="切片列表">
<div class="pane-header">
<h2>管理切片</h2>
<span class="pane-tag" id="chunkCountTag"></span>
</div>
<div class="pane-body chunk-list" id="chunkList" role="listbox" aria-label="切片列表"></div>
</section>
<section class="pane pane-preview" id="previewPanel" aria-label="选中切片预览">
<div class="pane-header">
<h2>切片预览</h2>
<span class="pane-tag" id="selectedChunkTag">未选择</span>
</div>
<div class="pane-body markdown-body" id="chunkPreview">
<p class="placeholder">点击中间列表中的切片查看 Markdown 渲染结果</p>
</div>
</section>
</div>
<details class="recall-panel hidden" id="recallPanel">
<summary>召回测试</summary>
<form class="recall-form" id="recallForm">
<label class="field recall-query">
<span>查询内容</span>
<input id="recallQuery" type="search" maxlength="500" placeholder="输入要检索的问题或关键词" required />
</label>
<label class="field recall-top-k">
<span>Top K</span>
<input id="recallTopK" type="number" min="1" max="20" value="5" required />
</label>
<button class="btn btn-primary recall-submit" id="recallSubmit" type="submit">开始召回</button>
</form>
<p class="recall-hint" id="recallHint">仅检索有语义价值的切片,“仅展示”切片不会进入候选集。</p>
<div class="recall-results" id="recallResults"></div>
</details>
<details class="api-panel hidden" id="apiPanel">
<summary>API 请求 / 响应</summary>
<div class="api-grid">
<div>
<h3>Request</h3>
<pre id="apiRequest" class="code-block"></pre>
</div>
<div>
<h3>Response</h3>
<pre id="apiResponse" class="code-block"></pre>
</div>
</div>
</details>
</main>
</div>
<script src="/js/app.js"></script>
</body>
</html>
+957
View File
@@ -0,0 +1,957 @@
/**
* RAG-cut Demo Frontend
*/
const API_BASE = (() => {
const meta = document.querySelector('meta[name="api-base"]')?.content?.trim();
if (meta) return meta.replace(/\/$/, "");
if (window.location.port && window.location.port !== "8000") {
return "http://127.0.0.1:8000";
}
return "";
})();
function apiUrl(path) {
return `${API_BASE}${path}`;
}
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const state = {
file: null,
result: null,
selectedIndex: 0,
objectUrl: null,
history: [],
activeHistoryId: null,
};
const els = {
dropzone: $("#dropzone"),
fileInput: $("#fileInput"),
fileName: $("#fileName"),
configForm: $("#configForm"),
submitBtn: $("#submitBtn"),
splitMode: $("#splitMode"),
modeHint: $("#modeHint"),
modeOptionsDefault: $("#modeOptionsDefault"),
modeOptionsDelimiter: $("#modeOptionsDelimiter"),
modeOptionsParentChild: $("#modeOptionsParentChild"),
modeOptionsByRow: $("#modeOptionsByRow"),
maxChunkSize: $("#maxChunkSize"),
overlap: $("#overlap"),
delimiter: $("#delimiter"),
delimiterMaxSize: $("#delimiterMaxSize"),
delimiterOverlap: $("#delimiterOverlap"),
parentDelimiter: $("#parentDelimiter"),
childDelimiter: $("#childDelimiter"),
parentMaxSize: $("#parentMaxSize"),
childMaxSize: $("#childMaxSize"),
parentChildOverlap: $("#parentChildOverlap"),
headerRowStart: $("#headerRowStart"),
headerRowEnd: $("#headerRowEnd"),
startRow: $("#startRow"),
rowsPerChunk: $("#rowsPerChunk"),
autoPolicy: $("#autoPolicy"),
policyKicker: $("#policyKicker"),
policyTitle: $("#policyTitle"),
autoPolicyGrid: $("#autoPolicyGrid"),
statusBadge: $("#statusBadge"),
emptyState: $("#emptyState"),
dualPane: $("#workspacePane"),
previewPanel: $("#previewPanel"),
apiPanel: $("#apiPanel"),
recallPanel: $("#recallPanel"),
recallForm: $("#recallForm"),
recallQuery: $("#recallQuery"),
recallTopK: $("#recallTopK"),
recallSubmit: $("#recallSubmit"),
recallHint: $("#recallHint"),
recallResults: $("#recallResults"),
statsSection: $("#statsSection"),
historyList: $("#historyList"),
historyHint: $("#historyHint"),
historyRefreshBtn: $("#historyRefreshBtn"),
originalPreview: $("#originalPreview"),
chunkList: $("#chunkList"),
chunkPreview: $("#chunkPreview"),
originalTag: $("#originalTag"),
chunkCountTag: $("#chunkCountTag"),
selectedChunkTag: $("#selectedChunkTag"),
statBlocks: $("#statBlocks"),
statChunks: $("#statChunks"),
statMode: $("#statMode"),
statDocId: $("#statDocId"),
apiRequest: $("#apiRequest"),
apiResponse: $("#apiResponse"),
};
function setStatus(type, text) {
els.statusBadge.innerHTML = `<span class="badge badge-${type}">${text}</span>`;
}
function setFile(file) {
if (state.objectUrl) {
URL.revokeObjectURL(state.objectUrl);
state.objectUrl = null;
}
state.file = file;
state.result = null;
state.activeHistoryId = null;
els.fileName.textContent = file ? file.name : "";
els.submitBtn.disabled = !file;
els.submitBtn.title = file ? "开始切分已选文件" : "请先上传文档";
highlightHistoryActive();
if (file) setStatus("idle", "已选择文件");
else setStatus("idle", "等待上传");
}
function formatSavedAt(iso) {
if (!iso) return "";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString("zh-CN", { hour12: false });
} catch {
return "";
}
}
function highlightHistoryActive() {
if (!els.historyList) return;
els.historyList.querySelectorAll(".history-item").forEach((row) => {
const active = row.dataset.docId === state.activeHistoryId;
row.classList.toggle("active", active);
row.setAttribute("aria-selected", active ? "true" : "false");
});
}
function clearWorkspace() {
state.result = null;
state.selectedIndex = 0;
state.activeHistoryId = null;
if (state.objectUrl) {
URL.revokeObjectURL(state.objectUrl);
state.objectUrl = null;
}
els.emptyState?.classList.remove("hidden");
els.dualPane?.classList.add("hidden");
els.apiPanel?.classList.add("hidden");
els.recallPanel?.classList.add("hidden");
els.statsSection?.classList.add("hidden");
if (els.originalPreview) els.originalPreview.innerHTML = "";
if (els.chunkList) els.chunkList.innerHTML = "";
if (els.chunkPreview) els.chunkPreview.innerHTML = "";
if (els.apiRequest) els.apiRequest.textContent = "";
if (els.apiResponse) els.apiResponse.textContent = "";
if (els.recallResults) els.recallResults.innerHTML = "";
highlightHistoryActive();
setStatus("idle", "等待上传");
}
function renderHistoryList(items) {
state.history = items || [];
if (!els.historyList) return;
if (!state.history.length) {
els.historyHint.textContent = "暂无历史记录,上传并切分后会出现在这里";
els.historyList.innerHTML = "";
return;
}
els.historyHint.textContent = `共 ${state.history.length} 条 · 点击加载`;
els.historyList.innerHTML = state.history.map((item) => {
const when = formatSavedAt(item.saved_at);
const orig = item.has_original ? "" : " · 无原文";
const active = item.doc_id === state.activeHistoryId;
return `
<div class="history-item${active ? " active" : ""}" role="option"
data-doc-id="${escapeHtml(item.doc_id)}"
aria-selected="${active ? "true" : "false"}">
<button type="button" class="history-item-load" data-doc-id="${escapeHtml(item.doc_id)}" title="加载此切片结果">
<span class="history-item-name">${escapeHtml(item.filename || item.doc_id)}</span>
<span class="history-item-meta">${item.chunk_count || 0} 片 · ${escapeHtml(item.split_mode || "—")}${when ? ` · ${escapeHtml(when)}` : ""}${orig}</span>
</button>
<button type="button" class="history-item-delete" data-doc-id="${escapeHtml(item.doc_id)}"
title="删除此历史记录" aria-label="删除 ${escapeHtml(item.filename || item.doc_id)}">删除</button>
</div>
`;
}).join("");
els.historyList.querySelectorAll(".history-item-load").forEach((btn) => {
btn.addEventListener("click", () => loadHistoryResult(btn.dataset.docId));
});
els.historyList.querySelectorAll(".history-item-delete").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
deleteHistoryResult(btn.dataset.docId);
});
});
}
async function deleteHistoryResult(docId) {
if (!docId) return;
const item = state.history.find((h) => h.doc_id === docId);
const label = item?.filename || docId;
if (!confirm(`确定删除「${label}」?\n将同时删除切片结果、上传原文与资源文件。`)) {
return;
}
setStatus("loading", "删除历史…");
try {
const res = await fetch(apiUrl(`/api/results/${docId}`), { method: "DELETE" });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) {
const detail = data?.detail;
const msg = typeof detail === "string" ? detail : (detail ? JSON.stringify(detail) : `HTTP ${res.status}`);
throw new Error(msg);
}
if (state.activeHistoryId === docId) {
clearWorkspace();
}
await refreshHistory();
setStatus("success", `已删除 · ${label}`);
} catch (err) {
setStatus("error", "删除失败");
alert(`删除失败:${err.message || err}`);
}
}
async function refreshHistory() {
if (!els.historyHint) return;
els.historyHint.textContent = "加载中…";
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
try {
const res = await fetch(apiUrl("/api/results"), { signal: controller.signal });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
throw new Error(res.ok ? "响应不是 JSON" : `HTTP ${res.status}`);
}
if (!res.ok) {
const detail = data?.detail;
const msg = typeof detail === "string" ? detail : (detail ? JSON.stringify(detail) : `HTTP ${res.status}`);
if (res.status === 404) {
throw new Error("接口不存在,请重启后端(backend: python run.py)");
}
throw new Error(msg);
}
renderHistoryList(data.results || []);
} catch (err) {
const message =
err?.name === "AbortError"
? "请求超时,请确认后端已启动在 :8000"
: (err.message || String(err));
els.historyHint.textContent = `历史加载失败:${message}`;
} finally {
clearTimeout(timer);
}
}
async function renderOriginalFromHistory(docId, filename, hasOriginal) {
if (!hasOriginal) {
els.originalTag.textContent = filename || docId;
els.originalPreview.innerHTML =
'<p class="placeholder">历史记录中未找到原文文件(可能已被清理)。仍可查看右侧切片结果。</p>';
return;
}
els.originalPreview.innerHTML = '<p class="placeholder">原文加载中…</p>';
try {
const res = await fetch(apiUrl(`/api/results/${docId}/original`));
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
const blob = await res.blob();
const name = filename || "document";
const file = new File([blob], name, { type: blob.type || "application/octet-stream" });
state.file = file;
els.fileName.textContent = name;
els.submitBtn.disabled = false;
await renderOriginalPreview(file, docId);
} catch (err) {
els.originalTag.textContent = filename || docId;
els.originalPreview.innerHTML =
`<p class="placeholder">原文预览失败:${escapeHtml(err.message || String(err))}。仍可查看切片结果。</p>`;
}
}
async function loadHistoryResult(docId) {
if (!docId) return;
setStatus("loading", "加载历史…");
els.apiRequest.textContent = JSON.stringify(
{ method: "GET", url: apiUrl(`/api/results/${docId}`) },
null,
2,
);
try {
const res = await fetch(apiUrl(`/api/results/${docId}`));
const data = await res.json();
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
state.activeHistoryId = docId;
highlightHistoryActive();
els.apiResponse.textContent = JSON.stringify(
{ doc_id: data.doc_id, filename: data.filename, chunk_count: data.chunk_count, split_mode: data.split_mode },
null,
2,
);
showResult(data);
setStatus("success", `已加载历史 · ${data.chunk_count} 片`);
await renderOriginalFromHistory(docId, data.filename, data.has_original);
} catch (err) {
setStatus("error", "加载失败");
els.apiResponse.textContent = JSON.stringify({ error: err.message || String(err) }, null, 2);
alert(`加载历史失败:${err.message || err}`);
}
}
function buildFormData() {
const fd = new FormData();
fd.append("file", state.file);
const fields = collectSplitFields();
Object.entries(fields).forEach(([key, value]) => {
if (value != null && value !== "") fd.append(key, String(value));
});
return fd;
}
function unescapeDelimiter(raw) {
return String(raw || "")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\r/g, "\r");
}
function collectSplitFields() {
const mode = els.splitMode?.value || "default";
if (mode === "default") {
return {
mode: "default",
max_chunk_size: Number(els.maxChunkSize?.value || 2200),
overlap: Number(els.overlap?.value || 0),
};
}
if (mode === "delimiter") {
const delimiter = unescapeDelimiter(els.delimiter?.value?.trim());
return {
mode: "delimiter",
delimiter: delimiter || "###",
max_chunk_size: Number(els.delimiterMaxSize?.value || 1500),
overlap: Number(els.delimiterOverlap?.value || 0),
};
}
if (mode === "parent_child") {
const parentDelimiter = unescapeDelimiter(els.parentDelimiter?.value?.trim());
const childDelimiter = unescapeDelimiter(els.childDelimiter?.value?.trim());
return {
mode: "parent_child",
parent_delimiter: parentDelimiter || "##",
child_delimiter: childDelimiter || "",
max_chunk_size: Number(els.parentMaxSize?.value || 2000),
child_max_size: Number(els.childMaxSize?.value || 512),
overlap: Number(els.parentChildOverlap?.value || 0),
};
}
if (mode === "by_row") {
return {
mode: "by_row",
header_row_start: Number(els.headerRowStart?.value || 1),
header_row_end: Number(els.headerRowEnd?.value || 1),
start_row: Number(els.startRow?.value || 2),
rows_per_chunk: Number(els.rowsPerChunk?.value || 10),
max_chunk_size: 2400,
overlap: 0,
};
}
return {};
}
function buildRequestPreview() {
const fields = {
file: state.file?.name,
...collectSplitFields(),
};
return {
method: "POST",
url: apiUrl("/api/chunk"),
contentType: "multipart/form-data",
fields,
};
}
const MODE_HINTS = {
default: "按标题/结构切分,适合 PDF、Word、PPT 等",
delimiter: "按自定义标识符切开,适合 Markdown/带固定分隔符的文本",
parent_child: "先按父标识符切父片,再按子标识符切子片;检索用子片、父片作上下文",
by_row: "按表格行切分,适合 xlsx / xls / csv",
};
function syncModeOptions() {
const mode = els.splitMode?.value || "default";
els.modeOptionsDefault?.classList.toggle("hidden", mode !== "default");
els.modeOptionsDelimiter?.classList.toggle("hidden", mode !== "delimiter");
els.modeOptionsParentChild?.classList.toggle("hidden", mode !== "parent_child");
els.modeOptionsByRow?.classList.toggle("hidden", mode !== "by_row");
if (els.modeHint) els.modeHint.textContent = MODE_HINTS[mode] || MODE_HINTS.default;
// CSS-hidden fields still join HTML5 constraint validation and can silently
// block <form> submit with no JS callback. Disable inactive panels' controls.
[
[els.modeOptionsDefault, mode === "default"],
[els.modeOptionsDelimiter, mode === "delimiter"],
[els.modeOptionsParentChild, mode === "parent_child"],
[els.modeOptionsByRow, mode === "by_row"],
].forEach(([panel, active]) => {
panel?.querySelectorAll("input, select, textarea").forEach((input) => {
input.disabled = !active;
});
});
if (els.policyKicker) {
els.policyKicker.textContent = "MANUAL";
}
if (els.policyTitle) {
els.policyTitle.textContent = "将使用上方手动选择的切分模式与参数";
}
const fields = collectSplitFields();
renderAutoPolicy({
mode: fields.mode,
max_chunk_size: fields.max_chunk_size,
child_max_size: fields.child_max_size,
overlap: fields.overlap,
pdf_chunk_strategy: mode === "by_row" ? null : undefined,
chunk_strategy: mode === "parent_child" ? "parent_child_delimiter" : undefined,
});
}
function renderAutoPolicy(config) {
if (!els.autoPolicyGrid) return;
const modeLabel = {
default: "结构感知",
delimiter: "标识符",
parent_child: "父子标识符",
by_row: "按行",
}[config?.mode] || "待上传后判断";
const strategyLabel = {
pdf_feature_step_screenshot: "功能步骤 + 截图",
pdf_outline_report: "章节报告(标题层级)",
heading_hierarchy: "标题层级优先",
heading_layout_multimodal: "标题布局多模态",
parent_child_delimiter: "父子标识符",
}[config?.pdf_chunk_strategy || config?.chunk_strategy]
|| (config?.mode === "by_row" ? "表格按行"
: (config?.mode === "delimiter" ? "通用标识符"
: (config?.mode === "parent_child" ? "父子标识符" : "自动判断")));
const maxSize = config?.max_chunk_size != null ? `${config.max_chunk_size} 字符` : "自动";
const overlap = config?.overlap != null ? `${config.overlap} 字符` : "自动";
const childSize =
config?.child_max_size != null ? `${config.child_max_size} 字符` : null;
els.autoPolicyGrid.innerHTML = `
<div><dt>模式</dt><dd>${escapeHtml(modeLabel)}</dd></div>
<div><dt>长度</dt><dd>${escapeHtml(maxSize)}</dd></div>
<div><dt>重叠</dt><dd>${escapeHtml(overlap)}</dd></div>
<div><dt>策略</dt><dd>${escapeHtml(strategyLabel)}</dd></div>
${childSize ? `<div><dt>子级长度</dt><dd>${escapeHtml(childSize)}</dd></div>` : ""}
`;
}
function toAssetUrl(path, docId) {
const raw = (path || "").trim();
if (!raw || raw.startsWith("http://") || raw.startsWith("https://") || raw.startsWith("data:")) {
return raw;
}
if (raw.startsWith("/assets/")) return apiUrl(raw);
if (raw.startsWith("assets/")) return apiUrl(`/${raw}`);
const name = raw.split(/[/\\]/).pop();
return apiUrl(`/assets/${docId}/${name}`);
}
function fixAssetPaths(markdown, docId) {
if (!markdown) return "";
return markdown.replace(/!\[(.*?)\]\(([^)]+)\)/g, (_, alt, path) => `![${alt}](${toAssetUrl(path, docId)})`);
}
function rewritePreviewImages(container, docId) {
container.querySelectorAll("img").forEach((img) => {
const src = img.getAttribute("src");
if (src) img.src = toAssetUrl(src, docId);
});
}
function formatBbox(bbox) {
if (!Array.isArray(bbox) || bbox.length < 4) return "";
return bbox.map((v) => Number(v).toFixed(0)).join(", ");
}
function renderBlockItem(block, docId) {
const order = block.order_index != null ? `data-order="${block.order_index}"` : "";
const page = block.page != null ? `data-page="${block.page}"` : "";
const bbox = formatBbox(block.bbox);
const metaBadge = [block.page != null ? `p${block.page}` : "", bbox ? `[${bbox}]` : ""]
.filter(Boolean)
.join(" · ");
if (block.type === "image") {
const src = toAssetUrl(block.image_path, docId);
const alt = block.text || block.image_id || "image";
let html = `<figure class="chunk-block chunk-block-image" ${order} ${page}>`;
if (block.bound_heading || block.nearest_heading) {
html += `<div class="block-context">${escapeHtml(block.bound_heading || block.nearest_heading)}</div>`;
}
html += `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" loading="lazy" />`;
if (block.ocr_text) {
html += `<figcaption class="ocr-caption">*[OCR]* ${escapeHtml(block.ocr_text)}</figcaption>`;
}
if (metaBadge) html += `<div class="block-meta">${escapeHtml(metaBadge)}</div>`;
html += "</figure>";
return html;
}
if (block.type === "heading") {
const level = Math.min(Math.max(block.level || 2, 1), 6);
return `<h${level} class="chunk-block chunk-block-heading" ${order} ${page}>${escapeHtml(block.text || "")}</h${level}>`;
}
if (block.type === "table") {
const title = block.table_title || block.nearest_heading || "";
const imgPath = block.image_path || block.crop_path;
let html = `<div class="chunk-block chunk-block-table" ${order} ${page}>`;
if (title) html += `<div class="block-context">${escapeHtml(title)}</div>`;
if (imgPath) {
const src = toAssetUrl(imgPath, docId);
html += `<figure class="table-screenshot"><img src="${escapeHtml(src)}" alt="${escapeHtml(title || "table")}" loading="lazy" /></figure>`;
}
html += `<div class="table-markdown">${renderMarkdown(block.markdown || block.text || "", docId)}</div>`;
if (block.ocr_text) {
html += `<div class="ocr-caption">*[表格 OCR]* ${escapeHtml(block.ocr_text)}</div>`;
}
if (block.footnotes) {
html += `<div class="table-footnote">${escapeHtml(block.footnotes)}</div>`;
}
if (metaBadge) html += `<div class="block-meta">${escapeHtml(metaBadge)}</div>`;
html += "</div>";
return html;
}
const inner = block.text ? `<p>${escapeHtml(block.text)}</p>` : "";
return `<div class="chunk-block chunk-block-paragraph" ${order} ${page}>${inner}${metaBadge ? `<div class="block-meta">${escapeHtml(metaBadge)}</div>` : ""}</div>`;
}
function renderChunkContent(chunk, docId) {
const blocks = chunk.meta?.blocks;
if (Array.isArray(blocks) && blocks.length > 0) {
const sorted = [...blocks].sort((a, b) => {
const ao = a.order_index ?? Number.MAX_SAFE_INTEGER;
const bo = b.order_index ?? Number.MAX_SAFE_INTEGER;
return ao - bo;
});
return `<div class="chunk-blocks">${sorted.map((b) => renderBlockItem(b, docId)).join("")}</div>`;
}
return renderMarkdown(chunk.content, docId);
}
function renderMarkdown(markdown, docId) {
const fixed = fixAssetPaths(markdown, docId);
if (typeof marked !== "undefined") {
try {
if (typeof marked.use === "function") {
marked.use({ breaks: true, gfm: true });
} else if (typeof marked.setOptions === "function") {
marked.setOptions({ breaks: true, gfm: true });
}
const html = marked.parse(fixed);
return typeof html === "string" ? html : String(html);
} catch (err) {
console.error("Markdown render failed:", err);
return `<pre>${escapeHtml(fixed)}</pre>`;
}
}
return `<pre>${escapeHtml(fixed)}</pre>`;
}
function escapeHtml(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function uniqueTypes(types) {
return [...new Set(types)];
}
function typeTagClass(type) {
if (type === "heading") return "type-tag type-tag-heading";
if (type === "image") return "type-tag type-tag-image";
if (type === "table") return "type-tag type-tag-table";
return "type-tag";
}
function renderChunkList(result) {
els.chunkList.innerHTML = "";
result.chunks.forEach((chunk, i) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `chunk-card${i === state.selectedIndex ? " active" : ""}`;
btn.setAttribute("role", "option");
btn.setAttribute("aria-selected", i === state.selectedIndex ? "true" : "false");
btn.dataset.index = String(i);
const types = uniqueTypes(chunk.block_types);
const heading = chunk.meta?.heading || chunk.meta?.nearest_heading || chunk.meta?.parent_heading;
const pageHint = chunk.meta?.page ? `p${chunk.meta.page}` : chunk.meta?.pages?.length ? `p${chunk.meta.pages.join(",")}` : "";
const imageCount = chunk.meta?.images?.length || chunk.block_types.filter((t) => t === "image").length;
const tableCount = chunk.meta?.tables?.length || chunk.block_types.filter((t) => t === "table").length;
const parentRef = chunk.meta?.parent_chunk_id != null ? ` · 父切片 ${chunk.meta.parent_chunk_id + 1}` : "";
const subBadge = chunk.meta?.is_sub_chunk ? '<span class="type-tag type-tag-heading">子切片</span>' : "";
const parentBadge = chunk.meta?.is_section_parent ? '<span class="type-tag">完整节</span>' : "";
const previewOnlyBadge = chunk.meta?.retrieval === false ? '<span class="type-tag type-tag-preview-only">仅展示</span>' : "";
const snippet = chunk.content.replace(/!\[.*?\]\([^)]+\)/g, "[图片]").slice(0, 160);
btn.innerHTML = `
<div class="chunk-card-header">
<span class="chunk-index">切片 ${chunk.index + 1}</span>
<span class="chunk-meta">${chunk.char_count} 字符${pageHint ? ` · ${pageHint}` : ""}${imageCount ? ` · ${imageCount} 图` : ""}${tableCount ? ` · ${tableCount} 表` : ""}${parentRef}</span>
</div>
<div class="chunk-types">
${subBadge}${parentBadge}${previewOnlyBadge}${types.map((t) => `<span class="${typeTagClass(t)}">${t}</span>`).join("")}
</div>
${heading ? `<div class="chunk-snippet" style="-webkit-line-clamp:1;font-weight:500;color:var(--color-text)">${escapeHtml(heading)}</div>` : ""}
<div class="chunk-snippet">${escapeHtml(snippet)}</div>
`;
btn.addEventListener("click", () => selectChunk(i));
els.chunkList.appendChild(btn);
});
}
function selectChunk(index) {
if (!state.result) return;
state.selectedIndex = index;
const chunk = state.result.chunks[index];
const docId = state.result.doc_id;
$$(".chunk-card").forEach((el, i) => {
el.classList.toggle("active", i === index);
el.setAttribute("aria-selected", i === index ? "true" : "false");
});
els.selectedChunkTag.textContent = `切片 ${chunk.index + 1} · ${chunk.char_count} 字符${chunk.meta?.parent_chunk_id != null ? ` · 父切片 ${chunk.meta.parent_chunk_id + 1}` : ""}`;
els.chunkPreview.innerHTML = renderChunkContent(chunk, docId);
rewritePreviewImages(els.chunkPreview, docId);
}
async function renderConvertedWordPreview(file, docId) {
if (!docId) return false;
try {
const res = await fetch(apiUrl(`/api/results/${docId}/preview`));
if (!res.ok) return false;
const blob = await res.blob();
if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
state.objectUrl = URL.createObjectURL(blob);
els.originalPreview.innerHTML =
`<iframe class="original-pdf" src="${state.objectUrl}" title="${escapeHtml(file.name)} PDF 原文预览"></iframe>`;
return true;
} catch (err) {
console.warn("Word PDF 预览加载失败:", err);
return false;
}
}
async function renderOriginalPreview(file, docId = null) {
els.originalPreview.innerHTML = '<p class="placeholder">原文加载中…</p>';
els.originalTag.textContent = file.name;
const ext = file.name.split(".").pop()?.toLowerCase() || "";
if (ext === "pdf") {
state.objectUrl = URL.createObjectURL(file);
els.originalPreview.innerHTML = `<iframe class="original-pdf" src="${state.objectUrl}" title="PDF 原文预览"></iframe>`;
return;
}
if (["doc", "docx"].includes(ext) && await renderConvertedWordPreview(file, docId)) {
return;
}
if (ext === "docx" && typeof mammoth !== "undefined") {
const buffer = await file.arrayBuffer();
const { value } = await mammoth.convertToHtml({ arrayBuffer: buffer });
els.originalPreview.innerHTML = `<div class="original-docx">${value}</div>`;
return;
}
if (["doc", "docx"].includes(ext)) {
els.originalPreview.innerHTML =
'<p class="placeholder">Word 原文将在切分完成后以 PDF 方式显示。</p>';
return;
}
if (["jpg", "jpeg", "png", "bmp", "gif"].includes(ext)) {
state.objectUrl = URL.createObjectURL(file);
els.originalPreview.innerHTML = `<img class="original-image" src="${state.objectUrl}" alt="上传的图片" />`;
return;
}
if (["txt", "md", "csv", "json", "xml", "log", "html", "htm"].includes(ext)) {
const text = await file.text();
els.originalPreview.innerHTML = `<pre class="original-text">${escapeHtml(text.slice(0, 50000))}${text.length > 50000 ? "\n\n… (已截断)" : ""}</pre>`;
return;
}
if (["ppt", "pptx", "ppsx"].includes(ext)) {
els.originalPreview.innerHTML =
`<p class="placeholder">演示文稿将先转为 PDF 再解析;原文预览暂不支持,请查看右侧切片结果。</p>`;
return;
}
els.originalPreview.innerHTML = `<p class="placeholder">该格式暂不支持原文预览,请查看右侧切片结果。</p>`;
}
function showResult(result) {
state.result = result;
state.selectedIndex = 0;
state.activeHistoryId = result.doc_id || null;
highlightHistoryActive();
els.emptyState.classList.add("hidden");
els.dualPane.classList.remove("hidden");
els.apiPanel.classList.remove("hidden");
els.recallPanel.classList.remove("hidden");
els.statsSection.classList.remove("hidden");
els.statBlocks.textContent = result.block_count;
els.statChunks.textContent = result.chunk_count;
els.statMode.textContent = result.split_mode;
els.statDocId.textContent = result.doc_id;
els.chunkCountTag.textContent = `${result.chunk_count} 个切片`;
renderAutoPolicy(result.split_config);
renderChunkList(result);
selectChunk(0);
setStatus("success", `切分完成 · ${result.chunk_count} 片`);
}
function renderRecallResults(data) {
els.recallHint.textContent = `候选 ${data.candidate_count} 个 · 返回 ${data.returned_count} 个 · ${data.elapsed_ms} ms`;
if (!data.results.length) {
els.recallResults.innerHTML = '<p class="placeholder">没有匹配的切片,请尝试更具体或更短的关键词。</p>';
return;
}
els.recallResults.innerHTML = data.results.map((item) => `
<button class="recall-result" type="button" data-chunk-index="${item.chunk_index}">
<span class="recall-rank">#${item.rank}</span>
<span class="recall-result-main">
<strong>${escapeHtml(item.heading || `切片 ${item.chunk_index + 1}`)}</strong>
<span>${escapeHtml(item.content.replace(/!\[.*?\]\([^)]+\)/g, "[图片]").slice(0, 180))}</span>
</span>
<span class="recall-score">${Number(item.score).toFixed(3)}</span>
</button>
`).join("");
els.recallResults.querySelectorAll(".recall-result").forEach((button) => {
button.addEventListener("click", () => {
const chunkIndex = Number(button.dataset.chunkIndex);
const position = state.result.chunks.findIndex((chunk) => chunk.index === chunkIndex);
if (position >= 0) selectChunk(position);
});
});
}
async function handleRecall(e) {
e.preventDefault();
if (!state.result) return;
const request = {
doc_id: state.result.doc_id,
query: els.recallQuery.value.trim(),
top_k: Number(els.recallTopK.value),
};
if (!request.query) return;
els.recallSubmit.disabled = true;
els.recallHint.textContent = "正在召回…";
els.apiPanel.classList.remove("hidden");
els.apiRequest.textContent = JSON.stringify({ method: "POST", url: apiUrl("/api/recall"), body: request }, null, 2);
try {
const response = await fetch(apiUrl("/api/recall"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`);
els.apiResponse.textContent = JSON.stringify(data, null, 2);
renderRecallResults(data);
} catch (err) {
els.recallHint.textContent = `召回失败:${err.message}`;
els.apiResponse.textContent = JSON.stringify({ error: err.message }, null, 2);
} finally {
els.recallSubmit.disabled = false;
}
}
async function handleSubmit(e) {
e.preventDefault();
if (!state.file) {
alert("请先在上方上传文档,再点击「开始切分」");
els.dropzone?.focus();
return;
}
const mode = els.splitMode?.value || "default";
if (mode === "delimiter") {
const delimiter = unescapeDelimiter(els.delimiter?.value?.trim());
if (!delimiter) {
alert("通用标识符切分需要填写标识符");
els.delimiter?.focus();
return;
}
}
if (mode === "parent_child") {
const parentDelimiter = unescapeDelimiter(els.parentDelimiter?.value?.trim());
if (!parentDelimiter) {
alert("父子标识符切分需要填写父级标识符");
els.parentDelimiter?.focus();
return;
}
const parentMax = Number(els.parentMaxSize?.value || 2000);
const childMax = Number(els.childMaxSize?.value || 512);
if (childMax > parentMax) {
alert("子级最大长度不能超过父级最大长度");
els.childMaxSize?.focus();
return;
}
}
const btnLabel = els.submitBtn.querySelector(".btn-label");
const spinner = els.submitBtn.querySelector(".btn-spinner");
const setBusy = (busy) => {
els.submitBtn.disabled = busy ? true : !state.file;
if (btnLabel) btnLabel.textContent = busy ? "切分中…" : "开始切分";
else els.submitBtn.textContent = busy ? "切分中…" : "开始切分";
if (spinner) spinner.classList.toggle("hidden", !busy);
};
setBusy(true);
setStatus("loading", "正在切分…");
if (els.apiRequest) {
els.apiRequest.textContent = JSON.stringify(buildRequestPreview(), null, 2);
}
els.apiPanel?.classList.remove("hidden");
const controller = new AbortController();
const abortTimer = setTimeout(() => controller.abort(), 10 * 60 * 1000);
try {
// Kick off API first; do not block chunking on original preview.
const previewPromise = renderOriginalPreview(state.file).catch((err) => {
console.warn("原文预览失败:", err);
});
const fd = buildFormData();
const res = await fetch(apiUrl("/api/chunk"), {
method: "POST",
body: fd,
signal: controller.signal,
});
const data = await res.json();
if (!res.ok) {
const detail = Array.isArray(data.detail)
? data.detail.map((d) => d.msg || JSON.stringify(d)).join("; ")
: data.detail;
throw new Error(detail || `HTTP ${res.status}`);
}
await previewPromise;
if (els.apiResponse) els.apiResponse.textContent = JSON.stringify(data, null, 2);
showResult(data);
await renderOriginalPreview(state.file, data.doc_id);
refreshHistory();
} catch (err) {
console.error("chunk submit failed:", err);
const message =
err?.name === "AbortError"
? "请求超时(超过 10 分钟)。后端可能卡在 MinerU/Office 转换,请重试或换较小文件。"
: err.message || String(err);
setStatus("error", "切分失败");
if (els.apiResponse) els.apiResponse.textContent = JSON.stringify({ error: message }, null, 2);
alert(`切分失败:${message}`);
} finally {
clearTimeout(abortTimer);
setBusy(false);
}
}
function initDropzone() {
els.dropzone.addEventListener("click", () => els.fileInput.click());
els.dropzone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
els.fileInput.click();
}
});
els.fileInput.addEventListener("change", () => {
if (els.fileInput.files?.[0]) setFile(els.fileInput.files[0]);
});
["dragenter", "dragover"].forEach((ev) => {
els.dropzone.addEventListener(ev, (e) => {
e.preventDefault();
els.dropzone.classList.add("dragover");
});
});
["dragleave", "drop"].forEach((ev) => {
els.dropzone.addEventListener(ev, (e) => {
e.preventDefault();
els.dropzone.classList.remove("dragover");
});
});
els.dropzone.addEventListener("drop", (e) => {
const file = e.dataTransfer?.files?.[0];
if (file) setFile(file);
});
}
els.configForm.addEventListener("submit", handleSubmit);
els.recallForm.addEventListener("submit", handleRecall);
els.historyRefreshBtn?.addEventListener("click", refreshHistory);
els.splitMode?.addEventListener("change", syncModeOptions);
[
els.maxChunkSize,
els.overlap,
els.delimiter,
els.delimiterMaxSize,
els.delimiterOverlap,
els.parentDelimiter,
els.childDelimiter,
els.parentMaxSize,
els.childMaxSize,
els.parentChildOverlap,
els.headerRowStart,
els.headerRowEnd,
els.startRow,
els.rowsPerChunk,
].forEach((el) => el?.addEventListener("change", syncModeOptions));
syncModeOptions();
initDropzone();
refreshHistory();
+39
View File
@@ -0,0 +1,39 @@
"""Serve RAG-cut frontend static files (development mode).
Requires the API backend to be running separately:
python backend/run.py
"""
from __future__ import annotations
import argparse
import http.server
import socketserver
from pathlib import Path
FRONTEND_DIR = Path(__file__).resolve().parent
DEFAULT_API = "http://127.0.0.1:8000"
class FrontendHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(FRONTEND_DIR), **kwargs)
def main() -> None:
parser = argparse.ArgumentParser(description="Start RAG-cut frontend dev server")
parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=5173, help="Bind port (default: 5173)")
args = parser.parse_args()
url = f"http://{args.host}:{args.port}"
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer((args.host, args.port), FrontendHandler) as httpd:
print(f"Frontend: {url}")
print(f"API: {DEFAULT_API} (start with: python backend/run.py)")
print("Press Ctrl+C to stop.")
httpd.serve_forever()
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.115.0
uvicorn>=0.30.0
pydantic>=2.0
PyMuPDF>=1.24.0
pdfplumber>=0.11.0
openpyxl>=3.1.0
mineru[all]
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""CLI to chunk documents and print/save results."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend"))
from rag_cut.models import SplitConfig, SplitMode
from rag_cut.pipeline import chunk_document
def main() -> None:
parser = argparse.ArgumentParser(description="RAG-cut document chunking CLI")
parser.add_argument("file", type=Path, help="Path to document")
parser.add_argument(
"--mode",
choices=[m.value for m in SplitMode],
default="default",
help="Split mode",
)
parser.add_argument("--delimiter", default=None, help="Delimiter for delimiter mode")
parser.add_argument("--parent-delimiter", default=None, help="Parent delimiter for parent_child mode")
parser.add_argument("--child-delimiter", default=None, help="Child delimiter for parent_child mode")
parser.add_argument("--max-chunk-size", type=int, default=1500, help="Parent/primary max chunk size")
parser.add_argument("--child-max-size", type=int, default=512, help="Child max size for parent_child mode")
parser.add_argument("--overlap", type=int, default=150)
parser.add_argument("--header-row-start", type=int, default=1)
parser.add_argument("--header-row-end", type=int, default=1)
parser.add_argument("--start-row", type=int, default=2)
parser.add_argument("--rows-per-chunk", type=int, default=1)
parser.add_argument("-o", "--output", type=Path, default=None, help="Save JSON result")
parser.add_argument("--preview", type=int, default=3, help="Print first N chunks")
args = parser.parse_args()
config = SplitConfig(
mode=SplitMode(args.mode),
delimiter=args.delimiter,
parent_delimiter=args.parent_delimiter,
child_delimiter=args.child_delimiter,
max_chunk_size=args.max_chunk_size,
child_max_size=args.child_max_size,
overlap=args.overlap,
header_row_start=args.header_row_start,
header_row_end=args.header_row_end,
start_row=args.start_row,
rows_per_chunk=args.rows_per_chunk,
)
result = chunk_document(args.file, config=config)
print(f"File: {result.filename}")
print(f"Doc ID: {result.doc_id}")
print(f"Blocks: {result.block_count} -> Chunks: {result.chunk_count}")
print(f"Mode: {result.split_mode.value}")
print("-" * 60)
for chunk in result.chunks[: args.preview]:
print(f"\n[Chunk {chunk.index}] ({chunk.char_count} chars) types={chunk.block_types}")
preview = chunk.content[:500]
print(preview + ("..." if len(chunk.content) > 500 else ""))
if args.output:
args.output.write_text(result.model_dump_json(indent=2), encoding="utf-8")
print(f"\nSaved full result to {args.output}")
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
"""Extract text and page summaries from requirements PDF."""
from pathlib import Path
import fitz
PDF = Path(__file__).resolve().parent.parent / "docx" / "自研搭建AI助手知识库.pdf"
OUT = Path(__file__).resolve().parent.parent / "docx" / "extracted_requirements.txt"
IMG_DIR = Path(__file__).resolve().parent.parent / "docx" / "pdf_pages"
def main() -> None:
IMG_DIR.mkdir(parents=True, exist_ok=True)
doc = fitz.open(PDF)
lines = [f"Pages: {len(doc)}", f"Path: {PDF}", ""]
for i, page in enumerate(doc):
text = page.get_text("text").strip()
imgs = page.get_images()
blocks = page.get_text("dict")["blocks"]
text_blocks = sum(1 for b in blocks if b.get("type") == 0)
img_path = IMG_DIR / f"page_{i + 1:02d}.png"
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
pix.save(str(img_path))
lines.append(f"{'=' * 60}")
lines.append(f"PAGE {i + 1} | images={len(imgs)} text_blocks={text_blocks}")
lines.append(f"{'=' * 60}")
lines.append(text if text else "[no extractable text - see pdf_pages/]")
lines.append("")
doc.close()
OUT.write_text("\n".join(lines), encoding="utf-8")
print(f"Wrote {OUT}")
print(f"Rendered {len(list(IMG_DIR.glob('*.png')))} page images to {IMG_DIR}")
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
$ErrorActionPreference = 'Stop'
$out = 'C:\Users\24019\Desktop\RAG-cut\data\test_sample.pptx'
$pp = New-Object -ComObject PowerPoint.Application
try {
$pres = $pp.Presentations.Add()
$slide = $pres.Slides.Add(1, 1)
$slide.Shapes.Title.TextFrame.TextRange.Text = 'RAG-cut Test Slide'
$slide.Shapes.Item(2).TextFrame.TextRange.Text = 'Body text for chunking test.'
$pres.SaveAs($out)
$pres.Close()
} finally {
$pp.Quit()
}
Write-Output "Saved $out"
+59
View File
@@ -0,0 +1,59 @@
"""Quick probe of sample documents for chunking strategy planning."""
import re
import zipfile
from pathlib import Path
DATA = Path(__file__).resolve().parent.parent / "data"
def probe_docx(p: Path) -> None:
with zipfile.ZipFile(p) as z:
media = [n for n in z.namelist() if n.startswith("word/media/")]
xml = z.read("word/document.xml").decode("utf-8", errors="ignore")
headings = len(re.findall(r'w:pStyle w:val="Heading', xml))
tables = xml.count("<w:tbl")
drawings = xml.count("<w:drawing") + xml.count("<w:pict")
print(f" media: {len(media)}, tables: {tables}, drawings: {drawings}, heading_styles: {headings}")
def probe_pdf(p: Path) -> None:
import fitz
doc = fitz.open(p)
imgs = sum(len(doc[i].get_images()) for i in range(len(doc)))
text_len = sum(len(doc[i].get_text()) for i in range(len(doc)))
print(f" pages: {len(doc)}, embedded_images: {imgs}, text_chars: {text_len}")
doc.close()
def probe_xlsx(p: Path) -> None:
import openpyxl
wb = openpyxl.load_workbook(p, read_only=True, data_only=True)
print(f" sheets: {len(wb.sheetnames)} -> {wb.sheetnames[:5]}")
for sn in wb.sheetnames[:2]:
ws = wb[sn]
print(f" {sn}: rows={ws.max_row}, cols={ws.max_column}")
wb.close()
def main() -> None:
for p in sorted(DATA.iterdir()):
if not p.is_file():
continue
print(f"=== {p.name} ({p.stat().st_size} bytes) ===")
ext = p.suffix.lower()
try:
if ext == ".docx":
probe_docx(p)
elif ext == ".pdf":
probe_pdf(p)
elif ext in (".xlsx", ".xls"):
probe_xlsx(p)
except Exception as e:
print(f" ERROR: {e}")
print()
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
"""Run chunking on all sample files in data/."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend"))
from rag_cut.models import SplitConfig, SplitMode
from rag_cut.pipeline import chunk_document
DATA = Path(__file__).resolve().parent.parent / "data"
OUT = Path(__file__).resolve().parent.parent / "storage"
def main() -> None:
for path in sorted(DATA.iterdir()):
if not path.is_file():
continue
ext = path.suffix.lower()
if ext == ".xlsx":
config = SplitConfig(mode=SplitMode.BY_ROW, rows_per_chunk=5)
else:
config = SplitConfig(mode=SplitMode.DEFAULT)
print(f"\n{'=' * 60}\n{path.name} ({ext})")
result = chunk_document(path, config=config)
print(f"blocks={result.block_count} chunks={result.chunk_count} mode={result.split_mode.value}")
out_file = OUT / f"{path.stem}_chunks.json"
out_file.write_text(result.model_dump_json(indent=2), encoding="utf-8")
print(f"saved -> {out_file.name}")
if result.chunks:
c0 = result.chunks[0]
print(f"chunk[0] types={c0.block_types} chars={c0.char_count}")
print(c0.content[:300].replace("\n", " ") + "...")
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.