コンテンツにスキップ

RAG 素手実装 学習メモ+用語集 — 写経で詰まったところ

RAG(Retrieval-Augmented Generation)をフレームワークなし・ベクトル DB なしの素手実装から始め、 ハイブリッド検索・ベクトル DB・グラフ RAG・LangChain(LCEL)版まで段階的に積み上げる ex01〜08 の学習メモ。 ロードマップ本体は ../../rag/STUDY_NOTES.md(STEP 1〜5 に対応)、 サンプルの並びは README.md を参照。


0. このフォルダで学ぶこと(1〜2 行サマリ)

「検索 = query ベクトルと文書ベクトルの類似度ランキングにすぎない」を numpy で素手実装してから、 評価(recall@k / MRR)→ ハイブリッド(BM25+RRF)→ ベクトル DB(Chroma)→ グラフ RAG → LangChain 版へと、 ライブラリに頼る前に各部品の中身を一度自分で書くことで「抽象化の下で何が起きているか」を体に入れる。


1. 全体像 — サンプル間の関係と学ぶ順序

RAG の核は 検索(retrieve)→ 注入(augment)→ 生成(generate) の 3 段。ex03 がこの全工程の最小実装で、 他のサンプルは「3 段のどこを掘り下げるか/別実装に差し替えるか」で位置づけられる。

# ファイル 学ぶ概念 3 段のどこ API
01 ex01_embeddings.py embedding + cosine 類似度を numpy で自作。「検索 = 類似度ランキング」の体感 検索の心臓部 Embeddings
02 ex02_chunking.py 固定長分割(overlap 有/無)と段落分割を自作して目で比較 検索の前処理(索引づくり)
03 ex03_vector_rag.py 3 関数(retrieve/augment/generate)で最小ベクトル RAG 検索→注入→生成 全部
04 ex04_retrieval_eval.py golden QA で recall@k / MRR を計測。分割方式を数値で比較 検索の品質計測 Embeddings
05 ex05_bm25_rrf.py BM25(文字 2-gram)+ ベクトルを RRF で融合 検索の強化(ハイブリッド) Embeddings
06 ex06_chroma.py Chroma へ載せ替え。永続化・メタデータフィルタ・距離関数 検索の実装差し替え Embeddings
07 ex07_graph_rag.py LLM トリプル抽出 → NetworkX 知識グラフ → ego graph 注入 検索対象をグラフに
08 ex08_langchain_rag.py 同じ RAG を LangChain(LCEL)で書き直し、素手部品と 1 対 1 対応 全部を抽象化に載せる

推奨順: 01 → 02 → 03(ここで RAG が一周する)→ 04(数値で測る癖をつける)→ 05/06/07(検索の強化・差し替え)→ 08(抽象化に乗せ替え)。

RAG の検索→注入→生成フロー(ex03 が基準形)

flowchart TD
    subgraph Index["索引づくり(起動時に1回だけ)"]
        D[CORPUS 各チャンク] -->|embed| DV[文書ベクトル群 doc_vectors]
    end

    Q[ユーザーの質問 query] -->|embed| QV[質問ベクトル qvec]
    QV --> R{retrieve: cosine 類似度ランキング}
    DV --> R
    R -->|上位 top_k 件| C[ヒットしたチャンク contexts]
    C --> A[augment: プロンプトに文脈を注入]
    Q --> A
    A -->|文脈に基づき答えよ・無ければ無いと言え| G[generate: LLM 呼び出し]
    G --> ANS[回答]

ポイントは 2 つ。(1) 文書側の embedding は起動時に 1 回だけ計算して使い回す(毎質問で再計算しない=索引づくり)。 (2) 注入プロンプトに「文脈に答えがなければ無いと言え」を入れて幻覚を抑える(ex03 の「明日の天気は?」で効果を確認)。

ハイブリッド検索のフロー(ex05)

flowchart TD
    Q2[質問 query] --> VR[vector_ranking: 意味の近さで順位]
    Q2 --> BR[bm25_ranking: キーワード一致で順位]
    VR -->|順位リスト| RRF[rrf_fusion: Σ 1/(k+rank)]
    BR -->|順位リスト| RRF
    RRF --> F[融合後の最終順位]

