技術 約14分で読めます

MintlifyがRAGを捨てて仮想ファイルシステムに切り替えた話

RAGはもう定番の手法になった。ドキュメントをチャンクに分割してベクトルDBに入れ、ユーザーのクエリに類似するチャンクを引っ張ってきてLLMのコンテキストに突っ込む。実用上はChromaの検索エージェントContext-1PageIndexのツリーRAGのように、精度を上げるための工夫が次々と出ている。

でもMintlifyは、そもそもRAGというパラダイム自体をやめた。代わりに作ったのがChromaFsという仮想ファイルシステムだ。AIエージェントにbashシェルを渡して grepcat でドキュメントを探させる。ファイルシステムへのアクセスは全部Chroma DBへのクエリに変換される。

RAGの基礎から振り返りつつ、ChromaFsが何を解決したのかを掘り下げる。

そもそもRAGとは

RAG(Retrieval-Augmented Generation)は、LLMの知識をリアルタイムに外部データで補強する手法だ。2020年にMeta(当時Facebook AI Research)が論文「Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks」で提案した。

LLMは学習時のデータしか知らない。社内ドキュメントや最新の製品マニュアルについて聞いても、ハルシネーション(もっともらしいウソ)を返す。RAGはこの問題を「質問に関連する文書を検索してプロンプトに添付する」というシンプルな方法で解決する。

graph TD
    A["ユーザーの質問"] --> B["Embeddingモデルで<br/>ベクトル化"]
    B --> C["ベクトルDBで<br/>類似検索"]
    C --> D["上位k件の<br/>チャンクを取得"]
    D --> E["質問 + チャンクを<br/>LLMに投入"]
    E --> F["回答生成"]

処理の流れを分解するとこうなる。

  1. ドキュメントを数百トークン単位のチャンクに分割する
  2. 各チャンクをEmbeddingモデル(OpenAIの text-embedding-3-small 等)でベクトル化する
  3. ベクトルをベクトルDB(Chroma、Pinecone、Weaviate等)に格納する
  4. ユーザーの質問もベクトル化し、コサイン類似度で近いチャンクを検索する
  5. 取得したチャンクをプロンプトに含めてLLMに渡す

この「検索して渡す」だけの仕組みが強力だった理由は、LLMのファインチューニングなしに最新の情報を反映できる点だ。ドキュメントが更新されたらベクトルDBを更新するだけでいい。

Embeddingとコサイン類似度

RAGの核になるのがEmbedding(埋め込み)だ。テキストを数百〜数千次元の数値ベクトルに変換する処理で、意味が近い文章は近いベクトルになる。

たとえば「Pythonでリストをソートする方法」と「Pythonの配列を並び替えるには」は異なる文字列だが、Embeddingベクトルは非常に近くなる。逆に「Pythonでリストをソートする方法」と「Pythonは南米に生息する蛇」は文字列上は似ていてもベクトルは離れる。

ベクトル間の近さを測るのがコサイン類似度だ。2つのベクトルの成す角度のコサインを計算し、1に近いほど類似、0に近いほど無関係、-1に近いほど逆の意味を表す。

類似度 = cos(θ) = (A · B) / (|A| × |B|)

RAGは「質問のベクトルに最も近いチャンクが、質問に答えるための情報を含んでいるだろう」という仮定に依存している。この仮定が成り立つ場面ではうまく機能するが、成り立たない場面もある。それが後述するRAGの構造的限界だ。

ベクトルDBとKVS、RDBMSの違い

RAGの文脈ではベクトルDBが当然のように出てくるが、なぜ既存のデータベースではダメなのか。ベクトルDB、KVS(Key-Value Store)、RDBMS(リレーショナルデータベース)はそれぞれ得意な問い合わせの種類が根本的に異なる。

特性RDBMSKVSベクトルDB
データモデルテーブル(行と列)キーと値のペアベクトル + メタデータ
主な問い合わせSQL(条件指定、JOIN)キーによる完全一致類似度による近傍探索
検索の例WHERE category = 'API'GET doc:12345「認証の設定方法」に近いチャンク
インデックスB-Tree、ハッシュハッシュテーブルHNSW、IVF等の近似最近傍
スケーラビリティ垂直(スケールアップ中心)水平(シャーディング容易)水平(シャーディング可能)
代表的な実装PostgreSQL、MySQLRedis、DynamoDBChroma、Pinecone、Weaviate
得意な用途構造化データの複雑なクエリセッション、キャッシュ意味的な類似検索

なぜRDBMSでRAGができないのか

RDBMSでも全文検索(LIKE '%認証%' やtsvector)はできる。しかしこれはキーワードの一致であり、意味の近さを捉えられない。「認証の設定方法」で検索しても「ログインの初期設定手順」というページはヒットしない。

