這座 wiki 的價值在連結。頁跟頁之間互相引用,才會從一堆摘要變成一張網。
之前我們已經看過連結長什麼樣:寫在 Markdown 內文裡的 [[llm-wiki-pattern]]。但我要回答的問題是「有哪些頁連到這一頁」跟「整張圖長什麼樣」,中間需要一次轉換。
最直覺的做法是需要的時候再算。要畫圖譜就把所有筆記撈出來,每一篇用正規表示式抓出所有 [[…]],再去比對哪一頁對應到哪一篇。
能動,但成本是 O(N×M):N 篇筆記、每篇 M 個連結,每個連結還要再查一次對應的頁面。一個幾十頁的知識庫感覺不出來,幾百頁就開始卡,而且每次打開圖譜都要重算一遍。
反向連結(誰連到我)更糟,因為要掃過所有筆記才知道答案。
改成在寫入的時候就解析完畢,存進 links 表:
links(from_note_id, target, to_note_id)
這是欄位簡記,不是原始碼。from_note_id 與 target 是最初就有的,完整定義見 migrations/001_phase1_schema.sql:36-41(實際還有 workspace_id)。to_note_id 是後來一次遷移加上的欄位(migrations/004_links_resolution.sql:9-10),存解析出來的目標頁。之後圖譜就是一句 SELECT 走 links 表,反向連結是 WHERE to_note_id = ?,兩個都有索引。複雜度從 O(N×M) 降到 O(E),E 是邊的數量。
第一,連結可以指向還不存在的頁。 這是 Obsidian 的慣例,也很合理:你在寫東西的時候提到一個概念,順手連過去,那一頁之後再寫。所以 to_note_id 允許是 NULL,代表這條邊還沒有落點。
那一頁真的被建立時,寫入的路徑上要反向補洞:新頁一建好,就回頭把所有指向它的懸空連結接上,不用等下次重新掃描,圖譜立刻正確。
const candidates = [...new Set([n.path, noMd, noMd.replace(/^[^/]+\//, ''), n.basename, n.title])];
await c.query(
`UPDATE links SET to_note_id = $1
WHERE workspace_id = $2 AND to_note_id IS NULL AND from_note_id <> $1 AND target = ANY($3::text[])`,
[noteId, ws, candidates],
);
那串 candidates 是同一頁的五種寫法:完整路徑、去掉 .md、再去掉層級前綴、檔名、標題。懸空的邊只要 target 命中其中之一就接上。
這件事不只發生在新增。改標題也要補洞,因為標題本身就是一種合法的連結寫法。你把一頁從「LLM Wiki 模式」改名成「LLM Wiki」,舊標題寫的連結從此對不到頁,用新標題寫的懸空邊則該接上。
第二,同一個目標有好幾種寫法。 使用者可能寫 [[llm-wiki-pattern]]、[[wiki/concepts/llm-wiki-pattern]]、[[wiki/concepts/llm-wiki-pattern.md]],甚至直接寫頁面標題 [[LLM Wiki 模式]]。這些應該都指到同一頁。
解析規則按優先序:完整路徑 → 路徑加 .md → 三層前綴加 .md → 檔名 → 標題。
關鍵是這條規則只寫在一個地方,一支 SQL 裡:
WHERE n.workspace_id = $1 AND n.deleted_at IS NULL
AND (n.path = t.target
OR n.path = t.target || '.md'
OR n.path IN ('raw/' || t.target || '.md', 'wiki/' || t.target || '.md', 'schema/' || t.target || '.md')
OR n.basename = t.target
OR n.title = t.target)
ORDER BY (n.path = t.target) DESC, …
前端渲染連結時也要判斷同樣的事(這條連結有沒有對應的頁),所以前端有一份對應的實作(web/src/lib/links.ts)。這是刻意接受的重複,但註解裡寫明了規則以後端那支 SQL 為準,改的時候兩邊要一起改。
上線前的安全審查抓到一個我完全沒想到的問題:解析連結的正規表示式會被二次方成本打爆。
原本的樣式大致是 \[\[(.+?)\]\]。看起來沒問題,直到有人送進一份內容是八萬個連續 [[ 的筆記。回溯讓它跑了十四秒,而且是 CPU 全滿的十四秒。單執行緒的 Node 在那段時間什麼都做不了,等於一個人就能讓全站停擺。
修法是把中間那段從 .+? 改成明確排除的字元類別:
/\[\[([^\[\]|#\n]+)(?:[#|][^\[\]\n]*)?\]\]/g
排除掉 [、]、|、# 跟換行之後,任何一個 [[ 最多只會往前掃到下一個方括號就停。後半段那個可選群組順便處理了 [[頁面#段落|顯示文字]] 的寫法,只取真正的目標。另外加上單頁內容一 MB 的上限,建立與更新都擋。
任何會跑在使用者輸入上的正規表示式,都要假設有人會餵最壞的輸入給它。 這種問題不會在功能測試裡出現,因為正常內容永遠不會長那樣。
學術引用 [@mensh2017ten] 我也塞進同一套機制。解析的時候把它當成指向那個引用鍵的連結,引用鍵就是來源頁的檔名,所以走的是解析規則裡「檔名」那一條。pandoc 的多重引用 [@a; @b, p. 12] 也吃得下來,一則裡面有幾個鍵就產生幾條邊。於是:
連結是在寫入的那一刻就變成資料庫裡的邊,所以圖譜與反向連結都只是查一次 links 表。使用者手打的字串怎麼寫都行,五種寫法、懸空連結、改過的標題,都得在那一刻對到具體的頁,之後的查詢就不必再懂任何一種寫法。