RAG:モデルにあなたのデータを与える
LLMの知識は学習カットオフで凍結されたが、あなたの文書は毎週変わる。検索拡張生成(RAG)はそれを解凍する。クエリ時にデータの関連する切片を取得し、プロンプトに貼り、モデルに根拠から引用付きで答えさせる。推論はモデルが、記憶はインデックスが担当。
パイプライン、1つの実例
12,000トークンの整備マニュアルを端から端まで通す。
- **チャンク化。**まずヘッダーで分割(セクションが無傷で生き残る)し、次に約400トークンを超えるセクションを再分割し、約60トークン(10〜15%)のオーバーラップを保持。妥当なチャンクサイズは200〜800トークン。結果:約35チャンク。
- **埋め込み。**各チャンクが1ベクトルになる——bge-m3はチャンクごとに1,024個のfloatを出す。CPUでマニュアル全体に数秒。
- **インデックス。**ベクトル+テキスト+メタデータ(ソースファイル、ヘッダーパス)をベクトルストアに格納。
- **検索。**質問を埋め込み、コサイン類似度で上位k = 5チャンクを取得(4〜10が正気の範囲)。
- **生成。**プロンプトを組み立てる。指示、IDタグ付きの5チャンク、質問。
意味境界でのチャンク化は固定ウィンドウに勝る。ヘッダー行から切り離された表や、ステップ4の途中で切られた番号付き手順は、エンベッダがどれほど優秀でも取得不能なゴミになる。
即座に元を取れる追加が2つ。ヘッダーパス(manual › §4 › Bolts)をメタデータとして保存し、正確にフィルタ・引用できるようにすること。そして、クエリをユーザーが打ったままの形で埋め込むこと——前処理の「きれいにする」は大抵、検索を悪くする。
埋め込み:多言語という要件
「似ている」の意味を決めるのはエンベッダ。韓国語と韓国語/英語混在コーパスでは選択肢は狭い——英語専用エンベッダは韓国語クエリで黙って失敗し、もっともらしいが間違ったチャンクを返す。
| モデル | 次元 | 備考 |
|---|---|---|
| bge-m3 | 1024 | 多言語、韓国語が強い——安全なローカルデフォルト |
| nomic-embed-text-v1.5 | 768 | 英語は堅実、長いコンテキスト |
| all-MiniLM-L6-v2 | 384 | 高速、英語中心、韓国語に弱い |
埋め込みモデルは小さい(0.1〜0.6Bパラメータ)。CPUコア1個が数千チャンクを咀嚼する——CPU推論。コーパスは一度埋め込み、クエリは質問時に埋め込む。エンベッダはインデックスごとに固定すること。インデックス途中でモデルを交換すると、異なる空間のベクトルが混ざり、類似度スコアがノイズになる。
ベクトルストア
| ストア | とき |
|---|---|
| sqlite-vec | 単一ユーザー、最大約100万ベクトル、運用ゼロ——ファイル1つ |
| ChromaDB | メタデータフィルタ付きのローカルファースト試作 |
| Qdrant | 本番。フィルタリング、ハイブリッド検索、スケール |
3つすべてローカルで動く。sqlite-vecから始め、実際に壁に当たったときに移動すること。その前ではなく。
プロンプトの組み立て
以下の抜粋のみから答えること。すべての主張を[n]として引用すること。
抜粋に答えが含まれない場合は「文書にない」と返信すること。
[1] (manual §4.2) M8ボルトは給油後12 Nmで締結...
[2] (manual §7.1) ストレッチボルトは再使用しないこと...
質問: M8ボルトに適用される締付トルクは?
引用により、ユーザーは信じる代わりに検証できる。「文書にない」出口が重要。それがないと、モデルは形式で失敗する代わりに答えをでっち上げる。
ハイブリッド検索とリランキング
純粋なベクトル検索は、キーワード検索が釘付けにする完全一致文字列——エラーコード、関数名、部品番号——を取りこぼす。ハイブリッド = BM25 + ベクトルを相互ランク融合で統合。ほぼ無料で、単独より確実に優れる。
品質のためにはリランカーを追加。20〜50候補を取得し、クロスエンコーダ(bge-rerankerファミリー——bge-m3と同じ多言語の物語)で上位5件へリランク。リランカーはチャンクとクエリを一緒に読む。単一ベクトルのエンベッダにはできないこと。大半のニーズをカバーする安いレシピ:ハイブリッドで25取得、5へリランク。
診断:検索ミスか生成ミスか
何かをチューニングする前に、1つの質問に答えること。正しいチャンクは取得されたか。
- 失敗するクエリの上位kチャンクをスコア付きで表示。
- 答えを含むチャンクがリストにないなら検索の問題——チャンク化、埋め込みを修正するかkを上げる。
- リストにあるのに答えが間違っているなら生成の問題——より大きいモデル、より良いプロンプト、妨害を押し出すリランク。
この1つのテストが、パイプラインの間違った半分のチューニングに費やす時間を節約する。
失敗モード
| 症状 | 原因 | 修正 |
|---|---|---|
| 正しい文書が検索されたのに誤答 | 上位k内の妨害 | リランク。kを縮小 |
| 答えは文書内にあるのに出てこない | 上位k飢餓または悪い境界 | kを上げる。ヘッダーでチャンク化 |
| 自信満々だが古い答え | インデックスが文書より古い | 変更時に再インデックス |
| 韓国語クエリが英語文書を取りこぼす | 単一言語埋め込み | bge-m3 |
| 引用が誤ったチャンクを指す | チャンクがソースメタデータを失った | チャンクごとにソース+ヘッダーを保存 |
雰囲気ではなく測る
既知の答えを持つ20〜50の実質問のゴールデンセットを維持する。2つの数値を別々に採点。検索ヒット率(正しいチャンクが上位kにいるか)と回答正確性。チャンクサイズ、エンベッダ、kの変更ごとに追跡。ゴールデンセットのないRAG作業は占星術。
代わりにファインチューニングするとき
ファインチューニングは挙動——形式、スタイル、用語——を変える。事実を追加する方法としては貧弱。モデルはチューニングされたものを引用できず、新しい文書は新しい学習実行を意味する。経験則:事実 → RAG、挙動 → LoRA、両方はプロンプトを使い果たしたときだけ:ファインチューニング。
ローカルスタック、最小から
Open WebUIはドキュメントアップロードとハイブリッド検索を内蔵する——そこから始めればパイプラインコードを書かずに済むかもしれない。RAGの正直な核は約50行。フレームワークが何をしているか知るために一度書くこと。
chunks = chunk_by_header(read("manual.md"), max_tokens=400, overlap=60)
index = [(embed(c), c) for c in chunks] # 一度だけ実行
def answer(question):
hits = topk_by_cosine(embed(question), index, k=5)
ctx = "\n\n".join("[{}] {}".format(i, c) for i, (v, c) in enumerate(hits))
prompt = TEMPLATE.replace("{ctx}", ctx).replace("{q}", question)
return chat(prompt) # 小さいローカルモデルで十分
LlamaIndexやLangChainに手を伸ばすのは、権限、定期リフレッシュ、評価ハーネスが必要なとき——上のループのためではない。韓国語文書については特に、多言語埋め込み(bge-m3)は選択制ではなく、ヘッダーベースのチャンク化はmarkdownの###見出しを尊重するのと同じように韓国語文書構造(장/절見出し)を尊重すべきだ。
関連: ファインチューニング · プロンプト · 構造化出力 · CPU推論