はじめに
社内向けのAIチャットアプリに、組織のナレッジをAIが自動で育てる仕組みを載せています。
会議の議論からAIが要点を抽出し、既存のナレッジと重複していれば統合し、新しければ追加する、というものです。
ここで問題になったのが、社内規程のように変更してはいけない文書の扱いでした。
AIが育てる前提で作った仕組みに、AIが書き換えてはいけないものを同居させることになります。
規程などは原本が正であり、AIが要約したり統合したりしてはいけませんが、同時に、AIが根拠として参照できないと意味がありません。
例えば「就業規則ではどうなっていますか」に答えられないRAGでは使ってもらえません。
つまり、同じ検索基盤の中に「AIが育てるナレッジ」と「AIが絶対に触らない資料」を共存させる必要がありました。
なお、インデックスを2つに分ければ、この問題は起きません。
ただしデプロイ済みのインデックスは検索が走っていなくても課金され続けるため、数を増やす選択は取りませんでした。
以前、Vector Search のインデックスを自動で起動・停止する記事を書いたのも、この常時課金を抑えるためでした。
同一インデックスのまま分離する、というのが今回の前提です。
この記事では、その分離をどう担保したか、そしてPDFの本文抽出で踏んだ2つの落とし穴と、関数のタイムアウトを避けた並列化をまとめます。
対象読者
- RAGに規程・マニュアルなどの「改変してはいけない文書」を載せたい方
- LLMがナレッジを自動更新する仕組みを運用している方
- Genkit で長いPDFを処理していてタイムアウトに悩んでいる方
課題:「参照させたいが、書き換えさせたくない」
既存のナレッジは、下記の流れでAIが更新します。
チャットの発言
→ AIが要点を抽出
→ 同カテゴリの既存ナレッジをベクトル検索で探す
→ 重複していれば統合、新しければ追加
ここに規程PDFを普通に入れると、規程が「既存ナレッジ」として検索に引っかかり、統合対象になるため、AIが規程の本文を要約して上書きしうる状態でした。
一方で、Vector Searchの検索対象から完全に外すと参照もできなくなります。
「検索には出るが、更新対象には出ない」 という状態を作る必要がありました。
設計:2層で担保する
フラグ1つで制御すると、そのフラグを見忘れた経路から漏れてしまうため、検索基盤とデータストアの両方で分離しました。
| 層 | 通常ナレッジ | 資料(読み取り専用) |
|---|---|---|
| Vector Search | doc_type: 'latest_info' |
doc_type: 'reference' |
| Firestore | information |
referenceDocuments / referenceChunks
|
Vector Search は同一インデックスのまま、restrict で doc_type を分けています。
Firestore は別コレクションに置き、マージ処理は information しか読みません。
更新側は latest_info しか引かない
AIが統合対象を探す処理は、allowList を絞って投げています。
// 統合対象を探すクエリ
restricts: [
{ namespace: 'category', allowList: [categoryId] },
{ namespace: 'doc_type', allowList: [KNOWLEDGE_DOC_TYPE.LATEST] } // ★reference は含めない
]
reference は候補として返ってこないので、AIが「統合すべき既存ナレッジ」として資料を見ること自体が、検索の段階で起きません。
参照側は種類ごとに別クエリを投げる
回答時の検索は複数の doc_type をまとめて引くのですが、ここで一度失敗しています。
// allowList に両方を入れて1回で引くと
// 「最新情報だけで上位が埋まって履歴が0件」になる
const results = await Promise.all(
docTypes.map((docType) =>
findNeighbors(denseVector, categoryIds, docType, limitByDocType?.[docType] ?? limit)
)
);
doc_type ごとに別クエリを投げ、件数枠を分けています。
資料は1件の本文が長いため、同じ枠で競わせると通常ナレッジが押し出されるか、逆に資料が入りきらないかのどちらかになります。
doc_type を足すだけでは不十分で、取得後に引く Firestore のコレクションも出し分ける必要があります。
以前、ベクトルだけ書き込んで誰も引いていない doc_type があり、完全な死蔵状態になっていました。
カテゴリの排他を両方向で塞ぐ
同じカテゴリ名に通常ナレッジと資料が混在すると、利用者から見て「中身が上書きされたのか」が判別できないため、双方向でチェックしています。
/** 指定カテゴリが「通常ナレッジ」として既に使われているか */
async function isEditableCategory(categoryId: string): Promise<boolean> {
const snap = await db.collection('information')
.where('categoryId', '==', categoryId)
.where('isActive', '==', true)
.limit(1)
.get();
return !snap.empty;
}
/** 指定カテゴリが「資料」として既に使われているか */
export async function isReferenceCategory(categoryId: string): Promise<boolean> {
const snap = await db.collection(REFERENCE_DOCUMENTS)
.where('categoryId', '==', categoryId)
.limit(1)
.get();
return !snap.empty;
}
両方向を実装しています。
資料アップロード側で「通常カテゴリなら弾く」だけでは足りません。
ナレッジ登録側でも「資料カテゴリなら弾く」を入れないと、逆方向から混ざります。
// ナレッジ登録の入口
if (await isReferenceCategory(categoryId)) {
throw new UserFacingError(
'FAILED_PRECONDITION',
`「${categoryId}」は資料(読み取り専用)のカテゴリです。別のカテゴリ名を指定してください。`
);
}
更新手段を用意しない
資料の編集UIは作っておらず、差し替えは「削除してから登録し直す」の一手だけにしています。
編集を許すと、Storage の原本・Firestore のチャンク・Vector Search のベクトルという3つの整合性を保ち続ける必要が出ます。
読み取り専用という前提を守るほうが、実装も説明も単純になります。
実装:PDFを章・条の単位に割る
検索の単位はPDF全体ではなく、見出しごとに割ったチャンクにしています。
「就業規則の第◯条」に当たる粒度で引ければ、出典としてページ番号も示せます。
抽出はLLMに任せていて、プロンプトで押さえているのは下記です。
1. 章・条・節などの見出し単位で区切ってください。
2. **本文は要約せず、原文をそのまま書き写してください。**
後で検索して根拠として提示するため、言い換えると使えなくなります。
(中略)
4. ページをまたぐ見出しは、始まったページの番号を page に入れてください。
(中略)
6. ヘッダー・フッター・ページ番号だけの行は含めないでください。
「要約しない」を明示するのが重要で、何も言わないとLLMは親切に要約します。
規程を要約したチャンクを根拠として提示すると、原本と食い違ってしまいます。
実測では、4ページのPDFが16チャンクに分割され、1ページあたり4条前後という粒度になりました。
原文を書き写す作業に思考は要らないため、推論も切っています。
config: {
temperature: 0,
// 原文を書き写す作業に推論は要らない。思考に時間を使わせると生成が長引くだけ
thinkingConfig: { thinkingLevel: MODEL_THINKING_MINIMAL }
}
落とし穴①:ai.generate では4ページでもタイムアウトした
最初の実装は非ストリーミングで、4ページのPDFで落ちました。
UND_ERR_HEADERS_TIMEOUT
原因はレスポンスヘッダのタイムアウトで、ai.generate は生成が完了するまでヘッダを返しません。
Node.js の HTTP クライアント(undici)の既定タイムアウトは300秒なので、生成が5分を超えると fetch そのものが落ちます。
そして今回は「原文をそのまま書き写す」処理なので、出力が入力と同じ長さになり、生成時間が普通の要約タスクより桁違いに長くなります。
4ページで300秒を超えたのはそのためでした。
対処:ai.generateStream にする
ストリーミングなら最初のトークンが出た時点でヘッダが返るため、ヘッダタイムアウトに当たりません。
const stream = await ai.generateStream({
model: vertexAI.model(MODEL_FLASH),
prompt: [mediaPart, { text: `…${start}ページ目から${end}ページ目まで…` }],
output: { schema: ChunkExtractionSchema },
config: { temperature: 0, thinkingConfig: { thinkingLevel: MODEL_THINKING_MINIMAL } }
});
// ストリームは読み捨てる。進捗を配信する相手はいないが、
// 読まないと生成が最後まで進まないので必ず回すこと。
for await (const _chunk of stream.stream) {
// 意図的に何もしない
}
const res = await stream.response;
const chunks = res.output?.chunks;
★ポイント:ストリームを読み捨てる場合も、必ずループを回してください。
進捗を誰にも配信しないので stream.response だけ待ちたくなりますが、それだと生成が進まず、ここは一度詰まりました。
ページ範囲は並列で投げる
①を直しても、ページ数が増えると別の壁が見えてきます。
この処理は Cloud Tasks から起動するイベント駆動の関数で、タイムアウトの上限は540秒です。
実測では1バッチが40秒前後で、30ページを5ページずつ6バッチ流しても240秒です。
ただし上限までの余裕は300秒しかなく、1バッチが倍に伸びれば超えます。
ここに埋め込み生成とFirestoreへの書き込みも乗るため、逐次のまま運用する判断は取りませんでした。
バッチを Promise.all でまとめて投げています。
// バッチは並列で投げること。逐次だと1バッチ100秒でも
// 30ページ(6バッチ)で600秒になり、関数のタイムアウト(540秒)に当たる。
// 重複排除は結果が揃ってからページ範囲の順に行うので、並列でも結果は変わらない。
const ranges: Array<{ start: number; end: number }> = [];
for (let start = 1; start <= totalPages; start += REFERENCE_PAGES_PER_BATCH) {
ranges.push({ start, end: Math.min(start + REFERENCE_PAGES_PER_BATCH - 1, totalPages) });
}
const perRange = await Promise.all(
ranges.map(({ start, end }) => extractChunksForRange(mediaPart, start, end, documentId))
);
これで全体の時間が「最も遅いバッチ1つ分」に収まります。
バッチサイズは5ページにしています。
export const REFERENCE_PDF_MAX_PAGES = 30;
export const REFERENCE_PAGES_PER_BATCH = 5;
1ページずつにしないのは見出しがページをまたぐためで、細かく割ると章の途中で切れたチャンクが増えます。
逆に大きくしすぎると1バッチの生成が長くなり、①の問題に近づきます。
落とし穴②:章見出しだけのチャンクが検索枠を食っていた
見出し単位で割ると、「第1章 総則」のような章の区切りもチャンクになります。
本文が見出しと同じ文字列になるため、中身が無いまま1件のベクトルを占めていました。
資料は1件の本文が長いので、検索枠は5件しか取っていません。
そこに中身の無いチャンクが上位で入ると、本当に必要な条文が押し出されます。
対処:見出しと本文が実質同一のものを落とす
登録の時点で判定して捨てています。
function isHeadingOnly(heading: string, text: string): boolean {
// 全角・半角や記号の差を無視して比べる
const normalize = (s: string) =>
s.replace(/[\s ]/g, '')
.replace(/[0-9]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0xfee0))
.replace(/[()()【】「」..、,::・]/g, '');
const h = normalize(heading);
const t = normalize(text);
if (!t) return true;
// 本文が見出しより十分長ければ中身がある
if (t.length >= h.length + 20) return false;
return t.includes(h) || h.includes(t);
}
文字数だけで切らないのが重要です。
「第10条 会社は出社を命じることがある」のように、短くても中身のある条文を落としてしまいます。
見出しと本文が実質同一かどうかで判定しています。
既知の副作用として、「第9条(削除)」のように本文が見出しの繰り返ししかない廃止条文も落ちます。
条番号だけを聞かれたときに答えられなくなりますが、原本のPDFは画面から開けるため許容しています。
上限値は3箇所で同時に守る
PDFの上限(10MB・30ページ)は、性質の違う3箇所に置いています。
| 場所 | 役割 |
|---|---|
| フロントエンドの定数 | アップロード前に弾く(無駄な通信をしない) |
| サーバ側の型定義 | 再検証(フロントを信用しない) |
| Storage のルール | 最終防衛線(APIを直接叩かれても止める) |
片方だけ直しても守れないので、変更時に3箇所すべてを直すよう、それぞれのファイル冒頭に他の2箇所へのパスを書いてあります。
/**
* この値を変えるときは3箇所すべてを直すこと。
* 1. (このファイル)クライアント側の事前チェック
* 2. functions/src/types/knowledge.ts(サーバ側の再検証)
* 3. storage.rules(Storage側の最終防衛線)
*/
おわりに
「AIが育てるナレッジ」と「AIが触らない資料」の共存は、フラグ1つでは担保できませんでした。
書き込む経路と読み出す経路の両方に、それぞれ独立した分離を置く必要がありました。
対策として有効なのは以下の3点です。
-
「触らせない」は2層で担保する
検索の段階で候補に出さない(restrictのallowList)ことと、データストアを分けることを両方やります
更新側のクエリでallowListを絞っておけば、新しい参照経路を足しても資料が統合候補に入りません -
原文を扱うタスクでは、ストリーミングをタイムアウト対策として使う
ai.generateは生成完了までヘッダを返さないため、出力が長いタスクでは undici の300秒に当たります
要約なら短く済みますが、原文の書き写しは出力が入力と同じ長さになるため簡単に超えます。ストリームを読み捨てる場合もループは回してください -
排他チェックは両方向に書く
A側に「Bなら弾く」を入れたら、必ずB側にも「Aなら弾く」を入れます
今回は登録の入口と資料アップロードの2箇所に同じ判定関数を置いて揃えました
規程のように「AIに改変されたら困る文書」は、社内利用のRAGでは必ず出てきます。
同じ構成を検討されている方の役に立てば幸いです。