夜雨聆风学习资料网

ARTICLE · 1162610

数据篇 -- 【动手】给一份技术文档写出可检索分块

数据篇 -- 【动手】给一份技术文档写出可检索分块

💡 摘要:只切分看不见效果。本文用父子块处理技术 Markdown:标题节当父块、小块入库检索,命中后再取回父块上下文;并用关键词 + 向量 Top-K 验证「问得回」。

目标很具体:拿一份带标题的技术文档,产出可检索的父子结构,并且用代码证明:

  • 子块够尖、父块够全,带标题元数据  
  • 人抽查读得懂  
  • 给定问题,先命中子块,再展开父块能看到答案原文

不追求一次完美,追求可跑、可验、可迭代。

图1|切完不算完:用问题打一遍 Top-K,才知道分块是否合格

准备什么

  1. 同目录下的 markdown.md(也可换成你自己的 MD)  
  2. Python 环境,已装 langchain / langchain-community / langchain-huggingface 等  
  3. 嵌入模型 BAAI/bge-small-zh-v1.5(首次会下载;国内慢可设 HF_ENDPOINT)

为什么必须有「检索验证」

只打印前几个 chunk,你最多知道「切开了」。真正要回答的是:

用户问 X 时,装答案的那段,会不会被检索打到?打到后上下文够不够生成?

所以验收至少两层:

层级
做什么
能证明什么
抽样可读
随机读父/子块
块是人话、元数据对、父子能对上
检索验证
固定问题看 Top-K
子块能命中;展开父块后答案在不在

第二层不过关,调 Prompt、换大模型都是空转。

推荐策略:父子块(Small-to-Big)

父块:MarkdownHeaderTextSplitter(按标题切成完整小节)        ↓子块:RecursiveCharacterTextSplitter(小节内再切小,写入 doc_id)        ↓检索子块 → 用 doc_id 取回父块(给生成用)

子块负责命中,父块负责给足上下文——这就是父子块。下面用 markdown.md 走一遍。

步骤 1–3:加载 → 建父块 → 切子块

from langchain_community.document_loaders import TextLoaderfrom langchain_core.documents import Documentfrom langchain_text_splitters import (    MarkdownHeaderTextSplitter,    RecursiveCharacterTextSplitter,)raw = TextLoader("markdown.md", encoding="utf-8").load()[0]# ① 父块:按标题切成逻辑小节(上下文完整)parent_splitter = MarkdownHeaderTextSplitter(    headers_to_split_on=[        ("#", "Header 1"),        ("##", "Header 2"),        ("###", "Header 3"),    ],    strip_headers=False,)parents = parent_splitter.split_text(raw.page_content)parent_store: dict[str, Document] = {}for i, parent inenumerate(parents):    doc_id = f"p{i}"    parent.metadata["doc_id"] = doc_id    parent_store[doc_id] = parent# ② 子块:在每个父块内再切小(向量检索用)child_splitter = RecursiveCharacterTextSplitter(    chunk_size=200,   # 子块更小,检索更尖    chunk_overlap=30,)children: list[Document] = []for parent in parents:for child in child_splitter.split_documents([parent]):        child.metadata["doc_id"] = parent.metadata["doc_id"]  # 指回父块        children.append(child)print(f"父块={len(parents)}, 子块={len(children)}")for i, c inenumerate(children[:3], 1):print(f"\n===== 子块 #{i} | {c.metadata} =====")print(c.page_content)    parent = parent_store[c.metadata["doc_id"]]print(f"→ 对应父块长度={len(parent.page_content)}")

先确认三件事:doc_id 能从子块指回父块、父块仍是完整小节、子块明显更短。

图2|子块宜尖、父块宜全;两者长度分布应明显不同

步骤 4:关键词检索验收(零模型,先过这关)

先在子块上粗排,再展开父块看上下文是否够用:

QUESTIONS = ["什么是强化学习?","强化学习智能体的类型有那些?","传统计算机视觉的过程组成是什么?","2012年,在ImageNet分类比赛中取得冠军的是什么模型?",]defkeyword_search_children(children, query, k=2):    hints = ("强化学习", "智能体", "ImageNet", "冠军", "模型", "计算机视觉")    scored = []for i, c inenumerate(children):        text = c.page_content        score = sum(3for h in hints if h in query and h in text)        scored.append((score, i, c))    scored.sort(reverse=True)return scored[:k]for q in QUESTIONS:print(f"\nQ: {q}")for score, idx, child in keyword_search_children(children, q):        parent = parent_store[child.metadata["doc_id"]]print(f"  [{score}] 子块#{idx} → 父块 {parent.metadata}")print(f"    子: {child.page_content.replace(chr(10), ' ')}")print(f"    父: {parent.page_content.replace(chr(10), ' ')}")

怎么判:

  • 子块 Top 能点中答案附近 → 边界大体可用  
  • 子块命中了,但父块展开后仍缺关键句 → 父块切得过碎(标题层级过细)或 overlap 不够  
  • 子块总排不进 Top → 子块太大/太杂,或答案被切散 → 回调子块 chunk_size
  • 命中的是目录、页眉噪音 → 加载/清洗问题

步骤 5:向量检索验收(子块入库,返回父块)

# import os# os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"from langchain_huggingface import HuggingFaceEmbeddingsfrom langchain_core.vectorstores import InMemoryVectorStoreembeddings = HuggingFaceEmbeddings(    model_name="BAAI/bge-small-zh-v1.5",# 若本地已下载,可改成绝对路径,例如 "E:/model/BAAI/bge-small-zh-v1.5"    encode_kwargs={"normalize_embeddings": True},)# 只把子块写入向量库store = InMemoryVectorStore.from_documents(children, embeddings)defretrieve_parents(query, k=2):"""检索子块,按 doc_id 去重后返回父块。"""    hits = store.similarity_search(query, k=k)    parents_out, seen = [], set()for child in hits:        doc_id = child.metadata["doc_id"]if doc_id in seen:continue        seen.add(doc_id)        parents_out.append((child, parent_store[doc_id]))return parents_outfor q in QUESTIONS:print(f"\nQ: {q}")for rank, (child, parent) inenumerate(retrieve_parents(q, k=2), 1):print(f"  Top{rank} 命中子块 → 展开父块 {parent.metadata}")print(f"    子: {child.page_content.replace(chr(10), ' ')}")print(f"    父: {parent.page_content.replace(chr(10), ' ')}")

验收口诀:

  1. Top 子块是否点在答案附近?  
  2. 展开父块后,答案原文是否完整可读?  
  3. 只改子块或只改父块(标题层级)其中一个变量,再复测同一批问题

这里先不要接 LLM 生成。生成漂亮但父块里没有答案,只是在优雅地胡说;生成差但父块对了,问题在 Prompt/模型,不在分块。

坏 case 怎么回调参

现象
可调方向
子块 Top-K 总缺答案
答案被切散 → 略增子块 chunk_size / overlap
子块命中准、父块仍缺上下文
父块标题切太细 → 少用一级 ###,或合并父块
父块太大、生成噪音多
父块用更细标题;或检索后只取父块局部
关键词能中、向量不能
子块主题仍杂;或问法与原文用词差太大
doc_id 对不上
切子块时漏写 metadata["doc_id"]

图3|完成标准:子块打得中 + 父块展得开 + 改完能复测

这篇你带走什么

  1. 动手默认用父子块:标题节当父、小块检索、命中后取回父块。  
  2. 切分必须配检索验证:关键词粗检 + 向量 Top-K(看子块,验父块)。  
  3. 先看召回再谈生成:父块里没有答案,就先改分块,别急着怪大模型。

相关学习资料