☰
法律RAG问答系统实践:知识库切分、混合检索与生成评估
2026/10/8 4:05:13 网站建设 项目流程

简介:一套基于RAG架构的智能法律问答系统项目极简说明,面向法律咨询场景,可供AI开发者、法律科技从业者及NLP初学者参考。其用检索增强生成方式处理法律查询,缓解传统人工咨询效率低、法规更新快和信息获取难的问题。压缩包共217个文件,约2.35MB,主体为178个txt语料文档,另有9个Python脚本、7个HTML页面、7套CSS样式、7个JS交互及5张架构图,附YAML配置与Markdown说明;前端涉及登录注册、知识库上传与编辑等模块,目录按前端页面、后端脚本和语料数据分层组织,便于按模块翻阅。已有92人浏览学习。借助该资料可理清RAG系统的检索、生成与知识库管理完整链路,参考Python实现和页面组织方式搭建原型,并利用文本语料与配置文件验证回答生成逻辑,适合作为法律AI问答项目的设计蓝本。

1. 法律智能问答为什么先选RAG:先解决“直接问大模型不靠谱”的问题

我做法律问答系统之前,先拿ChatGPT试过几轮。问“试用期被辞退能拿赔偿吗”,模型给的答复看着条理清晰,但引用条文的数字往往是编的——第几条、第几款对不上是常态。法律咨询场景最不能接受的恰恰是“看着对、实则错”。后来换成RAG(检索增强生成)架构,把《民法典》《劳动合同法》和真实判例先落入知识库,让大模型只在检索结果之上作答,引用出处可点开核对,正确率才真正能站在生产线上。

这套架构落地并不复杂:先建法律知识库,再做混合检索召回,最后由LLM生成带引用的回答。我拆的这个项目,正好覆盖了这整条链路。它适合谁?两类人:一类是想把法律咨询搬上线的产品开发者,另一类是正在做RAG应用、想看看法律这种“强事实领域”怎么处理数据切分和检索的工程师。下面按我的拆解顺序讲,从知识库怎么建,到检索链路怎么调,再到最后一个知识点一个知识点地抠细节。

2. 把法律条文装进知识库:文档清洗、切分与Embedding入库

2.1 法律数据选型:不是所有PDF都值得进库

法律知识库的数据源通常分四类:法律条文、司法解释、裁判文书、法律期刊观点。这四类清洗难度完全不同。法律条文最干净,从国家法律法规数据库导出的文本基本是结构化,但注意带有“目录”“章节标题”这类噪音。裁判文书最脏,真实判决书里既有“本院认为”的说理部分,也有当事人信息、律师信息等冗余字段,直接整段落库会严重稀释检索精度。

我一般会按用途拆分:条文和司法解释进“法条库”,裁判文书只保留“本院认为”段落和判决主文,期刊观点干脆不进正式库,只在答案需要法理分析时由模型自行发挥。这个选型决策直接影响后面对话质量——法条库负责“依据”,判例库负责“解释口径”,两套库的切分策略还不一样。

2.2 切分策略:按法律条文的天然边界来切

法律文书跟普通文章最大的区别是它有编号体系:第几条、第几款、第几项。如果按固定窗口硬切,很容易把一条完整法条拦腰截断。比如《劳动合同法》第四十七条讲经济补偿金的计算方式,前半条讲标准算法,后半条讲“不满六个月支付半个月工资”,中间一刀切下去,检索“工作半年被裁能拿多少补偿”时就只召回半条,模型拿到的上下文不完整。

切分策略我推荐“规则优先+窗口兜底”。先按条号(如“第四十七条”)定位切分点,把每条法条做成一个文档块;块太长超过模型上下文限制的,再按款向下切,但保留从“第几条”开始的完整前缀。下面是这套逻辑的Python实现,用的是LangChain的递归分割器加自定义条号分离器:

import re from langchain.text_splitter import RecursiveCharacterTextSplitter def split_law_text(text): # 先按“第XX条”定位切分点,保留法条编号作为块前缀 pattern = re.compile(r'(第[一二三四五六七八九十百千0-9]+条)') parts = pattern.split(text) chunks = [] current = "" for part in parts: if pattern.match(part): if current: chunks.append(current.strip()) current = part # 以条号作为新块起点 else: current += part if current: chunks.append(current.strip()) return chunks # 兜底分割:单条仍超长时按1000字符切,重叠150字符 splitter = RecursiveCharacterTextSplitter( chunk_size=1000, chunk_overlap=150, separators=["\n\n", "第", "(", ";", "。", " "], keep_separator=True, ) law_text = "第四十七条 经济补偿按劳动者在本单位工作的年限,每满一年支付一个月工资的标准向劳动者支付。..." for chunk in split_law_text(law_text): if len(chunk) > 1000: for sub_chunk in splitter.split_text(chunk): print(sub_chunk) else: print(chunk)

