RAG入门:Markdown 解析

UnstructuredMarkdownLoader 学习笔记: Markdown 解析

标签:Python、LangChain、Unstructured、Milvus、Tesseract
内容摘要:本文整理 UnstructuredMarkdownLoader 的加载模式、LangChain Document 数据结构和常见元素类型,并说明从非结构化文档解析到 Milvus 入库之间仍需完成的分块、向量化和元数据设计工作。适合正在搭建 RAG 文档摄取流程的开发者。

目录

学习目标

这套处理流程最终要解决的问题是:使用 Python 读取 PDF、Markdown 和图片等资料,将内容整理成适合检索的文本块和元数据,再生成向量并写入 Milvus。

本文先聚焦其中的“文档解析”阶段,尤其是 Markdown 文件经过 Unstructured 和 LangChain 后会得到什么。完整链路如下:

flowchart LR A[PDF / Markdown / 图片] --> B[Unstructured 解析] T[Tesseract OCR] -. 扫描件与图片文字识别 .-> B B --> C[LangChain Document] C --> D[清洗与分块] D --> E[Embedding 向量化] E --> F[(Milvus)] L[Loguru] -. 记录处理状态与异常 .-> B L -. 记录分块与入库结果 .-> F

注意:elements 模式得到的是语义元素,不一定就是适合直接写入向量数据库的最终分块。标题或单个列表项可能过短,通常还需要合并、补充上下文或按标题分块。

项目中的工具分工

工具 主要职责 本流程中的位置
Python 编写解析、清洗、向量化和入库程序 整体开发语言
LangChain 统一 Document、Embedding、向量存储和检索接口 串联解析结果与 Milvus
Unstructured 将不同格式的文件拆成统一的语义元素 文档解析与预处理
Loguru 记录文件处理进度、异常、元素数量和入库结果 可观测性与故障排查
Tesseract 从图片或扫描件中识别文字 OCR 能力
Milvus 存储、索引和检索向量及其关联元数据 数据落库与检索

这里还缺少两个不能忽略的环节:

  1. Milvus 的 LangChain 集成包:当前官方集成使用 langchain-milvus。
  2. Embedding 模型或服务:普通的 LangChain 向量存储流程需要先把文本转换为向量;也可以在满足版本和部署条件时使用 Milvus 服务端的文本嵌入功能。

换句话说,Unstructured 负责“读懂文档结构”,但它不负责把文本自动变成可用于语义检索的向量。

Unstructured 在做什么

Unstructured 是一个用于预处理结构化与非结构化文档的开源 Python 项目。它会把不同来源的文件统一转换为一组 Element 对象,例如标题、正文、列表项和表格。这样,下游流程不必为每一种文件格式设计完全不同的数据结构。

以 Markdown 为例,当前 partition_md 的实现会先把 Markdown 转换为 HTML,再交给 HTML 分区逻辑生成元素。因此,解析结果通常是规范化后的文本和结构信息,而不是原始 Markdown 源码的原样复制。

例如:

## 加载模式

- single
- elements

经过解析后,##、- 等语法标记一般不会继续作为正文内容保留;标题和列表的语义会体现在元素类型及其元数据中。不要依赖解析后的 page_content 再次读取原始 Markdown 标记。

UnstructuredMarkdownLoader 的两种加载模式

UnstructuredMarkdownLoader 位于 langchain_community.document_loaders,提供 single 和 elements 两种模式。

模式 返回结果 优点 适用场景
single 整个文件合并为一个 LangChain Document 上下文完整、对象数量少、便于采用自定义切分策略 全文摘要、小文档、需要自己控制分块方式
elements 每个 Unstructured 元素转换为一个 LangChain Document 保留标题、正文、列表、表格等语义类别 结构化过滤、按标题组织上下文、构建 RAG 摄取流程

single 模式并非没有意义

初看 single 模式,会觉得“既然最后还是一大块文本,为什么不直接读文件”。这个疑问很合理,但 single 仍然有明确用途:

  • 希望先通过 Unstructured 做统一解析和文本规范化,再使用自己的分块器。
  • 文档很短,本身不需要拆分。
  • 任务是全文摘要、分类或归档,需要尽量保留完整上下文。
  • 希望不同文件格式都通过相同的加载器接口返回 LangChain Document。