スコアの絶対値ではなく順位だけを使うのが RRF の肝。ベクトルの cosine(0〜1)と BM25 のスコア(青天井)は スケールが違って直接足せないが、順位なら単位が揃うので無調整で混ぜられる。


2. サンプル別の要点(原理から)

ex01 — embedding + cosine 類似度(検索の心臓部)

embedding とは: テキストを「意味空間の座標」へ写す関数。text-embedding-3-small なら 1 文 → 1536 次元のベクトル。 意味が近い文はベクトルも近くなるよう学習されている。だから「猫はこたつで丸くなって寝ている」と 「ネコがストーブの前でうたた寝している」は、1 文字も共通語がなくても類似度が高い。これがキーワード検索(文字一致)との決定的な違い。

cosine 類似度とは: 2 ベクトルのなす角の余弦。1 に近いほど同方向=意味が近い。ex01_embeddings.py:31-32:

def cosine_similarity(a, b):
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))  # ③ 内積 ÷ (ノルム積)
やってること なぜそうする
np.dot(a, b) 2 ベクトルの内積 同方向なら大、直交なら 0
/ (norm(a)*norm(b)) 各ベクトルの長さで割って正規化 ベクトルの長さを無視して向きだけを比べる(文の長短に左右されない)

素手実装の意義: ライブラリの cosine_similarity() を呼ぶと中身が見えないが、ここでは「内積を長さで割るだけ」と分かる。 ex01_embeddings.py:60-65 のミニ検索(query を embed → 全文と類似度 → argmax)がベクトル検索の原型そのもの。

ex02 — チャンク分割(検索品質を最も左右する前処理。API 不要)

なぜ文書まるごとではなくチャンク化するのか(2 つの理由):

  1. 精度 — 長い文書を 1 ベクトルに潰すと、いろんな話題が混ざって「意味の平均」になり、質問との類似度がぼやける。チャンクに割れば「質問に該当する一段落」だけがピンポイントで高スコアになる。
  2. コンテキスト窓・コスト — 文書全部を LLM に渡すと長すぎ・高コスト。必要な数チャンクだけ注入するのが効率的。

現実の RAG = 「長いソース文書をチャンク化 → 質問に近いチャンクを当てる → それを根拠に LLM が答える」。 検索でヒットするのはチャンク単位(ex04 はチャンクの由来 doc_id で正解判定する → 3.6 節)。

なぜ分割が要るか: 文書をそのまま 1 件として索引すると、長い文書では「質問に関係ある 1 文」が 無関係な大量の文に埋もれて類似度がぼやける。逆に細かく切りすぎると回答に必要な文脈が欠ける。 ちょうど良い粒度に切る作業がチャンク分割。ex02_chunking.py:32-44 に 2 方式:

  • 固定長分割split_fixed): size 文字ごとに切る。長さが揃い実装も単純だが、文や段落の途中でちぎれる。
  • overlap: step = size - overlap で開始位置を進め、隣接チャンクを重ねる。境界で文脈が切れる事故を緩和するが、同じ内容が 2 チャンクに重複して索引サイズが膨らむ(トレードオフ)。
  • 段落分割split_paragraph): 空行 \n\n で切る。意味のまとまりを保てるが長さがバラつく。

素手実装の意義: 「目で見る」段階。だがどれが良いかは感覚で決めず ex04 で recall@k を測って決める、という規律をここで植え付ける。

ex03 — 最小ベクトル RAG(検索→注入→生成 を 3 関数で)

RAG 全工程をフレームワークなしで一周する中核サンプル。3 関数に分解されているのが学習上の肝。

  • retrieveex03_vector_rag.py:38-44): query を embed し、全文書ベクトルとの cosine を行列演算で一括計算して上位 k 件。
  • build_prompt:48-54): ヒットしたチャンクを箇条書きでプロンプトに埋め込む(=注入)。「文脈に答えがなければ無いと言え」を明示。
  • generate:58-64): 文脈入りプロンプトで gpt-4o-mini に回答させる。temperature=0 で再現性確保。

ex03_vector_rag.py:41-42 の正規化テクニックに注目:

doc_norm = doc_vectors / np.linalg.norm(doc_vectors, axis=1, keepdims=True)  # 各行を単位ベクトル化
scores = doc_norm @ (qvec / np.linalg.norm(qvec))  # 正規化済み同士の内積 = cosine を一括計算

