Engineering11 min readMarian Engineering

インジェストパイプラインとcontent-hashキャッシュ — 再インデックスを差分だけにする

チャンキング戦略、埋め込みバッチ、FNV-1aデュアルハッシュによる再埋め込み回避

Key Takeaways

  • 再インデックスが支配的ワークロード。分割は毎回・埋め込みは差分だけという非対称設計にする
  • チャンクはファイル種別の構造(見出し/定義/段落/文)で分割し、file summaryチャンクでメタデータも検索可能にする
  • FNV-1aデュアルハッシュ(実効64bit)+planReindexで、変更のないチャンクの再埋め込みをゼロにする
  • 重複排除はアプリロジックでなくハッシュ+DBユニーク制約としてデータ層に沈める

課題: インデックスは作るより「作り直す」方が多い

RAGのインジェストは初回投入よりも、ファイルが少し変わるたびの再インデックスが支配的なワークロードです。素朴な実装は毎回全チャンクを再埋め込みしますが、埋め込みAPIは有料でレイテンシもあるため、1文字の変更で数百チャンクを埋め込み直すのは受け入れられません。

Marianのインジェストは「分割は安く、埋め込みは高い」という非対称を前提に、分割は毎回やり直し、埋め込みは差分だけという構成を取ります。

チャンキング: ファイル種別ごとの分割戦略

チャンクは検索の最小単位なので、固定長で機械的に切ると見出しと本文が泣き別れます。Marianはファイル種別ごとに構造を見て分割します。

  • Markdown: #######の見出しでセクション分割→セクション内を段落単位でパッキング
  • コード: function / class / type / const 等の定義境界で分割
  • プレーンテキスト: 段落区切り(空行)→文境界((?<=[.!?])\s+)の順でフォールバック
  • PDF: ページ→ブロック構造化。表・図は単独チャンクとして分離(後述)

さらに各ファイルの先頭には、パス・フォルダ・見出しアウトライン・キーワード・冒頭500字を詰めたfile summaryチャンクを1つ生成します。「このファイルは何か」という質問にチャンク単位で答えるための、メタデータの埋め込み化です。

定数意味
CHUNK_CHARS1,400ファイルチャンクの目標文字数
maxChars(段落分割)1,200段落ベース分割の上限
最小テール200残りがこれ未満なら分割しない
maxTokens(ブロック分割)256トークン基準分割の上限
MAX_BODY_CHUNKS_PER_FILE51ファイルの本文チャンク上限
EMBED_BATCH32埋め込みAPIのバッチサイズ

トークン数はceil(単語数 / 0.75)(約1.33トークン/語)で見積もります。正確なトークナイザではなく見積もりで十分なのは、上限値側に余裕を持たせてあるからです。

content-hash: 変わっていないものを埋め込まない

差分検出の鍵はチャンク内容のハッシュです。Marianは暗号学的ハッシュではなく、シードの異なるFNV-1a 32bitを2本連結した16桁hexを使います。

// lib/marian-files/embedding-cache.ts
function fnv1a(text: string, seed: number): string {
  let hash = seed >>> 0
  for (let i = 0; i < text.length; i++) {
    hash ^= text.charCodeAt(i)
    hash = Math.imul(hash, 0x01000193)
  }
  return (hash >>> 0).toString(16).padStart(8, '0')
}

export function hashContent(content: string): string {
  const normalized = content.replace(/\r\n?/g, '\n').trim()
  return fnv1a(normalized, 0x811c9dc5) + fnv1a(normalized, 0x9e3779b9)
}

SHA-256でなくFNV-1aなのは、ここで守りたいのが改ざん耐性ではなく「同じ内容を二度埋め込まない」確率的保証だからです。32bit単発では数十万チャンクで誕生日衝突が現実的になりますが、独立シード2本で実効64bitにすれば十分。Web Crypto APIの非同期性も避けられ、同期的な純粋関数のまま保てます。正規化はCRLF→LFとtrimのみで、空白の揺れまでは同一視しません。

