RAG入门:Markdown 解析
UnstructuredMarkdownLoader 学习笔记: Markdown 解析
标签:Python、LangChain、Unstructured、Milvus、Tesseract
内容摘要:本文整理UnstructuredMarkdownLoader的加载模式、LangChainDocument数据结构和常见元素类型,并说明从非结构化文档解析到 Milvus 入库之间仍需完成的分块、向量化和元数据设计工作。适合正在搭建 RAG 文档摄取流程的开发者。
目录
- 学习目标
- 项目中的工具分工
- Unstructured 在做什么
- UnstructuredMarkdownLoader 的两种加载模式
- LangChain Document 的结构
- 常见元素类型
- 图片与 OCR 的处理边界
- 从解析结果到 Milvus
- 最小实践代码
- 建议的练习步骤
- 今天的结论
- 参考资料
学习目标
这套处理流程最终要解决的问题是:使用 Python 读取 PDF、Markdown 和图片等资料,将内容整理成适合检索的文本块和元数据,再生成向量并写入 Milvus。
本文先聚焦其中的“文档解析”阶段,尤其是 Markdown 文件经过 Unstructured 和 LangChain 后会得到什么。完整链路如下:
注意:
elements模式得到的是语义元素,不一定就是适合直接写入向量数据库的最终分块。标题或单个列表项可能过短,通常还需要合并、补充上下文或按标题分块。
项目中的工具分工
| 工具 | 主要职责 | 本流程中的位置 |
|---|---|---|
| Python | 编写解析、清洗、向量化和入库程序 | 整体开发语言 |
| LangChain | 统一 Document、Embedding、向量存储和检索接口 |
串联解析结果与 Milvus |
| Unstructured | 将不同格式的文件拆成统一的语义元素 | 文档解析与预处理 |
| Loguru | 记录文件处理进度、异常、元素数量和入库结果 | 可观测性与故障排查 |
| Tesseract | 从图片或扫描件中识别文字 | OCR 能力 |
| Milvus | 存储、索引和检索向量及其关联元数据 | 数据落库与检索 |
这里还缺少两个不能忽略的环节:
- Milvus 的 LangChain 集成包:当前官方集成使用
langchain-milvus。 - 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 中的:

本质上只是图片引用和替代文本。加载 Markdown 时,不能默认认为加载器会读取该图片文件、执行 OCR,并把图片二进制自动写入 metadata。
image_base64 和 image_mime_type 是条件性元数据。根据 Unstructured 官方说明,image_base64 主要适用于采用 High Res 策略检测到的图片或表格,并且需要相应的图片提取配置;它不是每个 Image 元素必然具有的字段。分块之后,Base64 数据也可能不再保留。
更稳妥的多模态处理方式是:
- 单独读取 Markdown 图片链接。
- 校验并定位图片文件。
- 对需要文字识别的图片调用 OCR。
- 对图表或照片生成说明文字。
- 将说明文字作为可检索文本,将图片路径、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 解析之后增加一次初步语义合并。目标不是一次得到最终向量块,而是先解决标题和正文彼此分离、子标题缺少上级语境的问题。
合并遵循以下规则:
- 根据
element_id和parent_id建立元素之间的关系,不依赖元素在列表中的相邻位置。 NarrativeText只合并到它直接归属的Title,不把整棵子树的正文重复塞入父标题块。- 子标题向上追溯父级标题,形成完整的
heading_path,并将标题路径放在合并文本开头。 - 没有直接正文的标题不生成空块,但仍可作为下级标题的语义上下文。
- 找不到有效父标题的
NarrativeText不会被丢弃,而是保留并标记为孤立正文。 ListItem、Table等本次未参与合并的元素保持原样,便于后续继续扩展规则。- 标题关系出现循环时停止追溯,并通过
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:量子力学研究微观世界。光子具有波粒二象性。
整个处理过程可以概括为:
余弦相似度在判断什么
代码中的 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),
)
按执行顺序阅读代码
第一次阅读时,可以先忽略类型标注和元数据更新,只关注下面七步:
- 接入代码检查
OPENAI_API_KEY,并创建OpenAIEmbeddings。 - 只有
MergedSection、孤立的NarrativeText等正文类元素超过1200个字符时,才调用语义切割;短文本和表格等元素直接保留,也不会产生不必要的 Embedding 请求。 semantic_split_document暂时从page_content中移除标题前缀,只对正文进行切句和相似度计算。embeddings.embed_documents(sentences)一次接收多个句子,为每个句子生成一个向量。cosine_similarity逐一比较第 1、2 句,第 2、3 句,以此类推,并选择相似度较低的候选边界。- 程序从前向后累积句子。达到候选语义断点且当前块不太短,或者加入下一句会超过
max_chars时,就结束当前块。 - 最后为每个新
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))
建议的练习步骤
- 创建一个包含多级标题、正文、无序列表、有序列表、表格、代码块和图片引用的 Markdown 测试文件。
- 分别使用
single和elements模式加载,比较Document数量、page_content和metadata。 - 统计
metadata["category"]的分布,观察实际类型名称和标题层级字段。 - 检查图片引用的实际输出,验证是否存在
Image、image_url、image_base64或其他字段,不凭想象编写处理逻辑。 - 运行
merge_narrative_by_title,检查子标题是否带有完整heading_path,孤立正文是否被保留。 - 根据实际样本扩展
ListItem、Table等类型的合并规则,并记录合并前后的块数量和长度分布。 - 使用少量长文本测试语义切割,检查切分点、块长度和标题路径是否符合预期,再建立测试 Collection。
- 准备几个答案已知的问题,验证 Milvus 检索结果及来源字段。
今天的结论
今天最重要的收获不是“elements 一定比 single 好”,而是理解二者服务于不同阶段:
single提供经过统一解析的全文,适合保留上下文或自行分块。elements暴露文档的语义结构,更适合进行结构化清洗、过滤和重组。- LangChain
Document用page_content存文本、用metadata存附属信息。 category的准确值使用 PascalCase,例如Title、NarrativeText和ListItem。- 图片、OCR 和 Base64 元数据需要单独确认文件类型、解析策略与参数。
- 在写入 Milvus 前,还需要完成分块设计、Embedding、元数据规范、去重和检索验证。

浙公网安备 33010602011771号