# Marian Engineering Blog — Full Content Canonical index: https://memory.maria-code.ai/llms.txt --- # ハイブリッド検索の4段パイプライン — ACLフィルタ、バイナリ粗探索、int8リランク、RRF融合 URL: https://memory.maria-code.ai/blog/hybrid-retrieval-architecture Published: 2026-06-10 Category: Search & Retrieval Tags: rag, hybrid-search, rrf, acl, vector-search, retrieval Author: Marian Engineering ## Key Takeaways - 検索はACLフィルタ→バイナリ粗探索(200件)→int8リランク(50件)→RRF融合(20件)の4段パイプライン - ACLはスコアリング前に述語適用するfail closed設計で、権限リークを構造的に排除する - RRFはk=60・keyword重み0.85/vector重み1.0で、スコア正規化なしに複数retrieverを統合する - 前提が欠けると自動的に単純な方式へフォールバックし、フラグ有効化が常に安全 ## Body ## 課題: 「ベクトル検索だけ」では足りない RAGの検索品質は、単一のretrieverでは頭打ちになります。dense検索(埋め込み)は言い換えに強い一方で固有名詞や記号に弱く、keyword検索(全文検索)はその逆です。さらにマルチテナント環境では、**検索結果に他人のドキュメントが混ざる権限リーク**が最悪の障害になります。 MarianのAskパイプラインはこの2つを同時に解決するため、検索を4つのステージに分解しています。 ``` クエリ │ ├─ 1. ACLフィルタ … スコアリング前に不可視IDを除外(fail closed) ├─ 2. 粗探索(coarse) … バイナリ符号のHamming距離で上位200件 ├─ 3. リランク(rerank) … int8ドット積で上位50件に絞る └─ 4. RRF融合 … dense / keyword / graph のランキングを統合 → 20件 ``` > 設計原則: ACLは「後でフィルタ」ではなく「最初に遮断」。スコアリングに入る前に不可視IDを落とせば、どのステージにもリークの余地がない。 ## ステージ1: ACLを唯一のチョークポイントにする アクセス制御は `private / space / org / public` の4段階の可視性で表現され、判定は純粋関数 `canViewResource()` に集約されています。デフォルトは拒否(fail closed)です。 ```ts export function canViewResource(resource: ResourceAcl, viewer: Viewer): boolean { const visibility = resource.visibility ?? 'private' if (visibility === 'public') return true if (resource.ownerId === viewer.userId) return true if (resource.allowUserIds?.includes(viewer.userId)) return true if (visibility === 'org') return resource.orgId != null && viewer.orgId != null && resource.orgId === viewer.orgId if (visibility === 'space') return intersects(resource.spaceIds, viewer.spaceIds) return false // deny by default } ``` 実行時には全リソースに対する判定結果を `Set` に事前計算し、`(id) => boolean` の述語にコンパイルします。以降のステージはこの述語をO(1)で参照するだけです。重要なのは、この述語が**粗探索・リランク・融合のすべての入力**に適用されることです。「上位N件を取ってから権限で間引く」方式だと、間引いた分だけ結果が減って品質が劣化するうえ、Nの取り方次第でリークも起きます。 ## ステージ2-3: 2段階の量子化検索 粗探索はfp32ベクトルではなく**1bit量子化したバイナリ符号のHamming距離**で行います(詳細は別記事「バイナリ量子化」で扱います)。768次元が96バイトになるため、大量の候補をメモリ常駐で走査できます。粗探索で200件に絞った後、int8量子化ベクトルのドット積で50件にリランクします。 | ステージ | 表現 | 距離 | 候補数(デフォルト) | | --- | --- | --- | --- | | 粗探索 | binary (1bit/dim, 96B) | Hamming | 200 | | リランク | int8 (+scale) | dot product | 50 | | 融合後 | — | RRFスコア | 20 | ## ステージ4: RRF — スコアを混ぜず、順位を混ぜる dense・keyword・graphという性質の異なるretrieverの結果を統合するとき、素朴に「スコアの重み付き和」を取ると失敗します。コサイン類似度とBM25スコアはスケールも分布も違うため、正規化のチューニングが地獄になるからです。MarianはReciprocal Rank Fusion(RRF)を採用し、**スコアではなく順位だけ**を使います。 ```ts // lib/marian-search/fusion.ts export function reciprocalRankFusion(lists: readonly RankedList[], options: RrfOptions = {}) { const k = options.k ?? 60 // RRF論文由来の平滑化定数 // 各リストの各idについて: contribution = weight / (k + rank) // 全リストで合算し、スコア → ソース数 → id辞書順でソート } ``` k=60はオリジナルのRRF論文の推奨値をそのまま使っています。kが小さいと1位と2位の差が極端になり、大きいと順位差が均されます。Marianではさらにリストごとの重みを持たせており、現在の設定は**keyword 0.85 / vector 1.0**です。dense側をわずかに優先しつつ、固有名詞クエリではkeywordリストの上位が融合結果を支配できるバランスを取っています。 > RRFの利点は「キャリブレーション不要」に尽きる。retrieverを足すときに必要なのは順位リストと重み1つだけで、スコア分布の分析が要らない。 ## グレースフルデグラデーション: フラグの裏で常に動く保険 この4段パイプラインは `ASK_HYBRID=1` のフィーチャーフラグでオプトインです。さらに、有効時でも前提が欠けていれば自動的に1つ前の方式へ畳み込まれます。 - RAPTORツリーが未構築 → 2段階量子化検索へフォールバック - バイナリ符号が未バックフィル → 単段のIVFFlat(fp32コサイン)へフォールバック - ハイブリッド無効 → 従来のpgvectorブレンド(変更なし) フォールバックチェーンの終端(IVFFlat単段)は常に利用可能なので、**新機能のフラグを有効化してもAskが壊れることはない**という不変条件が成り立ちます。検索基盤のような足回りでは、この「有効化が安全」という性質がロールアウト速度を決めます。 ## コンテキスト組み立ての上限値 検索で20件まで絞った後、LLMに渡すコンテキストは文字数ベースの上限で刈り込みます。実際の定数は次のとおりです。 | 定数 | 値 | 意味 | | --- | --- | --- | | MAX_TEXT_FILES | 12 | コンテキストに入れる最大ファイル数 | | MAX_TOTAL_CHARS | 12,000 | コンテキスト合計文字数 | | PER_FILE_CHARS | 2,400 | 1ファイルあたりの上限 | | MAX_SOURCES | 6 | 引用として提示する最大ソース数 | | MAX_MEMORY_RECORDS | 8 | 併載するメモリレコード数 | PER_FILE_CHARSで1ファイルの支配を防ぎ、MAX_TOTAL_CHARSで全体を抑える二重の予算制御です。「検索は広く、コンテキストは狭く」が原則で、絞り込みの責任は融合までのランキングに負わせます。 ## まとめ Marianのハイブリッド検索は、(1)ACL述語をスコアリング前の唯一のチョークポイントにする、(2)量子化2段階で候補を安価に絞る、(3)RRFで順位だけを融合しキャリブレーションを排除する、(4)前提が欠けたら自動で前の方式に畳む、という4つの決定で構成されています。どれも単体では地味ですが、組み合わせると「権限安全で、壊れず、retrieverを足しやすい」検索基盤になります。 ## FAQ Q: なぜスコアの重み付き和ではなくRRF(Reciprocal Rank Fusion)を使うのか? A: コサイン類似度とkeyword検索のスコアはスケールも分布も異なり、重み付き和は正規化のチューニングが必要になるためです。RRFは各retrieverの順位だけを使い、weight/(k+rank)を合算します(Marianはk=60、keyword重み0.85、vector重み1.0)。retrieverを追加しても順位リストを渡すだけで済みます。 Q: マルチテナント環境での権限リークをどう防いでいるのか? A: ACL述語(private/space/org/publicの4段階、デフォルト拒否)をスコアリング前に適用し、不可視IDは粗探索・リランク・融合のどのステージにも入らない設計です。「上位N件取得後にフィルタ」方式と違い、品質劣化もリークの余地も構造的に発生しません。 Q: ASK_HYBRIDフラグを有効にして検索が壊れるリスクはないのか? A: フォールバックチェーンがあるため壊れません。RAPTORツリーがなければ2段階量子化検索へ、バイナリ符号がなければ単段IVFFlat(fp32コサイン)へ自動で畳み込まれます。終端のIVFFlatは常に利用可能なので、フラグ有効化は安全な操作として設計されています。 --- # pgvectorで実装するバイナリ量子化 — 768次元を96バイトに圧縮する2段階検索 URL: https://memory.maria-code.ai/blog/binary-quantization-pgvector Published: 2026-06-08 Category: Search & Retrieval Tags: quantization, pgvector, hnsw, hamming-distance, embeddings, postgres Author: Marian Engineering ## Key Takeaways - 符号1bit量子化で768次元fp32(3KB)を96バイトに圧縮し、Hamming距離で粗探索する - 精度は2段階設計で取り戻す: バイナリで200件→fp32コサインでリランク - pgvectorのbit(768)+HNSW bit_hamming_opsだけで実装でき、専用ベクトルDBは不要 - バイナリ列は既存fp32からSQLで導出でき、再埋め込みコストゼロ・冪等にバックフィルできる ## Body ## 課題: fp32ベクトルはスケールしない Marianの埋め込みはGemini embedding(`gemini-embedding-001`)の768次元です。fp32なら1ベクトル3,072バイト。チャンク数が50万本で約1.5GB、5億本なら1.5TBになり、「全ベクトルをメモリに置いて総当たり」はもちろん、ANNインデックスの構築・保持も重くなっていきます。 解決の方向性は量子化です。ただし量子化は精度を落とすので、**「どこで精度を捨て、どこで取り戻すか」**の設計が本体になります。Marianの答えは2段階検索です。 - **粗探索**: 1bit量子化(96バイト)のHamming距離で候補200件を高速に集める - **リランク**: fp32(またはint8)の正確な距離で上位だけ並べ直す > 量子化の設計とは精度を捨てる場所の設計である。全件に正確な距離は要らない。正確さが要るのは最終上位の数十件だけ。 ## 1bit量子化: 符号だけ残す 量子化関数は「各成分の符号が非負なら1、負なら0」というだけの素朴なものです。768次元 → 96バイト、**圧縮率32倍**。 ```ts // lib/marian-vector/quantize.ts export function quantizeBinary(vector: readonly number[]): Uint8Array { const bytes = new Uint8Array(Math.ceil(vector.length / 8)) for (let i = 0; i < vector.length; i++) { if (vector[i] >= 0) { bytes[i >> 3] |= 0x80 >> (i & 7) // MSB-first: bit 0 はbyte 0の最上位 } } return bytes } ``` なぜこれで検索が成立するのか。高次元埋め込みでは、2つのベクトルのコサイン類似度と「符号が一致する次元の割合」に強い相関があるためです(ランダム超平面LSHと同じ原理)。Hamming距離から類似度への変換は次の式です。 ```ts export function binarySimilarity(a: Uint8Array, b: Uint8Array, bits: number): number { const agree = bits - hammingDistance(a, b) return (2 * agree - bits) / bits // [-1, 1] に正規化(コサインと同レンジ) } ``` Hamming距離自体は、バイトXOR→256エントリの事前計算POPCOUNTテーブル引き、の累積で計算します。1比較あたり96回のXORとテーブル参照だけなので、TypeScript実装でも数十万候補の走査が現実的です。 ## int8リランク: 精度の取り戻し方 バイナリだけだと上位の順序が粗いため、中間段としてint8の対称スカラー量子化も実装されています。ベクトルごとに最大絶対値からスケールを決め、[-127, 127]に丸めます。 ```ts export function quantizeInt8(vector: readonly number[]): Int8Vector { let maxAbs = 0 for (const value of vector) maxAbs = Math.max(maxAbs, Math.abs(value)) const scale = maxAbs / 127 const codes = new Int8Array(vector.length) for (let i = 0; i < vector.length; i++) { codes[i] = Math.max(-127, Math.min(127, Math.round(vector[i] / scale))) } return { codes, scale } } export function dotInt8(a: Int8Vector, b: Int8Vector): number { let acc = 0 for (let i = 0; i < a.codes.length; i++) acc += a.codes[i] * b.codes[i] return acc * a.scale * b.scale // 整数で累積し、最後にスケール積を掛ける } ``` | 表現 | サイズ/768次元 | 距離計算 | 役割 | | --- | --- | --- | --- | | fp32 | 3,072 B | コサイン | 最終リランク・正解基準 | | int8 + scale | 772 B | 整数dot積 | 中間リランク | | binary | 96 B | Hamming | 粗探索(メモリ常駐) | ## PostgreSQL側: bit(768)とHNSW(bit_hamming_ops) サーバー側はpgvector拡張だけで完結します。バイナリ符号は`bit(768)`列に持ち、Hamming距離のHNSWインデックスを張ります。 ```sql alter table public.marian_file_embeddings add column if not exists embedding_binary bit(768); create index if not exists marian_file_embeddings_binary_idx on public.marian_file_embeddings using hnsw (embedding_binary bit_hamming_ops); ``` 2段階検索はRPC関数1つで実装します。CTEでHamming距離(`<~>`演算子)の上位`coarse_count`件を取り、その中をfp32コサイン(`<=>`)で並べ直して`match_count`件を返します。 ```sql create or replace function public.match_marian_files_2stage( query_embedding vector(768), query_binary bit(768), match_user uuid, match_count int default 8, coarse_count int default 200 ) returns table (file_id uuid, relative_path text, content text, similarity float) language sql stable as $$ with coarse as ( select e.file_id, e.relative_path, e.content, e.embedding from public.marian_file_embeddings e where e.user_id = match_user and e.embedding_binary is not null and e.embedding is not null order by e.embedding_binary <~> query_binary -- Hamming距離(HNSW) limit greatest(coarse_count, match_count) ) select c.file_id, c.relative_path, c.content, 1 - (c.embedding <=> query_embedding) as similarity from coarse c order by c.embedding <=> query_embedding -- fp32コサインでリランク limit match_count; $$; ``` 従来の単段検索(IVFFlat・lists=100)はフォールバックとして残してあります。バイナリ列が未整備のユーザーは自動的に単段へ落ちるため、移行期間中も検索は止まりません。 ## バックフィル: 再埋め込みゼロで移行する 既存データへのバイナリ列追加は、**LLM APIを一切呼ばずに**SQLだけで完了します。fp32埋め込みは既にDBにあるので、符号を見てビット文字列を組み立てるだけだからです。 ```sql update public.marian_file_embeddings set embedding_binary = ( select string_agg(case when v >= 0 then '1' else '0' end, '' order by ord) from unnest(embedding::real[]) with ordinality as t(v, ord) )::bit(768) where embedding is not null and embedding_binary is null; ``` `where embedding_binary is null`により冪等で、何度流しても安全です。「新しい表現を導入するとき、既存表現から純粋に導出できる形にしておく」と移行コストが消える、という好例です。 ## まとめ バイナリ量子化の実装は、(1)符号1bit量子化で32倍圧縮、(2)POPCOUNTテーブルによる高速Hamming距離、(3)pgvectorの`bit(768)`+HNSW`bit_hamming_ops`で粗探索、(4)fp32コサインでリランク、(5)SQLだけの冪等バックフィル、という構成です。専用ベクトルDBを足さずに、PostgreSQLの中で大規模検索への道を確保しています。 ## FAQ Q: 1bit量子化でなぜ検索精度が保てるのか? A: 高次元埋め込みではコサイン類似度と符号一致率に強い相関があるためです(ランダム超平面LSHと同じ原理)。さらにMarianは粗探索でしかバイナリを使わず、上位200件をfp32コサインでリランクするため、最終順位の精度はfp32側が保証します。 Q: 既存の埋め込みデータへのバイナリ列追加にコストはかかるのか? A: かかりません。fp32埋め込みがDBに保存済みなので、unnest+string_aggのSQLで符号からビット文字列を導出するだけです。LLM APIの再呼び出しはゼロで、WHERE embedding_binary IS NULLにより冪等に実行できます。 Q: なぜ専用のベクトルDB(Faiss、Qdrant等)を使わないのか? A: pgvectorのbit型+HNSW(bit_hamming_ops)で2段階検索が実装でき、データ・ACL・トランザクションをPostgreSQL一箇所に保てるためです。RPC関数1つで粗探索→リランクが完結し、運用するシステムが増えません。規模がさらに伸びた場合の専用エンジン移行も、量子化層が純粋関数なので差し替え可能です。 --- # RAPTOR要約ツリーの実装 — バイナリ重心クラスタリングとcollapsed-tree検索 URL: https://memory.maria-code.ai/blog/raptor-hierarchical-rag Published: 2026-06-05 Category: Search & Retrieval Tags: raptor, rag, hierarchical-retrieval, clustering, summarization, postgres Author: Marian Engineering ## Key Takeaways - RAPTORツリーで「複数チャンクにまたがる抽象的な質問」に答えられるようにする - 親ノードの重心は配下リーフ数で重み付けしたビット多数決で、小さい枝への偏りを防ぐ - クラスタリングは貪欲最近傍(branching=4)で決定的。品質はリランクで吸収する割り切り - 検索はcollapsed-tree方式で全階層を一括スコアリングし、トラバーサル設計を不要にする - LLM要約は注入分離されており、ツリー構築・検索はLLMなしで完結する ## Body ## 課題: チャンクRAGは「ズームアウト」できない チャンク分割+ベクトル検索のRAGは、答えが特定の段落に書いてある質問には強い。しかし「このプロジェクトの方針は?」「この50ファイルは全体として何を扱っている?」のような**複数チャンクにまたがる抽象的な質問**では、どのチャンクも部分しか持っておらず、検索が空振りします。 RAPTORはこの問題への定番アプローチで、チャンクをクラスタリング→各クラスタを要約→要約をさらにクラスタリング、と再帰して**抽象度の階層**を作ります。質問が具体的ならリーフに、抽象的なら上位の要約ノードにヒットする、という構造です。 ## ツリーのデータ構造 Marianの実装では、各ノードは埋め込みそのものではなく**バイナリ量子化された符号(centroid)**を持ちます。これは検索基盤全体がバイナリ粗探索を前提にしているためで、ツリー検索も同じHamming距離の土俵に乗ります。 ```ts // lib/marian-search/raptor-tree.ts export interface RaptorNode { id: string level: number // 0 = リーフ、上に行くほど抽象 code: Uint8Array // バイナリ重心(MSB-first) bits: number childIds: string[] // リーフでは空 size: number // 配下のリーフ数(リーフは1) summary?: string // LLM生成の要約(任意注入) } export interface RaptorTree { nodes: Map rootIds: string[] bits: number levels: number } ``` ## 親ノードの重心: 葉数で重み付けした多数決 親ノードのバイナリ重心は、子ノードのビットごとの多数決で決めます。ただし単純な子ノード数ではなく、**配下のリーフ数(size)で重み付け**します。 ```ts function weightedCentroid(children: readonly RaptorNode[], bits: number): Uint8Array { const code = new Uint8Array(Math.ceil(bits / 8)) const total = children.reduce((sum, child) => sum + child.size, 0) for (let b = 0; b < bits; b++) { let ones = 0 for (const child of children) if (bitAt(child.code, b)) ones += child.size if (ones * 2 >= total) setBit(code, b) // 配下リーフの過半数が立っていれば1 } return code } ``` 重み付けしない多数決だと、リーフ1枚だけの小さな子と100枚を束ねる子が同じ1票になり、重心が小さい枝に引っ張られます。葉数加重により、重心は常に「配下の実データの分布」を反映します。 ## クラスタリング: 貪欲最近傍で十分という割り切り オリジナルのRAPTOR論文はGMM(ガウス混合)+UMAPでソフトクラスタリングしますが、Marianの実装は**シードを起点にHamming類似度の近い未割当ノードをbranching数(デフォルト4)まで取る**だけの貪欲法です。 ```ts function nearestCluster(seed: number, level: RaptorNode[], assigned: boolean[], branching: number, bits: number): number[] { const candidates = [] for (let j = 0; j < level.length; j++) { if (assigned[j] || j === seed) continue candidates.push({ index: j, score: binarySimilarity(level[seed].code, level[j].code, bits) }) } candidates.sort((a, b) => b.score - a.score || a.index - b.index) return [seed, ...candidates.slice(0, branching - 1).map((c) => c.index)] } ``` この割り切りには2つの理由があります。第一に、クラスタリングの良し悪しは最終的にリランクで吸収されるため、粗くても検索品質への影響が限定的であること。第二に、**決定的(deterministic)であること**。同じ入力からは常に同じツリーが生まれ、テストもインクリメンタル再構築も単純になります。ソート時のタイブレークにインデックス順を使っているのも決定性のためです。 | パラメータ | デフォルト | 意味 | | --- | --- | --- | | branching | 4 | 1親あたりの子ノード数 | | minTopNodes | 1 | これ以下になったら積み上げ終了 | | maxLeaves | 50,000 | 1回のビルドで扱うリーフ上限 | | nodeTopK | 16 | 検索時にスコアするノード数 | | maxCandidates | 300 | リーフ展開後の候補上限 | ## collapsed-tree検索: 階層を辿らない 検索時はツリーを上から辿りません。**全階層の全ノードを一括でスコアリング**し、上位nodeTopK件を取ってからリーフに展開します(collapsed-tree方式)。 ```ts export function collapsedSearch(tree: RaptorTree, queryCode: Uint8Array, options = {}) { const hits: RaptorHit[] = [] for (const node of tree.nodes.values()) { hits.push({ id: node.id, level: node.level, size: node.size, score: binarySimilarity(queryCode, node.code, tree.bits) }) } hits.sort((a, b) => b.score - a.score || b.size - a.size || a.id.localeCompare(b.id)) return hits.slice(0, topK) } ``` ルートから貪欲に降りる方式は、上位階層で1回判断を誤ると正解の枝に二度と戻れません。collapsed方式なら「具体的な質問はリーフに、抽象的な質問は要約ノードに」が**検索スコアの自然な結果**として起き、トラバーサルポリシーの設計が丸ごと不要になります。同点時にsizeの大きいノードを優先するのは、迷ったら広いカバレッジを取るという方針です。 ## 永続化: ツリーもただのテーブル ツリーはPostgresの1テーブルに、ノード1行で永続化します。バイナリ重心は`embedding_binary`と同じMSB-firstビット文字列です。 ```sql create table if not exists public.marian_file_tree_nodes ( user_id uuid not null references auth.users(id) on delete cascade, node_id text not null, level int not null, size int not null, bits int not null, child_ids text[] not null default '{}', code text not null, -- MSB-firstビット文字列 summary text, is_root boolean not null default false, updated_at timestamptz not null default now(), primary key (user_id, node_id) ); ``` 要約文(summary)の生成はビルド関数に`summarize`コールバックとして**任意注入**します。LLMなしでもツリーは構築でき(重心とサイズだけで検索は機能する)、要約はあとから埋められる。LLM依存を構造から分離するこの設計のおかげで、ツリー構築はユニットテスト可能な純粋関数になっています。 ## まとめ MarianのRAPTOR実装は、(1)バイナリ重心による安価なクラスタリング、(2)葉数加重多数決で分布を保つ重心、(3)決定的な貪欲クラスタリング、(4)トラバーサル不要のcollapsed-tree検索、(5)LLM要約の注入分離、で構成されます。`ASK_RAPTOR=1`でオプトインし、ツリーがなければ通常検索に自動フォールバックするため、既存のAskを壊さずに「広い質問」への回答能力を追加できます。 ## FAQ Q: RAPTORは通常のチャンクRAGと何が違うのか? A: チャンクRAGは特定の段落に答えがある質問にしか強くありません。RAPTORはチャンクをクラスタリングして要約を再帰的に積み上げ、抽象度の階層を作ります。具体的な質問はリーフに、「全体として何を言っているか」のような広い質問は上位の要約ノードにヒットします。 Q: なぜ検索時にツリーを上から辿らず、全ノードを一括スコアリングするのか? A: 貪欲に降りる方式は上位階層での判断ミスから回復できないためです。collapsed-tree方式は全階層のノードを同じ土俵でスコアリングし、質問の抽象度に合った階層のノードが自然に上位に来ます。トラバーサルポリシーの設計自体が不要になります。 Q: LLMによる要約生成が失敗したらツリーは使えなくなるのか? A: 使えます。要約はsummarizeコールバックとして任意注入する設計で、ツリーの構築と検索はバイナリ重心とリーフ数だけで機能します。要約文は後から非同期に埋められるため、LLM障害がインデックス構築をブロックしません。 --- # インジェストパイプラインとcontent-hashキャッシュ — 再インデックスを差分だけにする URL: https://memory.maria-code.ai/blog/ingest-pipeline-content-hash Published: 2026-06-02 Category: Engineering Tags: ingest, chunking, embeddings, content-hash, dedup, rag Author: Marian Engineering ## Key Takeaways - 再インデックスが支配的ワークロード。分割は毎回・埋め込みは差分だけという非対称設計にする - チャンクはファイル種別の構造(見出し/定義/段落/文)で分割し、file summaryチャンクでメタデータも検索可能にする - FNV-1aデュアルハッシュ(実効64bit)+planReindexで、変更のないチャンクの再埋め込みをゼロにする - 重複排除はアプリロジックでなくハッシュ+DBユニーク制約としてデータ層に沈める ## Body ## 課題: インデックスは作るより「作り直す」方が多い RAGのインジェストは初回投入よりも、ファイルが少し変わるたびの**再インデックス**が支配的なワークロードです。素朴な実装は毎回全チャンクを再埋め込みしますが、埋め込みAPIは有料でレイテンシもあるため、1文字の変更で数百チャンクを埋め込み直すのは受け入れられません。 Marianのインジェストは「分割は安く、埋め込みは高い」という非対称を前提に、**分割は毎回やり直し、埋め込みは差分だけ**という構成を取ります。 ## チャンキング: ファイル種別ごとの分割戦略 チャンクは検索の最小単位なので、固定長で機械的に切ると見出しと本文が泣き別れます。Marianはファイル種別ごとに構造を見て分割します。 - **Markdown**: `#`〜`######`の見出しでセクション分割→セクション内を段落単位でパッキング - **コード**: function / class / type / const 等の定義境界で分割 - **プレーンテキスト**: 段落区切り(空行)→文境界(`(?<=[.!?])\s+`)の順でフォールバック - **PDF**: ページ→ブロック構造化。表・図は単独チャンクとして分離(後述) さらに各ファイルの先頭には、パス・フォルダ・見出しアウトライン・キーワード・冒頭500字を詰めた**file summaryチャンク**を1つ生成します。「このファイルは何か」という質問にチャンク単位で答えるための、メタデータの埋め込み化です。 | 定数 | 値 | 意味 | | --- | --- | --- | | CHUNK_CHARS | 1,400 | ファイルチャンクの目標文字数 | | maxChars(段落分割) | 1,200 | 段落ベース分割の上限 | | 最小テール | 200 | 残りがこれ未満なら分割しない | | maxTokens(ブロック分割) | 256 | トークン基準分割の上限 | | MAX_BODY_CHUNKS_PER_FILE | 5 | 1ファイルの本文チャンク上限 | | EMBED_BATCH | 32 | 埋め込みAPIのバッチサイズ | トークン数は`ceil(単語数 / 0.75)`(約1.33トークン/語)で見積もります。正確なトークナイザではなく見積もりで十分なのは、上限値側に余裕を持たせてあるからです。 ## content-hash: 変わっていないものを埋め込まない 差分検出の鍵はチャンク内容のハッシュです。Marianは暗号学的ハッシュではなく、**シードの異なるFNV-1a 32bitを2本連結した16桁hex**を使います。 ```ts // 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: 差分を「計画」として返す 再インデックスの中核は、保存済みハッシュと新規チャンクのハッシュを突き合わせて**実行計画を返す純粋関数**です。 ```ts // 戻り値: { 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_embeddings`に`content_hash text`列を足し、`(file_id, content_hash)`の複合インデックスを張るだけの**加法的マイグレーション**です。`ASK_REINDEX_CACHE=1`フラグでオプトインし、列がnullの既存行はキャッシュミス扱いで自然に埋まっていきます。 ## 埋め込みと格納 埋め込みはGemini embedding(768次元)を**32件バッチ**で呼び、`marian_file_embeddings`へ`onConflict: (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 Q: content-hashにSHA-256ではなくFNV-1aを使うのはなぜか? A: 目的が改ざん耐性ではなく「同じ内容を二度埋め込まない」ことだからです。シードの異なるFNV-1a 32bitを2本連結して実効64bitにすれば衝突確率は実用上十分小さく、同期的な純粋関数のまま保てるためテストも容易です。Web Crypto APIの非同期化も避けられます。 Q: ファイルを少し編集したとき、何が再埋め込みされるのか? A: planReindexが保存済みチャンクのハッシュと新チャンクのハッシュを突き合わせ、新規ハッシュのチャンクだけをtoEmbedに、消えたハッシュの行をtoDeleteに入れます。変更のない段落のチャンクはハッシュが一致するため埋め込みAPIに送られません。ファイル全体が無変更ならDB書き込み自体が発生しません。 Q: チャンクサイズはどう決まっているのか? A: 段落ベースで上限1,200字(目標1,400字)、トークンベースのブロック分割では256トークンです。固定長で切らず、Markdownは見出し、コードは定義境界、テキストは段落→文の順にフォールバックして意味の境界を保ちます。1ファイルの本文チャンクは5個までに制限し、先頭にメタデータを詰めたfile summaryチャンクを置きます。 --- # Postgresだけで作るジョブキュー — リース、冪等キー、指数バックオフの実装 URL: https://memory.maria-code.ai/blog/postgres-job-queue Published: 2026-05-30 Category: Engineering Tags: job-queue, postgres, supabase, idempotency, backoff, async Author: Marian Engineering ## Key Takeaways - Postgresテーブル1枚+インデックス4本で冪等投入・優先度クレーム・クラッシュ回復・バックオフ・デッドレターを実装できる - 冪等性は部分ユニークインデックス(where status <> dead)でDB層に沈め、dead後の再投入も両立する - クラッシュ回復はリース方式(30秒)で、ロックもハートビートも不要 - バックオフはrun_afterを未来に置くだけ(1s→2倍→5分cap)。スケジューリングの語彙が1つに統一される - 状態遷移は時刻を引数に取る純粋関数で、DBなしでテストできる ## Body ## 課題: キューは欲しい、インフラは増やしたくない AIアプリの裏側は非同期ジョブだらけです。埋め込み生成(embed)、音声転写(transcribe)、外部コネクタ同期(connector-sync)、再インデックス(reindex)。これらをリクエスト内で実行すればタイムアウトし、fire-and-forgetにすれば失敗が闇に消えます。 かといってRedis+BullMQやSQSを足すと、運用するシステムとデータの整合性境界が増えます。Marianの選択は**Postgres(Supabase)のテーブル1枚をキューにする**ことでした。ジョブのペイロードが参照するデータと同じDBにあるため、トランザクション整合性が自然に手に入ります。 ## スキーマ: 列の1本1本が設計判断 ```sql create table public.marian_jobs ( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users (id) on delete cascade, type text not null, status text not null default 'queued' check (status in ('queued','running','retrying','succeeded','dead')), payload jsonb not null default '{}'::jsonb, idempotency_key text, attempts integer not null default 0, max_attempts integer not null default 3, priority integer not null default 0, run_after timestamptz not null default now(), lease_until timestamptz, error text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); ``` 状態は5つ。`queued → running → succeeded`が正常系、失敗時は`retrying`(run_afterを未来に置いて再投入)を経由し、`attempts >= max_attempts`で`dead`(デッドレター)に落ちます。 ## 冪等性: 部分ユニークインデックスに任せる 「同じジョブを二度積まない」はアプリのチェックではなく、DBの**部分ユニークインデックス**で保証します。 ```sql create unique index marian_jobs_idem_active_idx on public.marian_jobs (idempotency_key) where status <> 'dead'; ``` ポイントは`where status <> dead`の部分性です。生きているジョブ(待機・実行・成功)の間は同じキーの再投入が既存行にdedupされ、デッドレター化した後は**同じキーで再投入できる**。「失敗しきったジョブのやり直し」と「二重投入の防止」が1本のインデックスで両立します。enqueue側は同キーの既存ジョブが見つかればそれを返すだけです。 ## リース: ロックを持たないクラッシュ回復 ワーカーがジョブを掴んだままクラッシュしたとき、そのジョブを誰がいつ救出するか。Marianは行ロックを保持し続けるのではなく、**可視性タイムアウト(リース)**方式を取ります。 ```ts const DEFAULT_LEASE_MS = 30_000 // 30秒 // claim時: lease_until = now + leaseMs を刻んで running へ // reclaimExpired: running かつ lease_until <= now の行を retrying に戻す ``` ワーカーはジョブを取るときに`lease_until`を30秒先に設定します。正常なら完了時に`succeeded`へ。クラッシュしたらリースが切れ、次のtickの`reclaimExpired`が`retrying`へ戻します。ワーカー間の調整も、ハートビートも、分散ロックも不要です。SQSのvisibility timeoutと同じモデルをテーブルで再現しています。 クレームの競合は`(status, run_after, priority desc, created_at)`のインデックスに沿った取得と`FOR UPDATE SKIP LOCKED`相当の挙動で捌きます。`run_after <= now`が「実行可能」の定義なので、バックオフは単に`run_after`を未来に置くことです。 ## バックオフ: 1秒から5分まで ```ts export function backoffMs(attempt: number, options: BackoffOptions = {}): number { const base = options.baseMs ?? 1000 const factor = options.factor ?? 2 const max = options.maxMs ?? 5 * 60_000 return Math.min(base * Math.pow(factor, Math.max(0, attempt - 1)), max) } // スケジュール: 1s, 2s, 4s, 8s, 16s, ... 上限5分 ``` 対象ジョブ(埋め込みAPI、外部コネクタ)の失敗は大半がレート制限か一時障害なので、序盤は密に・後半は粗く再試行し、5分でcapします。max_attempts(デフォルト3)を使い切ると`dead`へ移り、`error`列に最後の失敗理由が残ります。 ## 状態遷移は純粋関数、永続化はアダプタ 実装の分層も特徴的です。状態遷移(claim、complete、fail、reclaim)は`lib/marian-data/job-queue.ts`の**純粋関数**として書かれ、現在時刻も常に引数で渡されます。Supabaseへの永続化は`store-supabase.ts`のアダプタが担い、ワーカーは「1 tick = 実行可能ジョブを1件claimして処理」のループです。 - 純粋層: 遷移ロジックをクロックなし・DBなしでユニットテスト可能 - アダプタ層: Supabase固有のクエリとservice-roleキーの扱いを隔離 - ワーカー層: tick単位の実行。テストではtickを手動で回す | インデックス | 列 | 担当パス | | --- | --- | --- | | idem_active_idx | (idempotency_key) where status <> dead | 二重投入防止 | | runnable_idx | (status, run_after, priority desc, created_at) | claim(ホットパス) | | lease_idx | (status, lease_until) | リース切れ回収 | | user_status_idx | (user_id, status) | ユーザー別の一覧表示 | RLS(Row Level Security)は「ユーザーは自分のジョブだけ見える」を強制し、ワーカーはservice-roleキーでRLSをバイパスします。ジョブ一覧UIはanonキーで安全に同じテーブルを読めます。 ## このアプローチの限界 Postgresキューはスループットの上限が比較的早く来ます(ポーリング間隔とクレーム競合)。毎秒数千ジョブを捌く規模になったら専用キューに移すべきです。ただしMarianのジョブは「ユーザー操作起点の数十〜数百件バースト」が中心で、**整合性とインフラの少なさがスループットより価値が高い**領域です。純粋層とアダプタ層が分離されているため、将来ストアを差し替えても状態機械は再利用できます。 ## まとめ テーブル1枚+インデックス4本で、冪等な投入・優先度付きクレーム・クラッシュ回復・指数バックオフ・デッドレターを備えたキューになります。鍵は(1)部分ユニークインデックスによる「生きている間だけ」の冪等性、(2)リース方式のクラッシュ回復、(3)run_afterに統一されたスケジューリング、(4)純粋関数とアダプタの分離、です。 ## FAQ Q: RedisやSQSではなくPostgresでジョブキューを作る理由は? A: ジョブが参照するデータと同じDBにキューがあるとトランザクション整合性が自然に手に入り、運用するインフラも増えないためです。Marianのワークロードはユーザー操作起点のバースト(数十〜数百件)が中心で、Postgresキューのスループット上限が問題になりません。状態機械は純粋関数として分離してあるため、規模が変わればストアだけ差し替えられます。 Q: ワーカーがクラッシュしたジョブはどう回復されるのか? A: リース(可視性タイムアウト)方式です。claim時にlease_untilを30秒先に設定し、完了しないままリースが切れた running ジョブは reclaimExpired が retrying に戻します。分散ロックやハートビートは不要で、(status, lease_until)のインデックスを見るだけで回収できます。 Q: 同じジョブの二重投入はどう防いでいるのか? A: idempotency_keyの部分ユニークインデックス(where status <> dead)で防ぎます。アクティブな同キージョブがある間の再投入は既存ジョブにdedupされ、デッドレター化後は同じキーで再投入できます。アプリのチェックではなくDB制約なので、並行実行でも破れません。 --- # エージェントメモリの設計 — 7軸サリエンス、半減期減衰、エピソードからポリシーへ URL: https://memory.maria-code.ai/blog/agent-memory-episodes Published: 2026-05-27 Category: AI & Agents Tags: agent-memory, episodic-memory, salience, memory-consolidation, ai-agents Author: Marian Engineering ## Key Takeaways - 記憶システムの本体は書き込みではなく価値の経時管理(サリエンス・昇格・忘却) - 7軸サリエンスは軸ごとに半減期が異なり(urgency 7日〜sensitivity 365日)、時間構造を表現する - consolidationは「2回繰り返した」または「一度でもsalience 0.8以上」で短期→長期へ昇格させる - distillationは削除せず抽象度を上げる忘却。原本はprovenanceとして残る - 全判定が時刻を引数に取る純粋関数で、記憶のライフサイクルをユニットテストできる ## Body ## 課題: 記憶は貯めるほど劣化する エージェントに長期記憶を持たせる最も素朴な方法は「会話から事実を抽出して全部ベクトルDBへ」ですが、これは数週間でノイズの山になります。古い決定と新しい決定が同じ重みで並び、雑談由来の記憶が重要な制約を埋もれさせる。**記憶システムの本体は書き込みではなく、価値の経時管理**です。 Marianのメモリ基盤は3つの問いに分けて設計されています。(1)何をどのくらいの強さで覚えるか(サリエンス)、(2)どう思い出すか(リコール)、(3)どう忘れる/抽象化するか(consolidation / distillation)。 ## エピソードのスキーマ 記憶の単位は「エピソード」です。`marian_memory_episodes`テーブルに、出来事の叙述・結果・関与者・時間バケットを持ちます。 ```sql create table marian_memory_episodes ( id text primary key, user_id uuid not null, type text default 'note', -- meeting | decision | incident | deal | ... narrative text not null, outcome text, -- positive | negative | pending actors text[], occurred_at date, time_buckets text[], -- ['2026', '2026-06', '2026-06-07'] importance real, -- [0,1] risk real -- [0,1] ); -- generated column: importance_band = high(>=0.7) | med(>=0.4) | low -- GINインデックス: actors, time_buckets ``` `time_buckets`が年・月・日の3粒度の配列なのは、「2026年6月の出来事」「先週の」のような時間キューでGIN索引を引けるようにするためです。日付演算をクエリ時にやるのではなく、書き込み時に検索キーへ展開しておきます。 ## サリエンス: 7軸の重み付き合成 記憶の強さは単一スコアではなく、7軸のベクトルとして持ちます。合成時の重みは固定です。 | 軸 | 重み | 半減期(日) | | --- | --- | --- | | importance(重要度) | 0.28 | 180 | | impact(影響度) | 0.22 | 45 | | risk(リスク) | 0.18 | 180 | | urgency(緊急度) | 0.12 | 7 | | novelty(新規性) | 0.08 | 14 | | confidence(確信度) | 0.10 | 60 | | sensitivity(機密度) | 0.02 | 365 | ```ts compositeSalience(s) = Σ(weight[axis] × s[axis]) × (0.6 + 0.4 × s.confidence) isProtected(s) = s.risk >= 0.7 || s.importance >= 0.7 ``` confidenceが**部分ゲート**(0.6〜1.0の乗数)になっているのが特徴です。確信のない記憶は合成スコアを最大40%減衰しますが、ゼロにはしません。「不確かだが重要かもしれない」記憶を完全には殺さない設計です。`isProtected`はリスクか重要度が0.7以上の記憶を後述の忘却から保護します。 ## リコール: キュー索引で「全件走査しない想起」 想起は「actor=顧客A、time=2026-06、outcome=negative」のような**キューの積集合**で行います。実装は次元ごとの転置インデックス(ポスティングリスト)です。 ```ts // lib/marian-memory/cue-index.ts // dims: Map<次元, Map<値, Set<エピソードid>>> prune(index, query) → 1. 各キューをポスティングリストに解決(次元内はunion) 2. 選択率の高い(=小さい)リスト順にソート 3. 最小リストを起点に積集合 → examined ≈ 最も選択的なキューのリスト長(全件Nに非依存) ``` 計算コストの主部が「最も選択的なキューのポスティングリスト長」になるため、エピソードが100万件あってもリコールで実際に調べるのは数件〜数十件です。pruneの戻り値には`examined`と`fullScan`(全件走査した場合のコスト)が含まれており、**リコール効率の回帰テスト**ができます。 ## 書き込み: LLMなしのヒューリスティック抽出 Askの質問応答から記憶候補を作る`extractMemory`は、LLMを呼ばず正規表現ベースの分類で動きます。 - 選好・決定(`I prefer / we decided / 私の◯◯は`)→ 長期記憶、salience 0.8 - 定義(`XはYである`)→ 長期記憶、salience 0.65 - 曖昧性解消(`ここでのXは〜の意味`)→ セッション限定の短期記憶 - 挨拶・相槌 → 破棄 短期記憶のデフォルトTTLは**14日**。この段階の精度は粗くてよく、価値の判定は次のconsolidationに委ねます。書き込みを安価で決定的にしておくことで、抽出パイプラインがLLMのレイテンシ・コスト・非決定性から切り離されます。 ## consolidation: 夜3時の「組織の睡眠」 毎日03:00のスケジュールジョブが短期記憶を走査し、長期記憶へ昇格させます。判定は純粋関数です。 ```ts // lib/marian-memory/consolidate.ts (デフォルト値) // minOccurrences: 2, salienceFloor: 0.8, minConfidence: 0.4 subject(または content先頭60字)でグループ化し、 (出現回数 >= 2 または 最大salience >= 0.8) かつ 平均confidence >= 0.4 → 長期へ昇格(内容マージ、salienceはmax、confidenceは平均) それ以外 → 短期のまま(TTLで自然消滅) ``` 「繰り返し現れた」か「一度でも強烈だった」かのどちらかで昇格する、という2経路です。人間の記憶の固定化(反復学習と一発学習)と同じ構造を、しきい値2つで表現しています。 ## distillation: 削除しない忘却 週次ジョブは古く参照されない記憶のサリエンスを軸別半減期で減衰させ、床値(0.35)を下回ったものを**抽象度の階段**で1段上げます: `raw → summary → pattern → principle → policy`。 urgencyは7日、importanceは180日という半減期の差により、「先週は緊急だった」はすぐ消え、「重要だった」は半年残ります。重要なのは**元のエピソードを削除しない**ことです。抽象化されたレコードが前面に出て、原本は来歴(provenance)として保持されます。`isProtected`な記憶は減衰対象から除外されます。 ## エピソードからポリシーへ 最終段では、結果(outcome)つきエピソード群から行動方針を蒸留します。negative outcomeの共通原因は`mitigate`(回避)ポリシーに、positiveは`reinforce`(強化)に。最低支持数2、confidenceは`count/(count+1) + salienceSum/(count×4)`で支持数とともに漸近します。生成されたポリシー群は矛盾検出(同一主題で否定の偶奇が逆)を通り、新しい状況の判断時に`judge(situation, policies)`が根拠エピソードつきで適用ポリシーを返します。 > 記憶パイプラインの終着点は「思い出せること」ではなく「次の判断に効くこと」。エピソード→パターン→ポリシーという蒸留がその経路になる。 ## まとめ Marianのメモリ基盤は、(1)7軸サリエンス+confidenceゲートで記憶の価値をベクトルとして保持、(2)転置キュー索引でN非依存のリコール、(3)反復または強度で昇格するconsolidation、(4)軸別半減期+抽象度ラダーによる削除しない忘却、(5)エピソード→ポリシーの蒸留、で構成されます。すべての判定が時刻を引数に取る純粋関数なので、1年分の記憶のライフサイクルを1秒のユニットテストで検証できます。 ## FAQ Q: なぜ記憶のスコアを単一の数値ではなく7軸で持つのか? A: 減衰速度が軸ごとに違うからです。緊急度は半減期7日で消えるべきですが、重要度とリスクは180日残すべきです。単一スコアではこの時間構造を表現できません。合成が必要な場面ではcompositeSalience(重み付き和×confidenceゲート)に畳み、リスクまたは重要度0.7以上は忘却から保護します。 Q: 記憶が増えても想起が遅くならないのはなぜか? A: actor・time・outcome・typeの各次元に転置インデックス(ポスティングリスト)を持ち、最も選択的なキューのリストを起点に積集合を取るためです。実際に調べる件数は最小ポスティングリスト長に比例し、総エピソード数Nには依存しません。pruneはexaminedとfullScanのコストを返すので、効率の回帰テストも可能です。 Q: 古い記憶は削除されるのか? A: 削除されません。週次のdistillationジョブが半減期減衰後のサリエンスが床値(0.35)を下回った記憶をraw→summary→pattern→principle→policyの抽象度ラダーで1段引き上げ、原本は来歴として保持します。検索の前面には抽象化された記憶が出て、詳細が必要なときだけ原本に降りられます。 --- # エージェントAPIに「責任境界」を埋め込む — エンベロープ・プロトコルの設計 URL: https://memory.maria-code.ai/blog/envelope-protocol-multi-agent Published: 2026-05-24 Category: AI & Agents Tags: multi-agent, agent-protocol, governance, api-design, observability Author: Marian Engineering ## Key Takeaways - 全API入出力を共通メタデータ付きの封筒で包み、trace・発信元・権限を構造的に強制する - G.U.P.Z.A形式の座標がエージェントの住所になり、集計・ポリシー適用の単位として機能する - 責任境界はblocked/avoid/suggest_only/reconfirm/allowedの5段階で、can と should のずれを表現する - 規約はレジストリ+withEnvelopeアダプタ+静的ハーネスの3点セットで機械的に守らせる - 生JSONも受け付ける後方互換(受信は寛容に、送信は厳格に)で段階移行する ## Body ## 課題: エージェントAPIは「ただのJSON」では追跡できない エージェントが増えると、API呼び出しの様相が変わります。人間のクライアント1種類ではなく、エージェントがエージェントを呼び、その結果がさらに別のエージェントに渡る。このとき素のJSONボディだけだと、(1)リクエストの連鎖を横断するtraceが取れない、(2)「誰が」呼んだかの語彙がない、(3)エージェントごとの権限の宣言場所がない、という3つの欠落が運用を直撃します。 Marianの答えは、全APIの入出力を**エンベロープ(封筒)**で包むことです。中身(payload)が何であれ、封筒には必ず同じメタデータが書いてあります。 ```ts // lib/marian-protocol/envelope.ts — marian.envelope.v1 RequestEnvelope = { kind: 'request', meta: EnvelopeMeta, payload: T } ResponseEnvelope = { kind: 'response', meta: EnvelopeMeta, status: 'ok' | 'error' | 'rejected', payload: T | null, error: { code, message, retryable? } | null, boundary: 'blocked' | null } EnvelopeMeta = { protocol: 'marian.envelope.v1', id: string, // env_* 封筒ごとに一意 traceId: string, // trace_* リクエスト→レスポンス→次のホップを貫通 issuedAt: string, source: string, // 例: 'marian-client' target: string, // ルートid 例: 'marian-ai' coordinate: string, // 例: 'G1.U1.P2.Z1.A1' capability: string, // 例: 'ai:chat' } ``` ## 座標系: エージェントに住所を与える `coordinate`は`G{galaxy}.U{universe}.P{planet}.Z{zone}.A{agent}`形式の階層アドレスです。Planetがプロダクト領域(P1=knowledge、P2=ai、P3=ingest、P5=agents、P9=memory…)、Zoneがケイパビリティ群、Agentが個々のハンドラに対応します。現在13のAPIサーフェスがレジストリに登録されています。 | surface | coordinate | capability | | --- | --- | --- | | ask | G1.U1.P1.Z1.A1 | ask:query | | ask-index | G1.U1.P1.Z1.A2 | ask:index | | marian-ai | G1.U1.P2.Z1.A1 | ai:chat | | marian-ingest | G1.U1.P3.Z1.A1 | ingest:source | | marian-agents | G1.U1.P5.Z1.A1 | agents:run | | marian-memory | G1.U1.P9.Z1.A1 | memory:read | UUIDでもURLパスでもなく構造化座標にする利点は、**集計とポリシーの単位が住所から読める**ことです。「P3(ingest)配下の全エージェントのエラー率」「Z1ゾーンに新しい権限を一括付与」のような操作が、命名規約だけで可能になります。 ## 5段階の責任境界 このプロトコルの中心は、各エージェントが宣言する**責任エンベロープ**です。能力(できること)と権限(してよいこと)を分離し、グレーゾーンを2値ではなく5段階で表現します。 ```ts ResponsibilityEnvelope = { agentId, coordinate, role, grantedCapabilities: string[], // 付与された能力 naturalAvoidances: string[], // 技術的には可能だが姿勢として避ける suggestOnly: string[], // 起案はするが決定は人間 reconfirmBefore: string[], // 実行前に平易な言葉で再確認 hardBoundaries: string[], // 絶対禁止 } // 判定の優先順位(上から評価) hardBoundary or 未付与capability → boundary: 'blocked', emission: 'none' naturalAvoidance → boundary: 'avoid', emission: 'reconfirmation_request' suggestOnly → boundary: 'suggest_only', emission: 'proposal' reconfirmBefore → boundary: 'reconfirm', emission: 'reconfirmation_request' それ以外 → boundary: 'allowed', emission: 'execute' ``` 注目すべきは`blocked`と`allowed`の間にある3段階です。リサーチ系エージェントなら「出典の捏造」はhard boundary、「最終結論を出す」はsuggest only、「外部送信・公開」はreconfirm。「technically can(技術的に可能)」と「should(すべき)」のずれを、コードに書ける語彙にしたものです。判定結果はレスポンス封筒の`boundary`フィールドと`status: rejected`で呼び出し側に伝わるため、**拒否もまた構造化データ**になります。 ## withEnvelope: ルート実装をボイラープレートから解放する Next.jsのRoute Handlerは`withEnvelope`アダプタで包みます。封筒の解析・検証・trace付与・エラー整形はアダプタが行い、ハンドラはpayloadと`evaluate`(境界判定関数)だけを受け取ります。 ```ts export const POST = withEnvelope('ask', async ({ payload, evaluate }) => { if (!payload.question) throw new EnvelopeHttpError(400, 'missing_question', 'Question required.') // evaluate(action) → { boundary, permittedEmission, reason } return runAsk(payload) }) ``` ストリーミング応答には`withEnvelopeStream`という変種があります。SSE/NDJSONのボディはそのまま流し、封筒メタデータは`x-marian-trace`・`x-marian-coordinate`・`x-marian-capability`ヘッダに載せ替えます。ストリーム開始前のエラーだけJSON封筒で返す、という折衷で**「全応答が封筒を持つ」不変条件をストリーミングでも維持**します。 後方互換も重要な設計判断です。`unwrapPayload()`は生のJSONボディも封筒入りボディも受け付けるため、既存クライアントは壊れず、レスポンスは常に封筒付きで返ります。移行は「受信は寛容に、送信は厳格に」の一方通行で進みます。 ## プロトコルは書くだけでは守られない — 静的検証ハーネス この種の規約の最大の敵は「新しいルートが規約を知らずに生える」ことです。Marianには2つの静的ハーネスがあり、CIでルート実装とレジストリを突き合わせます。 - **APIコントラクトハーネス**: 全route.tsを走査し、未登録サーフェス、withEnvelope未採用、try/catchなしの.json()読み、検証なしのボディ読みなどを検出。error -0.6 / warning -0.15のペナルティでスコア化 - **エンベロープハーネス**: レジストリ側を検証。座標の重複・パース不能、capability未付与、責任境界4配列(avoidances/suggestOnly/reconfirmBefore/hardBoundaries)の欠落を検出 > プロトコルの実体はドキュメントではなく、レジストリ(唯一の真実)+アダプタ(実装の強制)+ハーネス(逸脱の検出)の3点セットである。 ## まとめ エンベロープ・プロトコルは、(1)全API入出力に共通メタデータ(trace・座標・capability)を強制する封筒、(2)blocked/avoid/suggest_only/reconfirm/allowedの5段階責任境界、(3)withEnvelope/withEnvelopeStreamによる実装の一律化、(4)静的ハーネスによる規約の機械的検証、で構成されます。エージェントが増えるほど、この「全員が同じ封筒を使う」制約が可観測性とガバナンスの土台になります。 ## FAQ Q: エンベロープ・プロトコルは何を解決するのか? A: マルチエージェント環境での(1)リクエスト連鎖を貫通するtrace、(2)発信元・宛先・権限の標準語彙、(3)エージェントごとの責任境界の宣言場所、の3つの欠落を解決します。全APIの入出力がprotocol/id/traceId/source/target/coordinate/capabilityを持つ封筒で包まれ、拒否応答もboundary付きの構造化データとして返ります。 Q: なぜ権限を許可/禁止の2値ではなく5段階にするのか? A: エージェントの行動には「技術的には可能だが姿勢として避ける(avoid)」「起案はするが決定は人間(suggest_only)」「実行前に平易な言葉で再確認(reconfirm)」というグレーゾーンが本質的に存在するためです。2値ではこれらをすべてblockedに倒すか、危険を許可するかの二択になります。5段階なら判定ロジックが優先順位つきの決定木として実装できます。 Q: ストリーミング応答でもエンベロープは維持されるのか? A: 維持されます。withEnvelopeStreamがボディ(SSE/NDJSON)はそのまま流し、封筒メタデータをx-marian-trace、x-marian-coordinate、x-marian-capabilityヘッダに載せます。ストリーム開始前に起きたエラーのみ通常のJSON封筒で返すため、「全応答が封筒情報を持つ」という不変条件が保たれます。 --- # ローカルLLMとクラウドLLMを同じ土俵に載せる — プロバイダ抽象化とブートストラップ設計 URL: https://memory.maria-code.ai/blog/local-llm-routing Published: 2026-05-22 Category: AI & Agents Tags: local-llm, ollama, streaming, provider-abstraction, gemini, llm-routing Author: Marian Engineering ## Key Takeaways - プロバイダ抽象化の主戦場はモデルではなくストリーミング形式(NDJSON vs SSE)の正規化 - プロンプト構築・コンテキスト上限・中断処理は共通化し、プロバイダで変えるのは輸送だけ - デフォルトをローカル(Ollama)にし、プライベートなノートを外部に送らない選択を一級市民にする - セットアップウィザードは「状態→次の1手」の純粋関数として書き、UIから分岐ロジックを排除する ## Body ## 課題: LLMプロバイダは1つに決められない ナレッジワークスペースが扱うのは私的なノートです。「ノートを外部APIに送りたくない」という要求は無視できず、ローカルLLMの選択肢が必須になります。一方で要約・埋め込みの品質はクラウドモデルが優位な場面も多い。つまり問題は「どのLLMを使うか」ではなく、**複数のLLMを同じ契約で差し替え可能にする抽象化**です。 Marianのチャットルートはリクエストボディの`provider`フィールドで分岐します。デフォルトはローカル(Ollama)です。 ```ts // app/api/marian-ai/route.ts type AiProvider = 'ollama' | 'openai-compatible' export const POST = withEnvelopeStream('marian-ai', ({ payload, request }) => { const messages = buildProviderMessages(payload) if (payload.provider === 'openai-compatible') return streamOpenAiCompatible(messages, request.signal) return streamOllama(messages, request.signal) // デフォルト }) ``` | プロバイダ | 既定エンドポイント | 既定モデル | ストリーム形式 | | --- | --- | --- | --- | | ollama | http://localhost:11434 | gemma3:4b | NDJSON | | openai-compatible | http://localhost:8000/v1 | gpt-oss | SSE | | gemini (@google/genai) | クラウド | gemini-3.1-flash-lite | SDK | ## ストリーミングの正規化: NDJSONとSSEを同じ顔にする プロバイダ抽象化の実務は、ほぼ**ストリーミング形式の差異の吸収**です。Ollamaは1行1JSONのNDJSONで`{message: {content}, done}`を流し、OpenAI互換サーバーはSSEで`data: {choices: [{delta: {content}}]}`を流し、終端は`[DONE]`という文字列です。 ```ts // Ollama: NDJSON // {"message":{"content":"こん"}}\n{"message":{"content":"にちは"}}\n{"done":true} // OpenAI互換: SSE // data: {"choices":[{"delta":{"content":"こん"}}]}\n\n // data: [DONE] ``` 各`stream*`関数はこれらをパースして「テキストチャンクのyield」という同一契約に正規化します。`request.signal`(AbortSignal)を下流のfetchへ伝播させるのも共通契約の一部で、ユーザーが生成を中断したらローカル/クラウドのどちらでも接続が切れます。エンベロープ・プロトコルとの統合は`withEnvelopeStream`が担い、trace等のメタデータはヘッダに載るためストリーム本文は汚れません。 プロンプト構築も全プロバイダ共通です。システムプロンプトで「選択されたコンテキストのみから答え、出典を本文で引用する」を指示し、ソースは1件2,400字まで、履歴は直近12メッセージに刈り込みます。**プロバイダで変えるのは輸送だけ、内容は変えない**のが原則です。 ## Geminiラッパー: JSONモードと使用量計測 クラウド側(@google/genai)のラッパーには、運用で必要になる2つの仕掛けがあります。`generateJson`はJSONレスポンスモード+Markdownフェンス除去+パース失敗時のフォールバック値を一体化し、呼び出し側から「LLMが変なJSONを返した」例外処理を消します。使用量計測は`MARIAN_USAGE_TELEMETRY=1`のオプトインで、ベストエフォートでトークン数を記録します。 ## ブートストラップウィザード: 「Ollamaがない」から始まるUX ローカルLLM対応の本当の難所はランタイムではなく、**初回セットアップ**です。ユーザーのマシンにはOllamaが(1)入っていない、(2)入っているが起動していない、(3)起動しているがモデルがない、のどれかの状態があり得ます。Marianはこれを4つの真偽値からなる状態として検出し、次にやるべき1手を返す純粋関数として実装しました。 ```ts // lib/ai/ollama-bootstrap.ts BootstrapState = { ollamaInstalled: boolean serverReachable: boolean hasChatModel: boolean hasEmbeddingModel: boolean declinedLocal?: boolean } nextBootstrapStep(state) → 'install' | 'start' | 'pull-models' | 'done' | 'use-cloud' ``` OS検出(`navigator.platform`+userAgentのパターンマッチ)→OS別インストールガイド(macOSは`brew install ollama`、Linuxはインストールスクリプト、Windowsはインストーラ)→`ollama serve`の起動→推奨モデルのpull、という導線です。モデルpullの進捗はOllamaの`/api/pull`が返すレイヤーごとの`completed/total`をパーセントに正規化してUIへ流します。 - 推奨チャットモデル: llama3.2:3b(軽量・既定)、qwen2.5:7b(品質)、gemma3:4b(コンパクト) - 推奨埋め込みモデル: nomic-embed-text 重要なのは`planBootstrap`が**I/Oを一切持たない**ことです。ヘルスチェックやpullの実行はUI層が行い、結果をstateに反映して再びplanを呼ぶ。ウィザードの全分岐(「インストール済みだが未起動」「チャットモデルだけある」「ローカルを拒否してクラウドへ」)が、モックなしの同期ユニットテストで網羅できます。 > セットアップウィザードは「画面の連なり」ではなく「状態→次の1手」の純粋関数として書く。UIはその関数の出力を描画するだけの存在にする。 ## まとめ ローカル/クラウドLLMの共存は、(1)provider 1パラメータでの分岐、(2)NDJSON/SSEを共通チャンク契約へ正規化、(3)AbortSignal伝播とエンベロープヘッダの共通化、(4)プロンプトはプロバイダ非依存、(5)セットアップは決定的ステートマシン、という積み重ねで実現しています。LLMはコモディティ化が進む一方で輸送形式は揃っていないため、**抽象化の主戦場はモデルではなくストリーム**だというのが実装から得た結論です。 ## FAQ Q: ローカルLLMとクラウドLLMの切り替えはどう実装されているのか? A: チャットAPIのリクエストボディにproviderフィールド(ollama / openai-compatible、デフォルトはollama)を持たせ、ルート内で対応するストリーム関数に分岐します。エンドポイントとモデルは環境変数(OLLAMA_BASE_URL、OPENAI_COMPATIBLE_BASE_URL等)で上書きでき、プロンプト構築・コンテキスト刈り込み・中断処理は全プロバイダ共通です。 Q: OllamaとOpenAI互換APIのストリーミング形式の違いはどう吸収するのか? A: OllamaはNDJSON({message:{content}}の行)、OpenAI互換はSSE(data: {choices:[{delta:{content}}]}、終端[DONE])という違いを、各stream関数がパースして「テキストチャンクの列」という同一契約に正規化します。呼び出し側はどちらのプロバイダでも同じコードでチャンクを消費できます。 Q: Ollamaが未インストールのユーザーはどう扱われるのか? A: ブートストラップウィザードが4つの真偽値(インストール済み/サーバー到達可能/チャットモデル有/埋め込みモデル有)の状態から次の1手(install / start / pull-models / done / use-cloud)を返します。OS別のインストールガイドとモデルpullの進捗表示つきで誘導し、ローカルを拒否した場合はクラウドにフォールバックします。この判定はI/Oゼロの純粋関数で全分岐がテスト済みです。 --- # コンテキストエンジンの設計 — スコープ×グレイン×トークン予算で「何をLLMに見せるか」を計画する URL: https://memory.maria-code.ai/blog/context-engine-token-budget Published: 2026-05-20 Category: Architecture Tags: context-engineering, token-budget, rag, summarization, prompt-engineering Author: Marian Engineering ## Key Takeaways - コンテキスト選択を暗黙のtop-kからスコープ×グレインの明示的なデータ構造に変える - デフォルト(ノートブック内=full、リンク近傍=summary)でユーザー意図の強さとトークン消費を比例させる - 見積もりは4文字=1トークンで十分。精度の要件は用途(判断支援)が決める - 計画と実行の分離(ContextPlan / SummaryPlan / ReindexPlan)はテスト・可観測性・キャンセル安全性を同時に買う ## Body ## 課題: コンテキスト選択はRAG最大の暗黙知 LLMアプリの品質を決める最大の変数は、モデルでもプロンプト文言でもなく**コンテキストに何を入れたか**です。ところが多くのRAG実装では、この選択は検索スコアとtop-kに埋め込まれた暗黙の決定で、ユーザーには制御も観察もできません。「なぜこの答えになったのか」の半分はコンテキスト選択の説明なのに、です。 Marianのコンテキストエンジンは、open-notebookスタイルの設計でこれを明示化します。中核は2つの軸の直積です。 - **スコープ**: `notebook`(選んだソース集合のみ)か`vault`(ノートブックのソースに加え、そこからリンクされた近傍ノートも候補化) - **グレイン**: ソースごとに`full`(全文)/`summary`(要約)/`excluded`(除外) ```ts // lib/marian-data/context.ts type ContextGrain = 'full' | 'summary' | 'excluded' type ContextScope = 'notebook' | 'vault' interface ContextSourcePlan { id: NoteId title: string grain: ContextGrain tokens: number // このソースの見積もり消費 external?: boolean // scope=vault時、ノートブック外から来た近傍 } interface ContextPlan { scope: ContextScope sources: ContextSourcePlan[] totalTokens: number includedCount: number } ``` ## デフォルトの妙: 中は全文、外は要約 `buildContextPlan`はスコープとグレイン指定からContextPlanを組み立てますが、グレインのデフォルトに設計判断が入っています。**ノートブック内のソースはfull、vault拡張で入ってきた近傍ノートはsummary**が初期値です。 ユーザーが明示的に選んだものは詳細に、リンクグラフを辿って自動で入ってきたものは薄く。トークン消費の主導権を「ユーザーの意図の強さ」に比例させる、という方針をデフォルト値だけで表現しています。近傍ノートには`external: true`が立つため、UIは「これは自動で追加された」と区別して見せられます。 ## トークン見積もり: 4文字=1トークンで十分 計画段階の見積もりは正確なトークナイザを使いません。 ```ts estimateTokens(id, grain): excluded → 0 summary → max(8, round(summaryText.length / 4)) full → round((title.length + body.length) / 4) CONTEXT_TOKEN_BUDGET = 8000 // ソフト予算(UI表示用) ``` なぜ正確さを捨てられるのか。この数字の用途が**課金でも切り詰めでもなく、ユーザーの意思決定支援**だからです。「全文で入れると予算の60%をこの1ノートが食う」が分かれば、summaryに落とす判断ができます。tiktoken相当の依存を足して得られる精度向上は、この用途には寄与しません。予算の8,000トークンも強制値ではなくソフトリミットで、超過はUIの警告として現れます。 > 見積もりの精度は用途が決める。人間の判断支援なら±20%の誤差は無害で、依存ゼロ・同期計算という性質の方が価値が高い。 ## 同じ思想がもう1つ: 要約プランナー 「計画をデータとして返し、実行と分離する」パターンは要約パイプラインにも貫かれています。`lib/marian-summarize`のプランナーは、入力テキストとモードから**SummaryPlan**を返す純粋関数です。 ```ts lengthSpecs = { short: { targetTokens: 80, format: 'TL;DR — 1–2文または3点以内' }, medium: { targetTokens: 250, format: 'セクション付きの1ページ要旨' }, long: { targetTokens: 700, format: '構造化された複数セクション要約' }, } // auto: 入力 <= 400トークンならshort、<= 2000ならmedium、それ以上はlong interface SummaryPlan { strategy: 'single' | 'map-reduce' chunks: SummaryChunkPlan[] // 文字レンジ [startChar, endChar) perChunkTargetTokens: number // map段の各チャンク予算(targetの約60%) contextWindow: number // 既定8000 reserveTokens: number // 既定1500(プロンプト+出力の取り分) } ``` 入力が`contextWindow - reserveTokens`に収まればsingle、収まらなければチャンク分割してmap-reduceに切り替えます。map段の各チャンクには最終目標の約60%のトークン予算を割り、reduce段でマージする余白を残します。プランはLLMを1回も呼ばずに計算されるため、「この10万字のドキュメントは何チャンクのmap-reduceになるか」がテストで断言できます。 ## 計画と実行の分離が買うもの コンテキスト計画・要約計画・(別記事で扱った)再インデックス計画。Marianの基盤レイヤーには「まず計画をデータとして作り、実行は別の層がやる」という同型のパターンが繰り返し現れます。得られる性質は共通です。 - **テスト容易性**: LLM・DB・クロックなしで計画ロジックを検証できる - **可観測性**: 実行前に「何が起きるか」をUIやログに出せる - **キャンセル安全性**: 計画段階では何も起きていないので、捨てるのが自由 ## まとめ コンテキストエンジンは、(1)スコープ×グレインの直積で「何を見せるか」を型のあるデータにする、(2)デフォルト値(中はfull・外はsummary)でユーザー意図とトークン消費を整合させる、(3)4文字=1トークンの粗い見積もり+8,000のソフト予算で判断支援に徹する、(4)計画と実行の分離を要約パイプラインまで貫く、という設計です。RAGの説明可能性は、検索スコアの可視化よりも先に**コンテキスト選択の可視化**から始まります。 ## FAQ Q: コンテキストエンジンのスコープとグレインとは何か? A: スコープはコンテキスト候補の範囲で、notebook(選択したソースのみ)とvault(ソースからリンクされた近傍ノートも候補に含む)の2種類。グレインはソースごとの粒度で、full(全文)/summary(要約)/excluded(除外)の3段階です。この直積からトークン見積もりつきのContextPlanが構築されます。 Q: トークン見積もりはなぜ正確なトークナイザを使わないのか? A: 用途が課金や強制切り詰めではなく、ユーザーの意思決定支援(このノートを全文で入れると予算の何%か)だからです。4文字=1トークンの近似で±20%程度の誤差は判断に影響せず、依存ライブラリなしの同期計算という性質の方が価値があります。予算8,000トークンもソフトリミットで、超過は警告として表示されます。 Q: 長文の要約はどう計画されるのか? A: 要約プランナーが入力トークン数(4文字=1トークン見積もり)とコンテキストウィンドウ(既定8,000、予約1,500)を比較し、収まればsingle、収まらなければmap-reduce戦略を選びます。map段の各チャンクには最終目標トークン(short=80/medium=250/long=700)の約60%を割り当て、チャンク分割は文字レンジとして計画に含まれます。LLMを呼ばずに計画が確定するためテスト可能です。 --- # RAG評価をCIに入れる — 決定的シンセティックコーパスとrecall@kゲート URL: https://memory.maria-code.ai/blog/rag-eval-harness Published: 2026-05-19 Category: Engineering Tags: evaluation, rag, recall, ndcg, ci, testing, information-retrieval Author: Marian Engineering ## Key Takeaways - 検索品質の回帰は型エラーにならない。CIに決定的な評価ゲートを置くことがretrieval層の変更可能性を守る - 評価対象を検索に絞ればLLM不要。recall@k / MRR / nDCG / MAP / precisionの並列出力で劣化の方向が見える - シード固定のシンセティックコーパスは絶対品質でなく差分検出のための道具 - CIゲートはrecall@10 >= 0.6の1条件のみ。多条件ゲートは形骸化を招く - retrieve関数を差し込む設計で、単段/2段階/RAPTORを同じ物差しで比較できる ## Body ## 課題: 検索の変更は「動くけど悪化した」が起きる 検索基盤の変更は型エラーにもユニットテストの失敗にもなりません。バイナリ量子化の符号規約を1つ間違えても、検索は「動き」ます — ただ静かに悪い結果を返すだけです。この**サイレントな品質回帰**を検出する仕組みがないと、retrieval層のリファクタリングは事実上凍結されます。 よくある答えはLLM-as-a-judge(LLMに回答品質を採点させる)ですが、CIゲートとしては3つの欠点があります。遅い(分単位)、高い(評価のたびに課金)、そして非決定的(同じ変更でも通ったり落ちたりする)。Marianの評価ハーネスは逆の性質から設計しました。**速く、無料で、決定的**。 ## 設計: 評価対象を「検索」に絞る RAGの品質は検索(retrieval)と生成(generation)に分解できます。生成の評価はLLMなしでは難しい一方、検索の評価は**正解集合つきのクエリ**さえあれば古典的なIR指標で計測できます。そしてRAGの障害の大半は検索の劣化です。ハーネスのインターフェースは検索関数そのものを差し込む形になっています。 ```ts // lib/marian-quality/retrieval-eval.ts export async function evaluateRetrieval( cases: readonly EvalCase[], // { id, query, relevant: 正解id集合 } retrieve: (query: Q) => string[] | Promise, options: EvaluateOptions = {}, // k: 既定10 ): Promise interface EvalReport { k: number meanRecall: number // recall@k の平均 meanPrecision: number // precision@k mrr: number // Mean Reciprocal Rank map: number // Mean Average Precision meanNdcg: number // nDCG@k cases: CaseResult[] // ケース別の内訳(デバッグ用) } ``` `retrieve`が引数なので、同じケース集合に対して「単段IVFFlat」「2段階量子化」「RAPTOR込み」を**同じ物差しで比較**できます。新しい検索方式を足すたびに評価コードを書き直す必要はありません。 ## 指標の実装: 5つで十分 各指標は教科書どおりの素直な実装です。例えばrecall@kとnDCG@k: ```ts export function recallAtK(retrieved: readonly string[], relevant: Iterable, k: number) { const rel = relevantSet(relevant) if (rel.size === 0) return 0 let hits = 0 for (const id of retrieved.slice(0, k)) if (rel.has(id)) hits++ return hits / rel.size } export function ndcgAtK(retrieved: readonly string[], relevant: Iterable, k: number) { const rel = relevantSet(relevant) let dcg = 0 retrieved.slice(0, k).forEach((id, i) => { if (rel.has(id)) dcg += 1 / Math.log2(i + 2) // 順位i(0-based)の減衰 }) let idcg = 0 for (let i = 0; i < Math.min(k, rel.size); i++) idcg += 1 / Math.log2(i + 2) return idcg === 0 ? 0 : dcg / idcg } ``` 使い分けの直観はこうです。**recall@k**は「正解を取りこぼしていないか」(RAGでは最重要 — コンテキストに入らなかった正解は存在しないのと同じ)、**MRR**は「最初の正解が何位に出るか」、**nDCG**は「正解が上位に固まっているか」、**MAP**はその平均的な総合点、**precision@k**は「ノイズ率」。単一指標に潰すと劣化の方向が見えなくなるため、5つを並列で出します。 ## シンセティックコーパス: 正解集合を作る問題を回避する IR評価の本当のコストは指標ではなく**正解ラベル付きデータの構築**です。人手でクエリと正解文書のペアを作るのは高く、実データは私的なノートなのでCIに置けません。Marianはコーパスごと合成します。 ```ts // scripts/eval-rag.ts const corpus = generateSyntheticCorpus({ topics: 24, // トピック=クラスタ中心 chunksPerTopic: 10, // 各トピック周りに240チャンク queriesPerTopic: 5, // 計120クエリ dims: 96, // 評価用の小さい埋め込み次元 noise: 0.2, // 成分ごとのジッタ seed: 42, // mulberry32による決定的乱数 }) ``` トピック中心ベクトルを乱数で置き、チャンクとクエリはその中心+ノイズとして生成します。クエリの正解集合は「同じトピックのチャンク」と定義により自明です。ノイズ0.2はトピック間の分離を保ちつつ、量子化誤差程度の劣化がスコアに現れる感度を持たせる値です。 乱数は`Math.random()`ではなく**mulberry32(シード42)**。これで全実行が同一のコーパスを見るため、スコアの変動はコードの変更だけを反映します。「seedを変えたら通った」のようなフレーキーさは構造的に発生しません。 > 評価データの合成は「現実の難しさ」を犠牲にして「比較の正確さ」を買う取引。CIゲートに必要なのは絶対品質の測定ではなく、変更前後の差分検出である。 ## CIゲート: しきい値は1つだけ CIで強制するのは`recall@10 >= 0.6`の1条件だけです(`--min-recall`で調整可)。他の指標はレポートには出ますがゲートにはしません。 しきい値を複数にすると、無関係な指標の自然な揺らぎでCIが落ち始め、やがて誰もが赤いCIを無視するようになります。「コンテキストに正解が入るか」を直接表すrecallを単独ゲートにし、nDCGやMRRの劣化はレビューで人間が見る、という役割分担です。`--json`フラグで機械可読レポートも出るため、指標の推移を別途追跡できます。 ## このハーネスが測らないもの 正直な限界も書いておきます。シンセティックコーパスは語彙の曖昧性・多言語・長文書の構造といった実データの難しさを持ちません。embedding品質そのものも測れません(コーパスがembedding空間で直接生成されるため)。これは意図的な割り切りで、ハーネスの守備範囲は**検索アルゴリズム層(量子化・融合・ツリー)の回帰検出**です。実データでの品質はステージング環境の手動評価が補完します。 ## まとめ RAG評価ハーネスの設計は、(1)評価対象を検索に絞りLLMを排除、(2)retrieve関数を差し込むインターフェースで方式間比較を可能に、(3)recall/MRR/nDCG/MAP/precisionを並列で出し劣化の方向を保存、(4)シード固定のシンセティックコーパスで決定性を確保、(5)CIゲートはrecall@10 >= 0.6の1条件のみ、です。数秒で終わる決定的な評価がCIにあることで、検索基盤は「怖くて触れないコード」になることを免れています。 ## FAQ Q: なぜLLM-as-a-judgeをCIゲートに使わないのか? A: 遅い(分単位)・高い(毎実行課金)・非決定的(同じコードで通ったり落ちたり)という3点でCIゲートに不適だからです。RAGの障害の大半は検索層の劣化なので、検索だけを古典的IR指標(recall@k等)で決定的に評価すれば、数秒・無料・再現可能なゲートになります。生成品質の評価はステージングでの手動評価に分離しています。 Q: シンセティックコーパスで実際の検索品質が保証できるのか? A: 絶対品質は保証できません。保証するのは「変更前後の差分」です。シード固定(mulberry32, seed=42)の24トピック×240チャンク×120クエリに対するスコア変動は、コード変更だけを反映します。量子化の符号ミスやRRF融合のバグのような検索アルゴリズム層の回帰はこれで検出でき、語彙の曖昧性など実データ固有の難しさは守備範囲外と割り切っています。 Q: CIのしきい値がrecall@10だけなのはなぜか? A: RAGではコンテキストに入らなかった正解は存在しないのと同じであり、recallが最も直接的な品質指標だからです。複数指標をゲートにすると自然な揺らぎでCIが落ち、赤いCIが常態化して形骸化します。MRR・nDCG・MAP・precisionはレポートに出して人間のレビュー判断に使い、機械的な強制はrecall@10 >= 0.6の1条件に絞っています。