返回 法律问答系统(llm-wiki-v1)课程讲义

知识构建(一)—— 从 PDF 到 205 个知识点

所属课程:《法律问答系统(llm-wiki)》八讲课件 · 第 3/8 讲 定位:走进 llm-wiki 最核心、也最容易翻车的环节——把”给人看的 PDF”编译成”给机器检索的知识点”。本讲对应 app/ingest/knowledge_extract.py

本讲目标:

  1. 看懂摄入管线 pipeline.py 的完整链路与”增量/全量”划分;
  2. 知道 PDF 转 Markdown 后会留下哪些噪音、如何清洗;
  3. 理解为什么法律 PDF 会出现”康熙部首变体”,不归一会有多严重的后果;
  4. 吃透”章→节→条”切分的两阶段状态机与三个关键判别点;
  5. 说出 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_mdif 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…
孤立页码单独一行的 12/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. 本讲小结

  1. 摄入 = 增量转 PDF + 全量重建知识点/图谱/Wiki,幂等可重跑。
  2. 清洗三坑:噪音行、康熙部首变体(归一必须在匹配前)、PDF 折行拼接。
  3. 章/节/条状态机三板斧:条后必须空白挡正文引用、条号单调兜底、**数字含”零”**防漏 101+。
  4. 产出 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”是否保持不变——切分逻辑若有漏切/误切,总数立刻报警。


下一讲:第 4 讲 知识构建(二):概念增强、关联图谱与 Wiki 落盘 上一讲:第 2 讲 系统总览与工程环境