简介:面向智能司法的多源信息搜索系统项目代码,是一份基于Java开发的毕业设计/课程设计资源,面向计算机相关专业学生,聚焦司法信息检索场景,可帮助掌握多源数据整合、全文检索(Elasticsearch)、自然语言处理、信息检索算法等关键技术,并学习JavaFX/Swing界面构建、数据库管理、安全权限控制及性能优化等工程化实践。资源包共56个文件,以25个Java源文件为核心,辅以7个XML配置、5个JavaScript、4个JSP页面、CSS与字体等静态资源,覆盖后端逻辑、前端展示与项目配置,整体仅274KB,目录结构分明,便于按模块阅读与二次开发。目前已有99人学习使用,适合需要毕业设计参考、课程设计提升或司法信息化方向入门的学习者。
1. 多源信息搜索系统:司法检索到底难在哪
多源信息搜索系统在司法场景里,最常见的痛点是数据不在一个地方:裁判文书在文书网,法条在法规库,案例摘要散在各家平台,新闻又是另一套格式。这套 esJudicatureSearch 毕业设计项目,就是用 Java 把这些来源统一收敛到 Elasticsearch 里,再做关键词检索和语义扩展。它适合两类人:一类是准备拿司法搜索当毕设或课设题目、想找一个完整工程做底子的学生;另一类是刚接手一个 Spring Boot + ES 后端的初级工程师,想看看正规一点的多源检索系统目录该怎么组织。源码包里带 data 词典、keywords 关键词表和完整 Maven 工程,能直接跑起来改。
2. 工程骨架与数据层:从 pom.xml 到多源文书归一化
2.1 先读懂 esJudicatureSearch 的目录结构
拿到 zip 解压后,第一眼看到的是 esJudicatureSearch-master 根目录下那串文件:pom.xml、.gitattributes、.idea、src、test、data。很多第一次做毕设的人会直接打开 src 找代码,但我的习惯是先读 pom.xml 和 data 目录,因为一个多源搜索系统的技术选型和数据来源都写在这两个地方。
pom.xml 决定整个项目的依赖边界。这是一个标准 Maven 工程,不是 Gradle,根目录没有 gradlew,所以后续构建都以 mvn 为主。src/test 与 src/main 分开,数据文件放在 data 下,baidu_dictionary 和 keywords 明显是给分词和查询扩展用的词表,说明作者把「词典」当成资源文件独立管理,而不是硬编码在 Java 代码里。
<!-- pom.xml 关键依赖(版本号给的是 7.x 常用组合,以你本地仓库实际版本为准) --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.7.18</version> </dependency> <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <version>7.17.9</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.hankcs</groupId> <artifactId>hanlp</artifactId> <version>portable-1.8.4</version> </dependency> </dependencies>Spring Boot Web 负责提供 REST 检索接口;elasticsearch-rest-high-level-client 是 ES 7.x 时代的官方 Java 客户端,8.x 之后官方主推 elasticsearch-java 新客户端,所以如果你手头工程里客户端版本和 ES 集群大版本不一致,握手阶段就会直接报错。MySQL 驱动用来存原始文书和检索日志,HanLP 负责分词、实体识别和关键词扩展。
版本兼容是这里最常见的坑:ES 客户端版本必须与服务器大版本一致,7.x 客户端连 8.x 集群会报 version mismatch。另一个容易被忽略的点是,.idea 目录里 workspace.xml、misc.xml 记录的是 IntelliJ 的窗口布局和 JDK 级别,这些文件不要提交到 Git,否则每次 clone 后打开都会有奇怪的配置冲突;.gitattributes 则是给 Git 配换行符用的,保证 Windows 和 Linux 之间 checkout 代码不乱码。
2.2 多源数据标准化:先把四类文书拧成一种结构
司法检索的多源主要体现在这里:法院判决书、法规法条、案例库摘要、相关新闻报道,它们的字段命名完全不同。判决书有「案号」「审判法院」「判决日期」,法条有「效力级别」「发布机关」,新闻只有标题、正文和发布时间。
如果不做归一化,ES 里就得建四个索引,查询时挨个搜再合并,排序权重很难统一。这个项目的做法是定义一个 StandardDoc 模型,把不同来源映射成 title、content、source、court、caseNo、publishDate 等通用字段。这样后续索引、查询、排序都只面向一份标准结构,新增数据源时只需扩展一个解析器。
public class DocumentNormalizer { public StandardDoc normalize(Map<String, String> raw) { StandardDoc doc = new StandardDoc(); // 用来源 + 原文 URL 生成稳定 docId,避免同一篇文书重复入库 doc.setDocId(hashWithSource(raw.get("source"), raw.get("url"))); doc.setSource(raw.getOrDefault("source", "unknown")); doc.setTitle(cleanText(raw.get("title"))); doc.setContent(cleanText(raw.get("content"))); // 判决书才有案号,法条和新闻没有,用空串兜底 doc.setCaseNo(raw.getOrDefault("caseNo", "")); doc.setCourt(raw.getOrDefault("court", "")); doc.setPublishDate(parseDate(raw.get("publishDate"))); return doc; } }setDocId 用 hashWithSource 把数据源和 URL 映射成稳定 ID,重复抓取同一地址不会新增文档,ES 会按 docId 做 upsert。cleanText 负责去掉 HTML 标签、全角空格和重复换行,不加这一步,分词质量会差很多,比如「北京 市」中间的全角空格会被误判成两个词。
下面是四类数据源归一化后的字段对照,也是设计 mapping 前的依据:
| 字段 | 判决书来源 | 法条来源 | 新闻来源 | ES 字段类型 |
|---|---|---|---|---|
| title | 文书标题 | 法规名称 | 新闻标题 | text + ik_max_word |
| content | 裁判理由 | 条文正文 | 新闻正文 | text + ik_max_word |
| source | court_judgment | law_regulation | news | keyword |
| court | 法院名 | 发布机关 | 空串 | keyword |
| caseNo | (2023)京01民终123号 | 空串 | 空串 | keyword |
| publishDate | 判决日期 | 发布日期 | 报道日期 | date |
source 字段用 keyword 而不是 text,是因为它只做筛选不做全文匹配;publishDate 用 date,后续按时间倒序排序才能正常工作。如果你在课程设计里把日期设成 text,后面 sort 的时候会直接报错——这是很多初学者会踩的坑。
2.3 词典文件怎么加载:data 目录的正确用法
data/baidu_dictionary 和 data/keywords 是本项目里容易被忽略但很关键的部分。baidu_dictionary 一般是从百度词典抓下来的基础词表,keywords 则是司法领域自定义关键词,比如「民间借贷」「执行异议」「再审申请」这类分词器默认词库里没有的词。
常见做法是在系统启动时把这两个文件读进一个 Set,然后注册到分词器扩展词典里,让 IK 或 HanLP 在切词时优先识别这些司法名词。下面是用 HanLP 运行时添加自定义词的示例:
public class DictionaryLoader { private static final Set<String> LEGAL_TERMS = new HashSet<>(); public static void load(String basePath) throws IOException { // baidu_dictionary 每行一个词,注释以 # 开头 Files.lines(Paths.get(basePath, "data", "baidu_dictionary")) .map(String::trim) .filter(line -> !line.isEmpty() && !line.startsWith("#")) .forEach(LEGAL_TERMS::add); // keywords 每行格式:标准词\t同义词1\t同义词2 Files.lines(Paths.get(basePath, "data", "keywords")) .map(String::trim) .filter(line -> line.contains("\t")) .map(line -> line.split("\t")[0]) .forEach(LEGAL_TERMS::add); } public static void registerToHanLP() { // 将所有司法术语注入 HanLP 自定义词典 for (String term : LEGAL_TERMS) { CustomDictionary.add(term); } } }注意 load 方法里两个文件的解析规则不同。baidu_dictionary 是纯词表,直接整行读入;keywords 是带同义词的 TSV,取第一列作为标准词,其余列留给查询扩展用。registerToHanLP 在应用启动时把所有词注入 CustomDictionary,确保用户搜「执行异议之诉」时不会被切成「执行/异议/之/诉」。
这里有个运行时路径问题:直接写相对路径 data/... 在 IDEA 里跑没问题,因为工作目录是项目根目录;但打成 jar 之后这个路径就不存在了。我的建议是把词典放到 src/main/resources 下,用 classpath 读取,这样发布时不需要额外指定外部路径。
3. Elasticsearch 检索层:索引映射与 BM25 排序调优
3.1 为什么是 Elasticsearch 而不是 MySQL LIKE
司法文书检索和普通业务搜索不一样,用户输入一句话,期望返回整篇相关判决,而不是精确匹配某一行。MySQL 的 LIKE '%关键词%' 有三个问题:无法处理分词、无法按相关性排序、数据量大时全表扫描性能很差。
ES 的核心是倒排索引,写入时把文本切成词元,查询时直接命中词元对应的倒排链表。所以这个项目把 ES 作为检索主库,MySQL 只当原始数据仓库,两边的职责分得很清楚。
| 维度 | MySQL LIKE | Elasticsearch |
|---|---|---|
| 分词 | 不支持,只能整串匹配 | ik_max_word / ik_smart |
| 相关性 | 无,只能自行排序 | BM25 评分 |
| 百万级数据 | 全表扫描,响应不可控 | 分片并行,亚秒级 |
| 同义词扩展 | 需要自己改 SQL | 查询 DSL + 词典 |
| 运维成本 | 低 | 需要独立集群 |
如果你只是几百条测试数据,MySQL LIKE 够用;但司法文书动辄几十万篇,还要做同义词、加权、分页,ES 几乎是绕不开的选择。这个项目导入 data 目录里的文书数据后,检索响应基本都能稳定在几百毫秒内。
3.2 索引映射:把司法字段与 BM25 参数一次配好
创建索引之前先设计 mapping。我见过很多课程设计直接在代码里拼一段 JSON,文本字段全用 keyword,导致搜「民间借贷」必须一字不差;日期字段用 text,排序直接失效。一个可用的司法文书索引映射,应该在创建时就同时解决分词、类型和相关性参数三个问题。
PUT /judicature_doc { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "analysis": { "analyzer": { "legal_analyzer": { "type": "custom", "tokenizer": "ik_max_word", "filter": ["lowercase"] } } }, "similarity": { "legal_bm25": { "type": "BM25", "k1": 1.2, "b": 0.3 } } }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "legal_analyzer", "similarity": "legal_bm25" }, "content": { "type": "text", "analyzer": "legal_analyzer", "similarity": "legal_bm25" }, "court": { "type": "keyword" }, "caseNo": { "type": "keyword" }, "source": { "type": "keyword" }, "publishDate": { "type": "date", "format": "yyyy-MM-dd" }, "importance": { "type": "integer" } } } }number_of_shards 是分片数,3 个分片适合课程设计的数据体量,单机环境不要太小;replicas 副本数设为 1,但如果你只在本地单节点演示,建议改为 0,否则集群状态会一直是 yellow。title 和 content 用 legal_analyzer 做最大粒度分词,召回率高;court、caseNo、source 只做精确过滤,不用分词。publishDate 用 date 类型并按 yyyy-MM-dd 格式化,这是按时间排序的前提。importance 是自定义权重字段,指导性案例、公报案例可以给高分值,后面查询加权会用到。
这里我加了自定义 similarity 配置 legal_bm25,把 b 从默认的 0.75 调到 0.3。BM25 的 b 参数控制文档长度对评分的影响,b 越大,长文档被惩罚越狠。司法文书正文动辄几千字,默认参数会让长文书普遍被压到后面,调低之后长正文和短标题的评分更公平。k1 控制词频饱和度,一般不动,1.2 是 BM25 的经典取值。
mapping 一旦创建,字段类型不能修改,只能新建索引再 reindex。所以前期字段设计和分词器选择一定要想清楚,上线后改 mapping 的成本很高。
3.3 查询 DSL 和 Java 客户端:用 should + boost 做业务排序
索引建好后,查询是核心部分。司法检索里有两个常见诉求:标题命中比正文命中更相关;带指导性案例标签的文书应该往前排。用 bool query 的 should 子句配合 boost 参数可以同时解决。
Java 侧如果用 RestHighLevelClient,查询代码大致是这样:
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); BoolQueryBuilder bool = QueryBuilders.boolQuery(); // 标题匹配权重 3.0,正文匹配权重 1.0 bool.should(QueryBuilders.matchQuery("title", keyword).boost(3.0f)); bool.should(QueryBuilders.matchQuery("content", keyword).boost(1.0f)); // importance 命中直接加 5 分,用于指导性案例置顶 bool.should(QueryBuilders.termQuery("importance", 10).boost(5.0f)); sourceBuilder.query(bool); sourceBuilder.from(0).size(20); sourceBuilder.sort(new FieldSortBuilder("publishDate").order(SortOrder.DESC)); SearchRequest request = new SearchRequest("judicature_doc"); request.source(sourceBuilder); SearchResponse response = client.search(request, RequestOptions.DEFAULT);should 子句之间是 OR 关系,ES 会给命中的子句分别算分再合并。boost 控制权重,标题权重要给到 3,因为标题高度概括案情,命中标题的文书通常比正文偶发提到关键词的文书更相关。importance 字段的 termQuery 是业务规则:只有被标记为指导性案例的文档才会在这个字段写入 10,命中后额外加 5 分,可以稳定地把这类文档顶到前排。
from 和 size 是分页参数,size 默认最大 10000,超过这个值要改 search_after 或 scroll。sort 按 publishDate 倒序是司法检索的刚需,注意 text 字段不能参与排序,所以前面 mapping 里 publishDate 才必须定义成 date。如果你在查询里同时用了 should、sort 和 boost,ES 默认按 _score 和后续 sort 字段混合排序,sort 优先级更高,这一点在调参时要记住。
4. 查询语义层:HanLP 关键词扩展与 REST 搜索接口
4.1 用 HanLP 做关键词抽取和实体识别
用户输入「许霆盗窃案再审有什么结果」,如果直接把整句丢给 ES,分词后每个词都参与匹配,噪音很大。常见做法是先做关键词抽取,只保留「盗窃案」「再审」「结果」这类核心词,再交给检索模块。
HanLP 在 Java 生态里是用的最多的开源 NLP 工具之一,它的标准和短语抽取接口可以直接复用。
// 从用户 query 中抽取最重要的 5 个词 List<String> keywords = HanLP.extractKeyword("许霆盗窃案再审有什么结果", 5); System.out.println(keywords); // 抽取关键短语,适合案件主题类查询 List<String> phrases = HanLP.extractPhrase("北京市高级人民法院关于民间借贷纠纷的判决", 3);extractKeyword 内部用 TextRank 算法计算词权重,返回 top N 词;extractPhrase 抽取关键短语,适合「民间借贷纠纷」这类名词性表达。但对很短的用户 query,比如「盗窃案再审」,HanLP 的结果不稳定,所以一般会叠加词典规则,强制把 data/keywords 里的标准词保留下来,两者取并集作为最终搜索词。
HanLP 默认词典不包含大量法律术语,所以刚才加载的自定义词典在查询阶段会发挥作用。代码层面可以这样验证:
// 启动时先执行 DictionaryLoader.registerToHanLP() CustomDictionary.add("执行异议之诉"); List<Term> terms = HanLP.segment("案外人执行异议之诉的审理范围"); for (Term term : terms) { // 打印格式:词语/词性,例如:执行异议之诉/nz System.out.println(term.word + "/" + term.nature); }不同 HanLP 版本的 API 有差异,portable-1.8.x 直接调 CustomDictionary.add 即可;如果是 2.x 版本,字典加载方式会不一样,以你引入的版本对应文档为准。校验标准很简单:看「执行异议之诉」是否被切成一个完整词元,如果被切开,说明自定义词典没有生效。
4.2 查询扩展:把「打官司」扩展成「诉讼」「起诉」
同一个意思,判决书写「诉讼」,用户搜「打官司」,如果只做字面匹配,这篇文书永远搜不到。data/keywords 文件的作用就在这里:每行放一个同义词组,运行时加载成 Map,查询前把用户输入做同义词替换和 OR 扩展。
public class QueryExpander { private final Map<String, List<String>> synonymMap = new HashMap<>(); public void load(Path keywordFile) throws IOException { for (String line : Files.readAllLines(keywordFile, StandardCharsets.UTF_8)) { String[] parts = line.trim().split("\\t"); if (parts.length < 2) { continue; } // 整行所有词互相映射,任何一个词都能找到整个同义词组 List<String> synonyms = Arrays.asList(parts); for (String part : parts) { synonymMap.putIfAbsent(part, synonyms); } } } public String expand(String query) { String[] words = query.split("\\s+"); List<String> expanded = new ArrayList<>(); for (String word : words) { List<String> list = synonymMap.get(word); if (list == null) { expanded.add(word); } else { expanded.add("(" + String.join(" OR ", list) + ")"); } } return String.join(" AND ", expanded); } }load 方法把同义词组建成双向索引,这样从「打官司」也能找到「诉讼」「起诉」。expand 方法把用户 query 转成布尔查询串,例如「诉讼 时效」会被扩展成「(诉讼 OR 打官司 OR 起诉) AND (时效 OR 期限)」。AN D 的作用是强制不同语义组都必须出现,避免召回范围失控;OR 则让同义词之间任意命中即可。
真正上线时不会直接拼查询字符串,而是用 BoolQueryBuilder 构造 should 和 must 嵌套。这里用字符串拼接是为了快速验证扩展逻辑,写单元测试时也更直观。keywords 文件的分隔符不固定,有的项目用逗号,有的用制表符,加载逻辑里的 split 正则要跟着实际文件调整。
4.3 搜索接口、权限与翻页设计
检索入口一般用 Spring Boot 写 REST 接口。考虑到司法信息的敏感性,接口不能裸奔,常见做法是网关层做 JWT 或 OAuth2 校验,服务内部再按角色过滤可见数据源。毕设项目里通常只做到 JWT 校验,这里给一个最小可用的接口实现。
@RestController @RequestMapping("/api/search") public class SearchController { private final SearchService searchService; public SearchController(SearchService searchService) { this.searchService = searchService; } @GetMapping("/docs") public SearchResponse search( @RequestParam String q, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestHeader(value = "Authorization", required = false) String token) { if (token == null || !JwtUtil.verify(token)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期"); } return searchService.search(QueryExpander.expand(q), page, size); } }q 是用户输入,page 和 size 控制分页,Authorization 头携带 JWT。verify 失败直接抛 401,不返回任何检索数据。SearchService 内部会执行两路检索:一路走 QueryExpander 扩展后的词,一路直接走 HanLP 抽取的关键词,最后按评分合并结果。
page 默认从 0 开始,对应 ES 的 from 偏移;size 限制 100,防止一次拉太多数据。如果以后要支持深翻页,不要继续用 from/size,要改 search_after,否则页码深了 ES 会报 result window 超限。JwtUtil 是简化版,实际项目建议接 spring-security,把认证逻辑从 Controller 里抽出去,不然每个接口都要重复校验一次。
5. 拿到 zip 之后:导入 IDEA、构建排错与 Local History 找回
从 GitHub 或毕设管理平台下载的 esJudicatureSearch-master.zip,第一件事不是双击解压,而是先校验包完整性。用 unzip -t 测试文件结构,能省掉后续一堆莫名其妙的编译错误。这是一个 Maven 工程,根目录没有 gradlew,所以构建用 mvn。
# 1. 校验 zip 结构,避免压缩包损坏 unzip -t esJudicatureSearch-master.zip # 2. 解压到工作目录 unzip esJudicatureSearch-master.zip -d ~/workspace # 3. 进入根目录,确认 pom.xml 存在 cd ~/workspace/esJudicatureSearch-master # 4. 跳过测试打包,首次会下载大量依赖 mvn -DskipTests clean package-t 是 test 模式,只检查压缩包 CRC 和目录结构,不实际释放文件;-d 指定解压目标目录。-DskipTests 跳过测试执行但保留测试类编译,如果是课程设计验收,建议先不要加这个参数,把 src/test 里的测试完整跑一遍,很多隐藏 bug 会在测试里暴露。
构建时最常见的报错是「error read zip archive」或 failed to read artifact descriptor。这个工程用 Maven,如果你在 IDEA 里用 Gradle 方式导入 pom.xml 工程,或者本地仓库里有损坏的 lastUpdated 文件,就会出现这个问题。处理办法是删除本地仓库中对应 jar 的目录,强制重新下载。
# 找到本地仓库中损坏的 jar 目录,删除后重试 rm -rf ~/.m2/repository/org/elasticsearch/client/elasticsearch-rest-high-level-client/7.17.9 mvn -U clean compile-U 强制刷新远程仓库元数据,能解决九成依赖不完整的问题。如果 IDEA 里仍然报错,执行 File -> Invalidate Caches -> Invalidate and Restart,清一次本地索引再导入。
接下来说 git pull 丢失本地代码的场景。这类项目通常配合 Git 管理,常见操作是本地改了一版 SearchController,执行 git pull 拉远端代码,IDEA 提示冲突或直接覆盖,本地修改看起来「丢失」了。其实 IDEA 的 Local History 默认是开启的,不依赖 Git commit 也能找回:右键丢失文件所在目录,选择 Local History -> Show History,在左侧时间线里找到修改前的版本,右键 Revert 即可。这个功能比 git reflog 更细粒度,它记录的是 IDEA 本地编辑快照,哪怕你从来没有 commit 过也能恢复。
JDK 版本也是一个高频坑。Spring Boot 2.x + ES 7.x 的组合,本地 JDK 建议 8 或 11。如果要用 JDK17,注意 reflection 相关报错和 ES 客户端的模块访问限制。去镜像站下载 temurin jdk8 的 windows x64 zip 包时,解压后要在 IDEA 的 Project Structure 里把 SDK 指到解压目录的父级路径,不要只配置系统 PATH,否则 IDE 内运行用的还是旧版本。演示前用 spring-boot-maven-plugin 打一个可执行 jar,java -jar 一行启动,比在 IDEA 里点绿色运行按钮稳定得多。
本文还有配套的精品资源,点击获取