ex01 では 1 ペアずつ cosine を計算したが、ここでは全文書を正規化しておけば内積 1 発で全件の cosine が出る。 これが「総当たり検索」の実体。3 つ目のクエリ「明日の天気は?」で知識ベース外でも幻覚せず「無い」と言えるかを確認するのが観察ポイント。

ex04 — recall@k / MRR で測る(「なんとなく良い」の禁止)

RAG 改善の規律を学ぶ。変更(分割方式を変える)→ 指標の差分を見る、を必ずセットで回す。

  • golden セットex04_retrieval_eval.py:29-36): (質問, 正解が書いてある文書 id) のペア集合。これが「正解」の定義。
  • recall@k: 正解文書由来のチャンクが上位 k 件に入った質問の割合(ヒットしたか否かの 0/1 を平均)。
  • MRR: 正解が出てきた順位の逆数の平均(1 位なら 1.0、2 位なら 0.5、3 位なら 0.333…)。順位の良さまで見る。

評価ループの核 ex04_retrieval_eval.py:70-78:

ranking = np.argsort(scores)[::-1]           # スコア降順に並べた文書インデックス
rank = next(r for r, i in enumerate(ranking) if chunks[i][1] == gold_doc)  # 正解が初出する順位
if rank < k: hits += 1                        # recall@k 用: 上位 k に入ったか
reciprocal_ranks.append(1.0 / (rank + 1))     # MRR 用: 順位の逆数
やってること なぜそうする
argsort(...)[::-1] スコアを降順ソートして順位列に 検索結果の並びを再現
next(r for ... if 正解) 正解文書のチャンクが最初に現れた順位 k 件中どこに正解が来たか
rank < k 上位 k に入ったか(recall) k=3 なら 0,1,2 位なら成功
1/(rank+1) 逆数(MRR) 1 位ほど高得点、下位ほど減衰

ex05 — ハイブリッド検索(BM25 + ベクトル + RRF)

ベクトル検索の弱点: 型番・固有名詞・専門用語の完全一致に弱い。E-4031 のような語は意味空間で近い文がなく空振りしやすい。 そこをキーワード検索(BM25)で補い、RRF で融合するのが実務の定番。

  • BM25ex05_bm25_rrf.py:50-52): TF-IDF 系のキーワードスコア。語の一致がないと 0 点。日本語は単語境界がないので、ここでは文字 2-gramtokenize:32-33)でトークン化(本格版は MeCab/fugashi で形態素分割)。
  • RRF:57-62): 一言でいうと「点数を信じず、順位で多数決」(絶対スコアではなく相対位置=順位が効く)。各検索系の順位だけを使い score = Σ 1/(k+rank)(k=60 は原論文の定数)で融合。スコアの単位が違っても混ぜられて頑健。

補足: TF-IDF 系とは(BM25 の親 = キーワード検索の古典)

TF-IDF = 「ある単語が、ある文書にとってどれだけ特徴的か」を TF × IDF で数値化する重み付け。embedding(意味の近さ)の対極にある、文字の一致ベースの古典。

  • TF(Term Frequency/単語頻度): その文書内でその単語が何回出るか。多いほど高。「猫」が 10 回出る文書は猫について書かれている度合いが高い。
  • IDF(Inverse Document Frequency/逆文書頻度): log(全文書数 / その単語を含む文書数)レアなほど高。「の」「です」のような全文書に出る語は価値ゼロ、ClickHouse のような一部にしか出ない語は高価値。
  • 掛け合わせ: 「その文書によく出て、かつ世間ではレアな単語」が高スコア = その文書を特徴づけるキーワード。ClickHouse で検索すれば、それを含む文書(ex04 なら langfuse)が確実にヒットする。

BM25 は TF-IDF の改良版。素朴な TF-IDF の弱点 2 つを直したもので、実務では生 TF-IDF より BM25 が使われる(ex05 もこちら):

  1. TF の頭打ち(飽和) — 「猫」が 100 回出ても 10 回の文書の 10 倍重要ではない。BM25 は TF が増えても頭打ちにする。
  2. 文書長の正規化 — 長い文書はどの単語もたくさん出て有利になりすぎる。BM25 は文書の長さで割って補正する。

→ 「TF-IDF 系」と言うときは、この単語頻度ベースのキーワード検索の系譜(TF-IDF → BM25)を指す。embedding がレアな固有名詞を取りこぼす穴を、文字一致で確実に拾うのが役割(だから両者を RRF で混ぜる)。

