# AI 知识库文档智能分块工具 — 优化方案

> 基于 `2026火山知识库用` 实际资料分析，共 12 项优化建议。

---

## 🔴 1. `.doc` 格式完全不支持

**现状：** `DocParser.supported_extensions()` 只返回 `[".docx"]`，`python-docx` 库无法读取旧版 Word 二进制格式（`.doc`）。

**影响范围：** 9 个文件无法处理：

```
培训新人起步三关.doc
招商与代理.doc
PM产品117个问与答.doc
PM产品后短暂皮肤发痒？问与答.doc
PM产品暖炉原理介绍.doc
PM产品整应反应好转反应解析.doc
PM公司各国地址是电话.doc
PM公司介绍时间更新.doc
PM公司实力介绍.doc
```

**优化方案：** 新建 `parsers/legacy_doc_parser.py`，通过 `subprocess` 调用 LibreOffice 将 `.doc` 转为 `.docx`，再复用 `DocParser` 解析。

```python
# parsers/legacy_doc_parser.py
"""旧版 Word (.doc) 解析器，通过 LibreOffice 转换后复用 DocParser"""

import os
import subprocess
import tempfile
from typing import List

from exceptions import ParseError
from parsers.base import BaseParser
from parsers.doc_parser import DocParser


class LegacyDocParser(BaseParser):
    """旧版 .doc 文件解析器，先转换为 .docx 再解析"""

    def __init__(self):
        self._docx_parser = DocParser()

    def supported_extensions(self) -> List[str]:
        return [".doc"]

    def parse(self, file_path: str) -> str:
        file_name = os.path.basename(file_path)

        with tempfile.TemporaryDirectory() as tmp_dir:
            try:
                subprocess.run(
                    [
                        "libreoffice", "--headless", "--convert-to", "docx",
                        "--outdir", tmp_dir, file_path,
                    ],
                    capture_output=True, timeout=60, check=True,
                )
            except FileNotFoundError:
                raise ParseError(file_name, "未安装 LibreOffice，无法处理 .doc 文件。"
                    "请安装: https://www.libreoffice.org/download/")
            except subprocess.TimeoutExpired:
                raise ParseError(file_name, "LibreOffice 转换超时")
            except subprocess.CalledProcessError as e:
                raise ParseError(file_name, f"LibreOffice 转换失败: {e.stderr.decode()}")

            # 找到转换后的 .docx 文件
            base_name = os.path.splitext(file_name)[0] + ".docx"
            converted_path = os.path.join(tmp_dir, base_name)

            if not os.path.exists(converted_path):
                raise ParseError(file_name, "LibreOffice 转换后未找到 .docx 文件")

            return self._docx_parser.parse(converted_path)
```

**注册到 Splitter：**

```python
# splitter.py 中新增
from parsers.legacy_doc_parser import LegacyDocParser

self._registry.register(LegacyDocParser())
```

**依赖：** 系统需安装 LibreOffice（`brew install --cask libreoffice` / `apt install libreoffice`）。

---

## 🔴 2. 不支持批量处理文件夹

**现状：** `main.py` 只接受单个文件路径，处理 25+ 文档和 80+ 图片需要逐个手动执行。

**优化方案：** 在 `main.py` 中增加 `--batch` / `-b` 参数，支持传入文件夹路径，递归扫描所有支持的文件格式并批量处理。

```python
# main.py 新增参数
parser.add_argument(
    "-b", "--batch",
    default=None,
    help="批量处理模式：指定输入文件夹路径，递归扫描所有支持的文件",
)
parser.add_argument(
    "--output-dir",
    default=None,
    help="批量模式的输出目录（默认：输入文件夹下的 output/ 子目录）",
)
```

**新增 `batch.py` 批量处理模块：**