PostgreSQLにはpgvectorという拡張があり、ベクトル型のカラムとコサイン類似度によるインデックスを追加できる。これを使えばRDBMS上でもベクトル検索は可能だが、専用のベクトルDBと比べるとインデックスの効率やスケーラビリティで劣る場面がある。小〜中規模のプロジェクトであれば、既存のPostgreSQLにpgvectorを足すのが最もシンプルな選択肢だ。

ベクトルDBのインデックス構造(HNSW)

ベクトルDBが高速な類似検索を実現できるのは、専用のインデックス構造を使っているからだ。最も広く採用されているのがHNSW(Hierarchical Navigable Small World)で、Chromaもデフォルトでこれを使っている。

graph TD
    A["検索クエリベクトル"] --> B["Layer 2(疎なグラフ)<br/>少数のノードで<br/>大まかな位置を特定"]
    B --> C["Layer 1(中間のグラフ)<br/>範囲を絞り込み"]
    C --> D["Layer 0(密なグラフ)<br/>近傍ノードを精密に探索"]
    D --> E["最近傍k件を返却"]

HNSWは多層のグラフ構造になっている。上位レイヤーほどノード数が少なく長距離のエッジを持ち、下位レイヤーほどノードが密で短距離のエッジを持つ。検索時は最上位レイヤーから入り、クエリベクトルに近いノードを辿りながら下位レイヤーへ降りていく。最下位レイヤーで最も近いノード群が検索結果になる。

この構造により、全ベクトルとの距離計算(O(n))を行わずに近似最近傍を高速に求められる。100万件のベクトルでもミリ秒単位で検索が完了する。ただし「近似」なので、厳密な最近傍が返る保証はない。

KVSの役割

ChromaFsのアーキテクチャではRedisも登場する。KVSの得意領域はキーを指定した高速な読み書きとキャッシュだ。ベクトルDBで類似検索してファイルの候補を絞り込んだ後、実際のチャンクデータをRedisから取得する、という使い分けをしている。検索はベクトルDB、データ取得はKVSという分業だ。

RAGの構造的な限界

ここからがMintlifyの話だ。Mintlifyはドキュメンテーションプラットフォームで、Discord、Vercel、Cursorなど多数の企業が利用している。月間85万件の会話を処理するAIアシスタントを提供しているが、従来のRAGには構造的な限界があった。

シングルパス検索の弱点

RAGの標準的なパイプラインは「1回のクエリで関連チャンクを取得して終わり」のシングルパスだ。これが破綻するケースがいくつかある。

ケース問題
回答が複数ページにまたがるベクトル検索は1つのクエリに対して類似チャンクを返す。「AとBの違い」のような比較質問では、AとB両方のチャンクが必要だが片方しか取れないことがある
正確な値が必要APIのシグネチャや設定パラメータの正確な値は、ベクトル類似度では捕まえにくい。「createUser の第3引数のデフォルト値」に対して、意味的に近い別のAPI説明が返ってくる
前後のコンテキストが必要チャンク分割でページの文脈が失われる。前のセクションを読まないと意味が取れないチャンクが返されても、LLMは正しく解釈できない

これはベクトル検索RAGの根本的な弱点として以前から指摘されてきた問題だ。マルチホップ検索やリランキングといった改良が数多く提案されているが、パイプラインの複雑さと引き換えになる。

Mintlifyのサンドボックス方式

Mintlifyが以前使っていたのはサンドボックス方式で、会話ごとにコンテナを起動してドキュメントをマウントし、エージェントにbashで自由に探索させていた。精度は高いが、P90のブート時間が約46秒、会話あたりのコストが$0.0137。月間85万件だと年間のインフラコストが7万ドル超になる計算だ。

graph TD
    A["会話リクエスト"] --> B["コンテナ起動<br/>(P90: 約46秒)"]
    B --> C["ドキュメントを<br/>ファイルシステムにマウント"]
    C --> D["エージェントが<br/>bashで自由に探索"]
    D --> E["回答生成"]
    E --> F["コンテナ破棄"]

精度は確かに高かった。エージェントはディレクトリ構造を見て、ファイルを読んで、grepで横断検索して、人間がドキュメントを探すのと同じ動きができる。問題はコストとレイテンシーだった。

ChromaFsのアーキテクチャ

ChromaFsは「サンドボックスの精度」と「RAGのコスト効率」の両立を狙って設計されている。コンテナの起動を不要にしつつ、エージェントには従来通りbashでの探索能力を与える。