観察ポイント: 「LLM の挙動を観測できるツールは?」(言い換え)はベクトルが勝ち、「E-4031 の意味は?」(型番)はBM25 が勝つ。RRF はその両方を取りこぼさない。

ex06 — ベクトル DB(Chroma)へ載せ替え

ex03 で numpy 素手でやった「索引づくり + 類似度検索」を Chroma に任せる。やっていることは ex03 と同じで、差は次の 3 つ:

  1. 永続化ex06_chroma.py:40): PersistentClient(path="./chroma_db") で索引がファイルに残る。再起動しても add し直さなくてよい。
  2. メタデータフィルタ:64-66): where={"category": "ops"} で「ops カテゴリの中だけ検索」。cosine 類似度だけでは書けない絞り込みを DB の where 句が担う。
  3. スケール: numpy 総当たりは数万件まで。それ以上は ANN(近似最近傍)を持つ DB が要る。

ハマりどころ:71-73): Chroma の既定の距離は L2(小さいほど近い)。cosine にしたいならコレクション作成時に metadata={"hnsw:space": "cosine"} を指定する。ex01〜05 の cosine(大きいほど近い)と向きが逆なので、距離を見て「近い/遠い」を読み違えやすい。

ex07 — グラフ RAG(多段ホップ・関係性質問に強い)

ベクトル RAG は「似た文を 1 つ拾う」のは得意だが、「山本さん→JAIST→石川県」のように複数の文を辿らないと答えられない質問に弱い。 そこで知識をグラフ(ノード=エンティティ、辺=関係)にして、質問エンティティ起点のサブグラフを注入する。

  • トリプル抽出ex07_graph_rag.py:33-50): LLM に (主語, 述語, 目的語) を抽出させる。response_format={"type":"json_object"} で出力を JSON 固定するのが肝(自由文だと後段のパースが壊れる)。
  • ego graph:60-66): あるノードから半径 N ホップ以内のサブグラフ。nx.ego_graph(undirected, e, radius=2) で 2 ホップ分を取り、関係を双方向に辿るため to_undirected() してから探索する。
  • グラフ RAG が勝つのは関係性・経路・多段ホップ型の質問。単発のベクトル検索では各文が単独でヒットしづらいタイプ。

一言でいうと: ベクトル RAG は「似た文を拾う」、グラフ RAG は「関係を辿る」。効くのは絶対的な類似度ではなくノード間の経路(相対的な繋がり)

実験: 正規化前後でサブグラフが変わる = entity resolution が多段ホップの生命線

抽出プロンプトに「エンティティ名は核となる固有名詞だけにし、同じ実体は同じ表記にする」を足すと、結果が劇的に変わる:

正規化なし 正規化あり
山本の所属 (山本さん, 所属, "JAIST の修士課程") (山本, 所属, "JAIST")
JAIST の場所 ("国立大学院大学", に, 石川県) ← 別ノード ("JAIST", 位置, 石川県)
注入サブグラフに JAIST→石川県 出ない(経路が切れる) 出る(2 ホップ開通)
  • 正規化なしだと "JAIST の修士課程""JAIST"別ノードに分裂し、ego_graph(radius=2) でも山本→JAIST→石川県を辿れない。それでも答えが「石川県」になるのは LLM のパラメトリック知識であって、グラフ経由ではない(=グラフ RAG が効いた証拠にならない)。
  • 正規化ありだと両者が JAIST に統合され辺が連結 → サブグラフに JAIST --位置--> 石川県 が現れ、構造を経由した多段ホップが成立する。
  • 教訓: グラフ RAG の精度はトリプル抽出の品質、特に entity resolution(表記揺れの統合)で決まる。ベクトル RAG は表記揺れに強い(ex01 の猫/ネコ)のが売りだが、グラフ RAG は逆に表記の一致が命という対照的な弱点を持つ。

ex08 — LangChain(LCEL)版(抽象化に乗せ替え)

ex01〜03 で素手実装した部品が LangChain のどのクラスに対応するかを 1 対 1 で確認する。中身を知ってから抽象化に乗るのが目的。