它的限制也很明显:结构化元素被重新合并后,元素级的类别和层级价值会减少;而且原始 Markdown 标记通常已经被规范化,后续再使用依赖 # 标题符号的 Markdown 分块器可能得不到预期结果。

elements 模式更适合当前目标

如果目标是构建可筛选、可引用的知识库,elements 通常更合适。每个元素都会成为独立的 LangChain Document,元素正文进入 page_content,元素类型和来源信息进入 metadata。

不过,“一个元素等于一个向量块”并不是固定规则。单个标题、短列表项或孤立的一句话缺少上下文,直接向量化可能降低召回质量。后续应根据标题层级和父子关系进行组合,或者使用 Unstructured 的 by_title 分块策略生成 CompositeElement。

LangChain Document 的结构

LangChain 的 Document 是用于检索流程的文本载体,核心字段是 page_content 和 metadata,另外还可以有可选的 id。

page_content

page_content 是字符串,保存当前 Document 的文本内容。

在 elements 模式下,它对应 Unstructured 元素的文本内容。例如标题元素的 page_content 是标题文字,正文元素的 page_content 是段落文字。

需要区分两层对象:

  • 原生 Unstructured Element 使用 text 表示元素文本。
  • 转换为 LangChain Document 后,文本位于 page_content。

因此,“图片描述存放在 text 字段”描述的是 Unstructured 元素或其字典形式,不是最终 LangChain Document 的字段名称。

metadata

metadata 是一个字典,用于保存正文之外的附属信息。常见字段包括:

  • source:原始文件路径或来源。
  • filename:文件名。
  • file_directory:文件所在目录。
  • filetype:文件类型。
  • category:元素类型,例如 Title 或 NarrativeText。
  • element_id:元素标识。
  • parent_id:父元素标识,可用于还原标题与正文的层级关系。
  • category_depth:同类元素的相对层级,例如标题深度。
  • languages:识别出的语言。
  • text_as_html:表格的 HTML 表示,仅在支持且成功提取时出现。

具体字段会随文件类型、Unstructured 版本、解析策略和参数而变化。读取元数据时应使用 dict.get(),不要假定每个元素都有所有字段。

元数据的主要用途包括:

  • 按来源、文档类别、章节或其他业务字段过滤。
  • 在检索结果中标注答案出处。
  • 根据 category 决定不同的清洗或分块策略。
  • 使用 parent_id、category_depth 等字段恢复文档上下文。
  • 生成稳定 ID,支持去重、更新和追踪。

常见元素类型

元素类型名称区分大小写。常见值如下:

metadata["category"] 含义 Markdown 中的典型来源
Title 文档标题或章节标题 # 标题、## 标题 等
NarrativeText 连贯的叙述性正文 普通段落
ListItem 列表中的单个条目 - 无序列表、1. 有序列表
Table 表格元素 Markdown 表格
Image 用于承载图片相关信息的元素 是否产生及包含哪些字段取决于解析器版本和参数
UncategorizedText 无法归入更具体类型的普通文本 零散或结构不明确的文本
CodeSnippet 代码片段 受解析器版本和具体结构影响
CompositeElement 多个连续文本元素合并形成的分块 启用分块策略后产生

提示:元素分类是解析器给出的结果,不应把它当作绝对正确的事实。官方文档也提醒,某些短段落或列表项可能被误判为 Title。入库前最好统计类别分布并抽样检查。

图片与 OCR 的处理边界

Markdown 图片不等于已经提取图片内容

Markdown 中的:

![架构图](images/architecture.png)

本质上只是图片引用和替代文本。加载 Markdown 时,不能默认认为加载器会读取该图片文件、执行 OCR,并把图片二进制自动写入 metadata。

image_base64 和 image_mime_type 是条件性元数据。根据 Unstructured 官方说明,image_base64 主要适用于采用 High Res 策略检测到的图片或表格,并且需要相应的图片提取配置;它不是每个 Image 元素必然具有的字段。分块之后,Base64 数据也可能不再保留。

