别再让 RAG 翻车在「入库」这一步!1200 行保姆级离线入库全流程(PDF → 可检索)

🔥 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 再强也只能基于错误上下文编。

📌 一句话定位:离线入库负责「把文档变成能被向量搜到的东西」,检索和生成都不归它管。


二、全流程总览:先有地图

离线入库是一条流水线,七个环节首尾相接。前一步的输出,是后一步的输入;任何一步偷工,后面全跟着歪。

flowchart LR A[原始文档] --> B[文档解析] B --> C[文本清洗] C --> D[分块 Chunking] D --> E[向量化 Embedding] E --> F[向量存储] D -. 派生 .-> G[元数据管理] G --> F F --> H[索引更新]

每个环节一句话定位:

环节 它解决什么 输出物
文档解析 把 PDF/Word 的「格式」还原成「纯文本+结构」 带结构的文本(含页码/标题)
文本清洗 去掉噪音、统一口径 干净文本
分块 把长文切成检索单元 一个个 chunk
向量化 让文本可被计算距离 高维向量
向量存储 把向量+原文落库 Milvus 里的实体
元数据管理 让「按业务维度过滤」成为可能 标量字段
索引更新 让库能快速被搜、且能增量维护 索引结构

下面逐个拆,每个都按「为什么 → 原理 → 工程细节」往下走,不跳步。


三、文档解析:把「格式」还原成「纯文本」

为什么:知识源不是纯文本。它们各有各的「格式容器」------PDF 混着标题层级和表格、Word 是 zip 包里的 XML、网页是一堆标签、Markdown 自带层级。Embedding 模型只吃文本,所以第一步必须把容器拆开,拿出文字,同时尽量保留结构信号(哪一章、第几页、是不是表格)。

解析的本质是「拆容器」------不同格式内部结构完全不同,没有哪个解析器能通吃,得按格式选。下面把常见格式逐个过一遍:先说它怎么被处理,再给一段真实可运行的代码,最后点几个容易踩的坑。所有加载器都来自 langchain-communitypip 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」不是只有一种做法,按能力从浅到深:

  1. 纯文本抽取(pdfminer.six):最底层,精细控制抽哪几页、什么编码,只要正文不在乎版面时用。
  2. 文本 + 坐标(PyMuPDF / fitz):抽取同时拿到每句话的页面和坐标框,做「溯源 / 高亮」首选。
  3. 版面与表格分析(pdfplumber):拿到单词、行、矩形、表格单元格,表格密集文档最稳。
  4. 开箱即用的元素切分(unstructured) :直接拆成 Title / NarrativeText / Table 结构化元素,RAG 起步最快。
  5. 表格专项(Camelot / Tabula):规则表 / 无边框表准确率最高。
  6. 扫描件 OCR(pdf2image + pytesseract / PaddleOCR):专治文本层为空的扫描件。
  7. 复杂混排版面(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。

TextLoaderautodetect_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,每行自动成一个 Documentpage_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 分隔------TextFileToDocumentAzureOCRDocumentConverter 这些转换器会自动加上 \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、调多细的参数都救不回。

📌 为什么必须分块

一句话:向量检索的最小单位是「块」,分块方式直接决定了「对的问题能不能映射到对的片段」。之所以非切不可,根子在四个地方:

  1. 模型吃不下整篇。Embedding 模型都有最大输入长度,超长文本会被截断------几百页的规范、几十万字的手册,根本塞不进一个输入。长文必须先切成模型能接受的单位,再逐个向量化。(各家模型上限不同,具体数值以你选用的模型官方文档为准,别凭记忆写死。)
  2. 一个向量 = 一段文本的平均语义 。Embedding 把一段文本压成一个向量,表达的是这段内容的整体语义倾向。块越大,里面挤的主题越多,向量越「平均」、越模糊------「防爆区域划分」和「设备温升限值」被挤在一个向量里,query 问前者时,这个向量既不全中也不全不中,排序就糊。这是分块的本质矛盾:精度 vs 完整性的拉锯
  3. 块太小会丢上下文。反过来,块切得太碎,一句话被拦腰截断,单独看不知所云。「应符合 4.2 的要求」切成「应符合 4.2」和「的要求」两块,召回任何一块都拼不出完整意思。向量是孤立算的,跨块的指代和承接它管不了。
  4. 检索返回的是「块」,块就是上下文的边界 。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_SQ8IVF_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 年的文件」「排除已废止的条款」。这些精确过滤靠标量字段,也就是元数据。没有它,只能全量语义搜,既慢又容易答非所问。

怎么定:三类数据,去哪要想清楚

  1. 进 schema(定长、常过滤)chapter / clause_no / source_file / page / doc_type。这些是高频过滤维度,建进 schema、Milvus 会自动给标量建索引,查询时高效缩圈。
  2. 进动态字段(稀疏、不确定)tags、临时业务标签。开 enable_dynamic_field=True 当自由键值存,不提前定义列。
  3. 根本不存进元数据 :超大自由文本(原文本身)用 VARCHARtext 字段单独存,别塞进动态字段当元数据------会拖慢过滤。

元数据从哪来(具体哪里抽,不是只能手填)

  • 解析阶段正则抽:第四章从文本里抽章号 / 条款号。两条正则就够用,定义成小函数挂在解析层,后面入库时直接调用:
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版_防爆规范.pdfyear=2025doc_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 条款现在怎么规定的」,可能召回的是已废止的旧版。增量维护就是要让「库里的状态」始终等于「文档的当前状态」。

几种动作,按需组合

  1. upsert(覆盖写) :主键存在则覆盖、不存在则插入。依赖第八章的确定性主键------同一文档同一块 id 不变,新向量直接盖掉旧的,最省事。
  2. delete(删除) :按主键 ids=[...] 精准删,或按表达式 expr="source_file == 'old.pdf'" 批量删整篇。后者依赖第九章的 source_file 元数据字段。
  3. 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() 足矣。
  4. 全量重建 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.17client.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_filepage 来自加载和分块环节。它们落到 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 怎么配合),再往上是「检索质量调优」(评估指标 + 四环定位 + 调优闭环)------那两篇站在本文之上,才有东西可优化。