planReindex: 差分を「計画」として返す

再インデックスの中核は、保存済みハッシュと新規チャンクのハッシュを突き合わせて実行計画を返す純粋関数です。

// 戻り値: { chunks, toEmbed, toDelete, stats }
//   toEmbed:  新規ハッシュのチャンクだけ(これだけ埋め込みAPIに行く)
//   toDelete: もう存在しないハッシュの行(孤児の削除)
const plan = planReindex(storedChunks, incomingContents)

export function isReindexUnchanged(existing: StoredChunk[], incoming: string[]): boolean {
  const plan = planReindex(existing, incoming)
  return plan.stats.embedded === 0 && plan.stats.deleted === 0
}

ファイル全体が無変更ならisReindexUnchangedがtrueになり、埋め込みもDB書き込みも発生しません。計画と実行を分離してあるため、「このreindexで何件埋め込むことになるか」をdry-runでテストでき、キャッシュロジック自体がDBなしでユニットテストされています。

スキーマ側はmarian_file_embeddingscontent_hash text列を足し、(file_id, content_hash)の複合インデックスを張るだけの加法的マイグレーションです。ASK_REINDEX_CACHE=1フラグでオプトインし、列がnullの既存行はキャッシュミス扱いで自然に埋まっていきます。

埋め込みと格納

埋め込みはGemini embedding(768次元)を32件バッチで呼び、marian_file_embeddingsonConflict: (file_id, chunk_idx)のupsertで格納します。upsertキーがチャンク位置なので、同じ位置のチャンクは上書き・増えた分は追加・減った分はplanのtoDeleteで削除、という3経路に整理されます。

外部コネクタ(GitHub / Slack / カレンダー等)からの取り込みも同じ思想で、(vault_id, provider, external_id, content_hash)のユニーク制約により同一コンテンツの再取り込みをDB制約レベルで遮断しています。

dedupは「アプリのif文」ではなく、ハッシュ+ユニーク制約という形でデータ層に沈める。アプリのバグや並行実行があっても、重複はDBが最後に止める。

まとめ

インジェストの設計は、(1)構造を見たチャンキング(見出し・定義・段落・文の階層フォールバック)、(2)file summaryチャンクによるメタデータの検索可能化、(3)FNV-1aデュアルハッシュ+planReindexで埋め込みを差分化、(4)upsert+ユニーク制約で重複をデータ層で遮断、の4点です。再インデックスが支配的という運用の現実から逆算すると、パイプラインの主役は分割でも埋め込みでもなく差分計画になります。

FAQ

content-hashにSHA-256ではなくFNV-1aを使うのはなぜか?
目的が改ざん耐性ではなく「同じ内容を二度埋め込まない」ことだからです。シードの異なるFNV-1a 32bitを2本連結して実効64bitにすれば衝突確率は実用上十分小さく、同期的な純粋関数のまま保てるためテストも容易です。Web Crypto APIの非同期化も避けられます。
ファイルを少し編集したとき、何が再埋め込みされるのか?
planReindexが保存済みチャンクのハッシュと新チャンクのハッシュを突き合わせ、新規ハッシュのチャンクだけをtoEmbedに、消えたハッシュの行をtoDeleteに入れます。変更のない段落のチャンクはハッシュが一致するため埋め込みAPIに送られません。ファイル全体が無変更ならDB書き込み自体が発生しません。
チャンクサイズはどう決まっているのか?
段落ベースで上限1,200字(目標1,400字)、トークンベースのブロック分割では256トークンです。固定長で切らず、Markdownは見出し、コードは定義境界、テキストは段落→文の順にフォールバックして意味の境界を保ちます。1ファイルの本文チャンクは5個までに制限し、先頭にメタデータを詰めたfile summaryチャンクを置きます。

Related Articles