代码逻辑分两步:第一遍按“第X条”做粗切,保证每个块以完整条号开头;第二遍是兜底,单条过长时才启用递归分割。这里两个参数值得注意:chunk_overlap=150保留前后文的衔接,避免条内句子在语义上断档;separators里“第”字作为分隔符,是因为有些条款没有空格分隔符,靠“第”字能兜底切开。

2.3 Embedding模型选型与入库参数

法律文本的Embedding选型,我踩过一次明显的坑:用通用中文Embedding模型(如bge-large-zh)跑法条检索,查“试用期工资标准”能召回,查“病假工资怎么算”就飘了。原因是法律术语和日常表达之间的语义鸿沟,“病假工资”对应条文的“医疗期工资”,这层映射关系通用模型学得不够。

我后来换用法律领域微调的Embedding模型(比如开源的law-bert系列或者继续预训练过的text2vec变体),准确率有明显提升。如果你暂时找不到合适的领域模型,退一步的办法是:在索引库里把法律术语的近义词做同义扩展,检索时把“病假”同时映射成“医疗期”“患病”,靠查询改写来弥补。

入库这一步用ChromaDB做向量存储比较顺手。批次写入、内嵌元数据、按库名隔离这三件事必须在建库时就规划好,数据一多再补元数据字段代价极高。参考写法:

