書籍をOCRして「本の知識と協力して文章を書く」ためのRAGを作ろうとしている。実装に入る前に、そもそもRAGの仕組みがふわっとしていたので、Cloudflare Vectorize を軸に「ベクトル化したデータとは何か」「どう取り出すのか」「chunkはどう区切るのか」を整理した。同じところで詰まる人向けのメモ。

そもそも「ベクトル化したデータ」とは何か

本を丸ごと保存するわけではない。こう分解する。

  1. OCRで本文テキストを起こす
  2. テキストを チャンク(chunk) という短い断片に分割し、それぞれに chunk_id を振る
  3. 各チャンクを埋め込みモデル(@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月時点の公式ページの値。変わりうるので本番判断の直前には公式で再確認する。)