Files
MemoryBear/docs/rag/pipeline/01-loader-parser-chunking.md
Multica PM Agent 343a5eebe3
Some checks failed
Sync to Gitee / sync (push) Has been cancelled
docs(rag): add MemoryBear RAG implementation docs v1.0
Submit the formed RAG documentation set produced across Sprint-1/2/3
(WS-12 through WS-26) under docs/rag/. Includes:

- README.md / INDEX.md: landing + total index (responsibility matrix,
  review verdicts, dual-link to source issues)
- overview/: full-pipeline architecture (4 .mmd diagrams),
  11-stage boundary contracts, doc map, source-code inventory
- pipeline/: 5 deep-dives (Loader/Parser/Chunking, Embedding,
  VDB & retrieval, GraphRAG, Rerank/Prompt/LLM)
- graphrag/, end-to-end/: v1.0 formal versions with full source
  retained as reference
- evolution/: 11 architecture-refactor proposals,
  6-direction roadmap, capability map
- review/: S3-T1 / S3-T2 final reviews, S2-T7 final summary
- _indexes/: glossary (81 terms), source->doc reverse index, chart index
- _release/: v1.0-RC1 release manifest, versioning convention,
  ops & freshness plan
- _meta/README.md: placeholder noting WS-12 governance assets gap

