本番投入に耐えるRAGパイプラインを作る

Dev.to / 2026/5/13

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical UsageModels & Research

要点

  • LLMは、知識の鮮度切れやコードベースの理解不足などの文脈ギャップを抱えており、必要な情報がないと自信満々に誤った回答をすることがあります。
  • この記事では、RAG(Retrieval-Augmented Generation)を「検索してからプロンプトに渡す」という考え方として説明し、検索インデックスから関連箇所を取得してプロンプトに追加することで再学習なしに問題を回避します。
  • 著者は、GitHubの活動(PR、コミット、Issue、ディスカッション)を取り込み、コードベースがそう見える理由を質問に答えるための「Keystone」を構築したと述べています。
  • 最初の試作では巨大な文字列をプロンプトに詰め込んでおり、小規模リポジトリでは動いたものの、大規模リポジトリではコンテキストウィンドウ超過、推論の途中での論点喪失、低速化とコスト増により破綻しました。
  • スケールするとリトリーブが不可欠になり、RAGは「任意」ではなく「必須」になる、というのが結論です。RAGの主要段階(リトリーブ→生成)も整理されています。

Large Language Models(別名 LLMs)には記憶の問題があります。知識は学習データの打ち切り日で止まってしまい、あなたのコードベースを知りませんし、先週のチケットのことも知りません…

文脈が欠けているとき、彼らはそれを「分からない」とは言いません……自信満々に推測します。丁寧な言い方は ハルシネーション、もっと失礼な言い方だと それっぽく騙す です。

LLMs sometimes hallucinate

Retrieval-Augmented Generation(別名 RAG)とは、何も再学習せずにそれを解決する方法です。

クローズドブックの試験をオープンブックに変えるイメージです。LLMは依然として「書き手」ですが、今度は「図書館員」が付くことになります。つまり、モデルが書き始める前に、あなたのデータから適切な箇所を取り出して渡す仕組みです。

私はこの仕組みをエンドツーエンドで学ぶためにKeystoneを作りました。

Keystoneは次の2つを行います:

  1. GitHubリポジトリの活動を取り込む → PR、コミット、issue、ディスカッションをすべて
  2. なぜそのコードベースはそのように見えるのかについて質問に答える。

最初のプロトタイプにはリトリーバルシステムがなく、巨大な1本の文字列でした。

小規模なリポジトリではそれで動きました。しかし実際のものでは(+1000 commits+500件のマージ済みPR、大量のissue、さらに 約1,200ファイル のツリー)同時に4つの点で破綻しました:

  • プロンプトが コンテキストウィンドウをはみ出しました。
  • モデルが途中で 流れを失いました
  • レイテンシが秒単位で2桁になり、しかも 毎回の回答が、0.01セント未満で済むはずのクエリにもかかわらず、トークンで約$0.15のコスト

その瞬間に RAGは「任意」ではなく「必須」になるのです。

以下は、今日動いているコードベースから実際に私が作ったものをそのまま抜粋した内容です

