
简介面向毕业设计、课程设计场景的智能问答系统完整项目基于SpringBoot、Neo4j与Spark构建融合朴素贝叶斯分类器与茶虫知识图谱能够支撑知识图谱问答、分类预测等教学演示与二次开发。压缩包共304个文件以Java源码、XML配置、CSV数据文件、TXT说明文档为主另含图片与日志等辅助材料整体仅4.44MB便于快速下载与部署。项目已获导师认可答辩评分95分代码经过运行验证适合计算机相关专业学生用于毕设、课设或项目启动演示。包内提供全部数据资料与详细文档覆盖知识图谱构建、分类器实现及问答交互流程可帮助学习者理解系统架构并在此基础上扩展功能。目前已有130人学习下载适合希望快速上手SpringBootNeo4jSpark整合开发的中级学习者。1. 茶虫知识图谱智能问答系统为什么朴素贝叶斯分类器跑在 Spark 上知识要存进 Neo4j茶园技术员问「小绿叶蝉爆发期用哪种药控制最快」系统要拆成三件事这句话属于哪类意图、问的是哪个实体、实体之间在图谱里怎么关联。只写关键词匹配的问答换个说法就失效知识一多 if-else 也维护不动。把 SpringBoot、Neo4j、Spark 组合起来是垂直领域问答最稳妥的落地形态Spark 离线训练朴素贝叶斯分类器把问句映射到意图模板Neo4j 以图结构存茶虫、病害、农药、防治方法之间的语义关系SpringBoot 做在线服务层把意图识别、实体抽取和 Cypher 查询串成一条完整链路。这套架构的核心是把「理解问题」和「检索知识」分开。朴素贝叶斯分类器不负责生成答案它只输出「这是防治类问题还是用药类问题」真正回答靠 Neo4j 沿关系边查到的子图。分类错误不会直接污染答案训练语料几百条就能起步图谱更新只需要动数据和 Cypher 模板不用重训模型。适合正在做垂直问答、知识图谱应用或者想把 SpringBoot 项目接上机器学习和图数据库的工程师。后面按图谱建模、Spark 分类器训练、SpringBoot 整合、上线调参四步展开代码可以直接抄回去改。2. 茶虫知识图谱在 Neo4j 中的建模与 Cypher 查询设计2.1 节点与关系设计把茶虫防治知识拆成三元组图谱第一步是定 schema。茶虫问答里最常见的实体是害虫、病害、农药、防治方法、症状、茶树品种六类Neo4j 里每类对应一个 Label属性按问答需要来定宁少勿多。节点标签代表实体关键属性TeaPest小绿叶蝉、茶尺蠖、茶毛虫name、aliases、hazardLevel、occurPeriodTeaDisease茶饼病、茶炭疽病name、pathogen、triggerConditionDrug吡虫啉、联苯菊酯name、ingredient、dosage、safeIntervalControlMethod灯光诱杀、修剪除虫name、season、descriptionSymptom叶片卷曲、芽梢枯焦name、damagePart、descriptionTeaVariety龙井43、福鼎大白name、resistance关系设计直接决定 Cypher 好不好写。一般保留五条核心关系提问时沿着关系边最多跳两步就能拿到答案。关系类型起点→终点语义SYMPTOM_OFSymptom→TeaPest/TeaDisease某症状属于某病虫害TREATED_BYTeaPest/TeaDisease→Drug用某药防治PREVENTED_BYTeaPest/TeaDisease→ControlMethod用什么方法预防AFFECTSTeaPest→TeaVariety害虫危害某品种建好 Label 后马上建唯一约束和索引数据量小的时候看不出差别等 CSV 导入几万行时没有索引的 MATCH 会全表扫描导入慢查询也慢CREATE CONSTRAINT pest_name IF NOT EXISTS FOR (p:TeaPest) REQUIRE p.name IS UNIQUE; CREATE CONSTRAINT drug_name IF NOT EXISTS FOR (d:Drug) REQUIRE d.name IS UNIQUE; CREATE INDEX symptom_name_idx IF NOT EXISTS FOR (s:Symptom) ON (s.name);name 上建唯一约束有双重作用防止重复导入同时自动带索引供等值查询使用。注意 Neo4j 5.x 的语法是 REQUIRE p.name IS UNIQUE老版本是 ON (p:TeaPest) ASSERT p.name IS UNIQUE升级后语法会变照当前版本写。2.2 问答系统最常用的三类 Cypher 查询模板问题五花八门落到图上就三类模式单实体取属性、沿关系找邻居、症状反查源头。把这三类模板化SpringBoot 端只需要拼参数不需要拼 Cypher 语句。第一类是实体档案查询回答「小绿叶蝉是什么」MATCH (n {name: $entityName}) WHERE n:TeaPest OR n:TeaDisease RETURN labels(n) AS type, n.name AS name, n.description AS description LIMIT 1$entityName 是参数占位符运行时由驱动传入参数不要用字符串拼接。原因有二拼接容易注入且 Neo4j 无法对拼接出来的查询做缓存每次都要重新解析。第二类是沿关系找邻居回答「茶尺蠖用什么药」MATCH (p:TeaPest {name: $entityName})-[:TREATED_BY]-(d:Drug) RETURN d.name AS drug, d.ingredient AS ingredient, d.dosage AS dosage, d.safeInterval AS safeInterval ORDER BY d.safeIntervalORDER BY safeInterval 把安全间隔期短的药排前面这是农药问答里很实用的排序逻辑。用户问防治方法时把关系换成 PREVENTED_BY模板结构完全一样。第三类是反查回答「叶片背面出现白色粉状物是得了什么病」MATCH (s:Symptom {name: $symptomName})-[:SYMPTOM_OF]-(d:TeaDisease) RETURN d.name AS disease, d.pathogen AS pathogen, d.triggerCondition AS triggerCondition所有模板都带 LIMIT 或明确返回条数避免同名节点多时返回整张子图。问答接口只取前几条拼成一句话答案不要把整行节点序列化丢给前端。2.3 Neo4j 导入 CSV 与全文索引的配置数据来源通常是 Excel 整理的三元组表格社区版最方便的是 LOAD CSV。把文件放到安装目录的 import 目录下再执行LOAD CSV WITH HEADERS FROM file:///pests.csv AS row MERGE (p:TeaPest {name: row.name}) SET p.aliases row.aliases, p.hazardLevel row.hazardLevel, p.occurPeriod row.occurPeriod;用 MERGE 而不是 CREATE靠唯一约束保证同一实体只建一次导入脚本重复跑不会产生脏数据。节点和关系分开导入先导节点再导关系LOAD CSV WITH HEADERS FROM file:///pest_drug.csv AS row MATCH (p:TeaPest {name: row.pestName}) MATCH (d:Drug {name: row.drugName}) MERGE (p)-[:TREATED_BY]-(d);这里两个 MATCH 都会走到 2.1 建的索引上否则几万行关系要跑很久。导入前确认 CSV 是 UTF-8 编码Windows 下 Excel 另存为 CSV 容易导出 GBKLOAD CSV 会读出乱码实体名这是「图谱有数据但问答查不到」最常见的原因。用户提问经常写别名比如「小绿虫子」指的就是小绿叶蝉。别名可以在应用层做词典匹配也可以在 Neo4j 里建全文索引CREATE FULLTEXT INDEX pestFulltext IF NOT EXISTS FOR (p:TeaPest) ON EACH [p.name, p.aliases];查询时用 CALL db.index.fulltext.queryNodes(pestFulltext, $entityName) 拿到带评分的结果取第一条作为候选实体。全文索引对中文依赖分词器默认分词器对短词支持一般实践上把别名完整写进 aliases 属性比依赖中文分词更可控。3. 基于 Spark 的朴素贝叶斯分类器意图识别模型的训练与落盘3.1 为什么意图识别选朴素贝叶斯而不是 BERT领域问答的意图分类样本量普遍只有几百到两三千条这个规模下 BERT 微调收益有限还要考虑推理延迟和标注成本。朴素贝叶斯分类器有三个优势训练在 Spark 上几秒到几十秒完成模型是一组概率表每个词对每个类别的贡献可以打印出来人工检查推理只是查表相加Java 端几十行代码就能复现不需要在线维护一套 Spark 服务。朴素贝叶斯的条件独立假设在短文本意图分类上是成立的问句通常只有十几个词词与词之间的依赖本来就弱把这个假设当成工程近似完全够用。Spark 在这里的角色是离线批处理——读取标注问句、训练模型、输出参数文件。Spark 的安装与使用按官方文档走到本地模式即可不需要搭集群数据量大之后把输入路径换成 HDFS代码不用改。3.2 Spark MLlib 训练朴素贝叶斯分类器的完整代码训练输入是一张两列的表text 是分词后的问句intent 是意图标签。分词在导入 Spark 前用 HanLP 或 jieba 做好空格分隔写入 CSV也可以注册 Spark UDF 在管道里分。关键一点是训练分词方式要和后面 SpringBoot 在线分词保持一致这个后面还会强调。下面的训练脚本用 pyspark管道由三段组成from pyspark.ml import Pipeline from pyspark.ml.classification import NaiveBayes from pyspark.ml.feature import Tokenizer, CountVectorizer from pyspark.ml.evaluation import MulticlassClassificationEvaluator from pyspark.sql import SparkSession spark SparkSession.builder.appName(tea_intent_nb).getOrCreate() train_df spark.read.csv(hdfs:///data/intent_train.csv, headerTrue, inferSchemaTrue) tokenizer Tokenizer(inputColtext, outputColwords) # CountVectorizer 保留词表便于导出给 Java 端做在线预测 cv CountVectorizer(inputColwords, outputColfeatures, vocabSize5000, minDF2) nb NaiveBayes(smoothing1.0, modelTypemultinomial, featuresColfeatures, labelColintent) pipeline Pipeline(stages[tokenizer, cv, nb]) model pipeline.fit(train_df) predictions model.transform(train_df) evaluator MulticlassClassificationEvaluator( labelColintent, predictionColprediction, metricNameaccuracy) print(train accuracy:, evaluator.evaluate(predictions)) model.write().overwrite().save(hdfs:///models/tea_intent_nb)这里特意没用 HashingTF 而用 CountVectorizer。HashingTF 不保存词表线上复现要对同一个词算哈希索引Java 端得重新实现 murmur3 哈希容易踩坑CountVectorizer 训练后能直接拿到 vocabulary 数组序列化成 JSON 给 SpringBoot 加载两边特征一致。vocabSize 设 5000对茶虫问答这种垂直语料够用minDF2 表示词至少出现在两条问句里才保留过滤只出现一次的噪声词。NaiveBayes 的两个参数说明一下。smoothing 是拉普拉斯平滑系数默认 1.0语料小或某类样本特别少时调到 2.0能减少零概率导致的误判。modelType 选 multinomial按词频计数建模适合「某个词出现几次」的问句bernoulli 只关心词是否出现对短问句信息利用不充分。3.3 训练数据的标注格式与类别平衡意图标签按问答模板划分控制在五到六个太多会让朴素贝叶斯分不清边界。茶虫问答可以这样标intent 标签含义示例问句PEST_INFO查害虫档案小绿叶蝉什么时间危害最重DISEASE_INFO查病害档案茶饼病的发病条件是什么DRUG_QUERY问用药方案茶尺蠖用什么药效果好CONTROL_QUERY问防治方法茶毛虫怎么防治SYMPTOM_QUERY症状反查叶片卷曲发黄是什么原因每类至少 150 到 300 条保证模型能学到稳定的词概率。类别不均衡时先验概率会偏向样本多的类比如 CONTROL_QUERY 有 800 条而 SYMPTOM_QUERY 只有 120 条模型经常把症状问题判成防治问题。先做类别统计低于平均数的类别用同义改写扩样本不要直接复制粘贴重复数据会让模型高估某些词的概率。3.4 模型评估、调参与导出参数给 SpringBoot评估不要只报一个 accuracy打印混淆矩阵重点看 DRUG_QUERY 和 CONTROL_QUERY 是否互相串from pyspark.mllib.evaluation import MulticlassMetrics metrics MulticlassMetrics(predictions.select(intent, prediction).rdd) print(metrics.confusionMatrix().toArray()) print(weighted f1:, metrics.weightedFMeasure())调参用 CrossValidator 在 smoothing 的 [0.5, 1.0, 2.0] 和 vocabSize 的 [3000, 5000, 8000] 上网格搜索取验证集 f1 最高的一组。垂直语料小训练很快多跑几组不亏。模型验证完不直接部署 Spark 服务而是把参数导出成 JSON 让 SpringBoot 启动时加载import json nb_model model.stages[-1] vocab model.stages[1].vocabulary # CountVectorizer 的词表 params { labels: nb_model.getOrDefault(labels), vocab: vocab, logPriors: nb_model.pi.toArray().tolist(), logCondProb: nb_model.theta.toArray().tolist(), } with open(/data/nb_params.json, w, encodingutf-8) as f: json.dump(params, f, ensure_asciiFalse)pi 是各类别的对数先验theta 是词在各类别下的对数条件概率都是 MLlib 训练完直接拿到的。把这份 JSON 连同分词词典一起打进 SpringBoot 的 classpath在线服务就完全不依赖 Spark 集群了这也是问答接口能保持低延迟的关键。4. SpringBoot 整合 Neo4j 与朴素贝叶斯模型串起智能问答链路4.1 SpringBoot 工程依赖与 Neo4j 连接配置工程用 Spring Initializr 创建时勾选 Spring Web 和 Spring Data Neo4j或者手动往 pom.xml 加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-neo4j/artifactId /dependency连接配置写在 application.yml。注意 SpringBoot 3.x 对应的 Spring Data Neo4j 6.x 用 spring.neo4j 前缀SpringBoot 2.x 老项目用 spring.data.neo4j前缀差一个词配置就不生效spring: neo4j: uri: bolt://127.0.0.1:7687 authentication: username: neo4j password: change-me pool: max-connection-pool-size: 50 connection-timeout: 5s问答服务并发量不高连接池 50 足够。connection-timeout 5s 是拿连接的超时防止 Neo4j 不可用时请求全部阻塞。4.2 Spring Data Neo4j 的实体映射与 Cypher RepositorySpring Data Neo4j 的用法是 Node 标注实体类Relationship 标注关联属性框架负责建实体和关系。问答系统大部分查询是聚合读取直接用 Query 写 Cypher 更灵活只映射结果不映射实体。Node(TeaPest) public class TeaPest { Id GeneratedValue private Long id; private String name; private String aliases; private String hazardLevel; private String occurPeriod; }Repository 里写两类方法一类返回实体一类返回自定义投影 Mappublic interface TeaPestRepository extends Neo4jRepositoryTeaPest, Long { Query(MATCH (p:TeaPest {name: $name}) RETURN p.name AS name, p.hazardLevel AS hazardLevel, p.occurPeriod AS occurPeriod) MapString, Object findBasicInfo(Param(name) String name); Query(MATCH (p:TeaPest {name: $name})-[:TREATED_BY]-(d:Drug) RETURN d.name AS drug, d.dosage AS dosage, d.safeInterval AS safeInterval ORDER BY d.safeInterval) ListMapString, Object findDrugs(Param(name) String name); }Param 绑定参数比拼字符串安全也方便 Cypher 缓存。返回 Map 时驱动会把节点属性转成 Java 类型属性名保持驼峰一致避免前端字段对不上。4.3 把朴素贝叶斯分类器参数移植到 SpringBoot 在线预测SpringBoot 端不跑 Spark启动时加载 nb_params.json预测就是查表求和。核心服务代码Component public class NaiveBayesPredictor { private final ListString labels new ArrayList(); private final MapString, Integer vocabIndex new HashMap(); private double[] logPriors; private double[][] logCondProb; private final Segmenter segmenter; public NaiveBayesPredictor(Segmenter segmenter) throws IOException { this.segmenter segmenter; JsonNode root new ObjectMapper().readTree( new ClassPathResource(nb_params.json).getInputStream()); JsonNode vocabNode root.get(vocab); for (int i 0; i vocabNode.size(); i) { vocabIndex.put(vocabNode.get(i).asText(), i); } root.get(labels).forEach(n - labels.add(n.asText())); int labelCount labels.size(); logPriors new double[labelCount]; logCondProb new double[labelCount][vocabNode.size()]; for (int c 0; c labelCount; c) { logPriors[c] root.get(logPriors).get(c).asDouble(); JsonNode probs root.get(logCondProb).get(c); for (int i 0; i probs.size(); i) { logCondProb[c][i] probs.get(i).asDouble(); } } } public String predict(String question) { ListString tokens segmenter.segment(question); double[] scores new double[labels.size()]; for (int c 0; c labels.size(); c) { scores[c] logPriors[c]; for (String token : tokens) { Integer idx vocabIndex.get(token); if (idx ! null) { scores[c] logCondProb[c][idx]; } } } int best 0; for (int i 1; i scores.length; i) { if (scores[i] scores[best]) best i; } return labels.get(best); } }预测时对所有类别的得分做对数累加最后 argmax 取最大一项不需要归一化成概率。词表里没有的词直接跳过相当于特征向量该维度为 0。theta 已经是训练时做过拉普拉斯平滑的对数概率线上不需要再处理零概率。这里的 segmenter 必须与训练时一致。最快的锁死办法训练时把自定义词典导出成文本文件SpringBoot 端用同一份词典初始化分词器。两边词表对不上准确率会明显下降。4.4 问答接口实现意图识别、实体抽取、图谱查询与应答拼装接口层把三步串起来朴素贝叶斯给意图实体抽取给实体名模板引擎把两者映射成 Cypher 并执行。RestController RequestMapping(/api/qa) public class QaController { private final NaiveBayesPredictor predictor; private final EntityExtractor entityExtractor; private final QaTemplateEngine templateEngine; PostMapping public QaResult answer(RequestBody QaRequest request) { String question request.getQuestion(); String intent predictor.predict(question); String entity entityExtractor.extract(question); if (entity null) { return QaResult.fallback(没有识别到病虫害名称请核对后再问); } MapString, Object answer templateEngine.query(intent, entity); if (answer null || answer.isEmpty()) { return QaResult.fallback(图谱中暂未找到该实体的关联数据); } return QaResult.ok(templateEngine.format(intent, entity, answer)); } }实体抽取用词典最长匹配从 Neo4j 的 aliases 属性导出全量实体别名表启动时加载到内存对问句做最大正向匹配匹配到的词就是实体用户写「小绿虫子」也能命中「小绿叶蝉」。模板引擎按 intent 选择 Cypher 模板并替换参数intent 到模板的映射关系如下intentCypher 模板要点应答拼装PEST_INFO按 name 查 TeaPest 属性危害等级与发生期DISEASE_INFO按 name 查 TeaDisease 属性病原与发病条件DRUG_QUERY沿 TREATED_BY 查 Drug推荐农药与安全间隔期CONTROL_QUERY沿 PREVENTED_BY 查 ControlMethod防治方法及时期SYMPTOM_QUERY沿 SYMPTOM_OF 反查病虫害可能的病虫害及特征这张表是问答系统的核心维护点新增实体类型或新意图都从这里改。4.5 超时、空结果与未知实体的兜底处理问答接口必须有兜底用户随便问一句「明天会下雨吗」应该得到友好提示而不是 500。空结果统一返回固定提示语异常捕获后区分超时和不可用try { return templateEngine.query(intent, entity); } catch (QueryTimeoutException e) { return null; } catch (ServiceUnavailableException e) { return null; }Neo4j 服务端的事务超时可在 neo4j.conf 里设 db.transaction.timeout5s避免某条环查询把线程池占满。兜底文案站在用户视角写「换个问法试试」比「系统错误」有用。5. 朴素贝叶斯分类器与问答系统的调参、验证与性能优化5.1 意图识别准确率的离线验证留出 20% 的问句做测试集训练时不要碰。评估除了 accuracy 还要看混淆矩阵茶虫问答里最常见的错是 DRUG_QUERY 和 CONTROL_QUERY 互相串因为「用什么药」和「怎么防治」问句里都出现病虫害名。如果这两类混得厉害检查是不是标注时把「怎么治」分到了防治类而用药问句也带「治」字。解法是给类别补专属词样本比如「药」「喷施」「亩用量」强关联 DRUG_QUERY或者把两个意图合并成一个 ANSWER_QUERY模板里同时查药和防治方法牺牲一点精确度换召回。5.2 三个必调参数与一个必守原则smoothing 从 1.0 试到 2.0。标注样本只有两三百条时很多词只在某一类出现一次平滑系数调大可以压低这部分概率的过拟合。vocabSize 不需要太大茶虫问句核心词一千到三千设 8000 只是徒增内存。minDF 保持 2过滤单次出现的词。必守原则只有一个分词词典和训练数据绑定发布升级词典必须重新导出 nb_params.json否则在线分词结果和训练特征对不上准确率会掉。5.3 用 PROFILE 和缓存把问答接口压进百毫秒问答响应时间的大头在 Neo4j 查询。怀疑某条 Cypher 慢直接在浏览器里跑 PROFILE 看执行计划PROFILE MATCH (p:TeaPest {name: 小绿叶蝉})-[:TREATED_BY]-(d:Drug) RETURN d.name, d.dosage, d.safeInterval执行计划里看到 NodeIndexSeek 而不是 NodeByLabelScan说明走了索引。另一个常见视觉误导是 Neo4j Browser 默认只渲染 25 个节点标签知识图谱看起来「丢数据」实际数据都在用 RETURN count(*) 验证即可。热点问答对加一层缓存。以 intent entity 作为 key把格式化好的答案缓存起来Caffeine 设 30 分钟过期命中缓存时接口延迟能到 20ms 以内。缓存只放格式化后的答案不要缓存 Cypher 结果避免图谱更新后答案不刷新。每次问答请求记录一条日志含原始问句、intent、实体和返回码每周统计一轮识别错误率高的类别回填进训练语料朴素贝叶斯分类器就能持续迭代。本文还有配套的精品资源点击获取