更稳妥的多模态处理方式是:

  1. 单独读取 Markdown 图片链接。
  2. 校验并定位图片文件。
  3. 对需要文字识别的图片调用 OCR。
  4. 对图表或照片生成说明文字。
  5. 将说明文字作为可检索文本,将图片路径、URL、MIME 类型等作为元数据。

Tesseract 的准确定位

Tesseract 是开源 OCR 引擎,不只是一个普通 Python 包。实际运行时通常需要安装:

  • Tesseract 引擎本体。
  • 对应语言的 traineddata,中文简体通常需要 chi_sim。
  • 与 Python 或 Unstructured 的调用集成。

Unstructured 可将 Tesseract 作为 OCR agent,用于图片和缺少可提取文本层的扫描 PDF。正常的 Markdown 文本解析不需要 Tesseract。

从解析结果到 Milvus

解析完成并不等于可以直接得到高质量的 Milvus 数据。建议在中间增加以下处理步骤。

1. 清洗与规范化元数据

为所有分块建立稳定字段,例如:

字段 用途
source 原始文件位置或来源 URL
filename 原始文件名
document_id 文档级稳定标识
element_id 原始元素标识
category Unstructured 元素类型
heading_path 当前元素所属的完整标题路径
chunk_index 文档内分块序号
content_hash 去重和判断内容是否变化

写入 Milvus 前,还应确认复杂列表、嵌套字典等元数据是否符合当前 Collection Schema 或动态字段配置。必要时只保留用于过滤和引用的字段,避免无控制地写入全部解析元数据。

2. 组合过短元素

Title、短段落和单个 ListItem 往往不适合独立生成向量。可以把同一标题下的连续元素合并,并把标题路径附加到文本或元数据中。

分块时需要在两个目标之间平衡:

  • 块太小:上下文不足,语义不完整。
  • 块太大:主题混杂,检索命中不精确,还可能超过模型限制。

3. 生成向量

典型 LangChain 流程需要一个 Embeddings 实现,把 page_content 转为固定维度的向量。向量维度必须与 Milvus Collection 中的向量字段一致。

Milvus 也提供服务端文本嵌入功能,但它有明确的 Milvus 版本和部署限制,并不等于所有环境都能省略 Embedding 配置。初次学习时,应先理解普通的客户端向量化流程。

4. 写入并验证

使用 langchain-milvus 写入后,至少检查:

  • 成功写入的数量是否等于预期分块数量。
  • ID 是否稳定且无重复。
  • 元数据字段是否完整。
  • 使用一组已知问题进行相似度检索,观察召回内容是否来自正确章节。
  • 更新同一文件时是否会产生重复数据。

最小实践代码

下面的代码在 Markdown 解析之后增加一次初步语义合并。目标不是一次得到最终向量块,而是先解决标题和正文彼此分离、子标题缺少上级语境的问题。

合并遵循以下规则:

  1. 根据 element_id 和 parent_id 建立元素之间的关系,不依赖元素在列表中的相邻位置。
  2. NarrativeText 只合并到它直接归属的 Title,不把整棵子树的正文重复塞入父标题块。
  3. 子标题向上追溯父级标题,形成完整的 heading_path,并将标题路径放在合并文本开头。
  4. 没有直接正文的标题不生成空块,但仍可作为下级标题的语义上下文。
  5. 找不到有效父标题的 NarrativeText 不会被丢弃,而是保留并标记为孤立正文。
  6. ListItem、Table 等本次未参与合并的元素保持原样,便于后续继续扩展规则。
  7. 标题关系出现循环时停止追溯,并通过 hierarchy_status 标记异常。
from collections import defaultdict
from pathlib import Path

from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_core.documents import Document
from loguru import logger


def load_markdown(path: Path) -> list[Document]:
    """按语义元素加载 Markdown,并输出基础统计信息。"""
    logger.info("开始解析 Markdown:{}", path)

    loader = UnstructuredMarkdownLoader(
        file_path=str(path),
        mode="elements",
    )
    documents = loader.load()

    logger.info("解析完成:{},得到 {} 个元素", path.name, len(documents))
    return documents


