知识构建(一)—— 从 PDF 到 205 个知识点
所属课程:《法律问答系统(llm-wiki)》八讲课件 · 第 3/8 讲 定位:走进 llm-wiki 最核心、也最容易翻车的环节——把”给人看的 PDF”编译成”给机器检索的知识点”。本讲对应
app/ingest/knowledge_extract.py。
本讲目标:
- 看懂摄入管线
pipeline.py的完整链路与”增量/全量”划分; - 知道 PDF 转 Markdown 后会留下哪些噪音、如何清洗;
- 理解为什么法律 PDF 会出现”康熙部首变体”,不归一会有多严重的后果;
- 吃透”章→节→条”切分的两阶段状态机与三个关键判别点;
- 说出
KnowledgePoint的结构,以及 205 = 98 + 107 是怎么来的。
1. 摄入管线全景(pipeline.py)
入口 app/ingest/pipeline.py::run_pipeline(),一条链跑到底:
convert_pdf_to_md() PDF→MD 增量:data/raw 里已有同名 md 就跳过
↓
collect_points() 逐个 md 提知识点,合并后全局编号 KP_001…
↓
enrich_keywords() 概念→口语词 注入关键词(第 4 讲展开)
↓
Embedder().cache() 加载 bge-m3,把所有知识点编码成向量并缓存到
wiki/knowledge_vectors.npy(可选 use_semantic)
↓
build_graph() 构建关联图谱 knowledge_graph.json(第 4 讲展开)
↓
write_wiki() 落盘 wiki/ 页面集(第 4 讲展开)
关键约定(记牢):
- PDF→MD 是增量:
convert_pdf_to_md里if md.exists(): continue,只转缺失的,重跑不重复转; - MD→知识点→图谱→Wiki 是全量幂等重建:每次从
data/raw/*.md重新提取,改了 md 就生效; - 源文件约定:PDF 放
data/src/*.pdf,md 放data/raw/*.md(可放转换产物,也支持用户直接放 md)。
# pipeline.py(要点节选)
def collect_points(raw_dir, verbose=True):
points = []
for md in discover_markdown_sources(raw_dir): # 扫 data/raw/*.md
pts = extract_knowledge_points(md)
points.extend(pts)
for i, p in enumerate(points, 1):
p.id = f"KP_{i:03d}" # 多文档合并后【全局统一编号】KP_001…
return points
编号是跨文档合并后统一编的:劳动合同法在前、劳动法在后,一路编到 KP_205。所以
wiki/index.md里能看到劳动合同法第十四条 =KP_014,劳动法第四十四条(加班费)=KP_142——不要用”条号”当主键,条号会跨法撞车(两部法都有第 98 条),全局KP_xxx才是唯一 id。
2. PDF → Markdown:转换产物的”脏”
法律官网 PDF 经 markitdown 转出 Markdown 后,并不是干净的条文文本,混着四类垃圾:
| 噪音类型 | 长什么样 |
|---|---|
| 页眉日期 / 网站信息 | 2024/01/02、主办单位:XX 人社局、加入收藏、京ICP… |
| 孤立页码 | 单独一行的 1、2/100 |
| 目录(TOC)与点线 | 第二章 促进就业..........5 |
| 网页脚注/杂行 | 网址 https://…、文件名带下划线如 …劳动法_中华人民共和国人力资源和社会保障部 |
还有一个隐蔽的坑:法律 PDF 的标题、正文里经常混入康熙部首变体字符。下一节单独讲。
3. 清洗三件套(knowledge_extract.py)
3.1 噪音行过滤
_NOISE_KEYWORDS = ("加入收藏", "网站无障碍", "网站声明", "主办单位", "京ICP", "版权所有",…)
_DATE_RE = re.compile(r"^\d{4}/\d{1,2}/\d{1,2}") # 页眉日期
_PAGE_MARKER_RE = re.compile(r"^\d+/\d+$") # 页码 2/100
_URL_RE = re.compile(r"^https?://")
def _is_footer_row(row):
compact = re.sub(r"[\s\d]+", "", row)
if compact == "": return True # 纯数字页码
if _DATE_RE.match(row) or _PAGE_MARKER_RE.match(row) or _URL_RE.match(row):
return True
if "_" in row: return True # 页眉文件名
return any(kw in row for kw in _NOISE_KEYWORDS)
目录”点线行”单独处理:re.search(r"\.{3,}", line) and 行尾是数字 → 丢弃。
3.2 康熙部首变体归一(法律 PDF 特有的坑)
现象:有些法律 PDF 的”第⼀条”用的不是标准汉字”一”,而是 U+2F00 康熙部首区的”⼀“(KANGXI RADICAL ONE)。它们在屏幕上几乎长一样,但
Unicode 码位不同,普通正则 [一二三四五六七八九十百零] 根本匹配不到。
后果(如果你不归一):条文头匹配失败 → 整部法律切不出知识点;实测劳动法(107 条)会漏切到只剩很少的条,后续编号、图谱全乱。
解法:写一张转换表,在匹配正则之前对每行做 translate:
_KANGXI_TABLE = str.maketrans({
chr(c): unicodedata.normalize("NFKC", chr(c)) # U+2F00 康熙部首区,NFKC 可自动归一的大头
for c in range(0x2F00, 0x2FD6)
} | {"⺠": "民", "⻅": "见", "⻓": "长", "⻔": "门"}) # U+2E80 补充区里 NFKC 漏掉的,手工补
def _normalize(text):
text = text.translate(_KANGXI_TABLE) # ① 归一字符变体
text = re.sub(r"\s+", " ", text).strip()
return re.sub(r"(?<=[一-龥])\s+(?=[一-龥])", "", text) # ② 去掉中文间的字间空格
教训可提炼成一句面试话术:“文本处理的字符归一要走在所有正则匹配之前;Unicode 同形异码(homoglyph)是最隐蔽的数据清洗陷阱。”
3.3 合并被 PDF 折行的两行
PDF 每行宽度有限,一个自然段经常被折断成两行。不能简单用 \n 连接(会插出空格),用 _smart_join:中文字符紧贴直接相连、数字/字母之间补一个空格:
def _smart_join(a, b):
if re.search(r"[一-龥]$", a) and re.search(r"^[一-龥]", b):
return a + b # 中文对中文:直接拼
if re.search(r"[a-zA-Z0-9%.)、]$", a) and re.search(r"^[a-zA-Z0-9(]", b):
return a + " " + b # 字母数字之间:补空格
return a + " " + b
4. 章 / 节 / 条 切分:两阶段状态机
这是全模块的大脑。它维护一组状态变量,逐行扫描:
chapter = chapter_title = section = clause = ""
body = False # 是否已进入正文(正文首个"第X条"出现后才算)
current_article_no = 0 # 当前条文号(用于兜底识别交叉引用)
匹配用三个正则(要求”第X章/节/条”后紧跟空白,数字含”零”):
_CHAPTER_RE = re.compile(r"^第([一二三四五六七八九十百零]+)章[ \s]+(.+)$")
_SECTION_RE = re.compile(r"^第([一二三四五六七八九十百零]+)节[ \s]+(.+)$")
_ARTICLE_RE = re.compile(r"^第([一二三四五六七八九十百零]+)条[ \s]+(.*)$")
4.1 阶段一:目录 / 元数据(正文第一条之前)
正文第一条出现之前的所有行都可能是”封面、目录、章节目录页”。此时遇到”第X章/第X节”只更新上下文,遇到普通内容行**直接丢弃 **,不产知识点。这就把目录页里”第四章 劳动合同的解除和终止……42”这类行挡在了门外。
4.2 阶段二:正文(从第一条起)
进入正文的标志是 handle_header 命中 _ARTICLE_RE 且条文号不小于当前值 → body = True。之后每命中一个”第X条”就
flush() 掉前一条的缓冲,开启新的知识点。
4.3 三个关键判别点(面试最常追问)
(1) 条文头要求”第X条”后跟空白——用来挡正文里的交叉引用
正文里经常写”依照本法第三十九条和第四十条……”,这里的”第三十九条”是折行/行内引用,条后没有空白,正则 第([…]+)条[ \s]
匹配不上,于是不会被误判成新条文——否则一部法会被切出几百个假知识点。
(2) 条文号单调递增兜底
if no < current_article_no:
return False # 编号倒退了 → 是交叉引用(即便带了空白),当正文继续
current_article_no = no
这是给”条后确实有空白但其实是引用”的极端情况兜底:一条法正常只会往条文号更大的方向走,遇到编号倒挂就判为引用。
(3) 中文数字要含”零”
第X条 里的 X 用 [一二三四五六七八九十百零]。若漏掉”零”,第一百零一条(一百零一含零)匹配失败,101–107 条会静默漏掉。
4.4 flush:落一个知识点
def flush():
if buf and chapter and body:
content = "\n".join(buf)
title = f"{chapter}·{clause}" if clause else f"{chapter} {chapter_title}"
kp = KnowledgePoint(
id=f"KP_{len(points) + 1:03d}", title=title, chapter=chapter,
chapter_title=chapter_title, clause=clause, content=content,
source=f"{md_path.name} L{start_line}-{line_no}",
)
kp.keywords = extract_keywords(kp, content)
points.append(kp)
buf = []
注意 source 里记了”文件名 + 起始行号”——这正是”可溯源”落到数据的起点,后续回答引用、评估对齐都靠它。
表格处理顺带一提:正文里的 markdown 表格(如某条文后的对照表)会被 _process_table 提取成文本并入当前条文,且表格单元格里也会先做
handle_header(防止表格里藏着条文头)。
5. KnowledgePoint 结构与”一条一知识点”
@dataclass
class KnowledgePoint:
id: str # 全局编号,如 KP_019
title: str # 知识点标题,如"第二章·第十九条"
chapter: str # 章节号,如"第二章"
chapter_title: str # 章节标题,如"劳动合同的订立"
clause: str = "" # 条文,如"第十九条";节内为"第X节 节名·第X条"
content: str = "" # 清洗后的完整正文
keywords: list = field(default_factory=list) # jieba TF-IDF + 概念增强后关键词
source: str = "" # 来源:"中华人民共和国劳动合同法.md L31-34"
粒度为什么是”一条一个”:知识点是语义完整的最小检索单元。用户问”试用期最长几个月”,答案就在”第十九条” 整条里,一次定位即命中,无需把一条法条拆散再拼回去。这正是第 1 讲”不用 chunk”承诺的实现。
关键词怎么来(extract_keywords):jieba TF-IDF 抽正文 top-8 + 清洗后的章节/条文主题词,过滤法律停用词:
_LEGAL_STOPWORDS = {"用人单位", "劳动者", "劳动合同", "应当", "依照", "规定", "有关", "不得", "可以",…}
把”用人单位/劳动者/应当”这种每条文都出现的词去掉,否则它们会让所有知识点关键词高度趋同,污染第 4 讲的 keyword 关联边。
6. 收数验证:205 = 98 + 107
跑完 uv run python -m app.ingest.pipeline,控制台会打印每个 md 提取数:
[pipeline] 中华人民共和国劳动合同法.md: 提取 98 个知识点
[pipeline] 中华人民共和国劳动法_….md: 提取 107 个知识点
[pipeline] 知识点总数 205
这就是”205 = 劳动合同法 98 条 + 劳动法 107 条”的来源。任何一次改动切分逻辑后,都要用这两个数回归 ——漏切、误切都会直接反映在总数上(比如康熙部首不归一,107 会大幅缩水)。
7. 本讲小结
- 摄入 = 增量转 PDF + 全量重建知识点/图谱/Wiki,幂等可重跑。
- 清洗三坑:噪音行、康熙部首变体(归一必须在匹配前)、PDF 折行拼接。
- 章/节/条状态机三板斧:条后必须空白挡正文引用、条号单调兜底、**数字含”零”**防漏 101+。
- 产出
KnowledgePoint:语义完整、source 带文件与行号 = 可溯源的地基;全局KP_xxx编号解决跨法同条号冲突。
企业常见面试问答
Q1:把 PDF 里的法条结构化,最难的地方是什么?
主要是三类。① 噪音清洗:页眉日期、页码、目录点线、网页脚注需要逐类过滤;② 字符变体:法律 PDF 的”第⼀条”很多是康熙部首 U+2F00 变体,普通数字正则匹配不到,必须先做 NFKC + 手工映射的字符归一,否则整部法会漏切(劳动法 107 条实际验证会漏到很少);③ 边界判别:正文折行里的”第三十九条和第四十条”是交叉引用不是新条文,所以要求”第X条”后必须跟空白、并叠加条文号单调递增兜底,双保险防止误切。
Q2:知识点的粒度为什么选”每一条法条一个”,而不是像 RAG 那样切 chunk?
法律文档有天然的语义边界”章→节→条”,一条法条是一个完整、自洽的规范单元(试用期的时长规则就在第十九条里)。按条切,用户一次定位就拿到整条依据,检索即溯源;按长度切 chunk 会把一条法条劈成几段,检索到的是残片,反而要拼。同时”一条一页”正好让
wiki/concepts/KP_*.md每页对应一个可读、可审计的完整知识点,契合 llm-wiki”知识是资产”的主张。
Q3:多部法律合并后,为什么知识点 id 要全局重编号?直接用”法律名+条号”不行吗?
不行,因为条号跨法会撞车:劳动法和劳动合同法都有 98 条、85 条。如果只用条号当主键,图谱节点、关联边、日志、评估都会混淆。所以合并后用
KP_001…KP_205全局唯一编号,条号只作为展示/匹配用的属性;需要区分是哪部法时靠source(文件名)里的法律名——后续生成时上下文标题带法律名也是出于同一原因(第 6 讲展开)。
Q4:改了一条切分规则,怎么确保没改坏?
两手。① 单元测试:
tests/test_knowledge_extract.py覆盖清洗、章/节/条解析、康熙部首变体、交叉引用排除、表格回填、关键词,还有用真实 md 跑出 98/107 条的用例,改了就跑uv run python -m pytest tests/ -v;② 数量回归:跑uv run python -m app.ingest.pipeline,核对”知识点总数 205”是否保持不变——切分逻辑若有漏切/误切,总数立刻报警。