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

系统总览与工程环境 —— 劳动法知识问答系统

所属课程:《法律问答系统(llm-wiki)》八讲课件 · 第 2/8 讲 定位:建立整盘图景。这一讲把”系统长什么样、用什么技术、代码怎么分目录、环境怎么搭”讲透,为第 3 讲起逐行看代码做铺垫。

本讲目标:

  1. 一句话说清系统是干什么的;
  2. 看懂 Ingest / Query 两阶段架构图与真实数据流;
  3. 理解每一项技术选型”为什么是它”;
  4. 能说出 app/ 四个分层的职责与 kb.py 的入口地位;
  5. 能独立跑通环境搭建与三个常用命令。

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 快一个量级、锁文件可复现
markitdownPDF → Markdown 转换微软开源、对版式还原较好
jieba中文分词中文 BM25 必需的前置;TF-IDF 抽取关键词也用
rank-bm25BM25 关键词检索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_m3modeldashscope_api_keydashscope_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. 本讲小结

  1. 系统 = 编译期(ingest)+ 查询期(query) 两阶段,知识本体在 wiki/ + knowledge_graph.json,不在向量里。
  2. app/ 四分层职责清晰,kb.py 是查询入口,app/config.py 是参数总控。
  3. 双份配置别搞混:根 config.py = 用户维护的机器相关值;app/config.py = 路径与检索参数
  4. 命令统一走 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 理念