據(jù)庫(kù)的電影知識(shí)問(wèn)答系統(tǒng)實(shí)戰(zhàn):從知識(shí)圖譜構(gòu)建到Cypher查詢(xún))
簡(jiǎn)介這份資源是面向計(jì)算機(jī)專(zhuān)業(yè)學(xué)生與知識(shí)圖譜初學(xué)者的一套電影知識(shí)問(wèn)答系統(tǒng)完整項(xiàng)目可作為課程大作業(yè)、畢業(yè)設(shè)計(jì)或自學(xué)練手參考。項(xiàng)目以知識(shí)圖譜為核心結(jié)合Neo4j圖數(shù)據(jù)庫(kù)存儲(chǔ)實(shí)體與關(guān)系并配套Python后端與前端頁(yè)面實(shí)現(xiàn)從數(shù)據(jù)到問(wèn)答的完整鏈路幫助讀者理解實(shí)體識(shí)別、關(guān)系抽取、知識(shí)融合與圖查詢(xún)推理等關(guān)鍵環(huán)節(jié)。壓縮包共55個(gè)文件約5.77MB包含11個(gè)py腳本、10個(gè)csv數(shù)據(jù)、8個(gè)json配置、5個(gè)txt說(shuō)明、5個(gè)svg與5個(gè)js前端資源以及wxss、png、md、wxml等覆蓋后端服務(wù)、爬蟲(chóng)、前端頁(yè)面與項(xiàng)目文檔等模塊。目前已有423人學(xué)習(xí)下載。通過(guò)該資源讀者可獲取可運(yùn)行的問(wèn)答系統(tǒng)源碼、圖數(shù)據(jù)庫(kù)建模思路、前后端交互示例與項(xiàng)目目錄組織方式便于快速搭建實(shí)驗(yàn)環(huán)境并在此基礎(chǔ)上二次開(kāi)發(fā)或撰寫(xiě)論文。1. 電影知識(shí)問(wèn)答系統(tǒng)從 Neo4j 圖數(shù)據(jù)庫(kù)到可用的問(wèn)答鏈路很多人第一次聽(tīng)到「基于知識(shí)圖譜和 Neo4j 圖數(shù)據(jù)庫(kù)的電影知識(shí)問(wèn)答系統(tǒng)」腦子里浮現(xiàn)的是聊天機(jī)器人。但真正動(dòng)手做過(guò)就知道它更像一條數(shù)據(jù)流水線先把電影、演員、導(dǎo)演、類(lèi)型、評(píng)分這些實(shí)體和關(guān)系抽出來(lái)存進(jìn) Neo4j 圖數(shù)據(jù)庫(kù)再把用戶(hù)一句「周星馳演過(guò)哪些評(píng)分高于 8 分的喜劇」翻譯成 Cypher 查詢(xún)最后把結(jié)果拼成自然語(yǔ)言返回。這條鏈路里知識(shí)圖譜負(fù)責(zé)語(yǔ)義結(jié)構(gòu)Neo4j 負(fù)責(zé)高效的多跳關(guān)系查詢(xún)問(wèn)答系統(tǒng)負(fù)責(zé)把自然語(yǔ)言映射到圖查詢(xún)。它適合誰(shuí)適合想入門(mén)知識(shí)圖譜構(gòu)建、想理解圖數(shù)據(jù)庫(kù)查詢(xún)、想做一個(gè)能演示的智能問(wèn)答系統(tǒng)的開(kāi)發(fā)者。難點(diǎn)不在模型多復(fù)雜而在實(shí)體關(guān)系設(shè)計(jì)是否合理、查詢(xún)模板是否覆蓋足夠多的問(wèn)法、以及 Neo4j 的導(dǎo)入和索引有沒(méi)有調(diào)對(duì)。下面按「先立住理論、再動(dòng)手復(fù)現(xiàn)、最后避坑」的順序拆開(kāi)講。2. 電影知識(shí)圖譜的本體設(shè)計(jì)與 Neo4j 建模2.1 先想清楚電影領(lǐng)域有哪些實(shí)體和關(guān)系知識(shí)圖譜的本體建模決定了后面能問(wèn)出什么問(wèn)題。電影領(lǐng)域常見(jiàn)的實(shí)體類(lèi)型有電影、演員、導(dǎo)演、編劇、類(lèi)型、制片公司、評(píng)分、用戶(hù)評(píng)論。關(guān)系類(lèi)型包括演員出演電影、導(dǎo)演執(zhí)導(dǎo)電影、電影屬于某類(lèi)型、電影獲得某評(píng)分、用戶(hù)評(píng)論電影。這里有個(gè)容易翻車(chē)的地方很多人把「評(píng)分」當(dāng)成電影的一個(gè)屬性而不是一個(gè)獨(dú)立節(jié)點(diǎn)。如果評(píng)分只是屬性那你就沒(méi)法問(wèn)「哪些電影的評(píng)分是由同一個(gè)用戶(hù)打出的」這類(lèi)問(wèn)題。常見(jiàn)做法是把評(píng)分做成關(guān)系屬性比如(用戶(hù))-[:RATED {score: 8.5}]-(電影)這樣既能查電影平均分也能查用戶(hù)評(píng)分行為。本體建模的產(chǎn)出通常是一張 ER 圖或本體圖。熱搜詞里有人搜「畫(huà)出電影評(píng)分與評(píng)價(jià)的 er 圖」說(shuō)明這一步是剛需。我一般會(huì)先用紙筆畫(huà)出節(jié)點(diǎn)和關(guān)系再轉(zhuǎn)成 Neo4j 的標(biāo)簽和關(guān)系類(lèi)型。注意 Neo4j 是屬性圖模型不是 RDF 三元組所以節(jié)點(diǎn)可以有屬性關(guān)系也可以有屬性。電影節(jié)點(diǎn)可以帶title、year、runtime演員節(jié)點(diǎn)帶name、birthday關(guān)系A(chǔ)CTED_IN可以帶role屬性表示角色名。2.2 Neo4j 社區(qū)版的安裝與最小配置Neo4j 社區(qū)版是免費(fèi)且夠用的選擇。安裝方式有幾種Windows 下直接下載 zip 解壓Linux 下用 tar.gz 離線包macOS 可以用 Homebrew。熱搜詞里「neo4j 安裝與配置」「neo4j linux 離線安裝包」「neo4j 安裝與配置 mac」都指向同一個(gè)痛點(diǎn)環(huán)境準(zhǔn)備。我一般推薦用 Docker 跑省去 JDK 版本匹配的麻煩。下面是一個(gè)最小可用的 docker-compose 配置version: 3.8 services: neo4j: image: neo4j:5.15-community container_name: movie-kg ports: - 7474:7474 # HTTP 瀏覽器界面 - 7687:7687 # Bolt 協(xié)議端口 environment: - NEO4J_AUTHneo4j/movie123456 # 默認(rèn)用戶(hù)名和密碼 - NEO4J_dbms_memory_heap_max__size2G # 堆內(nèi)存上限 - NEO4J_dbms_memory_pagecache_size1G # 頁(yè)緩存 volumes: - ./neo4j/data:/data - ./neo4j/logs:/logs - ./neo4j/import:/var/lib/neo4j/import這段配置做了三件事暴露瀏覽器和 Bolt 端口、設(shè)置認(rèn)證信息、掛載數(shù)據(jù)目錄防止容器重啟后數(shù)據(jù)丟失。參數(shù)說(shuō)明NEO4J_AUTH格式是用戶(hù)名/密碼密碼至少 8 位heap_max_size建議設(shè)為物理內(nèi)存的 25% 到 50%太小會(huì)導(dǎo)致導(dǎo)入大 CSV 時(shí) OOMpagecache_size用于緩存圖數(shù)據(jù)越大查詢(xún)?cè)娇?。啟?dòng)命令是docker-compose up -d然后瀏覽器打開(kāi)http://localhost:7474就能看到 Neo4j Browser。注意如果你在遠(yuǎn)程服務(wù)器上部署Neo4j 默認(rèn)只監(jiān)聽(tīng) localhost需要在neo4j.conf里把dbms.default_listen_address改成0.0.0.0否則會(huì)出現(xiàn)「neo4j 不能通過(guò) ip 訪問(wèn)」的問(wèn)題。改完記得重啟容器。2.3 用 Cypher 建約束和索引別等數(shù)據(jù)多了再補(bǔ)數(shù)據(jù)導(dǎo)入前先建約束和索引這是血淚經(jīng)驗(yàn)。電影名可能重復(fù)演員名也可能重復(fù)如果不加唯一約束導(dǎo)入時(shí)會(huì)產(chǎn)生重復(fù)節(jié)點(diǎn)。下面這段 Cypher 在 Neo4j Browser 里執(zhí)行// 電影標(biāo)題唯一約束 CREATE CONSTRAINT movie_title_unique IF NOT EXISTS FOR (m:Movie) REQUIRE m.title IS UNIQUE; // 演員姓名唯一約束 CREATE CONSTRAINT person_name_unique IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE; // 為電影年份建索引加速按年份過(guò)濾 CREATE INDEX movie_year_index IF NOT EXISTS FOR (m:Movie) ON (m.year); // 為類(lèi)型名建索引 CREATE INDEX genre_name_index IF NOT EXISTS FOR (g:Genre) ON (g.name);邏輯說(shuō)明約束不僅保證唯一性還會(huì)自動(dòng)創(chuàng)建對(duì)應(yīng)的索引所以movie_title_unique已經(jīng)能加速按標(biāo)題查電影。movie_year_index用于「2000 年以后的電影」這類(lèi)查詢(xún)。參數(shù)上Neo4j 5.x 的約束語(yǔ)法和 4.x 略有不同IF NOT EXISTS是冪等操作重復(fù)執(zhí)行不會(huì)報(bào)錯(cuò)。建完索引后可以用SHOW INDEXES查看狀態(tài)等狀態(tài)變成ONLINE再導(dǎo)入數(shù)據(jù)。3. 從 CSV 到圖數(shù)據(jù)庫(kù)數(shù)據(jù)導(dǎo)入與查詢(xún)模板3.1 準(zhǔn)備電影數(shù)據(jù)的 CSV 文件結(jié)構(gòu)數(shù)據(jù)來(lái)源可以是公開(kāi)電影數(shù)據(jù)集也可以自己爬。不管來(lái)源如何導(dǎo)入 Neo4j 前通常整理成幾個(gè) CSVmovies.csv、persons.csv、acted_in.csv、directed.csv、rated.csv。每個(gè) CSV 第一行是表頭。下面是一個(gè)acted_in.csv的示例movie_title,person_name,role 功夫,周星馳,星 功夫,元秋,包租婆 功夫,黃圣依,啞女 喜劇之王,周星馳,尹天仇 喜劇之王,張柏芝,柳飄飄movies.csv包含title,year,runtime,genrepersons.csv包含name,birthday。注意 CSV 文件要放到 Neo4j 的 import 目錄下Docker 部署時(shí)對(duì)應(yīng)掛載的./neo4j/import。文件編碼用 UTF-8避免中文亂碼。3.2 用 LOAD CSV 批量導(dǎo)入并建立關(guān)系導(dǎo)入分兩步先導(dǎo)入節(jié)點(diǎn)再導(dǎo)入關(guān)系。下面這段 Cypher 導(dǎo)入電影和演員節(jié)點(diǎn)// 導(dǎo)入電影節(jié)點(diǎn) LOAD CSV WITH HEADERS FROM file:///movies.csv AS row MERGE (m:Movie {title: row.title}) SET m.year toInteger(row.year), m.runtime toInteger(row.runtime) MERGE (g:Genre {name: row.genre}) MERGE (m)-[:BELONGS_TO]-(g); // 導(dǎo)入演員節(jié)點(diǎn) LOAD CSV WITH HEADERS FROM file:///persons.csv AS row MERGE (p:Person {name: row.name}) SET p.birthday row.birthday;邏輯說(shuō)明MERGE是「存在則匹配不存在則創(chuàng)建」配合唯一約束可以避免重復(fù)。toInteger把字符串轉(zhuǎn)成整數(shù)否則年份會(huì)變成字符串排序和比較會(huì)出錯(cuò)。file:///指向 import 目錄。導(dǎo)入關(guān)系// 導(dǎo)入出演關(guān)系 LOAD CSV WITH HEADERS FROM file:///acted_in.csv AS row MATCH (m:Movie {title: row.movie_title}) MATCH (p:Person {name: row.person_name}) MERGE (p)-[r:ACTED_IN]-(m) SET r.role row.role;參數(shù)說(shuō)明MATCH要求節(jié)點(diǎn)已存在所以必須先導(dǎo)入節(jié)點(diǎn)再導(dǎo)入關(guān)系。如果某個(gè)演員不在persons.csv里這條關(guān)系會(huì)被跳過(guò)不會(huì)報(bào)錯(cuò)但會(huì)丟數(shù)據(jù)。導(dǎo)入完成后用MATCH (n) RETURN count(n)檢查節(jié)點(diǎn)總數(shù)用MATCH ()-[r]-() RETURN count(r)檢查關(guān)系總數(shù)。3.3 把自然語(yǔ)言問(wèn)句映射成 Cypher 查詢(xún)模板問(wèn)答系統(tǒng)的核心是意圖識(shí)別加查詢(xún)模板。常見(jiàn)問(wèn)法有幾類(lèi)查某演員演過(guò)哪些電影、查某導(dǎo)演執(zhí)導(dǎo)的電影、查某類(lèi)型評(píng)分最高的電影、查兩個(gè)演員是否合作過(guò)。下面是一個(gè) Python 函數(shù)用簡(jiǎn)單規(guī)則匹配問(wèn)句并生成 Cypherimport re from neo4j import GraphDatabase driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, movie123456)) def answer_question(question): # 意圖1某演員演過(guò)哪些電影 m re.search(r(.?)演過(guò)哪些電影, question) if m: name m.group(1) cypher MATCH (p:Person {name: $name})-[:ACTED_IN]-(m:Movie) RETURN m.title AS title, m.year AS year ORDER BY m.year DESC with driver.session() as session: result session.run(cypher, namename) return [f{r[title]}{r[year]} for r in result] # 意圖2某類(lèi)型評(píng)分最高的電影 m re.search(r(.?)評(píng)分最高的電影, question) if m: genre m.group(1) cypher MATCH (m:Movie)-[:BELONGS_TO]-(g:Genre {name: $genre}) MATCH (u:User)-[r:RATED]-(m) RETURN m.title AS title, avg(r.score) AS avg_score ORDER BY avg_score DESC LIMIT 5 with driver.session() as session: result session.run(cypher, genregenre) return [f{r[title]}{r[avg_score]:.1f}分 for r in result] return [暫時(shí)沒(méi)有匹配到答案]邏輯說(shuō)明用正則做意圖識(shí)別雖然簡(jiǎn)單但覆蓋常見(jiàn)問(wèn)法足夠。參數(shù)$name和$genre是參數(shù)化查詢(xún)避免 Cypher 注入。ORDER BY和LIMIT控制返回?cái)?shù)量。如果問(wèn)句里出現(xiàn)「合作過(guò)」可以加一個(gè)查詢(xún)兩個(gè)演員共同出演電影的模板MATCH (p1:Person {name: $name1})-[:ACTED_IN]-(m:Movie)-[:ACTED_IN]-(p2:Person {name: $name2}) RETURN m.title AS title這個(gè)查詢(xún)利用了 Neo4j 的多跳關(guān)系能力從兩個(gè)演員節(jié)點(diǎn)出發(fā)通過(guò)電影節(jié)點(diǎn)連接。熱搜詞里「neo4j 查詢(xún)從一個(gè)節(jié)點(diǎn)出發(fā)如何查詢(xún)多條」說(shuō)的就是這類(lèi)模式從一個(gè)節(jié)點(diǎn)出發(fā)沿不同關(guān)系類(lèi)型擴(kuò)展再過(guò)濾。4. 問(wèn)答系統(tǒng)落地時(shí)最容易踩的五個(gè)坑4.1 坑一中文分詞和實(shí)體對(duì)齊沒(méi)做好問(wèn)句匹配不上現(xiàn)象用戶(hù)問(wèn)「星爺演過(guò)哪些電影」系統(tǒng)返回空結(jié)果因?yàn)閳D里存的是「周星馳」。原因沒(méi)有做別名映射。解決建一個(gè)別名表把「星爺」「周星星」映射到「周星馳」在意圖識(shí)別前先做實(shí)體歸一化。常見(jiàn)做法是用一個(gè)字典或小型的實(shí)體鏈接模塊。4.2 坑二Neo4j 內(nèi)存配置沒(méi)調(diào)導(dǎo)入大 CSV 直接卡死現(xiàn)象導(dǎo)入幾萬(wàn)行 CSV 時(shí)容器被 OOM kill。原因默認(rèn)堆內(nèi)存太小。解決在docker-compose.yml里把NEO4J_dbms_memory_heap_max__size調(diào)到 2G 以上同時(shí)用LOAD CSV時(shí)加CALL {} IN TRANSACTIONS分批提交避免單事務(wù)過(guò)大。4.3 坑三關(guān)系方向搞反查詢(xún)結(jié)果為空現(xiàn)象MATCH (m:Movie)-[:ACTED_IN]-(p:Person)查不到數(shù)據(jù)。原因?qū)霑r(shí)寫(xiě)的是(p)-[:ACTED_IN]-(m)方向反了。解決Neo4j 的關(guān)系有方向查詢(xún)時(shí)方向必須和導(dǎo)入一致。如果不確定方向可以用MATCH (a)-[r]-(b)不指定方向但性能會(huì)差一些。4.4 坑四沒(méi)有建索引多跳查詢(xún)慢到無(wú)法接受現(xiàn)象查「某演員合作過(guò)的演員」要好幾秒。原因Person.name沒(méi)有索引每次都要全圖掃描。解決對(duì)經(jīng)常作為查詢(xún)?nèi)肟诘膶傩越ㄋ饕热鏟erson.name、Movie.title、Genre.name。用EXPLAIN或PROFILE看執(zhí)行計(jì)劃確認(rèn)走了索引。4.5 坑五問(wèn)答模板覆蓋太窄用戶(hù)換個(gè)問(wèn)法就失效現(xiàn)象只有「演過(guò)哪些電影」能答「出演的電影」「參演作品」都答不了。原因正則模板太死。解決把意圖識(shí)別從純正則換成「正則加同義詞擴(kuò)展」或者用輕量級(jí)文本分類(lèi)模型。我一般會(huì)先收集 50 到 100 條真實(shí)問(wèn)法再歸納模板而不是拍腦袋寫(xiě)。5. 進(jìn)階技巧用 APOC 和全文索引提升問(wèn)答體驗(yàn)5.1 用 APOC 做路徑查詢(xún)和模糊匹配APOC 是 Neo4j 的常用過(guò)程庫(kù)社區(qū)版也能用。安裝方式是把 APOC 的 jar 包放到 plugins 目錄然后在neo4j.conf里加dbms.security.procedures.unrestrictedapoc.*。下面這個(gè)查詢(xún)用 APOC 找兩個(gè)演員之間的最短合作路徑MATCH (p1:Person {name: 周星馳}), (p2:Person {name: 吳孟達(dá)}) CALL apoc.algo.dijkstra(p1, p2, ACTED_IN, weight) YIELD path, weight RETURN path, weight參數(shù)說(shuō)明ACTED_IN表示沿ACTED_IN關(guān)系出方向遍歷weight是關(guān)系上的權(quán)重屬性如果沒(méi)有可以先用1.0代替。這個(gè)查詢(xún)能回答「周星馳和吳孟達(dá)通過(guò)哪些電影產(chǎn)生聯(lián)系」這類(lèi)問(wèn)題。5.2 用全文索引支持電影名模糊搜索用戶(hù)輸入「功夫」可能打錯(cuò)成「功父」用全文索引可以容錯(cuò)。創(chuàng)建全文索引CREATE FULLTEXT INDEX movieTitleFulltext IF NOT EXISTS FOR (m:Movie) ON EACH [m.title];查詢(xún)時(shí)用db.index.fulltext.queryNodesCALL db.index.fulltext.queryNodes(movieTitleFulltext, 功父) YIELD node, score RETURN node.title, scorescore是匹配得分可以按得分排序。全文索引對(duì)中文的支持取決于分詞器Neo4j 自帶的分詞器對(duì)中文效果一般常見(jiàn)做法是先用中文分詞工具把標(biāo)題切成詞再存入一個(gè)title_tokens屬性對(duì)title_tokens建全文索引。5.3 驗(yàn)證問(wèn)答系統(tǒng)是否可用的三個(gè)指標(biāo)做完之后怎么判斷系統(tǒng)能不能用我一般看三個(gè)指標(biāo)意圖識(shí)別準(zhǔn)確率、查詢(xún)返回率、平均響應(yīng)時(shí)間。意圖識(shí)別準(zhǔn)確率用 100 條標(biāo)注問(wèn)句測(cè)低于 80% 就要補(bǔ)模板查詢(xún)返回率指非空結(jié)果的比例低于 70% 說(shuō)明實(shí)體對(duì)齊或數(shù)據(jù)覆蓋有問(wèn)題平均響應(yīng)時(shí)間用PROFILE看超過(guò) 500 毫秒就要檢查索引。下面是一個(gè)簡(jiǎn)單的測(cè)試腳本test_questions [ 周星馳演過(guò)哪些電影, 喜劇評(píng)分最高的電影, 周星馳和吳孟達(dá)合作過(guò)哪些電影 ] for q in test_questions: ans answer_question(q) print(q, -, ans if ans else 無(wú)結(jié)果)跑完看哪些問(wèn)句返回空再針對(duì)性補(bǔ)模板或補(bǔ)數(shù)據(jù)。這個(gè)方案值不值得做如果你只是想理解知識(shí)圖譜和圖數(shù)據(jù)庫(kù)的配合它是最小閉環(huán)如果你想做成產(chǎn)品難點(diǎn)在實(shí)體對(duì)齊和問(wèn)句覆蓋需要持續(xù)迭代。我自己的習(xí)慣是先把圖建好、查詢(xún)跑通再考慮接前端或接大模型做自然語(yǔ)言生成。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取