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

知识构建(二)—— 概念增强、关联图谱与 Wiki 落盘

所属课程:《法律问答系统(llm-wiki)》八讲课件 · 第 4/8 讲 定位:摄入期剩下的三件事,也是 llm-wiki “可读可审计 + 支持多跳”的两大支撑物。对应 app/ingest/concept_map.pygraph_build.pywiki_writer.py

本讲目标:

  1. 讲清”法律措辞 ↔ 用户口语”的鸿沟,以及概念关键词如何弥合;
  2. 理解关联图谱四类带类型边各自来源、权重/阈值,以及 crossref 为什么最关键;
  3. 看懂图谱 JSON 结构与边合并逻辑;
  4. 认识 wiki/ 落盘物,能解释”可读、可审计”落到哪个文件。

1. 先解决一个检索层面的”代沟”:法律措辞 vs 用户口语

法律原文写得规范但拗口,用户提问说得随意。同一个意思,两边用词完全对不上:

用户口语(大概率这么问)法律原文措辞
加班费延长工作时间的工资报酬(劳动法第 44 条)
拖欠工资 / 欠薪未足额支付劳动报酬 / 责令限期支付(劳动合同法第 85 条)
试用期最长几个月试用期……不得超过(劳动合同法第 19 条)
被辞退要赔偿吗用人单位单方解除 / 经济补偿 / 赔偿金

如果只对法律原文做关键词索引,用户问”加班费”,BM25 里根本没有”加班费”三个字——检索直接脱靶

concept_map.py 怎么解

一张内容交付物映射表(谁维护知识,一目了然):

# 概念 → [(来源文档名关键字, 条文)],用于
#   1) wiki/index.md 核心概念导航; 2) 检索关键词增强
CONCEPT_MAP = {
    "试用期": [("劳动合同法", "第十九条"), ("劳动合同法", "第二十条"),
               ("劳动合同法", "第二十一条"), ("劳动合同法", "第八十五条")],
    "竞业限制": [("劳动合同法", "第二十三条"), ("劳动合同法", "第二十四条")],
    "经济补偿": [("劳动合同法", "第四十六条"), ("劳动合同法", "第四十七条"),
                 ("劳动合同法", "第四十八条")],
    "加班费": [("劳动法", "第四十四条")],
    "劳务派遣": [("劳动合同法", "第五十七条"), ("劳动合同法", "第六十三条"),
                 ("劳动合同法", "第六十五条"), ("劳动合同法", "第六十六条")],
    "劳动争议处理": [("劳动法", "第七十七条"), ("劳动法", "第七十九条"), ("劳动法", "第八十三条")],
    # …… 覆盖 20+ 概念
}

# 概念 → 用户常用查询词(注入用;刻意避开"劳动合同/工资/劳动"等过泛停用词)
CONCEPT_KEYWORDS = {
    "劳动者单方解除": ["拖欠工资", "克扣工资", "欠薪", "未足额支付"],
    "加班费": ["加班费", "加班"],
    "违法解除赔偿": ["违法解除", "赔偿金", "二倍赔偿"],
    "孕期解除限制": ["孕期", "产期", "哺乳期", "顺延"],
    "劳动监察": ["劳动监察", "监督检查", "责令改正", "投诉举报", "维权"],
    # ……
}

注入逻辑enrich_keywords,文档感知匹配):

def concept_terms_for(point):  # 该知识点命中了哪些概念的查询词
    for concept, refs in CONCEPT_MAP.items():
        for law, article in refs:
            if law in point.source and article in point.clause:  # ★文档感知
                terms.extend(CONCEPT_KEYWORDS.get(concept, ()))
    return terms


def enrich_keywords(points):
    for p in points:
        for t in concept_terms_for(p):
            if len(t) > 1 and t not in p.keywords:
                p.keywords.append(t)  # 幂等:去重注入
    return points

判定 law in point.source and article in point.clause文档感知的:要同时匹配”是哪部法”(source 文件名)+“是哪一条” (clause),避免”经济补偿”把劳动法与劳动合同法里同条号的知识点一起误注入。

效果:劳动法第 44 条(加班费)知识点的 keywords 里,除了 TF-IDF 抽出的词,还会被注入 ["加班费","加班"]。于是用户问” 加班费怎么算”,BM25 能直接命中它——口语到法条的桥在摄入期就修好了

面试点:这份映射是数据/内容而非代码逻辑,改它不碰任何检索代码;新增法律想让概念可导航,就来这里补行。


2. 关联图谱:四类带类型边

app/ingest/graph_build.py 把 205 个知识点建成一张图:节点是知识点,边是带 type 的关系。为什么是”带类型边” 而不是一条笼统的”相似边”?——因为不同关系含义不同、权重不同、下游用途不同(结构相近 vs 明确引用,对”该不该带出”的判断完全不同)。

