基于 RAG(Retrieval-Augmented Generation,检索增强生成)的法律条文问答演示项目。
纯 Go 实现,零外部依赖,单二进制部署,前端嵌入,打开即用。
# 复制并配置环境变量
cp .env.example .env
# 编辑 .env,填入 ARK_API_KEY
# 启动(默认 :8080)
go run main.go
# 浏览器打开 http://localhost:8080,输入 API Key,提问| 变量 | 必填 | 默认值 | 说明 |
|---|---|---|---|
ARK_API_KEY |
rag-build 时必填 | - | 豆包 API Key |
ARK_BASE_URL |
否 | https://ark.cn-beijing.volces.com/api/v3 |
豆包 API 地址 |
ARK_CHAT_MODEL |
否 | doubao-seed-2-0-pro-260215 |
Chat 模型 |
ARK_EMBEDDING_MODEL |
否 | doubao-embedding-vision-250615 |
Embedding 模型 |
PORT |
否 | 8080 |
HTTP 服务端口 |
rag-demo/
├── main.go # 入口:serve(默认) / rag-build
├── go.mod # 独立 module,零外部依赖
├── rag/
│ ├── store.go # RAGStore + gob 序列化
│ └── search.go # 余弦相似度搜索
├── api/
│ ├── handler.go # HTTP handler:/api/query, /api/status
│ └── doubao.go # 豆包 API 封装(Embedding + Chat)
├── web/
│ └── index.html # 单文件前端(Go embed 嵌入到二进制)
├── scripts/
│ ├── pdf2txt.py # PDF → 纯文本(pdfplumber / PyMuPDF)
│ └── rag-parse.go # 纯文本 → JSONL(按条文结构化)
├── data/
│ ├── labor_law.jsonl # 107 条结构化条文
│ └── labor_law.bin # 预构建的向量索引(878KB)
└── .env.example
用户提问
↓
[Embedding] 问题 → 向量
↓
[检索] 余弦相似度,找到最相关的 5 条法条
↓
[生成] 把问题 + 检索到的法条 → 喂给 LLM
↓
LLM 基于法条内容回答,引用具体条文编号
这就是最经典的 RAG 流程:Retrieve → Augment → Generate。
这一步是整个 RAG 项目中最关键也最容易被忽视的环节。
# PDF 转纯文本
python scripts/pdf2txt.py
# labor_law.pdf → labor_law.txt# 按"条"切分,提取章节、条号
go run scripts/rag-parse.go
# labor_law.txt → labor_law.jsonl输出的 JSONL 每行一条法条:
{"number":44,"chapter":"第四章 工作时间和休息休假","text":"第四十四条 有下列情形之一的...","char_count":128}为什么劳动法适合简单 RAG? 因为法律文本有天然的结构——"条"就是最小语义单元,每条内容自包含,切分没有歧义。
ARK_API_KEY=xxx go run main.go rag-build
# 逐条调用 Embedding API → 序列化为 labor_law.bin构建过程:遍历 107 条 → 每条调一次 Embedding API → 得到 2048 维向量 → gob 序列化到 .bin 文件。
用户问"加班工资怎么算",调用同一个 Embedding 模型把问题转为 2048 维向量。
// 暴力搜索,107 条数据不需要近似算法
func (s *RAGStore) Search(queryVec []float64, topK int) []SearchResult对问题向量和所有法条向量计算余弦相似度,取 Top-5。
107 条 × 2048 维,暴力搜索耗时 < 1ms。百万级数据才需要 ANN(近似最近邻)。
System: 你是中国劳动法律顾问。根据以下条文回答...
User:
以下是相关的劳动法条文:
【第44条】(第四章 工作时间和休息休假)有下列情形之一的,用人单位应当按照...
【第36条】...
【第41条】...
用户问题:加班工资怎么算
LLM 基于检索到的条文生成回答,并引用条文编号。不是靠 LLM 的记忆,而是靠喂给它的真实数据。 这就是 RAG 相比纯 LLM 的核心优势——可溯源、不幻觉。
劳动法 107 条,每条独立,检索简单。但换一个场景就不行了——
"刘备兵败当阳是在什么时候,是什么原因?"
这个问题用上面的劳动法方案完全搞不定,因为:
信息分散在多个回目中:
- 第40回 蔡夫人议献荆州(政治背景:刘琮投降)
- 第41回 刘玄德携民渡江(核心事件:当阳溃败)
- 第42回 张翼德大闹长坂桥(后续:断后撤退)
用"刘备兵败当阳"做向量搜索,大概率只命中第 41 回的某个片段,丢掉前因后果。
这是 RAG 最常见的失败模式:问题需要跨多个文档块的信息,但简单检索只能找到局部。
最实用、性价比最高的方案。
原始文本(120 回,每回 3000-5000 字)
↓ LLM 预处理
两层索引:
摘要层:每回一条 200 字摘要(120 条,用于检索定位)
原文层:每回完整文本(用于喂给 LLM 生成答案)
检索流程:
用户问题 → 搜摘要层 Top-5 → 拉对应的原文 → 喂 LLM
摘要层天然包含"刘备"、"当阳"、"曹操南下"、"荆州"等关键信息,而且一条摘要覆盖整回内容,不会漏掉分散的细节。
120 回摘要,用豆包生成一遍,几毛钱。但检索准确率会有质的提升。
- LlamaIndex 的
SummaryIndex/DocumentSummaryIndex原生支持这种两层结构 - LangChain 的
MultiVectorRetriever可以存摘要向量 + 关联原文
# LlamaIndex 示例
from llama_index.core import DocumentSummaryIndex
index = DocumentSummaryIndex.from_documents(
documents, # 120 回原文
summary_query="用一段话概括本回的主要事件、人物和结果",
)一个复杂问题拆成多个简单问题,分别检索,合并结果。
用户问题: "刘备兵败当阳是什么时候什么原因"
↓ LLM 拆解
┌─────────────────────────────────────┐
│ 子查询 1: "刘备当阳之战经过" │ → 命中第 41 回
│ 子查询 2: "曹操南下荆州原因" │ → 命中第 40 回
│ 子查询 3: "刘备携民渡江" │ → 命中第 41 回
│ 子查询 4: "长坂坡之战结果" │ → 命中第 42 回
└─────────────────────────────────────┘
↓ 合并去重
第 40、41、42 回的相关段落
↓
喂给 LLM 综合回答
让 LLM 做 query 拆解,这一步的 prompt 很简单:
给定用户问题,生成 3-5 个不同角度的搜索查询,用于检索相关文档。
每个查询应该覆盖问题的不同方面。
用户问题:刘备兵败当阳是什么时候什么原因
输出 JSON 数组:
["刘备当阳之战经过", "曹操南下攻打荆州", "刘备携民渡江", "长坂坡之战"]
# LangChain
from langchain.retrievers.multi_query import MultiQueryRetriever
retriever = MultiQueryRetriever.from_llm(
retriever=base_retriever,
llm=llm,
)
# 自动拆解 → 多次检索 → 合并
# LlamaIndex
from llama_index.core.query_engine import SubQuestionQueryEngine
query_engine = SubQuestionQueryEngine.from_defaults(
query_engine_tools=[...],
)
# 拆分成子问题,分别查询,合并答案每次查询多调几次 Embedding + 一次 LLM(拆解),延迟和成本都翻倍。适合对质量要求高、对延迟不敏感的场景。
向量搜索擅长语义匹配("兵败" ≈ "溃败" ≈ "战败"),但对专有名词不如关键词。两者结合:
查询:"刘备兵败当阳"
↓
┌──────────────────┐ ┌──────────────────┐
│ BM25 关键词检索 │ │ 向量语义检索 │
│ 精确匹配: │ │ 语义匹配: │
│ "当阳""长坂坡" │ │ "兵败""溃逃""战败" │
└────────┬─────────┘ └────────┬─────────┘
└──────┬───────────────┘
↓
Reciprocal Rank Fusion (RRF) 加权合并
↓
最终排序结果
大多数现代向量数据库原生支持混合检索:
| 数据库 | 混合检索 | 备注 |
|---|---|---|
| Elasticsearch 8.x | kNN + BM25 | 最成熟,生产环境首选 |
| Milvus | 稀疏+稠密向量 | 开源,支持大规模 |
| Qdrant | 原生 Hybrid | Rust 实现,性能好 |
| Weaviate | 内置 BM25 + 向量 | 开箱即用 |
| PostgreSQL + pgvector | 需自己组合 | 轻量方案 |
# Qdrant 混合检索示例
from qdrant_client import QdrantClient
results = client.query_points(
collection_name="sanguo",
prefetch=[
# 向量检索
models.Prefetch(query=dense_vector, using="dense", limit=20),
# 稀疏向量(BM25 效果)
models.Prefetch(query=sparse_vector, using="sparse", limit=20),
],
query=models.FusionQuery(fusion=models.Fusion.RRF), # RRF 融合
limit=10,
)预处理阶段让 LLM 从文本中提取实体和关系,构建知识图谱。检索时先查图谱定位,再拉原文。
对三国演义每一回,让 LLM 提取结构化三元组:
输入:第 41 回原文
提取结果(示例):
┌──────────────────────────────────────────────┐
│ 实体: │
│ (刘备, 人物, 蜀汉) │
│ (曹操, 人物, 曹魏) │
│ (当阳, 地点, 荆州) │
│ (长坂坡, 地点, 当阳) │
│ │
│ 关系: │
│ (曹操, 追击, 刘备) │
│ (刘备, 兵败于, 当阳) │
│ (赵云, 救出, 阿斗) │
│ (刘备, 携民渡江, 十万百姓) │
│ │
│ 事件: │
│ (当阳之战, 发生在, 第41回) │
│ (当阳之战, 原因, 曹操南下荆州) │
│ (当阳之战, 结果, 刘备军溃败) │
└──────────────────────────────────────────────┘
提取用的 prompt:
阅读以下文本,提取所有实体和关系,输出 JSON 格式。
实体类型:人物、地点、势力、事件
关系类型:攻击、联盟、从属、发生在、导致、参与
文本:
{第41回原文}
曹操南下荆州
│ 导致
↓
刘琮投降 ──→ 当阳之战 ──→ 刘备军溃败
│ │
发生在第41回 赵云救阿斗
│ 张飞断后
地点:当阳
地点:长坂坡
用户问题: "刘备兵败当阳"
↓
实体识别: 刘备、当阳
↓
图谱查询: 找到"当阳之战"节点
↓
关系遍历:
→ 原因: 曹操南下荆州(第40回)
→ 经过: 刘备携民渡江(第41回)
→ 后续: 张飞断后(第42回)
↓
拉取第 40、41、42 回原文 → 喂 LLM
| 工具 | 说明 |
|---|---|
| 微软 GraphRAG | 端到端方案,自动提取实体关系 + 社区检测 + 分层摘要 |
| Neo4j | 图数据库,存储和查询知识图谱 |
| LlamaIndex KnowledgeGraphIndex | 自动从文档构建知识图谱索引 |
| LangChain GraphCypherQAChain | 自然语言 → Cypher 查询 → Neo4j |
# 微软 GraphRAG(最完整的开箱方案)
# pip install graphrag
# 1. 初始化项目
graphrag init --root ./sanguo
# 2. 把 120 回文本放入 input/ 目录
# 3. 构建索引(自动提取实体关系 + 社区聚类)
graphrag index --root ./sanguo
# 4. 查询(global 模式适合这种跨章节问题)
graphrag query --root ./sanguo \
--method global \
--query "刘备兵败当阳是什么原因"# LlamaIndex 知识图谱
from llama_index.core import KnowledgeGraphIndex
index = KnowledgeGraphIndex.from_documents(
documents,
max_triplets_per_chunk=10,
include_embeddings=True,
)
query_engine = index.as_query_engine()
response = query_engine.query("刘备兵败当阳是什么原因")构建成本高——120 回三国演义,每回都要调 LLM 提取实体关系,可能需要几块钱的 API 费用和十几分钟的处理时间。但构建是一次性的,查询效果对这种关联性强的文本提升巨大。
| 策略 | 构建成本 | 查询成本 | 适合场景 | 效果 |
|---|---|---|---|---|
| 简单 RAG(本项目) | 低 | 低 | 法条、FAQ、独立段落 | 信息集中时好 |
| 分层索引 | 低 | 低 | 长文档、书籍、报告 | 显著提升 |
| Multi-Query | 无 | 高(多次调用) | 复杂问题、多角度 | 召回率高 |
| 混合检索 | 中 | 低 | 专有名词多的领域 | 精确+语义兼顾 |
| GraphRAG | 高 | 中 | 小说、历史、人物关系 | 跨文档最强 |
实际项目中这些策略不是互斥的,而是组合使用。比如:分层索引 + 混合检索 + Multi-Query,三者叠加效果最好。
| 框架 | 语言 | 特点 |
|---|---|---|
| LlamaIndex | Python | RAG 专精,索引策略最丰富 |
| LangChain | Python/JS | 通用 LLM 框架,生态最大 |
| GraphRAG | Python | 微软出品,知识图谱 RAG |
| RAGFlow | Python | 开源 RAG 平台,带可视化 |
| Dify | Python | 低代码 LLM 应用平台 |
如果需要重新处理原始 PDF:
# 1. PDF → 纯文本(需要 Python 环境)
pip install pdfplumber
python scripts/pdf2txt.py
# data/labor_law.pdf → data/labor_law.txt
# 2. 纯文本 → 结构化 JSONL
go run scripts/rag-parse.go
# data/labor_law.txt → data/labor_law.jsonl
# 输出:107 条,可人工检查确认
# 3. JSONL → 向量索引
ARK_API_KEY=xxx go run main.go rag-build
# data/labor_law.jsonl → data/labor_law.bin| 路由 | 说明 |
|---|---|
GET /api/status |
索引状态(条文数、维度、模型) |
POST /api/query |
RAG 问答,body: {"question": "...", "api_key": "..."} |