ChromaFsはVercel Labsが開発した just-bash の上に構築されている。just-bashはTypeScriptでbashを再実装したもので、grepcatlsfindcd といったUNIXコマンドをブラウザ環境でも動作するように一から書き直している。実際のシェルのラッパーではなく、パーサーからコマンド実行まで全部TypeScript製だ。

just-bashはプラガブルなファイルシステムインターフェース IFileSystem を提供している。ChromaFsはこのインターフェースを実装し、ファイルシステム操作をChroma DBへのクエリに変換する。

graph TD
    A["エージェント"] --> B["bashコマンド<br/>ls, cat, grep等"]
    B --> C["just-bash<br/>TypeScript製bashエミュレータ"]
    C --> D["IFileSystem<br/>インターフェース"]
    D --> E["ChromaFs<br/>仮想ファイルシステム"]
    E --> F["Chroma DB"]
    E --> G["Redis<br/>チャンクキャッシュ"]

エージェントは普通のbashコマンドを打っているつもりだが、裏側ではベクトルDBへの問い合わせが走る。

ディレクトリツリーの初期化

ドキュメント全体のファイル構造はgzip圧縮されたJSON(__path_tree__)としてChromaコレクション内に保存されている。起動時にこれを展開して2つのインメモリ構造を作る。

データ構造内容用途
Set<string>全ファイルパスの集合パスの存在確認(O(1))
Map<string, string[]>ディレクトリから子要素へのマッピングls コマンドの応答

2回目以降のセッションではキャッシュ済みのツリーにアクセスするため、ネットワーク呼び出しはゼロになる。gzip圧縮によりツリーのサイズが小さく抑えられるため、数千ページ規模のドキュメントでも展開は数ミリ秒で完了する。

ページの再構成

ドキュメントはChromaに保存する際にチャンクに分割されている。cat でページを読むときは、ページスラッグに一致する全チャンクをフェッチし、chunk_index でソートして結合する。

ベクトルDBが「ファイルシステムのバックエンド」として機能しているのがポイントだ。通常のRAGではベクトル類似検索でチャンクを取得するが、ChromaFsの cat ではメタデータクエリ(WHERE slug = 'target-page')でチャンクを取得する。ベクトルは一切使わない。ファイルの内容を正確に復元するのが目的なので、類似検索は不要だ。

同じページへの繰り返しアクセスはキャッシュで吸収する。Redisにプリフェッチしたチャンクを保持しておくことで、2回目以降の cat はDBアクセスなしで応答する。

2段階grep

grep -r のような再帰検索は、そのままではドキュメント全体のスキャンになる。ChromaFsはこれを2段階に分けて処理する。

graph TD
    A["grep -r 'pattern' /docs"] --> B["第1段階: Coarse Filter<br/>Chromaメタデータクエリで<br/>候補ファイルを絞り込み"]
    B --> C["候補ファイル群"]
    C --> D["第2段階: Fine Filter<br/>Redis上のチャンクで<br/>正規表現マッチング"]
    D --> E["検索結果<br/>行番号 + マッチ行"]

第1段階はChromaのメタデータクエリだ。ベクトル類似検索ではなく、チャンクの内容に検索パターンが含まれるかをメタデータフィルタで判定する。これにより候補ファイルを大幅に絞り込める。

第2段階はRedisにプリフェッチ済みのチャンクに対するインメモリでの正規表現マッチングだ。実際のファイル内容を復元した上で行番号付きの検索結果を返す。

2段階に分けることで、大規模ドキュメント(数千ページ)でもミリ秒単位で再帰検索が完了する。ファイルシステムの線形スキャンと違い、第1段階のインデックスによる絞り込みが効くためだ。

書き込みの禁止

全ての書き込み操作は EROFS(Read-Only File System)エラーを返す。これは意図的な設計判断だ。

  • セッション間で状態を持たないため、あるエージェントの操作が別のセッションを壊す心配がない
  • ドキュメントの「正」はChroma DB内のデータであり、仮想ファイルシステム経由での変更は整合性を壊す可能性がある
  • 読み取り専用にすることで、キャッシュの無効化戦略が大幅にシンプルになる

パフォーマンス比較

サンドボックス方式との比較が圧倒的だ。

指標サンドボックスChromaFs
P90ブート時間約46秒約100ミリ秒
会話あたりの限界コスト$0.0137$0(既存DBを再利用)
検索方式ディスクの線形スキャンDBメタデータクエリ
年間インフラコスト(85万会話/月)$70,000超実質$0

ブート時間が460倍速くなり、限界コストがゼロになった。1日3万件超の会話を処理する規模でこの差は効く。

