🔥 90% 的 RAG 答非所问,根子不在 LLM 弱,而在入库时该召回的条款没召回、或召回了一堆噪声 。 这篇把「离线入库」从三段拆到七环,每一环都配真实可运行代码(LangChain + Milvus),连《防爆规范》的完整脚本都给你端上来了。 建议先赞后看------内容太长,划走就找不回了。
写在前面:RAG 跑起来,其实是三件不同的事
RAG 本质上在三个不同时间点干三件不同的事:
- 索引构建(离线):把知识文档,变成「向量 + 元数据」,落进向量库。这件事在用户提问之前就跑完了。
- 检索(在线):用户问一句话,把这句话也变成向量,去库里找最近的 K 个片段。
- 生成(在线):把召回的片段拼进 prompt,交给 LLM 产出答案。
很多人刚开始做 RAG,注意力全在「检索」和「生成」上,因为那两部分用户能直接感知。但索引构建这一段,决定了后面能搜到什么、搜得准不准。它叫「离线」,不是因为它不重要,而是因为:
💡 文档不会每分钟变 ,预处理一次、查询千次,这笔账怎么算都划算;而且入库慢一点没人骂,查询慢 200 毫秒就有人跳。
离线入库,就是「索引构建」阶段的核心动作。
一、先把 RAG 的三段拆开看
把它和另外两段划清边界,才知道每段该操心什么:
| 维度 | 离线入库 | 在线检索 | 在线生成 |
|---|---|---|---|
| 触发时机 | 文档上传/更新时 | 每次用户提问 | 每次用户提问 |
| 输入 | 原始文档(PDF/Word/网页) | 用户 query 文本 | 召回片段 + query |
| 输出 | 向量 + 元数据 + 索引结构 | Top-K 相关片段 | 自然语言答案 |
| 延迟容忍 | 分钟级都行 | 百毫秒级 | 秒级 |
| 用到的模型 | Embedding | Embedding(query 侧) | LLM + rerank |
两个容易混的边界,单独点一下:
- 和检索的边界 :离线产出的是「向量 + 索引结构」,在线只做一件事------「给定 query 向量,找最近的 K 个」。在线不生产向量以外的东西,它依赖离线把地基打好。地基歪了,在线怎么优化都救不回来。
- 和生成的边界:离线完全不管答案长什么样,它只负责「喂对上下文」。这点特别关键------很多 RAG 答非所问,根子不在 LLM 弱,而在入库时该召回的条款没召回、或者召回了一堆噪声片段,LLM 再强也只能基于错误上下文编。
📌 一句话定位:离线入库负责「把文档变成能被向量搜到的东西」,检索和生成都不归它管。
二、全流程总览:先有地图
离线入库是一条流水线,七个环节首尾相接。前一步的输出,是后一步的输入;任何一步偷工,后面全跟着歪。
每个环节一句话定位:
| 环节 | 它解决什么 | 输出物 |
|---|---|---|
| 文档解析 | 把 PDF/Word 的「格式」还原成「纯文本+结构」 | 带结构的文本(含页码/标题) |
| 文本清洗 | 去掉噪音、统一口径 | 干净文本 |
| 分块 | 把长文切成检索单元 | 一个个 chunk |
| 向量化 | 让文本可被计算距离 | 高维向量 |
| 向量存储 | 把向量+原文落库 | Milvus 里的实体 |
| 元数据管理 | 让「按业务维度过滤」成为可能 | 标量字段 |
| 索引更新 | 让库能快速被搜、且能增量维护 | 索引结构 |
下面逐个拆,每个都按「为什么 → 原理 → 工程细节」往下走,不跳步。
三、文档解析:把「格式」还原成「纯文本」
为什么:知识源不是纯文本。它们各有各的「格式容器」------PDF 混着标题层级和表格、Word 是 zip 包里的 XML、网页是一堆标签、Markdown 自带层级。Embedding 模型只吃文本,所以第一步必须把容器拆开,拿出文字,同时尽量保留结构信号(哪一章、第几页、是不是表格)。
解析的本质是「拆容器」------不同格式内部结构完全不同,没有哪个解析器能通吃,得按格式选。下面把常见格式逐个过一遍:先说它怎么被处理,再给一段真实可运行的代码,最后点几个容易踩的坑。所有加载器都来自 langchain-community(pip install langchain-community);其中 Unstructured* 系列还需 pip install unstructured。每个 loader 返回统一的 Document 对象(page_content 文本 + metadata 元数据),天然衔接后面的切分与向量化------这是 RAG 流水线里最标准的做法。
🔍 快速索引:我要处理什么格式?
实际工作里你往往是「遇到某种格式 → 回来查」,而不是从头读到尾。先按格式定位:
| 我要处理的格式 | 跳到 |
|---|---|
| PDF(文字可选中) | 4.1 |
| PDF(扫描件 / 无文本层) | 4.1 末段(OCR 路线) |
| Word / .docx | 4.2 |
| 网页 / HTML | 4.3 |
| Markdown / .md | 4.4 |
| 纯文本 / .txt | 4.5 |
| 演示文稿 / .pptx | 4.6 |
| 表格 / .xlsx、.csv | 4.7 |
| 混合文件夹(多种格式) | 4.8 |
4.1 PDF(最复杂,先讲它)
PDF 本身又分为两种,选错路后面全歪:
- 数字版(文字可选中):直接抽,几十毫秒一页。
- 扫描件(文字是图片) :抽不到字,必须先 OCR(
pdf2image+pytesseract/PaddleOCR)。怎么判断?用解析库抽一页get_text(),为空就是扫描件。
Python 里「解析 PDF」不是只有一种做法,按能力从浅到深:
- 纯文本抽取(pdfminer.six):最底层,精细控制抽哪几页、什么编码,只要正文不在乎版面时用。
- 文本 + 坐标(PyMuPDF / fitz):抽取同时拿到每句话的页面和坐标框,做「溯源 / 高亮」首选。
- 版面与表格分析(pdfplumber):拿到单词、行、矩形、表格单元格,表格密集文档最稳。
- 开箱即用的元素切分(unstructured) :直接拆成
Title/NarrativeText/Table结构化元素,RAG 起步最快。 - 表格专项(Camelot / Tabula):规则表 / 无边框表准确率最高。
- 扫描件 OCR(pdf2image + pytesseract / PaddleOCR):专治文本层为空的扫描件。
- 复杂混排版面(MinerU / marker):论文、研报含公式 + 图表 + 多栏,需版面检测模型。
什么时候用哪个(决策表):
| 文档特征 | 推荐方案 |
|---|---|
| 要快、且要溯源到「第几页哪段」 | PyMuPDF |
| 表格多、要精确单元格 | pdfplumber / Camelot |
| 扫描件、图片型 PDF | pdf2image + pytesseract(先 OCR) |
| 不想自己拼管线、直接要结构化元素 | unstructured |
| 论文 / 研报含公式 + 图表 + 多栏 | MinerU / marker |
| 只要正文、精细控制 | pdfminer.six |
用 LangChain 的 PyMuPDFLoader(底层就是 PyMuPDF/fitz)直接拿到带页码元数据的 Document:
python
from langchain_community.document_loaders import PyMuPDFLoader
loader = PyMuPDFLoader("危险场所电气防爆安全规范.pdf")
docs = loader.load() # 每页一个 Document
print(docs[0].metadata) # 含 'page'(从 0 计)、'total_pages'、'source' 等
print(docs[0].page_content[:80])
page 元数据就是后面做「溯源到哪一页」的锚点;密码保护的 PDF 可传 password=,extract_tables="markdown" 还能直接把表格抽成 Markdown。
4.2 Word / .docx
.docx 是 OOXML(ISO/IEC 29500)格式的 ZIP 压缩包,里面是一组 XML 部件:正文在 word/document.xml、样式在 word/styles.xml,还有 [Content_Types].xml、_rels/、媒体目录等。把后缀改成 .zip 解压就能看到。所以它不能直接当纯文本读------那样标题层级、表格结构全没了,召回时连「这条出自哪一章」都说不清。
处理时最省事的做法是整篇读成一个文本块:
python
from langchain_community.document_loaders import Docx2txtLoader
loader = Docx2txtLoader("防爆设备选型手册.docx")
docs = loader.load() # 整篇一个 Document
print(docs[0].page_content[:200])
如果文档里标题层级、表格很多,你想把这些结构留下来(后面分块、按条款过滤都要用),就按元素拆开:
python
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
loader = UnstructuredWordDocumentLoader("防爆设备选型手册.docx", mode="elements")
docs = loader.load() # 每个 Title / Table / 段落 各成一个 Document
几个要注意的坑:
- 整篇读成一个块,遇到几十页的手册会很臃肿,下游分块还是得自己切,这一步只是「先拿出来」,结构信息没保留。
mode="elements"拆得细,但表格会被单独拆成一个Table元素,正文和表格容易脱节,需要时得自己把上下文拼回去。- 含图片、文本框、批注的复杂排版,纯文本 loader 拿不到图里的内容,必要时上 OCR 或 unstructured 的
strategy="hi_res"。
4.3 HTML / 网页
网页正文混在一堆标签里:<script>、<style>、导航栏、广告,直接取文本全变噪音。处理的关键是先把正文之外的标签剥掉,再拿文本。
BSHTMLLoader 底层用 BeautifulSoup,顺手把 <title> 写进 metadata:
python
from langchain_community.document_loaders import BSHTMLLoader
loader = BSHTMLLoader("help_center.html", open_encoding="utf-8")
docs = loader.load()
print(docs[0].metadata) # {'source': '...', 'title': '页面标题'}
print(docs[0].page_content[:200])
几个坑:
- 很多页面靠 JS 渲染内容,
BSHTMLLoader只读静态 HTML,动态加载的部分拿不到,这种情况得先用无头浏览器爬出渲染后的 HTML 再喂进来。 - 正文里嵌的广告、相关推荐区块,BeautifulSoup 的默认解析不一定剥得干净,必要时自己传
bs_kwargs指定要保留或剔除的标签。 - 中文网页编码乱的,
open_encoding="utf-8"要按实际文件设,否则出来一堆乱码。
4.4 Markdown / .md
Markdown 是最友好的格式------本身就有结构(# 标题、表格、代码块、列表),不需要复杂的解析器,按语法规则拆就行。处理的核心是「按标题层级切,同时把层级信息留作元数据」,这样后面既能按章节过滤,又不会把不同章节混进同一个块。
MarkdownHeaderTextSplitter 直接按你指定的标题层级切,并把层级写进 metadata:
python
from langchain_text_splitters import MarkdownHeaderTextSplitter
markdown_text = open("api_docs.md", encoding="utf-8").read()
headers_to_split_on = [("#", "h1"), ("##", "h2"), ("###", "h3")]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
docs = splitter.split_text(markdown_text)
print(docs[0].metadata) # {'h1': '...', 'h2': '...'}
print(docs[0].page_content[:80])
只想整篇当一个 Document 时,用 UnstructuredMarkdownLoader("api_docs.md").load() 即可。
坑:标题层级没写规范时(比如通篇只有 ### 没有 #),metadata 会缺上层级,过滤时容易对不上,切之前最好先把文档的标题层级理清楚。
4.5 纯文本 .txt
纯文本没有结构容器,处理起来最简单------读进来就行,剩下的「怎么切」留给第六章分块决定。唯一要留意的是编码,中文文件常常不是 utf-8。
TextLoader 带 autodetect_encoding=True 能自动猜编码:
python
from langchain_community.document_loaders import TextLoader
loader = TextLoader("README.txt", autodetect_encoding=True)
docs = loader.load()
print(docs[0].page_content[:80])
坑:超大单文件(几十 MB 一行到底的日志)直接读会爆内存,这种得按行流式读、边读边切,别等整篇 load 完。
4.6 演示文稿 .pptx
.pptx 和 .docx 同源,也是 OOXML 的 ZIP 包,但内容按「幻灯片 → 形状 → 文本框」组织。文字散落在各种形状里(文本框、表格、SmartArt),处理时要遍历每一页的所有 shape 才能拿全。
UnstructuredPowerPointLoader 会自动遍历所有 slide 的 shape,把每页 / 每个元素读成 Document:
python
from langchain_community.document_loaders import UnstructuredPowerPointLoader
loader = UnstructuredPowerPointLoader("培训材料.pptx")
docs = loader.load()
print(docs[0].page_content[:200])
坑:
- 一页里图文混排时,图片里的文字拿不到,需要 OCR 或
strategy="hi_res"。 - 演讲者备注(notes)默认不在
page_content里,如果是重要信息得单独读slide.notes_slide。 - 表格、图表被当成形状处理,提取出来可能是零散文本,语义不一定连贯。
4.7 表格数据 .xlsx / .csv
表格是结构化行列,没有自然语言段落。处理的关键是不能把整张表当一个块(一行里又含型号又含价格又含厂商,语义太杂),通常「一行」或「一个表」作为一个检索单元更合理。
Excel 用 UnstructuredExcelLoader 按表格 / 区域拆成 Document:
python
from langchain_community.document_loaders import UnstructuredExcelLoader
loader = UnstructuredExcelLoader("设备清单.xlsx", mode="elements")
docs = loader.load()
print(docs[0].page_content[:200])
CSV 用 CSVLoader,每行自动成一个 Document,page_content 写成 列名: 值:
python
from langchain_community.document_loaders import CSVLoader
loader = CSVLoader("设备清单.csv", encoding="utf-8")
docs = loader.load()
print(docs[0].page_content) # 型号: XXX | 价格: ... | 厂商: ...
坑:
- 多 sheet 的 Excel 默认只读第一个,
sheet_name=None才能全读,记得确认有没有漏。 - 列特别多的宽表,一行拼出来的文本会很长,下游分块时容易超限,必要时只挑关键列。
- 合并单元格、表头跨行这类结构,loader 不一定还原得对,出来可能是错位文本,得抽样核对。
4.8 一个 loader 通吃所有格式:LlamaIndex SimpleDirectoryReader
如果知识库是「一个文件夹里混着 pdf / docx / html / md / pptx / xlsx」,不想给每种格式写一套代码,LlamaIndex 的 SimpleDirectoryReader 会自动按扩展名识别并加载(pip install llama-index-core):
python
from llama_index.core import SimpleDirectoryReader
reader = SimpleDirectoryReader(input_dir="./knowledge_base", recursive=True)
docs = reader.load_data() # 自动识别 pdf/docx/html/md/pptx/xlsx 等
print(len(docs), docs[0].metadata) # 每个文件带 file_path / file_name / file_type 等元数据
它返回的 Document 与 LangChain 的不是同一个类,但思路一致:加载即带元数据,直接喂给后续的切分与向量化。
4.9 一张总决策表(格式 → 工具 → 输出)
| 格式 | 内部结构 | 推荐工具 | 解析输出 |
|---|---|---|---|
| PDF(数字版) | 文本层 + 版面 | PyMuPDF / pdfplumber / unstructured | 带页码/坐标的文本 + 表格 |
| PDF(扫描件) | 图片 | pdf2image + pytesseract(先 OCR) | OCR 文本 |
| .docx | OOXML zip 包 | python-docx / partition_docx | 段落(含样式) + 表格 |
| HTML / 网页 | 标签树 | trafilatura / BeautifulSoup | 正文纯文本 |
| .md | 层级语法 | 按标题切分 / markdown 库 | 章节块 |
| .txt | 无结构 | 直接读 | 全文 |
| .pptx | OOXML 幻灯片 | python-pptx / partition_pptx | 每页文本 |
| .xlsx / .csv | 行列 | pandas | 行/表文本 |
(更多格式------PDF 表单、Email .eml、JSON、Notion 导出------思路一致:先弄清「容器内部长什么样」,再选能读那个内部结构的库。)
要注意的坑:
- 扫描件没文本层,直接拿不到字,得先 OCR,否则入库的是空字符串。
- 表格被解析成乱序文本,单元格顺序错乱,语义全毁。
- 页眉页脚(「第 X 页 / 共 Y 页」「公司机密」)混进正文,变成噪音向量。
- 多栏排版(边栏小字)被按阅读顺序串行抽,上下文被打断。
- Word 用错库:拿
open()直接读 .docx,看到的是 ZIP 二进制乱码,不是文字。
反例:有人图省事,直接把 PDF 抽成全文一个字符串就送下游。结果分块时分不出条款边界,用户问「第 4.2 条」,召回的片段里混着第 3 章和第 5 章的废话。解析阶段丢掉的结构,后面花十倍力气也补不回来。
🎯 一句话:解析选型的优先级是「能带结构就别只抽字」------坐标、样式、标题层级、表格分隔,都是后面分块、过滤、溯源的锚点。
四、文本清洗:把噪音洗掉,把结构留下
📌 痛点:不洗,文本就带着垃圾进向量库
解析出来的文本不是干净的。它夹着页眉页脚、页码、乱码、重复段落、超链接、广告,HTML 源还带着 <script> <style> 残留。这些噪音一旦进向量库,后果很直接:
- 页眉页脚混进正文,等于往向量里掺了一堆「第 X 页 / 公司机密」这种无意义片段,检索时跟正经条款抢相似度;
- 乱码引号、全角半角不统一,让「RAG」和「RAG」被 Embedding 当成两个词,召回率无声无息地掉;
- 重复段落、广告语占着向量空间,召回结果一堆 boilerplate,LLM 拿到的是噪声上下文,答非所问。
一句话:清洗这步偷工,噪音直接变成「假相关」,后面 rerank 再强也救不回。
💡 为什么:清洗有「度」,保结构比「看着干净」重要
但反方向走极端也不行------不是越干净越好。把「4.2.1」这种条款编号、把标题层级、把表格分隔线也当噪音清掉,下游分块就切不出语义边界,按条款过滤、按章节溯源全做不了。
所以这里的核心矛盾是:噪音要去掉,结构信号要保住。标题层级、表格分隔、条款编号,是后面分块和元数据的锚点,洗的时候一根都不能动。
生产系统对这件事的共识高度一致------「保结构、去噪音」,而不是无脑删。而且有意思的是,看几个真正跑在生产里的开源 RAG 项目(Haystack、unstructured、LangChain、RAGFlow),它们把「清洗」都实现成一串可组合、有固定顺序的标准动作,而不是一把梭的正则。下面把这几个落地项目的真实做法摊开看。
🔧 怎么解决:看真实项目怎么做
Haystack 的 DocumentCleaner:带固定顺序的清洗流水线
Haystack(deepset 出品的 RAG 框架)在索引管线里专设了一个 DocumentCleaner 组件,位置就在「转换器之后、分块之前」。它的全部清洗动作都在初始化时声明,而且严格按固定顺序执行:先压缩多余空白,再去空行,再删指定子串,再跑正则,最后做跨页的页眉页脚去重。
python
from haystack import Document
from haystack.components.preprocessors import DocumentCleaner
# 真实 API:每个参数都是 DocumentCleaner 的真实初始化项
cleaner = DocumentCleaner(
remove_empty_lines=True, # 删空行
remove_extra_whitespaces=True, # 压缩多余空白(含 \xa0、连续换行)
remove_substrings=None, # 指定要删的固定字符串,如广告语
remove_regex=None, # 正则匹配到的内容整段删掉
remove_repeated_substrings=False, # 跨页去重(页眉页脚),详见下
unicode_normalization="NFKC", # 先归一 Unicode,再跑其它步骤
ascii_only=False,
)
doc = Document(content="这是待清洗的文档\n\n\n要去掉的声明")
result = cleaner.run(documents=[doc])
print(result["documents"][0].content)
它最值得抄的一个细节是跨页页眉页脚去重 :remove_repeated_substrings=True 会把各页里重复出现的内容(页眉、页脚、「公司机密」水印)当噪音去掉,前提是文本里的页与页用换页符 \f 分隔------TextFileToDocument、AzureOCRDocumentConverter 这些转换器会自动加上 \f。换句话说,真实生产里页眉页脚不是靠「第 X 页」正则硬匹配,而是靠「每页重复出现 → 判定为页眉页脚」来识别,误伤正文的概率低得多。
unstructured 的清洗砖块:一个个可插拔的 str→str 函数
unstructured 是很多 RAG 项目的解析底座,它把清洗做成了一组可插拔的「砖块」函数 (都在 unstructured.cleaners.core 里),每个都是纯 str → str,能单独用,也能串成链。
python
from unstructured.cleaners.core import (
clean, clean_extra_whitespace, clean_bullets,
clean_dashes, clean_trailing_punctuation, replace_unicode_quotes,
)
# clean 是组合砖块,可按选项开关子动作
clean("● An excellent point!", bullets=True, lowercase=True) # → "an excellent point!"
clean("ITEM 1A: RISK-FACTORS", extra_whitespace=True, dashes=True) # → "ITEM 1A: RISK FACTORS"
# 更常见的用法:作为 loader 的 post_processors,解析后自动串起来跑
from unstructured.cleaners.core import clean_extra_whitespace, remove_punctuation
# post_processors=[clean, remove_punctuation, clean_extra_whitespace]
它还有个很实用的模式:文档元素自带 apply,把一个清洗函数直接作用到元素上,不用新建对象:
python
from unstructured.documents.elements import Text
element = Text("Philadelphia Eaglesâ\x80\x99 victory")
element.apply(replace_unicode_quotes) # 把乱码引号换成正常引号
print(element) # → "Philadelphia Eagles' victory"
注意 replace_unicode_quotes 这类函数------它处理的是解析时常冒出来的 Unicode 乱码引号(如 \x91\x92)。这正是「乱码污染向量」的那类噪音,生产里靠这类定向砖块处理,而不是大范围正则。
LangChain 的 BeautifulSoupTransformer:专治 HTML 噪音
网页类文档的噪音集中在标签上------<script>、<style>、导航栏、广告区块。LangChain 的 BeautifulSoupTransformer 就是干这个的:指定要剔掉的标签、要保留的标签,剩下的就是正文。
python
from langchain_community.document_transformers import BeautifulSoupTransformer
bs_transformer = BeautifulSoupTransformer()
docs = bs_transformer.transform_documents(
docs,
unwanted_tags=("script", "style"), # 直接删掉的标签
tags_to_extract=("p", "li", "div", "a"), # 只保留这些标签里的文本
remove_lines=True, # 顺手清掉多余空行
unwanted_classnames=("nav", "footer", "ad"),# 按 class 名剔广告/导航
remove_comments=False,
)
它的实现就是拿 BeautifulSoup 把 script/style 整个 decompose() 掉,再只抽取指定标签的可见文本------和第四节讲的「先剥标签再取文本」一个道理,只是这里用现成组件,不用自己写 BeautifulSoup 样板。
RAGFlow 的视觉式清洗:当正则不够用时
前面三套都是「拿到文本后做规则清洗」。但面对多栏论文、带水印的扫描件、跨页表格这类脏文档,光靠正则往往失灵------页眉页脚位置不固定、多栏阅读顺序会乱、表格结构会被打散。
RAGFlow(InfiniFlow 开源的 RAG 引擎,生产落地很广)的做法是把清洗前置到「版面识别」阶段 :它的 DeepDoc 引擎用 OCR + 表格结构识别(TSR)+ 文档版面识别(DLR)三个视觉模型来「看」文档,能识别 10 类版面元素------正文、标题、图、图注、表格、表注、页眉、页脚、参考文献、公式。识别出来后,页眉/页脚/页码/水印直接判定为非内容、自动丢弃;多栏排版按正确阅读顺序拼接;表格结构还原成 LLM 好理解的句子。也就是说,真实生产里的「清洗」在难文档上其实是和解析一起完成的,靠的是布局理解而非字符串规则。
把这些动作拼成你自己的清洗器
框架的组件很全,但落到自己的管线,往往只需要组合其中几块。下面是参照上面几套真实实现、用已核实的工具拼出来的一个清洗器------它用 unstructured 的真实函数做空白与乱码归一,用标准库做 Unicode NFKC 归一(和 Haystack 的 unicode_normalization="NFKC" 同一个动作),再用针对性的正则处理中文文档特有的页码/分隔线,同时刻意保留条款编号等结构信号:
python
import re, unicodedata
from unstructured.cleaners.core import clean_extra_whitespace, replace_unicode_quotes
def clean_text(text: str) -> str:
# 1) Unicode 归一:全角"RAG"→半角"RAG",和 Haystack 的 NFKC 动作一致
text = unicodedata.normalize("NFKC", text)
# 2) 乱码引号等定向清理(unstructured 真实函数)
text = replace_unicode_quotes(text)
# 3) 压缩多余空白与空行(unstructured 真实函数,处理 \xa0/连续换行)
text = clean_extra_whitespace(text)
# 4) 中文文档特有噪音:页码行、分隔线(保留 4.2.1 这类条款号)
text = re.sub(r"第\s*\d+\s*页.*", "", text)
text = re.sub(r"^\s*---{3,}\s*$", "", text, flags=re.M)
return text.strip()
print(clean_text("RAG 技术\n\n第 3 页\n\n 向量检索 ---------\n\n原理......"))
注意第 4 步只删「第 X 页」和分隔线,条款号 4.2.1 原样保留------这就是「保结构、去噪音」的边界。如果图省事把数字全清了,后面按条款过滤就没法做了。
要注意的坑:
- 过度清洗:把条款编号、标题层级也当噪音清掉,下游分块和元数据抽取全失锚。保结构优先于「看着干净」。
- 页眉页脚靠正则硬匹配 :用
"第.*页"这类正则常常误伤正文里的「第 4 章第 3 页有这样的规定」,生产系统(如 Haystack)改用「每页重复出现 → 判页眉页脚」更稳。 - Unicode 不归一:全角/半角、兼容字符不统一,同一词被 Embedding 当成两个,召回率掉。NFKC 是生产标配动作,别省。
- HTML 只剥标签不剔 class :
<div class="ad">里的广告正文还在,得按unwanted_classnames把广告/导航 class 一并剔掉。 - 乱码引号/特殊字符 :解析出来的
\x91类乱码引号,靠replace_unicode_quotes这类定向函数处理,大范围正则反而误伤。 - 表格被当正文洗:表格一旦拆成散文本,结构全毁;保留它的 Markdown/JSON 结构(呼应第四节),别在清洗阶段把它拍平。
五、分块(Chunking):整篇最关键的一环
这一步值得花最多篇幅,因为90% 的检索效果差,根子在分块。分块切烂了,后面换多强的 rerank、调多细的参数都救不回。
📌 为什么必须分块
一句话:向量检索的最小单位是「块」,分块方式直接决定了「对的问题能不能映射到对的片段」。之所以非切不可,根子在四个地方:
- 模型吃不下整篇。Embedding 模型都有最大输入长度,超长文本会被截断------几百页的规范、几十万字的手册,根本塞不进一个输入。长文必须先切成模型能接受的单位,再逐个向量化。(各家模型上限不同,具体数值以你选用的模型官方文档为准,别凭记忆写死。)
- 一个向量 = 一段文本的平均语义 。Embedding 把一段文本压成一个向量,表达的是这段内容的整体语义倾向。块越大,里面挤的主题越多,向量越「平均」、越模糊------「防爆区域划分」和「设备温升限值」被挤在一个向量里,query 问前者时,这个向量既不全中也不全不中,排序就糊。这是分块的本质矛盾:精度 vs 完整性的拉锯。
- 块太小会丢上下文。反过来,块切得太碎,一句话被拦腰截断,单独看不知所云。「应符合 4.2 的要求」切成「应符合 4.2」和「的要求」两块,召回任何一块都拼不出完整意思。向量是孤立算的,跨块的指代和承接它管不了。
- 检索返回的是「块」,块就是上下文的边界 。RAG 召回 top-k 拿回来的就是 k 个块,LLM 只能基于这 k 个块作答。块切得边界合理,召回的上下文就完整;块切得乱,LLM 拿到的就是碎片,再强的模型也圆不回来。所以分块不是「预处理小事」,而是直接框定了检索和生成的质量上限。
目标就一句:每个块是一个完整、自洽的语义单元,同时不超出模型输入上限。
三问选型决策树(沿着路径往下走):
- Q1:文档有强结构吗? (标题 / 章节 / 条款层级清晰)
- ✅ 有 → 直接选文档结构分块(标准、手册、合同首选)
- ❌ 没有 → 进入 Q2
- Q2:内容是自然语言,还是代码 / 日志 / 数据表?
- 自然语言(文章 / 规范正文)→ 选递归字符 或句子级(中文记得补句号分隔符)
- 代码 / 日志 / 数据表 → 选固定大小 或Token 级(别按语义边界切,反而切碎结构)
- 无论哪种,都继续进入 Q3
- Q3:召回是不是当前系统的瓶颈? (多主题混合、跨引用多、长文档)
- ✅ 是 → 在 Q2 选型基础上,叠加语义 / 父子 / Late Chunking / 上下文增强
- ❌ 否 → 保持 Q2 的选型即可,别过早上复杂策略(成本高、难调试)
走完这三步,通常只剩 1~2 个候选,再往下看本节的详细展开和对比表做最终取舍。
🔧 几种分块策略,各自的取舍
下面把每种策略单独展开,讲清它怎么处理、适用与不适合,以及真实代码。
固定大小分块:按固定字符数切,配 overlap。实现最简单、速度最快、完全可控,但完全不认语义边界,常常在句子中间切断。只在文档无结构、格式极乱、或者只是想快速验证管线时用。
python
from langchain_text_splitters import CharacterTextSplitter
splitter = CharacterTextSplitter(
separator="", # 空串 = 逐字符切
chunk_size=500, # 每块 500 字符
chunk_overlap=50, # 相邻块重叠 50 字符
length_function=len,
)
chunks = splitter.split_text(text)
递归字符分块(RecursiveCharacterTextSplitter) :LangChain 的默认方案,按「段落 → 换行 → 空格 → 字符」的优先级递归拆,优先在语义边界停。绝大多数通用文档够用,是起步首选。不过它的默认分隔符只有 ["\n\n", "\n", " ", ""] (段落、换行、空格、字符),并不含句号/逗号------因为默认分隔器要兼顾代码、Markdown、数据表等非自然语言内容,盲目按句号切反而会造成不期望的碎片化。中文这类没有词边界的语言,通常会显式补充句子级分隔符,下面代码的 separators 就是这种自定义覆盖(不是默认值):
python
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 默认分隔符其实是 ["\n\n", "\n", " ", ""];下面为中文文本显式补充了句号/逗号(。,),属于自定义覆盖而非默认值
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", "。", ",", " ", ""],
)
docs = splitter.create_documents([text])
句子级分块 :先按句子边界切,再把句子攒到目标长度。句子是语言最自然的单位,不容易把一句话拦腰斩。LangChain 用 NLTKTextSplitter(需 nltk.download("punkt")),LlamaIndex 用 SentenceSplitter,思路一致。
python
from langchain_text_splitters import NLTKTextSplitter
splitter = NLTKTextSplitter(chunk_size=300, chunk_overlap=30) # chunk_size 单位为字符
chunks = splitter.split_text(text)
# LlamaIndex 等价写法
# from llama_index.core.node_parser import SentenceSplitter
# parser = SentenceSplitter(chunk_size=512, chunk_overlap=64)
# nodes = parser.get_nodes_from_documents([document])
Token 级分块(TokenTextSplitter) :按模型 tokenizer 的 token 数切,而不是字符数。关键好处是------切块大小和模型实际「看到」的单位一致,不会出现在字符层面切好、丢进模型却被 tokenizer 又拆一遍的错位。用和模型同款的 tokenizer(如 OpenAI 系用 cl100k_base)最准。
python
from langchain_text_splitters import TokenTextSplitter
splitter = TokenTextSplitter(
encoding_name="cl100k_base", # 与所用模型同款 tokenizer
chunk_size=256, # 每块 256 token
chunk_overlap=32,
)
chunks = splitter.split_text(text)
语义分块(SemanticChunker):不靠数字符,而是用 Embedding 算相邻句子的相似度,相似度骤降(话题切换)的地方才切。出来的块每个都话题纯净,检索召回最准。代价是入库时要为每个句子跑一次 Embedding,计算成本高,块大小也不固定。
python
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings # 换成你的 Embeddings 实现即可
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
splitter = SemanticChunker(
embeddings=embeddings,
breakpoint_threshold_type="percentile", # 也可 standard_deviation / interquartile
breakpoint_threshold_amount=90, # 在相似度落差最大的前 10% 处切
)
chunks = splitter.split_text(text)
父子分块(Small-to-Big / ParentDocumentRetriever):同一个文档存两份------小块(子块)用于精准检索,大块(父块,原文)用于喂 LLM。检索命中子块后,自动把对应的父块一起送进 prompt,兼顾「找得准」和「看得全」。下面是 LangChain 的实现(导入路径随版本变动,见代码注释),入库时子块向量化进向量库、父块进 docstore。
python
# ParentDocumentRetriever 在 LangChain 不同版本间导入路径有变动:旧版是
# from langchain.retrievers import ParentDocumentRetriever(经典包),
# 新版多数在 langchain_community.retrievers。以你本机实际可导入的路径为准。
from langchain_community.retrievers import ParentDocumentRetriever
from langchain_core.stores import InMemoryStore # 旧版路径为 langchain.storage
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
vectorstore = Chroma(embedding_function=OpenAIEmbeddings()) # 存子块向量
docstore = InMemoryStore() # 存父块原文(生产用 Redis/Postgres 持久化)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1500, chunk_overlap=100)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
search_kwargs={"k": 4}, # 召回 4 个子块,自动去重返回对应父块
)
retriever.add_documents(docs) # 入库:子块向量化、父块进 docstore
文档结构分块 :直接按文档原生结构(标题、章节、条款号)拆。格式规范的书籍、标准、产品手册最合适------防爆规范正是这种,按「章/节/条款」切,比数字符科学得多。Markdown 有现成组件,每块自动带上层级 metadata:
python
from langchain_text_splitters import MarkdownHeaderTextSplitter
headers_to_split_on = [
("#", "chapter"),
("##", "section"),
("###", "clause"),
]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
chunks = splitter.split_text(md_text) # 每块 metadata 带 chapter/section/clause
对 PDF/Word,结构是在解析阶段(第四章)就抽出来的,分块时直接按抽出的标题/条款层级切即可,道理一样。
Late Chunking(迟分,Jina AI) :传统做法是「先切块、再各自独立向量化」,块与块之间的指代就断了------「它」指谁、「本标准」指哪个,向量里没有前文。Late Chunking 反过来:先用长上下文 Embedding 模型把整篇编码成 token 级向量,再按边界池化成块向量。于是每个块向量都「看过」整段前文,跨句指代不丢失。Jina 的 jina-embeddings-v3 原生支持,API 加一个 late_chunking=True:
python
import os, requests
def late_chunk_embed(texts: list[str]) -> list:
# 每条 input 是一个待切的块(句子/段落);模型先整段编码,再按边界池化
resp = requests.post(
"https://api.jina.ai/v1/embeddings",
headers={"Authorization": f"Bearer {os.getenv('JINA_API_KEY')}"},
json={
"model": "jina-embeddings-v3",
"input": texts,
"task": "retrieval.passage",
"late_chunking": True, # 开启迟分
},
timeout=60,
)
return [d["embedding"] for d in resp.json()["data"]]
vectors = late_chunk_embed([
"防爆区域划分应符合 4.2 要求。",
"增安型设备温升限值见 5.3。",
])
注意它依赖模型的上下文窗口,超长文档要分段处理。
上下文增强分块(Anthropic Contextual Retrieval):前面几种都在「怎么切」,这种是在「切完之后」叠加一层------用 LLM 站在整篇视角,给每个块写一句上下文说明,拼回块前面再去向量化。这样即使块被单独召回,向量里也带着「它属于哪、讲什么」的全局信息。Anthropic 于 2024 年 9 月正式提出,是低成本提召回的实用招:其公开数据称 Contextual Embeddings + Contextual BM25 把检索失败率降低约 49%,再叠加 reranking 可降到约 67%。
python
from openai import OpenAI # 或任意 LLM
client = OpenAI()
def make_context(doc: str, chunk: str) -> str:
# 让 LLM 站在整篇文档视角,给这个块写一句上下文(不要复述块内容)
prompt = (
"你是文档处理助手。下面给出整篇文档和其中一个片段,"
"请只用一句话说明这个片段在整篇文档里的上下文(它讲的是什么、和前后文的关系)。\n\n"
f"<document>\n{doc}\n</document>\n\n"
f"<chunk>\n{chunk}\n</chunk>\n\n上下文:"
)
return client.chat.completions.create(
model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}],
temperature=0,
).choices[0].message.content.strip()
enriched = f"{make_context(doc, chunk)}\n\n{chunk}" # 拼回块前,再送去向量化
这套可以和任意一种切法叠加,尤其适合跨引用多的长文档(合同、论文、标准)。
📊 九种策略怎么选:什么场景用、什么场景别用
| 策略 | 适用场景 | 不适合场景 |
|---|---|---|
| 固定大小 | 无结构/格式极乱的文档、快速验证管线、对语义边界不敏感的批量预处理 | 任何需要保留句子/段落完整的正式文档(标准、合同会被切坏条款) |
| 递归字符 | 绝大多数通用文档(网页、技术文档、说明书),无显式或结构不规则时 | 格式高度规范、有强层级语义的文档(标准/法律,结构分块更准);topic 跳跃大的叙事文 |
| 句子级 | FAQ、短问答、句子是天然单位的文档;语义/父子分块的前置步骤 | 长段落必须保持一体(论证链)、段落结构比句子结构更重要的文档 |
| Token 级 | 用特定模型、想让块大小和 tokenizer 单位严格对齐避免二次切分错位 | 纯中文且无特定模型约束时,字符级已够,token 级反而引入 tokenizer 依赖 |
| 语义 | 多主题混合、topic 频繁切换(会议纪要、论文、杂文);检索召回是瓶颈时 | 作为默认(成本高、块大小不定、难调试);结构清晰的文档(结构分块更便宜且同等有效) |
| 父子 | 既要检索精准(小块)又要给 LLM 完整上下文(大块)的长文档问答 | 短文档(本身即一块,无父子之分);存储/延迟极度敏感(多存一份父块) |
| 文档结构 | 格式规范、有标题/章节/条款层级的书籍、标准、产品手册(防爆规范正是) | 无结构扫描件、结构不稳定的 HTML 网页、对话日志 |
| Late Chunking | 跨句/跨段指代多的长文档(合同、论文、标准),想零额外 LLM 成本保留全文上下文 | 超出模型上下文窗口的超大文档;不支持长上下文/mean pooling 的 Embedding 模型 |
| 上下文增强 | 跨引用多、单独块易丢全局信息的长文档(合同、论文、标准);愿用少量 LLM 调用换召回 | 预算敏感、文档短小(无跨块依赖时零提升);实时性要求极高的入库链路 |
🔗 重叠窗口(overlap):为什么需要、设多大
overlap 是相邻两个块之间故意重复的那段文本。它解决一个很具体的问题:关键信息恰好落在块边界时,单独任一块都不完整,召回就会漏。 比如「防爆区域划分应符合 4.2 要求」被切成「...防爆区域划分应符」和「合 4.2 要求...」,两块单独看都残缺;overlap 让这句话同时存在于两个块里,任一被召回都能还原完整语义。所以它不是可有可无的装饰,而是跨边界引用的「保险」。
设多大?行业共识是 chunk_size 的 10%--20%:中文通用块 200--500 字符配重叠 30--100 字符;专业文档(法律/医疗/标准)块 300--500 字符保精准;对话/日志按轮次切、块 200--300 字符。但 overlap 不是越大越好------过大意味着同一信息在多个块里重复,存储膨胀、检索可能重复命中、成本上升,20% 是兼顾连贯和效率的平衡点。
📊 分块大小与策略怎么选(按文档类型)
| 文档类型 | 推荐块大小(字符) | 推荐 overlap | 首选策略 |
|---|---|---|---|
| 通用文本/网页 | 500--800 | 10%--20% | 递归字符 |
| 技术文档/API | 300--800 | 50--150 | 结构感知 |
| 法律/标准/合同 | 500--1000 | 15%--25% | 文档结构 + 语义兜底 |
| 对话/客服日志 | 200--500 | 50--100 | 按轮次 |
| 学术论文/报告 | 1000--1500 | 100--200 | 按章节 |
落到防爆规范这种标准文档:最稳的是「按章节/条款结构分块 + 语义相似度兜底」。块定在 300--500 字符保精准,overlap 取 15% 左右,确保跨条款边界的引用不断。
六、向量化(Embedding):把文本变成可计算的坐标
为什么:计算机不懂「语义相似」,只懂「数值距离」。Embedding 模型把一段文本压成一个固定维的浮点向量,让「语义相近的文本在向量空间里挨得近」。检索的本质,就是算 query 向量和库里向量的距离,取最近的。
原理 :模型把 token 序列编码成高维向量。相似语义映射到相近坐标,用 cosine 距离(夹角余弦)度量相似度。cosine 只看方向不看长度,所以对向量模长不敏感------这点后面接归一化会再讲。
🔧 模型怎么选:按场景定,没有唯一答案
Embedding 模型没有「最好」,只有「最适合你场景」。挑之前先看四个旋钮:语言覆盖 (中文 / 多语)、是否要 dense + sparse 同出做混合检索 、数据能否出域 (本地 vs API)、预算与延迟。
| 模型 | 厂商 | 维度 | 多语 | 适合 |
|---|---|---|---|---|
| text-embedding-v3 | 阿里通义 | 1024(默认)/768/512/256/128/64 | 中/英为主 | 中文 RAG、dense+sparse 同出 |
| text-embedding-3-small/large | OpenAI | 1536 / 3072 | 多语 | 生态成熟、英文为主 |
| BGE-M3 | 智源 FlagEmbedding | 1024 | 100+ 语 | 多语、dense+sparse+multi-vector |
| gte-large / gte-qwen2 | 阿里 | 1024 / 3584 | 中/英 | 检索基准强 |
| bge-large-zh | 智源 | 1024 | 中文 | 纯中文经典 |
| Cohere embed | Cohere | 1024 / 4096 | 多语 | 官方 RAG API 集成好 |
具体场景怎么选:
- 纯中文 / 中文为主的 RAG:优先 text-embedding-v3、bge-large-zh、gte-qwen2,中文检索基准更强。
- 要 dense + sparse 同出做混合检索(语义 + 关键词双路):选 text-embedding-v3、BGE-M3,一条管线出两种向量,直接对接 Milvus 的 BM25 / hybrid search。
- 多语言(100+ 语):BGE-M3、Cohere embed、OpenAI text-embedding-3,跨语言检索更稳。
- 数据不能出域 / 要本地可控延迟:用 sentence-transformers / FlagEmbedding 跑开源权重(bge、gte 系列),不上 API、不传 Key;代价是要 GPU 资源和运维。
- 英文生态、想用现成工具链:OpenAI text-embedding-3-small/large,生态最成熟。
- 想少折腾、直接接官方 RAG 平台:Cohere embed,集成最顺。
本地 vs API 的取舍:API(上面这些)简单、效果稳,但要 Key、走网络、按量计费;本地部署(FlagEmbedding / sentence-transformers 跑开源权重)数据不出域、可控延迟,但要 GPU 资源和运维。个人学习和中小库,先用 API 跑通最划算。
下面示例用 text-embedding-v3,换成表里任意模型,调用方式一致,改 model= 和 dimensions= 即可。
📊 向量规格怎么定:维度、归一化、量化、距离度量
挑完模型,真正落库前还要把「向量长什么样」定清楚------这四件事互相独立、缺一不可,漏一个后面都得返工。
维度(dimension):向量有多长
维度就是向量里浮点坐标的个数,d 维向量就是 d 个 float,它直接决定每个向量占多少存储、检索时算多少次乘法。维度本质上是「语义容量 vs 成本」的旋钮:维度越高,能表达的语义区分越细,但存储、内存、建索引和检索的计算都线性上涨;维度过低,不同文本被压到相近坐标,召回就糊。实际落地时维度基本由模型说了算------要么模型固定输出某一维,要么调用时通过参数指定可选档位(具体能选哪些以你选用的模型官方文档为准,别凭记忆写死)。唯一一条硬约束是:Milvus 集合 schema 里的 dim 必须和模型实际输出的维度完全一致,对不上插入就报错。所以维度不是你随便拍的,而是「模型输出多少,schema 就写多少」;除非存储或内存真的吃紧,否则不要为了省而盲目降维,降维会损失语义区分度。还有一点要记牢:dim 在建集合时写死、建好不能改,哪天换了维度不同的模型,只能重建集合、重灌数据。
归一化(normalization):把向量压成单位向量
归一化指的是 L2 归一化,把每个向量按比例缩放成模长 ||v|| = 1 的单位向量(数学上 v' = v / ||v||)。为什么要做这一步?因为内积(IP / 点积)同时看「方向」和「长度」------如果向量模长不一,IP 会天然偏爱长向量,哪怕两段文本语义不沾边,只要某向量长,IP 值就高,排序就失真。归一化后所有向量等长,IP 退化成只看方向,等价于余弦。这里有两条路:一是入库前自己把所有向量 L2 归一化、再配 metric_type=IP;二是直接把 metric_type 设成 COSINE,让 Milvus 在比较时自动先归一化再算(Milvus 官方文档明确:未归一化的向量用 COSINE,引擎会按归一化后的向量计算,结果正确)。最稳是第二条路,建索引和查询都用 COSINE,别自己操心归一化------前面 Milvus 实战里讲过的「未归一化用 IP,top1 不是自己」那个坑,根因就是你用了 IP 却没归一化。
余弦 vs 点积:距离度量怎么选
Milvus 对稠密浮点向量提供 L2 / IP / COSINE 三种度量,文本 Embedding 常用后两种。COSINE 只比两个向量的「方向」、和模长无关,最贴合「语义越像、夹角越小」的直觉,也是 FLOAT_VECTOR 的默认度量;IP 既比方向也比模长,若向量已归一化,IP 在数学上严格等于 cosine、两者等价,但没归一化时 IP 会引入模长偏差。L2(欧氏距离)比的是空间里的直线距离,文本语义检索很少直接用,多见于图像等连续特征。最常见的陷阱就是拿「未归一化的文本向量 + IP」去检索,长文档向量天然占优,top1 可能不是最像的那条、而是最长的那条。结论是:文本 Embedding 默认用 COSINE,省心不出错;只有当你明确要利用「向量模长」携带的额外信息、且已控制好归一化时,才用 IP。
量化(quantization):用精度换内存
量化是用精度换内存。向量库要把向量加载进内存才能检索,大库全用 FP32(每维 4 字节),内存很快扛不住。量化的做法是把向量存成更低精度------字段级的 FP16 / BF16(2 字节)半精度类型或 INT8,以及索引级的标量量化 SQ8(每维压成 1 字节)、乘积量化 PQ(拆成子向量编码,压缩 4--32 倍)。收益很直接:内存能砍掉一大半甚至数倍,而召回率几乎不掉(Milvus 官方:SQ8 比 FP32 省约 75% 内存、保留合理精度)。两种落法:一是字段级精度,schema 里直接把向量字段声明成 FLOAT16_VECTOR / BFLOAT16_VECTOR(比 FP32 省一半)或 INT8_VECTOR,写入即生效;二是索引级量化,建索引时选带量化的类型------HNSW_SQ(sq_type 可选 SQ4U / SQ6 / SQ8 / BF16 / FP16)、IVF_SQ8、IVF_PQ,或者在 collection.load 时开 mmap 把索引映射到磁盘。量化是有损的,Milvus 会用 refiner 在候选集上用更高精度重排,把召回补回来。代价是极小的召回损失,对绝大多数 RAG 场景可忽略;集合上千万级、内存吃紧时才需要认真考虑,中小库 FP32 也跑得动,先别过早优化。
🔧 工程细节
- 批量向量化:一次请求传多条文本,比逐条调用快得多。注意每批总 token 数别超过模型的最大输入上限(具体以官方文档为准),否则会被截断或报错。
- 限流与重试:Embedding API 有 QPS 限制,大库灌入要控制并发、加重试退避,否则半路 429 断掉。
- API Key 只放后端:Embedding 调用走你自己的服务端,前端只传「用户问题」字符串。Key 进前端等于明文泄露------前面文章特意点过的安全点,入库链路同样适用。
下面是一次真实调用(OpenAI 兼容 SDK,DashScope):
python
import os
from openai import OpenAI
# Key 从环境变量读,绝不下发前端
client = OpenAI(
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", # endpoint 可能随地域/版本调整,以阿里云百炼官方文档最新地址为准
)
def embed(texts: list[str], dim: int = 1024) -> list[list[float]]:
# dimensions 控制输出维度;该值必须与 Milvus 集合 schema 的 dim 完全一致
resp = client.embeddings.create(
model="text-embedding-v3",
input=texts, # 支持传字符串数组批量向量化
dimensions=dim, # 模型支持的可选档位以官方文档为准
# encoding_format="base64" 可选,省带宽
)
return [d.embedding for d in resp.data]
# 用法
chunks = ["防爆区域划分应符合 4.2 要求......", "增安型设备温升限值......"]
vectors = embed(chunks, dim=1024)
print(len(vectors[0])) # 1024
Embedding 缓存:重跑管线时,别为同一段文本重复烧钱
向量化这一步最容易被人忽略的一笔账,是「重跑」。你头一回把整库灌进 Milvus,可能跑了几万次 API 调用;之后只要改了分块参数、动了清洗规则、或者某个源文档出了新版,整条管线往往得从头再跑一遍,那几万次调用又得重新烧一次。更现实的压力其实不是计费,是时间------大库重新向量化一遍,坐着等都让人心慌。
解法其实朴素:同一段文本,向量算过一次就够了。把「文本 → 向量」的结果按文本内容的哈希存起来,下次再遇到哈希一样的文本(重灌、增量更新、调试重跑都是这种情况),直接从缓存取,不再调 API。LangChain 里这一步不用自己写哈希表,用 CacheBackedEmbeddings 把原来的 Embedding 模型包一层就行,底层还是你那个真实模型,缓存本身落到 ByteStore------本地用 LocalFileStore,生产环境换成 RedisStore 让多进程、多机共享。
python
import os
from langchain_openai import OpenAIEmbeddings
# 导入路径:官方文档中 CacheBackedEmbeddings 在 langchain.embeddings、LocalFileStore 在 langchain.storage
#(langchain_core 里没有这两个类);最新版官方文档改用 langchain_classic.* 命名空间。
# 若其中一条导入报错,换另一条即可,以你本机实际可导入路径为准。
from langchain.embeddings import CacheBackedEmbeddings
from langchain.storage import LocalFileStore
# 底层还是你的真实模型(和前文 DashScope 调用同源,只是换成 LangChain 包装)
base = OpenAIEmbeddings(
model="text-embedding-v3",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", # endpoint 可能随地域/版本调整,以百炼官方文档最新地址为准
)
# 缓存落地:本地用 LocalFileStore,生产换 RedisStore
store = LocalFileStore("./embedding_cache/")
cached = CacheBackedEmbeddings.from_bytes_store(
base, store, namespace=base.model, # namespace 用模型名,避免不同模型向量串味
)
# 第一次:算完写入缓存;第二次遇到相同文本:直接读缓存,不再调 API
vecs = cached.embed_documents(["防爆区域划分应符合 4.2 要求......"])
vecs = cached.embed_documents(["防爆区域划分应符合 4.2 要求......"]) # 命中缓存
用的时候有个细节容易踩:缓存键是文本哈希,所以换了模型或者换了维度之后,旧的缓存会串味------namespace 用模型名(如上面 base.model)隔离就能避开。另外它默认只缓存文档嵌入、不缓存 query,这反而是对的:文档重灌才需要复用,query 每次都不同,缓存没意义。
缓存最值钱的地方在第十章------做增量更新时,绝大多数块的内容没变,直接命中缓存,等于白送一次「秒回」的重跑。还有个顺带的好处:如果你在第六章用了上下文增强分块,被向量化的 text 已经是「上下文 + 原文」,缓存键自然把这层上下文算进去,不用额外处理,这正是「先富化、再编码」在同一条管线里的顺序。
🔗 承上启下(向量化 → 存储的铁律) :第七章
embed()算出来的list[list[float]],要原样写进第八章的FLOAT_VECTOR字段。这里有一条硬约束:第七章embed()的dimensions取值,必须等于 第八章 schema 里FieldSchema(dim=...)的值------本文示例两边都是 1024,但上线时按你选用的模型实际档位填,改一处就要同步另一处。不一致,Milvus 在插入阶段直接报错,这条错在排查时最容易被忽略。
七、向量存储:把向量落进 Milvus
承接:第六章把整篇切成块、第七章把每块文本变成向量------这一环的任务,是把「每块 → 原文 + 向量 + 元数据」一起持久化,并且能被高效检索。
为什么 :向量算出来了,得有个地方存、且能被高效近邻搜索。Milvus 干的就是这个------它管「向量 + 原文 + 元数据」的持久化和检索,单条 insert 进来的就是上面那三样。
原理(schema 是怎么组织这三样的) :呼应《Milvus 实战》的 Schema 设计。一个 collection 里有三类字段:主键字段、向量字段(稠密用 FLOAT_VECTOR,稀疏用 SPARSE_FLOAT_VECTOR 或 BM25 函数派生)、若干标量字段(元数据)。重点:元数据字段在这一步就必须声明进 schema------具体该声明哪些、为什么,是第九章的主题;这里先把位置留好。
工程:建集合 → 插入 → load
防爆规范的 collection schema 设计示例:
python
from pymilvus import MilvusClient, DataType, FieldSchema, CollectionSchema
client = MilvusClient(uri="http://localhost:19530", token="root:Milvus")
fields = [
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=64),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=4096), # 原文,喂 LLM 用
FieldSchema(name="dense_vec", dtype=DataType.FLOAT_VECTOR, dim=1024), # 与第七章模型输出维度一致
FieldSchema(name="sparse_vec",dtype=DataType.SPARSE_FLOAT_VECTOR), # BM25 函数派生
FieldSchema(name="chapter", dtype=DataType.VARCHAR, max_length=32), # 元数据:章
FieldSchema(name="clause_no", dtype=DataType.VARCHAR, max_length=32), # 元数据:条款号
FieldSchema(name="source_file",dtype=DataType.VARCHAR, max_length=256), # 元数据:来源文件
FieldSchema(name="page", dtype=DataType.INT64), # 元数据:页码
]
schema = CollectionSchema(fields, enable_dynamic_field=True)
client.create_collection(collection_name="kb_ex_spec", schema=schema)
主键 id 怎么定(直接关系到第十章的增量) :不要随机生成,要用「文档来源 + 块序号」算一个确定性 id ,比如 md5(f"{source_file}#{chunk_idx}")[:16]。这样同一文档同一块永远同一个 id------第十章做 upsert 时能精准覆盖旧版、做 delete 时能精准命中,否则增量维护只能靠整篇表达式删除。
python
import hashlib
def chunk_id(source_file: str, chunk_idx: int) -> str:
return hashlib.md5(f"{source_file}#{chunk_idx}".encode()).hexdigest()[:16]
插入与 load :批量 insert 之后必须 load_collection 才能搜。很多人插完立刻 search 报「collection not loaded」,根因就在这。
承下 :落库之后,第九章讲这些元数据字段怎么填、在哪里被用(过滤 / 溯源);第十章讲文档变了怎么增量维护------它依赖这一章的确定性主键和第九章讲到的 source_file 等元数据字段。
八、元数据管理:让「过滤」成为可能
承接:第八章把元数据字段预留进了 schema,这一章讲------到底该留哪些字段、它们从哪来、又在哪里被用。
为什么(不存元数据会怎样):纯向量检索是「语义找近邻」,但业务常要「在第 4 章里找」「只搜 2025 年的文件」「排除已废止的条款」。这些精确过滤靠标量字段,也就是元数据。没有它,只能全量语义搜,既慢又容易答非所问。
怎么定:三类数据,去哪要想清楚
- 进 schema(定长、常过滤) :
chapter/clause_no/source_file/page/doc_type。这些是高频过滤维度,建进 schema、Milvus 会自动给标量建索引,查询时高效缩圈。 - 进动态字段(稀疏、不确定) :
tags、临时业务标签。开enable_dynamic_field=True当自由键值存,不提前定义列。 - 根本不存进元数据 :超大自由文本(原文本身)用
VARCHAR的text字段单独存,别塞进动态字段当元数据------会拖慢过滤。
元数据从哪来(具体哪里抽,不是只能手填)
- 解析阶段正则抽:第四章从文本里抽章号 / 条款号。两条正则就够用,定义成小函数挂在解析层,后面入库时直接调用:
python
import re
def extract_chapter(text):
m = re.search(r"第\s*(\d+)\s*章", text) # "第 4 章" → "4"
return m.group(1) if m else "?"
def extract_clause(text):
m = re.search(r"(\d+(?:\.\d+){1,3})", text) # 匹配 4.2 / 4.2.1 这类条款号
return m.group(1) if m else "?"
- 文件名 / 目录结构推断 :
2025版_防爆规范.pdf→year=2025、doc_type=规范;目录/标准/电气/→ 业务分类。 - 上游系统传入:CMS、业务库、审批流在推送文档时一并带上分类、生效日期。
- 人工标注:少量高质量标签(如「是否已废止」),成本可控。
元数据在哪用(具体哪里用,这才闭环) :元数据不在入库时生效,而在检索时 当过滤器。Milvus 的 search 支持 filter 表达式,先按标量缩圈、再做向量近邻------既快又准,也支持「按来源 / 章节 / 年份」溯源返回。
python
# 检索时:先按元数据缩圈,再做向量近邻(Milvus 真实 API)
results = client.search(
collection_name="kb_ex_spec",
data=[query_vec],
anns_field="dense_vec",
limit=5,
filter='chapter == "4" AND source_file == "防爆规范.pdf"', # 元数据过滤
output_fields=["text", "clause_no", "page"], # 连同元数据一并返回,供溯源
)
一句判断:元数据是入库阶段最容易欠考虑的。等上线了想「按业务维度过滤」,发现字段没存,只能回炉重灌。schema 设计时就该问一句:将来会不会按这个维度筛?会,就存成字段------第八章的 schema 已经把它们留出来了。
承下 :这些元数据字段里,source_file 还肩负第十章增量维护的「整篇删除」职责------改了一篇文档,靠它精准定位并删掉旧版所有块。
九、索引更新与增量:文档会变的
承接 :前面七环把文档落了库(第八章的确定性主键 + 第九章的 source_file 等元数据)。但知识库不是灌完就不动------规范出新版本、文档修订、过时条款作废,都要反映到库里。处理不好,库里残留旧向量,检索出过期答案。
为什么(不变更的代价):旧向量和新向量同时存在于库里,用户问「A 条款现在怎么规定的」,可能召回的是已废止的旧版。增量维护就是要让「库里的状态」始终等于「文档的当前状态」。
几种动作,按需组合
- upsert(覆盖写) :主键存在则覆盖、不存在则插入。依赖第八章的确定性主键------同一文档同一块 id 不变,新向量直接盖掉旧的,最省事。
- delete(删除) :按主键
ids=[...]精准删,或按表达式expr="source_file == 'old.pdf'"批量删整篇。后者依赖第九章的source_file元数据字段。 - compaction(整理) :合并小段、回收被删除标记占用的碎片,保持检索效率。版本说明:Milvus 2.6 的
client.compact()已经支持可选参数target_size/target_size_unit(例如client.compact("coll", target_size=512, target_size_unit="mb")控制合并后每个段的目标大小),并非 3.0 专属;3.0 更强调的是被单独命名为「force merge」的、把整库重排成更少更大段的集合级特性。日常增量维护用compact()足矣。 - 全量重建 vs 增量:小库(几百篇)改动后全量重灌最简单、最不易错;大库必须只重跑「受影响文档」那条管线做增量,否则重灌一次几小时。
工程:一条增量更新管线(串起第七~九章)
只重跑「受影响文档」的管线(解析→清洗→分块→向量化),其中向量化复用第七章的 Embedding 缓存;算完用确定性主键 upsert 新块、用 source_file 删掉旧版所有块。
python
def incremental_update(client, source_file: str, new_chunks: list[str], new_vecs: list[list[float]]):
# 1) 先按 source_file 删掉这篇文档的全部旧块(依赖第九章元数据字段)
# 安全:source_file 若来自用户上传或外部系统,直接拼 filter 会破坏表达式语法。
# Milvus 的转义规则是------双引号字符串内,双引号写作 \"(注意不是 SQL 的 '')。
safe_file = source_file.replace('"', '\\"')
client.delete(collection_name="kb_ex_spec",
filter=f'source_file == "{safe_file}"')
# 更稳的做法:入库前就对 source_file 做白名单校验(只允许字母/数字/点/下划线/中文等安全字符),
# 非法文件名直接拒绝,从源头消除这类问题,而不是靠转义兜底。
# 2) 用确定性主键 upsert 新块(依赖第八章 chunk_id;向量来自第七章,缓存命中则不调 API)
data = [{
"id": chunk_id(source_file, i),
"text": new_chunks[i],
"dense_vec": new_vecs[i],
"source_file": source_file,
# chapter / clause_no / page 等元数据同样从解析阶段抽好带进来
} for i in range(len(new_chunks))]
client.upsert(collection_name="kb_ex_spec", data=data)
# 调用:改了哪篇,就更新哪篇
incremental_update(client, "防爆规范.pdf", new_chunks, new_vecs)
插入后要不要重建索引 :不用。Milvus 会把新数据进「增长段(growing segment)」并自动索引、立即可搜;compaction 只是周期性把碎片整理干净、提升检索效率,不是每次增量都必须跑。
版本纪律 (呼应 Milvus 实战):你生产是 Milvus 2.6.17 ,client.compact() 即可用,且 2.6 已支持 target_size / target_size_unit 可选参数来控制合并后段大小(并非 3.0 才有)。至于 3.0 单独命名的「force merge」整库重排特性,用到时再升级即可。增量维护用 upsert / delete + compact() 足够。
十、贯穿案例:《防爆规范》PDF 如何变成可检索
前面十章把每个环节拆开讲,这一章把它们拼回一条能真跑的管线。样本选《危险场所电气防爆安全规范》这份 PDF------它几乎占满了入库阶段的所有坑:有清晰的「章/节/条款」层级、每页带页码和页眉、条款之间互相引用(「应符合 4.2 要求」),正文里还混着表格和图注。把它跑通,其它结构化文档(标准、手册、合同)照葫芦画瓢就行。
这一章的定位是串联图 ,不是第二次实现:clean_text(第五章)、chunk_id(第八章)、extract_chapter / extract_clause(第九章)这些函数在前文都有完整定义和设计理由,下面直接引用、不再重复抄一遍,只展示把各环节接起来的代码,每步标注它对应哪一章的决策。想直接复制整条管线跑,文末附录有一份汇总了全部函数定义的一站式脚本。
第零步:先把「地基」备好(第八章 schema + 第七章缓存)
在灌数据之前,集合和向量化器得先就位。集合用的是第八章定好的 schema:一个稠密向量字段 dense_vec 装向量,外加 chapter / clause_no / source_file / page 几个元数据字段;主键用第八章强调的确定性主键 。向量化器用第七章讲的办法------在 DashScope 的 OpenAI 兼容接口外面,叠一层 CacheBackedEmbeddings 做缓存,后面重灌、增量都白捡一次省下的 API 调用。
python
import os
from langchain_openai import OpenAIEmbeddings
# 导入路径同第七章:CacheBackedEmbeddings 在 langchain.embeddings、LocalFileStore 在 langchain.storage
#(langchain_core 里没有这两个类);最新版官方文档改用 langchain_classic.* 命名空间
from langchain.embeddings import CacheBackedEmbeddings
from langchain.storage import LocalFileStore
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from pymilvus import MilvusClient
DIM = 1024 # 必须与 Milvus 集合 schema 的 dim 完全一致;具体取值以你选用的模型官方文档为准
client = MilvusClient(uri="http://localhost:19530", token="root:Milvus")
# 向量化器:OpenAI 兼容接口包一层 LangChain,再叠缓存(第七章)
base_emb = OpenAIEmbeddings(
model="text-embedding-v3",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", # endpoint 可能随地域/版本变化,以百炼官方文档最新地址为准
)
embedder = CacheBackedEmbeddings.from_bytes_store(
base_emb, LocalFileStore("./embedding_cache/"), namespace=base_emb.model,
)
第一步:解析(第四章)------PDF 按页抽文本
用第四章核实过的 PyMuPDFLoader,它把 PDF 拆成按页的文档对象,每页带 metadata.page,页码后面做溯源和分块都用得上。
python
docs = PyMuPDFLoader("危险场所电气防爆安全规范.pdf").load()
第二步:清洗(第五章)------调用 clean_text,留住条款号
这份规范每页底部有「第 X 页」和机构页眉,但它们不是正文。清洗函数沿用第五章定义的 clean_text------用 unstructured 的真实清洗函数做 Unicode 归一和空白压缩,再用针对性正则删页码行,关键是条款号 4.2.1 这类结构信号原样留住(定义见第五章 5.5,这里直接调用):
python
for d in docs:
d.page_content = clean_text(d.page_content) # clean_text 定义见第五章 5.5
第三步:分块(第六章)------按条款结构切,配 overlap
防爆规范是典型的强层级文档,第六章给它的建议就是「结构分块 + 语义兜底」。这里用 RecursiveCharacterTextSplitter,把条款号、章节当作优先切分边界(separators 里把 \n第\d+章、\n\d+\.\d+ 放最前),块定 500 字符、overlap 80(约 16%)------保证「应符合 4.2 要求」这种跨边界引用不被腰斩。
python
splitter = RecursiveCharacterTextSplitter(
# 默认分隔符是 ["\n\n", "\n", " ", ""];这里为中文自定义补充了条款号正则与句号(。),属于覆盖而非默认值
separators=["\n第\\d+章", "\n\\d+\\.\\d+", "\n\n", "\n", "。", ""],
chunk_size=500, chunk_overlap=80,
)
chunks = splitter.split_documents(docs)
第四步:抽元数据(第九章)------调用 extract_chapter / extract_clause
元数据不是入库时才补,而是在分块之后、向量化之前就从文本里抽好。extract_chapter / extract_clause 的定义在第九章(解析阶段正则抽取),chunk_id(确定性主键)的定义在第八章------这里直接调用;source_file 和 page 来自加载和分块环节。它们落到 schema 字段上,检索时就能「只搜第 4 章」「排除已废止条款」。
python
SRC = "危险场所电气防爆安全规范.pdf"
# extract_chapter / extract_clause 定义见第九章;chunk_id(确定性主键)定义见第八章
第五步:向量化 + 入库(第七章 + 第八章)------批量算、确定性主键写入
最后把块批量送进向量化器(命中第七章缓存就不调 API),算完连同元数据和确定性主键一起写进 Milvus。注意两件事:批量控制并发避免 429;insert 之后必须 load_collection 才能搜------很多人插完立刻 search 报「collection not loaded」,根因就在这。
python
BATCH = 8
entities = []
for start in range(0, len(chunks), BATCH):
batch = chunks[start:start + BATCH]
vecs = embedder.embed_documents([c.page_content for c in batch]) # 缓存命中则不调 API
for i, (c, v) in enumerate(zip(batch, vecs)):
ci = start + i
entities.append({
"id": chunk_id(SRC, ci), # 第八章确定性主键,第十章增量靠它
"text": c.page_content,
"dense_vec": v,
"chapter": extract_chapter(c.page_content), # 第九章函数
"clause_no": extract_clause(c.page_content), # 第九章函数
"source_file": SRC,
"page": c.metadata.get("page", 0),
})
client.insert(collection_name="kb_ex_spec", data=entities)
client.load_collection("kb_ex_spec") # 不 load 不能搜
跑完这条管线,原本躺在 PDF 里的条款,就变成了 Milvus 里「问一句就能召回」的向量条目。等这份规范出了新版,不用全量重灌------直接调第十章的 incremental_update(client, SRC, new_chunks, new_vecs):它按 source_file 删掉旧版所有块、用确定性主键 upsert 新块,向量还复用着第七章的缓存。想拼成完整脚本直接跑,用文末附录的一站式汇总版。
十一、收束:入库是检索的地基
离线入库七环节,每一个都在给后面的检索定调。解析丢结构、清洗过头、分块切烂、向量化维度配错、元数据没存------任意一环偷工,检索和生成再怎么优化都只能在内伤里打转。
🎯 能带走的一个动作:先把这条管线跑通并灌一份真实文档,再去折腾检索参数和 rerank。下一站是「检索深入」(查询改写、混合检索、rerank 怎么配合),再往上是「检索质量调优」(评估指标 + 四环定位 + 调优闭环)------那两篇站在本文之上,才有东西可优化。
📚 参考与延伸
- DashScope text-embedding-v3 官方规格(维度/输入长度/稀疏向量):阿里云百炼模型文档 help.aliyun.com/en/model-st...(查阅于 2026-08,对应百炼当前线上文档)
- OpenAI 兼容 Embedding 接口(调用示例来源):docs.aliyun.com/zh/model-st...(查阅于 2026-08)
- 分块策略与参数共识:行业实践集中在 中文 200--500 字符 + 10--20% overlap,按文档类型差异化(前文表格已归纳)
- 向量库 Schema / 索引 / 检索:见同系列《Milvus 实战:从部署到检索》
- 文本清洗真实实现(第五章引用,均查阅于 2026-08):Haystack
DocumentCleanerdocs.haystack.deepset.ai/reference/p...;unstructured 清洗函数unstructured.cleaners.coreunstructured.readthedocs.io/en/main/cor...;LangChainBeautifulSoupTransformerpython.langchain.com/v0.2/api_re...;RAGFlow DeepDoc 视觉式版面清洗 www.ragflow.io/blog/is-dat... - Embedding 缓存(第七章引用):LangChain
CacheBackedEmbeddingspython.langchain.com/docs/integr...(查阅于 2026-08;导入路径为langchain.embeddings/langchain.storage,最新大版本改langchain_classic.*) - 上下文增强分块(第六章引用):Anthropic Contextual Retrieval www.anthropic.com/news/contex...(查阅于 2026-08,Anthropic 于 2024 年 9 月发布)
📎 附录:完整可运行脚本(一站式汇总)
第十一章正文只展示「串联逻辑」,这里把前文所有函数定义汇总成一份可直接复制运行的完整脚本:第五章的 clean_text、第八章的 chunk_id、第九章的 extract_chapter / extract_clause、第十章的 incremental_update,加上第十一章的解析→清洗→分块→向量化→入库串联。
运行前检查清单(跑不通先回来查这里,能省 80% 调试时间):
- 装依赖 :
pip install langchain-openai langchain-community langchain-text-splitters pymilvus unstructured - 设 API Key :终端执行
export DASHSCOPE_API_KEY="你的Key"(Windows CMD 用set DASHSCOPE_API_KEY=你的Key),或直接填进代码(仅测试用,生产务必用环境变量)。 - 起 Milvus :确保
localhost:19530有服务。快速体验用官方单容器脚本(内嵌 etcd/minio):curl -sfL https://raw.githubusercontent.com/milvus-io/milvus/master/scripts/standalone_embed.sh -o standalone_embed.sh && bash standalone_embed.sh start(Windows 在 Git Bash / WSL 里运行;完整部署见同系列《Milvus 实战:从部署到检索》)。 - 放文档 :把《危险场所电气防爆安全规范》PDF 命名为
危险场所电气防爆安全规范.pdf,放在脚本同级目录。
python
# -*- coding: utf-8 -*-
"""
《危险场所电气防爆安全规范》离线入库:完整可运行脚本
依赖:pip install langchain-openai langchain-community langchain-text-splitters pymilvus unstructured
"""
import os, re, hashlib, unicodedata
from langchain_openai import OpenAIEmbeddings
# 导入路径:langchain.embeddings / langchain.storage(langchain_core 里没有这两个类);
# 最新版官方文档改用 langchain_classic.embeddings / langchain_classic.storage
from langchain.embeddings import CacheBackedEmbeddings
from langchain.storage import LocalFileStore
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from pymilvus import MilvusClient
from unstructured.cleaners.core import clean_extra_whitespace, replace_unicode_quotes
# ---------- 0. 地基:第七章缓存 + 第八章 schema ----------
DIM = 1024 # 必须与集合 schema 的 dim 完全一致;具体取值以你选用的模型官方文档为准
client = MilvusClient(uri="http://localhost:19530", token="root:Milvus")
base_emb = OpenAIEmbeddings(
model="text-embedding-v3",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", # endpoint 以百炼官方文档最新地址为准
)
embedder = CacheBackedEmbeddings.from_bytes_store(
base_emb, LocalFileStore("./embedding_cache/"), namespace=base_emb.model,
)
# ---------- 1. 解析(第四章)----------
SRC = "危险场所电气防爆安全规范.pdf"
docs = PyMuPDFLoader(SRC).load()
# ---------- 2. 清洗(第五章)----------
def clean_text(t: str) -> str:
t = unicodedata.normalize("NFKC", t)
t = replace_unicode_quotes(t)
t = clean_extra_whitespace(t)
t = re.sub(r"第\s*\d+\s*页.*", "", t) # 去页码行,保留 4.2.1 条款号
return t.strip()
for d in docs:
d.page_content = clean_text(d.page_content)
# ---------- 3. 分块(第六章)----------
splitter = RecursiveCharacterTextSplitter(
# 默认分隔符是 ["\n\n", "\n", " ", ""];这里为中文自定义补充条款号正则与句号,属于覆盖而非默认值
separators=["\n第\\d+章", "\n\\d+\\.\\d+", "\n\n", "\n", "。", ""],
chunk_size=500, chunk_overlap=80,
)
chunks = splitter.split_documents(docs)
# ---------- 4. 元数据(第九章)+ 确定性主键(第八章)----------
def extract_chapter(text):
m = re.search(r"第\s*(\d+)\s*章", text)
return m.group(1) if m else "?"
def extract_clause(text):
m = re.search(r"(\d+(?:\.\d+){1,3})", text) # 匹配 4.2 / 4.2.1 这类条款号
return m.group(1) if m else "?"
def chunk_id(source_file: str, idx: int) -> str:
return hashlib.md5(f"{source_file}#{idx}".encode()).hexdigest()[:16]
# ---------- 5. 向量化 + 入库(第七章 + 第八章)----------
BATCH = 8
entities = []
for start in range(0, len(chunks), BATCH):
batch = chunks[start:start + BATCH]
vecs = embedder.embed_documents([c.page_content for c in batch]) # 缓存命中则不调 API
for i, (c, v) in enumerate(zip(batch, vecs)):
ci = start + i
entities.append({
"id": chunk_id(SRC, ci),
"text": c.page_content,
"dense_vec": v,
"chapter": extract_chapter(c.page_content),
"clause_no": extract_clause(c.page_content),
"source_file": SRC,
"page": c.metadata.get("page", 0),
})
client.insert(collection_name="kb_ex_spec", data=entities)
client.load_collection("kb_ex_spec") # 不 load 不能搜
# ---------- 6. 增量更新(第十章):改了哪篇,就更新哪篇 ----------
def incremental_update(client, source_file: str, new_chunks: list[str], new_vecs: list[list[float]]):
# 安全:Milvus 双引号字符串里,双引号用 \" 转义(不是 SQL 的 '')
safe_file = source_file.replace('"', '\\"')
client.delete(collection_name="kb_ex_spec",
filter=f'source_file == "{safe_file}"')
data = [{
"id": chunk_id(source_file, i),
"text": new_chunks[i],
"dense_vec": new_vecs[i],
"source_file": source_file,
} for i in range(len(new_chunks))]
client.upsert(collection_name="kb_ex_spec", data=data)
👍 写到这里,离线入库这条线就算铺完了。觉得有用点赞 + 收藏 + 关注走起,下一篇《RAG 在线检索生成全流程》接着讲「用户问一句之后,系统怎么把答案找回来」。