コンテンツにスキップ

STUDY_NOTES — ベクトル RAG(LangChain + Chroma)

素手版 ../rag_basics/ で「中身」を手で書いた後、同じ概念を代表ツールの部品で組み直したときの学習メモ。用語集を中心に置く(混乱はほぼ用語のすれ違いから来るため)。


1. 全体像 — ベクトル RAG の 2 フェーズ

ベクトル RAG は時間軸の違う 2 つのフェーズに分かれる。ここを混ぜると混乱する。

フェーズ いつ 何をする このフォルダの担当
索引づくり(ingest / indexing) 質問が来る前に 1 回 文書を分割 → embedding → ベクトルストアに格納 ex01
検索+生成(query time) 質問のたびに毎回 query を embedding → 類似チャンク取得 → プロンプト注入 → LLM 生成 ex02 / ex03
flowchart TD
    subgraph A[索引づくり ex01・質問前に1回]
        D[文書 DOCS] --> SP[分割 Splitter] --> EM[ベクトル化 Embeddings] --> VS[(Chroma)]
    end
    subgraph B[query time ex02・ex03・質問ごと]
        Q[query] --> RET[retriever<br/>類似検索] --> CTX[文脈注入] --> GEN[LLM 生成] --> ANS[回答]
    end
    VS -.索引を参照.-> RET

ポイント: 文書側の embedding は索引づくりで 1 回だけ。毎質問で再計算しない(素手版 ex03 の「doc_vectors は起動時に 1 回」と同じ思想で、ここでは Chroma が永続して持つ)。


2. 用語集(最重要)

2.1 RAG の部品(階層と対比)

用語 一言 具体例(このフォルダ) 作り方 / 使う API 判定の一文
Document LangChain の文書最小単位 Document(page_content=..., metadata={"source": "langfuse"}) langchain_core.documents.Document 「本文+メタデータの 1 件」なら Document
chunk(チャンク) 分割後の小片。検索の最小単位 ex01 で 5 文書 → 12 チャンク splitter.split_documents(docs) 「retriever が返してくる 1 件」がチャンク
embedding テキスト→意味空間の座標ベクトル text-embedding-3-small(1536 次元) OpenAIEmbeddings(model=...) 「テキストを数値ベクトルにする関数」なら embedding
vectorstore(ベクトルストア) ベクトル+本文の索引・保管庫 Chromachroma_db/ に永続) Chroma.from_documents(...) 「ベクトルを溜めて近傍検索できる入れ物」
retriever query→関連 Document を返す Runnable store.as_retriever(search_kwargs={"k": 2}) vectorstore.as_retriever() 「query 文字列を入れると Document が出る」なら retriever
LCEL チェイン Runnable を \| でつないだパイプ {...} \| prompt \| llm \| parser(ex03) \| 演算子 \| でつないだ実行可能な一本道」

階層イメージ: vectorstore ⊃ 多数の chunk(= Document)。retriever は vectorstore に「k 件取って」と頼む薄いラッパ。

2.2 分割(chunking)

用語 意味 このフォルダでの値
chunk_size 1 チャンクの最大文字数 ex01 は 60、ex04 で 20〜200 を比較
chunk_overlap 隣接チャンクの重なり文字数。境界の文脈切れを緩和 ex01 は 10
separators 分割を試す区切りの優先リスト ["\n\n", "\n", "。", "、", ""](段落→行→句点→読点→1 文字)
RecursiveCharacterTextSplitter separators を先頭から試し、収まらなければ次へ 素手版 split_fixed() の上位互換

判定の一文: 「大きすぎると類似度がぼやけ、小さすぎると文脈が欠ける」。最適値は固定値でなく ex04 の数値で決める

2.3 検索の種類(similarity vs MMR / dense vs sparse)

用語 強み 弱み このフォルダ
検索戦略 similarity(類似度) query に最も近い順に取る。素直 似たチャンクが重複しがち as_retriever() の既定
検索戦略 MMR(Maximal Marginal Relevance) 関連度と多様性を両立。重複を弾く やや遅い・パラメータ要調整 ex05 search_type="mmr"
ベクトルの質 dense(密ベクトル) 意味の近さ・言い換えに強い 型番・固有名詞の完全一致は苦手 OpenAIEmbeddings + Chroma
ベクトルの質 sparse(疎ベクトル / BM25) 語の完全一致・型番に強い 言い換えに弱い(語が一致しないと 0 点) ex05 BM25Retriever

