Files
bigwo/OPTIMIZATION.md
2026-03-02 17:38:28 +08:00

19 KiB
Raw Permalink Blame History

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 解析。

# 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

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

self._registry.register(LegacyDocParser())

依赖: 系统需安装 LibreOfficebrew install --cask libreoffice / apt install libreoffice)。


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

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

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

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

新增 batch.py 批量处理模块:

# 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

使用方式:

# 批量处理整个文件夹
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 包裹单个文件处理)。额外增加处理完成后的汇总报告:

# 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。

# 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。

# 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 中增加参数:

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

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

现状: api_client.pyvision() 方法中写死了 data:image/png;base64,...

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

优化方案:vision() 接受 MIME 类型参数,由 ImageParser 根据扩展名传入。

# 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)
# 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() 方法开头加一行:

# 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 中增加更详细的阶段提示:

# 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 中实现)。

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

单文件模式也应支持:

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

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

现状: api_client.py 的重试逻辑只处理 openai.RateLimitErroropenai.APIError,不处理网络层异常。

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

优化方案:_retry 中增加对网络异常的重试:

# 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.pyPRE_SPLIT_SIZE = 8000 字符。中文一个字就是一个字符8000 字符 ≈ 8000 字。

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

优化方案:PRE_SPLIT_SIZE 改为可配置参数,并适当增大默认值:

# 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
# main.py 新增参数
parser.add_argument(
    "--chunk-size",
    type=int,
    default=None,
    help="预切分大小(字符数),默认 12000。中文文档建议 10000-15000",
)

🟢 12. 缺少输出格式选项

现状: 只能输出 Markdown 格式。

影响: 知识库场景通常需要将分块结果导入向量数据库(如 Milvus、Pinecone、WeaviateJSON 格式更方便。

优化方案: 新增 JsonWriter,支持 --format 参数选择输出格式。

# 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)
# 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.pyparsers/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.pymain.py
🟢 P2 12 缺少 JSON 输出 writer.pymain.py

建议实施顺序

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