资讯动态

法律RAG问答系统实践:知识库切分、混合检索与生成评估

发布时间:2026/10/9 13:31:35 来源:尧图企业网站定制
简介一套基于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_size1000, chunk_overlap150, separators[\n\n, 第, , , 。, ], keep_separatorTrue, ) 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_overlap150保留前后文的衔接避免条内句子在语义上断档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( namestatute_law, embedding_functionembedding_functions.SentenceTransformerEmbeddingFunction( model_namelaw-embedding-model # 按你实际选用的领域模型调整 ), metadata{hnsw:space: cosine, hnsw:M: 32, hnsw:efConstruction: 200}, ) # 逐条入库每个chunk带完整元数据 for idx, chunk in enumerate(chunks): law_collection.add( ids[flaw_{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向量的双路召回再用RRFReciprocal 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, k60): 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(), keylambda x: x[1], reverseTrue) # 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, n20) vec_result law_collection.query(query_texts[query], n_results20) final rrf_fusion(vec_result[ids][0], bm25_result, k60)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_urlhttp://localhost:11434/v1, api_keyollama) # 本地推理服务或按需替换 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( modelqwen2.5-14b-instruct, messages[{role: user, content: prompt_template.format( documents\n.join(top5_docs), questionuser_question )}], temperature0.2, max_tokens800, response_format{type: json_object} )温度参数temperature0.2是法律问答的关键设定。太多人习惯用默认值0.7甚至更高生成法律答案时文采有余、严谨不足。0.2以下能显著减少模型自由发挥的空间。response_format强制JSON输出方便后端直接解析展示。4.3 置信度字段的价值输出JSON里的confidence字段不是摆设。它有两个用途前端拿到low时可以额外渲染“以上答案仅供参考建议咨询执业律师”的提示条后端可以做日志统计追踪系统回答质量。我建议在代码里写一个规则confidencelow时自动追加免责声明让交互层多一道缓冲。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系统最大的隐患就在这儿——模型见过太多法条看一眼前半段就能默写出后半段可你没法保证它的默写永远正确。 解决入库时对每条文块生成“内容指纹”检索返回后校验指纹完整性如果块被截断在元数据里标记“incompletetrue”生成时排除该块或提示模型谨慎引用。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_k5) 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 { recall5: hit / len(test_questions), citation_accuracy: correct_ref / len(test_questions), answer_relevance: relevant / len(test_questions), }这套评估脚本每次跑完出一份报告我把它当成系统的体检单。从那以后我每次调完Embedding模型、改完切分窗口、换过提示词模板都强制跑一遍离线测试集——哪怕只是把温度从0.2调到0.1这种小改动也要确认三个指标没有回退才敢进下一轮迭代。法律问答系统一旦上线用户引用错误法条去维权后果不是掉个用户那么简单。扎实的RAG架构决定系统“能答”严格的评估流程决定系统“敢答”。希望这套实践思路能帮你在做自己的法律问答系统时少走几趟我走过的弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