本节摘要:Transformer 是摄取流水线的加工车间——清洗、切分、元数据抽取都在这里完成。本节比较三种主力切分器(SentenceSplitter / SentenceWindowNodeParser / SemanticSplitter)的机理与适用面,演示转换函数与自动元数据抽取器,最后给出一组可复用的对照实验方法,让你用自己的语料量测哪种切分最好。
SentenceSplitter(默认):按 token 长度滑窗切,带重叠。它不关心语义,只保证块长可控、实现稳定、成本为零。适合绝大多数"没有明显结构"的连续文本。
SentenceWindowNodeParser:每个句子单独成节点(检索粒度极细),但节点的关系字段里挂满了前后若干句。检索命中"那一句"后,合成阶段把整个窗口送进模型——检索用细粒度找得准,生成用粗粒度看得全。这是"细召回、粗生成"的典范设计。
SemanticSplitterNodeParser:用嵌入模型计算相邻句子的语义距离,在"语义突变处"下刀。切分质量通常更好,但摄取时要对每句话算嵌入,成本与耗时显著上升。
from llama_index.core.node_parser import ( SentenceSplitter, SentenceWindowNodeParser, SemanticSplitterNodeParser) from llama_index.core import Document text = ("公司成立于 2015 年,主营企业协同软件。" "2020 年推出移动端产品,用户量突破百万。" "财务方面,2023 年营收 4.2 亿元,同比增长 31%。" "本季度公司将重点投入海外市场。") doc = Document(text=text) base = SentenceSplitter(chunk_size=128, chunk_overlap=20).get_nodes_from_documents([doc]) win = SentenceWindowNodeParser.from_defaults( window_size=3, window_metadata_key="window").get_nodes_from_documents([doc]) sem = SemanticSplitterNodeParser( buffer_size=1, breakpoint_percentile_threshold=95, embed_model=Settings.embed_model).get_nodes_from_documents([doc]) print("滑窗切分:", len(base), "块") print("句子窗口:", len(win), "块,每块携带窗口元数据:", win[0].metadata["window"][:40]) print("语义切分:", len(sem), "块 —— 在语义突变处下刀")
三者的输出差异值得亲手跑一遍:同一份文本,语义切分往往把"公司沿革"与"财务数据"分成两块,而滑窗切分可能把它们混在一块里——前者对"营收多少"这类问题召回更纯净。
清洗是脏数据的第一道淋浴。常见操作:去页眉页脚、合并断行、全半角归一、脱敏。转换函数就是一个普通函数,吃 Node 吐 Node:
import re def clean_node_fn(nodes): for n in nodes: t = n.text t = re.sub(r"\d+/\d+ 页", "", t) # 去页码 t = re.sub(r"第 \d+ 页", "", t) # 去页脚 t = re.sub(r"[ \t]+", " ", t) # 压缩空白 t = re.sub(r"1[3-9]\d{9}", "[手机号]", t) # 简单脱敏 n.text = t return nodes
对没有结构信息的文本,可以让大模型抽取标题、关键词、摘要等元数据(抽取器走 LLM,按文档数计费,贵但值):
from llama_index.core.extractors import ( TitleExtractor, QuestionsAnsweredExtractor, SummaryExtractor) from llama_index.core.ingestion import IngestionPipeline extractors = [ TitleExtractor(nodes=5), # 抽文档标题 QuestionsAnsweredExtractor(questions=3), # 抽该块能回答的问题 SummaryExtractor(summaries=["self"]), # 抽摘要 ] # QuestionsAnsweredExtractor 产出的元数据会参与嵌入, # 使"用户提问"与"该块能回答的问题"在向量空间更近 —— 隐性的检索增益
方法比结论重要:固定嵌入模型与查询集,只变切分器,比召回命中率。
def hit_rate(nodes, keyword_sets): """粗评:每个问题的关键词是否出现在召回节点里""" hits = sum(any(all(k in n.text for k in kws) for n in nodes[i]) for i, kws in enumerate(keyword_sets)) return hits / len(keyword_sets) questions = [ [["2023", "营收"]], # 问题1的关键词 [["海外", "市场"]], # 问题2 [["成立", "2015"]], # 问题3 ] print("滑窗切分命中率:", hit_rate([base_retriever.query(q) for ...], questions))
在我的制度类语料上,句子窗口对"精确条款定位"类问题提升明显,语义切分对"主题综述"类问题更优,滑窗切分从不出彩但从不翻车——结论会随语料变化,所以请务必在自己的数据上重跑这个实验,而不是背我的结论。
💡 关键直觉:切分策略的选型不是"哪个更先进",而是"哪个的信息边界和我的查询粒度最匹配"。法条问答要句子级,方案评审要章节级。
语义切分效果好但太慢,有折中方案吗? 有两条路:一是用小而快的嵌入模型专门跑切分(切分用的嵌入不必与检索用的同款);二是离线批处理——切分只在摄取期发生一次,慢就慢点,别在在线路径上用即可。真正要避免的是每天全量重切,配合 2.4 节缓存,语义切分的总成本完全可控。
清洗会不会把有用信息洗掉? 会,所以清洗规则要保守且可回溯:正则规则集中管理、进版本库,每次修改后抽样对比清洗前后的文本。激进的清洗(比如去掉所有短行)可能误杀表格与列表。原则是"只清洗确定是噪音的模式",拿不准的内容保留并打上标记,靠检索层的重排去压制。
元数据抽取器值得花这个钱吗? 看语料结构化程度。本身有清晰结构的文档(Markdown 标题、带字段的工单)用规则抽取,零成本;结构缺失的自然文本才用 LLM 抽取器。折中方案是只对"长文档的第一个节点"做标题与可答问题抽取,成本降一个量级,收益保留大半。