書籍をOCRして「本の知識と協力して文章を書く」ためのRAGを作ろうとしている。実装に入る前に、そもそもRAGの仕組みがふわっとしていたので、Cloudflare Vectorize を軸に「ベクトル化したデータとは何か」「どう取り出すのか」「chunkはどう区切るのか」を整理した。同じところで詰まる人向けのメモ。
そもそも「ベクトル化したデータ」とは何か
本を丸ごと保存するわけではない。こう分解する。
- OCRで本文テキストを起こす
- テキストを チャンク(chunk) という短い断片に分割し、それぞれに
chunk_idを振る - 各チャンクを埋め込みモデル(
@cf/baai/bge-m3)に通して ベクトル に変換する
ベクトルというのは「そのチャンクの意味を表す1024個の数字の並び」で、文章そのものではない。
"蝶は鱗粉で水を弾く..." ──bge-m3──▶ [0.021, -0.11, 0.34, ... ] ← 1024個の数字
意味が近い文章どうしはベクトルも近くなる。これが「意味検索」の正体。
保存は3か所に分かれる(ここが最初のつまずきポイント)
| 保存先 | 中身 | 役割 |
|---|---|---|
| Vectorize | チャンクのベクトル + chunk_id |
「意味が近いチャンクを探す」だけ |
| D1 | chunk_id に対応する本文テキストそのもの |
実際の原文を引く |
| R2 | ページ画像 | 原本の閲覧 |
重要なのは、Vectorize には文章は入っていないこと。入っているのは数字のベクトルと「どのチャンクか」を示す chunk_id だけ。だから Vectorize 単体では文章を取り出せない。
どうやって取り出すのか(検索の流れ)
「LLMがDBに問い合わせて文章を吸い出す」というイメージを持っていたが、正確には アプリ(Worker)が2段階の検索をして、その結果をLLMに渡す。
① 質問文「蝶の鱗粉について」
│ 埋め込みモデルでベクトル化 ← ここが見落としやすい一段!
▼
② Vectorize に「このベクトルに近いものトップK個」を問い合わせ
│ 返るのは chunk_id のリスト(+類似スコア)
▼
③ その chunk_id で D1 を引く(普通のSQL)
│ ここで初めて本文テキストが取れる
▼
④ 「原文+出典」をまとめて返す
外から見た /api/search?query=... は普通のGET APIでいい。特殊なのは中身で、クエリ文自身も一度ベクトルに変換してから Vectorize に渡している。
// GET /api/search?query=蝶の鱗粉について
const embed = await c.env.AI.run('@cf/baai/bge-m3', { text: query })
const vector = embed.data[0] // 1024次元の配列
// Vectorize への問い合わせは SQL でも文字列クエリでもなく、ベクトルを渡す専用メソッド
const results = await c.env.VECTORIZE.query(vector, { topK: 10, returnMetadata: true })
const chunkIds = results.matches.map(m => m.metadata.chunk_id)
// 本文は Vectorize に無いので D1 から引く(ここは普通のSQL)
const rows = await c.env.DB.select().from(bookChunks).where(inArray(bookChunks.chunkId, chunkIds))
ポイント3つ。
- 保存時と検索時で 同じ埋め込みモデルを使わないと、ベクトル空間がズレて検索が成立しない。
- Vectorize が返すのは
chunk_idと類似スコアだけ。本文は D1 から引く2段構え。 - 近傍検索は「完全一致」ではなく「近い順トップK件」を返す。
topKの値が精度に効く。
そしてここが構成上のキモで、生成(回答文づくり)はLLM側の仕事、Cloudflare側は「原文+出典を返す検索エンジン」に徹する。LLMはDBを直接吸い出すのではなく、検索エンジンの結果を受け取って書く側にまわる。だから MCP は必須ではなく、「Claude Code から検索APIを呼びやすくする薄いラッパ」でしかない。
chunkの分け方(RAGの精度を決める最重要ポイント)
「文字数でぶつ切りにすると意味が壊れるのでは?」という直感は正しい。埋め込みベクトルは「チャンク全体の意味を1つの点に潰したもの」なので、
- 1チャンクに複数の話題が混ざると、ベクトルが中途半端な点になりどちらでも引っかかりにくい(意味の希釈)
- 文の途中で切ると、両方のチャンクが意味の不完全なベクトルになる
理想は 「1チャンク=1つの意味的なまとまり」。
「大きくすればいい」ではない理由
大きいチャンクには別々の2つのコストがある。
- 検索精度のコスト:でかいほど1つのベクトルに詰め込む意味が増え、ベクトルがぼやける → 意味検索そのものが鈍る
- トークンのコスト:ヒットしたチャンクは最終的にLLMのコンテキストに丸ごと入る。でかい×topK件でトークンが膨らみ、コスト増&回答も薄まる
だから答えは「大きくでも小さくでもなく、意味が1つに収まる最小限」。出発点の目安は 300〜500字+10〜20%のオーバーラップ、そして文字数ではなく 段落・文の境界を尊重して切る。
区切りは誰がやるのか — 自分で目印を入れる必要はない
一番知りたかったのがここ。結論は、手で目印を入れる必要も、毎回LLMに区切らせる必要もない。手段には賢さとコストの違う段階がある。
- ルールベースの分割器(実務の主役):
RecursiveCharacterTextSplitterのように「見出し > 段落 > 文 > 文字」の優先順位で機械的に切るライブラリ。無料・一瞬・再現性100%。オーバーラップもパラメータを渡すだけで自動適用される。 - 構造を活かす:分割器は元テキストの段落・見出しを頼りにする。つまり元テキストに構造が残っていれば、ルールベースだけでかなり良い分割になる。
- LLMに意味で区切らせる(semantic chunking):質は高いが、書籍1冊を丸ごと通すとトークン代がかさむ。全体には使わず、崩れた箇所だけ部分的に。
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
chunkOverlap: 50,
separators: ["\n\n", "\n", "。", "!", "?", " "], // 切れ目の優先順位
})
const chunks = await splitter.splitText(bookText)
そして書籍RAGでの一番コスパの良い構成が、「意味の目印」だけはOCRのVLMに文字起こしのついでで付けさせるというやり方。
ページ画像 ──OCR(VLM)──▶ 見出し(#)・段落(空行)つきの整ったMarkdown ──分割器──▶ chunk
↑ ここでAIが構造(=目印)を付与 ↑ ここは無料の機械分割
役割分担でいうと、目印付けはOCRのVLM(自分ではない)、実際に区切って重ねるのは機械の分割器(LLMではない)、意味での分割は原則不要で崩れた所だけLLM。自分は何もしないのが正解パターンだった。
さらに「小さく検索・大きく渡す(親子チャンク / small-to-big)」というテクもある。検索はベクトルがシャープな小チャンクで当て、LLMに渡すときは前後を含む大きめの塊を渡す。「小さいと文脈不足/大きいとぼやける」ジレンマの実務的な解。
Cloudflare Vectorize の料金(2026年4月時点)
RAGバックエンドを全部Cloudflareに寄せる前提で、Vectorize のコスト感も調べた。課金は「ベクトルの本数」ではなく 「本数 × 次元数」の総量 で測る。
| 課金軸 | 単価(無料枠超過後) |
|---|---|
| Stored(保存) | $0.05 / 1億次元 / 月 |
| Queried(検索) | $0.01 / 100万次元 |
無料枠は Free プランが Stored 500万次元・Queried 3000万次元/月、Workers Paid(月$5)が Stored 1000万次元・Queried 5000万次元/月まで込み。CPU・メモリ・インデックス数・egress では課金されない。
主要な制限は 1ベクトル最大1536次元・1インデックス最大1000万ベクトル・topK最大50。bge-m3 の1024次元や書籍100冊規模はどれも余裕。
100冊シナリオの試算
bge-m3(1024次元)、100冊でチャンク5万個、執筆用に月1万クエリと仮定すると、
Stored次元 = 50,000 × 1024 = 51.2M
Queried次元 = (10,000 + 50,000) × 1024 = 61.4M
Workers Paid の込み枠を引くと:
Stored 課金分 = (51.2M − 10M) → $0.021
Queried 課金分 = (61.4M − 50M) → $0.114
Vectorize 追加料金 ≈ $0.13/月
つまり Vectorize 自体は月十数円レベル。実際の主コストは Workers Paid の月$5のほう。ただし Free プランだと Stored 500万次元 ≒ 本10冊ぶんが限界なので、100冊やるなら Workers Paid が事実上の前提になる。
まとめ
- ベクトル化データ=「意味を表す数字の列」。Vectorize に文章は入っておらず、近いものを探す用途専用。
- 取り出しは「Vectorizeで近傍検索 → 返った chunk_id で D1 から本文を引く」の2段構え。SQLではなくベクトルを渡す専用API+普通のSQLの組み合わせ。
- LLMはDBを直接吸い出さない。Cloudflareが「原文+出典」を返し、LLMがそれを受け取って書く。MCPは必須ではない。
- chunkは「1つの意味的まとまりを過不足なく」。区切りは無料の機械分割器に任せ、意味の目印はOCRのVLMにつけさせる。自分は手を動かさない。
- Vectorize の料金は「本数×次元数」の総量課金。100冊でも Vectorize 単体は月十数円、実質は Workers Paid の$5が主コスト。
(料金は2026年4月時点の公式ページの値。変わりうるので本番判断の直前には公式で再確認する。)