Skip to main content

ベクトルデータベースなしのRAG:「Ask about Yudi」の実際の仕組み

7分で読む

誇張抜きのRAG

少し前に、このサイトに無料のAIアシスタントを作った話を書きました。あれは「何を」「なぜ」の話でした。今回は「どうやって」——特に検索(retrieval)の部分についてです。多くのRAGチュートリアルが思わせるより、ずっと単純だからです。

正直な見出しはこうです。「Ask about Yudi」は、ベクトルデータベースもembeddingも類似度検索もないRAGシステムです。 ただ全部を読み込んでモデルに渡すだけ。そしてこの規模のコーパスなら、それは手抜きではなく、正しいエンジニアリング判断です。

これを書くのは、説明のためでもあり、この方法を捨てなければならないのが正確にいつかを分かるくらいトレードオフを理解するため、でもあります。

つながったノードを流れるデータを抽象的に可視化した図。検索パイプラインを表している
検索=ベクトルDB、とは限らない。ときには「全部読み込む」で十分。

RAGとは何か(そして何ではないか)

まず、自分自身が持っていた誤解を正しておきます。

RAGはモデルを訓練しません。 Retrieval-Augmented Generation は、クエリの時点で関連データを取り出し、プロンプトに注入します。モデルの重みは一切変わりません。何も「学習」しない。答える前に読むための、より良いメモを渡しているだけです。

それが仕組みのすべてで、だからこそRAGは魅力的なのです。

  • ファインチューニングはモデルの重みを変える。高価で、更新が遅く、それでもモデルは自信満々に作り話をしうる。
  • RAGプロンプトを変える。安く、即座に更新でき(ファイルを編集して終わり)、指し示せるテキストから答える。

CVとブログ記事から答え、勤務先や日付を決して捏造してはいけない個人サイトのアシスタントには、RAGが明らかに正解です。モデルに私を暗記してほしいわけじゃない。質問された瞬間に私を読んでほしいのです。

データフロー

これがパイプラインの全体、端から端までです。

┌─────────────┐   question    ┌────────────────────────┐
│   Browser   │ ────────────▶ │    POST /api/chat      │
│  (Chat.tsx) │               │   (server route)       │
└─────────────┘               └───────────┬────────────┘
       ▲                                   │  1. rate-limit + validate input
       │                                   ▼
       │                       ┌────────────────────────┐
       │                       │     getKnowledge()     │
       │                       │  concatenate, cached:  │
       │                       │   • profile + FAQ      │
       │                       │   • uses / projects    │
       │                       │   • ALL blog posts     │
       │                       └───────────┬────────────┘
       │                                   │  2. system = PROMPT + knowledge
       │                                   ▼
       │        streamed tokens  ┌────────────────────────┐
       └──────────────────────── │    Gemini 2.5 Flash    │
                                 │      (streamText)      │
                                 └────────────────────────┘

3ステップ:リクエストを守り、コンテキストを組み立て、答えをストリームする。「検索」はステップ2で、この設計が意図的に、ほとんど恥ずかしいくらい単純なところです。

検索ステップは恥ずかしいほど単純

教科書的なRAGでは、検索とはこうです。ドキュメントを分割(チャンク化)し、ベクトルにembedし、ベクトルDBに保存し、来た質問をembedし、類似度検索をかけ、最も関連する上位k件のチャンクを取り出す。

私のはそれを全部飛ばします。検索は、全部を一度読み込んでキャッシュする関数ひとつです。

// lib/knowledge.ts — "retrieval" is just: gather all the docs.
export const getKnowledge = cache((): string => [
  readProfile(),   // curated profile + FAQ (public-safe)
  readUses(),      // tools / setup
  readProjects(),  // projects
  readPosts(),     // every blog post, globbed from /posts
].join('\n\n---\n\n'));

そしてルートは、そのコーパス全体をシステムメッセージに詰め込んでプロンプトを「拡張」し、答えをストリームします。

// app/api/chat/route.ts
const result = streamText({
  model: google('gemini-2.5-flash'),
  system: SYSTEM_PROMPT + getKnowledge(),  // ← the entire corpus, every request
  messages: cleaned,
  maxOutputTokens: 500,
  temperature: 0.3,
});
return result.toTextStreamResponse();

以上です。作るインデックスも、更新するembeddingも、調整する「top-k」もありません。新しいブログ記事? 自動でglobに拾われる——デプロイした瞬間にアシスタントは知っています。検索の質は、ある意味で完璧です。関連するコンテキストをすべて見る——なぜならすべてのコンテキストを見るからです。

振る舞いの重い部分はシステムプロンプトが担います——コンテキストからのみ答える、話題外は断る、ユーザーの言語で返す、マークダウンなし。そしてその周りにガードレールがあります——IP単位のレート制限、入力長の上限、ターン数の上限、出力の上限。でも検索そのものは? 全部読み込む、それだけ。

トレードオフ

この方法は賢くありません。ただ問題によく合っているだけです。正直な収支表がこちら。

