上一篇我们已经基本实现了一个有记忆,有通过向量的RAG的智能体,但也只是实验室级别的,距离真正的生产标准还有一定的距离。而且我们V1版本主要以实验为主,很多代码耦合度较高,没有细化分类,所以我们本篇会根据开闭原则(对扩展开发,对修改关闭)重构项目。     

        下面我们重新创建一个项目DocMind,这次不添加版本号了,因为重构之后的代码,我们会按需放入项目中,而且是新增为主,通过新增代码来区分功能:

        1.创新新项目DocMind,使用uv环境,创建完成后将 pyproject.toml 修改如下:

[project]
name = "docmind"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.14"
dependencies = [
    "chromadb>=1.5.9",
    "langchain>=1.3.13",
    "langchain-chroma>=1.1.0",
    "langchain-community>=0.4.2",
    "langchain-ollama>=1.1.0",
    "langchain-openai>=1.3.5",
    "langchain-text-splitters>=1.1.2",
    "openpyxl>=3.1.5",
    "pydantic>=2.13.4",
    "pypdf>=6.14.2",
    "python-docx>=1.2.0",
    "python-dotenv>=1.2.2",
]

[[tool.uv.index]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
default = true

        以上都是我们之前项目的依赖,修改完成后,终端执行,重新安装依赖:

uv sync

        还要添加一些本篇需要的依赖,终端执行:

uv add pymupdf4llm

        2.创建目录

New-Item -ItemType File -Path core/__init__.py, parsers/__init__.py, cleaners/__init__.py, chunkers/__init__.py, enhancers/__init__.py, stores/__init__.py, tools/__init__.py -Force

        如果没有这一堆 __init__.py,主流程代码就需要把所有文件堆在根目录,或者用非常丑陋的强行导入——那样项目会变成一团乱麻。有了它们,各个阶段才能像乐高积木一样,通过清晰的 import 互相组装。

        3. 根目录创建 .env 文件

# DeepSeek API
DEEPSEEK_API_KEY=sk-你的DeepSeek密钥
DEEPSEEK_BASE_URL=https://api.deepseek.com
LLM_MODEL=deepseek-v4-flash

# Ollama 远程服务器(嵌入向量模型)
OLLAMA_BASE_URL=http://ollama服务IP:11434
EMBEDDING_MODEL=qwen3-embedding:4b

# 分块参数
CHUNK_SIZE=500
CHUNK_OVERLAP=50

        4.根目录:embeddings.py(兼容层)

# @File  :  embeddings.py
"""
V1.5 嵌入模块(兼容层)
供 stores/vector_store.py 调用
"""

from langchain_ollama import OllamaEmbeddings
from core.config import Config


def get_ollama_embeddings():
    """连接远程 Ollama 服务器"""
    print(f"🔗 连接远程 Ollama: {Config.OLLAMA_BASE_URL}")
    return OllamaEmbeddings(
        model=Config.EMBEDDING_MODEL,
        base_url=Config.OLLAMA_BASE_URL,
    )

        5.core/__init__.py

# @file : core/__init__.py
"""
核心基础设施层
导出所有核心模块
"""

from core.config import Config
from core.models import DocumentChunk, ChunkWithEmbedding
from core.http_client import LoggingHttpClient

__all__ = [
    "Config",
    "DocumentChunk",
    "ChunkWithEmbedding",
    "LoggingHttpClient",
]

        6. core/config.py

# @file:core/config.py
import os
from dotenv import load_dotenv

load_dotenv()


class Config:
    """集中管理所有配置项"""

    DEEPSEEK_API_KEY: str = os.getenv("DEEPSEEK_API_KEY", "")
    DEEPSEEK_BASE_URL: str = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
    LLM_MODEL: str = os.getenv("LLM_MODEL", "deepseek-v4-flash")

    OLLAMA_BASE_URL: str = os.getenv("OLLAMA_BASE_URL", "http://192.168.0.2:11434")
    EMBEDDING_MODEL: str = os.getenv("EMBEDDING_MODEL", "qwen3-embedding")

    CHUNK_SIZE: int = int(os.getenv("CHUNK_SIZE", "500"))
    CHUNK_OVERLAP: int = int(os.getenv("CHUNK_OVERLAP", "50"))

    @classmethod
    def validate(cls):
        if not cls.DEEPSEEK_API_KEY:
            raise ValueError("请在 .env 文件中设置 DEEPSEEK_API_KEY")
        print(f"✅ 配置验证通过,Ollama 地址: {cls.OLLAMA_BASE_URL}")

        7.core/models.py

# @file : core/models.py
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field


class DocumentChunk(BaseModel):
    content: str = Field(description="切片文本内容")
    source_file: str = Field(description="来源文件路径")
    page_num: Optional[int] = Field(default=None, description="页码或行号")
    section_path: Optional[str] = Field(default=None, description="章节层级路径")
    chunk_type: str = Field(default="paragraph", description="内容类型")
    original_length: int = Field(default=0, description="清洗前字符数")
    clean_length: int = Field(default=0, description="清洗后字符数")
    parent_id: Optional[str] = Field(default=None, description="父级切片ID")
    child_ids: List[str] = Field(default_factory=list, description="子级切片ID列表")
    generated_questions: List[str] = Field(default_factory=list, description="Q2Q预生成问题")
    tags: List[str] = Field(default_factory=list, description="关键词标签")
    summary: Optional[str] = Field(default=None, description="一句话摘要")
    entities: List[str] = Field(default_factory=list, description="命名实体列表")


class ChunkWithEmbedding(BaseModel):
    chunk: DocumentChunk
    embedding: List[float]
    metadata: Dict[str, Any]

        8. core/models.py

# @file : core/models.py
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field


class DocumentChunk(BaseModel):
    content: str = Field(description="切片文本内容")
    source_file: str = Field(description="来源文件路径")
    page_num: Optional[int] = Field(default=None, description="页码或行号")
    section_path: Optional[str] = Field(default=None, description="章节层级路径")
    chunk_type: str = Field(default="paragraph", description="内容类型")
    original_length: int = Field(default=0, description="清洗前字符数")
    clean_length: int = Field(default=0, description="清洗后字符数")
    parent_id: Optional[str] = Field(default=None, description="父级切片ID")
    child_ids: List[str] = Field(default_factory=list, description="子级切片ID列表")
    generated_questions: List[str] = Field(default_factory=list, description="Q2Q预生成问题")
    tags: List[str] = Field(default_factory=list, description="关键词标签")
    summary: Optional[str] = Field(default=None, description="一句话摘要")
    entities: List[str] = Field(default_factory=list, description="命名实体列表")


class ChunkWithEmbedding(BaseModel):
    chunk: DocumentChunk
    embedding: List[float]
    metadata: Dict[str, Any]

        9.core/http_client.py

# @file : core/http_client.py
import httpx
import json


class LoggingHttpClient(httpx.Client):
    def send(self, request, *args, **kwargs):
        print(f"🔗 请求 URL: {request.url}")
        if request.content:
            try:
                body_json = json.loads(request.content)
                print("📤 请求体 (JSON):")
                print(json.dumps(body_json, indent=2, ensure_ascii=False))
            except:
                print("📤 请求体 (文本):", request.content.decode())
        return super().send(request, *args, **kwargs)

        10.parsers/__init__.py

# @file : parsers/__init__.py
from parsers.base import load_documents
from parsers.pdf_parser import parse_pdf
from parsers.docx_parser import parse_docx
from parsers.excel_parser import parse_excel

__all__ = ["load_documents", "parse_pdf", "parse_docx", "parse_excel"]

        11.parsers/base.py

# @file : parsers/base.py
import os
from typing import List
from core.models import DocumentChunk
from parsers.pdf_parser import parse_pdf
from parsers.docx_parser import parse_docx
from parsers.excel_parser import parse_excel


def load_documents(file_path: str) -> List[DocumentChunk]:
    ext = os.path.splitext(file_path)[1].lower()
    if ext == ".pdf":
        return parse_pdf(file_path)
    elif ext == ".docx":
        return parse_docx(file_path)
    elif ext == ".xlsx":
        return parse_excel(file_path)
    else:
        raise ValueError(f"不支持的文件格式: {ext}")

        12. parsers/pdf_parser.py

# @file : pdf_parser
# 导入操作系统接口模块,用于文件和路径操作
import os
# 导入正则表达式模块,用于文本匹配与替换
import re
# 从 typing 模块导入 List 类型,用于类型提示
from typing import List
# 导入 PyMuPDF4LLM 库,用于将 PDF 转换为 Markdown 文本
import pymupdf4llm
# 从核心模型模块导入 DocumentChunk 数据类
from core.models import DocumentChunk
# 从文本清洗模块导入 clean_noise 清理函数
from cleaners.text_cleaner import clean_noise


# 定义解析 PDF 文件的函数,接收文件路径,返回 DocumentChunk 列表
def parse_pdf(file_path: str) -> List[DocumentChunk]:
    # 初始化一个空列表,用于存储所有的文档块
    chunks = []
    # 调用 pymupdf4llm 将 PDF 内容转换为 Markdown 格式的字符串 (只能还是文本转换,不能ocr识别)
    md_text = pymupdf4llm.to_markdown(file_path)
    # 按两个换行符分割 Markdown 文本,得到内容块列表(段落、表格、标题等)
    blocks = md_text.split("\n\n")
    # 初始化页码计数器,后续用于估算每个块所在的页码
    page_num = 1
    # 初始化当前章节标题变量,用于记录最近遇到的标题
    current_section = ""

    # 遍历所有内容块,i 为索引,block 为块内容
    for i, block in enumerate(blocks):
        # 如果块为空或只包含空白字符,则跳过该块
        if not block or not block.strip():
            continue
        # 调用 clean_noise 清洗当前块,去除噪声(多余空格、特殊符号等)
        clean_block = clean_noise(block)
        # 如果清洗后的内容为空,则跳过该块
        if not clean_block:
            continue

        # 默认设置块类型为普通段落
        chunk_type = "paragraph"
        # 如果清洗内容以竖线开头且包含 "---",则判定为表格
        if clean_block.startswith("|") and "---" in clean_block:
            # 将块类型设为表格
            chunk_type = "table"
        # 否则如果清洗内容以井号开头,则判定为标题
        elif clean_block.startswith("#"):
            # 将块类型设为标题
            chunk_type = "heading"
            # 使用正则去除开头的井号和空格,提取纯标题文本,并更新当前章节
            current_section = re.sub(r'^#+\s*', '', clean_block).strip()

        # 创建 DocumentChunk 对象,封装当前块的所有信息
        chunk = DocumentChunk(
            # 清洗后的文本内容
            content=clean_block,
            # 源文件名(从路径中提取)
            source_file=os.path.basename(file_path),
            # 估算页码(每 5 个块大致递增 1 页)
            page_num=page_num + (i // 5),
            # 如果是标题块则不记录章节路径,否则记录当前章节
            section_path=current_section if chunk_type != "heading" else None,
            # 块类型(paragraph / table / heading)
            chunk_type=chunk_type,
            # 原始块的字符长度(清洗前)
            original_length=len(block),
            # 清洗后块的字符长度
            clean_length=len(clean_block)
        )
        # 将构造好的块对象添加到结果列表中
        chunks.append(chunk)

    # 返回包含所有文档块的列表
    return chunks

        13.parsers/docx_parser.py

# @file : parsers/docx_parser.py

# 导入操作系统接口模块,用于文件路径操作(如提取文件名)
import os
# 从 typing 模块导入 List 类型,用于函数返回值的类型提示
from typing import List
# 从 python-docx 库导入 Document 类,并重命名为 DocxDocument,用于操作 .docx 文件
from docx import Document as DocxDocument
# 从核心模型模块导入 DocumentChunk 数据类,用于封装文档块
from core.models import DocumentChunk
# 从文本清洗模块导入 clean_noise 函数,用于清除文本中的噪声
from cleaners.text_cleaner import clean_noise


# 定义解析 DOCX 文件的函数,接收文件路径,返回 DocumentChunk 列表
def parse_docx(file_path: str) -> List[DocumentChunk]:
    # 初始化一个空列表,用于存储所有的文档块
    chunks = []
    # 使用 python-docx 打开并加载指定的 DOCX 文件
    doc = DocxDocument(file_path)
    # 初始化当前章节标题变量,用于跟踪最近的标题
    current_section = ""

    # 遍历文档中的所有段落(paragraphs 是段落对象列表)
    for para in doc.paragraphs:
        # 获取段落文本并去除首尾空白字符
        text = para.text.strip()
        # 如果段落文本为空(或仅空白),则跳过该段落
        if not text:
            continue
        # 调用 clean_noise 函数清洗当前段落文本,去除噪声
        clean_text = clean_noise(text)
        # 如果清洗后的文本为空,则跳过该段落
        if not clean_text:
            continue

        # 判断块类型:如果清洗后文本长度小于20且不含句号,则视为标题,否则为段落
        chunk_type = "heading" if (len(clean_text) < 20 and "。" not in clean_text) else "paragraph"
        # 如果当前块是标题,则更新当前章节标题为该清洗后文本
        if chunk_type == "heading":
            current_section = clean_text

        # 创建 DocumentChunk 对象,封装当前段落的所有信息
        chunk = DocumentChunk(
            # 清洗后的文本内容
            content=clean_text,
            # 源文件名(从文件路径中提取)
            source_file=os.path.basename(file_path),
            # 如果是标题块则不记录章节路径,否则记录当前章节标题
            section_path=current_section if chunk_type != "heading" else None,
            # 块类型(heading 或 paragraph)
            chunk_type=chunk_type,
            # 原始段落的字符长度(清洗前)
            original_length=len(text),
            # 清洗后段落的字符长度
            clean_length=len(clean_text)
        )
        # 将构造好的块对象添加到结果列表中
        chunks.append(chunk)

    # 返回包含所有文档块的列表
    return chunks

        14. excel_parser

# @file : parsers/excel_parser.py
# 导入操作系统接口模块,用于文件和路径操作(如提取文件名)
import os
# 从 typing 模块导入 List 类型,用于函数返回值的类型提示
from typing import List
# 从 openpyxl 库导入 load_workbook 函数,用于加载 Excel 工作簿
from openpyxl import load_workbook
# 从核心模型模块导入 DocumentChunk 数据类,用于封装文档块
from core.models import DocumentChunk
# 从文本清洗模块导入 clean_noise 函数,用于清除文本中的噪声
from cleaners.text_cleaner import clean_noise


# 定义解析 Excel 文件的函数,接收文件路径,返回 DocumentChunk 列表
def parse_excel(file_path: str) -> List[DocumentChunk]:
    # 初始化一个空列表,用于存储所有的文档块
    chunks = []
    # 使用 openpyxl 以只读模式加载指定的 Excel 工作簿(read_only=True 可优化内存占用)
    wb = load_workbook(file_path, read_only=True)

    # 遍历工作簿中的每一个工作表(worksheets 属性返回所有工作表对象)
    for sheet in wb.worksheets:
        # 获取当前工作表的名称
        sheet_name = sheet.title
        # 初始化行号计数器(从0开始,后续在循环开始时递增)
        row_num = 0
        # 遍历当前工作表的每一行,values_only=True 表示只获取单元格的值(不包含样式等)
        for row in sheet.iter_rows(values_only=True):
            # 行号递增(从1开始计数)
            row_num += 1
            # 将当前行的所有非空单元格转换为字符串,并组成列表
            cells = [str(cell) for cell in row if cell is not None]
            # 如果该行所有单元格均为空,则跳过该行
            if not cells:
                continue
            # 将单元格列表格式化为 Markdown 表格的一行,如 "| 值1 | 值2 | 值3 |"
            table_row = "| " + " | ".join(cells) + " |"
            # 调用 clean_noise 清洗当前表格行文本,去除噪声
            clean_text = clean_noise(table_row)
            # 如果清洗后的文本为空,则跳过该行
            if not clean_text:
                continue
            # 创建 DocumentChunk 对象,封装当前 Excel 行的所有信息
            chunk = DocumentChunk(
                # 清洗后的文本内容(表格行格式)
                content=clean_text,
                # 源文件名(从文件路径中提取)
                source_file=os.path.basename(file_path),
                # 页码/行号:此处以行号作为类似页码的标识(便于追踪位置)
                page_num=row_num,
                # 章节路径:记录当前工作表名称,便于溯源
                section_path=f"Sheet: {sheet_name}",
                # 块类型固定为表格(因为 Excel 数据以表格行呈现)
                chunk_type="table",
                # 原始表格行的字符长度(清洗前)
                original_length=len(table_row),
                # 清洗后文本的字符长度
                clean_length=len(clean_text)
            )
            # 将构造好的块对象添加到结果列表中
            chunks.append(chunk)

    # 返回包含所有文档块(每行对应一个块)的列表
    return chunks

        15.cleaners/__init__.py

# @file : cleaners/__init__.py
from cleaners.text_cleaner import clean_noise
__all__ = ["clean_noise"]

        16.cleaners/text_cleaner.py

# @file : cleaners/text_cleaner.py
# 导入正则表达式模块,用于文本模式匹配与替换
import re

# 定义清洗噪声的函数,接收原始文本字符串,返回清洗后的字符串
def clean_noise(text: str) -> str:
    # 使用正则替换掉页码标记,如 "第 1 页 / 共 10 页" 或 "第1页/共10页"(允许空格和斜杠变体)
    text = re.sub(r'第\s*\d+\s*页\s*[/|/]\s*共\s*\d+\s*页', '', text)
    # 使用正则替换掉英文页码标记,如 "Page 1 of 10"(不区分大小写)
    text = re.sub(r'Page\s*\d+\s*of\s*\d+', '', text, flags=re.IGNORECASE)
    # 将多个连续换行符(可能中间夹有空白)替换为单个换行符,以规范化段落间距
    text = re.sub(r'\n\s*\n', '\n', text)
    # 返回去除首尾空白字符后的文本
    return text.strip()

        17.chunkers/__init__.py

# @file : chunkers/__init__.py
from chunkers.chunker_factory import split_chunk
from chunkers.semantic_chunker import SemanticChunker
from chunkers.hierarchical_chunker import HierarchicalChunker

__all__ = ["split_chunk", "SemanticChunker", "HierarchicalChunker"]

        18. chunkers/semantic_chunker.py

# @file : chunkers/semantic_chunker.py
# 从 typing 模块导入 List 类型,用于类型提示
from typing import List
# 从 langchain_text_splitters 导入递归字符文本分割器,用于按语义切分长文本
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 从核心配置模块导入 Config,获取分块大小和重叠等配置参数
from core.config import Config
# 从核心模型模块导入 DocumentChunk 数据类,用于封装文档块
from core.models import DocumentChunk


# 定义语义分块器类,负责将过长的文档块拆分为更小的子块
class SemanticChunker:
    # 定义类的初始化方法,在创建实例时调用
    def __init__(self):
        # 创建一个 RecursiveCharacterTextSplitter 实例并赋值给实例变量 splitter
        self.splitter = RecursiveCharacterTextSplitter(
            # 从配置中获取每个块的最大字符数
            chunk_size=Config.CHUNK_SIZE,
            # 从配置中获取块之间重叠的字符数
            chunk_overlap=Config.CHUNK_OVERLAP,
            # 设置分割优先级:优先按双换行、单换行、句号、感叹号、问号、分号、逗号、空格、字符依次切分
            separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
        )

    # 定义拆分方法,接收一个 DocumentChunk 对象,返回拆分后的 DocumentChunk 列表
    def split(self, chunk: DocumentChunk) -> List[DocumentChunk]:
        # 如果当前块的内容长度不超过配置的最大块大小,则无需拆分,直接返回包含原块的列表
        if len(chunk.content) <= Config.CHUNK_SIZE:
            return [chunk]
        # 调用分割器的 split_text 方法,将内容按分隔符切分为多个子文本片段
        sub_texts = self.splitter.split_text(chunk.content)
        # 初始化空列表,用于存储生成的子块对象
        children = []
        # 遍历每个子文本片段
        for sub_text in sub_texts:
            # 为每个子片段创建一个新的 DocumentChunk 对象,并添加到 children 列表中
            children.append(DocumentChunk(
                # 子块的内容为切分后的子文本
                content=sub_text,
                # 继承原块的源文件名
                source_file=chunk.source_file,
                # 继承原块的页码
                page_num=chunk.page_num,
                # 继承原块的章节路径
                section_path=chunk.section_path,
                # 继承原块的块类型
                chunk_type=chunk.chunk_type,
                # 记录父块 ID:如果原块有 parent_id 则使用,否则使用原块的内存地址作为父 ID(用于追踪层级关系)
                parent_id=chunk.parent_id or str(id(chunk)),
                # 记录子块的原始长度(切分后的长度)
                original_length=len(sub_text),
                # 记录子块清洗后的长度(去除首尾空白后的长度)
                clean_length=len(sub_text.strip())
            ))
        # 返回拆分后的所有子块列表
        return children

        19.chunkers/hierarchical_chunker.py

# @fiel : chunkers/hierarchical_chunker.py
# 从 typing 模块导入 List 类型,用于函数返回值的类型提示
from typing import List
# 从 langchain_text_splitters 导入 Markdown 标题分割器,用于按标题层级切分 Markdown 文档
from langchain_text_splitters import MarkdownHeaderTextSplitter
# 从核心模型模块导入 DocumentChunk 数据类,用于封装文档块
from core.models import DocumentChunk


# 定义层级分块器类,负责按 Markdown 标题层级将文档块拆分为带有层级结构的子块
class HierarchicalChunker:
    # 定义类的初始化方法,在创建实例时调用
    def __init__(self):
        # 设置要分割的 Markdown 标题层级,元组列表包含(标题标记,对应层级名称)
        self.headers_to_split_on = [("#", "h1"), ("##", "h2"), ("###", "h3"), ("####", "h4")]
        # 创建 MarkdownHeaderTextSplitter 实例并赋值给实例变量 splitter
        self.splitter = MarkdownHeaderTextSplitter(
            # 传入要分割的标题层级配置
            headers_to_split_on=self.headers_to_split_on,
            # 设置保留标题文本在内容中(不剥离),便于后续使用
            strip_headers=False
        )

    # 定义拆分方法,接收一个 DocumentChunk 对象,返回拆分后的 DocumentChunk 列表
    def split(self, chunk: DocumentChunk) -> List[DocumentChunk]:
        # 调用分割器的 split_text 方法,按标题将内容切分为多个带元数据的文档片段
        md_splits = self.splitter.split_text(chunk.content)
        # 初始化空列表,用于存储生成的子块对象
        children = []
        # 遍历每个分割后的文档片段
        for md_doc in md_splits:
            # 获取当前片段的元数据(包含标题层级信息)
            metadata = md_doc.metadata
            # 从元数据中提取所有非空的标题值,用 " > " 连接组成层级路径
            section_path = " > ".join([v for k, v in metadata.items() if v])
            # 为每个片段创建一个新的 DocumentChunk 对象,并添加到 children 列表中
            children.append(DocumentChunk(
                # 子块的内容为片段的主体文本
                content=md_doc.page_content,
                # 继承原块的源文件名
                source_file=chunk.source_file,
                # 继承原块的页码
                page_num=chunk.page_num,
                # 如果有元数据则使用生成的层级路径,否则继承原块的章节路径
                section_path=section_path or chunk.section_path,
                # 如果元数据存在(即该片段包含标题)则类型为 heading,否则继承原块类型
                chunk_type="heading" if metadata else chunk.chunk_type,
                # 记录父块 ID:如果原块有 parent_id 则使用,否则使用原块的内存地址
                parent_id=chunk.parent_id or str(id(chunk)),
                # 记录子块的原始长度(注意这里用了原块内容长度,可能需修正,但按原代码保留)
                original_length=len(chunk.content),
                # 记录子块清洗后的长度(即当前片段的长度)
                clean_length=len(md_doc.page_content)
            ))
        # 返回拆分后的所有子块列表
        return children

        20.chunkers/chunker_factory.py

# @file : chunkers/chunker_factory.py
# 从 typing 模块导入 List 类型,用于函数返回值的类型提示
from typing import List
# 从核心模型模块导入 DocumentChunk 数据类,用于封装文档块
from core.models import DocumentChunk
# 从语义分块器模块导入 SemanticChunker 类,用于按语义切分长文本
from chunkers.semantic_chunker import SemanticChunker
# 从层级分块器模块导入 HierarchicalChunker 类,用于按 Markdown 标题层级切分
from chunkers.hierarchical_chunker import HierarchicalChunker

# 初始化全局变量 _semantic 为 None,用于缓存语义分块器单例
_semantic = None
# 初始化全局变量 _hierarchical 为 None,用于缓存层级分块器单例
_hierarchical = None

# 定义获取语义分块器实例的函数,实现单例模式
def get_semantic():
    # 声明使用全局变量 _semantic
    global _semantic
    # 如果 _semantic 尚未初始化,则创建一个新的 SemanticChunker 实例
    if _semantic is None:
        _semantic = SemanticChunker()
    # 返回语义分块器实例
    return _semantic

# 定义获取层级分块器实例的函数,实现单例模式
def get_hierarchical():
    # 声明使用全局变量 _hierarchical
    global _hierarchical
    # 如果 _hierarchical 尚未初始化,则创建一个新的 HierarchicalChunker 实例
    if _hierarchical is None:
        _hierarchical = HierarchicalChunker()
    # 返回层级分块器实例
    return _hierarchical

# 定义智能拆分函数,根据内容特征选择合适的拆分策略
def split_chunk(chunk: DocumentChunk) -> List[DocumentChunk]:
    # 判断内容是否以 Markdown 标题开头(支持 #, ##, ###)
    if any(chunk.content.startswith(header) for header in ["#", "##", "###"]):
        # 若包含标题,则获取层级分块器实例
        hierarchical = get_hierarchical()
        # 调用层级分块器进行拆分,得到初步子块列表
        result = hierarchical.split(chunk)
        # 对过长的子块再走语义分块,初始化最终结果列表
        final = []
        # 遍历每个初步子块
        for sub in result:
            # 如果子块内容长度超过 500 字符,则需进一步切分
            if len(sub.content) > 500:
                # 获取语义分块器实例
                semantic = get_semantic()
                # 对当前子块进行语义切分,并将结果扩展到 final 列表
                final.extend(semantic.split(sub))
            else:
                # 如果子块长度适中,直接保留
                final.append(sub)
        # 返回经过两级处理后的最终块列表
        return final
    else:
        # 若不包含标题,直接使用语义分块器进行切分
        semantic = get_semantic()
        # 返回语义分块的结果
        return semantic.split(chunk)

        21.enhancers/__init__.py

# @file : enhancers/__init__.py
from enhancers.q2q_augmenter import augment_chunks_with_questions
from enhancers.metadata_generator import augment_chunks_with_metadata

__all__ = ["augment_chunks_with_questions", "augment_chunks_with_metadata"]

        22.enhancers/q2q_augmenter.py

# @file : enhancers/q2q_augmenter.py

# 导入正则表达式模块,用于文本清洗和模式匹配
import re
# 从 typing 模块导入 List 类型,用于类型提示
from typing import List
# 从 langchain_openai 导入 ChatOpenAI 类,用于调用 OpenAI 风格的大语言模型
from langchain_openai import ChatOpenAI
# 从核心模型模块导入 DocumentChunk 数据类,用于封装文档块
from core.models import DocumentChunk


# 定义为单个文档块生成相关问题列表的函数,接收文档块和 LLM 实例,返回问题字符串列表
def generate_questions_for_chunk(chunk: DocumentChunk, llm: ChatOpenAI) -> List[str]:
    # 截取文档块内容的前 800 个字符,作为提示词的上下文(避免超出 token 限制)
    content_preview = chunk.content[:800]
    # 构建发送给 LLM 的提示词,要求生成 3 个简短问题,每行一个且不带编号
    prompt = f"""
请根据以下文档片段,生成 3 个用户可能会问的简短问题(每行一个,不要编号):
{content_preview}
"""
    # 尝试执行 LLM 调用,捕获可能出现的异常
    try:
        # 调用 LLM 的 invoke 方法,传入提示词,获取响应对象
        response = llm.invoke(prompt)
        # 提取响应内容,去除首尾空白,并按换行符拆分为行
        lines = response.content.strip().split("\n")
        # 初始化空列表,用于存储清洗后的问题
        questions = []
        # 遍历每一行
        for line in lines:
            # 去除当前行的首尾空白
            line = line.strip()
            # 如果行为空,则跳过
            if not line:
                continue
            # 使用正则移除行首的数字序号(如 "1." "2、" 等)
            cleaned = re.sub(r'^\d+[.、]\s*', '', line)
            # 如果清洗后非空,则添加到问题列表
            if cleaned:
                questions.append(cleaned)
        # 最多返回前 5 个问题(防止意外输出过多)
        return questions[:5]
    # 捕获所有异常,打印错误信息并返回空列表
    except Exception as e:
        print(f"⚠️ 生成问题失败: {e}")
        return []


# 定义批量增强文档块的函数,为每个块生成问题并存入 generated_questions 属性
def augment_chunks_with_questions(chunks: List[DocumentChunk], llm: ChatOpenAI) -> List[DocumentChunk]:
    # 获取文档块总数,用于进度显示
    total = len(chunks)
    # 遍历所有文档块,i 为索引,chunk 为当前块对象
    for i, chunk in enumerate(chunks):
        # 打印进度信息,显示当前处理的文件及页码
        if chunk.page_num is not None:
            print(f"  Q2Q {i + 1}/{total}: {chunk.source_file} (第{chunk.page_num}页)")
        else:
            print(f"  Q2Q {i + 1}/{total}: {chunk.source_file}")
        # 如果文档块内容长度小于 20 个字符,则跳过(太短的内容不适合生成问题)
        if len(chunk.content) < 20:
            continue
        # 调用生成问题函数,并将结果赋值给当前块的 generated_questions 属性
        chunk.generated_questions = generate_questions_for_chunk(chunk, llm)
    # 返回增强后的文档块列表(原列表被修改,但为了语义清晰仍返回)
    return chunks

        23.enhancers/metadata_generator.py

# @file : enhancers/metadata_generator.py
# 从 typing 模块导入 List 和 Tuple 类型,用于类型提示
from typing import List, Tuple
# 从 langchain_openai 导入 ChatOpenAI 类,用于调用大语言模型
from langchain_openai import ChatOpenAI
# 从核心模型模块导入 DocumentChunk 数据类,用于封装文档块
from core.models import DocumentChunk


# 定义为单个文档块生成元数据(标签、摘要、实体)的函数,返回三元组
def generate_metadata_for_chunk(chunk: DocumentChunk, llm: ChatOpenAI) -> Tuple[List[str], str, List[str]]:
    # 截取文档块内容的前 600 个字符作为预览,避免超出模型上下文限制
    content_preview = chunk.content[:600]
    # 构建发送给 LLM 的提示词,要求提取标签、摘要和实体三类信息
    prompt = f"""
请分析以下文档片段,提取三类信息(每行一个标签):
标签:关键词1, 关键词2, 关键词3
摘要:一句话摘要(20字内)
实体:实体1, 实体2, 实体3

片段:{content_preview}
"""
    # 尝试执行 LLM 调用,捕获可能发生的异常
    try:
        # 调用 LLM 的 invoke 方法,传入提示词获取响应对象
        response = llm.invoke(prompt)
        # 提取响应内容,去除首尾空白,并按换行符拆分成行列表
        lines = response.content.strip().split("\n")
        # 初始化标签列表、摘要字符串、实体列表
        tags, summary, entities = [], "", []
        # 遍历每一行响应文本
        for line in lines:
            # 去除当前行的首尾空白
            line = line.strip()
            # 判断行是否以“标签”或“标签:”开头
            if line.startswith("标签") or line.startswith("标签:"):
                # 提取冒号后的部分,兼容中文冒号和英文冒号
                parts = line.split(":")[-1] if ":" in line else line.split(":")[-1]
                # 按逗号分割并去除每个标签的空格,生成标签列表
                tags = [t.strip() for t in parts.split(",") if t.strip()]
            # 否则判断行是否以“摘要”或“摘要:”开头
            elif line.startswith("摘要") or line.startswith("摘要:"):
                # 提取冒号后的部分,并去除首尾空白
                summary = line.split(":")[-1] if ":" in line else line.split(":")[-1]
                summary = summary.strip()
            # 否则判断行是否以“实体”或“实体:”开头
            elif line.startswith("实体") or line.startswith("实体:"):
                # 提取冒号后的部分
                parts = line.split(":")[-1] if ":" in line else line.split(":")[-1]
                # 按逗号分割并去除每个实体的空格,生成实体列表
                entities = [e.strip() for e in parts.split(",") if e.strip()]
        # 返回提取到的标签、摘要和实体
        return tags, summary, entities
    # 捕获所有异常,打印错误信息并返回默认空值
    except Exception as e:
        print(f"⚠️ 生成元数据失败: {e}")
        return [], "", []


# 定义批量增强文档块的函数,为每个块生成元数据并存入对象属性
def augment_chunks_with_metadata(chunks: List[DocumentChunk], llm: ChatOpenAI) -> List[DocumentChunk]:
    # 获取文档块总数,用于进度显示
    total = len(chunks)
    # 遍历所有文档块,i 为索引,chunk 为当前块对象
    for i, chunk in enumerate(chunks):
        # 打印进度信息,显示当前处理的文件及页码
        print(f"  元数据 {i+1}/{total}: {chunk.source_file} (第{chunk.page_num}页)")
        # 如果文档块内容长度小于 30 个字符,则跳过(内容太少无法提取有效元数据)
        if len(chunk.content) < 30:
            continue
        # 调用生成元数据函数,获取标签、摘要和实体
        tags, summary, entities = generate_metadata_for_chunk(chunk, llm)
        # 将标签列表赋值给当前块的 tags 属性
        chunk.tags = tags
        # 将摘要字符串赋值给当前块的 summary 属性
        chunk.summary = summary
        # 将实体列表赋值给当前块的 entities 属性
        chunk.entities = entities
    # 返回增强后的文档块列表(原列表被修改,此处返回便于链式调用)
    return chunks

        24.stores/__init__.py

from stores.vector_store import get_vector_store, search_knowledge_base, get_parent_context
from stores.index_pipeline import index_pipeline

__all__ = ["get_vector_store", "search_knowledge_base", "get_parent_context", "index_pipeline"]

        25.stores/vector_store.py

# @file : stores/vector_store.py
# 导入 hashlib 模块,用于生成哈希值作为文档 ID
import hashlib
# 从 typing 模块导入多种类型,用于类型提示(列表、可选、字典、任意类型、元组)
from typing import List, Optional, Dict, Any, Tuple
# 从 langchain_chroma 导入 Chroma 向量数据库类
from langchain_chroma import Chroma
# 从 langchain_core.documents 导入 Document 类,用于 LangChain 文档对象
from langchain_core.documents import Document
# 从核心配置模块导入 Config,获取配置参数
from core.config import Config
# 从核心模型模块导入 DocumentChunk 数据类,用于封装文档块
from core.models import DocumentChunk
# 从 embeddings 模块导入获取 Ollama 嵌入函数的函数
from embeddings import get_ollama_embeddings

# 初始化全局变量 _vector_store 为 None,用于缓存向量存储单例
_vector_store = None

# 定义获取向量存储实例的函数,接收集合名称参数,返回 Chroma 对象
def get_vector_store(collection_name: str = "docmind_kb_v15") -> Chroma:
    # 声明使用全局变量 _vector_store
    global _vector_store
    # 如果 _vector_store 尚未初始化,则创建新的 Chroma 实例
    if _vector_store is None:
        _vector_store = Chroma(
            # 设置向量数据库的集合名称
            collection_name=collection_name,
            # 设置嵌入函数,从 Ollama 获取
            embedding_function=get_ollama_embeddings(),
            # 设置持久化目录,用于存储向量数据
            persist_directory="./chroma_db_v15"
        )
    # 返回向量存储实例
    return _vector_store

# 定义内部函数,构建增强文本(包含问题、摘要、原文)
def _build_augmented_text(chunk: DocumentChunk) -> str:
    # 初始化列表,存储各部分文本
    parts = []
    # 如果文档块包含生成的问题列表,则组装问题部分
    if chunk.generated_questions:
        # 添加“用户可能这样问”前缀,并用分号连接所有问题
        parts.append("【用户可能这样问】" + ";".join(chunk.generated_questions))
    # 如果文档块包含摘要,则添加摘要部分
    if chunk.summary:
        parts.append("【摘要】" + chunk.summary)
    # 添加原文部分
    parts.append("【原文】" + chunk.content)
    # 将所有部分用两个换行符连接,返回增强后的文本
    return "\n\n".join(parts)

# 定义内部函数,将 DocumentChunk 转换为元数据字典
def _chunk_to_metadata(chunk: DocumentChunk) -> Dict[str, Any]:
    # 返回包含各种元数据字段的字典
    return {
        # 源文件名
        "source_file": chunk.source_file,
        # 页码,若为 None 则设为 0
        "page_num": chunk.page_num or 0,
        # 章节路径,若为 None 则设为空字符串
        "section_path": chunk.section_path or "",
        # 块类型
        "chunk_type": chunk.chunk_type,
        # 原始长度
        "original_length": chunk.original_length,
        # 清洗后长度
        "clean_length": chunk.clean_length,
        # 标签列表转为逗号分隔的字符串
        "tags": ",".join(chunk.tags) if chunk.tags else "",
        # 摘要,若为 None 则设为空字符串
        "summary": chunk.summary or "",
        # 实体列表转为逗号分隔的字符串
        "entities": ",".join(chunk.entities) if chunk.entities else "",
        # 父块 ID,若为 None 则设为空字符串
        "parent_id": chunk.parent_id or "",
        # 生成问题的数量
        "question_count": len(chunk.generated_questions),
    }

# 定义内部函数,将 DocumentChunk 转换为 LangChain Document 对象
def _chunk_to_langchain_doc(chunk: DocumentChunk) -> Document:
    # 调用 _build_augmented_text 构建增强文本作为页面内容
    augmented_text = _build_augmented_text(chunk)
    # 调用 _chunk_to_metadata 获取元数据字典
    metadata = _chunk_to_metadata(chunk)
    # 计算内容哈希值,用于生成唯一文档 ID
    content_hash = hashlib.md5(chunk.content.encode("utf-8")).hexdigest()
    # 组合文档 ID:源文件名 + 页码 + 哈希前8位
    doc_id = f"{chunk.source_file}_{chunk.page_num}_{content_hash[:8]}"
    # 创建并返回 LangChain Document 对象
    return Document(page_content=augmented_text, metadata=metadata, id=doc_id)

# 定义知识库搜索函数,接收查询和返回数量,返回文档块和相似度分数的列表
def search_knowledge_base(query: str, k: int = 3) -> List[Tuple[DocumentChunk, float]]:
    # 获取向量存储实例
    store = get_vector_store()
    # 执行相似度搜索,返回文档和分数的列表
    docs_with_scores = store.similarity_search_with_score(query, k=k)
    # 初始化结果列表
    results = []
    # 遍历搜索出的文档及其分数
    for doc, score in docs_with_scores:
        # 提取元数据
        meta = doc.metadata
        # 从元数据和页面内容构造 DocumentChunk 对象
        chunk = DocumentChunk(
            # 页面内容(即增强文本)作为 content
            content=doc.page_content,
            # 源文件名
            source_file=meta.get("source_file", ""),
            # 页码
            page_num=meta.get("page_num"),
            # 章节路径
            section_path=meta.get("section_path"),
            # 块类型
            chunk_type=meta.get("chunk_type", "paragraph"),
            # 标签列表(从逗号分隔字符串解析)
            tags=meta.get("tags", "").split(",") if meta.get("tags") else [],
            # 摘要
            summary=meta.get("summary", ""),
            # 实体列表(从逗号分隔字符串解析)
            entities=meta.get("entities", "").split(",") if meta.get("entities") else [],
            # 父块 ID
            parent_id=meta.get("parent_id"),
        )
        # 将块和分数添加到结果列表
        results.append((chunk, score))
    # 返回搜索结果
    return results

# 定义获取父级上下文的函数,根据子块的 parent_id 查找父块
def get_parent_context(chunk: DocumentChunk) -> Optional[DocumentChunk]:
    # 如果当前块没有 parent_id,则直接返回 None
    if not chunk.parent_id:
        return None
    # 尝试执行查询
    try:
        # 获取向量存储实例
        store = get_vector_store()
        # 使用相似度搜索并添加过滤条件,只返回 parent_id 匹配的文档(取第一个)
        docs = store.similarity_search(chunk.content, k=1, filter={"parent_id": chunk.parent_id})
        # 如果找到文档
        if docs:
            # 提取第一个文档的元数据
            meta = docs[0].metadata
            # 构造并返回父级 DocumentChunk 对象
            return DocumentChunk(
                # 页面内容
                content=docs[0].page_content,
                # 源文件名
                source_file=meta.get("source_file", ""),
                # 页码
                page_num=meta.get("page_num"),
                # 章节路径
                section_path=meta.get("section_path"),
                # 块类型
                chunk_type=meta.get("chunk_type", "paragraph"),
            )
    # 捕获异常并打印错误信息
    except Exception as e:
        print(f"⚠️ 获取父级上下文失败: {e}")
    # 未找到或发生异常则返回 None
    return None

        26.stores/index_pipeline.py

# @file : stores/index_pipeline.py
# 导入操作系统接口模块,用于文件和目录操作
import os
# 从 typing 模块导入 List 类型,用于类型提示
from typing import List
# 从 langchain_openai 导入 ChatOpenAI 类,用于调用大语言模型
from langchain_openai import ChatOpenAI
# 从核心配置模块导入 Config,获取分块大小等配置
from core.config import Config
# 从核心模型模块导入 DocumentChunk 数据类
from core.models import DocumentChunk
# 从解析器基础模块导入 load_documents 函数,用于根据文件类型解析文档
from parsers.base import load_documents
# 从分块器工厂模块导入 split_chunk 函数,进行智能分块
from chunkers.chunker_factory import split_chunk
# 从增强器模块导入 Q2Q 问题生成函数
from enhancers.q2q_augmenter import augment_chunks_with_questions
# 从增强器模块导入元数据生成函数
from enhancers.metadata_generator import augment_chunks_with_metadata
# 从向量存储模块导入获取向量存储实例和转换函数
from stores.vector_store import get_vector_store, _chunk_to_langchain_doc


# 定义索引管道函数,接收文档目录和可选的 LLM 实例,返回成功索引的文档数量
def index_pipeline(docs_dir: str = "./docs", llm: ChatOpenAI = None) -> int:
    # 检查文档目录是否存在
    if not os.path.exists(docs_dir):
        # 如果不存在,则创建目录(包括必要的父目录)
        os.makedirs(docs_dir, exist_ok=True)
        # 打印提示信息,要求用户放入文档后重启
        print("📁 已创建 docs 文件夹,请放入文档后重启")
        # 返回 0 表示未索引任何文档
        return 0

    # 获取文档目录下所有支持的文件(.pdf, .docx, .xlsx)
    files = [f for f in os.listdir(docs_dir) if f.endswith((".pdf", ".docx", ".xlsx"))]
    # 如果没有找到任何支持的文件
    if not files:
        # 打印提示信息
        print("📂 docs 文件夹为空")
        # 返回 0
        return 0

    # 打印开始解析文档的步骤信息
    print("📄 步骤1: 解析文档...")
    # 初始化空列表,用于存储所有解析出的原始块
    all_chunks = []
    # 遍历每个文件名
    for filename in files:
        # 拼接完整的文件路径
        file_path = os.path.join(docs_dir, filename)
        # 打印正在处理的文件名
        print(f"  - {filename}")
        # 调用 load_documents 解析该文件,返回文档块列表
        chunks = load_documents(file_path)
        # 将解析出的块扩展到总列表中
        all_chunks.extend(chunks)

    # 如果没有解析到任何有效内容
    if not all_chunks:
        # 打印警告信息
        print("⚠️ 未解析到有效内容")
        # 返回 0
        return 0
    # 打印解析出的原始切片数量
    print(f"  共解析 {len(all_chunks)} 个原始切片")

    # 打印智能分块步骤信息
    print("✂️ 步骤2: 智能分块(层级 + 语义)...")
    # 初始化最终块列表
    final_chunks = []
    # 遍历每个原始块
    for chunk in all_chunks:
        # 如果当前块内容长度超过配置的最大块大小
        if len(chunk.content) > Config.CHUNK_SIZE:
            # 调用 split_chunk 进行分块(先层级后语义)
            sub_chunks = split_chunk(chunk)
            # 将分块后的子块扩展到最终列表
            final_chunks.extend(sub_chunks)
        else:
            # 否则直接保留该块
            final_chunks.append(chunk)
    # 打印最终生成的切片数量
    print(f"  共生成 {len(final_chunks)} 个最终切片")

    # 如果传入了 LLM 实例
    if llm:
        # 打印 Q2Q 生成问题步骤信息
        print("🤔 步骤3: Q2Q 预生成问题...")
        # 调用问题生成增强函数,更新 final_chunks
        final_chunks = augment_chunks_with_questions(final_chunks, llm)

    # 如果传入了 LLM 实例
    if llm:
        # 打印元数据生成步骤信息
        print("🏷️ 步骤4: LLM 生成元数据...")
        # 调用元数据生成增强函数,更新 final_chunks
        final_chunks = augment_chunks_with_metadata(final_chunks, llm)

    # 打印存入向量库步骤信息
    print("💾 步骤5: 存入向量库...")
    # 获取向量存储实例(单例)
    store = get_vector_store()
    # 将所有最终块转换为 LangChain Document 对象列表
    docs_to_add = [_chunk_to_langchain_doc(chunk) for chunk in final_chunks]
    # 如果有文档需要添加
    if docs_to_add:
        # 调用向量存储的 add_documents 方法批量添加
        store.add_documents(docs_to_add)
        # 打印成功索引的切片数量
        print(f"✅ 成功索引 {len(docs_to_add)} 个切片")

    # 打印空行和索引统计信息
    print("\n📊 索引统计:")
    # 打印总原始切片数
    print(f"  总原始切片数: {len(all_chunks)}")
    # 打印最终切片数
    print(f"  最终切片数: {len(final_chunks)}")
    # 打印压缩比(原始/最终)
    print(f"  压缩比: {len(all_chunks) / len(final_chunks):.2f}x")
    # 打印向量库集合名称
    print(f"  向量库集合: docmind_kb_v15")
    # 返回成功添加的文档数量
    return len(docs_to_add)

        27.tools/__init__.py

from tools.rag_tool import query_knowledge_base
__all__ = ["query_knowledge_base"]

        28.tools/rag_tool.py

# @file : tools/rag_tool.py
# 从 langchain.tools 导入 tool 装饰器,用于定义工具函数
from langchain.tools import tool
# 从 pydantic 导入 BaseModel 和 Field,用于定义输入参数模型
from pydantic import BaseModel, Field
# 从向量存储模块导入搜索知识库和获取父级上下文的函数
from stores.vector_store import search_knowledge_base, get_parent_context


# 定义搜索输入参数模型,继承自 BaseModel
class SearchInput(BaseModel):
    # 定义 query 字段,描述为"用户问题中的核心检索关键词,提取最相关的名词或短语"
    query: str = Field(description="用户问题中的核心检索关键词,提取最相关的名词或短语")


# 使用 @tool 装饰器将函数定义为工具,并指定参数模式为 SearchInput
# 🆕 显式提供 description,完全绕过 docstring 解析
@tool(args_schema=SearchInput, description="从企业文档知识库中检索相关信息。")
# 定义查询知识库的工具函数,接收 query 字符串,返回上下文文本
def query_knowledge_base(query: str) -> str:
    # 调用搜索函数,获取最多 3 个相关块及其相似度分数
    results = search_knowledge_base(query, k=3)
    # 如果没有搜索结果,返回提示信息
    if not results:
        return "知识库中未找到相关信息。请先上传文档。"
    # 初始化上下文部分列表
    context_parts = []
    # 遍历每个搜索结果(块和分数)
    for chunk, score in results:
        # 构建基本来源信息:文件名和页码
        part = f"【来源】{chunk.source_file} 第{chunk.page_num}页\n"
        # 如果块有章节路径,添加章节信息
        if chunk.section_path:
            part += f"【章节】{chunk.section_path}\n"
        # 如果块有标签,添加前5个标签
        if chunk.tags:
            part += f"【标签】{', '.join(chunk.tags[:5])}\n"
        # 添加块的内容
        part += f"【内容】{chunk.content}"
        # 尝试获取父级上下文
        parent = get_parent_context(chunk)
        # 如果存在父级块,添加父级内容(截取前300字符)
        if parent:
            part += f"\n【父级上下文】{parent.content[:300]}..."
        # 将组装好的部分添加到列表中
        context_parts.append(part)
    # 用分隔符连接所有部分,返回完整的上下文
    return "\n\n---\n\n".join(context_parts)

        29.main.py

# @file : main.py
# 从 langchain_core.messages 导入 AIMessage 类,用于处理 AI 回复消息
from langchain_core.messages import AIMessage
# 从 langchain_openai 导入 ChatOpenAI 类,用于初始化大语言模型客户端
from langchain_openai import ChatOpenAI
# 从 langchain.agents 导入 create_agent 函数,用于创建智能体
from langchain.agents import create_agent
# 从 langgraph.checkpoint.memory 导入 MemorySaver,用于对话状态记忆
from langgraph.checkpoint.memory import MemorySaver

# 从核心配置模块导入 Config,获取应用配置
from core.config import Config
# 从核心 HTTP 客户端模块导入 LoggingHttpClient,用于带日志的 HTTP 请求
from core.http_client import LoggingHttpClient
# 从工具模块导入知识库查询工具函数
from tools.rag_tool import query_knowledge_base
# 从向量存储索引管道模块导入索引函数
from stores.index_pipeline import index_pipeline

# ---------- 初始化 ----------
# 验证配置项是否完整有效(如 API 密钥等)
Config.validate()
# 创建带有日志功能的 HTTP 客户端,设置超时时间为 60 秒
http_client = LoggingHttpClient(timeout=60.0)

# 初始化 ChatOpenAI 大语言模型实例
llm = ChatOpenAI(
    # 模型名称(从配置中读取)
    model=Config.LLM_MODEL,
    # API 密钥(从配置中读取 DeepSeek API 密钥)
    api_key=Config.DEEPSEEK_API_KEY,
    # API 基础 URL(从配置中读取 DeepSeek 端点)
    base_url=Config.DEEPSEEK_BASE_URL,
    # 温度参数设为 0.7,控制回复的随机性
    temperature=0.7,
    # 传入自定义 HTTP 客户端
    http_client=http_client
)

# ---------- 启动时索引 ----------
# 打印分隔线和启动信息
print("=" * 60)
print("DocMind V1.5 启动中...")
print("=" * 60)
# 调用索引管道,指定文档目录和 LLM 实例
index_pipeline(docs_dir="./docs", llm=llm)

# ---------- 创建 Agent ----------
# 创建内存检查点保存器,用于存储对话历史
memory = MemorySaver()
# 使用 create_agent 创建智能体
agent = create_agent(
    # 指定使用的模型
    model=llm,
    # 提供工具列表(知识库查询工具)
    tools=[query_knowledge_base],
    # 设置系统提示词,指导智能体行为
    system_prompt=(
        "你是企业文档智能助手 DocMind V1.5。\n"
        "1. 回答问题时,优先使用 query_knowledge_base 工具检索知识库。\n"
        "2. 检索结果已包含来源页码、章节标签和父级上下文,请综合使用。\n"
        "3. 回答时尽量注明信息来源(如'根据第3页...')。"
    ),
    # 传入检查点保存器,实现对话记忆
    checkpointer=memory
)

# ---------- 对话循环 ----------
# 打印就绪信息和提示
print("\n" + "=" * 60)
print("DocMind V1.5 已就绪")
print("📌 新增特性: OCR扫描件 | 语义分块 | Q2Q问题 | 元数据指纹 | 父子索引")
print("输入 'exit' 退出")
print("=" * 60)

# 设置线程 ID,用于区分不同用户的对话
thread_id = "user_001"
# 进入无限循环,持续接收用户输入
while True:
    # 获取用户输入(去除首尾空白)
    user_input = input("\n你: ")
    # 如果用户输入 'exit'(不区分大小写),则退出循环
    if user_input.lower() == "exit":
        break
    # 如果输入为空或仅有空白,则跳过本次循环
    if not user_input.strip():
        continue

    # 调用智能体的 invoke 方法,传入用户消息和配置(包含线程 ID)
    result = agent.invoke(
        {"messages": [{"role": "user", "content": user_input}]},
        config={"configurable": {"thread_id": thread_id}}
    )

    # 初始化变量,用于存储最后一条 AI 消息
    last_ai_msg = None
    # 反向遍历智能体返回的消息列表(从后向前)
    for msg in reversed(result['messages']):
        # 如果消息是 AIMessage 类型且包含内容
        if isinstance(msg, AIMessage) and msg.content:
            # 赋值并跳出循环
            last_ai_msg = msg
            break

    # 打印分隔线
    print("-" * 60)
    # 如果找到了 AI 消息,则打印其内容
    if last_ai_msg:
        print(f"🤖 DocMind V1.5: {last_ai_msg.content}")
    else:
        # 否则打印未获取到有效回复的提示
        print("🤖 DocMind V1.5: (未获取到有效回复)")
    # 打印分隔线
    print("-" * 60)

V1.0 vs V1.5 完整对比表

一、项目结构与代码组织

对比维度 V1.0(扁平结构) V1.5(分层架构)
项目名称 docmind-v1 docmind-v1.5
目录结构 所有代码平铺在根目录(8个文件) 7个分层包,职责清晰(28个文件)
核心分层 core/ parsers/ cleaners/ chunkers/ enhancers/ stores/ tools/
导入方式 直接从文件名导入 从包名导入,内部隐藏实现细节
代码可读性 中等(需要翻阅多个函数才能理解流程) 高(每个包的名称即代表其职责)
新增功能影响 必须修改现有文件(如 document_loader.py 在对应包中新增文件,原有代码零改动

二、文档解析能力

对比维度 V1.0 V1.5
PDF 解析 pypdf 纯文本提取 pymupdf4llm(OCR + 表格 + 多栏排版)
扫描件支持 ❌ 不支持(乱码/空白) ✅ 支持(集成 Tesseract OCR)
表格识别 ❌ 丢失行列结构 ✅ 转为 Markdown Table 保留结构
多栏排版 ❌ 提取顺序错乱 ✅ 智能重组阅读顺序
Word 解析 python-docx 按段落提取 python-docx + 噪声清洗
Excel 解析 openpyxl 拼接为纯文本 openpyxl 转为 Markdown 表格行
返回格式 List[str](纯文本) List[DocumentChunk](结构化对象)

三、分块策略

对比维度 V1.0 V1.5
分块方式 RecursiveCharacterTextSplitter 按 500 字符硬切 智能分块:层级分块 + 语义分块双策略
句子完整性 ❌ 可能切断句子 ✅ 按句号、问号、段落边界切分
结构保留 ❌ 不保留章节信息 ✅ 按 Markdown 标题(# ## ###)切分,保留层级路径
分块策略 单一策略 组合策略:层级分块 → 语义精修

四、存储内容与增强

对比维度 V1.0 V1.5
存储内容 仅存储原文向量 存储“Q2Q问题 + 摘要 + 原文”拼接向量
Q2Q 预生成问题 ❌ 不支持 ✅ LLM 为每个切片生成 3~5 个口语化问题
元数据 极少(仅文件名) 全量元数据指纹:页码、章节路径、内容类型、标签、摘要、实体
LLM 元数据生成 ❌ 不支持 ✅ 自动生成标签/摘要/命名实体
父子索引 ❌ 扁平索引 ✅ 建立父子关系,检索时自动带回父级上下文
数据模型 List[str] 裸列表 DocumentChunk(Pydantic 强类型实体)

五、代码质量与工程化

对比维度 V1.0 V1.5
配置管理 os.getenv + 手动 validate() 同上(保留,与 Pydantic 分离)
数据校验 Pydantic BaseModel 定义所有实体
调试能力 LoggingHttpClient 拦截 DeepSeek 请求 同左 + 各模块独立日志输出
类型安全 类型注解部分缺失 全量类型注解,IDE 友好
模块解耦 各模块直接依赖,耦合紧密 通过 __init__.py 控制导出,内部可独立重构

六、扩展性(对后续版本的影响)

场景 V1.0(扁平结构) V1.5(分层架构)
新增 PDF 解析增强 改 document_loader.py(牵一发动全身) 改 parsers/pdf_parser.py(不影响其他格式)
新增分块策略 改 embeddings.py,加 if 分支 在 chunkers/ 新增文件,工厂自动注册
新增检索增强 改 vector_store.py,加参数 在 enhancers/ 新增文件,管线自动调用
新增 Agent 工具 在 main.py 里添加 @tool 在 tools/ 新增文件,main.py 只负责导入
升级到 Milvus 全部重写 vector_store.py 在 stores/ 新增 milvus_store.py,通过接口切换
V2.0 新增编排层 改 main.py,越来越臃肿 新增 orchestrator/ 包,main.py 保持清爽
V3.0 新增检索策略 改 vector_store.py,加多路召回 新增 retrievers/ 包,专注检索优化
V4.0 新增可观测性 到处加日志埋点,侵入性强 新增 observability/ 包,通过钩子注入,零侵入

七、技术依赖对比

依赖 V1.0 V1.5 说明
pypdf ✅(保留,仅作备用) 早期 PDF 解析
pymupdf4llm ✅ 新增 OCR + 表格 + 多栏
pymupdf ✅ 新增 PDF 底层引擎
python-docx Word 解析
openpyxl Excel 解析
langchain-ollama Ollama 嵌入(已从 community 迁移)
pydantic 数据校验(V1.5 全面使用)
pydantic-settings 未使用(保持 os.getenv
reportlab ❌(仅测试脚本用) PDF 生成(用户测试时使用)

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