import chromadb from chromadb.utils import embedding_functions client = chromadb.PersistentClient(path="./law_kb") law_collection = client.get_or_create_collection( name="statute_law", embedding_function=embedding_functions.SentenceTransformerEmbeddingFunction( model_name="law-embedding-model" # 按你实际选用的领域模型调整 ), metadata={"hnsw:space": "cosine", "hnsw:M": 32, "hnsw:efConstruction": 200}, ) # 逐条入库,每个chunk带完整元数据 for idx, chunk in enumerate(chunks): law_collection.add( ids=[f"law_{idx}"], documents=[chunk], metadatas=[{ "source": law_title, "article_no": article_no, "category": "statute", }], )

参数说明:hnsw:space: cosine决定相似度度量方式,法律文本场景用余弦相似度比内积稳定,不受文本长度影响;M: 32控制图的最大连接数,调大提高召回率但索引体积膨胀;efConstruction: 200是建索引时的搜索广度,越大索引质量越高。三个参数都在工程上影响检索质量,不是随便填的。

3. 检索链路设计:混合检索让“召回率”和“精确率”同时站得住

3.1 纯向量检索的盲区在哪里

向量检索典型的问题是“语义相似≠法律适用”。用户问“公司拖欠工资三个月怎么办”,向量召回的可能是一堆关于“劳动报酬争议”的论文和评析,而不是《劳动法》第五十条“工资应当以货币形式按月支付”。法律咨询里,正确答案往往是具体条文编号和具体判例,这类信息恰恰是关键词精确匹配最擅长的事情。

纯向量检索还有一个盲区:条款编号本身就携带语义。用户输入“劳动合同法第47条”,除非Embedding模型在训练时见过大量“第四十七条”和文本的关联,否则向量化后这个数字符号几乎不贡献语义。不做关键词兜底,这类查询一定失败。

3.2 BM25与向量检索的RRF合并

我常用的是BM25+向量的双路召回,再用RRF(Reciprocal Rank Fusion)合并结果。BM25负责条款编号、法条名称、专有名词这类精确表达,向量负责语义扩展。RRF合并公式简单有效:对每个文档,把两路结果中的排名取倒数求和,排名越靠前权重越大,两路都召回的文档会被推到最前面。

实现代码:

from rank_bm25 import BM25Okapi import numpy as np # 假设已有向量检索结果 vec_results = [(doc_id, score), ...] def rrf_fusion(vec_results, bm25_results, k=60): fused_scores = {} for doc_id, _ in vec_results: fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1 / (k + vec_results.index((doc_id, _)) + 1) for doc_id in bm25_results: rank = bm25_results.index(doc_id) fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(fused_scores.items(), key=lambda x: x[1], reverse=True) # BM25索引构建,需要预先对法律文本分好词 tokenized_corpus = [chunk.split() for chunk in law_chunks] bm25 = BM25Okapi(tokenized_corpus) query = "劳动合同法 第四十七条 经济补偿" bm25_result = bm25.get_top_n(query.split(), law_chunks, n=20) vec_result = law_collection.query(query_texts=[query], n_results=20) final = rrf_fusion(vec_result["ids"][0], bm25_result, k=60)

RRF的k值默认取60比较稳妥,它决定两路排名的融合平滑度。k越小,高排名文档优势越大;k越大,两路结果的分布越平均。法律场景建议k保持60不变,你要调的其实是两路各取多少条——我一般向量取20条、BM25取10条,保证精确匹配不会被语义结果淹没。

3.3 重排序才能保证top结果可用

混合检索召回30条后,直接全塞给大模型是不现实的:上下文窗口有限,噪音也会干扰生成质量。必须做重排序,把最相关的5条顶到最前面。重排序模型这里强烈推荐cross-encoder结构,它对(query, document)拼接后直接打分,比双塔结构更准,代价是计算量高,但30条文档的排序量完全可接受。

from sentence_transformers import CrossEncoder cross_encoder = CrossEncoder("law-cross-encoder-model") # 按实际可用模型替换 pairs = [(query, doc) for doc in top30_docs] scores = cross_encoder.predict(pairs) top5_indices = np.argsort(scores)[-5:][::-1]

这里一个血泪经验:cross-encoder的打分分布可能整体偏高或偏低,不要拿原始分数做阈值判断“这条是否够格进答案”,而要看相对排序。绝对分数受模型训练分布影响极大,只有相对排名是稳定的。

4. 生成链路与提示词:让大模型按法律人的方式说话

4.1 提示词:先判断有没有依据,再决定怎么回答

RAG的生成环节,最常见的翻车是模型在检索结果不充分时“硬答”。法律场景里“没有依据”也是一种答案——告诉用户“您的问题超出当前知识范围,建议咨询专业律师”比乱引法条强一百倍。所以我设计的提示词里第一步不是“回答”,而是“判断是否有足够依据”。

具体逻辑我放在提示词里,让模型先列出检索材料中直接相关的条文编号和关键内容,再判定相关性等级。只有存在至少一条直接相关的条文时,才进入生成环节;否则走拒答分支。这一步能挡掉大量幻觉输出,比在模型层后加过滤器便宜得多。

4.2 带引用溯源的结构化输出

法律问答的输出不是让模型自由发挥写小作文。生产环境里,答案后面必须挂“依据”字段,且每条依据要能回溯到知识库里的原文。做法是把输出格式强行指定为JSON结构,用字段约束来倒逼模型只能基于检索材料作答。

from openai import OpenAI client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") # 本地推理服务或按需替换 prompt_template = """你是法律咨询助手。以下是从法律知识库检索到的材料: <documents> {documents} </documents> 用户问题:{question} 回答要求: 1. 先判断<documents>中是否有直接回答该问题的法律依据。 2. 若有,基于材料作答,并在"basis"字段列出使用的条文编号。 3. 若无直接依据,在"answer"字段明确写"当前检索材料未能提供直接法律依据",并给出一般性维权建议。 4. 不得引用检索材料以外的任何法条。 输出严格使用以下JSON格式: {{"answer": "回答内容", "basis": ["《中华人民共和国民法典》第一千零七十六条", "..."], "confidence": "high|medium|low"}}""" resp = client.chat.completions.create( model="qwen2.5-14b-instruct", messages=[{"role": "user", "content": prompt_template.format( documents="\n".join(top5_docs), question=user_question )}], temperature=0.2, max_tokens=800, response_format={"type": "json_object"} )

温度参数temperature=0.2是法律问答的关键设定。太多人习惯用默认值0.7甚至更高,生成法律答案时文采有余、严谨不足。0.2以下能显著减少模型自由发挥的空间。response_format强制JSON输出,方便后端直接解析展示。

4.3 置信度字段的价值

输出JSON里的confidence字段不是摆设。它有两个用途:前端拿到low时可以额外渲染“以上答案仅供参考,建议咨询执业律师”的提示条;后端可以做日志统计,追踪系统回答质量。我建议在代码里写一个规则:confidence=low时自动追加免责声明,让交互层多一道缓冲。

5. 避坑专题:法律RAG里的六个常见翻车点

5.1 切分把一条完整法条劈成两半,检索永远缺上下文

现象:查“经济补偿金计算标准”,答案引用不完整,把“六个月以上不满一年的按一年计算”这条关键句漏掉了。 原因:固定长度切分,一条法条被拦腰截断成两个文本块,向量召回时只命中了前半段,后半段的计算标准没进上下文。 解决:改用条号优先的切分策略,定位“第X条”作为切分边界,禁止在条中间硬切。条超长时按“款”继续切,末尾保留“本条所称…是指…”的完整句。

5.2 数据清洗不彻底,当事人信息污染检索结果

现象:用户问“离婚财产怎么分割”,召回的第一条结果是一份家暴判决书里的“经审理查明”段落。 原因:裁判文书整体入库,没有剥离当事人信息和案件细节,这些冗长叙事在向量空间里构成了大量噪音。 解决:入库前抽取“本院认为”段落和判决主文部分,用正则或结构化解析器先预处理。我一般在解析PDF之后再加一步规则过滤,只保留“本院认为”到“判决如下”之间的文本。

5.3 提示词里写了“不知道就拒绝”,模型还是硬编

现象:知识库完全没有相关内容的问题,模型答得像模像样,还编了个不存在的“司法解释”。 原因:上下文里塞了30条检索结果,其中排名靠后的结果与问题有微弱关联,模型抓住这层弱关联强行推理,把“可以参考”放大成了“依据”。 解决:两招一起用。一是把检索结果从30条缩减到5条,减少次相关信息对模型的诱导;二是在提示词里增加硬规则——“没有直接包含完整法律要件的材料时,禁止作答”。前者管住输入,后者管住生成。

5.4 索引建好之后没做去重,同一条法条的各种版本都进库

现象:知识库里同时存在《劳动合同法》2012年修正版、2008年原文版,以及某个论坛转载的“精华版”,检索时乱序召回,答案引用了旧版条款。 原因:数据来源没有做版本统一,也没做文本去重。法律条文更新会带来条款序号变化,比如“本法自2008年1月1日起施行”这类时效性表述。 解决:入库前对每一条法条做“版本+条号+内容哈希”三字段校验,同一版本的同一法条只保留最新内容。对判例类文本,按案号去重,同一案号只保留最全版本。

5.5 把embedding模型当成万能,从不做同义扩展

现象:用户说“公司辞退我”能命中,说“用人单位单方解除劳动合同”就漏掉关键条文,原因是法律术语和口语表达之间的语义鸿沟。 原因:Embedding模型训练语料里法律文本占比低,对法言法语和日常表达的映射掌握不牢。 解决:在检索前加一层查询改写。把用户口语关键词映射到法律术语表——“辞退”映射“解除劳动合同”,“工资”映射“劳动报酬”,“赔偿”映射“经济补偿金”。这个映射表要跟随用户的真实查询持续补充,上线前至少要覆盖几十个高频咨询词。

5.6 重排序之后不做答案完整性校验

现象:答案引用了“《民法典》第一千零七十六条”,但知识库里这个条文实际只有半条(因为切分时截断了),而模型在回答里描绘了后半条里才有的“离婚协议应当载明双方自愿离婚的意思表示”。 原因:检索到的chunk内容不完整,但生成模型依据“法条编号”脑补了完整内容。法律AI系统最大的隐患就在这儿——模型见过太多法条,看一眼前半段就能默写出后半段,可你没法保证它的默写永远正确。 解决:入库时对每条文块生成“内容指纹”,检索返回后校验指纹完整性;如果块被截断,在元数据里标记“incomplete=true”,生成时排除该块或提示模型谨慎引用。

6. 用离线测试集给系统做体检:RAG效果评估的落地做法

做RAG系统最容易忽视的一环是“我怎么知道改完检索参数之后,系统是真的变好了还是心理作用”。我搭了一套离线评估流程,每次改完索引、切分参数、提示词就强制跑一遍。做法是:收集真实咨询记录里高频的100个问题,每个问题配上标准答案和期望引用的条文编号,组成测试集。

评估指标看三件事:检索命中率(top5结果里是否包含期望引用的条文)、答案相关性(生成内容与标准答案语义相似度)、引用准确率(模型引用的条文是否真实存在于知识库且内容一致)。这三项可以做成脚本自动跑,引用准确率用字符串匹配就能完成大部分判断,答案相关性用LLM打分器辅助,不搞复杂的人工评审流程。

def evaluate(test_questions): hit, relevant, correct_ref = 0, 0, 0 for q in test_questions: retrieved = hybrid_search(q, top_k=5) retrieved_sources = [r.metadata["article_no"] for r in retrieved] if q.expected_article in retrieved_sources: hit += 1 answer = generate_answer(q, retrieved) if q.expected_article in answer["basis"]: correct_ref += 1 relevance_score = llm_judge(q.question, answer["answer"]) if relevance_score > 0.8: relevant += 1 return { "recall@5": hit / len(test_questions), "citation_accuracy": correct_ref / len(test_questions), "answer_relevance": relevant / len(test_questions), }

这套评估脚本每次跑完出一份报告,我把它当成系统的体检单。从那以后我每次调完Embedding模型、改完切分窗口、换过提示词模板,都强制跑一遍离线测试集——哪怕只是把温度从0.2调到0.1这种小改动,也要确认三个指标没有回退才敢进下一轮迭代。法律问答系统一旦上线,用户引用错误法条去维权,后果不是掉个用户那么简单。扎实的RAG架构决定系统“能答”,严格的评估流程决定系统“敢答”。希望这套实践思路能帮你在做自己的法律问答系统时,少走几趟我走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询