@jungtz/wiki-router
v5.1.0
Published
Deterministic Wiki 知識庫框架 — 把結構化來源轉成 markdown wiki, 多租戶 + plugin 化
Maintainers
Readme
@jungtz/wiki-router
把結構化來源(CMS API / JSON / 任何資料)轉成 markdown wiki,多租戶儲存,並提供 getContext 給下游使用(例如 chatbot 的 RAG context)。
想直接導入到自己的專案? 看 INTEGRATION.md(給 AI agent 或工程師的整合指南,含完整 templates)。本檔是 API 參考。
由你寫 builder 函式決定怎麼把 source 轉成 markdown,套件負責 fingerprint 比對、transaction、manifest、多租戶協調。
source ─→ build() ─→ getContext(prompt) ─→ retriever ─→ 組合好的 wiki 內容為什麼用這個套件
- 多租戶 ready:N 家旅館 / N 個專案 / N 個使用者各有自己的 wiki,內建狀態追蹤
- fingerprint 短路:source 沒變就不重 build
- transaction safety:build 失敗自動 rollback,不留半套狀態
- plugin 化:builder / retriever 兩個擴充點
- Deterministic:沒有 LLM 依賴,相同輸入永遠相同輸出
安裝
npm install @jungtz/wiki-router
# 用 sqlite 儲存 (推薦):
npm install sqlite3 sqlite最小範例
import { createWikiRouter } from '@jungtz/wiki-router'
const wiki = createWikiRouter({
knowledgeDir: './knowledge', // 從目錄讀 JSON / md
outputDir: './wiki-output', // 輸出到目錄
// 必填:自己決定怎麼把 source 轉成 store 的 .md
builder: async ({ source, store }) => {
const keys = await source.list()
for (const k of keys) {
const { content } = await source.read(k)
await store.write(`${k}.md`, `# ${k}\n\n${content}`)
}
return { ok: true, filesWritten: keys.length }
},
})
await wiki.build()
const ctx = await wiki.getContext('使用者問題')
// ctx = "\n[Wiki 相關參考資料]:\n\n--- 檔案: ... ---\n..."多租戶範例
import { createTenantManager, sqliteStore, ensureWikiTables } from '@jungtz/wiki-router'
import sqlite3 from 'sqlite3'
import { open } from 'sqlite'
const db = await open({ filename: './wiki.db', driver: sqlite3.Database })
await ensureWikiTables(db)
const manager = createTenantManager({
source: tenantId => myCmsSource(tenantId),
store: tenantId => sqliteStore({ db, tenantId }),
listTenants: () => myDb.getAllHotelIds(),
builder: async ({ source, store, tenantId }) => {
const keys = await source.list()
for (const k of keys) {
const { content } = await source.read(k)
await store.write(`${k}.md`, renderForHotel(content))
}
return { ok: true, filesWritten: keys.length }
},
retriever: 'concat-all', // 預設值,把所有 .md 串接回傳
})
// 啟動時預熱
await manager.buildAll()
// 服務時
const ctx = await manager.getContext('使用者問題', 'hotel-uuid-1')API
createWikiRouter(config)
建立單一 wiki 實例。
interface WikiRouterConfig {
// 來源 / 儲存 (至少設一組)
source?: Source
store?: Store
knowledgeDir?: string // fsSource 簡寫
outputDir?: string // fsStore 簡寫
// 兩大 plugin point
builder: BuilderFn // 必填
retriever?: 'concat-all' | 'graph-rag' | RetrieverFn // 預設 'concat-all'
// Embedding (graph-rag 用)
embedder?: (texts: string[]) => Promise<number[][]> // 自接 OpenAI / Ollama / 自託管
topK?: number // graph-rag 種子數, 預設 5
graphRagDepth?: number // wikilink 擴張跳數, 預設 1
// 多租戶下由 createTenantManager 自動注入
tenantId?: string
logger?: Logger
throwErrors?: boolean
}回傳:
interface WikiRouterInstance {
build(options?: { force?: boolean }): Promise<boolean>
getContext(prompt: string, options?: { maxChars?: number; topK?: number; graphRagDepth?: number }): Promise<string>
getRelated(filename: string, opts?: { direction?: 'out' | 'in' | 'both'; depth?: number }): Promise<string[]>
getFrontmatter(filename: string): Promise<Record<string, any> | null>
}createTenantManager(config)
多租戶版本,內含 wikiCache / buildPromises / builtTenants 追蹤。
interface TenantManagerConfig {
source: (tenantId: string) => Source
store: (tenantId: string) => Store
listTenants?: () => string[] | Promise<string[]> // buildAll 需要
builder: BuilderFn
retriever?: 'concat-all' | RetrieverFn
logger?: Logger
}回傳:
interface TenantManager {
build(tenantId, options?): Promise<boolean>
buildAll(): Promise<PromiseSettledResult<boolean>[]>
getContext(prompt, tenantId, options?): Promise<string>
getRelated(tenantId, filename, opts?): Promise<string[]>
getFrontmatter(tenantId, filename): Promise<Record<string, any> | null>
isBuilt(tenantId): boolean
listBuilt(): string[]
}getContext 在 tenant 還沒 build 過時回空字串(不阻塞)。建議啟動時跑 buildAll(),或在請求進來時觸發 build(tenantId) 預熱。
Plugin 簽名
Builder
type BuilderFn = (ctx: {
source: Source
store: Store
tenantId?: string
log: Logger
options: { force?: boolean }
/** 相對上次 build 新增或變動的 source key (排序); 首次 build 或 source 無 fingerprint 時等同全部 */
changedKeys: string[]
/** 上次 build 存在、本次 source 已移除的 key */
removedKeys: string[]
/** 是否首次 build (沒有 stored manifest) */
isInitialBuild: boolean
}) => Promise<{ ok: boolean; filesWritten?: number; error?: string }>Incremental build:builder 可只處理 changedKeys 跳過未變動檔;舊 builder 忽略此欄位即維持全量行為(向後相容)。
框架在呼叫 builder 前後會自動處理:
- 用
source.getFingerprint()比對store.readManifest(),相同就跳過 build(除非force: true) - 開啟 transaction(
store.beginTransaction()) - 呼叫你的 builder
- builder 回傳
{ ok: true }→ 寫 manifest + commit;失敗 → rollback
所以 builder 本身只要:抓資料 → render → 用 store.write() 落地。不需要管 transaction、fingerprint、manifest。
Retriever
type RetrieverFn = (ctx: {
prompt: string
store: Store
tenantId?: string
log: Logger
}) => Promise<string>內建 'concat-all':把 store 內所有 .md(排除 Index.md 和 _ 開頭衍生檔)串接後回傳,格式:
\n[Wiki 相關參考資料]:\n
\n--- 檔案: A.md ---\n<內容>\n
\n--- 檔案: B.md ---\n<內容>\ngetContext 可傳 { maxChars }:concat-all 超過上限會跳過後續檔案並 log warn。自訂 retriever 從 ctx 拿到 maxChars 自行處理。
'graph-rag' (內建, 需要 embedder)
embedding top-k 取種子 → 沿 wikilink 圖擴張 → 依 maxChars 截斷組裝。
import { createWikiRouter } from '@jungtz/wiki-router'
async function openaiEmbedder(texts) {
const res = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.OPENAI_KEY}` },
body: JSON.stringify({ model: 'text-embedding-3-small', input: texts }),
})
const data = await res.json()
return data.data.map(d => d.embedding)
}
const wiki = createWikiRouter({
knowledgeDir: './knowledge',
outputDir: './wiki-output',
builder,
retriever: 'graph-rag',
embedder: openaiEmbedder,
topK: 5, // 預設
graphRagDepth: 1, // 預設
})
await wiki.build() // build 後框架自動 embed 變動的檔
const ctx = await wiki.getContext('營業時間?', { maxChars: 8000 })增量 embed:框架在 build 結束時掃 store 內 .md 算 content hash,比對 _embed_manifest.json,只對新增/變動的檔呼叫 embedder;source 消失的檔會呼叫 store.removeEmbedding。Embedder 失敗只 log warn,不會拖垮 build。
Store 需求:retriever 需要 store.queryNearest(vec, k),增量機制需要 store.writeEmbedding / removeEmbedding。內建 fsStore / sqliteStore / mysqlStore 都已實作。其他 store(Postgres / 自訂)只要實作這三個 optional 方法即可。框架 export cosineNearest(queryVec, candidates, k) helper,只想存 raw vec 的 adapter 可以直接拿來算。
Gemini embedder (內建)
不想自己 wrap REST 的話, 套件已內建 Gemini embedder 工廠:
import { createGeminiEmbedder, createTenantManager } from '@jungtz/wiki-router'
const embedder = createGeminiEmbedder({
apiKey: process.env.GEMINI_API_KEY,
model: 'gemini-embedding-001', // 必填; 強制顯式選擇避免誤上 token
outputDimensionality: 768, // 預設 768 (MRL 截斷, 省儲存)
// 其他 optional: maxChars, batchSize, taskType, endpoint, fetch
})
createTenantManager({
...,
embedder,
retriever: 'graph-rag',
})走 REST batchEmbedContents endpoint, 不相依 @google/generative-ai SDK。
MySQL adapter (5.1.0+)
import mysql from 'mysql2/promise'
import { mysqlStore, ensureMysqlWikiTables, createTenantManager } from '@jungtz/wiki-router'
const pool = mysql.createPool({ host, user, password, database })
await ensureMysqlWikiTables(pool)
createTenantManager({
source: tid => mySource(tid),
store: tid => mysqlStore({ pool, tenantId: tid }),
builder,
})跟 sqliteStore 完整對齊: 三張表 wiki_files / wiki_manifests / wiki_embeddings 自動建立, transaction 走獨立 connection。需要自訂表名可傳 tables: { files, manifests, embeddings }。
Observability (5.1.0+)
監測每次 getContext 回傳的 context 體積, 判斷何時該升級 retriever:
createTenantManager({
...,
onContextSize: ({ tenantId, chars, retriever }) => {
// 寫 DB / metrics / log 都行
db.run('UPDATE stats SET max=? WHERE id=? AND max<?', chars, tenantId, chars)
},
})event.retriever 為 'concat-all' | 'graph-rag' | 'custom'。Callback 異常被框架接住, 不影響 getContext 回傳。
Frontmatter
Builder 可以用物件形式寫 YAML frontmatter 到檔頂:
await store.write('Rooms.md', {
content: '# Rooms\n\n房型介紹...',
frontmatter: { type: 'room', tags: ['suite', 'standard'], count: 12 },
})落地內容:
---
type: room
tags: [suite, standard]
count: 12
---
# Rooms
房型介紹...讀回:
const fm = await wiki.getFrontmatter('Rooms.md')
// → { type: 'room', tags: ['suite', 'standard'], count: 12 }支援型別:string / number / boolean / null / array of primitives。不支援巢狀物件或多行字串——若需要請自行 escape 或寫純字串。
Retriever 可以用 parseFrontmatter(await store.read(f)) 拿到 { frontmatter, body },做 metadata 過濾或 type-aware 路由。
Wikilink 圖
Build 後框架會掃過所有 .md 抽出 [[target]] 連結,將鄰接表寫入 _links.json(底線開頭,retriever 預設排除)。
支援語法:
[[Rooms]]→ 目標Rooms[[Rooms.md]]→ 目標Rooms(自動去.md)[[Rooms|顯示文字]]→ 目標Rooms(取|前)
解析後若 <target>.md 不在 store 中,視為未解析連結並丟棄。
await wiki.build()
await wiki.getRelated('Rooms.md')
// → ['Hours.md', 'Address.md'] (Rooms.md 向外連的)
await wiki.getRelated('Index.md', { direction: 'in' })
// → 連向 Index.md 的所有檔
await wiki.getRelated('Rooms.md', { depth: 2 })
// → 一跳 + 兩跳鄰居 (BFS, 自動去重)注意:解析不感知 markdown 結構,程式碼區塊內的 [[…]] 也會被掃到。Builder 端如有特殊需求請自行 escape。
視覺化
_links.json 是標準鄰接表,直接餵給現成工具即可,框架不附視覺化。
轉成 Mermaid(GitHub / markdown 原生渲染):
import fs from 'fs'
const graph = JSON.parse(fs.readFileSync('./wiki-output/_links.json', 'utf-8'))
const lines = ['graph LR']
for (const [src, targets] of Object.entries(graph)) {
for (const t of targets) lines.push(` ${src.replace('.md', '')} --> ${t.replace('.md', '')}`)
}
console.log('```mermaid\n' + lines.join('\n') + '\n```')用 Obsidian 開:若 store 是 fsStore,輸出目錄本身就是個合法的 Obsidian vault——直接 "Open folder as vault",內建 Graph View 會讀 .md 內的 [[…]](不需要 _links.json)。
前端 D3 / Cytoscape:把 _links.json 轉成各自的 nodes/edges 格式餵入即可。
內建工具函式
給 builder 用的 helpers,從套件直接 import:
| 函式 | 用途 |
|---|---|
| parseQuillDelta(str) | Quill Delta JSON → 純文字 / markdown |
| flattenLangFields(node, lang) | 攤平多語物件 {zh_tw, en, ja} 到指定語系 |
| canonicalJson(v) | 對 JSON 產生 key-排序的字串(給 hash 用) |
| hashCanonical(v) | 算 SHA-256 hex(適合 source.getFingerprint()) |
| asText(v) | 任意值 → 字串(自動 parse Quill / strip HTML) |
| kv(label, v) | 生成 - **label**:value,空值回空字串 |
| joinLines(...parts) | 攤平 + 過濾空值 + \n 串接 |
| formatElapsed(startMs) | 毫秒 → "123ms" / "1.23s" / "2m3.4s" |
| fingerprintsEqual(a, b) | 比對兩個 fingerprint 物件 |
| cosineSimilarity(a, b) | 兩個 vec 的 cosine 相似度 (0..1) |
| cosineNearest(query, candidates, k) | 在 candidates [{filename, vec}] 中找出與 query 最相似的 k 筆 |
| parseFrontmatter(content) / serializeFrontmatter(fm) | YAML frontmatter 處理 |
Adapters
fsSource(dir)/fsStore(dir)— 檔案系統sqliteStore({ db, tenantId })+ensureWikiTables(db)— sqlite(透過sqlitewrapper)
需要 MySQL / Postgres / 其他 store?實作 Store 介面即可,拿 sqliteStore 當範本。
Store 介面
interface Store {
list(): Promise<string[]>
read(filename): Promise<string | null>
write(filename, content: string | { content: string; frontmatter?: object }): Promise<void>
readManifest?(): Promise<Record<string, string> | null>
writeManifest?(manifest): Promise<void>
beginTransaction?(): Promise<void>
commit?(): Promise<void>
rollback?(): Promise<void>
// graph-rag 相關
writeEmbedding?(filename, vec: number[]): Promise<void>
removeEmbedding?(filename): Promise<void>
queryNearest?(vec: number[], k: number): Promise<Array<{ filename: string; score: number }>>
}所有 ? 標記皆 optional。沒實作 manifest 就無法 fingerprint 短路;沒實作 transaction 就無法 rollback;沒實作 embedding 三方法則 graph-rag retriever 無法運作。fs / sqlite adapter 都完整實作。
Source 介面
interface Source {
list(): Promise<string[]>
read(key): Promise<{ type: 'json' | 'markdown'; content: string }>
getFingerprint?(): Promise<Record<string, string>>
}getFingerprint 是 optional 但強烈建議實作——沒有 fingerprint 就無法短路重複 build,每次都會跑完整 builder。
設計哲學
- 框架不知道你的資料長什麼樣。CMS schema、欄位命名、render 規則都是專案專屬,放在 builder 函式裡,不在套件裡。
- 可預測、可重現。Deterministic 流程,相同輸入永遠相同輸出。
- 小核心 + 大彈性。框架只做 orchestration,所有業務邏輯由你決定。
- 失敗要明顯。Build 失敗 rollback、fingerprint 不寫,下次重試。不會留下「半套」狀態。
範例
examples/basic.js— 單一 wiki + fsSource/fsStoreexamples/multi-tenant.js— 多租戶 + SQLite
Roadmap
後續規劃見 ROADMAP.md(incremental build / token budget / frontmatter / embedding / hybrid retrieval)。
互動預覽
單一 wiki:
npm run preview
npm run preview -- --knowledge ./my-knowledge --output ./my-wiki多租戶(createTenantManager + sqliteStore):
npm run preview:multi
npm run preview:multi -- --knowledge ./knowledge --db ./data/wiki-preview.dbpreview:multi 額外支援 :tenants、:tenant <id> 切換、:buildall 全租戶重建。其餘指令::list / :build / :help / :quit。
發布(自動)
push 到 master 後 CI 掃 commit message 決定 bump:
| Commit 訊息 | Bump |
|---|---|
| :boom: 💥 / BREAKING CHANGE | major |
| :sparkles: ✨ | minor |
| 其他 (:bug: / :hammer: / ...) | patch |
錯誤處理
預設靜默失敗:build 失敗回 false、getContext 回 ''。設 throwErrors: true 可改為丟出 WikiRouterError(含 code 與 cause)。
相依
| 套件 | 關係 |
|------|------|
| 無外部執行期相依 | 僅需 Node.js ≥ 18 |
| SQLite 驅動(如 sqlite3 + sqlite) | 由使用者提供(傳入 sqliteStore),套件不直接相依 |
License
MIT