素手実装 LangChain
ex02 split_fixed() RecursiveCharacterTextSplitter(separators の先頭から順に試す賢い版)
ex01 embed() + client OpenAIEmbeddings
ex03 doc_vectors + retrieve() InMemoryVectorStore + as_retriever()
ex03 build_prompt() ChatPromptTemplate
ex03 generate() ChatOpenAI
ex03 main の手続き LCEL チェイン| でつなぐ宣言的パイプ)

LCEL チェインの核 ex08_langchain_rag.py:69-74:

chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}  # dict は並列評価
    | prompt | llm | StrOutputParser()
)

{...} の中は並列に評価され、questionRunnablePassthrough() で素通しでプロンプトまで届く。 ex03 の main 関数(検索→注入→生成の手続き)が、この 1 本の宣言的パイプに圧縮されている。 各部品は全部 Runnable なので retriever.invoke(...) 単体でもデバッグできる(:88)。


3. 用語集(最重要)— 混同しやすい同系語を対比で

3.1 検索評価の 2 語:recall@k と MRR

用語 何を測るか 値の出方 具体例(ex04) 計算
recall@k 正解が上位 k 件に入ったか(0/1) 質問ごとに入った/入らないを平均 recall@3 = 6 問中 5 問で上位 3 に正解 → 0.83 rank < k の割合
MRR 正解が何位に出たか(順位の良さ) 順位の逆数の平均 1 位なら 1.0、2 位 0.5、3 位 0.333 mean(1/(rank+1))

判定基準: 「とにかく拾えれば OK(k 件読ませる)」なら recall@k。「上位に出すこと自体が大事(1 件しか注入しない、UX 上 1 位が効く)」なら MRR も見る。 よくある誤解 →「recall が同じなら同性能」ではない。recall@3 が同じでも MRR が高い方がより上位に正解を出しているので、top_k を絞ったとき強い。

3.2 類似度・距離の 2 系:cosine 類似度 と L2(ユークリッド)距離

指標 意味 値の向き 出てくる場所
cosine 類似度 ベクトルのなす角。長さ無視で向きだけ 大きいほど近い(最大 1) ex01〜05・ex08 の既定、Chroma の hnsw:space="cosine"
L2 距離(ユークリッド) ベクトル間の直線距離 小さいほど近い ex06 Chroma の既定result["distances"]

ハマりどころ: ex06 までは「スコアが大きい=近い」だったのに、Chroma の既定 L2 では「dist が小さい=近い」と向きが逆。 embedding を正規化(単位ベクトル化)すると cosine と L2 の順位は一致するが、生の値の大小は逆なので「近い/遠い」の読み方を取り違えないこと。

3.3 検索の 2 大系統:dense(ベクトル)検索 と sparse(キーワード)検索

系統 代表 表現 強い場面 弱い場面 出てくる場所
dense 検索 embedding + cosine 密ベクトル(全次元に値) 言い換え・表記揺れ・意味の近さ 型番・固有名詞の完全一致 ex01,03,06,08
sparse 検索 BM25 / TF-IDF 疎ベクトル(語の有無) 型番・専門用語・完全一致 言い換え・同義語 ex05

いつ使い分けるか(1 文判定): クエリが言い換え・抽象語中心なら dense、型番・固有名詞・専門用語の一致が効くなら sparse。 どちらか迷うなら両方を RRF で混ぜる(=ハイブリッド、ex05)のが実務の安全策。

3.4 BM25 と embedding 検索

観点 BM25(sparse) embedding 検索(dense)
スコアの源 語の出現頻度(TF)× レア度(IDF) 意味ベクトルの cosine
一致しない語があると 0 点になりうる(語が無いと拾えない) 0 点にはならない(意味で拾う)
日本語の前処理 トークン化が必須(ex05 は文字 2-gram、本格版は MeCab) 不要(モデルが内部で処理)
学習データ依存 なし(統計のみ) あり(モデルの学習分布に依存)
API コスト なし(ローカル計算) embedding API 呼び出しが要る

3.5 RRF(Reciprocal Rank Fusion)とは何か

複数の検索系の結果をスコアの絶対値ではなく順位だけで融合する手法。各文書について score = Σ 1/(k + rank) を全系統で合計し降順に並べ直す(ex05_bm25_rrf.py:57-62、k=60 は原論文定数)。 なぜ順位だけ使うか → cosine(0〜1)と BM25(青天井)はスケールが違って直接足せない。順位なら単位が揃うので正規化やスケール調整が不要で頑健。複数系統の「だいたい上位に来た」を素直に拾える。