def resolve_heading_path(
    title_id: str,
    documents_by_id: dict[str, Document],
) -> tuple[list[str], str]:
    """从当前标题向上追溯,返回从根标题到当前标题的路径。"""
    headings: list[str] = []
    visited: set[str] = set()
    current_id: str | None = title_id
    hierarchy_status = "ok"

    while current_id:
        if current_id in visited:
            hierarchy_status = "cycle_detected"
            break
        visited.add(current_id)

        current = documents_by_id.get(current_id)
        if current is None:
            hierarchy_status = "missing_parent"
            break

        if current.metadata.get("category") == "Title":
            headings.append(current.page_content.strip())

        current_id = current.metadata.get("parent_id")

    headings.reverse()
    return headings, hierarchy_status


def merge_narrative_by_title(documents: list[Document]) -> list[Document]:
    """把 NarrativeText 合并到直接父标题,并为子标题补充完整标题路径。"""
    indexed_documents: list[tuple[int, str, Document]] = []
    documents_by_id: dict[str, Document] = {}

    for index, document in enumerate(documents):
        element_id = document.metadata.get("element_id") or f"__index_{index}"
        indexed_documents.append((index, element_id, document))
        documents_by_id.setdefault(element_id, document)

    narrative_by_title: dict[str, list[Document]] = defaultdict(list)
    consumed_indexes: set[int] = set()

    for index, _, document in indexed_documents:
        if document.metadata.get("category") != "NarrativeText":
            continue

        parent_id = document.metadata.get("parent_id")
        parent = documents_by_id.get(parent_id)
        if parent and parent.metadata.get("category") == "Title":
            narrative_by_title[parent_id].append(document)
            consumed_indexes.add(index)

    merged_documents: list[Document] = []

    for index, element_id, document in indexed_documents:
        category = document.metadata.get("category")

        if category == "Title":
            children = narrative_by_title.get(element_id, [])
            body_parts = [
                child.page_content.strip()
                for child in children
                if child.page_content.strip()
            ]

            # 空标题不单独成块,但仍保留在索引中供子标题追溯。
            if not body_parts:
                continue

            heading_path, hierarchy_status = resolve_heading_path(
                element_id,
                documents_by_id,
            )
            body = "\n\n".join(body_parts)
            source_element_ids = [
                source_id
                for source_id in (
                    document.metadata.get("element_id"),
                    *(child.metadata.get("element_id") for child in children),
                )
                if source_id
            ]

            metadata = dict(document.metadata)
            metadata.update(
                {
                    # MergedSection 是本文定义的自定义类别,并非 Unstructured 原生类型。
                    "category": "MergedSection",
                    "original_category": "Title",
                    "title_id": document.metadata.get("element_id"),
                    "heading_path": heading_path,
                    "source_element_ids": source_element_ids,
                    "hierarchy_status": hierarchy_status,
                }
            )
            merged_documents.append(
                Document(
                    page_content=f"{' > '.join(heading_path)}\n\n{body}",
                    metadata=metadata,
                )
            )
            continue

        # 已经进入标题块的正文不再重复输出。
        if category == "NarrativeText" and index in consumed_indexes:
            continue

        # 没有有效父标题的正文必须保留,避免静默丢失内容。
        if category == "NarrativeText":
            metadata = dict(document.metadata)
            metadata["orphan"] = True
            merged_documents.append(
                Document(page_content=document.page_content, metadata=metadata)
            )
            continue

        # ListItem、Table 等暂未参与合并的元素保持原样。
        merged_documents.append(document)

    return merged_documents


raw_documents = load_markdown(Path("example.md"))
merged_documents = merge_narrative_by_title(raw_documents)

logger.info(
    "初步语义合并完成:合并前 {} 个元素,合并后 {} 个块",
    len(raw_documents),
    len(merged_documents),
)

for index, document in enumerate(merged_documents[:5]):
    logger.info(
        "块序号={} category={} heading_path={}",
        index,
        document.metadata.get("category", "Unknown"),
        document.metadata.get("heading_path", []),
    )
    print(document.page_content)
    print(document.metadata)