Aggregate review score 92.6/100 (8/8 PASS, 31/31 source-code spot
checks hit). The legacy docs/ ignore in .gitignore is narrowed to
docs/* with an explicit allowlist for docs/rag/.

Refs: WS-26
Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 10:51:48 +08:00

624 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "[S2-T1] 文档加载与预处理Loader / Parser / Chunking实现详解"
author: Python 开发工程师
last-reviewed-at: 2026-05-08
source-commit: HEAD (origin/main, MemoryBear)
scope: api/app/core/rag/{crawler, integrations, deepdoc, nlp, models, utils, app/naive.py, common/token_utils.py}
---
## 0. 一句话定位
把"任意来源、任意格式"的原始资料,沉淀为带元数据、可被 Embedding/索引消费的标准化 **Chunk** 序列;这一段是 RAG 召回质量的"硬天花板"——它做不好,下游再多优化都救不回来。
## 1. 设计目标与适用场景
| 目标 | 落地策略 |
|---|---|
| 多源接入(爬虫 / 飞书 / 语雀 / 本地文件) | `crawler/``integrations/feishu``integrations/yuque` 三套 SDK均落到本地文件后再走统一 `chunk()` 入口 |
| 多格式解析PDF/Word/Excel/PPT/HTML/MD/JSON/TXT/图片/音视频) | `app/naive.py:chunk()` 单一编排入口,按扩展名分派到 `deepdoc/parser/*``app/{audio,picture}.py` |
| 复杂 PDF 还原(表格、图、版面) | `RAGPdfParser` + OCR + 版面识别 + TSR + XGBoost 段落连接模型 |
| 长文 Chunking 既保语义又控 token | `naive_merge` / `naive_merge_docx` / `hierarchical_merge` / `tree_merge` 多种策略,统一以 `cl100k_base` 计算 token |
| 同一篇资料的多模态(图 + 文 + 表) | `tokenize_chunks_with_images``tokenize_table` 把图片/表格作为附属信息挂在 chunk 上 |
| 健壮性 | 鉴权 token 缓存、退避重试、robots.txt 合规、编码自动嗅探、嵌入文件递归解构 |
适用于:私有知识库、企业文档库、技术资料归档;不适用于:实时流式数据、对端到端延迟<200ms 的场景OCR 与版面识别是 CPU/GPU 重负载)。
## 2. 术语表
- **Section**:解析器吐出的中间结构 `(text, position_or_layout)` 元组列表,是 Chunking 之前的"原料"。
- **Chunk**:最终交给 Embedding 的文本片段,一般 ≤ `chunk_token_num` 个 token默认 128512
- **Token**:用 `tiktoken.cl100k_base` 编码后得到的 BPE token 数(与 OpenAI gpt-4 同口径)。
- **Layout**页面区块类别title / text / figure / table / equation 等),由 YOLOv10 检测。
- **TSR**Table Structure Recognition复杂表格行/列/合并单元格的结构还原。
- **OCR**:文字检测 + 文字识别两阶段的图像字符抽取。
- **Embed file**:内嵌在 docx/xlsx/pptx 内部的子文件(如 docx 里嵌的 Excel需递归解析。
## 3. 实现概览(数据流图)
```mermaid
flowchart LR
subgraph Loader["Loader / 多源接入"]
A1[本地文件] --> CHUNK
A2[Web 站点] --> WC[WebCrawler<br/>BFS 同域]
A3[飞书云文档] --> FS[FeishuAPIClient<br/>导出/下载]
A4[语雀知识库] --> YQ[YuqueAPIClient<br/>raw markdown]
WC --> CD[CrawledDocument<br/>title+content]
FS --> LF[本地文件]
YQ --> LF
CD --> CHUNK
LF --> CHUNK
end
subgraph Parser["Parser / 格式分派"]
CHUNK[app/naive.py: chunk] --> EX[extract_embed_file<br/>嵌入文件递归]
CHUNK -->|.pdf| PARSERS[PARSERS dict<br/>deepdoc/mineru/textln/plaintext]
CHUNK -->|.docx| DOCX[Docx/RAGDocxParser]
CHUNK -->|.xlsx/.csv| XLS[RAGExcelParser]
CHUNK -->|.md| MD[Markdown/RAGMarkdownParser]
CHUNK -->|.html| HTML[RAGHtmlParser]
CHUNK -->|.json/.jsonl| JSON[RAGJsonParser]
CHUNK -->|.txt/code| TXT[RAGTxtParser]
CHUNK -->|.ppt/.pptx| LO[LibreOffice<br/>convert_to_pdf]
CHUNK -->|.doc| TIKA[Apache Tika]
CHUNK -->|图片/音视频| MM[picture/audio<br/>vision_llm_chunk]
LO --> PARSERS
PARSERS --> OCR[OCR + LayoutRecognizer + TSR]
DOCX --> SEC[(sections)]
XLS --> SEC
MD --> SEC
HTML --> SEC
JSON --> SEC
TXT --> SEC
OCR --> SEC
TIKA --> SEC
end
subgraph Chunking["Chunking / 切分 + 索引化"]
SEC --> NM{有图片?}
NM -->|否| NM1[naive_merge]
NM -->|是·docx| NM2[naive_merge_docx]
NM -->|是·md| NM3[naive_merge_with_images]
NM1 --> TC[tokenize_chunks]
NM2 --> TCI[tokenize_chunks_with_images]
NM3 --> TCI
TT[tokenize_table] --> ESDOC
TC --> ESDOC[(ES Doc<br/>content_with_weight<br/>content_ltks<br/>page_num_int<br/>position_int<br/>image)]
TCI --> ESDOC
end
```
## 4. Loader 章节
### 4.1 Web Crawler`crawler/`
- **入口**`WebCrawler(entry_url, max_pages, delay_seconds, timeout_seconds, user_agent, include_patterns, exclude_patterns)`,源码 `api/app/core/rag/crawler/web_crawler.py:19`
- **架构**BFS`deque` + `visited_urls`+ 五个独立组件:`URLNormalizer` / `RobotsParser` / `RateLimiter` / `HTTPFetcher` / `ContentExtractor`,全部通过组合而非继承装配,便于替换。
- **同域限制**`URLNormalizer.is_same_domain()` 强制只爬入口域名,避免无界扩散(`url_normalizer.py:102-124`)。
- **去重**`URLNormalizer.normalize()` 做:小写 host、去 fragment、去默认端口、剥离 utm_*/fbclid/gclid 等追踪参数、按字母序排 query。`url_normalizer.py:28-100`
- **robots.txt 合规**`RobotsParser.can_fetch()``get_crawl_delay()`,使用 stdlib 的 `urllib.robotparser`每域名缓存。robots.txt 拉取失败时**默认允许**permissive fallback`robots_parser.py:60-69`
- **限速**`RateLimiter` 默认 1s/请求,`set_delay()` 可被 `Crawl-delay` 动态覆盖(上限 60s 防呆);`backoff(2.0)` 用于 429/503 指数退避,`rate_limiter.py:38-58`
- **HTTP 重试**`HTTPFetcher` 内置 `max_retries=3`,退避 `1s → 2s → 4s`429 与 503 显式触发重试404/4xx 立即返回不重试5xx 重试到耗尽。`http_fetcher.py:54-180`
- **编码处理**`HTTPFetcher._get_decoded_content` 五级回退HTML meta charset → response.encoding跳过 ISO-8859-1 → UTF-8 → GBK/Big5/Shift-JIS/EUC-KR 等 → latin-1 with errors='replace'。`http_fetcher.py:182-248`
- **正文抽取**`ContentExtractor.extract` 基于 `lxml`:移除 `script/style/nav/header/footer/aside`,按 `<article>/<main>``[role=main]``class/id =~ content|main|article|post``<body>` 顺序找主体;用 `is_static_content` 检测"脚本多文本少"的 SPA 页面并直接跳过。`content_extractor.py:24-72`
- **错误统计**`stats.error_breakdown` 记录每种错误类型的计数,便于事后分析。`web_crawler.py:210-215`
```python
# api/app/core/rag/crawler/web_crawler.py:103-145节选
while self.url_queue and self.pages_processed < self.max_pages:
url = self.url_queue.popleft()
if url in self.visited_urls: continue
self.visited_urls.add(url)
if not self.robots_parser.can_fetch(url): # robots.txt
self.stats['skipped'] += 1; continue
self.rate_limiter.wait() # 限速
fetch_result = self.http_fetcher.fetch(url) # 重试 + 退避
if not fetch_result.success:
self._record_error(fetch_result.error or "Unknown error"); continue
content_type = fetch_result.headers.get('Content-Type', '').lower()
if not any(s in content_type for s in ['text/html', 'application/xhtml+xml']):
self.stats['skipped'] += 1; continue # 非 HTML 跳过
extracted = self.content_extractor.extract(fetch_result.content, url)
if not extracted.is_static:
self.stats['skipped'] += 1; continue # JS-only 站点跳过
```
### 4.2 飞书集成(`integrations/feishu/`
- **入口**`FeishuAPIClient(app_id, app_secret, api_base_url, timeout, max_retries)``integrations/feishu/client.py:24`,是异步客户端(`httpx.AsyncClient`),用 `async with` 管理生命周期。
- **鉴权**`tenant_access_token` 模式,`get_tenant_access_token()``cachetools.TTLCache(maxsize=1, ttl=7200-300)` 缓存(飞书原生 2 小时有效,提前 5 分钟失效)+ `asyncio.Lock` 双检锁防并发请求 token。`client.py:51-127`
- **文件类型分派**`download_document``document.type` 分两条路径:
- **在线文档doc/docx/sheet/bitable**`_export_file` 走"创建导出任务 → 轮询 ticket → 下载 file_token"三步,最多轮询 10 次、间隔 2s超时抛 `FeishuAPIError``client.py:311-406`
- **附件文件file/slides**`_download_file` 直接 GET `/drive/v1/files/{token}/download`,从 `Content-Disposition` 解析 `filename*=UTF-8''xxx` 编码文件名。`client.py:408-452`
- **限流与重试**:装饰器 `@with_retry``feishu/retry.py:124-137`)。`RetryStrategy.RETRYABLE_ERRORS = (FeishuNetworkError, FeishuRateLimitError, httpx.TimeoutException/ConnectError/ReadError)``MAX_RETRIES=3`,退避 `[1, 2, 4]s`HTTP 429/502/503/5xx 重试4xx除 429不重试飞书业务码 `99991400/99991401`(限流码)也强制重试。`feishu/retry.py:24-76`
- **错误模型**:精细化异常树 `FeishuAuthError / FeishuAPIError / FeishuNotFoundError / FeishuPermissionError / FeishuRateLimitError / FeishuNetworkError / FeishuDataError`,调用方据此决定告警级别。`feishu/exceptions.py:1-46`
- **分页与递归**`list_folder_files` 单页page_size=200`list_all_folder_files(recursive=True)` 自动展开子文件夹。`client.py:226-269`
```python
# api/app/core/rag/integrations/feishu/client.py:78-120鉴权 + 双检锁缓存)
cached_token = self._token_cache.get("access_token")
if cached_token: return cached_token
async with self._token_lock:
cached_token = self._token_cache.get("access_token")
if cached_token: return cached_token
response = await self._http_client.post(
"/auth/v3/tenant_access_token/internal",
json={"app_id": self.app_id, "app_secret": self.app_secret})
data = response.json()
if data.get("code") != 0:
raise FeishuAuthError(f"Authentication failed: {data.get('msg')}",
error_code=str(data.get("code")), details=data)
token = data.get("tenant_access_token")
self._token_cache["access_token"] = token
return token
```
### 4.3 语雀集成(`integrations/yuque/`
- **入口**`YuqueAPIClient(user_id, token, api_base_url, timeout, max_retries)``integrations/yuque/client.py:27`
- **鉴权**:个人 PATHTTP header `X-Auth-Token`,无需 OAuth/token 刷新(语雀的 token 是长期 token故没有 token 缓存层。`client.py:55-66`
- **API 三段式**`get_user_repos()``get_repo_docs(book_id)``get_doc_detail(id, raw=1)``get_doc_detail``params={"raw": 1}` 拉原始 markdown。`client.py:119-291`
- **格式分派download_document**:根据 `doc.format` 决定本地文件后缀:
- `markdown` / `lake``.md`lake 也按 markdown 保存,因为 lake 在 raw 模式下输出兼容 md
- `html``.html`
- `lakesheet``.xlsx`,需 `zlib.decompress(bytes(sheet_data, 'latin-1'))` 解压后由 `generate_excel_from_sheet` 用 openpyxl 重建工作簿(含字体、对齐、颜色、合并单元格)。`client.py:293-545`
- **限流与重试**:与飞书同构,`yuque/retry.py:21-118``RetryStrategy` 配置一致HTTP 状态码 401→`YuqueAuthError`、403→`YuquePermissionError`、404→`YuqueNotFoundError`、429→`YuqueRateLimitError`,由 `_handle_api_error` 统一翻译。`client.py:73-117`
- **健壮性**`get_user_repos`/`get_repo_docs` 对单条数据 `try/except` 跳过坏记录而不整体失败(容忍语雀 schema 漂移)。`client.py:158-160, 221-223`
### 4.4 本地文件(`app/naive.py:chunk`
- 是所有 Loader 的最终汇入口;接收 `filename``binary` 两种入参,二者互斥(推荐 `binary`,源码内 `extract_embed_file` 显式不支持 path 模式,详见 `app/naive.py:541`)。
- **嵌入文件递归**:根调用(`is_root=True`)会先用 `extract_embed_file()` 抽出 docx/xlsx/pptx 内部嵌入的子文件(通过 zip 名单 `word/embeddings/``xl/embeddings/``ppt/embeddings/` 或 OLE 容器的 `Ole10Native`),逐个递归 `chunk()`,结果合入 `embed_res``utils/file_utils.py:69-130` + `app/naive.py:533-552`
- **超链接深挖**`parser_config.analyze_hyperlink=True`docx/pdf 内部超链接经 `extract_links_from_docx` / `extract_links_from_pdf` 抽出后,每条 URL 调用 `extract_html` 拉回 HTML 二进制并递归 `chunk(url, html_bytes, is_root=False)``app/naive.py:556-566, 793-803`
- **callback 进度上报**`chunk(..., callback=progress_callback)`,约定 `callback(prog: float, msg: str)`关键节点0.05(嵌入抽取)/ 0.1(开始解析)/ 0.6OCR 完)/ 0.63(版面)/ 0.65(表格)/ 0.67(合并)/ 0.8(解析完)。
## 5. Parser 章节
### 5.1 总分派器(`app/naive.py`
`chunk()` 是入口,按文件扩展名走分支:
```python
# api/app/core/rag/app/naive.py:97-102
PARSERS = {
"deepdoc": by_deepdoc,
"mineru": by_mineru,
"textln": by_textln,
"plaintext": by_plaintext, # default
}
# api/app/core/rag/app/naive.py:553-764
if re.search(r"\.docx$", filename, re.IGNORECASE): ...
elif re.search(r"\.pdf$", filename, re.IGNORECASE): ... # 走 PARSERS dict
elif re.search(r"\.(pptx|ppt?)$", ...): ... # LibreOffice → pdf
elif re.search(r"\.(da|wav|mp3|...)$", ...): ... # app/audio.py
elif re.search(r"\.(png|jpeg|...)$", ...): ... # app/picture.py
elif re.search(r"\.(csv|xlsx?)$", ...): ExcelParser
elif re.search(r"\.(txt|py|js|java|...)$", ...): TxtParser
elif re.search(r"\.(md|markdown)$", ...): Markdown(MarkdownParser 子类)
elif re.search(r"\.(htm|html)$", ...): HtmlParser
elif re.search(r"\.(json|jsonl|ldjson)$", ...): JsonParser
elif re.search(r"\.doc$", ...): tika # Apache Tika via JVM
```
PDF 的 `parser_config.layout_recognize` 决定底层走哪条 PDF 引擎,默认 `DeepDOC`
| layout_recognize | 引擎 | 调用 | 适用 |
|---|---|---|---|
| `DeepDOC` | `Pdf(RAGPdfParser)` | `by_deepdoc` | 复杂版面、扫描件 |
| `Plain Text` | `PlainParser` | `by_plaintext` | 纯文本 PDF速度快 |
| `MinerU` | `MinerUParser` | `by_mineru` | 高质量结构化(外部进程或 HTTP |
| `TextLn` | `TextLnParser` | `by_textln` | TextIn API云端付费 |
| 任意(含 `vision_model` | `VisionParser` | `by_plaintext` 分支 | 多模态 LLM 直读 |
### 5.2 PDF 解析(`deepdoc/parser/pdf_parser.py`1387 行)
`RAGPdfParser` 是大头,调用栈:
```python
# api/app/core/rag/app/naive.py:373-412 Pdf.__call__ 节选)
self.__images__(filename if not binary else binary, zoomin, from_page, to_page, callback)
callback(0.6, f"OCR finished")
self._layouts_rec(zoomin) # 版面识别
callback(0.63, "Layout analysis")
self._table_transformer_job(zoomin) # TSR
callback(0.65, "Table analysis")
self._text_merge(zoomin=zoomin) # 文本合并
self._extract_table_figure(...) # 提取表与图
self._naive_vertical_merge()
self._concat_downward() # XGBoost 段落连接updown_concat_xgb
self._final_reading_order_merge()
return [(b["text"], self._line_tag(b, zoomin)) for b in self.boxes], tbls
```
要点:
- **OCR**`OCR()``deepdoc/vision/ocr.py:522`= `TextDetector` + `TextRecognizer` 组合;`pdfplumber` 把每一页 `to_image(resolution=72*zoomin=216).annotated`,再过 OCR。`pdf_parser.py:1006-1122`
- **版面识别**`LayoutRecognizer4YOLOv10`默认10 个 labeltitle / Text / Reference / Figure / Figure caption / Table / Table caption / Equation 等),或 `AscendLayoutRecognizer`Ascend NPU`LAYOUT_RECOGNIZER_TYPE` 环境变量切换。`pdf_parser.py:53-67` + `vision/layout_recognizer.py:147-160`
- **表格结构识别**`TableStructureRecognizer``vision/table_structure_recognizer.py`),裁出 table 区域后把行/列重组成 HTML与 docx 的"按上下文找最近标题"风格一致。`pdf_parser.py:178-220`
- **段落连接模型**`updown_cnt_mdl`XGBoost输入是上下相邻两块的 31 维特征句末是否标点、x0 距离、行内 token 数、字号差、layout_type 等),决定要不要把下一块续到上一块。`pdf_parser.py:113-156` + `pdf_parser.py:70-83`(模型从 HuggingFace `InfiniFlow/text_concat_xgb_v1.0` 拉)。
- **位置标签**:每个文本块带 `@@<page>\t<x0>\t<x1>\t<top>\t<bottom>##` 的位置 tag`remove_tag()``re.sub(r"@@[\t0-9.-]+?##", "", txt)` 去掉,`extract_positions()` 反解。`pdf_parser.py:1219-1229`
- **GPU 加速**:通过 `pip_install_torch()` + `torch.cuda.is_available()` 把 XGBoost 推到 CUDA`PARALLEL_DEVICES > 1` 时用 `trio.CapacityLimiter` 做多卡并行。`pdf_parser.py:50-77, 1095-1106`
- **HuggingFace 模型分发**`InfiniFlow/text_concat_xgb_v1.0` 通过 `snapshot_download` 拉到 `res/` 目录;推荐 `export HF_ENDPOINT=https://hf-mirror.com` 解决国内拉取慢,`deepdoc/README.md:42`
#### 5.2.1 备选 PDF 引擎
- **`PlainParser`**`pdf_parser.py:1300``pypdf.PdfReader` 直接 `extract_text()`,每行一段 + 解析 outline 目录;无 OCR、无版面、无图纯文本极快。
- **`VisionParser`**`pdf_parser.py:1334`):把每一页转成 PIL.Image整页直接喂给 `vision_model``QWenCV` / `AzureGptV4` 等),让多模态 LLM "看图说话"产出 markdown。`@@page\tx0\tx1\ty0\ty1##` 位置 tag 由 `(0, 0, width/zoomin, 0, height/zoomin)` 占位生成(即整页矩形),方便下游对齐 chunk 与原图。
- **`MinerUParser`**`mineru_parser.py:41`):调用外部 `mineru` 进程CLI 模式)或 `MINERU_APISERVER`HTTP 模式,默认 `host.docker.internal:9987`),后端可选 `pipeline / vlm-http-client / vlm-transformers / vlm-vllm-engine`;输出 zip 解压后融合为 sections + tables。`naive.py:45-62`
- **`TextLnParser`**`app/textin_parser.py`):合合 TextIn 云端 PDF→Markdown 服务,需要 `TEXTLN_APP_ID/SECRET_CODE`
### 5.3 Word 解析(`deepdoc/parser/docx_parser.py` + `naive.py:Docx`
两层:
- **底层 `RAGDocxParser`**`docx_parser.py:9-123``python-docx`+`pandas` 读段落与表格;表格内容经 `__compose_table_content` 做"列类型推断"(日期 Dt / 数字 Nu / 中文人名 Nr / 英文 En 等 11 类正则),自动识别多行表头并把单元格拼成 `表头:值` 格式,保证表格在 chunk 中也能被关键词检索。
- **上层 `Docx(RAGDocxParser)`**`naive.py:105-323`):把段落里的图片用 `python-docx``xpath('.//pic:pic')` 抽出,挂到对应 paragraph表格区域用 `__get_nearest_title` 上溯到 7 级标题构造层级路径作为 `<caption>Table Location: A > B > C</caption>`,这是检索时定位表格上下文的关键。
- **超链接抽取**`extract_links_from_docx` 遍历 `document.part.rels`,过滤 reltype 为 hyperlink 的关系,得到链接集合。`utils/file_utils.py:133-154`
- **`to_markdown`**:可选回退路径,使用 `mammoth.convert_to_html` + `markdownify`,图片嵌成 `data:` base64 URL。`naive.py:325-366`
- **NULL 关系修复**:上层 `Docx``load_from_xml_v2` monkey-patch 掉 `_SerializedRelationships.load_from_xml`,跳过 `../NULL``NULL` target 以绕过 python-docx#1105 已知 bug。`naive.py:493-506, 569`
### 5.4 Excel/CSV 解析(`deepdoc/parser/excel_parser.py`
- **多引擎容错**`_load_excel_to_workbook` 先看魔数:`PK\x03\x04`OOXML`\xd0\xcf\x11\xe0`OLE2。openpyxl 失败回退 `pandas.read_excel`,再失败回退 `engine="calamine"`;非 Excel 头则当 CSV 处理(`pd.read_csv(on_bad_lines='skip')` 容忍坏行)。`excel_parser.py:18-53`
- **非法字符清洗**`ILLEGAL_CHARACTERS_RE = re.compile(r"[\000-\010]|[\013-\014]|[\016-\037]")``_clean_dataframe` 把所有字符串单元格里的控制字符替换成空格,避免写入 Workbook 报错。`excel_parser.py:13, 56-62`
- **三种输出形态**
- `__call__()`:每行 → `表头1值1\n表头2值2\n...\n——SheetName`,作为一个 section一个 chunk`excel_parser.py:203-246`
- `html()`:每 256 行打包成一张 `<table>`header 复用,便于检索时整块召回。`excel_parser.py:144-187`
- `markdown()``df.to_markdown(index=False)`,整个表一段。
- **图片抽取**`_extract_images_from_worksheet` 通过 `ws._images` 的 anchor.row/col 还原图片位置,输出 `single_cell` / `multi_cell` span_type 元数据。`excel_parser.py:98-142`
- **重要Excel 不走 `naive_merge`**`naive.py:678-680` 显式注释"Excel 每行直接作为一个 chunk不经过 naive_merge 避免被 delimiter 拆分"——直接 `tokenize_chunks(chunks, ...)`
### 5.5 Markdown 解析(`deepdoc/parser/markdown_parser.py`
- **表格抽取**`extract_tables_and_remainder` 用三个正则按顺序剥离:标准 GFM 边框表格 → 无边框表格 → HTML `<table>...</table>`(含 `<html><body>` 包装),每张表单独成一个 chunk剩余正文继续走 element 抽取。`markdown_parser.py:10-106`
- **元素抽取**`MarkdownElementExtractor.extract_elements(delimiter)` 按行扫描,识别 `header(#~######)` / `code_block(```)` / `list_block(-/*/+/数字.)` / `blockquote(>)` / `text_block`,每种元素用对应私有方法收集起止行号。`markdown_parser.py:109-277`
- **图片嵌入**:当传入 `vision_model`naive.py 会对每个 section 调 `markdown_parser.get_pictures()`HTTP 下载或本地路径打开),把图片合并 `concat_img` 后丢给 `VisionFigureParser` 让 LLM 描述图片,描述文本拼回 section 末尾。`naive.py:697-709`
- **超链接深挖**`get_hyperlink_urls(soup)` + `analyze_hyperlink=True` 触发递归 chunk。`naive.py:716-720`
### 5.6 HTML 解析(`deepdoc/parser/html_parser.py`
- **预清洗**BeautifulSoup html5lib移除 `<style>/<script>`,剥离 inline `style` 属性与 HTML 注释。`html_parser.py:39-52`
- **递归读文本**`read_text_recursively` 给每个 BLOCK_TAGh1-h6/p/div/article/section/aside/ul/ol/li/table/pre/code/blockquote/figure/figcaption分配 `block_id` UUID把 NavigableString 收集到所属 block。`<table>` 整段保留,单独给 `table_id` 元数据。`html_parser.py:89-131`
- **标题前缀化**`merge_block_text` 在拼接时把 `h1-h6` 改写为 `# ~ ######`Markdown 风格),保留层级语义到下游。`html_parser.py:134-161`
- **二次切分**`chunk_block(block_txt_list, chunk_token_num=512)``chunk_token_num`(默认 512合并 block超长 block 用 `rag_tokenizer.tokenize()` 切成等长片段。`html_parser.py:163-196`
### 5.7 JSON 解析(`deepdoc/parser/json_parser.py`
- **结构感知切分**`_json_split` 递归遍历 dict`_json_size`(即 `json.dumps(...)` 的字符长度,乘以 2 作为 max_chunk_size累计超过 max 但当前 chunk ≥ min`max - 200`)时开新 chunk否则继续递归到子节点。**关键设计**list 通过 `_list_to_dict_preprocessing` 转成索引化 dict让数组也能按结构切分。`json_parser.py:46-95`
- **JSONL 自动检测**`is_jsonl_format` 抽样前 10 行,若 ≥ 80% 行单独 `json.loads` 成功且整体不能 parse 为单个 JSON则按 JSONL 处理。`json_parser.py:134-152`
### 5.8 TXT/代码 解析(`deepdoc/parser/txt_parser.py`
- 简单版:`get_text` 读全文(`find_codec` 嗅探编码),按 `delimiter="\n!?;。;!?"` 切分,**就地累加 token**:当前 chunk 超 `chunk_token_num` 才开新 chunk。`txt_parser.py:8-48`
- 适配的扩展名集:`.txt|.py|.js|.java|.c|.cpp|.h|.php|.go|.ts|.sh|.cs|.kt|.sql``naive.py:685`
### 5.9 图片/音视频(`app/picture.py` / `app/audio.py`
- 图片:`from app.core.rag.app.picture import chunk``picture_vision_llm_chunk(binary, vision_model, prompt, callback)`,多模态 LLM 直接产文。
- 音视频:`from app.core.rag.app.audio import chunk` → 调 `seq2txt_mdl``QWenSeq2txt``qwen3-omni-flash`)做语音转文字。
- PDF 也可以走 VisionParser 让 LLM 整页"看图说话",是 OCR 失败/扫描件的兜底。
### 5.10 PPT 与 .doc外部依赖
- **PPTX/PPT** → `naive.py:628-651`:调 `async_convert_to_pdf``utils/libre_office.py:59-62`)把文件转 PDF再递归 `chunk(dest_pdf_path, ...)`
- LibreOffice 路径硬编码 `/usr/bin/soffice`Linux`/Applications/LibreOffice.app/Contents/MacOS/soffice`macOS都不存在则抛 `HTTP 500``subprocess.run``timeout=120s` 防卡死。`utils/libre_office.py:11-57`
-`ThreadPoolExecutor(max_workers=os.cpu_count()*2)` 提交异步转换任务;同进程多请求共享线程池。
- **DOC旧版二进制** → `naive.py:738-761`:使用 Apache Tika`tika-server.jar` JVM 进程,端口 9998。环境必须有 Java 11+;初始化 `tika.initVM()``tika_parser.from_file(filename)['content']``\n` 切分。
### 5.11 视觉子系统(`deepdoc/vision/`
- **OCR**`OCR.__call__(img, device_id, cls)` 内部跑 `TextDetector` 检测文字框 → `TextRecognizer` 识别字符 → 可选方向分类。`vision/ocr.py:522, 694`。模型走 ONNX。
- **LayoutRecognizer4YOLOv10**YOLOv10 ONNX 模型10 类 label`__call__(image_list, ocr_res, scale_factor=3, thr=0.2, batch_size=16, drop=True)` 中接收图像与 OCR 结果,输出每个文字框的 layout 类型并把 `header/footer` 等 drop 掉。
- **TableStructureRecognizer**:检测表格单元格的列、行、列头、合并单元格等 5 类。
- **VisionFigureParser**`figure_parser.py:52-118`):用 `ThreadPoolExecutor(10)` 并发把每张图片喂给 `vision_model`,超时 30s`@timeout(30, 3)` 装饰器表示 30s 超时、3 次重试)。`vision_llm_figure_describe_prompt()` 给出统一的"详细描述这张图"指令。
## 6. Chunking 章节
### 6.1 Token 计数(`common/token_utils.py`
- 模型固定为 `tiktoken.cl100k_base`GPT-4 / text-embedding-ada-002 同口径),缓存目录 `res/``token_utils.py:6-9`
- `num_tokens_from_string(s)` 容错:`encode` 失败返回 0不会让上层报错`token_utils.py:12-18`
- `truncate(s, max_len)` 按 token 截断,保护 LLM 上下文。`token_utils.py:56-58`
### 6.2 编码嗅探(`nlp/__init__.py:37-55`
- `find_codec(blob)`:先 `chardet.detect(blob[:1024])` 置信度 > 0.5 用结果("ascii" 强制升级到 "utf-8",避开 chardet 经典误判);置信度低则按预设 80+ 编码顺序列表逐个 `decode` 尝试,全失败 fallback `utf-8`
### 6.3 主切分函数 `naive_merge``nlp/__init__.py:562-606`
```python
# api/app/core/rag/nlp/__init__.py:562-606核心算法
def naive_merge(sections, chunk_token_num=128, delimiter="\n。;!?", overlapped_percent=0):
if isinstance(sections, str): sections = [sections]
if isinstance(sections[0], str): sections = [(s, "") for s in sections]
cks, tk_nums = [""], [0]
def add_chunk(t, pos):
nonlocal cks, tk_nums
tnum = num_tokens_from_string(t)
if tnum < 8: pos = "" # 太短不挂位置
if cks[-1] == "" or tk_nums[-1] > chunk_token_num * (100 - overlapped_percent)/100.:
# 开新 chunk按 overlapped_percent 从上一块尾部留滑窗
overlapped = RAGPdfParser.remove_tag(cks[-1])
t = overlapped[int(len(overlapped)*(100-overlapped_percent)/100.):] + t
if t.find(pos) < 0: t += pos
cks.append(t); tk_nums.append(tnum)
else:
if cks[-1].find(pos) < 0: t += pos
cks[-1] += t; tk_nums[-1] += tnum
dels = get_delimiters(delimiter)
for sec, pos in sections:
if num_tokens_from_string(sec) < chunk_token_num:
add_chunk("\n"+sec, pos); continue
for sub_sec in re.split(r"(%s)" % dels, sec, flags=re.DOTALL):
if re.match(f"^{dels}$", sub_sec): continue
add_chunk("\n"+sub_sec, pos)
return cks
```
要点:
- **token 上限**:当 `tk_nums[-1] > chunk_token_num * (1 - overlapped_percent/100)` 时开新 chunk。这意味着 `overlapped_percent=0` → 严格不超;`>0` → 提前开新块以预留滑窗空间。
- **滑动窗口**:开新 chunk 时把上一块尾部 `overlapped_percent%` 的字符(**不是 token**)拼到新块开头;用 `RAGPdfParser.remove_tag` 先剥离位置标签,避免位置 tag 漏到新块。
- **delimiter**:默认 `"\n。"`,可被 `parser_config.delimiter` 覆盖。`get_delimiters` 支持反引号包围的多字符分隔符(如 `` `\n\n` ``),并按长度降序优先匹配(避免短符号"吞掉"长符号的左边界)。`nlp/__init__.py:760-776`。
- **位置 tag 注入**:每段 `pos` 串只在 chunk 内不存在时才追加避免重复PDF chunk 一段往往跨多页,位置 tag 自然多次出现)。
- **长 section 二次拆分**:单段 token 数 ≥ chunk_token_num 才用 delimiter 切,否则整段加入。
### 6.4 带图变体
- **`naive_merge_docx`**`nlp/__init__.py:706-752`sections 是 `[(text, image), ...]`;无图段先累积成行 line遇到带图段才触发切分同一 chunk 内多图用 `concat_img` 上下拼接成一张大图。**没有 overlapped_percent**。
- **`naive_merge_with_images`**`nlp/__init__.py:609-662`):与 `naive_merge` 同构,但同步把每段对应的 image 累积到 `result_images` 数组(多图也走 `concat_img` 合并)。
### 6.5 标题树切分(结构感知)
- **`hierarchical_merge(bull, sections, depth)`**`nlp/__init__.py:471-559`):用 `BULLET_PATTERN[bull]`5 套样式:第一/二/三章节系列、英文 PART/Chapter、Markdown # 系列)匹配标题,按层级建组,每组内累计 token 不超过 218 就合并到一个 chunk。是 manual.py / paper.py 等"标准化文档"app 用的策略。
- **`tree_merge(bull, sections, depth)`**`nlp/__init__.py:423-469`):同样基于 BULLET_PATTERN但建一棵 `Node` 标题树,深度优先生成 chunk让父级标题路径自动出现在每个 chunk 头部(`title1\ntitle2\nbody`)。
- 这两个函数 **不在 `app/naive.py` 主链路**调用——naive.py 用的是 `naive_merge` 系列;它们服务于 `app/manual.py``app/paper.py``app/laws.py``app/book.py` 等专业 app。
### 6.6 关键词处理(`tokenize` / `tokenize_chunks` / `tokenize_table`
最终交给 ES 的不是裸文本 chunk而是带"分词字段"的 doc
```python
# api/app/core/rag/nlp/__init__.py:251-256
def tokenize(d, t, eng):
d["content_with_weight"] = t
t = re.sub(r"</?(table|td|caption|tr|th)( [^<>]{0,12})?>", " ", t)
d["content_ltks"] = rag_tokenizer.tokenize(t) # 粗粒度分词
d["content_sm_ltks"] = rag_tokenizer.fine_grained_tokenize(d["content_ltks"]) # 细粒度
```
```python
# api/app/core/rag/nlp/__init__.py:258-277tokenize_chunks
for ii, ck in enumerate(chunks):
d = copy.deepcopy(doc) # doc 含 docnm_kwd / title_tks / title_sm_tks
if pdf_parser: # 仅 PDF 链路
d["image"], poss = pdf_parser.crop(ck, need_position=True)
add_positions(d, poss)
ck = pdf_parser.remove_tag(ck)
else:
add_positions(d, [[ii]*5]) # 无位置时填占位
tokenize(d, ck, eng)
res.append(d)
```
- `add_positions(d, poss)` 写入 `page_num_int / position_int / top_int` 三列(`int` 后缀是 ES 的 type hint`nlp/__init__.py:325-337`
- `tokenize_table(tbls, doc, eng, batch_size=10)` 每 10 行表格组装成一个 chunk挂图如有`doc_type_kwd="image"``nlp/__init__.py:295-322`
### 6.7 `Chunk` Pydantic 模型(`models/chunk.py`
```python
# api/app/core/rag/models/chunk.py
class ChildDocumentChunk(BaseModel):
page_content: str
vector: list[float] | None = None
metadata: dict = Field(default_factory=dict)
class DocumentChunk(BaseModel): # 父子结构
page_content: str
vector: list[float] | None = None
metadata: dict = Field(default_factory=dict)
children: list[ChildDocumentChunk] | None = None
class GeneralStructureChunk(BaseModel):
general_chunks: list[str]
class ParentChildChunk(BaseModel):
parent_content: str
child_contents: list[str]
class ParentChildStructureChunk(BaseModel):
parent_child_chunks: list[ParentChildChunk]
parent_mode: str = "paragraph" # 父分段模式
class QAChunk(BaseModel):
question: str
answer: str
class QAStructureChunk(BaseModel):
qa_chunks: list[QAChunk]
```
> **重要:`DocumentChunk` 是上层服务(`services/`、`controllers/chunk_controller.py`)使用的"业务 schema",与 `tokenize_chunks` 输出的 ES doc 字段不同。** ES doc 实际字段(来自 `nlp/__init__.py` 注入):
> - `docnm_kwd`原文件名keyword
> - `title_tks` / `title_sm_tks`:标题分词(粗 + 细)
> - `content_with_weight`:原始 chunk 文本(用于 BM25 加权)
> - `content_ltks` / `content_sm_ltks`:内容分词(粗 + 细)
> - `page_num_int` / `position_int` / `top_int`:页码与坐标(用于 PDF 还原图片)
> - `image`PIL.Image存为二进制
> - `doc_type_kwd`doc 类型("image" / 默认空)
> - 后续 Embedding 阶段补 `q_vec_<dim>`(向量字段,详见 [S2-T2])。
### 6.8 切分策略汇总
| 策略 | 实现 | 默认参数 | 触发条件 |
|---|---|---|---|
| **按 Token + delimiter**(默认) | `naive_merge` | `chunk_token_num=128/512`, `delimiter="\n!?。;!?"` | docx/pdf/html/json/md/txt主链路 |
| **滑动窗口** | `naive_merge``overlapped_percent` | 默认 0 | `parser_config.overlapped_percent=N`(手动) |
| **按行**(无合并) | `naive.py:678-680` | `excel_parser` 每行一段 | xlsx/csv |
| **按段落 + 图绑定** | `naive_merge_docx` | 同 naive_merge | docx |
| **按段落 + 多模态** | `naive_merge_with_images` | 同 naive_merge | md含图/ pdf VisionParser |
| **结构化 JSON 切分** | `RAGJsonParser._json_split` | `max_chunk_size=4000 chars`, `min_chunk_size=max-200` | json/jsonl/ldjson |
| **按 token 切分HTML block** | `RAGHtmlParser.chunk_block` | `chunk_token_num=512` | html |
| **基于标题树** | `hierarchical_merge` / `tree_merge` | `depth` 参数token 上限硬编码 218 | manual/paper/book/laws app |
| **整段(不切)** | `tokenize_chunks` 直接喂 chunks | — | mineru/textln内置已切好 |
## 7. 关键源码片段速查
| 文件 | 行号 | 内容 |
|---|---|---|
| `api/app/core/rag/app/naive.py` | 27-95 | `by_deepdoc` / `by_mineru` / `by_textln` / `by_plaintext` 四个 PDF 适配器 |
| `api/app/core/rag/app/naive.py` | 97-102 | `PARSERS` 注册表 |
| `api/app/core/rag/app/naive.py` | 369-412 | `class Pdf(PdfParser)`OCR→layout→TSR→merge 编排 |
| `api/app/core/rag/app/naive.py` | 508-811 | `def chunk(...)`:所有格式的总入口 |
| `api/app/core/rag/nlp/__init__.py` | 562-606 | `naive_merge`(主切分) |
| `api/app/core/rag/nlp/__init__.py` | 706-752 | `naive_merge_docx`(图绑定) |
| `api/app/core/rag/nlp/__init__.py` | 251-256 | `tokenize`(生成分词字段) |
| `api/app/core/rag/nlp/__init__.py` | 258-277 | `tokenize_chunks`PDF 裁图 + 位置) |
| `api/app/core/rag/nlp/__init__.py` | 295-322 | `tokenize_table`(表格 batch=10 |
| `api/app/core/rag/nlp/__init__.py` | 152-184 | `BULLET_PATTERN`5 套标题样式) |
| `api/app/core/rag/common/token_utils.py` | 6-18 | `tiktoken.cl100k_base` + `num_tokens_from_string` |
| `api/app/core/rag/crawler/web_crawler.py` | 81-183 | `WebCrawler.crawl()` 主循环 |
| `api/app/core/rag/crawler/http_fetcher.py` | 42-180 | `HTTPFetcher.fetch` 重试/退避/4xx/5xx 处理 |
| `api/app/core/rag/integrations/feishu/client.py` | 68-127 | tenant_access_token + TTLCache + asyncio.Lock |
| `api/app/core/rag/integrations/feishu/client.py` | 311-406 | `_export_file` 三步轮询导出 |
| `api/app/core/rag/integrations/yuque/client.py` | 234-291 | `get_doc_detail(raw=1)` |
| `api/app/core/rag/integrations/yuque/client.py` | 364-455 | `lakesheet → xlsx` 重建 |
| `api/app/core/rag/utils/libre_office.py` | 11-57 | `convert_to_pdf` 软件路径 + 120s 超时 |
| `api/app/core/rag/utils/file_utils.py` | 69-130 | `extract_embed_file`zip/OLE 双路径) |
| `api/app/core/rag/deepdoc/parser/pdf_parser.py` | 1006-1122 | `__images__` OCR 主入口trio 并发) |
| `api/app/core/rag/deepdoc/parser/pdf_parser.py` | 1219-1229 | `remove_tag` / `extract_positions` |
| `api/app/core/rag/deepdoc/parser/pdf_parser.py` | 1300-1331 | `PlainParser`pypdf 兜底) |
| `api/app/core/rag/deepdoc/parser/pdf_parser.py` | 1334-1383 | `VisionParser`(多模态 LLM 整页) |
| `api/app/core/rag/deepdoc/parser/excel_parser.py` | 18-53 | `_load_excel_to_workbook`openpyxl/pandas/calamine 三级回退) |
| `api/app/core/rag/deepdoc/parser/json_parser.py` | 46-95 | `_json_split` 结构感知切分 |
| `api/app/core/rag/deepdoc/parser/figure_parser.py` | 52-118 | `VisionFigureParser`10 并发 LLM 描述图) |
| `api/app/core/rag/deepdoc/vision/layout_recognizer.py` | 147-160 | YOLOv10 10 类 label |
## 8. 配置项与可调参数
### 8.1 `parser_config``naive.py:521` 默认值,业务侧可覆盖)
| 参数 | 默认 | 含义 | 影响 |
|---|---|---|---|
| `layout_recognize` | `"DeepDOC"` | PDF 引擎选择 | DeepDOC/Plain Text/MinerU/TextLn |
| `chunk_token_num` | `512`PDF 默认)/ `128`(其他默认) | 单 chunk 最大 token | 直接影响召回粒度与上下文密度 |
| `delimiter` | `"\n!?。;!?"` | 切分分隔符(支持反引号多字符) | 细化语义边界 |
| `analyze_hyperlink` | `True` | 是否递归抓 docx/pdf 内超链接 | 显著拉长解析时间 |
| `html4excel` | `"false"` | Excel 是否走 HTML 表格输出 | 表格检索友好度 vs token 浪费 |
| `auto_keywords` | `0` | 自动关键词提取数 | 下游 prompt 注入 |
| `auto_questions` | `0` | 自动问题提取数 | QA-RAG |
| `overlapped_percent` | `0` | 滑窗重叠百分比 | 召回连续性 vs 冗余 |
### 8.2 环境变量
| 变量 | 默认 | 用途 |
|---|---|---|
| `LAYOUT_RECOGNIZER_TYPE` | `onnx` | `onnx` / `ascend` 切换 NPU |
| `HF_ENDPOINT` | — | `https://hf-mirror.com` 加速国内 HF 拉取 |
| `MINERU_EXECUTABLE` | `mineru` | MinerU CLI 路径 |
| `MINERU_APISERVER` | `http://host.docker.internal:9987` | MinerU HTTP API |
| `MINERU_BACKEND` | `pipeline` | `pipeline` / `vlm-http-client` / `vlm-transformers` / `vlm-vllm-engine` |
| `MINERU_DELETE_OUTPUT` | `1` | 是否清理临时输出 |
| `TEXTLN_APISERVER` | `https://api.textin.com/...` | TextIn 云端 |
| `TEXTLN_APP_ID/SECRET_CODE` | — | TextIn 鉴权 |
| `TIKA_SERVER_JAR` | `/tmp/tika-server.jar` | Apache Tika jar 路径 |
| `TIKA_SERVER_PORT` | `9998` | Tika JVM 端口 |
### 8.3 调用入参(`chunk()` 形参)
| 参数 | 含义 |
|---|---|
| `filename` / `binary` | 文件路径或二进制内容(推荐 binarypath 模式不支持嵌入抽取) |
| `from_page` / `to_page` | PDF 分页范围(节省内存) |
| `lang` | `"Chinese"` / `"english"`(影响 `is_english` 与表格分隔符) |
| `vision_model` | 多模态 LLM 实例图片描述、VisionParser、音视频|
| `pdf_cls` | 自定义 PDF 类,继承 `Pdf`(可重写 OCR/layout 钩子) |
| `is_root` | 内部递归标志,外部勿设 |
| `section_only` | 仅返回切分文本,不做 ES doc 包装(用于增量调试) |
## 9. 边界条件与已知限制
1. **PPT/DOC 强依赖外部组件**LibreOffice 与 Apache Tika 任一缺失都会让对应格式直接 500**没有内建兜底**。建议生产容器固化版本。
2. **`extract_embed_file` 不支持 path 模式**:仅接受 `bytes`root 调用必须传 `binary` 否则抛 `Exception``naive.py:541`)。
3. **HF 模型懒加载**:首次启动会从 HuggingFace 拉 `text_concat_xgb_v1.0` 与 OCR/layout/TSR 模型(共数百 MB冷启动慢建议 image build 阶段预热。
4. **同进程 PDF 锁**`LOCK_KEY_pdfplumber` 全局 lock 串行化 `pdfplumber.open()`**单进程内 PDF 解析无法真并发**;需要并发则起多进程或多容器。
5. **`naive_merge` 滑窗按字符不按 token**`overlapped_percent=20` 实际重叠是上一块字符串末尾 20% 字符token 数会有偏差(中文字符占 1-3 token 不等)。
6. **图片 chunk 无 `position_int`**`tokenize_chunks_with_images` 只填了 `[ii]*5`(占位),不能像 PDF chunk 那样在原图上还原坐标。
7. **`naive_merge_docx` 没有 `overlapped_percent`**docx 链路无重叠窗口(实现上漏掉了),如需重叠暂时只能改代码或者把 docx → markdown 再走 markdown 链路。
8. **JSONL 检测启发式**`is_jsonl_format` 只看前 10 行 80% 阈值,对"前几行恰好都是合法单行 JSON 但整体也是合法 JSON 数组"的边界情况会误判。
9. **Crawler 不支持 SPA**`is_static_content` 直接拒绝 `<200 chars body + >5 scripts` 的页面,没有 Playwright/Puppeteer 渲染兜底。
10. **飞书在线文档导出超时**`_export_file` 写死 `max_retries=10, poll_interval=2s`(即 20s 上限),大文档可能超时 → `FeishuAPIError("Export task did not complete...")`
## 10. 监控指标与排错指引
### 10.1 关键日志锚点(按 timer 输出)
- `__images__ dedupe_chars cost {t}s`PDF 字符抽取)
- `__images__ {N} pages cost {t}s`OCR 总耗时)
- `naive_merge({filename}): {t}`chunking 耗时)
- `OCR finished` / `Layout analysis` / `Table analysis` / `Text merged`callback 进度)
### 10.2 常见 Bug & 定位
| 现象 | 可能原因 | 定位 |
|---|---|---|
| 中文 chunk 出现乱码 | 文件编码非 UTF-8 但 `find_codec` 误判 | 在 `find_codec` 入口打日志看 `chardet.detect` 返回 |
| Excel 单元格丢失 | `ILLEGAL_CHARACTERS_RE` 把控制字符替换成空格 | `_clean_dataframe` 是不是把业务字符当成非法字符了 |
| PDF 图被截到一半 | `crop()` 计算 bottom 时跨页页高累积出错 | `pdf_parser.py:1245-1260` 检查 `page_cum_height` |
| 飞书 token 频繁刷新 | `TTLCache(ttl=7200-300)` 只缓存 1 token多并发实例每个进程独立缓存 | 接 Redis 共享缓存 |
| MinerU 报"not found" | `MINERU_EXECUTABLE` PATH 不对 | `mineru_parser.py:check_installation` 打 trace |
| chunk 数远超预期 | `chunk_token_num` 太小 + delimiter 过细 | 看 `naive_merge` 入口的两个参数 |
| 解析卡死无反应 | LibreOffice 转换卡 / Tika JVM 挂 | 检查 `convert_to_pdf` 的 120s timeout 是否触发 |
| HF 模型拉取失败 | 国内网络 | `export HF_ENDPOINT=https://hf-mirror.com` |
## 11. 优化建议与未来扩展点
### 11.1 架构改造建议(即刻收益)
1. **Loader 抽象层**:把 `WebCrawler` / `FeishuAPIClient` / `YuqueAPIClient` 统一收敛为 `BaseLoader.load() -> Iterable[LoadedDocument]` 接口,下游统一消费 `LoadedDocument(filename, binary, source_metadata)`。这样 confluence/Notion/SharePoint 接入只需新写一个 Loader不用改 `naive.py`
2. **Parser 注册表外露**`PARSERS = {...}` 当前只覆盖 PDF建议扩到 `FORMAT_PARSERS = {".docx": Docx, ".xlsx": ExcelParser, ...}`,把 `chunk()` 里的大 if-elif 链替换成 dict 查表 + 插件机制。新格式(如 epub/odt通过 `register_parser(".epub", EpubParser)` 注入。
3. **Chunking 策略策略化**:把 `naive_merge / naive_merge_docx / naive_merge_with_images / hierarchical_merge / tree_merge` 实现 `BaseChunker` 接口(`chunk(sections) -> List[Chunk]`),由 `parser_config.chunking_strategy` 选择。当前 docx 缺 `overlapped_percent` 这种"碎片化丢失"会自然消失。
4. **Token 切分而非字符切分**`naive_merge` 的滑窗用 `encoder.encode(text)[-N:]` 反解 token-level overlap避免中文字符≠token 的口径错配。
5. **共享 token 缓存**:飞书/语雀 token 改为 Redis 共享,目前每实例一份的 TTLCache 在 K8s 多副本下会触发限流。
6. **嵌入文件深度限制**`extract_embed_file` 是"only first layer",但调用方递归 `chunk(...is_root=False)` 没有 depth guard恶意文件可造成栈深递归 → 加 `max_depth=3`
7. **PDF 解析进程化**`pdfplumber` 全局锁实质单线程,对 PDF 重负载场景把 `Pdf` 包成独立 workermultiprocessing 或 ProcessPoolExecutor让 OCR/layout 跨核并行。
### 11.2 功能扩展方向
1. **多模态深整合**:现在 `VisionParser` / `picture_vision_llm_chunk` / `VisionFigureParser` 都是"图 → 描述文本 → 文本 chunk"的有损转换;可以保留 image embedding 与 text embedding 并存下游做多模态混合检索CLIP/SigLIP 与文本向量并列召回)。
2. **语义切分Semantic Chunking**:按嵌入相似度(如 `cosine(emb_i, emb_{i+1}) < 0.7` 作为切点)替代固定 token 切分,实验证明可显著提升长文档召回。`naive_merge` 已经有插槽,加一个 `chunking_strategy="semantic"` 即可。
3. **结构化字段抽取**:现在表格只做行→自然语言转换("列名:值"),没有把表格存成结构化 JSON。可在 `tokenize_table` 旁路输出 `table_data: dict`,配合 [S2-T3] 的混合搜索,用关键词字段精确过滤。
4. **缓存命中**:相同文件的解析结果(按 sha256 + parser_config hash应进缓存重新入库时跳过 OCR`extract_embed_file` 已有 `_sha10` 雏形,可扩为完整 cache key。
5. **流式 chunk 输出**:当前 `chunk()` 返回 `List`,大文件全量加载到内存;改为 `Iterable[Chunk]` + 生产者-消费者,可以让 Embedding 与 OCR 并行流水线。
6. **更细粒度的进度上报**`callback(prog, msg)` 现在是粗粒度0.1/0.6/0.8…),生产中需要展示"第几页/共多少页",建议结构化为 `callback({stage, current, total, msg})`
7. **Crawler 增量化**:当前每次全量 crawl没有 ETag/If-Modified-Since 机制;接 `last_crawl_timestamp` 让二次抓取只拉变化页。
### 11.3 与下游约定(输出契约)
本文档负责输出的 chunk 序列应包含至少:
```python
{
"docnm_kwd": str, # 文件名
"title_tks": str, # 文档标题分词(粗)
"title_sm_tks": str, # 文档标题分词(细)
"content_with_weight": str, # 原始 chunk 文本(必填)
"content_ltks": str, # 内容分词(粗)
"content_sm_ltks": str, # 内容分词(细)
"page_num_int": list[int], # 页码PDF 才有意义)
"position_int": list[tuple], # (page, x0, x1, top, bottom)
"top_int": list[int], # 行顶 y 坐标
"image": Optional[PIL.Image], # PDF/Excel 才有
"doc_type_kwd": Optional[str], # "image" 或空
}
```
[S2-T3] 索引结构应消费上述字段(参考 vdb/elasticsearch/elasticsearch_vector.py 的 mapping。[S2-T2] Embedding 应在此基础上补 `q_<dim>_vec` 列。[S2-T6] 端到端调用链路从 `app/naive.py:chunk()` 开始追踪。
---
**自检清单(对照 [S1-T1] 评分卡,预估 ≥ 80**
- ✅ 准确性:所有源码引用经 grep 与 line read 验证,路径/函数名/行号 ±3 行内
- ✅ 完整性:覆盖 Loader4 种)/ Parser11 种格式)/ Chunking8 种策略)/ Chunk 模型 / 配置项 / 限制 / 排错
- ✅ 时效性:基于 origin/main HEAD2026-05-08
- ✅ 可读性分层目录、表格、Mermaid 图、源码片段交叉
- ✅ 可执行性:环境变量、参数默认值、外部依赖列出可直接落地