```python
# batch.py
"""批量处理模块，递归扫描文件夹并逐个处理"""

import os
from dataclasses import dataclass, field
from typing import List, Set

from splitter import Splitter

# 所有支持的扩展名
SUPPORTED_EXTENSIONS: Set[str] = {
    ".txt", ".md", ".csv", ".html", ".htm",
    ".pdf", ".docx", ".doc",
    ".xlsx", ".xls",
    ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp",
}


@dataclass
class BatchResult:
    """批量处理结果"""
    success: List[str] = field(default_factory=list)
    failed: List[tuple] = field(default_factory=list)   # (file_path, error_msg)
    skipped: List[str] = field(default_factory=list)


def scan_files(input_dir: str) -> List[str]:
    """递归扫描文件夹，返回所有支持格式的文件路径列表"""
    files = []
    for root, _, filenames in os.walk(input_dir):
        for filename in sorted(filenames):
            ext = os.path.splitext(filename)[1].lower()
            if ext in SUPPORTED_EXTENSIONS:
                files.append(os.path.join(root, filename))
    return files


def batch_process(
    splitter: Splitter,
    input_dir: str,
    output_dir: str,
    skip_existing: bool = False,
) -> BatchResult:
    """批量处理文件夹中的所有文件"""
    result = BatchResult()
    files = scan_files(input_dir)
    total = len(files)

    os.makedirs(output_dir, exist_ok=True)

    for i, file_path in enumerate(files, start=1):
        rel_path = os.path.relpath(file_path, input_dir)
        output_path = os.path.join(
            output_dir,
            os.path.splitext(rel_path)[0] + ".md",
        )

        # 跳过已处理的文件
        if skip_existing and os.path.exists(output_path):
            result.skipped.append(file_path)
            print(f"[{i}/{total}] 跳过（已存在）: {rel_path}")
            continue

        print(f"[{i}/{total}] 正在处理: {rel_path}...")

        try:
            os.makedirs(os.path.dirname(output_path), exist_ok=True)
            splitter.process(file_path, output_path)
            result.success.append(file_path)
        except Exception as e:
            result.failed.append((file_path, str(e)))
            print(f"  ✗ 失败: {e}")

    return result
```

**使用方式：**

```bash
# 批量处理整个文件夹
python main.py -b "2026火山知识库用/" -k sk-xxx --output-dir output/

# 跳过已处理的文件（断点续传）
python main.py -b "2026火山知识库用/" -k sk-xxx --skip-existing
```

---

## 🔴 3. 批量处理无容错机制

**现状：** `main.py` 中任何异常都会 `sys.exit(1)` 直接退出，一个文件失败就全部中断。

**优化方案：** 在上面的 `batch.py` 中已包含容错逻辑（try/except 包裹单个文件处理）。额外增加处理完成后的汇总报告：

```python
# batch.py 中新增
def print_summary(result: BatchResult) -> None:
    """打印批量处理汇总报告"""
    total = len(result.success) + len(result.failed) + len(result.skipped)
    print(f"\n{'='*50}")
    print(f"处理完成! 共 {total} 个文件")
    print(f"  ✓ 成功: {len(result.success)}")
    print(f"  ✗ 失败: {len(result.failed)}")
    print(f"  ⊘ 跳过: {len(result.skipped)}")

    if result.failed:
        print(f"\n失败文件列表:")
        for path, err in result.failed:
            print(f"  - {os.path.basename(path)}: {err}")
```

---

## 🟡 4. 图片解析器没有利用文件名上下文

**现状：** `ImageParser.parse()` 只传了图片二进制数据给 Vision API，没有利用文件名中的产品名信息。

**影响：** 文件名如 `辅酶Q10.jpg`、`氨基酸.jpg` 本身就是强上下文提示，不用白不用。

**优化方案：** 修改 `ImageParser`，将文件名作为上下文传入 prompt。