假设输入元素表达的是下面的层级:

RAG
├─ RAG 是一种检索增强技术。
└─ 文档解析
   └─ 解析阶段需要保留文档结构。

合并后会形成两个文本块:

RAG

RAG 是一种检索增强技术。
RAG > 文档解析

解析阶段需要保留文档结构。

这样既保留了子章节的完整标题语境,又不会把“文档解析”的正文重复写入 RAG 的父级文本块。

注意:该函数只完成基于 parent_id 的第一轮结构合并。正式向量化前仍应根据 Embedding 模型的 Token 限制拆分过长块,并考虑是否将 ListItem、Table、CodeSnippet 等类型纳入标题块。heading_path 和 source_element_ids 是列表,写入 Milvus 前要确认当前 Collection Schema 或动态字段配置是否支持;否则可先序列化为字符串。

使用 OpenAI Embedding 对长文本进行语义切割

为什么合并之后还要切割

前面的 merge_narrative_by_title 解决了“标题和正文分离”的问题,但一个标题下可能包含几十个段落。把这些内容全部放进一个向量块,会产生两个新问题:

  • 文本块过大时可能同时包含多个主题,检索结果不够精确。
  • 检索只命中其中一小段内容时,仍要把整块内容交给后续模型,增加上下文和费用。

最简单的做法是每隔固定字符数切一次,但固定位置可能刚好落在一个句子或一个主题中间。语义切割的目标是在满足长度限制的同时,优先选择“前后内容关联较弱”的位置切开。

这里使用的不是聊天模型,也不是让模型重新概括原文。OpenAI Embedding 会把每个句子转换成一个由许多数值组成的向量;语义相近的文本通常会得到更接近的向量,因此可以用向量之间的距离估计两个句子的关联程度。OpenAI 官方文档也将 Embedding 定义为文本的数值表示,可用于衡量文本之间的相关性。

用四句话理解切分过程

假设标题下有四句话:

苹果含有多种营养成分。
香蕉也是常见水果。
量子力学研究微观世界。
光子具有波粒二象性。

先把四句话分别转换为向量,再比较每一对相邻句子。下面的数值只是为了说明过程,并非模型的真实输出:

相邻位置 内容关系 示例相似度 是否适合切分
第 1、2 句之间 都在讨论水果 0.92 不适合
第 2、3 句之间 从水果转到量子力学 0.18 适合
第 3、4 句之间 都在讨论物理 0.89 不适合

因此,比较自然的结果是:

文本块 1:苹果含有多种营养成分。香蕉也是常见水果。
文本块 2:量子力学研究微观世界。光子具有波粒二象性。

整个处理过程可以概括为:

flowchart LR A[标题下的长文本] --> B[按句末标点切成句子] B --> C[为每个句子生成 Embedding] C --> D[计算相邻句子的余弦相似度] D --> E[找出低相似度候选断点] E --> F[结合最大和最小长度组成文本块] F --> G[为每个子块重新添加标题路径]

余弦相似度在判断什么

代码中的 cosine_similarity 用来比较两个向量的方向是否接近。为了入门理解,可以先这样看:

  • 越接近 1:两个句子的向量方向越接近,通常表示语义关联较强。
  • 越接近 0:关联通常较弱,更可能是主题转换位置。
  • 出现负值:向量方向差异更大,但实际分布取决于模型和文本,不能把某个固定值当成通用结论。

为了让入门代码更短,下面直接使用 0.75 作为示例阈值:低于该值时,把当前位置视为候选切分点。这个数值不是通用标准;不同模型、语言和文档类型的相似度分布可能不同,正式使用时应通过样本文档重新评估,也可以进一步升级为按文档动态计算百分位阈值。

三个主要参数

参数 示例值 作用 调整影响
max_chars 1200 限制正文块的最大字符数 越小,块越多;越大,单块上下文越完整
min_chars 300 达到该长度后才允许因语义变化切分 越小,越容易产生短块
similarity_threshold 0.75 低于该相似度时,把边界视为主题转换候选点 越高,越容易切分;越低,切分越保守

