简介:面向人工智能与知识图谱实践者的项目资源包,基于豆瓣图书数据,演示图书推荐、知识图谱构建与知识引擎的简易实现,适合对Neo4j图数据库、推荐系统或知识检索感兴趣的开发者、学习者作为课设或入门实践。包体共11个文件,包含4个CSV数据文件(图书、类别、作者等实体关系数据)、2个Python脚本(数据生成与推荐主流程)、3张结果展示图、1个辅助数据压缩包及1份说明文档,整体大小14.12MB,目录结构清晰,便于按模块查阅。已有99人学习下载。通过该项目,可掌握知识图谱实体关系建模、Neo4j图数据库的Cypher查询与存储方式,理解基于内容的推荐思路与搜索模块的整合方法。资源附带完整代码与样例数据,覆盖从数据预处理、图谱存储查询到推荐检索的完整链路,便于动手复现和二次扩展。
1. 基于豆瓣图书的推荐、知识图谱与知识引擎简单构建:先搞清楚这是哪类工程
这个标题在人工智能大作业和毕设压缩包里反复出现,它其实是一条完整的落地链路:把豆瓣读书的图书条目清洗成实体和关系,导入 neo4j 社区版,形成一张能回答“作者是谁、属于哪些标签、和哪些书相似”的知识图谱,再基于图结构做推荐,最后用规则模板搭一个能查书的简单知识引擎。它不训练大模型,也不用 GPU,核心是数据建模、Cypher 和导入工具。适合三类人:要交人工智能课程项目但不想只做 PPT 的学生、想从零上手知识图谱构建的工程师、以及想快速看 neo4j 实战效果的产品或后端同学。明白这一点后,你会知道真正的工程量不在“画图”,而在数据清洗、关系去重和查询设计。
2. 豆瓣图书知识图谱的数据模型:实体、关系与属性怎么设计才不返工
数据模型决定后续所有查询和推荐的口径。很多人拿到 zip 里的源码就急着跑 neo4j,结果跑完发现推荐结果全是重复的、同名的书混在一起、作者和出版社查不到。这不是代码问题,是开始就少做了数据建模。把这个项目当成一次简化版的本体建模:实体类型、关系类型、属性命名这三件事定下来,后面的导入和查询才不会被返工拖死。
2.1 实体类型选型:四类节点足够撑起推荐与问答
我会把实体收敛成四类:Book、Author、Publisher、Tag。为什么不做 User?因为公开的豆瓣书单数据里没有真实的用户评分行为,建一个 User 节点却没有任何可用关系,等于留了一堆空壳。为什么不单独做 Category?豆瓣标签本身就带着分类语义,“小说”“科幻”“文学”都是 Tag,再单独建一层分类会增加导入复杂度。如果以后确实需要“民国文学”这种上位概念,可以在 Tag 之间加一种“上位标签”关系,属于扩展项,不应该在第一天进模型。
这一步就是很多人说的“本体建模”,工业场景下的知识图谱设计里它叫 ontology,落到 neo4j 里简单说就是节点标签和关系类型的定义表。语义层则由属性命名来体现,比如书名用 title、出版年份用 pub_year、评分用 rating,全部小写下划线,保持统一。知识管理里最忌讳的是同一个字段今天叫 author、明天叫 writer,后面写 Cypher 时反复排查字段,纯粹浪费时间。
2.2 关系设计:把方向、属性和度数一次定清楚
四类实体之间至少有四条关系,我一般先画一张关系矩阵再动手写导入脚本。
| 关系 | 起点 | 终点 | 方向 | 属性 |
|---|---|---|---|---|
| 写了 | Author | Book | Author -> Book | 无 |
| 出版 | Publisher | Book | Publisher -> Book | 无 |
| 属于 | Book | Tag | Book -> Tag | 无 |
| 相似 | Book | Book | Book -> Book | weight |
前三条关系做知识查询,例如“红楼梦的作者是谁”“三体是哪个出版社出的”。第四条“相似”关系在实际项目里可以不在导入阶段生成,因为相似度需要基于全量标签统计,比较适合在推荐查询时动态计算。只有当数据量很大、推荐接口 QPS 高时,我才会把相似关系预计算后写回图里,这是性能和开发成本的取舍。
关系方向要固定下来。比如“写了”统一写成(a:Author)-[:写了]->(b:Book),查询作者时用MATCH (b:Book)<-[:写了]-(a:Author),查询作品时用MATCH (a:Author)-[:写了]->(b:Book)。最怕导入的时候方向随意,一会儿反一会儿正,查询时就得写无方向的(a)-[:写了]-(b),在数据量大时会显著拖慢执行计划。
2.3 属性取舍与去重键:豆瓣 ID 是主键,ISBN 只是辅助
图书节点保留这些属性:douban_id、title、isbn、pub_year、pages、rating、rating_count。作者节点只保留 name,出版者保留 name,标签保留 name。不要什么字段都往节点上塞,比如作者简介、豆瓣书评数量这种字段,在这个推荐项目里用不上,导入还会拖慢速度。
去重键一定要用 douban_id,不要用 ISBN。豆瓣同一本书经常对应多个 ISBN,不同版本共用一个作品条目,反过来一个 ISBN 也可能被多个条目错误复用;还有大量老书 ISBN 字段是空的。用 douban_id 做主键可以避免“三体被插成三行”的经典翻车现场。属性字段里 rating 用浮点数,rating_count 用整数,pub_year 用整数。CSV 里如果这些字段是字符串,Cypher 里要用 toFloat、toInteger 转换,否则排序会出现可怕的字典序问题,比如 “9” 比 “10” 大。
2.4 最小可跑通的 CSV 表结构:节点表和关系表分开
这个项目最稳妥的导入方式是先把原始数据拆成 CSV,再交给 neo4j。节点表和关系表分开,不要尝试把作者、出版社塞进 Books 节点里。最小编制是七个文件:
books.csv authors.csv publishers.csv tags.csv book_author.csv book_tag.csv book_publisher.csvbooks.csv 的列这样设计:
douban_id,title,isbn,pub_year,rating,rating_count,pages 1001,红楼梦,,1982,9.6,98642,1536 1002,三体,9787536692930,2008,8.8,385000,302authors.csv 和关系表示例:
author_id,name A0001,曹雪芹 A0002,刘慈欣:START_ID,:END_ID,:TYPE 1001,A0001,写了 1002,A0002,写了注意关系表里:START_ID和:END_ID必须和能力表的主键一致。作者 ID 我用A0001而不是纯数字,是为了在 neo4j-admin import 时避免和 Book 的douban_id发生 ID 空间冲突。标签字段虽然原始数据里是“科幻/小说/刘慈欣”这样的字符串,但导入前必须拆到 book_tag.csv 里,不要偷懒把整个字符串存成属性数组,否则后面做相似推荐时没法用标签关系做图查询。关于 CSV 具体的导入路径和约束语句,下一章接着写。
3. 数据准备到入库:从爬虫 JSON 到 Neo4j 的完整导入链路
拿到源码包之后,常见的数据形态是豆瓣接口或爬虫导出的 JSON,也可能是一张历史的 books.csv。不管哪种,直接喂给 neo4j 都会出问题。我通常把链路分成四步:清洗去重、拆实体和关系表、选导入方式、建约束和索引。每一步都有固定的坑,按顺序做可以省掉大量重复劳动。
3.1 清洗规则:去重、缺失值、多值字段拆分
原始 JSON 里的 author 字段经常是“刘慈欣 / 某某”或一个 Python 列表,tags 字段也类似。先用 pandas 做一次标准化:
import pandas as pd books = pd.read_json("data/books.json", encoding="utf-8") books = books.drop_duplicates(subset=["douban_id"]) books = books[books["title"].notna()].copy() books["isbn"] = books["isbn"].fillna("") books["pub_year"] = pd.to_numeric(books["pub_year"], errors="coerce") books["rating"] = pd.to_numeric(books["rating"], errors="coerce") books["rating_count"] = pd.to_numeric(books["rating_count"], errors="coerce") def split_multi(val): if isinstance(val, list): parts = val else: parts = str(val).split("/") return [p.strip() for p in parts if p and p.strip()] books["author_list"] = books["author"].map(split_multi) books["tag_list"] = books["tags"].map(split_multi)这段代码有两个关键动作。第一,去重键用 douban_id 而不是 title,因为同名书太多;第二,把 author 和 tags 从混合分隔符拆成列表。pub_year用pd.to_numeric(..., errors="coerce")是为了把“不详”“2016年”这类脏值统一转成缺失值,后面导入时按空处理。如果某个字段是 NaN 直接写入 CSV,Cypher LOAD CSV 会读到一个空字符串,但连接 pandas 转 CSV 时报错,所以 isbn 要提前 fillna。
3.2 用 pandas 把实体表和关系表拆出来
拆表是关键技术活。要保证 author 表先建好并且拿到稳定 ID,book_author 表才能关联上。顺序不能颠倒:
authors = [] book_author = [] for _, row in books.iterrows(): for name in row["author_list"]: authors.append({"name": name}) book_author.append({"book_id": row["douban_id"], "author": name}) authors = pd.DataFrame(authors).drop_duplicates(subset=["name"]).reset_index(drop=True) authors["author_id"] = "A" + authors.index.astype(str).str.zfill(4) book_author = pd.DataFrame(book_author).drop_duplicates(subset=["book_id", "author"]) book_author = book_author.merge(authors, left_on="author", right_on="name", how="left") book_author[["book_id", "author_id"]].to_csv("book_author.csv", index=False) authors.to_csv("authors.csv", index=False, encoding="utf-8")这里给作者 ID 加了A前缀,为后续 neo4j-admin import 的 ID 空间隔离做准备。drop_duplicates(subset=["book_id", "author"])必须做,否则原始数据里重复出现的作者会导致关系表里重复行,最终让图里出现“同一作者写了同一本书两遍”的脏关系。tags 的拆分逻辑完全一样,把 author 换成 tag、author_id 换成 tag_id 即可。
注意一点:这段循环在十万本书时跑起来偏慢,但它是可接受的一次性开销。如果你要处理百万级数据,建议改用 groupby + explode 的向量化写法,但需要保证 explode 后的去重逻辑一致。
3.3 两种导入方式:LOAD CSV 与 neo4j-admin import 怎么选
项目源码里通常给的是 LOAD CSV,因为直观、可以在已有库上跑。我根据数据量做选择:几万条用 LOAD CSV,数据量大或需要重复重建库时用 neo4j-admin import。
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 数据量小于 10 万、需要增量更新 | LOAD CSV | 可以在已运行的库上执行,写错可调整 |
| 初始全量导入、超过 50 万节点 | neo4j-admin import | 批量导入效率高,但要求空库且不可增量追加 |
neo4j-admin import 的常见写法如下,注意数据库名要和配置文件里的一致:
bin/neo4j stop # 清掉旧库,确保目标是全新库 rm -rf data/databases/neo4j bin/neo4j-admin database import full \ --nodes=Book=import/books.csv \ --nodes=Author=import/authors.csv \ --nodes=Publisher=import/publishers.csv \ --nodes=Tag=import/tags.csv \ --relationships=写了=import/book_author.csv \ --relationships=出版=import/book_publisher.csv \ --relationships=属于=import/book_tag.csv \ --database=neo4j bin/neo4j start这个命令要求节点文件表头里有:ID(Book)这种类型标注。比如 books.csv 第一列表头应该写成douban_id:ID(Book),关系文件用:START_ID(Book),:END_ID(Author),:TYPE。如果不加类型标注,admin import 会不知道 ID 属于哪个标签,导致跨类型 ID 冲突。所以在第 2.4 节的表结构基础上,做 admin import 前要再改一遍表头。
LOAD CSV 方式更灵活,适合增量追加和想在已有库里补数据:
LOAD CSV WITH HEADERS FROM 'file:///books.csv' AS row MERGE (b:Book {douban_id: row.douban_id}) SET b.title = row.title, b.rating = toFloat(row.rating), b.rating_count = toInteger(row.rating_count), b.pub_year = toInteger(row.pub_year)关键点:file:///books.csv指向的是NEO4J_HOME/import目录,不是项目源码目录。建好 CSV 后先把文件拷进 neo4j 的 import 目录,否则 LOAD CSV 一直报文件不存在。MERGE而不是CREATE是为了防止脚本跑第二遍时产生重复节点;这个差别会在第 5 章具体展开。
3.4 导入后的索引与约束:防止重复和慢查询的必做操作
导入任何数据前,先把约束建好。这一步既是保护也是性能优化。Cypher 写法如下:
CREATE CONSTRAINT book_unique IF NOT EXISTS FOR (b:Book) REQUIRE b.douban_id IS UNIQUE; CREATE CONSTRAINT author_unique IF NOT EXISTS FOR (a:Author) REQUIRE a.name IS UNIQUE; CREATE INDEX tag_name_index IF NOT EXISTS FOR (t:Tag) ON (t.name); CREATE INDEX book_title_index IF NOT EXISTS FOR (b:Book) ON (b.title);REQUIRE ... IS UNIQUE是 Neo4j 5.x 的语法;4.x 老版本用CREATE CONSTRAINT ON (b:Book) ASSERT b.douban_id IS UNIQUE。约束本身就带索引,所以作者和书籍主键不用再单独建索引。Tag 的 name 和 Book 的 title 不唯一,所以额外建普通索引,供后续MATCH (t:Tag {name: ...})快速命中。
这个步骤的真正价值是数据校验。如果 books.csv 里 douban_id 有重复,建约束直接报错,而不是让重复节点悄悄混进图里。关系重复也一样,靠约束能让 MERGE 在并发下不乱出重复边。先建约束再导入,还是先导入再建约束?我建议先建约束,哪怕导入前库是空的。这样 LOAD CSV 里的 MERGE 每一步都能快速定位节点,而不是全库扫描,性能差距在十万级以上非常明显。
4. 基于图谱的图书推荐:让 Cypher 替你算“看了又看”
图数据库做推荐最舒服的地方,是天然适合“邻居”语义。传统协同过滤要构造用户-物品矩阵,维度爆炸;基于知识图谱的推荐则是把相似性转化成图上路径。这本书和那本书共享了三个标签,这就是一条可解释的推荐理由。这一章全部用 Cypher 实现,不需要额外算法包。
4.1 最简单的“看了又看”推荐:两跳邻居加 COUNT
假设我们要为“三体”找类似书,核心思路是:找出三体的全部标签,再找拥有这些标签的其他书,按共享标签数量排序。
MATCH (b:Book {douban_id: '1002'})-[:属于]->(t:Tag) MATCH (t)<-[:属于]-(other:Book) WHERE other.douban_id <> '1002' RETURN other.title AS title, count(DISTINCT t) AS shared_tags, other.rating AS rating, other.rating_count AS rating_count ORDER BY shared_tags DESC, rating_count DESC LIMIT 10第一个 MATCH 找到种子书籍的全部标签,第二个 MATCH 从这些标签反向找到其他书。count(DISTINCT t)统计的是两本书共享的标签数,用 DISTINCT 防止脏数据导致同一个标签重复计数。最后用rating_count做排序并列时的热度兜底,因为两个标签数相同的书,更多人看过的通常更值得推荐。
这就是从一个节点出发按多条路径展开的典型写法。手册里经常出现MATCH p = (b)-[*1..3]-(x)这种可变路径,但在推荐场景里,我更喜欢显式写两跳到三跳,因为可变路径容易带来意料之外的中介节点,而且数据量大时执行计划很难控制。
4.2 给推荐加权重:评分过滤与标签热度的口径
基础版只能叫“标签重合度”,不算真正的推荐质量。加上评分阈值和热度过滤后,结果会更接近人的判断:
MATCH (b:Book {douban_id: '1002'})-[:属于]->(t:Tag) MATCH (t)<-[:属于]-(other:Book) WHERE other.douban_id <> '1002' AND other.rating >= 7.0 AND other.rating_count >= 500 WITH other, count(DISTINCT t) AS shared_tags RETURN other.title AS title, shared_tags, other.rating AS rating, other.rating_count AS rating_count ORDER BY shared_tags * 2 + other.rating * 0.5 DESC LIMIT 10shared_tags * 2 + other.rating * 0.5是一个可解释的加权分。标签重合度是核心,给高权重;评分只是辅助,给低权重。权重系数完全取决于你的数据分布,不能照搬。比如小众书数据集里评分普遍偏高,0.5 的系数就会让评分优势过大。调参的依据是抽看推荐结果:如果前几名全是标签共享但评分很水的书,说明评分权重太高;如果全是热门爆款但和种子书无关,说明标签拆得太粗。
关于“看过的人还喜欢”,如果你手头真的拿到了豆瓣用户的在读/想读/读过记录,那应该把 User 节点加进来,用(u:User)-[:读过]->(b:Book)做真正的协同过滤。本标题里的数据没有用户行为,所以退而求其次用图书标签做内容过滤。
4.3 把推荐结果封装成接口:Flask + neo4j 驱动的常见做法
课程作业要演示,往往需要给前端一个接口。常见做法是用 Flask 起一个轻量服务,通过 neo4j 官方驱动连图库:
from flask import Flask, request, jsonify from neo4j import GraphDatabase app = Flask(__name__) driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) RECOMMEND_QUERY = """ MATCH (b:Book {douban_id: $book_id})-[:属于]->(t:Tag) MATCH (t)<-[:属于]-(other:Book) WHERE other.douban_id <> $book_id AND other.rating_count >= 500 RETURN other.title AS title, other.rating AS rating, count(DISTINCT t) AS score ORDER BY score DESC, other.rating_count DESC LIMIT $limit """ @app.get("/recommend") def recommend(): book_id = request.args.get("book_id", type=str) limit = request.args.get("limit", default=10, type=int) if not book_id: return jsonify({"error": "book_id is required"}), 400 with driver.session() as session: rows = session.run(RECOMMEND_QUERY, book_id=book_id, limit=limit).data() return jsonify(rows)几个容易踩的细节:驱动连接串用bolt://localhost:7687,端口不是 7474,7474 是 HTTP 管理端口;查询参数一定用$book_id绑定,不要用 f-string 拼字符串,否则有 Cypher 注入风险,译码时还可能因为引号问题翻车。.data()方法把 Record 列表转成字典列表,前端直接就能用。
4.4 冷启动问题:新书没有任何关系时怎么办
新书只有 ISBN 和出版社,没有标签,也没有共享邻居,上面两条查询都返回空。这时候要降级到出版者和热门度兜底。新书至少有出版社关系:
MATCH (b:Book {douban_id: '1002'})-[:出版]->(p:Publisher) MATCH (p)<-[:出版]-(other:Book) WHERE other.douban_id <> '1002' AND other.rating_count > 200 RETURN other.title AS title, other.rating_count AS rating_count ORDER BY other.rating_count DESC LIMIT 5如果出版社关系也没有,最终兜底就是全库热门榜,直接按 rating_count 排序返回。冷启动是个工程问题,不是 Cypher 问题。工业场景下的知识图谱设计里,通常会在推荐服务里做多级 fallback:标签邻居 -> 出版社邻居 -> 全局热门。这个顺序写清楚,接口的可用性就上来了。
5. Neo4j 构建知识图谱避坑与排查:导入慢、中文乱码、关系重复
这一章写我反复踩过的坑,全部按“现象、原因、解决”展开。能看完这章再动手,能少走一半弯路。
5.1 中文乱码:CSV 保存成 GBK 或 UTF-8 BOM 导致 LOAD CSV 翻车
现象:LOAD CSV 导入后,中文字段全部变成乱码,或者表头第一列带一个看不见的\ufeff,用字段名匹配一直失败。
原因:Neo4j 默认按 UTF-8 读取 import 目录下的文件。CSV 如果是 Windows Excel 另存的,通常保存为 GBK/ANSI;如果 Python 用encoding="utf-8-sig"写了 BOM,表头第一列会带隐藏字符,row.douban_id拿到的字段名对不上。
解决:统一先转成 UTF-8 无 BOM。一条命令:
python -c "import pandas as pd; pd.read_csv('raw_books.csv', encoding='gbk').to_csv('books.csv', index=False, encoding='utf-8')"如果已经入库了,先删库重新导入,不要试图在 Cypher 里修复编码。乱码数据一旦进图,清洗成本和重建成本基本一样,我都是直接重建。
5.2 关系重复:同一本书和同一个标签出现多条关系
现象:推荐结果里 shared_tags 数值莫名翻倍,比如一本《三体》和《流浪地球》共享 15 个标签,但计数显示 30。
原因:两个来源。一是原始数据里 tags 字段本身有重复,比如 “科幻/科幻/刘慈欣”;二是导入脚本用了 CREATE 而不是 MERGE,或者 pandas 拆表时没有去重。标签重复在数据清洗阶段就埋下了。
解决:清洗阶段强制drop_duplicates(subset=["book_id", "tag"]);导入阶段用 MERGE 写关系,不要用 CREATE。对于已经脏掉的库,先删掉重复关系再重建:
MATCH (b:Book)-[r:属于]->(t:Tag) WITH b, t, collect(r) AS rels WHERE size(rels) > 1 FOREACH (r IN tail(rels) | DELETE r)这种“清一遍”脚本只对轻度污染有用,根本解法是建唯一约束并用 MERGE。
5.3 内存配置没生效:改了 neo4j.conf 却还是 OOM
现象:导入 10 万条数据时报 OutOfMemoryError,明明已经改了dbms.memory.heap.max_size。
原因:三种情况最常见。一是 Neo4j 5.x 的配置项名称和 4.x 不一样,堆大小相关配置在conf/neo4j.conf,但改完没重启;二是配置行前面是注释符号,虽然“看起来改了”,实际读的还是默认值;三是 Windows 服务方式启动时,读取的是服务注册时的配置路径。
解决:先用命令确认进程实际生效的配置,而不是猜:
bin/neo4j info | grep heap bin/neo4j restart再用 Cypher 查实际运行时配置:
CALL dbms.listConfig() YIELD name, value WHERE name CONTAINS 'memory' RETURN name, value;如果dbms.listConfig()里显示的值和文件不一致,说明文件没被加载。这时检查是否同时存在多个 neo4j 安装目录、配置是否存在自定义路径。这类映射比调参数本身更折磨人,我建议每次改配置后都用neo4j info验证,不要凭“文件里写了”就下结论。
5.4 查询从一个节点出发如何查询多条时,结果出现笛卡尔积
现象:写一个从书籍出发查标签再查其他书的 MATCH,返回行数比预期多出几十倍,直接导致前端接口超时。
原因:图里存在脏数据时,一个节点和同一标签有多条关系,或者同名书籍有多个节点,MATCH 的组合会把这些全部乘起来。另一个常见点是多个 MATCH 写在了同一行,中间没有用逗号分隔的计划外组合。
解决:在中间节点上加 DISTINCT,并且用WHERE other <> b排除自环:
MATCH (b:Book {douban_id: '1002'})-[:属于]->(t:Tag) WITH DISTINCT t MATCH (t)<-[:属于]-(other:Book) WHERE other.douban_id <> '1002' RETURN DISTINCT other.title, count(DISTINCT t) AS score ORDER BY score DESC LIMIT 10如果需要遍历更深的多层路径,比如MATCH p = (b)-[*1..3]-(x),记得先看执行计划,加上LIMIT,并且用nodes(p)检查中间节点类型。不是所有路径都值得展开,“幂等去重”是治理查询膨胀的第一手段。
5.5 导入慢:百万级 LOAD CSV 卡到没脾气
现象:LOAD CSV 导入十万本书、几十万条关系时,每秒只处理几十条,跑了一个晚上还差一半。
原因:导入时没建索引和约束,MERGE 每处理一行都要全库扫描匹配目标;或者节点主键是字符串类型,但 CSV 关联字段被读成整数,索引匹配不上,导致 Cypher 走全表扫描。新版本 LOAD CSV 虽然会自动分批提交,但每行的节点查找代价还是绕不开。
解决:先建约束再导入,这是收益最大的一步。大数据量优先考虑第 3.3 节的neo4j-admin database import full,它绕开了 Cypher 逐行解析,能做到分钟级导入千万条。老版本里的USING PERIODIC COMMIT 5000属于临时止血手段,数据量一大就不是办法。另外,导入时不要开一堆浏览器监控和后台写任务,磁盘 IO 被抢,Cypher 查询锁等待变长,整个导入会进一步变慢。
6. 知识引擎的简单构建:把图谱变成能回答问题的接口
最后一步是把图谱包成一个“能回答问题”的东西。这个标题里的知识引擎不是大模型 RAG,而是规则模板加 Cypher 查询,数据量不大时完全够用,也比硬接大模型更可控。
6.1 用模板规则把自然语言映射成 Cypher
我用正则做意图识别。“红楼梦的作者是谁”“三体是哪年出版的”“推荐和小王子类似的书”,都能拆成意图加实体。
import re patterns = [ (r"^《?(.{1,20})》?的作者是谁$", "book_author"), (r"^《?(.{1,20})》?是哪年出版", "book_year"), (r"^推荐和《?(.{1,20})》?类似的书$", "similar_books"), ] def parse_question(text): text = text.strip() for pattern, intent in patterns: m = re.search(pattern, text) if m: return intent, m.group(1) return None, None实体抓到后,再拼一个查询函数,比如查作者:
def answer_author(title): with driver.session() as session: rows = session.run( """ MATCH (b:Book {title: $title})<-[:写了]-(a:Author) RETURN a.name AS name """, title=title, ).data() names = [r["name"] for r in rows] return "《" + title + "》的作者是:" + "、".join(names)如果图谱里有同名书,按 title 查会一次返回多本书的作者,这时要把全部结果列出来并提示可能有多本同名书。否则用户会误以为是脏数据。更稳的写法是先在接口里查douban_id候选,再二次确认,但课程演示做到 title 级别已经足够。
6.2 上线前先跑三条验证查询
知识引擎发布前,我会用三条 Cypher 验证图谱质量。第一条看节点分布,第二条看孤立书籍,第三条看关系密度:
MATCH (n) RETURN labels(n) AS label, count(*) AS cnt;// 没有任何标签的书,推荐接口里基本是冷启动资源 MATCH (b:Book) WHERE NOT EXISTS { MATCH (b)-[:属于]->(:Tag) } RETURN b.title LIMIT 20;MATCH (b:Book)-[:属于]->(t:Tag) RETURN t.name, count(*) AS cnt ORDER BY cnt DESC LIMIT 20;这三条能最快暴露数据问题:标签分布过于集中说明原始 tags 清洗失败;孤立书太多说明拆表时丢失了关系;某类节点数量异常说明导入时去重没生效。我每次做完图谱第一件事不是写推荐接口,而是先跑这几条检查;标签集中在两三个词上时,调任何推荐权重都是给脏数据打工。养成这个习惯后,后面做知识引擎就很少被奇怪的查询结果带偏方向。希望这份落地路径帮到你,少踩几个已经写在序号里的坑。
本文还有配套的精品资源,点击获取