```python
# parsers/image_parser.py

VISION_SYSTEM_PROMPT = (
    "请识别并提取图片中的所有文字内容，包括产品名称、成分、功效、用法用量等信息。"
    "请以结构化的方式输出。如果图片中没有文字，请详细描述图片的主要内容。"
)

def parse(self, file_path: str) -> str:
    file_name = os.path.basename(file_path)
    product_name = os.path.splitext(file_name)[0]

    # ... 读取和编码图片 ...

    # 将文件名作为上下文提示
    context_prompt = (
        f"{VISION_SYSTEM_PROMPT}\n\n"
        f"参考信息：该图片的文件名为「{product_name}」，可能与图片内容相关。"
    )

    try:
        result = self._api_client.vision(
            system_prompt=context_prompt,
            image_base64=image_base64,
        )
    except ApiError as e:
        raise ParseError(file_name, f"Vision API 调用失败: {e}")

    return result
```

---

## 🟡 5. Vision API 的 prompt 太通用

**现状：** 当前 prompt 是 `"请识别并提取图片中的所有文字内容。如果图片中没有文字，请描述图片的主要内容。"`

**影响：** 用户的图片是产品标注图（保健品、护肤品等），通用 prompt 无法引导 API 提取结构化的产品信息。

**优化方案：** 支持自定义 Vision prompt，并提供更好的默认 prompt。

```python
# parsers/image_parser.py

# 默认 prompt 优化为结构化提取
VISION_SYSTEM_PROMPT = """\
请识别并提取图片中的所有文字和关键信息。请按以下结构输出：

1. **产品/主题名称**：图片展示的主要产品或主题
2. **文字内容**：图片中所有可见的文字，保持原始排版
3. **关键信息**：成分、功效、用法用量、规格、价格等结构化信息
4. **图片描述**：简要描述图片的视觉内容（产品外观、包装等）

如果某项信息不存在，可以省略该项。"""


class ImageParser(BaseParser):
    def __init__(self, api_client: ApiClient, vision_prompt: str = None):
        self._api_client = api_client
        self._vision_prompt = vision_prompt or VISION_SYSTEM_PROMPT
```

**在 CLI 中增加参数：**

```python
# main.py
parser.add_argument(
    "--vision-prompt",
    default=None,
    help="自定义图片识别的 system prompt",
)
```

---

## 🟡 6. 图片 MIME 类型硬编码为 `image/png`

**现状：** `api_client.py` 的 `vision()` 方法中写死了 `data:image/png;base64,...`。

**影响：** 用户的图片全是 `.jpg`/`.jpeg`，虽然大多数 API 能容错处理，但不规范，可能导致某些模型解析异常。

**优化方案：** 让 `vision()` 接受 MIME 类型参数，由 `ImageParser` 根据扩展名传入。

```python
# api_client.py
EXTENSION_MIME_MAP = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".bmp": "image/bmp",
    ".webp": "image/webp",
}

def vision(self, system_prompt: str, image_base64: str,
           mime_type: str = "image/png",   # 新增参数
           model: str = "deepseek-chat") -> str:
    def _call():
        response = self._client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": system_prompt},
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": f"data:{mime_type};base64,{image_base64}",
                            },
                        },
                    ],
                },
            ],
        )
        return response.choices[0].message.content
    return self._retry(_call)
```

```python
# parsers/image_parser.py 调用时传入正确的 MIME 类型
from api_client import EXTENSION_MIME_MAP

ext = os.path.splitext(file_path)[1].lower()
mime_type = EXTENSION_MIME_MAP.get(ext, "image/png")

result = self._api_client.vision(
    system_prompt=context_prompt,
    image_base64=image_base64,
    mime_type=mime_type,
)
```

---

## 🟡 7. `writer.py` 没有自动创建输出目录

**现状：** 如果输出路径的父目录不存在，`open()` 会抛出 `FileNotFoundError`。

**优化方案：** 在 `write()` 方法开头加一行：

```python
# writer.py
def write(self, chunks, output_path, source_file, delimiter="---"):
    # 自动创建输出目录
    output_dir = os.path.dirname(output_path)
    if output_dir:
        os.makedirs(output_dir, exist_ok=True)

    # ... 后续逻辑不变 ...
```

改动极小，但能避免批量处理时因目录不存在而失败。

