FastAPI・llama.cpp・Chroma・Open WebUIでPDF用ローカルRAGを組む記事を読んだ
目次
DEV Communityに「Building a Persistent Knowledge Base RAG System with FastAPI, llama.cpp, Chroma, and Open WebUI」という記事が出ていた。
PDFフォルダをChromaに取り込み、FastAPIでOpenAI互換の /v1/chat/completions を生やし、llama.cppのローカルGGUFモデルに投げ、Open WebUIからチャットする構成だ。
新しい製品発表ではない。
ただ、ローカルRAGを「アプリとして使えるところ」まで持っていく最小構成としては分かりやすい。
前にMac mini M4 Pro + Difyで社内ヘルプデスクRAGを組む話を書いたが、あれはDifyを中心に据えるローコード寄りの構成だった。
今回の構成はその逆で、Difyのようなワークフロー基盤を使わず、RAGの薄いAPI層だけを自分で持つ。
OpenAI互換APIを境界にする
記事の構成でいちばん効いているのは、FastAPI側がOpenAI互換の /v1/models と /v1/chat/completions を提供している点だ。
flowchart LR
PDF[PDFフォルダ] --> Ingest[PDF読み込み<br/>チャンク分割]
Ingest --> Chroma[(Chroma<br/>永続ベクトルDB)]
User[Open WebUI] -->|OpenAI互換API| FastAPI[FastAPI RAG API]
FastAPI -->|類似検索| Chroma
FastAPI -->|OpenAI互換API| Llama[llama.cpp server]
Llama --> FastAPI
FastAPI --> User
Open WebUIはOpenAI互換APIを話せるサーバーに接続できる。
公式ドキュメントでも、Docker内のOpen WebUIからホスト側のモデルサーバーへ行く場合は localhost ではなく host.docker.internal を使う説明になっている。
llama.cpp側も llama-server がOpenAI互換の /v1/chat/completions を持つ。
つまりFastAPIは、UIから見るとOpenAI互換サーバーであり、LLMから見るとOpenAI互換クライアントになる。
この境界にしておくと、Open WebUIをLM StudioやvLLMやOllama互換サーバーへ差し替える話と同じ感覚で扱える。
Difyでもopen-notebookでもなく薄い自作API
この構成が刺さるのは、RAGの中身を直接いじりたい場合だ。
open-notebookをM1 Maxで完全ローカル運用した記事では、SurrealDB、FastAPI、Next.js、Worker、Ollamaをまとめて動かした。
PDFやURL取り込み、ノートブック、引用、insight生成までアプリとして揃っている一方で、内部処理はそれなりに大きい。
今回のDEV記事はそこまでのアプリではない。
PDFを読む、チャンクにする、Chromaへ入れる、検索してLLMに渡す。
この細い流れだけをFastAPIで持つ。
| 構成 | 向いている場面 | 苦手な場面 |
|---|---|---|
| Dify | 社内ヘルプデスクやワークフロー込みの運用 | 低レベルな検索処理を細かく触りにくい |
| open-notebook | NotebookLM風の完成アプリが欲しい | 内部コンポーネントが多く、検証対象が広い |
| FastAPI + Chroma + llama.cpp | RAG APIの挙動を自分で制御したい | 認証、権限、UI、ジョブ管理を自分で作る必要がある |
なので「とりあえず業務に出す」ならDifyやopen-notebookのほうが早い。
「検索条件、プロンプト、チャンク更新、モデル呼び出しを全部コードで見たい」なら、今回のような薄いAPIが合う。
永続化はChromaのディレクトリに寄せる
記事では ./vector_store をChromaの persist_directory にしている。
Chromaは永続クライアントやサーバーとして起動すると、指定ディレクトリの下に chroma.sqlite3 などのストレージを作る。
PDFを再投入しなくても、再起動後にコレクションを読み直せる、という意味での「永続型」だ。
永続化と更新管理は別物だ。
ベクトルDBがディスクに残るだけでは、次のような運用問題は解けない。
| 見るところ | 理由 |
|---|---|
| チャンクID | 同じPDFを再投入したときに重複を作るか、置き換えるかが決まる |
| メタデータ | ファイル名だけでなくページ番号、更新時刻、ハッシュを持たないと差分更新しにくい |
| 削除処理 | PDFを消したとき、古いチャンクを残すか消すかを決める必要がある |
| Embeddingモデル名 | モデルを変えたとき、既存ベクトルを再生成すべきか判定できる |
DEV記事のコードは /reload でコレクションを削除して作り直す方針になっている。
小さなPDFフォルダならこれで十分だが、数千ページを超えるなら差分更新を入れたくなる。
このあたりは、MintlifyがRAGを捨ててChromaFsに寄せた話で触れた「チャンクをファイルとしてどう復元するか」と同じ問題に繋がる。
RAGは検索だけなら簡単だが、ドキュメントのライフサイクルを持ち始めると急にDB設計の話になる。
そのまま貼る前に直すところ
原典のコードは学習用のひな形として読むのがよさそうだ。
コピーして動かす前に、少なくともここは確認する。
| 箇所 | 気になる点 |
|---|---|
| worker関数名 | 定義は _ingest_pdfs_worker なのに、スレッド起動側は ingest_pdfs_worker を参照しているように見える |
| Chroma連携 | langchain_community.vectorstores.Chroma と persist() 前提の書き方は、環境によっては新しめの langchain_chroma へ寄せたほうがよい |
| ストリーミング | stream を受け取るが、実装は非ストリーミング応答のみ |
| 認証 | Open WebUIから叩く前提でも、LAN内公開ならAPIキーやリバースプロキシを考える |
| 引用 | ファイル名は返すが、ページ番号やチャンク位置がないので検証が弱い |
特にworker関数名は単純な写経ミスに見える。
この手の「全部入り記事」は、設計の骨格を拾うには便利だが、コードは自分の環境で最低限テストしてから使うほうがいい。
検索品質はここから先が本番
今回の構成は、検索としてはかなり素朴だ。
sentence-transformers/all-MiniLM-L6-v2 でチャンクを埋め込み、Chromaのretrieverで上位4件を取り、プロンプトに詰める。
英語PDF中心なら入り口として動く。
日本語PDFや社内文書を相手にするなら、ここから先が本番になる。
| 変更点 | 効果 |
|---|---|
Embeddingを bge-m3 など多言語モデルにする | 日本語の意味検索が安定しやすい |
| BM25とのハイブリッド検索を足す | 型番、エラーコード、固有名詞の取りこぼしを減らす |
| ページ番号をメタデータに入れる | 回答の根拠をPDF上で確認しやすい |
| リランキングを足す | 上位k件のノイズを減らす |
| チャンクサイズを文書種別で変える | 表、手順書、規約文で検索粒度を調整できる |
Chroma Context-1の記事で書いたように、最近のRAGは単発のベクトル検索から、検索クエリを反復したり不要チャンクを削ったりする方向に進んでいる。
今回のFastAPI構成はそこまで行かないが、逆に言えば、その前段のミニマムな実験台として扱いやすい。
どこまでローカルにするか
記事の動機は「PDFコレクションを外へ出さずにローカルLLMで読む」ことにある。
これはかなり現実的になった。
Open WebUI、llama.cpp、Chroma、FastAPIの全部が手元で動くし、GGUFモデルを選べばGPUが弱くてもCPUやApple Siliconで試せる。
ただし、ローカル化すると責任の場所も手元に寄る。
モデル品質、PDFの更新・削除・再インデックス、API公開範囲やOpen WebUIの認証、起動順やログやバックアップ。
「データを外に出さない」は強い理由だが、クラウドAPIやSaaSが背負っていた運用を丸ごと引き受けることになる。
小さく始めるなら、まずはこの構成でPDF 10本くらいを食わせる。
その上で、検索に失敗した質問、根拠ページを間違えた回答、固有名詞を言い換えた回答だけを集める。
RAGの改善は、そこからEmbedding、チャンク、メタデータ、プロンプトのどこを直すかに分解したほうが早い。
PDFだけで足りるか
ここまでの構成はPDFフォルダが入り口だ。
でも実際にローカルで溜まるナレッジはPDFだけではない。
ターミナルの出力をスクリーンショットで残す、Slackの会話をキャプチャする、ホワイトボードを写真に撮る。
テキスト化してから検索するよりも、画像のまま検索できたほうが手間が減る場面はある。
OCR-Memoryの記事では、エージェントの長い作業履歴をテキストではなく画像として保存し、VLMで検索する手法を扱った。
テキストを画像に押し込むと視覚トークンの圧縮が効き、限られたコンテキストでの検索精度がテキストRAGを上回る。
あの方法はエージェントメモリ用だが、発想を借りると「画像をファーストクラスの入力にする」RAGが見えてくる。
Sentence Transformers v5.4で、テキストと画像を同じ encode() に渡して同一ベクトル空間に埋め込めるようになった。
PDFから抽出したテキストチャンクと、スクリーンショットや図表の画像を、同じChromaコレクションに入れられる。
テキストクエリで両方がヒットする。
完全ローカルのマルチモーダルRAG構成
PDF+画像の混在ナレッジベースをローカルだけで組む場合、前半の構成にマルチモーダルEmbeddingとVLMを足す形になる。
flowchart TD
PDF[PDFフォルダ] --> TextChunk[テキストチャンク化]
IMG[画像フォルダ<br/>スクショ・図表・写真] --> ImgEmbed[マルチモーダル<br/>Embedding]
TextChunk --> TextEmbed[マルチモーダル<br/>Embedding]
TextEmbed --> Chroma[(Chroma<br/>統合インデックス)]
ImgEmbed --> Chroma
Query[テキストクエリ] --> QEmbed[クエリEmbedding]
QEmbed --> Chroma
Chroma --> TextHit[テキストチャンク]
Chroma --> ImgHit[画像]
ImgHit --> VLM[ローカルVLM<br/>Qwen2.5-VL 7B]
VLM --> Desc[画像の説明テキスト]
TextHit --> LLM[llama.cpp<br/>GGUFモデル]
Desc --> LLM
LLM --> Answer[回答]
前半の構成から変わるのは2箇所だ。
Embeddingが all-MiniLM-L6-v2 からマルチモーダル対応モデルに変わる。
Sentence Transformers v5.4経由でBGE-VL-base(0.1B)を使えば、VRAM 1GB未満で画像とテキストを同じベクトルに落とせる。
精度を上げるならQwen3-VL-Embedding-2Bだが、こちらは8GB程度のVRAMが要る。
もうひとつ、検索結果に画像が混ざるので、画像をLLMに渡す前にVLMで説明テキストを生成する経路が要る。
ローカルVLMの実験でQwen2.5-VL 7BがOllamaで安定して動くのは確認している。
画像ヒット→VLMで説明→LLMへ注入、という流れだ。
llama.cppのGGUFモデルは画像を直接受けられないから、VLMをテキスト変換器として間に挟む。
ローカルで揃う部品
TTSや画像生成を除くと、テキスト理解・画像理解・Embeddingが要る。
日本語LLMの整理記事と各過去記事から、M1/M4 Macで動かせる範囲を並べるとこうなる。
| 役割 | モデル例 | サイズ | 動作環境 |
|---|---|---|---|
| テキストLLM | LLM-jp-4-32B-A3B | 32B MoE(3.8B active) | Apple Silicon 16GB〜 |
| テキストLLM | Qwen3.5-35B-A3B | 35B MoE | Apple Silicon 32GB〜 |
| VLM | Qwen2.5-VL 7B | 7B | Apple Silicon 16GB〜、Ollama経由 |
| マルチモーダルEmbedding | BGE-VL-base | 0.1B | CPU可 |
| マルチモーダルEmbedding | Qwen3-VL-Embedding-2B | 2B | GPU 8GB〜 |
| ベクトルDB | Chroma | — | CPU、ディスク永続 |
| モデルサーバー | llama.cpp / Ollama | — | CPU or GPU |
Foundry Localのようなバンドル型ランタイムも出てきているが、マルチモーダルEmbeddingを自分で制御するならSentence Transformers + Chroma + FastAPIのほうが自由度がある。
BGE-VL-baseならCPUで動くから、Embeddingだけで別GPUを食わない。
VLMとLLMが同じGPUを取り合う問題はあるが、Ollamaのモデル切り替えで対応できる。
同時に動かしたいならメモリの多いApple Silicon機のほうが都合がいい。
M1 Max 64GBなら、Qwen2.5-VL 7BとLLM-jp-4-32B-A3Bを両方Ollamaに載せて、Embeddingは別プロセスのSentence Transformersで回す構成が現実的だ。
画像の取り込みをどうするか
PDFは PyPDFLoader でテキスト化→チャンク分割→Embeddingという定番の流れがある。
画像の場合はもう少し考えることがある。
画像をそのままマルチモーダルEmbeddingに通せば、ベクトル化自体はできる。
ただし、Chromaから返ってきた画像を最終的にLLMのプロンプトへ入れるには、テキストに変換する必要がある。
llama.cppのGGUFモデルは画像入力に対応していないからだ。
取り込み時にVLMでキャプションを生成してメタデータに持たせる方法と、検索ヒット時にオンデマンドでVLMに通す方法がある。
取り込み時にキャプションを生成するなら、インジェスト処理は重くなるが検索後の応答は速い。
画像が大量にあるなら、バッチで夜間にキャプション生成を回しておくのが現実的だ。
オンデマンドでVLMに通すなら、インジェストは軽いが検索のたびにVLM推論が入る。
画像ヒットが少ない検索パターンなら、こちらのほうが無駄がない。
メタデータにキャプションを持たせておけば、テキスト検索とマルチモーダル検索の両方で画像がヒットするようになる。
ベクトル検索で類似画像を引き、BM25でキャプションテキストを引く。
前半で触れたハイブリッド検索が、画像に対しても効く。
PDFしかないならこの記事の前半の構成で十分だし、スクリーンショットやホワイトボード写真が混ざるなら画像の経路を足す価値がある。
手持ちのナレッジが何の形式で溜まっているかを眺めてから判断すればいい。