網址處理完,剩下的是檔案:PDF、Word、HTML、Markdown、純文字,還有兩種書目格式。
把檔案讀進來不夠。書目資料躺在 front matter 裡沒有用,要能在 wiki 頁裡寫一個引用鍵、渲染出來變成(作者, 年份)、頁尾自動長出參考文獻,它才用得起來。
我用 unpdf 抽文字跟 metadata。各頁文字會併成一份,metadata 只用 Title 跟 Author,建立時間有抓到但沒接著用。
你拿到的是文字流,不是版面。兩欄排版的論文會變成兩欄交錯的一團,表格會變成一串沒有結構的數字,圖說會插在奇怪的地方。
對後續的 agent 編纂來說,文字順序稍微亂掉,還是比完全沒有這份資料有用。模型有能力從一團亂的文字裡抽出重點。要把版面還原,需要接專門的解析服務,我沒做。
掃描出來的 PDF 則完全沒轍,那是圖片,沒有文字層,需要 OCR。這條路現在還是會建成一頁,只是內文是空的。
標題先看 PDF 的 Title 欄,沒有才用內文第一行(八到一百六十字、開頭不是 arXiv、doi 或網址),再沒有才用檔名。metadata 常常是空的,有時候寫著「Microsoft Word - 未命名.doc」,Title 欄有字就照用,沒有另外過濾。另外會掃描前五千字找 DOI,找到就寫進 front matter。網頁匯入那條路找到 DOI 才會去 Crossref 補完整書目,上傳 PDF 這條不會。
mammoth 把 .docx 轉成 HTML,再走跟網頁一樣的 HTML 轉 Markdown 流程。這條路很順,因為 .docx 本來就有結構化的樣式資訊,標題就是標題、清單就是清單。
比 PDF 好處理得多,這也是我會建議使用者「如果同時有 .docx 和 PDF,丟 .docx」的原因。
這兩個是書目格式,處理方式跟其他檔案不同:一個檔案會產生很多頁,一筆文獻一頁。
BibTeX 的剖析我花最久,因為它的語法有很多歷史包袱:
@string 巨集:定義一個縮寫之後在別的地方引用,包括月份縮寫@comment 與 @preamble 要略過{The {LLM} Wiki} 裡面那層是用來保護大小寫的"Part " # "One"
\"{o} 是 ö、\'{e} 是 é,還有 \&、\% 這些跳脫Last, First and Last2, First2,要拆成陣列並且順序正確剖析器是一個手寫的掃描迴圈,略過巨集跟註解的地方長這樣:
if (type === 'string') { // @string{name = "value"}: later bare tokens expand to it
skipWs(); const sm = src.slice(i).match(/^([^=\s]+)\s*=/); if (sm) { i += sm[0].length; strings.set(sm[1].toLowerCase(), readValue()); }
const close = src.indexOf('\n@', i); i = close < 0 ? src.length : close + 1; continue;
}
if (type === 'comment' || type === 'preamble') { const close = src.indexOf('\n@', i); i = close < 0 ? src.length : close + 1; continue; }
CSL-JSON 相對乾淨,就是 JSON,主要處理的是 Zotero 匯出時欄位型別不一致的問題:同一個欄位有時候是字串有時候是數字,一律強制轉型。
匯入的行為是:一筆一頁,citation_key 已經存在的跳過(避免重複收藏),全部都跳過就回一個明確的訊息。回應裡會告訴你匯入幾筆、跳過幾筆,並附上一段可以直接用的提示詞,讓 agent 一次把這批新來源都編纂掉。
很多研究者的文獻本來就在 Zotero 裡,不會想重新匯入一次。
所以做了同步:使用者貼一把 Zotero 的 API key,選一個收藏夾,之後每小時增量同步一次。
export function scheduleZoteroSync(intervalMs = 3600_000): NodeJS.Timeout {
…
const { rows } = await pool.query<{ workspace_id: string }>(
`SELECT workspace_id FROM zotero_links WHERE last_error IS NULL OR last_sync_at < now() - interval '6 hours'`);
那句 WHERE 藏著一個判斷:上次失敗過的連結不會每小時重試,要等六小時。對方的 key 被撤銷或收藏夾被刪掉時,我不想每小時去敲一次別人的 API,然後在自己的日誌裡累積一整天的錯誤。Zotero 的項目轉成我們的來源頁,作者、年份、DOI、期刊、標籤都對應過來。如果它有用 Better BibTeX 產生引用鍵,就沿用,這樣兩邊的鍵一致。
預設會連 PDF 附件一起抓下來抽全文,接在摘要後面。可以關掉。
書目的真值留在 Zotero,我們這邊是唯讀的副本,只負責編纂。雙向同步要處理衝突、合併、刪除傳播,我沒做。單向比較簡單:Zotero 管文獻,這裡管理解。
反過來也支援匯出:把 raw/ 底下所有有引用鍵的頁面吐成 .bib 或 CSL-JSON,note 欄位記著它在知識庫裡的路徑。
匯入時我會小心處理別人的檔案,匯出時差點忘了自己也在產生別人要剖析的檔案。使用者的標題裡出現一個 & 或 %,寫進 .bib 就是一個壞掉的檔案,而壞掉的地方會在他把檔案餵給 pandoc 的時候才爆開,那時候他不會覺得是我這邊的問題。
所以有一份反向的跳脫表:
const LATEX_ESC: Record<string, string> = { '\\': '\\textbackslash{}', '{': '\\{', '}': '\\}', '&': '\\&', '%': '\\%', '#': '\\#', '_': '\\_' };
const textToLatex = (s: string) => s.replace(/[\r\n]+/g, ' ').replace(/[\\{}&%#_]/g, ch => LATEX_ESC[ch]);
還有換行也被換成空白,BibTeX 的欄位值裡塞換行,有些剖析器會直接放棄那一筆。
引用的寫法我直接抄 pandoc,因為學術圈的人本來就熟:
這個模式主張編纂而不是檢索 [@karpathy2026llmwiki]。
單一來源出版的三篇文獻 [@dunn2003single; @fauchie2023the] 也提到類似的取捨。
細節見原文的第三節 [@mensh2017ten, p. 12]。
渲染之後,[@karpathy2026llmwiki] 變成「(Karpathy, 2026)」,可以點,連到那份來源頁。頁面最下方自動出現參考文獻區塊,列出這一頁引用過的所有文獻,格式是作者、年份、標題、期刊、DOI。
作者的縮寫規則照學術慣例,一行就寫完了:
// (author, year): one author Chen; two Chen & Lin; three or more Chen et al.
export function citeLabel(e: BibEntry): string {
const fam = e.authors.map(familyName);
const who = fam.length === 0 ? e.title.slice(0, 24) : fam.length === 1 ? fam[0] : fam.length === 2 ? `${fam[0]} & ${fam[1]}` : `${fam[0]} et al.`;
第一個分支是給沒有作者的來源用的:網頁、報告、法規常常查不到作者,這時候退回標題的前二十四個字,至少讀者看得出引用的是什麼,而不是一個空括號。
解析引用鍵、對應到來源、渲染成(作者, 年份)、在頁尾長出參考文獻、引用到不存在的來源要被抓出來:這些事聽起來各要一套機制。
之前我們已經把 [@key] 併進連結的解析,引用就是一條指向該引用鍵的邊,跟 [[連結]] 存在同一張表、走同一套解析規則。所以待編纂的判斷、反向連結、圖譜、健檢,全部是既有的查詢,一行都不用改。
新寫的只有兩個:渲染時怎麼把引用鍵變成(作者, 年份),以及頁尾那份參考文獻怎麼組出來。
功能做好了,但 agent 不會自己知道要用這個語法。
處理方式是改研究者模版的規則頁,明確寫出:引用來源時使用 [@citation_key],不要用括號寫作者年份,也不要只寫連結。因為規則放在 schema/ 而不是系統提示詞裡,使用者也能自己調整成他習慣的格式。
對話功能的系統提示詞也一起改了,回答問題時如果引用到有書目的來源,就用同樣的語法。
引用定在一個鍵上。wiki 頁寫 [@karpathy2026llmwiki],旁邊出現(Karpathy, 2026);規則頁寫死這一種,agent 不會自己用括號編一個作者年份。兩邊點進去是同一份來源,那句話才對得到來源裡寫的。