---

## 🟢 8. 缺少处理进度展示

**现状：** `splitter.py` 只打印简单的文字信息，批量处理时无法直观看到整体进度。

**优化方案：** 在 `batch.py` 中已包含 `[i/total]` 格式的进度展示（见第 2 项）。对于单文件处理，可以在 `splitter.py` 中增加更详细的阶段提示：

```python
# splitter.py
def process(self, input_path: str, output_path: str) -> None:
    file_name = os.path.basename(input_path)
    file_size = os.path.getsize(input_path)
    size_str = f"{file_size / 1024:.1f}KB" if file_size < 1024 * 1024 else f"{file_size / 1024 / 1024:.1f}MB"

    print(f"[1/4] 解析文件: {file_name} ({size_str})")
    text = parser.parse(input_path)
    print(f"       提取文本: {len(text)} 字符")

    print(f"[2/4] AI 语义分块中...")
    chunks = self._chunker.chunk(text, on_progress=progress_callback)

    print(f"[3/4] 写入输出: {output_path}")
    self._writer.write(chunks, output_path, source_file, self._delimiter)

    print(f"[4/4] 完成! 共 {len(chunks)} 个分块")
```

---

## 🟢 9. 没有断点续传 / 跳过已处理文件

**现状：** 重新运行会重复处理所有文件，包括重复调用 API 产生费用。

**优化方案：** 在 `main.py` 中增加 `--skip-existing` 参数（已在第 2 项的 `batch.py` 中实现）。

```python
# main.py
parser.add_argument(
    "--skip-existing",
    action="store_true",
    default=False,
    help="跳过已存在的输出文件（避免重复处理和 API 费用）",
)
```

**单文件模式也应支持：**

```python
# main.py 的 main() 函数中
if args.skip_existing and os.path.exists(output_path):
    print(f"输出文件已存在，跳过: {output_path}")
    return
```

---

## 🟢 10. `_retry` 没有捕获网络异常

**现状：** `api_client.py` 的重试逻辑只处理 `openai.RateLimitError` 和 `openai.APIError`，不处理网络层异常。

**影响：** 批量处理 80+ 张图片时，网络超时、连接中断等问题很常见，当前会直接抛异常中断。

**优化方案：** 在 `_retry` 中增加对网络异常的重试：

```python
# api_client.py
def _retry(self, call: Callable[[], str]) -> str:
    """执行带指数退避重试的 API 调用，对速率限制和网络异常重试"""
    for attempt in range(self.MAX_RETRIES + 1):
        try:
            return call()
        except openai.RateLimitError:
            if attempt < self.MAX_RETRIES:
                self._sleep(self.RETRY_DELAYS[attempt])
            else:
                raise ApiError("速率限制重试耗尽", status_code=429)
        except openai.APIConnectionError:
            # 网络连接失败，重试
            if attempt < self.MAX_RETRIES:
                self._sleep(self.RETRY_DELAYS[attempt])
            else:
                raise ApiError("网络连接失败，重试耗尽")
        except openai.APITimeoutError:
            # 请求超时，重试
            if attempt < self.MAX_RETRIES:
                self._sleep(self.RETRY_DELAYS[attempt])
            else:
                raise ApiError("API 请求超时，重试耗尽")
        except openai.APIError as e:
            raise ApiError(str(e), status_code=getattr(e, "status_code", None))
```

---

## 🟢 11. 中文分块的 `PRE_SPLIT_SIZE` 可能偏小

**现状：** `chunker.py` 的 `PRE_SPLIT_SIZE = 8000` 字符。中文一个字就是一个字符，8000 字符 ≈ 8000 字。

**影响：** 像 `PM产品117个问与答.doc` 这种长文档，问答对可能被切到不同段中，破坏语义完整性。

**优化方案：** 将 `PRE_SPLIT_SIZE` 改为可配置参数，并适当增大默认值：