What RAG actually is (and what it isn't)

すっきりした考え方はこれです。RAGはただの「検索して、それからプロンプト。

事前にデータを 検索インデックスに変換します。クエリ時に最も関連する箇所を調べて、その部分だけをプロンプトに貼り付けます。以上です!

大きく2つの段階に分けられます:

  1. Retrieval:データを検索し、ユーザーの質問に最も関連するチャンクを取り出します。
  2. Generation:それらのチャンクと質問を通常のLLM呼び出しに渡し、回答文を書かせます。

それ以外のもの; embeddingsベクトルデータベース再ランキングハイブリッド検索… は、意味を一致させるのが キーワードを一致させるより難しいために存在します。

When to use RAG

答えは、モデルが持っていない事実に依存します。

プライベートドキュメント、あなたのコードベース、先週のチケット、そして打ち切り後の情報や非公開の何でも。

さらに、根拠(引用)付きの裏取りの取れた回答を得る方法でもあります。モデルは、どのドキュメントのどのチャンクを使ったかを正確に示すことができます。これが ユーザーに出荷できる「ツール」と、出せない「デモ」の違いです。

When NOT to use RAG

モデルに 別の振る舞いをさせたい(トーン、フォーマット、推論スタイルなど)。それは ファインチューニングプロンプトの工夫の問題であって、リトリーバルの問題ではありません。

RAGは知識を注入しますが、振る舞いは変えません。

私が見た 2つ目の間違いもあります。データがプロンプトに収まるほど小さいのに、RAGを取りに行くことです。コンテキストが10,000トークンあるなら、そのまま貼り付ければいい。RAGは、関連性のバグがプロダクトに漏れ込むという追加レイヤーのコストと引き換えに、スケールを買うものです。

The four stages every RAG has

本番環境の あらゆるRAGには同じ4つの段階があり、どれも素朴にやるとそれぞれ独特の形で壊れます:

  • Ingestion:どこかからデータを取り出して、チャンクに分割する。
  • Embedding:各チャンクをベクトルに変換し、類似度を数学にする。
  • Retrieval:ユーザーの質問に最も近いチャンクを探す。
  • Synthesis:そのチャンクをLLMに渡し、回答を書かせる。

The four stages of a solid RAG pipeline

次のセクションでは、順番にそれぞれを説明します。Keystoneで私が何をしているのか、そして皆さんに注意してほしい点は何かを一つずつ取り上げます。

1. Chunking: where most RAG systems fail

ここは「チャンク分割ってなんか退屈そう」に見えるので、誰も読みたくないセクションです。

しかし「あなたのRAGが実際に動くかどうか」を決めるのも、この部分です。

素朴なアプローチは「500トークンごとにテキストを分割する」です。これがダメになる理由は2つあります:

  1. 例えばPRは、単に500トークン分の「ひとつのもの」ではありません。タイトル本文ファイルのリストコミットメッセージのリスト、場合によってはコメントディスカッションも含まれます。これらを1つのかたまり(blob)として埋め込むと、5つの異なるトピックの平均が1つのベクトルになります。リトリーバルは誤ったPRを返します。なぜなら ベクトルが無関係なものの平均になってしまうからです。
  2. すべての成果物(artifact)が同じ価値を持つわけではない。レビューが5件付いたマージ済みPRは、「typoを修正」というコミットよりも、より強いアーキテクチャ上のシグナルを含みます。同じチャンクサイズと同じメタデータで扱うと、非対称性を捨ててしまいます。

私は typed chunking(型付きチャンク分割)を使っています。成果物の種類ごとに異なる チャンクャ、異なるサイズ 予算(budget)、異なる メタデータを使います:

function chunkPR(pr: IngestPullRequest): EmbeddingChunk {
  const filesStr   = pr.files.join(', ')
  const commitsStr = pr.commits.map(c => c.message).join(' | ')
  const commentsStr = pr.comments?.length
    ? '  | Comments: ' + pr.comments.map(c => `${c.author}: ${c.body}`).join(' | ')
    : ''
  const reviewsStr = pr.reviews?.length
    ? '  | Reviews: ' + pr.reviews.map(r => `${r.author}: ${r.body}`).join(' | ')
    : ''

  const raw = `[PR #${pr.number}] ${pr.title}: ${pr.body ?? ''} | Files: ${filesStr} | Commits: ${commitsStr}${commentsStr}${reviewsStr}`

  return {
    sourceId: `pr:${pr.number}`,        // <- 安定していて、重複排除可能
    content:  truncate(raw, 4000),       // <- PR は 4000 文字
    metadata: { type: 'pr', author: pr.author, number: pr.number, merged_at: pr.merged_at }
  }
}

そして、この関数から実際に出力される チャンク がこちらです:

{
  "sourceId": "pr:42",
  "content": "[PR #42] Replace REST with GraphQL for the data layer: Switched from ...",
  "metadata": {
    "type": "pr",
    "author": "wencesms92",
    "number": 42,
    "merged_at": "2025-11-14T10:22:00Z"
  }
}

Issue は、同じ形の chunker を持ちますが、より小さな予算(1500 chars)と、異なるメタデータになります。 同じパターンで、異なるパラメータです。

注目すべき点は 3 つあります:

返却形式: {"translated": "翻訳されたHTML"}
  1. [PR #N] の接頭辞は意図的です。埋め込みモデルはテキスト先頭に何があるかに敏感なので、アーティファクトの種類と番号を最初に置くことでモデルがそれにアンカーしやすくなります。接頭辞なしで試したところ、「PR 42 で何が変わった?」 のようなクエリでは同じPRの順位が低くなりました。
  2. それぞれの sourceId は安定していて、かつグローバルに一意です(pr:42, issue:7, readme:root, topology:tree)。この key が upsert を成立させる要であり、さらに、マージ後に世界中を作り直すことなく単一のPRをウェブフックで再埋め込みできるのもこれです。同じチャンク分割、同じSQLのupsert、違うのは1行だけ。
  3. コミットは 個別にチャンク分割されるのではなく集約されます。これはパイプライン全体の中でも特に分かりにくい意思決定です。全コミットを1つずつ埋め込むと、ノイズによってインデックスが溺れます。代わりに、PRの中にすでに存在しているコミットを重複排除します(それらはPRと一緒に埋め込まれます)。そして残った「孤児」コミットを1つのチャンクに要約します:
// 孤児 = どのPRにも含まれていないコミット
const prCommitShas = new Set(data.pullRequests.flatMap(pr =>
  pr.commits.map(c => c.sha)))
const orphans = data.commits.filter(c => !prCommitShas.has(c.sha) &&
  !isNoiseCommit(c))

if (orphans.length > 0) {
  chunks.push({
    sourceId: 'commits:orphan-summary',
    content: truncate(
      `[Commits] ${orphans.length} 単独のコミット(PR内ではない) | 作者: ${authorsStr} | 最近: ${recentMsgs}`,
      4000
    ),
    metadata: { type: 'commits', count: orphans.length, orphan: true }
  })
}

孤児がまとめられる前に、ノイズフィルタで役に立たないものを取り除きます:

const NOISE_MSG_PATTERNS = [
  /^merge branch/i, /^merge pull request/i, /^wip$/i, /^fix typo/i,
  /^fixup!/i, /^squash!/i, /^initial commit$/i, /^update \S+$/i
]
const NOISE_AUTHOR_PATTERNS = [/\[bot\]$/, /^dependabot/i, /^renovate/i, /^github-actions/i]

埋め込みAPIに到達する前に、ボットのコミットやマージノイズをフィルタリングすることでコストを節約し、インデックスを密に保ち、「アーキテクチャは何?」系のクエリが、17個ぶんのdependabotで押し上げられたlodashチャンクを返してしまうのを防ぎます。

なので…… ゴミは埋め込まないでください!

2. 埋め込み:適切なモデルの選び方

つまらない真実:ほとんどの埋め込みモデルは 十分に良い です。重要なのは 次元数 × コスト × ドメイン適合 のトレードオフです。

Mistral AI codestral-embed-25051536 次元)を選びました。これはコードにチューニングされた埋め込みモデルで、汎用モデルとは違うランキングになります。

主な理由は2つ:

  1. 寛大な無料枠 → Mistralの無料枠は、サイドプロジェクト初日の時点で課金の壁にぶつからず、実際の埋め込みワークロードを回せるほど十分に寛大です。OpenAIの無料クレジットは、現実のデータセットを埋め込んだ瞬間に蒸発します。
  2. ドメイン適合 → 私のデータは コードに近い ものです:コミットメッセージ、ファイルパス、PRタイトル。

呼び出し自体は平凡で、それが狙いです。作業はここではなく チャンク分割 で行われます:

// クエリ時に、ユーザーの質問を同じモデルで埋め込む
const { embedding } = await embed({
  model: mistral.textEmbeddingModel('codestral-embed-2505'),
  value: query
})

3. レトリーバル(ダメにならないやつ)

レトリーバルは1つのクエリでもよい:

const vectorStr       = `[${embedding.join(',')}]`
const projectIdsArray = `{${projectIds.join(',')}}`

const results = await prisma.$queryRawUnsafe<MatchEmbeddingRow[]>(
  `SELECT pe.id, pe."projectId" as project_id, p.name as project_name,
          pe."sourceId" as source_id, pe.content, pe.metadata,
          (1 - (pe."embedding" <=> $1::vector(1536)))::float as similarity
   FROM "ProjectEmbedding" pe
   JOIN "Project" p ON p.id = pe."projectId"
   WHERE pe."projectId" = ANY($2::text[])
     AND 1 - (pe."embedding" <=> $1::vector(1536)) > 0.3
   ORDER BY pe."embedding" <=> $1::vector(1536)
   LIMIT 12`,
  vectorStr,
  projectIdsArray
)

意図的にやっていることは5つあります:

  1. <=>pgvectorcosine-distance operator です。vector_cosine_ops 上に構築した HNSW index と組み合わせることで、このクエリは逐次スキャンではなくインデックスを使います。
  2. projectId = ANY(...)プリフィルタ します。ベクトル検索の前に権限付与を行うため、類似度ランキングの時点では アクセスできないプロジェクトのチャンクは一切表示されません。さらにインデックスによって探索空間が絞られます。
  3. 0.3 の類似度 しきい値。これ未満では、チャンクはシグナルよりノイズが多くなります。しきい値を下げる → レコールが増える → プロンプトにガラクタが増える。これは合成データではなく、実際のクエリで調整してください。
  4. 上位12件。2〜3件の取りこぼしがあっても 十分に使えるシグナル が残ります。量としては プロンプトを安く保つ 程度に小さく抑えています。最初は25にしていましたが過剰でした。モデルは結局最初の5件に張り付いていて、残りは埋め合わせでした。
  5. SELECT 内でプロジェクト名を JOIN します。複数リポジトリにまたがるクエリでは、モデルはチャンクがどのリポから来たかを知る必要があるためです。リポ名はチャンクのペイロードに含まれ、回答で [repo-A] vs [repo-B] を正確に引用できるのは、そのためです。

re-ranker なし。キーワードのプリフィルタなし。1段階のみ。

チャンク分割が前処理で十分な作業をしてくれるので、2段目のランカーがレイテンシ相当の価値をまだ生んでいません。これは怠けではなく、実際の結果です。

Re-rankers は、チャンクが大きく、ノイズが多く、区別がつきにくい場合に価値を発揮します。私のチャンクは 小さく、型付きで、接頭辞付き です。

4. コンテキスト組み立てとLLM呼び出し

ここで Keystone は教科書的な RAG と分岐します。

典型的なRAGはこうします:

embed(query) → search → concat(top_k) → prompt → generate

私はこうしています:

prompt(LLM, tools={search, tree, file}) → LLMが決定 → 最大10回のツール呼び出し → 最終回答

LLM はオーケストレーターです。2つのデータソース(ベクトル化されたメモリとライブコード)と、利用可能なリポジトリについて説明するシステムプロンプトを見ます。次に、どのツールを呼ぶかを選択します。

分割は次のようになっています:

  • ベクトル化されたメモリは「なぜ」を保持 → PRの説明、イシューのスレッド、コミットメッセージ、意思決定が説明されている成果物。コードが変わっても、これらのベクトルは有用なままです。
  • ライブファイルアクセスは「何」を保持 → 現在の package.json、現在のプラグイン一覧、現在の定数の値。数か月前のコードの古いベクトルは現状について嘘をつくので、「何」の質問にはGitHub API 経由でファイルを最新の状態で読み取ります

こちらは エージェント型の検索(agentic retrieval) が実運用ログでどう見えるかです:

[Chat Tool] searchTechnicalMemory: "relationship between open-webui, opencode, and openclaw" 3つのプロジェクト(s) にわたって

[Chat Tool] 結果9件を発見 [
  { repo: 'open-webui', sourceId: 'readme:root',            similarity: 0.555 },
  { repo: 'openclaw',   sourceId: 'readme:root',            similarity: 0.544 },
  { repo: 'opencode',   sourceId: 'readme:root',            similarity: 0.503 },
  { repo: 'openclaw',   sourceId: 'topology:tree',          similarity: 0.465 },
  { repo: 'open-webui', sourceId: 'topology:tree',          similarity: 0.463 },
  { repo: 'opencode',   sourceId: 'topology:tree',          similarity: 0.463 },
  { repo: 'opencode',   sourceId: 'commits:orphan-summary', similarity: 0.439 },
  { repo: 'openclaw',   sourceId: 'commits:orphan-summary', similarity: 0.431 },
  { repo: 'open-webui', sourceId: 'commits:orphan-summary', similarity: 0.420 }
]

モデルは3つのリポジトリすべてを1回の呼び出しで検索することを選びました。こちらからそう伝えていないのに、クエリがプロジェクト横断であることを理解できていたのです。

  1. readme:root のチャンクが最上位にランクされます(0.550.540.50)。これは README が「プロジェクトが何であるか」を説明しており、クエリもそれをそのまま聞いているからです。
  2. 次に topology:tree のチャンク。3つのリポジトリがどう関係しているかを理解するための2番目に有用なシグナルとして、ファイル構造が効いています。
  3. commits:orphan-summary のチャンクは最後に来ますが、それでも 0.3 の下限を超えています。個々のコミットのノイズではなく、コミット単位の文脈が追加されます。

実務上の効果は2つあります:

  1. モデルが反復できる → メモリを検索して、答えにファイルが必要だと気づけば、ファイルを取りに行ってから回答します。
  2. プロンプトは小さく保つ → モデルが実際に要求したチャンクだけが会話に入ります。「上位25のものをシステムプロンプトに詰め込む」による無駄な膨張はありません。

合成(シンセシス)モデル自体は Mistral AI devstral-small-latestsmallcheapfast です。検索(リトリーバル)がうまくできていれば、書き込み手順のためにフロンティアモデルは不要です。「知能」の高コストな部分は、適切な文脈を見つけることです。良い文脈から一貫した段落を書くのは簡単な方です。

すべての呼び出しは、入力/出力トークン、ステップ数、終了理由とともにログに記録されます。使用状況のテーブルとPostHogの両方に出力されるので、私が実際に「今週はリトリーバルが良くなっているのか悪くなっているのか?」を、雰囲気ではなくグラフで答えられる観測性(オブザーバビリティ)レイヤーになっています。

Keystoneの入力&出力トークン使用量

まとめ

上記のパイプライン(typed chunkingcode-tuned embeddingsHNSW + pgvector、そして検索すべきタイミングを理解しているLLM)が、今日のKeystone の中で動いているものです。

これは小さく、意見(方針)があり、それぞれの工程に1つの役割があり、次工程の制約をきちんと守っているから機能します。

もし1つだけ持ち帰ってほしいことがあるなら:1週間はモデルのリーダーボードを無視して、自分のチャンク分割に執着してみてください。そこで勝ちが生まれます。

世界で一番洗練された埋め込みモデルであっても“ぐちゃぐちゃに結合された”データを救うことはできません。一方で、最も安価なモデルでも十分に良い結果になります。投入されてくるチャンクがシャープで、型付けされていて、ノイズがない限りです。

RAGはLLMに対する魔法のアップグレードではありません。RAGは司書であり、司書は棚の整理の仕方がよくなければ、良い仕事はできません。

Keystone は、ソフトウェアチームにコードベースの「生きた記憶(リビングメモリー)」を提供するために私が作っているプロジェクトです:すべてのPR、コミット、issue、そして意思決定を自然言語で検索可能にします。

提案があれば、ぜひコメント欄で聞かせてください!

読んでいただきありがとうございます!

Wences.