边类型来源权重/阈值含义
sibling同章节内的一级条款1.0同一章里的兄弟条文(结构关系)
keyword关键词 Jaccard 相似度≥0.3主题词重合
semanticbge-m3 余弦相似度≥0.75语义相近
crossref正文里”第X条”引用同法其他条文1.5法条间的显式引用
_CROSSREF_WEIGHT = 1.5


def _jaccard(a, b):
    sa, sb = set(a), set(b)
    if not sa or not sb: return 0.0
    return len(sa & sb) / len(sa | sb)


def add_edge(u, v, etype, weight):
    key = (min(u, v), max(u, v))  # 无向:统一 key
    entry = edges.setdefault(key, {"types": [], "weight": 0.0})
    if etype not in entry["types"]:
        entry["types"].append(etype)  # 一条边可带多个 type
    entry["weight"] = max(entry["weight"], weight)  # 权重取最大

一条无向边能同时挂多个 type、权重取大——比如 A 与 B 既同章(sibling)又同关键词(keyword),就合并成一条 types:["sibling","keyword"], weight:1.0 的边。这为查询期的 BFS 扩展提供干净邻接表。

2.1 sibling:结构边

p.chapter 分组,同章内所有有 clause 的知识点两两相连,权重 1.0。表达”同一章里的条文彼此相关”。

2.2 keyword:关键词边

两两算关键词 Jaccard,≥0.3 建边,权重 = Jaccard 值。注意它依赖第 3 讲的关键词清洗:如果没过滤”用人单位/应当” 等法律停用词,几乎每个知识点都共享这些词,Jaccard 会虚高、图会近全连接。

2.3 semantic:语义边(需要向量编码器)

语义边不是查询时算的,是摄入期一次性算好的:

texts = [f"{p.title}\n{p.content[:200]}" for p in points]  # 标题 + 正文前 200 字
vecs = embedder.encode(texts)  # bge-m3 批量编码
# 两两点积(向量已 L2 归一)→ 余弦
if cos >= semantic_threshold:  # 0.75
    add_edge(...)

调参心得:semantic 阈值踩过 0.65 → 图近全连接(噪音边爆炸),调到 0.75 才得到稀疏、可信的语义边。同理 keyword 0.3。* 阈值不是拍脑袋,是用”图的稀疏度/边数”调的*。

2.4 crossref:法条交叉引用(最关键,权重最高)

法条正文经常”引用”别的条文(如”依照本法第三十九条……”)。crossref 把这种显式的条文间引用建出来:

_ART_REF_RE = re.compile(r"第[一二三四五六七八九十百零]+条")  # 正文里扫"第X条"

by_law: 每个知识点先按来源文件分组(source.split(" L")[0]
取文件名)
→ 组内建立
{条文号文本 -> KP
id} 映射
→ 逐条文扫
content
里的
"第X条",命中同法另一条文 → add_edge(..., "crossref", 1.5)

两个设计细节

  • 权重 1.5 > sibling 的 1.0:引用是”这条的答案依赖那条”,扩展时应优先带到被引用条文,所以要压过普通结构边;
  • 只在同法内建边(跨法不加):两部法都有第 98 条,若跨法用”第98条”去连就会张冠李戴;按来源文件分组天然杜绝了跨法误连。

真实例子(从知识库里能查到):劳动合同法第十四条 → 第三十九条、第四十条、第九十七条;第二十三条 → 第二十五条;第二十六条 → 第三十八条、第三十九条;劳务派遣一章里第二十一条 → 第三十九条……这些 crossref 边正是第 6 讲”多跳把被引用条文带进上下文”的地基。

图谱产物

{
    "nodes": [
        {
            "id": "KP_019",
            "title": "第二章·第十九条",
            "chapter": "第二章",
            "chapter_title": "劳动合同的订立",
            "clause": "第十九条",
            "keywords": [
                "试用期"

            ],
            "content": "…",
            "source": "中华人民共和国劳动合同法.md L31-34"
        }
    ],
    "edges": [
        {
            "source": "KP_046",
            "target": "KP_038",
            "types": [
                "crossref"
            ],
            "weight": 1.5
        }

    ]
}

当前规模:205 节点、2962 条边


3. Wiki 落盘:把”知识资产”摆到人能读的地方

app/ingest/wiki_writer.py 产出三样东西,这就是第 1 讲”可读可审计”的物质载体:

3.1 wiki/index.md:核心概念导航 + 按源文档分组

## 核心概念导航

- **试用期**:[[KP_019|第十九条]]、[[KP_020|第二十条]]、[[KP_021|第二十一条]]、[[KP_085|第八十五条]]
- **竞业限制**:[[KP_023|第二十三条]]、[[KP_024|第二十四条]]
- **加班费**:[[KP_142|第四十四条]]
- **劳务派遣**:[[KP_057|第五十七条]]、[[KP_063|第六十三条]]、[[KP_065|第六十五条]]、…