3.6 チャンク と ドキュメント

用語 何を指すか 粒度 具体例(ex04)
ドキュメント 元の文書(1 件のソース) DOCS["langfuse"] の本文 1 件
チャンク ドキュメントを分割した検索単位 build_chunks() が作る (チャンク文字列, 由来文書id)

ハマりどころ: 評価(recall@k)の「正解」はドキュメント単位で定義し、検索はチャンク単位で行う。 ex04 はチャンクが「どの文書から来たか」(由来 id)を覚えておき、chunks[i][1] == gold_doc で「正解文書由来のチャンクが上位に来たか」を判定する。チャンクと文書を混同すると評価ロジックが破綻する。

3.7 ベクトル DB と numpy 総当たり

観点 numpy 総当たり(ex03) ベクトル DB / Chroma(ex06)
検索方式 全件と内積(厳密最近傍) ANN(近似最近傍、HNSW など)
規模 〜数万件 数百万件〜
永続化 なし(毎回 embed し直し) あり(ファイルに索引が残る)
フィルタ 自前で書く必要あり where 句で標準サポート
やっていること 同じ(類似度ランキング) 同じ

よくある誤解: 「ベクトル DB は魔法の検索エンジン」ではない。中身は ex03 と同じ類似度検索で、差は永続化・フィルタ・スケール(近似化)だけ。

3.8 グラフ RAG と ベクトル RAG

観点 ベクトル RAG(ex03) グラフ RAG(ex07)
検索対象 チャンク(似た文を拾う) サブグラフ(エンティティ起点の関係)
得意な質問 「X とは何か」(単発・事実) 「X と Y はどう繋がるか」(多段ホップ・関係性)
索引づくり embed するだけ LLM でトリプル抽出 → グラフ構築(コスト高)
注入されるもの チャンク文字列 主語 --述語--> 目的語 の辺列

判定基準: 単発の事実質問ならベクトル RAG で十分。経路・関係性・複数文を辿る質問が中心ならグラフ RAGを検討(ただし索引づくりに LLM 抽出のコストがかかる)。

3.9 よくある誤解の訂正

  • ❌「embedding が似ている = 単語が同じ」→ ✅ 意味が近ければ共通語ゼロでも類似度は高い(ex01 の猫/ネコ)。
  • ❌「チャンクは小さいほど精度が上がる」→ ✅ 小さすぎると回答に必要な文脈が欠ける。ちょうど良い粒度はex04 で測って決める。
  • ❌「ベクトル検索があれば BM25 は不要」→ ✅ 型番・固有名詞の完全一致は BM25 が勝つ(ex05 の E-4031)。
  • ❌「RRF はスコアを足し合わせる」→ ✅ 順位を足す(1/(k+rank))。スコアの絶対値は使わない。
  • ❌「ベクトル DB に変えると精度が上がる」→ ✅ 検索ロジックは同じ。変わるのは永続化・フィルタ・スケールだけ(精度は embedding と分割で決まる)。
  • ❌「LangChain を使うと別物の RAG になる」→ ✅ 中身は素手実装と同じ。InMemoryVectorStore の内部も ex03 と同じ cosine 総当たり。

3.10 クイック早見表(困りごと → 見る/使うもの)

困りごと 見る/使うもの
表記揺れ・言い換えでも拾いたい embedding + cosine(dense 検索、ex01/03)
型番・固有名詞の完全一致を拾いたい BM25(sparse 検索、ex05)
言い換えも型番も両方拾いたい BM25 + ベクトルを RRF で融合(ex05)
「良くなった」を客観的に言いたい golden セット + recall@k / MRR(ex04)
上位 k に入ればいい / 1 位の質を見たい recall@k / MRR(ex04)
索引を永続化・カテゴリで絞りたい Chroma の PersistentClient + where(ex06)
「近い」が大きい値か小さい値か迷う cosine=大きいほど近い / L2=小さいほど近い(§3.2)
多段ホップ・関係性の質問に答えたい グラフ RAG(トリプル抽出 → ego graph、ex07)
幻覚を抑えたい 注入プロンプトに「文脈に無ければ無いと言え」(ex03)
LLM 出力のパースが壊れる response_format={"type":"json_object"}(ex07)
抽象化に乗せ替えたい LangChain LCEL(素手部品との対応表、ex08)