判定の一文: - similarity か MMR か → 「取った結果が似たもの同士で被って困る? Yes→MMR」 - dense か sparse か → 「クエリは言い換え主体? Yes→dense。型番・固有名詞の一致が要る? Yes→sparse(BM25)」

2.4 ハイブリッドと融合(RRF)

用語 意味 使う API
ハイブリッド検索 dense と sparse の両方を引いて混ぜる EnsembleRetriever([bm25, dense], weights=[...])
RRF(Reciprocal Rank Fusion) スコアの絶対値ではなく 順位だけで融合(Σ 1/(k+rank))。スケール調整不要で頑健 EnsembleRetriever が内蔵(素手版は rrf_fusion() を手書き)

判定の一文: 「2 つの検索系のスコアは単位が違う(cosine と BM25)→ 絶対値で足せない → 順位で混ぜる = RRF」。

2.5 評価(recall@k vs MRR)

用語 定義 値の範囲 何に敏感か
golden データセット (質問, 正解が書いてある source) のペア集合 _corpus.GOLDEN
recall@k 正解 source のチャンクが上位 k 件に入った質問の割合 0〜1。k を上げると上がる 「正解が圏内か」の YES/NO
MRR(Mean Reciprocal Rank) 正解が最初に出た順位の逆数の平均(1 位=1.0、2 位=0.5) 0〜1。順位そのものに敏感 「正解が何位か」の細かさ

判定の一文: 「正解が上位 k に入ったかだけ見たい→recall@k。何位に来たかまで見たい→MRR」。 よくある誤解: 「recall@k が 1.00 なら完璧」ではない。k を緩めれば誰でも 1.00 に近づく。recall@1 や MRR の方が分割の良し悪しに鋭い


3. ex ごとの学びの核

ex 一番のキモ 素手版で対応する苦労
01 索引づくりは「分割→embed→格納」の 3 ステップ。metadata["source"] を全チャンクに引き継ぐ embed・split・chromadb add を別々に手書き
02 retriever は query 1 個→Document リストの Runnable。k がノイズと recall のトレードオフ retrieve() を numpy で 10 行
03 LCEL {context, question} \| prompt \| llm \| parser でデータ流が図になる main の手続き 3 行
04 採点は retriever.invoke の返り Document を上から見るだけ。ロジックは素手版と同一 evaluate() で argsort を自作
05 EnsembleRetriever が RRF を内蔵。MMR は多様性確保の引き出し rrf_fusion() を手書き、MMR は無し
06 CallbackHandler 1 個で検索段+生成段が 1 トレースに トレース自体が無かった

4. つまずきメモ(このフォルダで実際に踏んだ罠)

  • Chroma.from_documents は「追記」。同じ collection に繰り返し呼ぶとチャンクが二重格納され、k=2 で同じ文が 2 回ヒットする。ex01 の build_vectorstore() は呼ぶたびに delete_collection() してから作り直して冪等化した。
  • build_vectorstore を 2 回呼ぶと先の retriever が壊れる。delete_collection が前回のコレクション ID を消すため、ex05 は索引を 1 回だけ作って dense / hybrid / MMR で使い回す(build_hybrid(store) に store を渡す形にした)。
  • LangChain v1 で import パスが移動langchain.retrievers は消え、EnsembleRetrieverlangchain_classic.retrieversBM25Retrieverlangchain_community.retrievers(sunset 警告つき)。Chromalangchain-chroma
  • 小コーパスでは recall も MRR も 1.00 に張り付く。素手版 ex04 も全 1.00。これはバグではなく「採点が正しく動いている」証拠。設定差を体感したいなら紛らわしい言い換えクエリやディストラクタ文書を GOLDEN/DOCS に足す。

5. Chroma に実際に何が入るか(from_documents の引数 と 中身の覗き方)

5.1 from_documents の引数の正体(ベクトル・本文・メタデータはどこ?)

ex01 のキモのこの 1 行。

return Chroma.from_documents(
    documents=chunks,        # ①
    embedding=embeddings,    # ②
    collection_name=COLLECTION,
    persist_directory=PERSIST_DIR,
)

「ベクトル・本文・メタデータの 3 つが別々の引数にある」と思って探すと見つからない。実際の対応はこう。

中身 どの引数 解説
本文 documents=chunks の中 chunksDocument のリスト。本文は各 Document.page_content
メタデータ documents=chunks の中 同じ Document.metadata{"source": "langfuse"} 等)。本文とメタデータは1 つの Document に同居
ベクトル どの引数にも無い from_documents が内部で各チャンクの本文を embedding してその場で生成する