_resolve_concept(law, article) 用第 1 节同样的”文档感知”把 (法律, 条文) 解析成全局 KP_xxx 并渲染成双向链接;再往下按 源文档 → 章 → 条分组列出全部知识点。人要找法条,从这里点进去——知识库不再是黑盒。

3.2 wiki/concepts/KP_*.md:每知识点一页

---
id: KP_019
title: 第二章·第十九条
chapter: 第二章
source: 中华人民共和国劳动合同法.md L31-34
keywords: 试用期, …
---

# 第二章·第十九条

(条文正文……)

## 关联知识点

- [[KP_046]] (crossref,权重 1.5)
- [[KP_020]] (sibling,权重 1.0)

带 frontmatter(结构化元数据)、正文、以及按权重排序的关联知识点双向链接——点一个知识点能看到它连着谁、以什么关系连着。

3.3 wiki/log.md:构建操作日志

记录每次构建时间与规模,知识库”什么时候建成、多大”可审计。

这三个文件 + knowledge_graph.json 一起,构成”无需跑系统也能被人工翻阅/审计的知识本体”——这是 llm-wiki 区别于 RAG 黑盒向量库最直观的一处。


4. 摄入产物全貌(第 3、4 讲合起来看)

data/raw/*.md
  └─► 205 KnowledgePoint(KP_001…KP_205,source 带行号)
  └─► keywords 注入用户口语词(concept_map,幂等)
  └─► wiki/knowledge_vectors.npy(bge-m3 向量缓存,供查询召回 + semantic 边)
  └─► knowledge_graph.json(205 节点 + 2962 带类型边)
  └─► wiki/(index.md 概念导航 + KP_*.md + log.md)

第 5、6 讲开始,系统将加载这批编译产物做检索与生成——摄入期做得越干净,查询期越省心。


5. 本讲小结

  1. 概念关键词增强在摄入期把”用户口语词”注入法条知识点关键词,弥合检索代沟;改映射表不碰代码。
  2. 图谱四类边各司其职:sibling 结构、keyword 主题、semantic 语义、crossref 显式引用且权重最高;跨法不连边防张冠李戴。
  3. 图谱 JSON 是无向带类型边、多 type 合并、权重取大。
  4. Wiki = 概念导航 + 每知识点一页 + 双向链接 + 日志 → “可读可审计”落地。

企业常见面试问答

Q1:怎么解决”用户用大白话问、法条用书面语写”导致的检索脱靶?

核心思路是在摄入期做概念关键词增强:维护一张”法律概念 → (来源法律, 条文)“的映射表和”概念 → 用户常用查询词”表,把” 加班费/欠薪/试用期/二倍赔偿”这类口语词,在编译时幂等注入到对应法条知识点的 keywords 字段。BM25 建索引时语料就含标题+正文+keywords(第 5 讲),所以用户说”加班费”,能命中原文只有”延长工作时间的工资报酬”的劳动法第四十四条。映射表是内容交付物,扩充不需要改代码,重跑管线即生效。

Q2:知识图谱里四种边分别怎么来的?crossref 边为什么权重最高?

sibling 来自结构:同一章下的条文两两相连;keyword 来自主题:关键词 Jaccard≥0.3;semantic 来自语义:bge-m3 余弦≥0.75(摄入期一次算好);crossref 来自法条正文的显式引用:扫 content 里的”第X条”连到同法对应条文。crossref 权重 1.5 压过 sibling 的 1.0,因为”这条的规则是引用另一条算出来的”——多跳召回时应优先把被引用条文带进上下文(比如试用期条款引到解除条款)。它只在同法内建边,因为两部法同条号会跨法误连。

Q3:semantic 边的阈值 0.75 怎么定的?有什么坑?

靠图的稀疏度实测调参。一开始用 0.65,结果图近全连接——语义稍有沾边的都连上,边的噪音远大于信息量,BFS 扩展会拉到一堆无关条文。逐步把阈值抬到 0.75 后,边才变得稀疏可信,扩展才有效。同类教训对 keyword 边(0.3)和停用词过滤同样适用: 关联边的质量决定了多跳扩展的质量,宁可少而准,不要多而脏。

Q4:你总说”知识库可读可审计”,具体体现在哪些文件上?

三处落盘物:① knowledge_graph.json —— 节点和带类型的边是纯文本 JSON,可以直接打开检查”46 条连没连到 38 条、是什么类型、权重多少”;② wiki/index.md —— 概念导航,人点概念直达法条页;③ wiki/concepts/KP_*.md —— 每知识点一页,frontmatter 带 id/来源文件/行号,正文下方列出按权重排序的关联双向链接。再加上每次构建写 wiki/log.md,全部可由人工翻阅核验——这跟” 一串看不见的浮点向量”形成鲜明对比,是 llm-wiki 对法律这类需追溯场景的核心卖点。


下一讲:第 5 讲 查询(一):BM25 + bge-m3 双路召回与 RRF 融合 上一讲:第 3 讲 知识构建(一):从 PDF 到 205 个知识点