知识构建(二)—— 概念增强、关联图谱与 Wiki 落盘
所属课程:《法律问答系统(llm-wiki)》八讲课件 · 第 4/8 讲 定位:摄入期剩下的三件事,也是 llm-wiki “可读可审计 + 支持多跳”的两大支撑物。对应
app/ingest/concept_map.py、graph_build.py、wiki_writer.py。
本讲目标:
- 讲清”法律措辞 ↔ 用户口语”的鸿沟,以及概念关键词如何弥合;
- 理解关联图谱四类带类型边各自来源、权重/阈值,以及 crossref 为什么最关键;
- 看懂图谱 JSON 结构与边合并逻辑;
- 认识
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 | 主题词重合 |
semantic | bge-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. 本讲小结
- 概念关键词增强在摄入期把”用户口语词”注入法条知识点关键词,弥合检索代沟;改映射表不碰代码。
- 图谱四类边各司其职:sibling 结构、keyword 主题、semantic 语义、crossref 显式引用且权重最高;跨法不连边防张冠李戴。
- 图谱 JSON 是无向带类型边、多 type 合并、权重取大。
- 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 个知识点