简介:面向Java方向课程设计与知识图谱入门者,这份资源以化妆品领域为背景,完整覆盖知识图谱从数据采集、关系建模到智能问答的落地链路。项目图谱包含3000个节点、15000条边,覆盖口红与香水两类商品,支持图谱检索与智能问答,可据此理解Neo4j图数据库、实体识别与问答匹配等核心技术,也能快速掌握从数据清洗、图谱构建到问答接口实现的完整工程范式。压缩包共243个文件,约75.93MB,主要包含jar依赖库、xml配置、class编译文件、java源码、设计报告Word与项目截图,其中jar为项目运行依赖,class为编译产物,png为界面展示,便于直接导入IDE运行调试。目前已有436人学习/下载,适合课程设计或毕业设计参考。内含设计报告,并展示通过Fiddler抓取App数据、Selenium采集电商价格评论的完整思路,对构建行业知识图谱或复现智能问答系统具有实践价值。
1. 3000 节点、15000 边:这个化妆品知识图谱把问答做成了闭环
一个知识图谱项目做到什么程度才算「能答辩」?多数人以为节点越多越好,而这份资源给出相反答案:只有 3000 个节点、15000 条边,覆盖口红和香水两个品类,却完整跑通了「采集 → 建图 → 实体识别 → 查询图生成 → 子图问答 → Web 接口」整条链路。能回答「适合干皮的平价口红有哪些」,靠的不是大模型,而是词典规则加 Cypher 子图匹配。它的价值在于把知识图谱构建中「从非结构化文本到图查询」每一步都落到了代码上,而不是停在 PPT 里。适合两类人:一是把 Neo4j 与 Spring Boot 知识图谱当作选题的学生;二是想弄清企业里 FAQ 机器人为什么还在用规则引擎的工程师。下面按数据层、实体层、问答层、接口层逐段拆。
2. 数据源选型与图结构设计:Fiddler 抓包、Selenium 电商兜底、Neo4j Schema
2.1 数据源优先级:为什么官网数据被弃用
原始数据有三处来源:化妆品官网、美妆 APP「心心」、电商平台京东。官网信息最规范,但实际爬下来后价值有限——单品页缺少价格、评论这类动态字段,图片水印重,色号参数更新慢;更麻烦的是官网字段命名各异,不同品牌同一属性要对齐成本极高。这个项目最终把官网降级为「仅供参考」,正式入库以 APP 的图片、色号数值、成分表和电商的价格评论为准。
这个取舍值得复制:图谱的井别(schema)设计必须跟着「问什么问题」走。答案是价格、评论、色号、成分、肤质匹配,那么数据源优先级就应该是电商 > APP > 官网,而不是反过来。多源数据还带来一个隐性收益:品牌、产品名在多个源里重复出现,正好用来做实体对齐的锚点。
2.2 抓包获取 APP 数据:Fiddler 拦截但无法直连
APP 数据是整个项目里信息密度最高的部分,但采集方式比较特殊。项目用 Fiddler 抓取手机与服务器之间传输的数据包,操作路径可以复现:
- Fiddler 开启 HTTPS 解密,默认监听 8888 端口,手机 WiFi 设置里把网络代理指向电脑 IP,装好 Fiddler 根证书。
- 在 Filters 页签按域名过滤,只保留「心心」APP 的请求,排除其他应用的包。
- 在 APP 里逐个点击口红、香水列表页与详情页,抓到
list与detail两类接口,返回体一般是压缩过的 JSON。 - 将 JSON 里的
product_name、color_value、ingredient字段按规则落库。
这里有一个关键结论:抓到包不等于能再造请求。APP 请求参数里有加密 token,每次请求由服务器下发时间戳并参与签名,直接复制参数重放立刻失效。项目没有选择反编译 APP 去逆 token 算法,而是把抓包结果保存为静态 JSON 再解析入库,这个决定是务实的——课程设计的时间成本不允许耗在混淆代码逆向里。如果你要用同样的思路抓其他 APP,判断标准是:token 是否带时间戳和随机数,若带,就不要在请求层硬刚,转去分析抓包缓存。
2.3 电商价格评论:Selenium 渲染后的页面才是完整数据
电商数据用 Selenium 爬取,核心原因在于京东这类页面的目标信息由 JavaScript 动态渲染,直接请求 HTML 拿不到价格与评论。Selenium 模拟真实浏览器执行渲染后才抓取,代价是慢,但胜在选择器稳定。抓取逻辑如下:
from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options import json opts = Options() opts.add_argument("--headless=new") # 无头模式,减少资源占用 driver = webdriver.Chrome(options=opts) driver.get("https://search.jd.com/Search?keyword=口红") results = [] for card in driver.find_elements(By.CSS_SELECTOR, "li.gl-item")[:20]: name = card.find_element(By.CSS_SELECTOR, ".p-name em").text.strip() price = card.find_element(By.CSS_SELECTOR, ".p-price i").text.strip() comment = card.find_element(By.CSS_SELECTOR, ".p-commit strong").text.strip() results.append({"name": name, "price": price, "comment": comment}) with open("jd_lipstick.json", "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False)代码里headless=new是 Chrome 较新版本的无头模式写法,老版本用headless即可。CSS 选择器.p-name em取商品标题、.p-price i取价格、.p-commit strong取评价数,这三个类名在京东搜索结果页长期稳定。评论数这里只取总量,不做翻页,一是因为商品详情页评论是异步分段加载,二是因为单条评论文本对「适合肤质」这类结构化属性贡献有限,性价比低。
抓包与 Selenium 两条线产出的数据最终都归一化到同一套字段:product、brand、color_code、ingredients、price、comment_count。这一步对齐是图谱能建起来的先决条件。
2.4 图 Schema:3000 节点、15000 边如何划分
资源描述里说到 3000 节点、15000 边,很多人第一反应是规模太小,但如果按「品类 × 品牌 × 产品 × 色号 × 成分 × 肤质」六类实体建模,这个数量恰好形成一个密度合适的局部图。节点与关系分配如下:
| Label | 用途示例 | 边关系 |
|---|---|---|
| Category 品类 | 口红、香水 | (:Category)-[:HAS_BRAND]->(:Brand) |
| Brand 品牌 | 具体品牌名 | (:Brand)-[:HAS_PRODUCT]->(:Product) |
| Product 产品 | 单品名 | (:Product)-[:HAS_COLOR]->(:Color) |
| Color 色号 | 数值化色号 | (:Product)-[:HAS_INGREDIENT]->(:Ingredient) |
| Ingredient 成分 | 成分条目 | (:Ingredient)-[:NOT_FOR]->(:SkinType) |
| SkinType 肤质 | 中性、干性等 | (:Product)-[:FOR]->(:SkinType) |
价格不单独建节点,而是作为Product的属性存,因为价格的查询场景是范围过滤而不是图遍历。评论数同样作为属性。在 Neo4j 中导入时,项目使用的思路是先用 CSV 批量建点,再统一建关系,这也是课程设计中比逐条插入更稳的做法:
LOAD CSV WITH HEADERS FROM 'file:///product.csv' AS row CREATE (p:Product { id: row.id, name: row.name, price: toFloat(row.price), comment_count: toInteger(row.comment_count) }); LOAD CSV WITH HEADERS FROM 'file:///relationship.csv' AS row MATCH (p:Product {id: row.from_id}) MATCH (c:Color {id: row.to_id}) CREATE (p)-[:HAS_COLOR]->(c);LOAD CSV第一段建点,第二段建边,分两条语句执行能有效避免在一条 CYPHER 里反复MATCH造成的性能抖动。toFloat和toInteger必须显式转换,否则价格会被存成字符串,后面的范围查询会全部失效。这是我从这个项目里最想强调的细节:字符串化的数字字段会让图谱查询出现「查不到」假象,排错时先看属性类型。
3. 从问句到查询图:EntityRec 实体识别与 QuestionGraph 生成
3.1 为什么不用深度学习做 NER
看到EntityRec.class时,容易下意识以为用了 LSTM+CRF。但项目场景有一个硬约束:语料只有口红和香水两个品类,模型训练数据撑不起一个通用 NER。与其套一个开源模型再不断微调,不如把领域知识直接变成词典。这符合知识图谱问答系统的工程常态:垂直领域里词典规则的上限不低,且错误可解释。
EntityRec 的设计是「词典最大匹配 + 规则补充」。先加载自定义词典,词典条目包含品牌名、产品名、色号名、成分名、品类名、肤质词,每一条都标注类型。匹配时对问句做正向最大匹配,优先命中更长的词条,避免「迪奥」和「迪奥烈艳蓝金唇膏」冲突时切错。
public class EntityRec { private final Map<String, String> dict; // 词典:词条 -> 实体类型 public List<Entity> recognize(String question) { List<Entity> entities = new ArrayList<>(); int i = 0; while (i < question.length()) { String matched = null; String matchedType = null; // 最大长度优先,从最长候选开始截取 for (int len = maxWordLen; len > 1; len--) { if (i + len > question.length()) continue; String cand = question.substring(i, i + len); if (dict.containsKey(cand)) { matched = cand; matchedType = dict.get(cand); break; } } if (matched != null) { entities.add(new Entity(matched, matchedType, i)); i += matched.length(); } else { i++; } } return entities; } }maxWordLen初始化为词典中最长词条的字符数,这个值在加载词典时统计。正向最大匹配的代价是首轮扫描 O(n×L),n 为问句长度、L 为最大词长,对短问句完全可接受。Entity对象记录实体文本、类型和起始偏移,偏移量在后面的槽位填充里会用到。这一步只负责「切出实体」,不负责「判断意图」。
3.2 属性抽取:色号、价格区间和肤质条件的规则
实体识别完成后,问句里还残留非实体信息,比如「适合干皮的平价口红」中「干皮」「平价」是限定条件。这里用两个规则层处理。
第一层是同义词归一。「干皮」「偏干」「干性皮肤」统一映射到SkinType:干性;「平价」「便宜」「性价比高」统一映射到price_level:low。频率词不同写法之间差异大,但语义空间小,用一个别名表就能覆盖。第二层是正则捕获数值,比如:
Pattern priceRange = Pattern.compile("(\\d+)\\s*[-到至]\\s*(\\d+)元?"); Pattern colorCode = Pattern.compile("色号\\s*[::#]?\\s*([A-Za-z0-9]+)"); Matcher m1 = priceRange.matcher(question); if (m1.find()) { slot.setMinPrice(Integer.parseInt(m1.group(1))); slot.setMaxPrice(Integer.parseInt(m1.group(2))); }正则只做三类事情:价格区间、明确带「色号」前缀的数值、数字开头的容量规格(如「30ml」)。其他一概不碰。这样做是为了把「识别失败」控制在一个可以预判的范围内——两条正则覆盖不到的写法,会在 QuestionGraph 阶段落入兜底模板,不致于整句崩溃。
3.3 QuestionGraph:把槽位和实体组装成中间图
QuestionGraph的职责是把EntityRec的输出组织成一张「查询图」,它不是最终 Cypher,而是一个结构化的中间表示。核心组件是QGItem,可以理解为查询图上的一个节点,每个 QGItem 绑定一个实体类型和一组约束。比如「适合干皮的平价口红」解析后得到三个 QGItem:
Category = 口红SkinType = 干性PriceLevel = low
这三个 QGItem 之间通过预定义的模式关联起来。模式就是「带条件品类查询」,对应的是一条带WHERE的 Cypher 模板。QuestionGraph 做的事就是判断:问句里出现了哪些 QGItem?它们能不能拼进同一条模板?能拼进去就生成查询图对象:
public class QuestionGraph { private List<QGItem> items; // 查询图中的全部约束点 private String templateId; // 命中的模板 ID,如 "query_by_skintype_price_range" public boolean isComplete() { // 至少要有品类节点,否则不具备查询条件 return items.stream().anyMatch(it -> "Category".equals(it.getType())); } }isComplete()的意义在于拦截无效问句。如果用户只说「推荐一下」,识别结果里只有意图没有品类,直接返回引导语而不是去执行空查询。这比在数据库端报错要友好得多。查询图生成后交给 SubGraphQA 执行,执行层向调用方屏蔽 Cypher 细节,这是问答系统里比较干净的分层。
4. SubGraphQA 子图匹配与 QAController 接口设计
4.1 Cypher 模板:问答的查询边界
SubGraphQA接收QuestionGraph,核心逻辑是把它翻译成可在 Neo4j 上执行的 Cypher 模板。模板有边界:只能回答图谱中显式建模的关系,比如品类、品牌、价格区间、色号、肤质、成分。超过这个范围的问题不会硬答,而是走兜底回复。
| 功能 | 问法示例 | Cypher 模板 |
|---|---|---|
| 按品类+肤质查询 | 适合干皮的口红 | MATCH (p:Product)-[:FOR]->(s:SkinType) WHERE s.name=$skin MATCH (p)-[:HAS_CATEGORY]->(c:Category) WHERE c.name=$category RETURN p.name |
| 价格区间过滤 | 200 到 300 的香水 | MATCH (p:Product)-[:HAS_CATEGORY]->(c:Category) WHERE c.name=$category AND p.price>=min AND p.price<=max RETURN p.name, p.price |
| 查询色号 | 迪奥999的色号值 | MATCH (b:Brand {name:$brand})-[:HAS_PRODUCT]->(p:Product {name:$product}) RETURN p.color_code |
| 成分排雷 | 含酒精的口红 | MATCH (ing:Ingredient {name:"酒精"})<-[:HAS_INGREDIENT]-(p:Product) RETURN p.name |
模板参数全部走参数化查询,避免字符串拼接。这个建议无论项目大小都成立:Cypher 里$占位符由驱动转义,既防注入又能让 Neo4j 复用执行计划。模板固定在SubGraphQA里以静态配置维护,新增问答能力就是新增一行模板配置,不需要改代码结构。
4.2 子图召回与置信度打分
模板执行前,SubGraphQA 会先在候选实体上做一层子图召回。假设问句里识别出两个品牌名,两个品牌都进入候选集,但只能有一个是用户真正想问的。召回阶段的做法是:把每个候选品牌对应的子图(品牌节点 + 一跳内的 Product、Color、Ingredient)拉出来,再逐个子图算分。
public double scoreSubgraph(SubGraphCandidate sub, QuestionGraph qg) { double score = 0.0; // 实体类型与问句槽位匹配度,占主要权重 score += 0.6 * typeMatchRate(sub, qg); // 品牌词在问句中的位置,越靠前越可能是核心 score += 0.3 * positionScore(sub, qg); // 属性完整度,包含的约束条件越多越可信 score += 0.1 * Math.min(1.0, sub.getMatchedProperties() / (double) sub.getRequiredProperties()); return score; }三个分项的设定有讲究。类型匹配率权重最高,因为模板的查询逻辑完全依赖类型;位置分则是利用中文问答里核心实体通常出现在谓语之前的统计规律;属性完整度只有在候选子图数量少时才有区分度,所以只占 0.1。分数最高的子图如果低于阈值 0.5,直接返回「换个问法试试」的引导文案。这套打分策略放在课程设计里足够,但如果你要迁移到线上,建议把阈值和权重视为可配置项,不要让算法同学去改 Java 代码。
4.3 QAController:对外暴露的 REST 接口
QAController是整个项目的出口,走标准 Spring Boot MVC,只处理两个动作:接收 GET 请求、返回 JSON。接口参数与返回结构需要固定下来,前端、Postman、自动化测试都依赖这份契约:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| question | String | 是 | 用户问句,需 URL 编码 |
| topK | Integer | 否 | 返回结果条数上限,默认 5 |
@RestController @RequestMapping("/api/qa") public class QAController { private final SubGraphQA subGraphQA; public QAController(SubGraphQA subGraphQA) { this.subGraphQA = subGraphQA; } @GetMapping("/ask") public Map<String, Object> ask(@RequestParam String question, @RequestParam(defaultValue = "5") int topK) { EntityRec recognizer = new EntityRec(); List<Entity> entities = recognizer.recognize(question); QuestionGraph graph = new QuestionGraph(entities); Map<String, Object> result = new HashMap<>(); if (!graph.isComplete()) { result.put("code", 200); result.put("answer", "可以问我「适合干皮的口红」「200元以内的香水」这类问题"); return result; } SubGraphQA.Answer answer = subGraphQA.answer(graph, topK); result.put("code", 200); result.put("answer", answer.getText()); result.put("subgraph", answer.getSubgraphNodes()); return result; } }返回体里同时带answer和subgraph是有意的设计:answer 是给用户看的自然语言结果,subgraph 是图节点集合,前端可以基于它绘制图谱可视化。这样同一个接口既服务对话框,又服务图谱展示页,省掉一个单独的可视化数据接口。Spring Boot 版本差异对这个类影响极小,唯一要注意的是@RequestParam默认只接受 GET,若前端用 POST 调用需要额外加method配置。
5. 验证链路与 zip 包排错快查
5.1 跑通后的自测命令
拿到源码后先在本地起 Neo4j,把 3000 节点、15000 边的 CSV 数据导入,然后启动 Spring Boot 项目。验证问答链路不需要打开浏览器,一条 curl 就够:
curl "http://localhost:8080/api/qa/ask?question=适合干皮的平价口红&topK=3"返回 JSON 里的answer字段应当是具体口红名称列表。如果返回空,优先检查 Neo4j 里SkinType节点是否存在「干性」这个名字,再检查 CSV 中肤质字段是否有全角空格。接口通了以后,把同一批问句放到 Postman 里跑一遍,凑成一份包含 20 条问答对的自测报告,课程设计答辩时这份报告比截图更能说明问题。
5.2 资源包解压与导入常见报错快查
zip 资源包在 Windows 上解压出现乱码,绝大多数是文件名编码问题。用 7-Zip 打开时选择以 UTF-8 编码解压即可,命令行方式是在 7-Zip 的参数里显式指定代码页。无密码的情况下提示error read zip archive: could not find eocd,通常是文件没下载完整,重新下载并核对压缩包字节数即可。
| 现象 | 原因 | 处理 |
|---|---|---|
| 解压后中文文件名乱码 | zip 内编码为 GBK 或 UTF-8 未识别 | 用 7-Zip 以 UTF-8 重新解压 |
| Ne4j 中知识图谱只显示 25 个标签 | Bloom 等工具的标签显示上限 | 属可视化端限制,不影响查询,增加标签种类时合并相近 Label |
LOAD CSV导入后属性为空 | CSV 字段含引号未转义 | 导出 CSV 时指定QUOTE_ALL_NONE或用分隔符 tab 重导 |
Gradle 构建报invalid zip archive | 依赖缓存中的 jar 损坏 | 删除~/.gradle/caches下对应模块后重新构建 |
| GitHub 下载的 zip 与 Git 远程无法关联 | zip 不含.git目录 | 解压后执行git init && git remote add origin <url>再拉取 |
排错顺序遵循一个原则:先验证数据端,再验证代码端。数据端问题占了这个项目排错里的大部分,尤其是 CSV 字段格式与节点 Label 错写。把「色号」和「成分」这种高频属性建成独立节点而不是字符串属性之后,再用 Neo4j 的社区发现算法跑一遍子图,能看到按品牌聚集的社区结构——这是把它从问答系统扩展成推荐系统的下一步落点。
本文还有配套的精品资源,点击获取