4. 学んだこと(要点)

  • 検索の本質は類似度ランキング。ベクトル DB もフレームワークも、この上に永続化・フィルタ・宣言的記法を被せた皮にすぎない(ex03 ↔ ex06 ↔ ex08 が同じことをしている)。
  • RAG は 3 関数(retrieve / augment / generate)に分解できる。どこを掘るか・差し替えるかでサンプルが整理できる。
  • 改善は必ず数値で。分割・top_k・検索方式を変えたら recall@k / MRR の差分で判断する(感覚で決めない)。golden セットが「正解」の定義になる。
  • dense と sparse は補完関係。言い換えは dense、完全一致は sparse、迷うなら RRF で融合。
  • RRF は順位だけで融合するからスケール調整が要らず頑健。これがハイブリッド検索を実務で使える形にしている。
  • 距離の向きに注意(cosine は大、L2 は小が「近い」)。Chroma の既定は L2 なので cosine にしたいなら明示指定。
  • グラフ RAG は関係性質問の切り札だが索引づくりに LLM コストがかかる。単発事実質問はベクトル RAG で十分。
  • 抽象化に乗る前に中身を一度書く。LangChain の各クラスが素手のどの関数かを知っていれば、デバッグも差し替えも怖くない。
  • 幻覚抑制はプロンプトの 1 行(「文脈に無ければ無いと言え」)と出力フォーマット固定(JSON)という、地味だが効く実装習慣。

5. 拡張アイデア(最低 3 案)

  1. MeCab/fugashi トークナイザで BM25 を強化(ex05): 文字 2-gram を形態素分割に置き換え、recall@k がどれだけ上がるか ex04 の枠組みで計測する。
  2. 再ランキング(reranker)を足す: ハイブリッド検索の上位 N 件を Cross-Encoder(例 bge-reranker)や LLM-as-Judge で並べ替え、MRR の改善を測る。
  3. チャンクサイズ・overlap のグリッドサーチ(ex02+ex04): size × overlap を総当たりして recall@k / MRR のヒートマップを作り、データに最適な分割を数値で決める。
  4. セマンティックチャンキング: 固定長ではなく、隣接文の embedding 類似度が落ちる境界で切る分割を実装し、段落分割と比較する。
  5. グラフ RAG とベクトル RAG のルーティング(ex03+ex07): 質問が「関係性型」か「事実型」かを LLM で分類し、検索方式を動的に切り替える Adaptive RAG(連載第 12 回相当)に発展させる。
  6. Chroma のメタデータ設計を実務化(ex06): sourcedatecategory を付け、「最新 3 か月の ops 文書だけ」のような複合フィルタ検索を試す。

6. 現代版・実装上の注意

  • embedding モデル: text-embedding-3-small(1536 次元・$0.02/1M tokens)を使用。旧 text-embedding-ada-002 より安価で高性能。次元削減したいなら dimensions= 引数で切り詰められる。
  • OpenAI SDK: openai>=1.40 の新 API(client.embeddings.create / client.chat.completions.create)を使用。旧 openai==0.xopenai.Embedding.create ではない。
  • LangChain import パス(ex08): langchain_core / langchain_openai / langchain_text_splitters分割パッケージを使用。旧 langchain.embeddings.openai のような一枚岩パスは非推奨。
  • API キー: op run --env-file=.env.op -- uv run python exNN.py 経由で OPENAI_API_KEY を注入(生キーは書かない)。ex02 のみ API 不要でそのまま動く。
  • コスト: 全サンプルを回しても合計 $0.05 以下のはず(生成 gpt-4o-mini、埋め込み text-embedding-3-small)。

7. 記事参照

  • ロードマップ本体: ../../rag/STUDY_NOTES.md(STEP 1〜5 に対応。STEP 6 GraphRAG・STEP 7 動的 KG はこの先の発展課題)
  • サンプルの並び・観察ポイント: README.md
  • 連載との対応: 第 02・03 回(RAG 基礎)、第 10 回(CRAG)、第 12 回(Adaptive RAG)、第 26・27 回(DSPy による RAG 最適化)

作成: 2026-06-12 / 最終更新: 2026-06-15