最大の勘違いポイントは embedding=embeddings はベクトルではないこと。これは OpenAIEmbeddings(...) のインスタンス、つまり「本文 → ベクトルに変換する道具(関数)」。from_documents はこの道具を使って ベクトル = embeddings(本文) を裏で計算するので、ベクトルを引数で渡す必要はない(渡せない)

from_documents(documents=chunks, embedding=embeddings) を呼ぶと…
  各 chunk について:
    本文     = chunk.page_content     ← documents から取り出す
    メタdata = chunk.metadata         ← documents から取り出す
    ベクトル  = embeddings(本文)       ← その場で計算(embedding 引数の道具を使う)
  → この 3 点セットを Chroma に書き込む

判定の一文: 「embedding= に渡すのは “ベクトル” ではなく “ベクトルの作り方” 」。

5.2 Chroma の中身は覗ける(API キー不要)

Chroma は chroma_db/ にローカル永続(SQLite ベース)するので、OpenAI を一切叩かずに中身を直接読めるlangchain_chroma のラッパを通さず、生の chromadb クライアントで開くのが手軽(ラッパで開くと embedding_function 引数を要求されるが、.get() 自体は API を呼ばない)。

import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
col = client.get_collection("rag_vector_basics")
print(col.count())  # → 12

data = col.get(include=["documents", "metadatas", "embeddings"])
# data["ids"] / ["documents"] / ["metadatas"] / ["embeddings"] が並ぶ

実際に覗いた出力(5.1 の裏取り):

collection='rag_vector_basics'  件数=12
[0] id=aa351ad4-…
    本文      : Langfuse は LLM アプリの Observability…
    メタデータ: {'source': 'langfuse'}
    ベクトル  : 次元=1536  先頭5要素=[-0.0354, 0.0508, 0.0002, -0.0164, 0.0274]

ここから読めること: - 1 レコード = 本文 + メタデータ + ベクトル がセットで保存されている(5.1 の図そのまま) - ベクトルは 1536 次元。これは embedding モデル text-embedding-3-small の出力次元(_corpus.EMBED_MODEL で決まる) - id は格納時に Chroma が自動採番する UUID(from_documents に ids を渡さなかったため) - 件数 12 = ex01 のチャンク数と一致 = 冪等化(4 節)が効いている証拠

注意: include="embeddings" を入れないとベクトルは返らない(既定は本文とメタデータのみ。ベクトルは重いので明示要求する設計)。ids は常に返るので include に書かない。


6. 実行ログ(実際に動かして見えたこと)

教材の想定値ではなく、op run --env-file=.env.op -- uv run python <file> で実際に出た現物。

6.1 ex02(as_retriever の k の効き目)

Q:「Langfuse をセルフホストするには何が必要?」

k=1: [langfuse] Langfuse は…Observability…        ← 一般紹介だけ
k=2: +[langfuse] 。セルフホストは docker compose…  ← ★答えはココ(2位)
k=4: +[errorcode] エラーコード E-4031 は…          ← 関連だが的外れ=ノイズ
観察 学び
k=1 では答えを取り逃す(docker compose チャンクは2位) 正解は1位とは限らない → だから k を上げる(recall の話)
k=4 で errorcode が混ざる k↑ で recall↑ だが precision↓(ノイズ増)のトレードオフ
言い換え「LLM の挙動を観測できるツール」でも1位 langfuse キーワード不一致でも意味の近さで当てる=dense 検索の本質

6.2 ex03(LCEL チェインの生成+幻覚ガード)

Q: Langfuse をセルフホストするには何が必要?
A: docker compose で起動することと、必須コンポーネントとして ClickHouse が必要です。
Q: recall@k は何を測る指標?
A: 正解チャンクが上位 k 件に入った割合を測る指標です。
Q: 明日の東京の天気は?
A: 文脈に情報がありません。            ← ★知識ベース外で幻覚せず正直に
観察 学び
Q1 が docker compose + ClickHouse を統合 元文書 langfuse に両方記載(metadata で裏取り済み)=幻覚でなく根拠ベース
Q3 が「文脈に情報がありません」 プロンプトのガード(文脈になければ無いと言え)が作動。RAG の幻覚抑制が効いた
A: の行=直列チェインの終点の str str→dict→PromptValue→AIMessage→strStrOutputParser が本文だけ抽出
temperature=0 最頻 token を選ぶ=ほぼ決定論。写経・評価で出力を安定させる定石

6.3 ex04(recall/MRR が全 1.00 の意味)

