LLM を信頼できない世界では、エンジニアリングの職責は LLM を完璧に強いることではなく、あらゆる失敗の形態に出口を用意しておくことである。
Andrej Karpathy は 2024 年にある概念を公開共有した:従来の RAG は chunk を直接 LLM に投入するには「断片的」すぎて構造がない;むしろ先に LLM で全ソース文書をwiki にコンパイルし、各 wiki は明確な slug / page_type / TLDR / tags を持ち、query 時にはまず wiki を見て(速い、安い、説明可能)、wiki に答えがなければ chunk-level RAG に fallback する。
我々はこの概念を cs-frontend(企業 RAG 知識ベース製品)+ GEO Platform 共用の rag-backend-v2 マイクロサービスに実際に実装し、宣伝概念として使うだけでなく、実際に本番で走らせている:
tenant_documents テーブルへwiki_pages テーブルへこの L1+L2 hybrid が生む効益:同じ query「百原 GEO はどうやってブランドの AI 引用率を上げるのか」:
しかし L1 の成立には 1 つの前提がある:Wiki のコンパイルが安定していなければならない。実際には、4 月初旬から 5 月初旬の 1 ヶ月で、6 種の LLM ハルシネーション形態の落とし穴を踏み、そのどれもが wiki コンパイルを部分的または全面的に失敗させた。本章はこの 6 層の堅牢化を記録する。
GEO 主 backend と rag-backend-v2 は同一の PostgreSQL instance 内にあるが、2 つの DB は完全に分離している:
geo_db |
cs_rag_db |
|
|---|---|---|
| サービス | GEO Platform 主 backend | rag-backend-v2 マイクロサービス |
| 用途 | brands / users / axp_pages / ground_truths | tenant_documents / chunks / wiki_pages |
| 役割 | ビジネスロジック + マルチテナントデータ | ベクトル検索 + LLM Wiki コンパイル |
| クロス DB query | csRagQuery(text, params) 独立 pool |
— |
分割の理由は 3 つ:
chunks テーブル(ベクトル embedding)は顧客量とともに爆発し、単一テーブル 100M row も珍しくなく、ビジネステーブルと混ぜると主 DB の性能を損なうbackend/src/config/database.js は 2 つの独立 PG pool を開く:
const geoPool = new Pool({ connectionString: DATABASE_URL });
const csRagPool = new Pool({
connectionString: DATABASE_URL.replace(/\/geo_db$/, '/cs_rag_db'),
});
async function csRagQuery(text, params) {
try {
return await csRagPool.query(text, params);
} catch (err) {
console.error('[csRagQuery] failed:', err.message);
return { rows: [] }; // fail-soft 不擋主流程
}
}
UAT には cs_rag_db がない(DATABASE_URL は single-DB instance に対応)→ csRagQuery は直接 fail するが、try/catch が受け止めて空配列を返し、主流程はブロックされない。PROD は両 DB がそろっており、csRagQuery は正常に動く。
代償:GEO 主 backend が「N 個の Wiki ページ」を表示するために wiki_link_count をクロス DB で取るには csRagPool query を回さねばならず、同 DB JOIN に比べ +30ms の遅延。しかし 2 つの DB を統合する運用コストに比べれば、この 30ms は許容できる。
V1 の RAG は chunks(L2 ベクトル検索)+ pgvector + BM25 + RRF だけだった。L1 layer は 2026 年 4 月に加えた「Karpathy LLM Wiki」概念の実装である。V1→V2 の決定要因:
V2 で wiki を加えた後、L1 hit のときは 1.5 秒で返し + 「ソース」として wiki page_id を指せるようになり、顧客満足度が上がった。しかし L1 は安定していなければならず、いかなるコンパイル失敗も顧客に「関連データなし」という虚像を見せてしまう。
顧客がフロントエンドで「文書を削除」を押すと、連鎖的に掃除する必要がある:
brand_documents (geo_db)
│ via central_doc_id UUID
▼
tenant_documents (cs_rag_db) ← LLM Wiki 編譯來源
│ via wiki_page_sources junction
▼
wiki_page_sources ← 多對多關聯表
│ via SSOT 同步 trigger
▼
wiki_pages.source_doc_ids ← 被引用的 wiki page
4 層の全自動 cascade は以下を通じて行われる:
brand_documents.central_doc_id 欄(migration 170)— アップロード時に中央が返す doc id をこの欄に書き込むragDeleteDocument(tenantId, central_doc_id) → 中央の trg_source_soft_delete cascade trigger が自動的に junction を掃除migration 201:sync_wiki_page_sources trigger — wiki_pages.source_doc_ids 配列と wiki_page_sources junction の同期を強制する(SSOT)設計の要:junction が SSOT、array は冗長な derivation である。もとは両者を別々に維持しており、よく desync していた(36 個の stale junction entries を観察)、migration 201 の後 trigger が両者の一致を強制し、顧客側の「N 個の Wiki ページ」UI 表示が常に正しくなった。
migration 201 の trigger は両者の同期を強制する:
CREATE OR REPLACE FUNCTION sync_wiki_page_sources()
RETURNS TRIGGER AS $$
BEGIN
-- INSERT/UPDATE wiki_pages.source_doc_ids → 同步 junction
IF TG_TABLE_NAME = 'wiki_pages' AND (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
-- 刪除 junction 中不在 source_doc_ids 的 row
DELETE FROM wiki_page_sources
WHERE wiki_page_id = NEW.id
AND NOT (source_doc_id = ANY(NEW.source_doc_ids));
-- 插入 source_doc_ids 中不在 junction 的 row
INSERT INTO wiki_page_sources(wiki_page_id, source_doc_id)
SELECT NEW.id, doc_id
FROM unnest(NEW.source_doc_ids) AS doc_id
WHERE NOT EXISTS (
SELECT 1 FROM wiki_page_sources
WHERE wiki_page_id = NEW.id AND source_doc_id = doc_id
);
END IF;
-- DELETE wiki_page_sources 後,若 wiki_page 失去最後一個 source → 軟刪
IF TG_TABLE_NAME = 'wiki_page_sources' AND TG_OP = 'DELETE' THEN
UPDATE wiki_pages
SET deleted_at = NOW(), source_doc_ids = '{}'
WHERE id = OLD.wiki_page_id
AND NOT EXISTS (
SELECT 1 FROM wiki_page_sources WHERE wiki_page_id = OLD.wiki_page_id
);
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
この trigger をデプロイする前は、掃除ロジックが 7 ヶ所に散らばっていた(application code 4 ヶ所 + migration script 3 ヶ所)、よく desync した。trigger オンライン後は 1 ヶ所を変えれば 7 ヶ所を触る必要がなくなり、1 万テナント scale に必要である。
trigger オンライン時に一度きりの reconciliation を行った:
-- 偵測 stale junction(junction 指向不存在的 wiki_pages 或 tenant_documents)
SELECT wps.*
FROM wiki_page_sources wps
LEFT JOIN wiki_pages wp ON wp.id = wps.wiki_page_id
LEFT JOIN tenant_documents td ON td.id = wps.source_doc_id
WHERE wp.id IS NULL OR td.id IS NULL;
-- → 36 row
一度の DELETE で掃除し、trigger 以降は新規発生しないことを保証する。この 36 個の entry はどこから来たのか?追跡すると:
trigger 以降は 3 種の case すべてが行き止まりとなり、以下を保証する:junction は常に array の内容を reflect する。
UAT には中央 RAG がない(CENTRAL_RAG_URL unset)→ Phase 1 path が自動 skip し、ローカルの brand_documents は純粋に pgvector adapter を通り、主流程に影響しない(try/catch がブロックしない)。PROD には中央 RAG があり → 完全な 4 層 cascade。これは「降格境界が明確」という設計に合致する。
各失敗はすべて実際の PROD 観察から来ており、以下 Patch N の番号順で記録する。この 6 個の patch は 2026 年 4 月末 5 月初旬の 1 週間で連鎖的に落とし穴を踏んで修したものである、それぞれが血みどろの障害の瞬間に対応する。
失敗形態:ある KB の 84 個の docs のうち 33 個しか wiki にコンパイルされず、51 個が永久 orphan になった。
発見過程:顧客が「84 個の FAQ 文書をアップロードしたのに、wiki のページ数は 33 ページしか表示されない」と反応。最初は LLM コンパイル失敗を疑ったが log は全部緑;次に doc が not ready を疑ったが DB チェックで全部 status='ready'。最後に wikiCompiler.js を grep して cutoff ロジックを発見した。
根本原因:compileWiki() function の冒頭に cutoff ロジックがあり、compileIncremental から借りてきたがロジックが適用されない:
// Buggy 原版
let totalChars = 0;
const selectedDocs = [];
for (const doc of docs) {
if (totalChars + (doc.content?.length || 0) > 150000) break; // 砍超過 150K 後的全部 doc
selectedDocs.push(doc);
totalChars += doc.content?.length || 0;
}
問題は、main compileWiki は doc ごとに独立した LLM call を回すので、aggregate token 制限の理由がないことである。しかし compileIncremental は複数 doc を 1 つの prompt に詰め込むので prompt token 制限がある。コードを借りたとき使用状況をよく見なかった。
さらに ORDER BY created_at ASC が初期 doc を先に selectedDocs に入れ、新 doc は常に切られる。1 万テナント scale:84 docs × 4.5K avg = 378K → 33 docs しかコンパイルされない → 51 docs が永久 orphan。
修法:cutoff を削除し、すべての ready wiki source を取り込む(per-doc LLM call は sequential で burst しない):
// Patched
const selectedDocs = docs;
const totalChars = docs.reduce((s, d) => s + (d.content?.length || 0), 0);
失敗形態:KB 全体の compile が中断し、後続 docs が全くコンパイルされない。PROD log:
[WIKI] Compile error: invalid input syntax for type uuid: "b7bced27"
発見過程:Patch 1 deploy 後に compile を回し直すと、13 個目の doc で全体が abort。log は「invalid uuid」を示すが、完全な UUID は b7bced27-3e4f-4abc-... で、先頭 8 文字だけ切り取られたように見える。
根本原因:LLM(DeepSeek)が SOURCE_IDS: header 行で稀に切り取られた UUID(完全な 36 文字でなく 8 文字 prefix)を出力する。元のコードは検証せず、そのまま PG INSERT INTO wiki_pages(source_doc_ids UUID[]) に渡す → PG RAISE → catch できない → batch 全体の compile transaction が rollback。
要:単一 LLM の 1 回の小さな誤りが、batch 全体を吹き飛ばす。1 万テナント scale では、LLM が偶発的に 1 回 hallucinate するだけで KB 全体を回し直すことになる。許容できない。
修法:LLM 出力を正規表現で検証する:
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const sourceDocIds = sourceIdsRaw
? sourceIdsRaw.split(',').map(s => s.trim()).filter(s => UUID_RE.test(s))
: [];
filter 後に空配列なら、後段の「保底」ロジックが依然として doc.id の実 UUID を prepend し、LLM が garbage を出しても帰属の正しさに影響しない。
失敗形態:LLM が稀に prompt で定めた ---ARTICLE---/---CONTENT--- marker を無視して純テキストを吐く。parseCompiledArticles が marker を見つけられない → 0 article を返す → その doc が永久 orphan。PROD で 02_醫美業_FAQ_與_Query集.md のコンパイルで LLM が marker なしの 5670 字を出力するのを観察。
発見過程:Patch 2 deploy 後に回し直すと、84 docs すべてが compile 完了したが、wiki_pages は 51 個しか追加されなかった。対応する doc id を grep し、一部の doc に対応する wiki ページがないことを確認。LLM 出力 log をチェックすると、一部 doc の LLM が純テキストを直接吐き、---ARTICLE--- すらなかった。
根本原因:LLM の出力 schema は信頼できず、特定の token / marker / JSON shape に依存する parser はすべて偶発的に失敗する。Anthropic / OpenAI / Gemini はどれも稀に「指示を忘れる」、DeepSeek は割合が高め(約 5〜8%)。
修法:fallback wrap helper を書き、marker が欠けても内容が ≥50 字あれば保底で single article に包む:
function fallbackWrapArticle(text, doc) {
const trimmed = (text || '').trim();
if (trimmed.length < 50) return null;
return {
title: doc.title.replace(/\.md$/i, '').slice(0, 100),
slug: deriveSlug(doc.title) || `fallback-${doc.id.slice(0, 8)}`,
pageType: heuristicPageType(doc.title), // faq/product/policy/concept
confidence: 'low', // 標 low 提示 admin 人工檢視
tags: ['wiki_fallback'], // admin 用此 tag 過濾
content: trimmed,
sourceDocIds: [doc.id], // 與原 doc 雙向綁定
// ...
};
}
設計の要:低信頼で包む(tags=wiki_fallback)+ admin 警告のほうがよく、顧客がアップロードしたソース文書を orphan にしてはならない。compileWiki の per-doc loop と compileIncremental の batch hook の両経路で適用する。
admin UI は WHERE 'wiki_fallback' = ANY(tags) で人手レビュー用にフィルタする。実測では 84 docs KB のうち 14 個が fallback bucket に入り、人手で内容を見るとほとんど OK でただ書式が整っていないだけで、signal はまだ十分だった。
失敗形態:rag コンテナが postgres 内部 hostname を解決できない → pg.Pool.connect("postgres") SERVFAIL silent hang(throw しない)→ wikiCompiler の最初の await q(SELECT...) が永遠に connection を待つ → wiki compile 全体が表面上は正常だが 0 進度。
発見過程:Patch 3 deploy 後 PROD で wiki コンパイルが「止まっている」のを観察 — admin UI に progress: 0/84、コンテナ CPU 0.01%、DB 0 active query、error log は一切なし。30 分進度なし。SSH でコンテナに入り nslookup postgres → SERVFAIL。
根本原因:コンテナの /etc/resolv.conf が host の systemd-resolved(127.0.0.53)を指し、Docker 内部 DNS(127.0.0.11)ではないという、Lightsail Ubuntu daemon の DNS 設定のエッジケース。SERVFAIL は NXDOMAIN ではない、pg client はこれを「一時的失敗、リトライを続ける」と解釈し「ドメインが存在しない、あきらめる」とはせず、無限に待つ。
修法(三版進化、前 2 版はどちらも失敗):
| バージョン | 設定 | 結果 |
|---|---|---|
| 初版 | extra_hosts: ["postgres:172.18.0.10"] ハードコード IP |
失敗:postgres IP が docker recreate 時に shift する |
| 第二版 | dns: ["127.0.0.11"] only |
惨事:www.googleapis.com を解決できない → Google OAuth が全プラットフォームで 8 時間壊れる |
| 最終版 | dns: ["127.0.0.11", "1.1.1.1", "8.8.8.8"] |
通過:内外の hostname が両方通る |
第二版の惨事は特にひどかった——backend / worker / frontend / rag をすべて dns: [127.0.0.11] に変えた後、コンテナ内 Node.js が www.googleapis.com(Google OAuth 用)を解決できず、Google ログインが getaddrinfo EAI_AGAIN を返し、顧客がまるまる 8 時間ログインできなくなった。緊急ロールバック後に根本原因を追った:Docker embedded DNS(127.0.0.11)は内部 hostname(postgres / redis / backend)しか扱わず、外部 hostname は host の resolv.conf に forward するが、Lightsail Ubuntu の systemd-resolved が誤設定されていて forward が失敗した。
最終版は 1.1.1.1(Cloudflare)+ 8.8.8.8(Google)を外部 fallback として加え、内部 hostname は 127.0.0.11 を通り、外部 hostname は fallback を通り、両方が通るようにした。
教訓:単に Docker embedded DNS を設定するだけでは足りず、外部 fallback を残さねばならない。docker-compose.prod.yml は backend / worker / frontend / rag すべてこう書く必要がある。新コンテナを追加するときは必ず「dns: は内部 + 外部 fallback を含まねばならない」をプラットフォーム憲法に書き込んだ。
失敗形態:wikiCompiler progress が「AI 編譯 (13/84):官網-FAQ」で 10 分以上止まる、CPU 0.01%、DB 0 active query。aiCall が永遠に返らない DeepSeek の stream を待っている。
発見過程:Patch 4 deploy 後 DNS は通ったが、顧客がある回のコンパイルが「13 個目で止まる」と反応。SSH でコンテナに入り top で node process が 0% CPU、netstat で DeepSeek API への ESTABLISHED 接続が 1 つあるが 0 byte traffic が 5 分続いているのを見た。
根本原因:OpenAI SDK のデフォルトは timeout: 600_000ms(10 分)+ maxRetries: 2 で、理論上 20 分止まる;Gemini SDK(@google/generative-ai)はネイティブの timeout / abort API を提供しない。DeepSeek は稀に ECONNRESET / 半開接続 / 低速応答があり、デフォルト timeout では持ちこたえられない。
さらに悪いのは、streaming response の中断はエラーを投げないことである——SDK は「stream はまだ終わっていない、待ち続ける」と考え、永遠に [DONE] event を受け取れない。
修法(三層 timeout):
// 1) OpenAI client constructor 60s + maxRetries=1
new OpenAI({ apiKey, timeout: 60_000, maxRetries: 1 });
// 2) per-request abort signal(防 streaming 卡 chunk)
client.chat.completions.create(params, { signal: AbortSignal.timeout(60_000) });
// 3) Gemini Promise.race + 60s timeout(SDK 無 abort)
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${label} timeout ${ms}ms`)), ms)
),
]);
}
withTimeout(genModel.generateContent(...), 60_000, `gemini/${model}`);
修正後:単一 doc の失敗は最大 60s で止まり、wikiCompiler の per-doc try/catch が受け止め → Patch 3 の fallback に行くか次の doc をスキップ、batch 全体 84 docs が単一 LLM のハングに引きずられなくなる。1 万テナント scale で安全。
この patch は後にプラットフォーム憲法「LLM call は必ず ≤60s timeout を持つ」となった——rag-backend-v2 + geo-saas backend + frontend のすべての LLM call site に適用する。この鉄則に違反した結果:単一 LLM の半開接続だけで batch 全体のワークフローを引きずり、1 万 brand が同時に失敗しうる。
失敗形態:Wiki L1 が全プラットフォームでミスし、大半の query が L2 に fallback し、「関連データなし」を返す。
発見過程:2026-04-30 に PROD primary LLM を deepseek-v3-direct から deepseek-v4-flash に切り替えた(reasoning model のほうが賢く、理論上 wiki query のルーティングがより正確)が、翌日顧客が「何を聞いても答えられない」と反応。wiki query log を grep すると全部空。
根本原因:reasoning model は message.content の他に reasoning_tokens を追加で食う(token budget の 70%+ を占めうる)。wikiCompiler.js の 2 ヶ所の wiki_query_route call が maxTokens: 200 を使っており、deepseek-v3 には十分だったが、V4 reasoning は200 token をすべて reasoning に食い、content が常に空文字列 → JSON parse fail → wiki query ルーティング失敗 → fallback L2 も「ヒットなし」を返す。
最も奇妙なのは:エラーがないこと、LLM API が 200 OK で { content: "", reasoning_tokens: 198 } を返し、content が空なだけ。元の try/catch では捕まえられない。
修法:2 ヶ所の maxTokens を 200 → 2000。実測で 2000 token は reasoning + content の両方に十分で、wiki query routing が正常に戻った。
1 万テナントへの影響:wiki を有効にし + V4-style reasoning model(DeepSeek V4 / OpenAI o1-o4 / Anthropic の thinking 含む)を使うテナントはすべて、Wiki L1 が全失効し、fallback L2 はかろうじて答えられるがコストが高く品質が悪い。この教訓はプラットフォーム憲法に広げられた:reasoning model に切り替えたら必ず全 maxTokens 設定をチェックする、いかなる maxTokens < 1500 も reasoning model 上で content が空でないことを検証すべきである。
完全な 6 patch をデプロイした後の PROD 観察:
| 指標 | Before | After |
|---|---|---|
| プラットフォーム wiki orphan rate | 45%(156/348) | ≤25%(見込み) |
| 5ce78a51 KB(84 docs)compile 完了率 | 39%(33/84) | 100%(84/84) |
| 単一 doc LLM hang が batch を止める確率 | ~20% | <1% |
| Wiki L1 hit rate | 12%(reasoning model 後) | 65% |
orphan rate が 45% から ≤25% に下がったがまだ 0% に達していない理由:Patch 3 の fallback は「保底の解決」だが「完璧なコンパイル」ではなく、fallback bucket に入る doc は依然として confidence='low' とマークされ、一部の admin の計算基準では partial orphan とみなされる。orphan を完全に消すには LLM が永遠に marker を漏らさない(不可能)か、全 doc が構造化 schema を使う(JSON output mode、一部の LLM が対応)必要があり、これは V2 の作業である。
振り返ると、6 個の patch の発見順序は非常に巧妙だった——前の patch が直るまで、次の patch のバグは見えなかった:
Patch 1 (cutoff) → 修了才看到所有 doc 都進編譯
↓
Patch 2 (partial UUID) → 修了才看到後續 doc 都編完
↓
Patch 3 (no marker) → 修了才看到 wiki page 真實生成
↓
Patch 4 (DNS) → 修了才看到生成過程不卡死
↓
Patch 5 (LLM timeout) → 修了才看到單 doc 失敗不拖整批
↓
Patch 6 (maxTokens) → 修了才看到 wiki query 真實命中
各 patch は「下流が正常に見えて実は正常でない」層を解く、これは LLM-driven システムの debug の典型的な形態である:エラーが多層にわたって silent swallow され、玉ねぎを一枚一枚剥くように見えてくる。
WikiCompiler の失敗は氷山の一角にすぎない。BullMQ worker 体系全体も同種の罠を踏んだ。
各 cron worker はビジネスロジックの前に必ず entity の存在をチェックする:
async (job) => {
const { brandId } = job.data;
// 預檢 — brand 被刪後 cron 還在跑會 throw → BullMQ 累積 failed jobs → admin UI 永遠紅
const { rows: [b] } = await query('SELECT id FROM brands WHERE id = $1', [brandId]);
if (!b) {
console.warn(`[Worker] Brand ${brandId} not found — removing orphan scheduler`);
const q = new Queue(QUEUE_NAME, { connection });
await q.removeJobScheduler(`monthly-${brandId}`); // 自動清孤兒 scheduler
await q.close();
return { skipped: true, reason: 'brand_not_found', brandId };
}
// ... 業務邏輯
}
要点:チェックが通らない → skipped を return し throw しない。throw は failed とみなされ、return は completed とみなされ、UI は常に緑。同時についでに removeJobScheduler で orphan scheduler も掃除する。
PROD で観察した適用範囲:monthly.worker.js(brand が削除された)、abTesting.worker.js(experiment が削除された → FK violation)、closedLoop sentinel(テーブルが brand_prompts → multilingual_prompts にリネームされた)。
backend/src/workers/queueRetention.worker.js は毎日 02:30 に走る:
// 24 個 known queue,daily cleanup
for (const name of KNOWN_QUEUES) {
const q = new Queue(name, { connection });
await q.clean(7 * 24 * 3600 * 1000, 500, 'failed'); // 7 天 failed
await q.clean(30 * 24 * 3600 * 1000, 500, 'completed'); // 30 天 completed
await q.close();
}
掃除しない結果:per-brand cron が毎日大量の jobs を生成し、失敗したものを掃除しないと Redis OOM まで積み上がり、かつ admin UI(/admin/scan-frequencies)が永遠に赤ランプで累積し新しい状態が見えなくなる。
実測の一度きり cleanup の結果:
[QueueRetention] Cleaned 2026-05-02 02:30
monthly-report-queue: 7 failed, 312 completed
ab-testing-queue: 30 failed, 845 completed
geo-closed-loop-queue: 4 failed, 198 completed
geo-rlhf-queue: 0 failed, 67 completed
geo-axp-queue: 2 failed, 630 completed
...
Total: 43 failed + 2052 completed cleaned
きれいに掃除した後、admin UI がリアルタイムの状態に戻り、いかなる新しい失敗も即座に見える。1 万テナント scale では daily cleanup が ≥ 1 万件と見込まれ、retention worker は必須である。
retention worker の KNOWN_QUEUES 配列は全プラットフォームのすべての queue 名を含まねばならず、さもなくば新 queue は永遠に掃除されない:
const KNOWN_QUEUES = [
'geo-axp-queue',
'geo-axp-immediate',
'geo-axp-bootstrap',
'monthly-report-queue',
'tier-a-visual-monthly',
'billing-cycle-queue',
'payment-dunning-queue',
'reverse-scan-queue',
'ab-testing-queue',
'geo-closed-loop-queue',
'geo-rlhf-queue',
'geo-f12-optimize-queue',
'queue-retention-queue', // self
// ... 其他 11 個
];
ある queue を書き漏らす → 失敗が永久累積する。漏れを避けるため、admin UI に /admin/scan-frequencies ページを加えた — BullMQ から全 queue 名をリストし KNOWN_QUEUES array と比べ、差集合を「⚠ retention 未収録」赤ラベルで表示する。新 queue を加えて KNOWN_QUEUES の更新を忘れると → admin が即座に見る。
wikiCompiler の他に、geo-saas backend も同時に LLM timeout を加えた(同種の silent hang を防ぐ):
services/modelRouter.service.js Anthropic SDK timeout: 60000, maxRetries: 0(元のデフォルト 600s)services/modelRouter.service.js Gemini call に Promise.race + 60s timeout を追加services/tier-a/visualEnrichment.service.js Anthropic Vision API fetch に signal: AbortSignal.timeout(60_000) を追加検証コマンド(commit 前の自己チェック):
grep -rn "fetch(.*api\.\(deepseek\|openai\|anthropic\|googleapis\|xiaomimimo\)" backend/src/services/ \
| grep -v "AbortSignal\|signal:"
# 應為空,有 = 違反 LLM timeout 鐵律
この grep を pre-commit hook に加えた(計画中)、違反 → commit をブロック。1 万テナント scale はいかなる漏れも許容できない。
rag-backend-v2 は geo-saas git repo にない(独立マイクロサービス)、patch 記録は docs/rag-backend-v2-patches/ に保持する:
docs/rag-backend-v2-patches/
├── README.md ← 部署順序 + verification block
├── wikiCompiler.js ← Patch 1, 2, 3, 6 都改這個
├── modelRouter.js ← Patch 5 改這個
├── docker-compose.rag.yml ← Patch 4 DNS 設定
└── verification-block.sh ← 一鍵驗證 6 patch live
RAG コンテナを再構築するたびに 3 つのファイルを一緒に scp しなければならない(1 つ漏らすと patch が回帰する)。
# 1. 從 patches/ 取最新版
scp wikiCompiler.js ubuntu@PROD:/home/ubuntu/rag-backend-v2/src/services/
scp modelRouter.js ubuntu@PROD:/home/ubuntu/rag-backend-v2/src/services/
scp docker-compose.rag.yml ubuntu@PROD:/home/ubuntu/geo-saas/
# 2. 重 build + recreate
ssh ubuntu@PROD
cd /home/ubuntu/rag-backend-v2
sudo docker build -t geo-saas-prod-rag:latest .
cd /home/ubuntu/geo-saas
sudo docker compose -f docker-compose.rag.yml --project-name geo-saas-prod up -d --force-recreate rag
# 3. 驗證 6 個 patch 全 live
sudo docker exec geo-saas-prod-rag-1 sh -c '
grep -c "selectedDocs = docs" /app/src/services/wikiCompiler.js # patch 1: 1
grep -c UUID_RE /app/src/services/wikiCompiler.js # patch 2: 2
grep -c fallbackWrapArticle /app/src/services/wikiCompiler.js # patch 3: 3
grep -c postgres /etc/hosts # patch 4 prep: 1+
grep -c LLM_CALL_TIMEOUT_MS /app/src/services/modelRouter.js # patch 5: 8
grep -c "maxTokens: 2000" /app/src/services/wikiCompiler.js # patch 6: ≥3
'
期待される出力は 1 / 2 / 3 / 1 / 8 / ≥3。いずれか合致しなければ patch 回帰であり、即座に再 scp + rebuild する。PROD コンテナを再構築するたびにこの verification block を必ず持ち込む、さもなくば patch が黙って失われ同じ轍を踏む。
patch のデプロイ以外に、1 万テナント readiness にはさらに以下が必要:
f12_cache_metrics を毎時集計、目標 ≥65%。50% 未満 → admin alertf12_billing_records を月度集計、starter が $1 超 / pro が $50 超 → email warnSELECT COUNT(*) FROM wiki_pages WHERE 'wiki_fallback' = ANY(tags) > 100 / brand → admin reviewSELECT COUNT(*) FROM tenant_documents td WHERE NOT EXISTS (SELECT 1 FROM wiki_page_sources WHERE source_doc_id=td.id) > 10% → compile を再トリガーこの 4 指標はすべて /admin/rag-health admin dashboard に入り、daily cron が算出後に alert を書く。
2026-04-23 から 2026-05-02 までのわずか 9 日で、rag コンテナは 11 回 recreate された:
| 日付 | 原因 | Patch |
|---|---|---|
| 04-23 | DeepSeek SDK のアップグレード | — |
| 04-25 | Wiki Cascade trigger の追加 | migration 170 |
| 04-27 | sync_wiki_page_sources trigger の追加 | migration 201 |
| 04-29 | Patch 1 (cutoff) | wikiCompiler.js |
| 04-30 | Patch 2 (UUID) | wikiCompiler.js |
| 04-30 | V4 reasoning への切り替え(Patch 6 の前) | platform_llm_config |
| 05-01 | Patch 3 (no marker) | wikiCompiler.js |
| 05-01 | Patch 4 v1 (extra_hosts IP) | docker-compose.rag.yml |
| 05-01 | Patch 4 v2 (dns 127.0.0.11 only) | docker-compose.rag.yml |
| 05-01 | Patch 4 v3 (dns + fallback) | docker-compose.rag.yml + .prod.yml |
| 05-02 | Patch 5 + 6 | modelRouter.js + wikiCompiler.js |
各再構築は ~3 分の downtime(build & recreate 中)、11 回で累計 ~33 分。次に大改修するときは blue-green deployment(双コンテナ交替)で downtime を減らすことを勧めるが、現状 quota が足りず 2 コンテナを同時に走らせられない。
6 層の patch は一見よくある失敗モードをカバーしたように見えるが、LLM の進化速度は patch の速度より速い:
maxTokens が reasoning に足りない)これは LLM-driven システムの robustness 設計が常に LLM が新しい方法で失敗すると仮定しなければならないことを意味し、新 model を 1 つ加えるごとに hallucination test を 1 巡回し直す必要がある。
Patch 3 の低信頼 fallback は顧客の doc を救ったが、confidence='low' + tags=['wiki_fallback'] の wiki page が L1 retrieval のとき実際どれほどの品質かは、現状end-to-end evaluation をしていない。理論上は:
3 点目はまだやっていない。
さらに、fallback 内容の「ソース紐付け」は存在するが(sourceDocIds)、「LLM の再構成品質」は検証されていない——LLM が顧客の無関係な 2 つの doc の内容を混ぜている可能性がある。これは人手または LLM-as-judge のサンプルレビューが必要で、V2 の作業である。
理論上、同じ query を 2 つの異なる LLM(DeepSeek + Gemini)で同時に回し、marker / UUID / 内容を交差検証すれば、大半の hallucination 問題を解ける。しかしコストが倍になり遅延も倍増する、それに見合うか?現状データが足りず決めきれない、V2 の評価に残す。
具体的な定量化:単一 wiki compile call は約 $0.0003(DeepSeek V4 flash)、双 LLM で $0.0006 + max latency 1.5s → 3s。84 docs KB compile $0.025 → $0.05。1 万テナント月 100 KB recompile = $50/月、許容できる。しかしまず A/B test で双 LLM が本当に hallucination を減らすか検証する必要がある(2 つの LLM が稀に同じ誤りを犯す可能性がある)。
削除は cascade が自動処理するが、復元(undo soft-delete)はやっていない。顧客が誤って文書を削除 → wiki_pages が soft delete → 30 日後に自動 hard delete。顧客が 7 日後に戻したいと思っても、現状はカスタマーサポートが手動で SQL UPDATE する。よくある要望だがエンジニアリングが手を付けていない。
完全な実装には以下が必要:
deleted_at IS NOT NULL チェック + restore 関数エンジニアリング量は約 1〜2 週間、V2 で手を付ける。
V1 のコンパイル失敗 → DB に last_compile_error を書き、UI に「コンパイル失敗」を表示。しかしリトライ機構がない — admin が手動でボタンを押す必要がある。1 万テナント readiness では:
これも V2 の作業である。
ragDeleteDocument 失敗後に brand_documents はすでに soft delete だが tenant_documents は掃除されず、orphan tenant_document(対応する brand_document がない)を生む。現状は daily cron が orphan を捕まえて再度掃除するが、この cron は実行漏れや失敗の可能性がある。
よりクリーンな設計は outbox pattern である:
-- brand_documents.delete 時寫 outbox row
INSERT INTO outbox_events(event_type, payload, status)
VALUES ('rag_delete_document', '{"central_doc_id": "..."}', 'pending');
-- 獨立 worker 讀 outbox,call ragDeleteDocument,成功才標 'done'
-- 失敗自動 retry,長期失敗 alert
しかし outbox を適用するには cascade flow 全体を再構築する必要があり、V2 の作業量が大きく、現状は daily cron で当面十分である。
6 個の patch + Worker robustness の進化(2 週間、5 回の PROD 障害)から、5 条の教訓を整理した:
6 個の patch はどれも LLM が直接 error を throw したものではない。すべて「一見成功だが実際は空 / 切断 / 停止」だった:
LLM-driven システムの debug の第一規則:「error が見えない」を信頼できず、「output が本当に期待した shape か」を検証しなければならない。各 LLM call の後に schema validate(UUID_RE / marker / minLength)を加えるのが基本動作である。
DNS SERVFAIL / streaming 中断 / reasoning content 空——3 種の状況すべてがシステムを「正常に動いているように見える」が 0 進度にする。従来の「失敗は throw する」という仮定が破綻する。
この種の失敗を防御するエンジニアリングパターン:
Patch 3 の fallback 設計は「LLM 失敗 → 緩い解析 → まだ失敗 → doc title で保底包装」の三層で、いずれかの層が成功すれば保底になる。一層 retry(失敗したら 3 回リトライ、まだ失敗なら throw)に比べ、三層 fallback は常に出口がある:
代償:fallback bucket の wiki page 品質が低く、admin が見る必要がある。しかし「LLM 失敗 → doc が orphan」に比べれば、それだけの価値がある。
1 万テナント scale は「永久 orphan」を許容できず、顧客がアップロードしたいかなる内容も出口を持たねばならない。
6 個の patch の発見順序を振り返る——それぞれ直すと次を「暴露」する:
看到 33/84 完成 → 修 cutoff → 看到 全部 doc 進編譯
看到 全部 doc 進編譯 → 看到第 13 個就斷 → 修 UUID → 看到 全部 doc 完成
看到 全部 doc 完成 → 看到 wiki page 數不對 → 修 marker → 看到 wiki 真實生成
看到 wiki 真實生成 → 看到 編譯卡住 → 修 DNS → 看到 編譯運行
看到 編譯運行 → 看到 卡在第 13 個 → 修 LLM timeout → 看到 編譯穩定
看到 編譯穩定 → 看到 wiki query 全 miss → 修 maxTokens → 看到 wiki 真實命中
各層は「前の層の silent failure が次の層を覆い隠していた」。debug は一層ずつ剥がねばならず、一度に全部解こうと焦ると根本原因を見誤る。
rag-backend-v2 は geo-saas git repo にないが、patch が入った後に失われやすい。docs/rag-backend-v2-patches/ を SSOT として設計した:
verification block を書かなければ、次の再構築で持ち込みを忘れて回帰する。一度踏んだ:あるとき RAG をアップグレード再構築した際、wikiCompiler.js の scp を忘れ、Patch 6 の maxTokens=2000 が 200 に戻り、顧客が「Wiki がまたヒットしない」と反応。'maxTokens: 2000' を grep して count=0 で patch が持ち込まれていないと即座に分かった。以後 RAG container を recreate するたびに必ず verification を回し、二度と踏まない。
| 日付 | バージョン | 説明 |
|---|---|---|
| 2026-05-03 | v1.1 | 新章 — 6 層の LLM ハルシネーション patch + Wiki Cascade + Worker robustness |
| 2026-05-03 | v1.1.1 | 章を ~7100 字に拡充 — 15.1.1/3 Karpathy 概念と V1→V2 進化、15.2.2/3 SSOT trigger SQL と 36 entry デバッグ、15.3 各 patch に発見過程、15.3.8 Patch 順序の因果連鎖、15.4.3 KNOWN_QUEUES 保守、15.5.4 RAG 11 回再構築史、15.6.6 outbox pattern、15.7 エンジニアリング教訓 5 条を追加;章番号 14.x → 15.x の typo を修正 |
ナビゲーション:← 第 14 章: F12 三層構造オプティマイザ · 📖 目次 · 第 16 章: プラットフォーム SSOT チェーン →