系统总览与工程环境 —— 劳动法知识问答系统
所属课程:《法律问答系统(llm-wiki)》八讲课件 · 第 2/8 讲 定位:建立整盘图景。这一讲把”系统长什么样、用什么技术、代码怎么分目录、环境怎么搭”讲透,为第 3 讲起逐行看代码做铺垫。
本讲目标:
- 一句话说清系统是干什么的;
- 看懂 Ingest / Query 两阶段架构图与真实数据流;
- 理解每一项技术选型”为什么是它”;
- 能说出
app/四个分层的职责与kb.py的入口地位; - 能独立跑通环境搭建与三个常用命令。
1. 一句话定位
把《中华人民共和国劳动法》《中华人民共和国劳动合同法》两份 PDF 提前” 编译”为 205 个结构化知识点 + 关联图谱 + Wiki 页面集;用户提问时,用 BM25 关键词 + bge-m3 向量混合检索 定位相关知识点,再由 qwen-max 依据这些知识点综合生成带引用的回答,并用 SQLite 存短期记忆支持多轮对话。
- 领域:劳动法(两部法律)
- 交互:命令行问答(CLI),非 Web
- 定位:llm-wiki 理念的教学与落地样板
2. 两阶段架构总览
┌────────────────── 知识构建管线 Ingest(摄入,一次性"编译") ──────────────────┐
│ │
│ data/src/*.pdf(劳动合同法 / 劳动法) │
│ │ markitdown(微软,PDF→MD,增量) │
│ ▼ │
│ data/raw/*.md ──► 清洗噪音 ──► 按"章→节→条"切分 ──► 205 个知识点(一条一知识点) │
│ │ │
│ ├─► 概念关键词增强(concept_map:加班费→劳动法第44条 等口语词注入) │
│ ├─► 关联图谱 knowledge_graph.json(sibling/keyword/semantic/crossref, │
│ │ 2962 条带类型边) │
│ └─► Wiki 页面集 wiki/(index 概念导航 + 每知识点一页 KP_*.md) │
└──────────────────────────────────────────────────────────────────────────────┘
▲ 编译产物即"知识库本体"
│ 知识更新 = 重跑管线
│
│(启动时加载图谱 + 向量缓存)
▼
┌────────────────── 检索问答 Query(查询,只做"定位 + 综合") ─────────────────┐
│ │
│ 用户问题 + 短期记忆注入(并入最近一问,解"那/具体/继续"指代) │
│ │ │
│ ├─► 关键词检索:jieba 分词 + BM25(索引含标题/正文/概念增强后关键词) │
│ ├─► 向量检索:bge-m3 编码 query,与预计算向量算余弦 │
│ │ └─► RRF 融合 Σ 1/(60+rank) ─► Top-K 种子知识点(默认 5) │
│ └─► 沿关联图 BFS ≤3 跳扩展(分数=种子×边权×0.5^hop)补 3 个 → 至多 8 块 │
│ │ │
│ ▼ │
│ qwen-max(DashScope OpenAI 兼容接口)流式综合生成 │
│ │ + 短期记忆历史 + 上下文标题带法律名(防两部法同条号张冠李戴) │
│ ▼ │
│ 回答(带引用)→ 写 memory.db(SQLite)→ 写 logs/qa.log(JSON 事件流) │
└──────────────────────────────────────────────────────────────────────────────┘
贯穿全课的一条线:编译产物(wiki + 图谱)是知识的本体;查询只是”定位 + 综合”,向量只做召回打分、不存知识。*
改动任何代码前都要守住这条线。*
3. 技术选型逐一说清
| 技术 | 在这个系统里干嘛 | 为什么选它 |
|---|---|---|
| Python 3.13+ | 全系统语言 | 生态成熟;NLP/检索/LLM 库齐全 |
| uv | 虚拟环境与依赖管理 | 比 pip/venv 快一个量级、锁文件可复现 |
| markitdown | PDF → Markdown 转换 | 微软开源、对版式还原较好 |
| jieba | 中文分词 | 中文 BM25 必需的前置;TF-IDF 抽取关键词也用 |
| rank-bm25 | BM25 关键词检索 | BM25Okapi 即用、轻量 |
| bge-m3 | 语义向量编码(召回 + 语义边) | 中英双语效果好、本地可跑;向量仅做打分 |
| qwen-max(DashScope) | 综合生成回答 | 通义千问商业模型,OpenAI 兼容接口,API_KEY 即可调 |
| SQLite | 短期记忆(memory.db) | 单文件、零依赖,满足”最近 N 轮”轻量需求 |
| pytest | 单元测试 | 42 项全离线(假编码器 + mock LLM),可 CI |
版本红线(踩坑所得,勿乱改):
ragas==0.3.0+langchain-community==0.4.0+ langchain 1.x 是唯一验证兼容的组合。ragas 0.4.x 与 langchain-community≥0.4.2 不兼容;改动三者前务必确认。
4. 目录结构
llm_wiki_user_qa/
├── config.py # ★用户维护的单一配置源(模型路径/LLM/…,含无关旧配置)
├── pyproject.toml / uv.lock # uv 依赖与锁文件
├── data/
│ ├── src/ # 原始 PDF(只读源,新增法律放这里)
│ └── raw/ # markdown(PDF 转换产物 + 用户直接放 .md)
├── app/
│ ├── config.py # ★应用配置:路径常量 + 检索参数 + sys.path 注入
│ ├── kb.py # ★知识库加载与统一检索入口(KnowledgeBase)
│ ├── cli.py # 命令行交互问答(含短期记忆 + 日志闭环)
│ ├── logger.py # 结构化问答日志(滚动写 logs/qa.log)
│ ├── ingest/ # ① 摄入管线:PDF→MD→知识点→增强→图谱→Wiki
│ │ ├── pipeline.py # 管线入口
│ │ ├── pdf_to_markdown.py # PDF→MD 增量转换
│ │ ├── knowledge_extract.py # 清洗+章/节/条切分+关键词(核心)
│ │ ├── concept_map.py # 法律概念→条文映射 + 关键词增强
│ │ ├── graph_build.py # 关联图谱(4 类带类型边)
│ │ └── wiki_writer.py # Wiki 页面落盘(概念导航)
│ ├── retrieval/ # ② 混合检索
│ │ ├── embedder.py # bge-m3 编码器(带缓存 + 测试用假编码器)
│ │ ├── keyword_retriever.py # BM25 关键词
│ │ ├── vector_retriever.py # bge-m3 向量
│ │ └── hybrid_retriever.py # RRF 融合 + 图谱多跳扩展(核心)
│ ├── generation/ # ③ 生成
│ │ └── answer.py # qwen-max 流式生成(法律领域 prompt + 引用)
│ └── memory/ # ④ 短期记忆
│ └── memory_store.py # SQLite 存储
├── wiki/ # ★编译产物知识库(llm-wiki 页面集,可重建)
│ ├── index.md # 概念导航 + 按源文档分组索引
│ ├── log.md # 构建日志
│ ├── knowledge_vectors.npy # 知识点向量缓存
│ └── concepts/KP_*.md # 205 个知识点页面(含双向链接)
├── knowledge_graph.json # ★关联图谱(节点 + 带类型边),知识本体之一
├── logs/qa.log # 问答日志(JSON 事件流)
├── memory.db # 短期记忆 SQLite(运行生成)
├── eval/ # 评估:30 条用例 + recall@k + ragas
├── tests/ # pytest 单元测试(42 项)
├── docs/ # 本课程课件
└── README.md / HANDOVER.md / CLAUDE.md
记忆口诀:app/ 四分层 = ingest(编译) → retrieval(定位) → generation(综合) → memory(记忆),kb.py 是查询的统一大门,
wiki/ + knowledge_graph.json 是编译后的知识本体。
5. 双份配置:谁维护什么
系统有两份 config,各有分工:
5.1 根 config.py —— 用户维护的”单一配置源”
放机器相关、因人而异的值:本地 bge-m3 模型路径、LLM 模型名、DashScope Key/URL。另含 MySQL/Redis/Milvus 等**与本系统无关的旧配置
**,别被误导,本项目只用 bge_m3、model、dashscope_api_key、dashscope_base_url 几项。
⚠️
bge_m3是机器相关的本地模型路径,换一台机器第一件事就是改它。
5.2 app/config.py —— 路径常量 + 检索参数
BASE_DIR = Path(__file__).resolve().parent.parent
if str(BASE_DIR) not in sys.path:
sys.path.insert(0, str(BASE_DIR)) # 让 `python -m app.*` 能 import 到根
SRC_DIR = BASE_DIR / "data/src" # 原始 PDF
RAW_DIR = BASE_DIR / "data/raw" # markdown
WIKI_DIR = BASE_DIR / "wiki"
GRAPH_PATH = BASE_DIR / "knowledge_graph.json"
VECTOR_CACHE_PATH = BASE_DIR / "wiki" / "knowledge_vectors.npy"
MEMORY_DB_PATH = BASE_DIR / "memory.db"
LLM_MODEL = "qwen-max"
DASHSCOPE_API_KEY = os.getenv("API_KEY") # ★key 从环境变量读
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
TOP_K = 5 # 混合检索返回的知识点种子数
EXPAND_K = 3 # 沿关联边再扩展补充的知识点数
EXPAND_HOPS = 3 # 图谱多跳扩展最大跳数
EXPAND_DECAY = 0.5 # 多跳分数逐跳衰减系数
注意它开头把项目根注入 sys.path——这就是为什么统一用 uv run python -m ... 而不是裸命令跑(裸 pytest 可能找不到
app 包)。
6. 环境搭建与常用命令
要求:Python 3.13+、已装 [uv]、本地 bge-m3 模型、DashScope API Key。
uv sync # 安装/还原依赖(含 dev: pytest)
uv run python -m app.ingest.pipeline # ① 构建知识库(扫 data/src、data/raw,全量重建,幂等)
export API_KEY="your-dashscope-key" # ② 只有生成答案才需要真实 key
uv run python -m app.cli # ③ 命令行问答(无 key 时仅展示检索命中)
uv run python -m pytest tests/ -v # ④ 跑全部单元测试(42 项,全离线)
测试为什么离线:单元测试用测试专用假编码器 + mock LLM,不加载 2.2GB 的 bge-m3、不调 DashScope,秒级跑完;只有 cli
生成与 eval 才需要真实模型与 API_KEY。
数据增/换的约定(重要,贯穿全课):
- 要新增一部法律 → 把 PDF 放
data/src/(或把 md 直接放data/raw/); - 重跑
app.ingest.pipeline:PDF→MD 是增量(已转换跳过);MD→知识点→图谱→Wiki 是全量幂等重建; - 想让新概念出现在
wiki/index.md的”核心概念导航”,还要去app/ingest/concept_map.py补映射(第 4 讲展开)。
7. 本讲小结
- 系统 = 编译期(ingest)+ 查询期(query) 两阶段,知识本体在
wiki/ + knowledge_graph.json,不在向量里。 app/四分层职责清晰,kb.py是查询入口,app/config.py是参数总控。- 双份配置别搞混:根 config.py = 用户维护的机器相关值;app/config.py = 路径与检索参数。
- 命令统一走
uv run python -m ...;数据新增 = 放对目录 + 重跑 pipeline。
企业常见面试问答
Q1:介绍一下你这个项目的整体架构(经典开场问)。
它分两阶段。摄入期(ingest):用 markitdown 把两部劳动法 PDF 转 Markdown,清洗后按”章→节→条”切分成 205 个知识点,再注入用户口语关键词(concept_map),构建 2962 条带类型边的关联图谱,并落盘成可读的 Wiki 页面集;这些就是知识本体。查询期(query):用户问题先并入短期记忆里的最近一问,然后 BM25 关键词与 bge-m3 向量双路召回、RRF 融合出 Top-5 种子,再沿图谱 BFS 最多 3 跳扩展补 3 个,把最多 8 个知识块交给 qwen-max,让它依据给定知识点流式生成带引用的回答,最后写回 SQLite 记忆和日志。
Q2:为什么用 bge-m3?它和 API 的 embedding 有什么区别,为什么不用后者?
选型考量三点:本地可跑(不依赖每次调外部 embedding API 的网络与费用)、中英双语效果好、能离线复用。在这个系统里 bge-m3 还承担 摄入期语义关联边的构建,摄入时会批量编码所有知识点并存成 numpy 缓存(
knowledge_vectors.npy),查询时直接加载、query 现编码,开销可控。API embedding 更适合不需要离线/量小的场景,这里要批量+离线所以走本地模型。注意:本项目查询侧的召回融合用的是 bge-m3;评估 ragas 时用 DashScope 的text-embedding-v3是另一码事(第 8 讲会讲为何踩坑不能用 langchain 的 OpenAIEmbeddings 接 DashScope)。
Q3:知识库更新(比如新增一部法律)怎么做?需要动代码吗?
大多数情况不用动代码。把新法律 PDF 放进
data/src/(或 md 放进data/raw/),重跑uv run python -m app.ingest.pipeline即可:PDF→MD 增量转换,随后知识点合并、全局重新编号 KP_001…,图谱与 Wiki 全量重建。只有一个例外——如果希望新法律里的概念出现在wiki/index.md的”核心概念导航”以及让用户口语词能命中,需要在app/ingest/concept_map.py里补一条概念映射,再重跑管线。这就是”内容与代码分离”:知识是数据,代码是加工厂。
Q4:你说项目”不是 RAG”,那遇到不规整、海量的资料时怎么办?
说明项目定位的边界:llm-wiki 的成立前提是领域文档结构规整、语义颗粒分明(法律章→节→条天然可分),这种场景适合把知识” 编译”成可读资产。如果知识源是海量无结构网页、问答语料、代码库等,RAG 仍是更合适的方案。真要扩展能力,可以在本系统上叠加——比如把新文档作为补充数据源仍走 ingest 管线,或保留一个 RAG 兜底检索。方案服务于知识形态,不是非此即彼。
下一讲:第 3 讲 知识构建(一):从 PDF 到 205 个知识点 上一讲:第 1 讲 RAG 的局限与 llm-wiki 理念