用 Nebula Graph 破解成語版 Wordle 謎底

如果有小伙伴玩過 Wordle 這個火爆社交媒體的猜詞游戲,可能對成語版本的漢兜有所耳聞。在玩漢兜過程中,我發現用 Nebula Graph 的圖查詢來解 Antfu 的漢兜(中文成語版 Wordle)會是件特別有意思的事情,很適合當作圖數據庫語句的實操。在本文中,你將了解我是如何用知識圖譜“作弊”解漢兜。????
漢兜(https://handle.antfu.me )是由 Vue/Vite 核心團隊成員的 Antfu 的又一個非常酷的作品,一個非常精致的漢字版的 Wordle,它是一個每日挑戰的填字游戲的中文成語版。
每天,漢兜會發起一個猜成語挑戰,人們要在十次內猜對對應成語才能獲勝,每一步之后都會收到相應的文字、聲母、韻母、聲調的匹配情況的提示,其中:綠色表示這個因素存在并且位置匹配、橘色表示這個元素存在但是位置不對,詳細的規則可見如下的網頁截圖:
漢兜的樂趣在于在有限的嘗試次數中,在大腦中搜尋可能的答案,不斷地去逼近真理,任何試圖作弊、討巧去泄漏結果的行為都是很無趣、倒胃口的(比如從開源的漢兜代碼里竊取信息),這個過程就像大腦做了個體操。
說到大腦的成語詞匯量體操,我突然想到,為什么我們不能在大腦之外造一個漢語成語知識圖譜,然后基于這個圖譜實操一把圖數據庫,做個圖查詢體操呢?
什么是知識圖譜?
簡單來說,知識圖譜是一個連接實體之間關聯關系的網絡,它最初由 Google 提出并用來滿足搜索引擎中基于知識推理才可獲得(而不是網頁倒排索引)的搜索問題,比如:”姚明妻子的年齡?“、”火箭隊得過幾次總冠軍?“
這些問題里邊,我們關注問題中的條件。到 2022 年的現在,知識圖譜已經被廣泛應用在推薦系統、問答系統、安全風控等等更多搜索之外的領域。
為什么需要用知識圖譜解決漢兜?
原因就是:because I can
實際上,我們在大腦中解決字謎游戲的過程像極了圖譜網絡中的信息搜尋的過程,漢兜的解謎反饋提示條件天然適合被用圖譜的語義來進行表達。在本文后邊,你們會發現解謎條件翻譯成圖語義是非常自然的,這個問題就像是一個天然的為圖譜而存在的練習一樣,我相信這和知識圖譜的結構和人腦中的知識結構接近有很大的關系。
如何構建面向漢兜解謎的知識圖譜?
知識圖譜是由實體(頂點)和關系(邊)組成的,用圖數據庫管理系統(Graph Database MS)可以很方便地進行知識的入庫、更改、查詢、甚至可視化探索。
在本文里,我將利用開源的分布式圖數據庫 Nebula Graph 實踐這個過程,具體圖譜系統的搭建我都會放在文末。
在本章,我們只討論圖譜的建模:如何面向漢兜的解謎去設計“實體”與“關系”。
圖建模
最初的想法
首先,一定存在的實體是:
- 成語
- 漢字
- 成語-[包含]->漢字,每個漢字-[讀作]->讀音。
其次,因為解謎過程中涉及到了聲母、韻母以及聲調的條件,考慮到圖譜本身的量級非常?。ㄇЪ墑e),而且字的讀音是一對多的關系,我把讀音和聲母(包涵聲母-initial和韻母-final)也作為實體,他們之間的關系則是順理成章了:
最終的版本
然而,我在后邊基于圖譜進行查詢的時候發現最初的建模會使得 (成語)–>(字)–>(讀音)
查詢過程中丟失了這個字特定的讀法的條件,所以我最終的建模是:
這樣,純文字的條件只涉及了 (成語)-->(字)
這一跳,而讀音、聲母、聲調的條件則是另一條關系路徑,既沒有最初版本條件的冗余,又可以在一個路徑模式匹配里帶上兩種條件(后邊的例子里會涉及這樣的表達)。
構建成語知識圖譜
有了建模、這么簡單的圖譜的構建就剩下了數據的收集、清洗和入庫。
對于所有成語數據和他們的讀音,我一方面直接抽取了漢兜代碼內部的數據,另一方面利用 PyPinyin 這個開源的 Python 庫將漢兜數據中沒有讀音的數據獲得讀音,同時,我也用到了 PyPinyin 里的很多方便的函數,比如:獲取一個拼音的聲母、韻母。
至此,我假設咱們都已經有了我幫大家搭建的成語作弊知識圖譜了,開始我們的圖譜查詢體操吧!
首先,打開漢兜
假設我們想從一個成語開始,如果你沒有想法的話可以試試這個:
# 匹配成語中的一個結果
MATCH (x:idiom) "愛憎分明" RETURN x LIMIT 1
# 返回結果
("愛憎分明" :idiom{pinyin: "['ai4', 'zeng1', 'fen1', 'ming2']"})
然后我們把它填到漢兜之中,獲得第一次嘗試的提示條件:
我們運氣不錯,得到了三個位置上的條件!
- 有一個非第一個位置的字,拼音是 4 聲,韻母是 ai,但不是愛(愛)
- 有一個一聲的字,不在第二個位置(憎)
- 有一個字韻母是 ing,不在第四個位置(明)
- 第四個字是二聲(明)
下面,我們開始圖數據庫語句體操!
# 有一個非第一個位置的字,拼音是 4 聲,韻母是 ai,但不是愛
MATCH (char0:character)<-[with_char_0:with_character]-(x:idiom)-[with_pinyin_0:with_pinyin]->(pinyin_0:character_pinyin)-[:with_pinyin_part]->(final_part_0:pinyin_part{part_type: "final"})
WHERE id(final_part_0) == "ai" AND pinyin_0.character_pinyin.tone == 4 AND with_pinyin_0.position != 0 AND with_char_0.position != 0 AND id(char0) != "愛"
# 有一個一聲的字,不在第二個位置
MATCH (x:idiom) -[with_pinyin_1:with_pinyin]->(pinyin_1:character_pinyin)
WHERE pinyin_1.character_pinyin.tone == 1 AND with_pinyin_1.position != 1
# 有一個字韻母是 ing,不在第四個位置
MATCH (x:idiom) -[with_pinyin_2:with_pinyin]->(:character_pinyin)-[:with_pinyin_part]->(final_part_2:pinyin_part{part_type: "final"})
WHERE id(final_part_2) == "ing" AND with_pinyin_2.position != 3
# 第四個字是二聲
MATCH (x:idiom) -[with_pinyin_3:with_pinyin]->(pinyin_3:character_pinyin)
WHERE pinyin_3.character_pinyin.tone == 2 AND with_pinyin_3.position == 3
RETURN x, count(x) as c ORDER BY c DESC
在圖數據庫之中運行,得到了 7 個答案:
("驚愚駭俗" :idiom{pinyin: "['jing1', 'yu2', 'hai4', 'su2']"})
("驚世駭俗" :idiom{pinyin: "['jing1', 'shi4', 'hai4', 'su2']"})
("驚見駭聞" :idiom{pinyin: "['jing1', 'jian4', 'hai4', 'wen2']"})
("沽名賣直" :idiom{pinyin: "['gu1', 'ming2', 'mai4', 'zhi2']"})
("驚心駭神" :idiom{pinyin: "['jing1', 'xin1', 'hai4', 'shen2']"})
("荊棘載途" :idiom{pinyin: "['jing1', 'ji2', 'zai4', 'tu2']"})
("出賣靈魂" :idiom{pinyin: "['chu1', 'mai4', 'ling2', 'hun2']"})
看起來“驚世駭俗“比較主流,試試!
我們很幸運,借助于成語作弊知識圖譜,居然一次就找到了答案,當然這實際上得益于第一次隨機選取的詞帶來的限制條件的個數,不過在大部分情況下,兩次嘗試獲得最終答案的可能性還是非常大的!
注,這中間很長的 253 分鐘是因為我在查詢中發現之前代碼里構造的圖譜有點 bug,是“披枷帶鎖”這個詞引起的讀音圖譜的錯誤數據,還好后來被修復了。
大家知道“披枷帶鎖”的正確讀音么?????
回題,我給大家詳細解釋一下這個成語破解的過程。
語句的含義
我們從第一個字的條件開始,這是一個既有聲音、又有字形信息的條件。
- 聲音信息:存在一個韻母為 ai4 的發音,位置不在第一個字
- 文字信息:這個韻母為 ai4 的字,不是愛字
對于聲音信息條件,轉換為圖模式匹配為:(成語)-一個字發音-(拼音)-包含聲母-(韻母) WHERE 拼音韻母為 ai4 AND 位置不是第一個
。
因為建模的時候,屬性名稱我用的是英文(其實中文也是支持的),實際上的語句為:
# 有一個非第一個位置的字,拼音是 4 聲,韻母是 ai
MATCH (x:idiom)-[with_pinyin_0:with_pinyin]->(pinyin_0:character_pinyin)-[:with_pinyin_part]->(final_part_0:pinyin_part{part_type: "final"})
WHERE id(final_part_0) == "ai" AND pinyin_0.character_pinyin.tone == 4 AND with_pinyin_0.position != 0
# ...
RETURN x
類似的,表示非第一個位置的字,不是愛
的表達是:
# 有一個非第一個位置的字,拼音是 4 聲,韻母是 ai,但不是愛
MATCH (char0:character)<-[with_char_0:with_character]-(x:idiom)
WHERE with_char_0.position != 0 AND id(char0) != "愛"
# ...
RETURN x, count(x) as c ORDER BY c DESC
而因為這兩個條件最終描述的是同一個字,所以它們是可以被寫在一個路徑下的:
# 有一個非第一個位置的字,拼音是 4 聲,韻母是 ai,但不是愛
MATCH (char0:character)<-[with_char_0:with_character]-(x:idiom)-[with_pinyin_0:with_pinyin]->(pinyin_0:character_pinyin)-[:with_pinyin_part]->(final_part_0:pinyin_part{part_type: "final"})
WHERE id(final_part_0) == "ai" AND pinyin_0.character_pinyin.tone == 4 AND with_pinyin_0.position != 0 AND with_char_0.position != 0 AND id(char0) != "愛"
# ...
RETURN x
更多的 MATCH 語法和例子細節,請大家參考文檔;
我們把每一個條件的匹配路徑作為輸出,利用 Nebula Graph 的可視化能力,可以得到:
# 有一個非第一個位置的字,拼音是 4 聲,韻母是 ai,但不是愛 # 有一個非第一個位置的字,拼音是 4 聲,韻母是 ai,但不是愛
MATCH p0=(char0:character)<-[with_char_0:with_character]-(x:idiom)-[with_pinyin_0:with_pinyin]->(pinyin_0:character_pinyin)-[:with_pinyin_part]->(final_part_0:pinyin_part{part_type: "final"})
WHERE id(final_part_0) == "ai" AND pinyin_0.character_pinyin.tone == 4 AND with_pinyin_0.position != 0 AND with_char_0.position != 0 AND id(char0) != "愛"
# 有一個一聲的字,不在第二個位置
MATCH p1=(x:idiom) -[with_pinyin_1:with_pinyin]->(pinyin_1:character_pinyin)
WHERE pinyin_1.character_pinyin.tone == 1 AND with_pinyin_1.position != 1
# 有一個字韻母是 ing,不在第四個位置
MATCH p2=(x:idiom) -[with_pinyin_2:with_pinyin]->(:character_pinyin)-[:with_pinyin_part]->(final_part_2:pinyin_part{part_type: "final"})
WHERE id(final_part_2) == "ing" AND with_pinyin_2.position != 3
# 第四個字是二聲
MATCH p3=(x:idiom) -[with_pinyin_3:with_pinyin]->(pinyin_3:character_pinyin)
WHERE pinyin_3.character_pinyin.tone == 2 AND with_pinyin_3.position == 3
RETURN p0,p1,p2,p3
在可視化工具的 Console 控制臺里執行上邊的語句之后,選擇導入圖探索,就可以看到:
如果大家是從本文第一次了解到 Nebula Graph 圖數據庫,那么大家可以下一步從 Nebula Graph 項目和 Nebula Graph 社區的官方 Bilibili 站點了解更多有意思的入門知識。
另外,Nebula 在線演示是 Nebula Graph 的官方線上試玩環境,大家可以照著文檔,利用試玩環境嘗鮮。
后邊,Nebula Graph 會開展每天的漢兜 nGQL 體操活動,敬請關注哈!
Happy Graphing!
收集、生成圖譜數據
$ python3 graph_data_generator.py
導入數據到 Nebula Graph 圖數據庫
部署圖數據庫
借助于 Nebula-Up,一行就可以了。
$ curl -fsSL nebula-up.siwei.io/install.sh | bash -s -- v3.0.0
部署成功的話,會看到這樣的結果:
┌────────────────────────────────────────┐
│ ???? Nebula-Graph Playground is Up now! │
├────────────────────────────────────────┤
│ │
│ ???? Congrats! Your Nebula is Up now! │
│ $ cd ~/.nebula-up │
│ │
│ ???? You can access it from browser: │
│ http://127.0.0.1:7001 │
│ http://<other_interface>:7001 │
│ │
│ ???? Or access via Nebula Console: │
│ $ ~/.nebula-up/console.sh │
│ │
│ To remove the playground: │
│ $ ~/.nebula-up/uninstall.sh │
│ │
│ ???? Have Fun! │
│ │
└────────────────────────────────────────┘
圖譜入庫
借助于 Nebula-Importer,一行就可以了。
$ docker run --rm -ti \
--network=nebula-docker-compose_nebula-net \
-v ${PWD}/importer_conf.yaml:/root/importer_conf.yaml \
-v ${PWD}/output:/root \
vesoft/nebula-importer:v3.0.0 \
--config /root/importer_conf.yaml
大概一兩分鐘數據就導入成功了,命令也會正常退出。
連到圖數據庫的 Console
獲得本機第一個網卡的地址,這里是 10.1.1.168
$ ip address
2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 2a:32:4c:06:04:c4 brd ff:ff:ff:ff:ff:ff
inet 10.1.1.168/24 brd 10.1.1.255 scope global dynamic enp4s0
進入 Console 的容器執行下邊的命令:
$ ~/.nebula-up/console.sh
# nebula-console -addr 10.1.1.168 -port 9669 -user root -p nebula
檢查一下導入的數據:
(root@nebula) [(none)]> show spaces
+--------------------+
| Name |
+--------------------+
| "chinese_idiom" |
+--------------------+
(root@nebula) [(none)]> use chinese_idiom
Execution succeeded (time spent 1510/2329 us)
Fri, 25 Feb 2022 08:53:11 UTC
(root@nebula) [chinese_idiom]> match p=(成語:idiom) return p limit 2
+------------------------------------------------------------------+
| p |
+------------------------------------------------------------------+
| <("一丁不識" :idiom{pinyin: "['yi1', 'ding1', 'bu4', 'shi2']"})> |
| <("一絲不掛" :idiom{pinyin: "['yi1', 'si1', 'bu4', 'gua4']"})> |
+------------------------------------------------------------------+
(root@nebula) [chinese_idiom]> SUBMIT JOB STATS
+------------+
| New Job Id |
+------------+
| 11 |
+------------+
(root@nebula) [chinese_idiom]> SHOW STATS
+---------+--------------------+--------+
| Type | Name | Count |
+---------+--------------------+--------+
| "Tag" | "character" | 4847 |
| "Tag" | "character_pinyin" | 1336 |
| "Tag" | "idiom" | 29503 |
| "Tag" | "pinyin_part" | 57 |
| "Edge" | "with_character" | 116090 |
| "Edge" | "with_pinyin" | 5943 |
| "Edge" | "with_pinyin_part" | 3290 |
| "Space" | "vertices" | 35739 |
| "Space" | "edges" | 125323 |
+---------+--------------------+--------+
CREATE SPACE IF NOT EXISTS chinese_idiom(partition_num=5, replica_factor=1, vid_type=FIXED_STRING(24));
USE chinese_idiom;
# 創建點的類型
CREATE TAG idiom(pinyin string); #成語
CREATE TAG character(); #漢字
CREATE TAG character_pinyin(tone int); #單字的拼音
CREATE TAG pinyin_part(part_type string); #拼音的聲部
# 創建邊的類型
CREATE EDGE with_character(position int); #包含漢字
CREATE EDGE with_pinyin(position int); #讀作
CREATE EDGE with_pinyin_part(part_type string); #包含聲部