📚 参考与延伸


📎 附录:完整可运行脚本(一站式汇总)

第十一章正文只展示「串联逻辑」,这里把前文所有函数定义汇总成一份可直接复制运行的完整脚本:第五章的 clean_text、第八章的 chunk_id、第九章的 extract_chapter / extract_clause、第十章的 incremental_update,加上第十一章的解析→清洗→分块→向量化→入库串联。

运行前检查清单(跑不通先回来查这里,能省 80% 调试时间)

  1. 装依赖pip install langchain-openai langchain-community langchain-text-splitters pymilvus unstructured
  2. 设 API Key :终端执行 export DASHSCOPE_API_KEY="你的Key"(Windows CMD 用 set DASHSCOPE_API_KEY=你的Key),或直接填进代码(仅测试用,生产务必用环境变量)。
  3. 起 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 实战:从部署到检索》)。
  4. 放文档 :把《危险场所电气防爆安全规范》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 在线检索生成全流程》接着讲「用户问一句之后,系统怎么把答案找回来」。

相关推荐
DreamLife☼2 小时前
Agent的典型应用场景深度解析
自动驾驶·agent·iot·工业·智慧交通·医疗健康
liulilittle2 小时前
为什么需要回程闲置保护?
ai·llm·prompt·agent·tools·subagent·opencode
七牛开发者3 小时前
HarnessDev:让 LLM 自己创建并迭代 Agent Harness
chatgpt·llm·agent
梦想不只是梦与想3 小时前
大模型系列(三):提示工程、RAG 与 Agent
大模型·agent·rag
知无涯者4 小时前
Agent 007 - Parallel Tool Use
agent
面向Google编程4 小时前
Kafka 成了 Agent 的「共享内存」,Flink 成了它的「大脑」
flink·kafka·agent
明月_清风4 小时前
DeepSeek Harness 安全审计实录:四个 PoC 揭露的 Agent 运行时"信任危机"
后端·安全·agent
LayZhangStrive4 小时前
Prompt - 如何生成贴合我们业务需求的有效prompt
ai·prompt·agent·提示词
SuperHeroWu75 小时前
HarmonyOS Dev Assistant (HarmonyOS开发助手)如何打通元服务开发全流程
ai·agent·harmonyos·vs code·元服务·hbuilderx·assistant