
简介基于RAG架构的智能法律问答系统项目包面向法律咨询场景为法律科技开发者、NLP学习者和毕业设计团队提供一套可运行、可研读的工程样例。它解决的是传统人工法律咨询效率低、响应慢的问题通过检索增强生成技术实现专业准确的问答。资源共217个文件、约2.35MB包含178个txt法律数据/语料笔记、9个Python脚本文档切分、向量检索、生成调用、7个HTML页面及配套CSS/JS界面另有yaml配置与license信息目录结构适合按功能模块阅读。目前已有92人学习下载。价值点在于既能从源码中掌握RAG在垂直领域的落地流程也能借助极简说明快速梳理法律知识库构建、查询向量化、上下文融合及大模型回答生成等关键环节同时还适用于智能客服、政务咨询等相似场景的前端与后端联动参考。1. 法律问答不吃“大模型直接答”那一套为什么必须走 RAG法律咨询是我见过最不适合纯靠大模型裸答的场景。用户问“离职时公司扣押工资条合法吗”一个没有外部知识的模型可能给出《劳动法》条文却不知道地方高院的裁审口径已经变了用户问“离婚冷静期内对方转移财产怎么办”模型可能把“冷静期”和“诉前保全”混在一起说。法律问答的容错率极低答错一条就可能误导用户做出错误决策所以业内的共识是必须用 RAG 架构把大模型的生成能力和外部法律知识库的检索能力绑在一起让模型先查证据、再开口说话。这个“基于 RAG 架构的智能法律问答系统”本质上就是一个面向法律咨询场景的检索增强问答平台。它解决的核心问题是三个法条和裁判口径怎么存、怎么检索、怎么让大模型基于检索结果作答而不是凭记忆编造。适合谁适合要做法律咨询产品、企业合规问答、法院诉服辅助工具的团队也适合正在学 RAG 落地、想找一个有明确业务边界的参考案例的开发者。2. 先拆 RAG 骨架法律文本的分块、向量化和召回是怎么串起来的2.1 法律文本不能按 Markdown 标题切块要按“法条单元”切做 RAG 的第一步不是选模型是决定知识库里的文本怎么切。很多初学 RAG 的人直接拿 LangChain 的RecursiveCharacterTextSplitter按 500 字符硬切这在法律场景里会切出大量“半条法条”。为什么不行因为法律文本的引用单位是“第几条”下游检索时用户问的是“试用期工资不得低于转正工资的多少”如果知识库里一条完整的《劳动合同法》第二十条被切成了两半向量检索召回的可能只有前半句模型拿到的上下文就是残缺的。我一般会优先做法条单元的粗切分再配合向量化做细召回。具体做法是先把原始文本按章、节、条的结构解析成树每个叶子节点是一条完整法条对过长的法条比如《民法典》里那种几百字的条款再按句号切句保留“条号 本条全文”的元数据。切分之后每条文本块的长度控制在 300800 字之间既保证语义完整又不至于让 embedding 模型把整段语义压成一个模糊向量。import re from typing import List, Dict def parse_law_articles(raw_text: str) - List[Dict[str, str]]: 按“第X条”粗切法条保留条号和正文。 法律文本常见格式第二十条 试用期工资不得低于本单位相同岗位最低档工资... pattern re.compile(r(?Pnum第[一二三四五六七八九十百零\d]条)\s*(?Pbody.*?)(?第[一二三四五六七八九十百零\d]条|$), re.S) articles [] for m in pattern.finditer(raw_text): body m.group(body).strip() if len(body) 20: continue # 过滤只有条号没有正文的脏节点 articles.append({ article_no: m.group(num), content: m.group(num) m.group(body) }) return articles raw open(labor_contract_law.txt, encodingutf-8).read() articles parse_law_articles(raw) print(f共解析出 {len(articles)} 条法条)这段代码的关键在于正则里的re.S让.能匹配换行否则跨行的法条正文会被切断。(?第X条)是零宽先行断言用来做切分边界但不消费边界字符这样每一条法条都不会丢失“第X条”这个前缀。过滤len(body) 20是为了丢掉解析过程中遇到的“目录”“附则”里只有条号没有正文的脏数据。粗切分之后我还会再做一次“语义合并”如果某条法条被法律原文里的“一二三”子项切成多段需要把同一“条”下的子项拼回一个文本块。这一步不是必须的但做不做直接影响检索命中率。2.2 Embedding 模型选型法律领域要的是“判例近义”而不是“字面近义”知识库切好了接下来是把文本块变成向量。这里有个很容易翻车的点通用 embedding 模型在法律领域的表现并不好。比如“借条”和“欠条”在法律上完全是两回事但通用模型可能把它们当成高度相似反过来“合同解除”和“合同终止”在裁判口径里有细微差别通用模型可能认为它们差异很大。常见做法是优先选中文法律语料上微调过的 embedding 模型或者在通用模型之上做一次领域适配。如果团队没有资源和算力做微调至少要把模型切换为对中文长文本支持更好的版本并在评测集上对比召回率。评测集不需要很大100 条真实法律问答就够看出差异指标看hit_rate标准答案是否在召回 Top-K 内和MRR标准答案的排序位置。from langchain_community.embeddings import HuggingFaceEmbeddings embeddings HuggingFaceEmbeddings( model_namepath/to/finetuned-law-embedding, # 换成你自己的法律领域模型 model_kwargs{device: cuda}, encode_kwargs{normalize_embeddings: True} # 归一化余弦相似度计算更稳 )normalize_embeddingsTrue是必开项。归一化之后向量检索用的相似度计算从点积变成余弦相似度数值范围收敛到 [-1, 1]不容易出现同一个文本块在不同批次下相似度分数漂移的问题。如果向量库用的是 Faiss 的IndexFlatIP归一化是前置条件否则内积分数会偏向长文本。RAG 检索效果好不好七成由切分和 embedding 决定大模型只负责“翻译”检索结果。我在前面的项目里踩过最大的坑就是把调优精力全部放在 Prompt 上结果检索回来一堆无关法条Prompt 写得再好也是垃圾进垃圾出。2.3 召回策略别只做 Top-K 向量相似要混合召回加重排法律问答的召回阶段我推荐直接走“向量召回 关键词召回 重排”的三段式。为什么不能只靠向量因为用户提问常常是口语化的“我被公司开了能拿多少钱”这句话向量化之后能匹配到关于经济补偿金的法条但“开了”这个词在法律文本里不存在倒排索引能命中“解除”“终止”等正规表述却命中不了“开了”。反过来向量检索可能因为“钱”这个字把借贷相关的法条也召回来。混召怎么做向量召回用 Faiss 或 Milvus 取 Top-50关键词召回用 ES 的 BM25 取 Top-30两路结果按文档 ID 合并去重再交给一个重排模型cross-encoder打分最后取 Top-5 作为上下文。整套链路在业务上等价于先广撒网再用精确匹配粗筛最后用交互式模型精排。# 混合检索示例向量召回 BM25召回 rerank合并 def hybrid_retrieve(query: str, k: int 5) - List[str]: query_vec embeddings.embed_query(query) # 向量召回 vector_hits vector_store.similarity_search_by_vector(query_vec, k50) # 关键词召回伪代码ES查询 bm25_hits es_law_index.search(query, size30) merged {} for doc in vector_hits bm25_hits: merged[doc.id] doc # 按文档ID去重优先保留向量版本 # 用cross-encoder对合并结果精排 pairs [(query, merged[doc_id].content) for doc_id in merged] scores reranker.predict(pairs) # 返回相关性分数列表 ranked_ids [doc_id for _, doc_id in sorted(zip(scores, merged.keys()), reverseTrue)] return [merged[i].content for i in ranked_ids[:k]]混合召回的关键参数不是 K 值是两路召回的“召回数量配比”。向量取 50 条、BM25 取 30 条是为了保证召回池里有足够的候选重排模型可以从容地把不相关的滤掉如果向量只取 5 条、BM25 只取 3 条重排环节就成了摆设。Rerank 模型我建议选 cross-encoder 而不是 bi-encoder虽然慢一点但在法律问答这种“差之毫厘谬以千里”的场景精度优先。还有一个容易被忽略的细节法律文本的版本问题。同一部法律可能有修正前和修正后两个版本存储在知识库时必须在文档元数据里写入effective_date生效日期和is_current是否现行有效。检索结果返回时如果命中了两版同一条文需要按生效日期取较新版本否则模型会答出新旧法条混淆的内容。3. 从检索到作答把法条变成回答而不是把法条背出来3.1 Prompt 模板让大模型当“法条翻译官”而不是“法条复读机”很多 RAG demo 的 Prompt 就一句话“基于以下知识回答问题。”这在法律场景远远不够。法律问答的输出要求是先给结论再列依据最后加风险提示。如果模型只是把召回的法条重新排列组合输出用户看不懂。我常用的 Prompt 结构是四段式角色定义、任务说明、引用格式约束、不确定时的处理策略。角色定义限定了“你是一名法律顾问”任务说明要求“结合检索到的法条分析不要依赖自身记忆”引用格式约束强制输出时标注“根据《XX法》第X条”不确定时的处理策略要求模型在检索知识不足时明确说“当前知识库未覆盖该问题建议咨询律师”。prompt_template 你是一名专业的法律咨询顾问请基于【检索到的法律知识】回答用户问题。 回答必须遵守以下规则 1. 先直接给出结论再引用具体法条展开分析最后给出合规建议或风险提示 2. 引用法条时必须标注法律名称和条号例如根据《劳动合同法》第二十条 3. 如果检索知识不足以支撑完整回答明确告知用户“该问题需要结合具体案情判断”不要推测 4. 不要编造检索知识中不存在的法条或案例。 【检索到的法律知识】 {context} 【用户问题】 {question} 请作答 这个模板的关键在于第 3 条。法律领域最常见的幻觉不是编造法条是模型把相似的法规张冠李戴比如把《劳动法》的条款安到《劳动合同法》头上。加了“不要推测”之后模型在知识不足时会选择保守回答这一条能显著降低法律风险。Prompt 里不用写太多“你是一个智能助手”之类的废话法律问答用户要的是准确不是聊天体验。反而是输出格式值得多花两行字约束因为下游如果接了客服工单系统结论、依据、风险提示三段式解析起来更方便。3.2 生成参数法律问答的温度和 max_tokens 怎么定大模型生成参数里temperature和top_p是最容易被忽略的。很多 RAG 项目直接用默认值 0.7这在文案创作场景没问题但法律问答需要的是稳定输出不是文采。我一般把temperature设到 0.1top_p设到 0.3让模型尽可能选概率最高的 token减少同一个问题在不同时间问得到不同答案的情况。max_tokens也要单独算。法律问答的回答通常需要引用法条原文和解说输出长度一般比普通问答长。但如果设得过大模型可能开始无关展开把检索到的多条法条全部复述一遍。我一般设 1024如果分析本身需要更长篇幅优先优化检索结果而不是放开 token 限制。from openai import OpenAI client OpenAI(base_urlhttp://localhost:11434/v1, api_keyollama) response client.chat.completions.create( modelqwen2.5:14b, # 或你的部署模型名 messages[ {role: system, content: 你是法律问答助手。}, {role: user, content: prompt_template.format( context\n\n.join(retrieved_docs), questionuser_query )} ], temperature0.1, top_p0.3, max_tokens1024, streamFalse )temperature设低之后模型输出会显得“干”但这是好事。法律问答的用户要的是确定性答案不是“或许”“可能”模棱两可的表述。如果你在自己项目的评测里发现答案过于重复、缺乏对具体案情的适配可以微调到 0.2但不要突破 0.3。base_url指向 Ollama 的本地接口时api_key随便填一个占位符即可。这行代码用的是 OpenAI 兼容协议方便后续切换推理后端。真正要注意的是模型上下文窗口RAG 检索回来的 5 条法条每条 300800 字拼接后可能已经吃掉 3000 个 token加上问题和输出如果模型的上下文窗口只有 8K就会捉襟见肘。选模型时要保证上下文窗口至少 16K我一般选 32K。3.3 引用溯源回答里附上“法条出处”是法律问答的刚需法律 RAG 和普通 RAG 最大的区别是回答必须能溯源。用户问“试用期工资是多少”模型答“不得低于本单位相同岗位最低档工资的 80%”用户凭什么是这条系统必须在回答后面附带来源法律名称、条号、原文片段、检索得分。这一步既是为了用户信任也是产品合规的底线。实现上很直接Prompt 生成的回答不直接返回给前端而是在后端把retrieved_docs的元数据和回答绑定成结构化 JSON。前端渲染时把“结论/依据/来源”分开展示依据部分引用法条原文。{ answer: 根据《劳动合同法》第二十条试用期工资不得低于本单位相同岗位最低档工资或者劳动合同约定工资的百分之八十。, cited_sources: [ { law_name: 劳动合同法, article_no: 第二十条, content_snippet: 劳动者在试用期的工资不得低于本单位相同岗位最低档工资或者劳动合同约定工资的百分之八十..., retrieval_score: 0.87 } ] }retrieval_score是个红队指标而不是展示指标。当前端展示给用户时不建议直接显示“相似度 0.87”这种技术黑话用户不知道 0.87 意味着什么。但后台值班人员应该看到这个分数因为分数低于 0.6 的检索结果基本是凑数的这类回答应该被标记为“低置信度”。引用溯源还有一个隐藏用途错误定位。如果用户反馈某个回答错了值班人员可以直接看cited_sources里命中哪条法条是检索错了还是生成错了。没有溯源的话排查一个错误回答要翻日志、复现场景成本高得多。4. 项目落地代码框架一个极简 RAG 问答系统的完整链路4.1 知识库构建从原始法条到可检索向量的三步流水线法律知识库的建设是一个持续过程不是跑一次脚本就完事。常见做法是三步流水线文本清洗与解析、分块与向量化、写入向量库。每一步都要做日志记录和数据版本管理。# build_knowledge_base.py - 完整构建流程 from langchain_community.vectorstores import FAISS from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter import json # 第一步加载原始文本并粗解析为法条单元 loader TextLoader(law_documents/civil_code.txt, encodingutf-8) documents loader.load() articles parse_law_articles(documents[0].page_content) # 第二步对过长法条做二次切分 写入元数据 splitter RecursiveCharacterTextSplitter( chunk_size800, chunk_overlap120, separators[\n, \n第, 。, , \n\n], # 按法律文本结构优先切 length_functionlen ) chunks_with_meta [] for art in articles: if len(art[content]) 800: chunks_with_meta.append({ page_content: art[content], metadata: {article_no: art[article_no], law_name: 民法典} }) else: sub_chunks splitter.split_text(art[content]) for idx, sub in enumerate(sub_chunks): chunks_with_meta.append({ page_content: sub, metadata: {article_no: art[article_no], law_name: 民法典, chunk_idx: idx} }) # 第三步向量化并写入本地向量库 texts [c[page_content] for c in chunks_with_meta] metas [c[metadata] for c in chunks_with_meta] vector_store FAISS.from_texts(texts, embeddings, metadatasmetas) vector_store.save_local(faiss_law_index) print(f已构建 {len(texts)} 个文本块)RecursiveCharacterTextSplitter的separators顺序按“优先完整保留法律结构”的原则排列。\n优先于。是为了让“一二”这种子项不被切断。是最后的兜底切分点保证即使 4000 字的长法条被切碎了每一块至少是语义完整的整句。FAISS.from_texts会默认调用一次 embedding 模型如果文本量很大这一步是耗时的瓶颈。常见做法是先批量算向量再写入索引但项目极简版直接from_texts够用。chunk_overlap120是有意为之法条被切分的边界处容易丢失上下文比如“前款所称的‘以上’包括本数”这种指代表述如果前一章的“本数”在上一块当前块就丢了关键信息。120 字符的 overlap 能在向量化时保留部分边界语义。4.2 问答接口封装把检索和生成串成一个 Serve 服务知识库构建好了剩下的是把整条链路串成一个可供业务调用的服务。项目极简说明里给的框架一般是 FastAPI RetrievalQA 链但我会更倾向于手写检索和调用的编排不用 LangChain 的高层 Chain。原因很简单高层 Chain 封装太多检索失败、重排失败、生成超时这些异常情况很难单独处理。from fastapi import FastAPI, HTTPException from pydantic import BaseModel app FastAPI() class QueryRequest(BaseModel): question: str top_k: int 5 class QueryResponse(BaseModel): answer: str sources: list app.post(/ask, response_modelQueryResponse) async def ask_legal_question(req: QueryRequest): try: retrieved_docs hybrid_retrieve(req.question, kreq.top_k) context_text \n\n.join(retrieved_docs) final_prompt prompt_template.format(contextcontext_text, questionreq.question) llm_answer get_llm_response(final_prompt, temperature0.1) return QueryResponse( answerllm_answer, sources[build_source_meta(doc) for doc in retrieved_docs] ) except Exception as e: raise HTTPException(status_code500, detailf问答处理失败: {str(e)})这段代码的top_k设计成接口参数有讲究前端页面可以针对不同类型的用户调不同 K 值。比如普通用户问题简单Top-5 够用企业合规顾问问的问题复杂可以调成 Top-8 再多喂两条法条。但注意 K 值不宜超过 10否则大模型的上下文会被无关内容稀释反而拉低回答质量。build_source_meta函数需要把retrieved_docs里的元数据提取成 JSON 可序列化格式否则 FastAPI 的 response_model 校验会报错。这个细节我有一次上线时踩过LangChain 的Document对象里 metadata 是 dict但page_content超长时直接塞进 response 会把几 MB 的文本全文返回给前端一定要截断成snippet片段。4.3 三个必调的部署参数向量库阈值、超时时间、并发数项目落地之后要调的参数不只是top_k和temperature还有三个容易被忽略的部署参数第一个是向量库阈值。Faiss 的similarity_search_with_score返回的分数在归一化 embedding 下是余弦相似度我一般设 0.5 作为最低分阈值低于这个分数的文档不进上下文。第二个是 HTTP 超时。大模型推理在 CPU 机器上可能 10 秒都不返回FastAPI 默认没有超时限制前端如果等太久会以为服务挂了。我一般把超时设 30 秒并在服务端做好流式响应或任务队列。第三个是并发数。RAG 链路里 embedding 和 rerank 都吃 GPU 显存如果并发上来直接 OOM需要限制同时处理的请求数常见做法是信号量或队列。import asyncio from fastapi import FastAPI from contextlib import asynccontextmanager # 限流同一时间最多处理4个RAG请求 semaphore asyncio.Semaphore(4) app.post(/ask) async def ask_with_limit(req: QueryRequest): async with semaphore: return await process_ask(req)Semaphore(4)意思是同时最多 4 个请求在做检索生成链路其他请求排队。这个数字不是随便定的需要拿压测数据说话。我一般用locust或wrk先试 1/2/4/8 线性看 P95 延迟找到延迟拐点。对端到端服务来说与其让 10 个请求全部超时不如让 4 个请求稳定 3 秒返回。排队体验优于超时重试。5. 避坑与常见问题法律 RAG 落地时最容易翻车的 5 个地方5.1 现象用户问“离婚财产分割”回答引用了《婚姻法》而不是《民法典》原因知识库里有《婚姻法》旧版全文embedding 检索时旧法条文和用户问题高度相关被召回了。解决在知识库写入时用元数据标记is_currenttrue/false检索阶段过滤掉is_currentfalse的文本块。更保险的做法是直接不把废止法律导入向量库只留“旧法对照表”作为辅助文档。这个事看着简单但只要知识库更新过一次没加过滤翻车概率极高。5.2 现象同一个问题上午下午回答不一致用户投诉原因temperature设太高比如默认 0.7模型每次从概率分布里采样输出不完全一样。解决把temperature压到 0.1 以下top_p压到 0.3。法律问答场景不需要创造性表达需要的是确定性。如果你发现同一个问题输出差异还是大再看一下是否有多条相似法条都被检索到了导致模型每次侧重点不同这种情况要调检索而不是调生成。5.3 现象回答看起来专业但法条编号是编的原因模型基于检索到的上下文做生成时偶尔会把不同法条的编号和内容拼接错位。比如检索到《劳动合同法》第二十条和《劳动合同法实施条例》第十五条模型可能把两者的内容和条号互相安错了。解决Prompt 里明确要求“引用的法条编号必须来自检索内容禁止自行推测”同时在服务端加一道校验用正则从回答里提取“《XXX》第X条”检查该条号是否存在于cited_sources中。如果不存在把引用高亮标记为“疑似存疑”让值班人员人工复核。5.4 现象检索命中率很高但回答质量分数很低原因命中率只说明相关法条进入了上下文不代表大模型真的使用了它。常见情况是 5 条检索结果里只有第 4 条是真正有用的但 Prompt 把 5 条全部堆给模型模型被前面 3 条不相关的信息带偏了。解决看检索结果里的retrieval_score排序如果 Top-3 的分数都低于 0.6说明 embedding 选型或切分策略有问题。把 RAG 评估拆成两个指标分别看hit_rate看召回answer_quality看生成两个指标单独调优别混在一起。5.5 现象新增法条后查询旧问题结果变差了原因向量库新增文档后某些新文档和旧文档语义相似度过高Top-K 结果里被新文档占坑旧文档排到了后面。如果新增的是修正版法条新旧同条文本同时存在会互相干扰。解决每次知识库版本更新后必须重建索引而不是增量追加。法律文档的增量和普通文档不一样它涉及版本替换旧版本要物理删除不是靠 metadata 过滤就行。我一般在 CI 里跑一个流水线原始法条文本变化 → 触发重建 → 跑一遍固定评测集对比 hit_rate确认没有回退再上线。6. 进阶用评测集和 Query 改写把法律 RAG 调到可上线状态前面讲的都是“系统能跑起来”但能不能上线要看评测数据。法律 RAG 的评测集建起来并不复杂100 条问答足够。每条数据包含三部分用户问题、标准答案对应的法条 ID、期望的回答要点。评测时自动计算hit_rate5再抽样人工评审回答质量。import json eval_data [ {question: 试用期最长可以约定多久, expected_articles: [劳动合同法_第十九条]}, {question: 公司拖欠工资员工可以辞职吗, expected_articles: [劳动合同法_第三十八条]}, {question: 离婚冷静期是多久, expected_articles: [民法典_第一千零七十七条]} ] def evaluate_hit_rate(top_k_docs, expected_articles): hits 0 for item in eval_data: retrieved_ids {doc.metadata[doc_id] for doc in top_k_docs[item[question]]} if set(item[expected_articles]) retrieved_ids: hits 1 return hits / len(eval_data)hit_rate5的目标值我建议定在 0.85 以上。如果你跑下来低于 0.7优先检查切分策略而不是换模型因为法律文本的结构特殊性意味着切分对命中率的影响比 embedding 模型选型更大。可以先试“按条切 二级句子切”的效果再试不同 embedding 模型的对比。另一个上线前要做的优化是 Query 改写。用户的原始提问往往是口语化的比如“我被裁员了怎么办”“公司逼我自己辞职怎么维权”。直接拿这些话去检索法条原文里没有对应表述命中率会掉。常见做法是用一个小模型先把用户问题改写为“法律检索式”比如“裁员 经济补偿 劳动合同法”或“公司 胁迫 辞职 解除劳动合同 赔偿金”再用改写后的文本去做向量检索和 BM25 检索最后把原始问题喂给生成模型。def rewrite_query_for_retrieval(user_question: str) - str: # 用轻量模型做改写避免让主生成链路变慢 rewrite_prompt f请把以下用户问题改写为一个法律检索式输出关键词组合不要输出完整句子。 用户问题{user_question} 检索式 return call_llm(rewrite_prompt, temperature0.0, max_tokens50)Query 改写需要注意一个坑改写后的检索式可能引入歧义。例如“离职赔偿”可以指向经济补偿金也可以指向赔偿金两者法律性质不同。解决方法是检索时同时用原始问题和改写检索式两路召回合并结果时把两份结果按文档 ID 交叉去重再一起进 rerank。这样既保留了口语化问题的语义又补上了法律术语的精确匹配。上线后还有一个习惯值得养成每次知识库版本更新后跑一次完整的评测集并记录 hit_rate、MRR 和人工评审分。我见过不少团队把 RAG 系统上线后就不管了法条更新了但知识库还是旧的直到用户投诉“这个法条不是改了吗”才发现问题。法律知识是有时效性的RAG 的优势正在于知识库可以快速更新但前提是你把更新和评测做成常态化机制。最后聊一下我自己的习惯我会在每次调整切分参数或向量库版本后固定抽 20 条评测样本人工读一遍回答不是看有没有语法错误而是看法条引用是否和结论一致。这个问题自动化很难完全覆盖因为法律逻辑的合理性判断需要人来把关。系统做得再完善也需要在产线上留一个“人工复核”的出口这比堆再多参数都管用。希望这些实践能帮你在法律 RAG 的落地路上少踩几个坑。本文还有配套的精品资源点击获取