利点欠点
インフラゼロ——ベクトルDBもembeddingパイプラインもない毎クエリでコーパス全体を入力トークンとして送る
メンテ不要——新しい記事は自動で含まれるコストとレイテンシは、質問ではなくコーパスに比例して増える
検索バグなし——常に送るものは取り違えようがない毎回、無関係なドキュメントも送ってトークンを浪費する
陳腐化しない——常にリポジトリと一致モデルのコンテキストウィンドウに制限される
推論もデバッグも自明大きなプロンプトは注意を薄める("lost in the middle")

肝心なのはここです。「全部検索する」のコストは、必要な量ではなく持っている量に比例する。 いまの私は、小さなプロフィール、/usesページ、いくつかのプロジェクト、少数の記事しかありません。Geminiのコンテキストに余裕で収まり、安上がりです。だから欠点はすべて理論上のもの——今日は

これが破綻するとき

正直な問いは「これは良いか」ではなく「いつ機能しなくなるか」です。私が見ている閾値をいくつか。

  • コンテキストウィンドウ。 Gemini 2.5 Flash のウィンドウは大きいので、まだ全然届きません。でも足すドキュメントごとに削られていく。コーパスがウィンドウに近づけば「全部詰め込む」は物理的に不可能です。
  • コスト。 無料枠ではタダ。でも一般に入力トークンはリクエストごとに課金され、しかも私はメッセージのたびにコーパス全体を再送する料金を払っている。規模が大きくなれば馬鹿げています。
  • レイテンシ。 大きなプロンプトは最初のトークンまでが遅い。小さなコーパスでは誰も気づかない。プロンプトが200KBになれば全員が気づきます。
  • 注意の希薄化。 大きなコンテキストのモデルは、巨大なプロンプトの真ん中に埋もれた関連情報を見落としうる。コンテキストが多いほど良い答え、とは限りません。

ざっくりした目安:コーパス全体が数万トークンに収まり、レイテンシが許容範囲なうちは、詰め込みが勝つ。 長い記事が数十本になったら——あるいはフルのドキュメントのような大きなものから答えさせたくなったら——本物の検索が必要な線を越えたということです。

アップグレードの道筋(次にやること)

「全部読み込む」が破綻したら、私が取る道筋は、だいたいこの順です。

  1. まずプロンプト/コンテキストのキャッシュ。 インフラを足す前に、静的なコーパスをプロバイダ側でキャッシュし、変わらないテキストを毎回再送する料金を止める。最も安い勝ち、アーキテクチャ変更なし。
  2. embedding+ベクトルストア。 ドキュメントをチャンク化し、各チャンクをembedしてベクトルを保存。クエリ時に質問をembedして、関連する上位k件のチャンクだけを取り出す。**Supabaseのpgvector**を選ぶと思います——tangochoで既にSupabaseを使っているので、新しく覚えるものが一つ減る。
  3. チャンク戦略。 素朴なRAGが悪い答えを出すのはここ。大きすぎると検索が不正確、小さすぎると文脈を失う。見出しで分割する(私のMDX記事には既にある)のは妥当な出発点。
  4. リランキング。 ベクトル類似度は曖昧。上位k件の候補に対するリランキング一段で、実際にプロンプトに入るチャンクが目に見えて良くなる。
  5. 評価用ハーネス。ないけれど最も欲しいもの:質問と期待する答えの小さなセット。チャンク数やk、プロンプトを変えたときの劣化を捕まえるため。今は「なんとなく」でテストしている。それも規模に耐えません。

順番に注目してください。ベクトルDBに手を伸ばす前に、安く単純な選択肢を出し尽くす。避けたい失敗は、「正しいやり方」だからと検索インフラを足すこと——私のデータ量にとって正しいやり方は、まだjoin()なのに。

学んだこと

これを書いたことで、感覚でしか掴んでいなかったことを言語化できました。

  • RAGは製品ではなくスペクトラム。 「コーパス全体をプロンプトに詰め込む」はその最も単純な一点で、それでも立派なRAG。ベクトル検索は、データに迫られたときに移る先であって、デフォルトではない。
  • アーキテクチャをデータ量に合わせる。 ブログ6本のためのベクトルDBは、エンジニアリングではなく「履歴書駆動開発」。最良のシステムは、実際の問題に合う最も単純なもの。
  • 検索よりプロンプトが効く。 小さなコーパスでは、丁寧なスコープ設定(「コンテキストからのみ答え、それ以外は断る」)のほうが、どんな検索の高度さよりも答えの質に効く。
  • 自分の破綻点を知る。 価値は作ったことではなく、いつ作り直さねばならないかを正確に言えること。閾値を言えないなら、トレードオフを理解していない。

おわりに

「Ask about Yudi」は、RAGとしては地味の極みです。自分の文章を読み込み、Geminiに渡し、答えをストリームする。ベクトルDBはどこにもない。そしてそれが要点です——データ量に対してちょうど良い量のエンジニアリングで、それ以上は一切足していない。

面白い仕事は、派手にすることではありませんでした。派手にしたい衝動に抗うことでした。

読んでくれてありがとう🙏

おすすめの記事