設定                      recall@1  recall@3     MRR
chunk20 overlap0            1.00      1.00   1.000
chunk40 overlap0            1.00      1.00   1.000
chunk60 overlap10           1.00      1.00   1.000
chunk200 overlap40          1.00      1.00   1.000

1.00 張り付き=「完璧」ではなく「採点が正しく動く + コーパスが簡単すぎて4設定の差が出ない」(ceiling effect)。MRR=1.000 は「正解が常に1位」。差を出すには GOLDEN に言い換えクエリ、DOCS にディストラクタ文書を足す → recall@1 から先に崩れ、recall@3 が持ちこたえる(k を緩める効果が数値で見える)。

6.4 OpenAI はいつ・どっちが呼ばれるか

embedding と chat は別エンドポイント・別モデル。混同しない。

種類 モデル 呼ばれる場所 ex04 で叩く?
Embeddings text-embedding-3-small(1536次元) from_documents(格納時)/検索時の query embed
Chat/生成 gpt-4o-mini 回答生成(ex03 の llm ❌(ex04 は生成しない)
  • OpenAIEmbeddings(model=...) を作る行はまだ呼ばない(道具を用意するだけ)。Chroma.from_documents(...) を呼んだ瞬間に全チャンクをまとめて embed API に送る。
  • collection_name(ex04 は f"eval_{size}_{overlap}" と動的)=Chroma 内の「名前付きの棚」。設定ごとに別の棚にして4インデックスを隔離し、フェアに比較する。persist_directory を渡さない ex04 はメモリ上だけの使い捨て。

6.5 ex05(ハイブリッド検索:RRF と MMR)

BM25 のトークン化は「2文字スライド」(bigram)

def tokenize_bigram(text):
    return [text[i:i+2] for i in range(len(text)-1)]
# "E-4031" → ['E-','-4','40','03','31']

2つの単語ではなく、隣り合う2文字を1つずつずらして切る(重なりあり)。日本語はスペース区切りが無く本来は MeCab 等が要るが、それを2文字分割で安く代用している(preprocess_func に渡す)。

BM25 のスコアは「一致数」でなく IDF 重み付き

「E-4031 の意味は?」を実際に採点した現物(rank_bm25.BM25Okapi で計測):

クエリトークンの DF(5文書中の出現数):
  E- -4 40 03 31 1   … DF=1(errorcode のみ)★レア → 高得点源
  の意 意味 味は は?   … DF=0(どの文書にも無い)→ 死にトークン(0貢献)
   の                … DF=2 → ありふれ → ほぼ0点

スコアと順位:
  1位 [errorcode] 7.547  ← 型番bigram E-,-4,40,03,31,1 が全部一致(全部レア)
  2位 [dspy]      0.349  ← " の" だけ偶然一致
  3位 [chunking]  0.342  ← " の" だけ
  4位 [langfuse]  0.000  ← 一致ゼロ
  5位 [metrics]   0.000

BM25 = TF-IDF 系。3つの仕掛けで「ただの一致数」と違う: - IDF(レア度): 珍しいトークンほど高得点 → 型番 E-4031 が突出 → 型番・固有名詞に強い理由 - TF飽和(k1≈1.5): 出現回数が増えても頭打ち(連呼ゲー防止) - 長さ正規化(b≈0.75): 長い文書が「ただ長いから一致が多い」で勝つのを防ぐ - 注意: 質問の主旨「意味」「は?」は DF=0=コーパスに字面が無くスコアに効かない。BM25 は意味を理解せず字面で動く(だから言い換えに弱い)。

実行結果:このコーパスでは dense/bm25/hybrid が割れない

Q: LLM の挙動を観測できるツールは?  → 3つとも [langfuse]
Q: E-4031 の意味は?              → 3つとも [errorcode]

教材は「言い換え→dense/型番→BM25 で勝者が割れる」を期待するが、全員一致した。理由は ex04 の全1.00 と同根=小コーパスで正解が明白すぎるから: - 「LLM…」はクエリに字面 LLM があり langfuse 文書にも LLM → bigram 一致で BM25 も langfuse を引けた(純粋な言い換えになっていない) - 「E-4031」はコーパスに errorcode が1つだけ → dense も外しようがない

→ 分岐を体感したいなら、字面を一致させない言い換え(例:「ClickHouse に繋がらない時のコードは?」=E-4031 を含めない)を投げる。BM25 が崩れ dense が踏ん張る。

MMR は効いた(多様性が出た)

Q: "RAG の評価と Langfuse について"
  [langfuse] / [chunking] / [metrics]   ← 3件とも別 source

クエリが「評価」「Langfuse」の2話題を含むので、普通の類似検索なら近い話題のチャンクが固まりがち。MMR は λ·関連度 − (1−λ)·かぶりλ=0.5, fetch_k=8k=3)で重複を弾き、話題を散らして両側面をカバーした。