```python
# chunker.py
class AIChunker:
    DEFAULT_PRE_SPLIT_SIZE = 12000  # 增大到 12000，更适合中文文档

    def __init__(self, api_client: ApiClient, delimiter: str = "---",
                 pre_split_size: int = None):
        self._api_client = api_client
        self._delimiter = delimiter
        self.PRE_SPLIT_SIZE = pre_split_size or self.DEFAULT_PRE_SPLIT_SIZE
```

```python
# main.py 新增参数
parser.add_argument(
    "--chunk-size",
    type=int,
    default=None,
    help="预切分大小（字符数），默认 12000。中文文档建议 10000-15000",
)
```

---

## 🟢 12. 缺少输出格式选项

**现状：** 只能输出 Markdown 格式。

**影响：** 知识库场景通常需要将分块结果导入向量数据库（如 Milvus、Pinecone、Weaviate），JSON 格式更方便。

**优化方案：** 新增 `JsonWriter`，支持 `--format` 参数选择输出格式。

```python
# writer.py 新增 JsonWriter
import json

class JsonWriter:
    """将 Chunk 列表写入 JSON 文件，方便导入向量数据库"""

    def write(
        self,
        chunks: List[Chunk],
        output_path: str,
        source_file: str,
        delimiter: str = "---",
    ) -> None:
        output_dir = os.path.dirname(output_path)
        if output_dir:
            os.makedirs(output_dir, exist_ok=True)

        # 将扩展名改为 .json
        json_path = os.path.splitext(output_path)[0] + ".json"

        data = {
            "source_file": source_file,
            "process_time": datetime.now().isoformat(),
            "total_chunks": len(chunks),
            "chunks": [
                {
                    "index": i,
                    "title": chunk.title,
                    "content": chunk.content,
                    "char_count": len(chunk.content),
                }
                for i, chunk in enumerate(chunks)
            ],
        }

        if os.path.exists(json_path):
            print(f"警告: 输出文件已存在，将覆盖: {json_path}")

        with open(json_path, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
```

```python
# main.py 新增参数
parser.add_argument(
    "-f", "--format",
    choices=["markdown", "json"],
    default="markdown",
    help="输出格式（默认: markdown）",
)
```

---

## 优化优先级总览

| 优先级 | 编号 | 问题 | 涉及文件 | 工作量 |
|--------|------|------|----------|--------|
| 🔴 P0 | 1 | `.doc` 格式不支持 | 新建 `legacy_doc_parser.py`，改 `splitter.py` | 中 |
| 🔴 P0 | 2 | 不支持批量处理 | 新建 `batch.py`，改 `main.py` | 中 |
| 🔴 P0 | 3 | 无容错机制 | `batch.py`（同上） | 小 |
| 🟡 P1 | 4 | 图片文件名未利用 | `parsers/image_parser.py` | 小 |
| 🟡 P1 | 5 | Vision prompt 太通用 | `parsers/image_parser.py` | 小 |
| 🟡 P1 | 6 | MIME 类型硬编码 | `api_client.py`，`parsers/image_parser.py` | 小 |
| 🟡 P1 | 7 | 输出目录不自动创建 | `writer.py` | 极小 |
| 🟢 P2 | 8 | 缺少进度展示 | `splitter.py` | 小 |
| 🟢 P2 | 9 | 无断点续传 | `main.py` | 小 |
| 🟢 P2 | 10 | 网络异常不重试 | `api_client.py` | 小 |
| 🟢 P2 | 11 | PRE_SPLIT_SIZE 偏小 | `chunker.py`，`main.py` | 小 |
| 🟢 P2 | 12 | 缺少 JSON 输出 | `writer.py`，`main.py` | 中 |

---

## 建议实施顺序

1. **第一阶段（核心功能）：** #1 + #2 + #3 → 解决"能不能用"的问题
2. **第二阶段（质量提升）：** #4 + #5 + #6 + #7 → 提升处理质量
3. **第三阶段（体验优化）：** #8 + #9 + #10 + #11 + #12 → 提升使用体验