コストがゼロになる理由は、ChromaFsが既存のChroma DBを共有利用するからだ。サンドボックス方式では会話ごとにコンテナとファイルシステムを新規に作成していたが、ChromaFsではすべてのセッションが同じChromaコレクションに対してクエリを投げる。インフラの追加コストは発生しない。

アクセス制御の仕組み

ドキュメントプラットフォームでは、ユーザーごとにアクセス可能なページが異なる。ChromaFsではパスツリーのエントリに isPublicgroups フィールドを持たせている。

graph TD
    A["セッション開始"] --> B["セッショントークンから<br/>ユーザー権限を取得"]
    B --> C["パスツリー構築時に<br/>権限のないパスを除外"]
    C --> D["エージェントに見える<br/>ファイルシステム"]
    D --> E["ls: 権限のあるファイルのみ"]
    D --> F["grep: 権限のあるチャンクのみ"]
    D --> G["cat: 権限のないファイルは<br/>ENOENT"]

ツリー構築時にセッショントークンを使って権限のないパスを刈り込み、その後のChromaクエリにも同じフィルタを適用する。

権限のないファイルは「アクセス拒否」ではなく「存在しない」状態になる。ls で見えないし、grep でも引っかからない。サンドボックス方式では会話ごとにファイルシステムをマウントし直す必要があったが、ChromaFsではクエリレベルでフィルタリングするためコンテナ起動が不要になった。

この「存在しない」というアプローチはセキュリティ的にも理にかなっている。アクセス拒否を返すと「そのファイルが存在する」という情報が漏洩する。ファイルの存在自体を隠すことで、情報漏洩のリスクを最小化している。

just-bashの設計思想

Vercel Labsの just-bash はChromaFsの基盤だが、それ自体が興味深い設計判断の集合体だ。

通常のシェル(bash、zsh等)と異なり、just-bashは以下の特性を持つ。

特性通常のbashjust-bash
実行環境OS上のプロセスTypeScriptランタイム
シェル状態コマンド間で保持exec() ごとにリセット
ファイルシステムOS提供プラガブル(IFileSystem
ネットワークデフォルトで全許可デフォルトで無効
実行モデルfork + exec関数呼び出し

exec() ごとに状態がリセットされる設計は、AIエージェントの用途を強く意識している。エージェントのツール呼び出しは各呼び出しが独立しており、前のコマンドの副作用(環境変数の変更、カレントディレクトリの移動)に依存しない方が安全だ。

ネットワークアクセスがデフォルトで無効なのも、エージェントのサンドボックスとして重要な特性だ。URLプレフィックスのホワイトリストで明示的に許可したエンドポイントだけにアクセスできる。プロンプトインジェクションによってエージェントが意図しない外部通信を行うリスクを軽減する。

Mintlifyにとってjust-bashの最大の利点はプラガブルなファイルシステムだ。IFileSystem インターフェースを差し替えるだけで、エージェントが触るファイルシステムの実体をChroma DBに向けられる。just-bashの @vercel/sandbox 互換APIもそのまま使えるため、既存のサンドボックス方式のコードからの移行コストも低い。

RAGとツール使用のパラダイムの違い

ChromaFsの本質は「RAG vs ツール使用」というパラダイムの違いにある。

graph LR
    subgraph "RAGパラダイム"
        R1["クエリ"] --> R2["検索"]
        R2 --> R3["チャンク取得"]
        R3 --> R4["LLMに渡す"]
        R4 --> R5["回答"]
    end
    subgraph "ツール使用パラダイム"
        T1["質問"] --> T2["ls で構造把握"]
        T2 --> T3["cat で読む"]
        T3 --> T4["grep で横断検索"]
        T4 --> T5["追加の cat"]
        T5 --> T6["回答"]
    end

RAGでは「何を検索するか」をクエリの時点で決めなければならない。検索結果が不十分でもやり直しがきかないシングルパスだ。エージェント型のマルチホップRAGもあるが、検索→取得→再検索のループを回すだけで、探索の自由度は限られる。

ツール使用パラダイムでは、エージェントが自律的に探索戦略を組み立てる。ls でディレクトリ構造を見て当たりをつけ、cat で中身を読んで理解を深め、そこから得た手がかりで次の grep を打つ。人間がドキュメントを探す手順そのものだ。

言い換えれば「検索」と「探索」の違いだ。

項目RAG(検索)ChromaFs(探索)
戦略の柔軟性クエリ時に固定途中で変更可能
複数ページの参照チャンク上限に依存自由に複数ファイルを読める
構造の理解なし(フラットなチャンク)ディレクトリ構造が見える
正確な値の取得ベクトル類似度に依存grep で完全一致検索可能
LLMの呼び出し回数1回複数回(ツール呼び出し分)
レイテンシー低(1パス)やや高(複数ターン)