RRF / MMR の式(ex05 の2アルゴリズム)

RRF(EnsembleRetriever の融合):
  score(d) = Σ_i  weight_i × 1/(c + rank_i(d))    (c=60, ex05 は weights=[0.5,0.5])
  → cosine と BM25 はスケールが違い足せない。順位だけで「多数決」。
    +60 が1位と2位の差を均し「複数リストに載る」方を「片方1位」より優遇する。

MMR(多様性つき):
  pick argmax_d [ λ·sim(d,q) − (1−λ)·max_{d'∈選択済み} sim(d,d') ]
  → 関連度から「既に選んだ物とのかぶり」を引く。λ=1で純粋類似、λ=0で純粋多様。

RRF=横(複数の検索器)をまとめる、MMR=縦(1つの結果リスト)の重複を間引く。役割が直交。

警告メモ(無害)

BM25Retriever の import で langchain-community is being sunset 警告。READMEの v1 移行メモ既出で動作影響なし

6.6 ex06(langfuse で RAG チェイン全体をトレース)

langfuse 計装: ON(jp.cloud に送信)
Q: Langfuse のセルフホストに必須のコンポーネントは?  A: 必須コンポーネントは ClickHouse です。
Q: MIPROv2 は何を探索する?                        A: few-shot 例と指示文を自動探索します。

両方とも検索チャンクに基づく根拠ベース回答。.env.op に LANGFUSE キー(jp.cloud)があるので計装 ON で送信された。

仕組み:CallbackHandler を invoke の config に挿すだけ

config = langfuse_config()                 # → {"callbacks": [CallbackHandler()]}(キーがある時)
answer = chain.invoke(query, config=config)  # ← この実行が丸ごと 1 トレースに乗る
要素 役割
CallbackHandler LangChain のイベントフック。実行中の retriever 検索・LLM 呼び出しを横取りして langfuse に送る
自動集約 invoke 1回=1トレース。中に retriever 段・prompt 段・LLM 段が入れ子 span でぶら下がる
@observe 不要 素手版(langfuse_basics)は各段を手計装したが、ここは Runnable がイベントを発火するので手計装ゼロ

ダッシュボードの見え方:

Trace: chain.invoke("...")
 ├─ span: Chroma retriever   ← どのチャンクを取ったか(query / docs)
 ├─ span: ChatPromptTemplate ← 文脈注入後の完成プロンプト
 └─ generation: ChatOpenAI   ← gpt-4o-mini の入出力・token・レイテンシ・コスト
→「どのチャンクを取って何を答えたか」が1画面で追え、幻覚の原因が検索ミスか生成ミスか切り分けられる。

大事な2点

  • flush() を最後に必ず呼ぶ: langfuse はトレースをバッファに溜めて送るので、短命スクリプトは終了前に flush しないと未送信のまま消える(_trace.pyflush())。
  • キー無しでも落ちない: _trace.pyLANGFUSE_PUBLIC_KEY が無ければ {} を返し計装 OFF で素通り → CI でも安全。今回はキーありで ON。

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

困りごと 見る / 使うもの
文書をベクトル化して溜めたい ex01 Chroma.from_documents
query に近いチャンクを取りたい ex02 as_retriever(search_kwargs={"k": k})
検索→生成を 1 本にまとめたい ex03 LCEL チェイン
検索品質を数値で測りたい ex04 recall@k / MRR
型番も言い換えも両方当てたい ex05 EnsembleRetriever(BM25+ベクトル)
似たチャンクの重複を避けたい ex05 search_type="mmr"
検索段と生成段を 1 画面で追いたい ex06 CallbackHandler(config の callbacks に挿す。6.6 節)
「どのチャンクから来たか」を知りたい doc.metadata["source"]
BM25 がなぜその順位か数値で見たい 6.5 節 / rank_bm25.BM25Okapi(corpus).get_scores(qtok)+DF でレア度確認
RRF / MMR の式を確認したい 6.5 節末(RRF=順位の多数決 / MMR=関連度−かぶり)
Chroma に実際に何が入ったか覗きたい 5 節 / chromadb.PersistentClient(...).get_collection(...).get(include=["documents","metadatas","embeddings"])
from_documents の引数とベクトルの出どころ 5.1 節(ベクトルは引数に無く内部生成)
手書きの中身を確かめたい 素手版 ../rag_basics/

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