max_chars 和 Token 不是同一个单位。这里用字符数只是为了让示例保持简单;生产环境仍应使用与模型匹配的 Tokenizer 进行最终检查。OpenAI Embedding API 还存在单条输入和单次请求总 Token 数限制,不能因为完成了字符切割就忽略接口限制。

输入和输出发生了什么变化

假设第一轮合并得到一个 Document:

page_content:
RAG > 文档解析

这里是一段很长的正文……

metadata:
heading_path = ["RAG", "文档解析"]
category = "MergedSection"

经过语义切割后,它会变成多个 Document。每个子块只包含部分正文,但都会重新带上:

RAG > 文档解析

这样,无论 Milvus 命中哪一个子块,都不会丢失“这段内容属于哪个章节”的信息。

准备依赖和 API Key

先安装 OpenAI 的 LangChain 集成包:

python -m pip install -U langchain-openai

再通过环境变量提供 API Key。下面的 PowerShell 设置只对当前终端会话生效:

$env:OPENAI_API_KEY = "sk-请替换为自己的密钥"

警告:不要把真实 API Key 写进 Python 源码、Markdown、版本库或日志。OpenAI 官方快速入门也建议将密钥保存在安全位置,并通过 OPENAI_API_KEY 环境变量提供给 SDK。

核心切割函数

import math
import re

from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings


def cosine_similarity(left: list[float], right: list[float]) -> float:
    """计算两个向量的余弦相似度。"""
    dot_product = sum(x * y for x, y in zip(left, right))
    left_norm = math.sqrt(sum(x**2 for x in left))
    right_norm = math.sqrt(sum(y**2 for y in right))
    return dot_product / (left_norm * right_norm)


def semantic_split_document(
    document: Document,
    embeddings: OpenAIEmbeddings,
    *,
    max_chars: int = 1200,
    min_chars: int = 300,
    similarity_threshold: float = 0.75,
) -> list[Document]:
    """在相邻句子语义变化明显的位置切分长文本。"""
    heading_path = document.metadata.get("heading_path", [])
    heading = " > ".join(heading_path)
    heading_prefix = f"{heading}\n\n" if heading else ""
    body = document.page_content
    if heading_prefix and body.startswith(heading_prefix):
        body = body[len(heading_prefix):]

    sentences = [
        sentence.strip()
        for sentence in re.split(r"(?<=[。!?!?])|(?<=\.)\s+", body)
        if sentence.strip()
    ]

    if len(sentences) <= 1:
        return [document]

    vectors = embeddings.embed_documents(sentences)
    body_chunks: list[str] = []
    current_sentences = [sentences[0]]

    for sentence_index, sentence in enumerate(sentences[1:], start=1):
        current_text = "\n".join(current_sentences)
        exceeds_limit = len(current_text) + len(sentence) + 1 > max_chars
        similarity = cosine_similarity(
            vectors[sentence_index - 1],
            vectors[sentence_index],
        )
        reaches_semantic_break = (
            similarity < similarity_threshold
            and len(current_text) >= min_chars
        )

        if exceeds_limit or reaches_semantic_break:
            body_chunks.append(current_text)
            current_sentences = [sentence]
        else:
            current_sentences.append(sentence)

    body_chunks.append("\n".join(current_sentences))

    split_documents: list[Document] = []
    for chunk_index, body_chunk in enumerate(body_chunks):
        metadata = dict(document.metadata)
        metadata["semantic_chunk_index"] = chunk_index
        metadata["semantic_chunk_count"] = len(body_chunks)
        split_documents.append(
            Document(
                page_content=f"{heading_prefix}{body_chunk}",
                metadata=metadata,
            )
        )

    return split_documents

接入现有处理流程

import os


if not os.getenv("OPENAI_API_KEY"):
    raise RuntimeError("请先设置 OPENAI_API_KEY 环境变量")

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
semantic_documents: list[Document] = []

for document in merged_documents:
    is_long_text = (
        document.metadata.get("category")
        in {"MergedSection", "NarrativeText"}
        and len(document.page_content) > 1200
    )
    if is_long_text:
        semantic_documents.extend(
            semantic_split_document(document, embeddings)
        )
    else:
        semantic_documents.append(document)

logger.info(
    "语义切割完成:切割前 {} 个块,切割后 {} 个块",
    len(merged_documents),
    len(semantic_documents),
)

按执行顺序阅读代码

第一次阅读时,可以先忽略类型标注和元数据更新,只关注下面七步:

  1. 接入代码检查 OPENAI_API_KEY,并创建 OpenAIEmbeddings。
  2. 只有 MergedSection、孤立的 NarrativeText 等正文类元素超过 1200 个字符时,才调用语义切割;短文本和表格等元素直接保留,也不会产生不必要的 Embedding 请求。
  3. semantic_split_document 暂时从 page_content 中移除标题前缀,只对正文进行切句和相似度计算。
  4. embeddings.embed_documents(sentences) 一次接收多个句子,为每个句子生成一个向量。
  5. cosine_similarity 逐一比较第 1、2 句,第 2、3 句,以此类推,并选择相似度较低的候选边界。
  6. 程序从前向后累积句子。达到候选语义断点且当前块不太短,或者加入下一句会超过 max_chars 时,就结束当前块。
  7. 最后为每个新 Document 恢复标题前缀,并复制原有元数据。

代码为切割后的文档额外加入了以下元数据:

字段 含义
semantic_chunk_index 当前子块在原文档中的序号,从 0 开始
semantic_chunk_count 原文档一共生成了多少个子块

similarity_threshold=0.75 只是便于理解的起始值。真正执行切分时,语义阈值、min_chars 和 max_chars 会共同决定结果,不能只调一个参数就判断方案好坏。

这段代码的适用边界

注意:这是一种“语义断点 + 长度上限”的入门实现,不是生产级分块器。为了缩短示例,超长单句、零向量、中文切句、英文缩写、代码块、表格、极短尾块、API 批处理、重试、限流和 Token 级硬上限均未完整处理。OpenAI Embedding 请求会产生 API 用量和费用,调试时应先使用少量样本文档。

Milvus 接入的基本形态如下,其中 embeddings 必须替换为实际选定并完成配置的 Embeddings 实例:

from langchain_milvus import Milvus


vector_store = Milvus(
    embedding_function=embeddings,
    connection_args={"uri": "http://localhost:19530"},
    collection_name="knowledge_documents",
)

inserted_ids = vector_store.add_documents(documents=semantic_documents)
logger.info("已写入 Milvus:{} 个分块", len(inserted_ids))

建议的练习步骤

  1. 创建一个包含多级标题、正文、无序列表、有序列表、表格、代码块和图片引用的 Markdown 测试文件。
  2. 分别使用 single 和 elements 模式加载,比较 Document 数量、page_content 和 metadata。
  3. 统计 metadata["category"] 的分布,观察实际类型名称和标题层级字段。
  4. 检查图片引用的实际输出,验证是否存在 Image、image_url、image_base64 或其他字段,不凭想象编写处理逻辑。
  5. 运行 merge_narrative_by_title,检查子标题是否带有完整 heading_path,孤立正文是否被保留。
  6. 根据实际样本扩展 ListItem、Table 等类型的合并规则,并记录合并前后的块数量和长度分布。
  7. 使用少量长文本测试语义切割,检查切分点、块长度和标题路径是否符合预期,再建立测试 Collection。
  8. 准备几个答案已知的问题,验证 Milvus 检索结果及来源字段。

今天的结论

今天最重要的收获不是“elements 一定比 single 好”,而是理解二者服务于不同阶段:

  • single 提供经过统一解析的全文,适合保留上下文或自行分块。
  • elements 暴露文档的语义结构,更适合进行结构化清洗、过滤和重组。
  • LangChain Document 用 page_content 存文本、用 metadata 存附属信息。
  • category 的准确值使用 PascalCase,例如 Title、NarrativeText 和 ListItem。
  • 图片、OCR 和 Base64 元数据需要单独确认文件类型、解析策略与参数。
  • 在写入 Milvus 前,还需要完成分块设计、Embedding、元数据规范、去重和检索验证。
posted @ 2026-09-15 22:16  天启A  阅读(29)  评论(0)    收藏  举报