コンテキストウィンドウが長くなる中、ベクトルデータベースはまだ必要か?
1. 概要
大言語モデル(LLM)の各世代はコンテキストウィンドウがより長くなっています。そのため、多くのチームが疑問に思い始めました:もし全ドキュメントをプロンプトに含められるなら、vector databaseを使ったRAGを構築する必要があるのか、と。
この記事は「ある」「ない」と絶対的に答えることはしません。2つのアプローチがどこで異なるかを見ていきます。この記事にはJavaコード例が含まれており、トークン予算を推定する方法、retrieval機構を理解するための最小限のベクトルストア、そして戦略選択の簡単なセットが示されます。読み終わった後、あなたのシステムに対して自分で判断することができるようになります。
2. LLMに知識をもたらす2つの方法
LLMはあなたの内部データを知りません。例えば払戻ポリシー、API ドキュメント、顧客の注文履歴などです。そのデータを応答に含める主な2つの方法があります。
2.1. Vector databaseを使ったRetrieval
典型的なRAG(Retrieval-Augmented Generation)プロセスは4つのステップで構成されています:
- ドキュメントを小さなチャンク(断片)に分割します。
- 各チャンクを埋め込みモデルで数値ベクトルに変換します。
- ベクトルをvector databaseに保存し、最近傍を見つけるためのインデックスを作成します。
- ユーザーが質問したとき、質問を埋め込み、最も近い上位Kチャンクを取得し、プロンプトに含めます。
最終的なプロンプトには関連のあるいくつかのチャンクのみが含まれ、全ドキュメント集合は含まれません。
2.2. Long context:「全てをプロンプトに詰め込む」
このアプローチはより単純です:全ドキュメントをプロンプトに連結させ、モデル自体に情報を見つけさせるのです。チャンキング、埋め込み、追加のインフラが不要です。
ドキュメント集合が小さい場合、通常このアプローチは良い結果を生みます。モデルは全コンテキストを見ているので、複数の部分を合成する必要がある質問にも答えられ、retrieval で top-K が見落とす可能性があるような場合でも対応できます。
3. なぜこの質問が今注目を集めているのか
この議論が熱くなっている理由はいくつかあります:
- コンテキストウィンドウの急速な増加。 かつては細かく分割する必要があったドキュメント集合が、今は1つのプロンプトに収まります。
- プロンプトキャッシング。 多くのプロバイダーはプロンプトの固定部分をキャッシュできるようにしています。これにより、同じドキュメントブロックを繰り返し送信するコストとレイテンシーが大幅に削減されます。
- RAGは正しく実装するのが難しい。 不正なチャンキング、言語に合わないEmbedding、top-K で重要な段落を見落とすことは非常に一般的なエラーです。RAGパイプラインをデバッグしたことのあるチームは、複雑さの層を1つ減らしたいと考えています。
Long contextにも独自の制限があります。非常に長いプロンプト内での情報検索の品質は不均一である可能性があります:大きなテキストブロックの中間に位置する情報は見落とされることがあります。コストとレイテンシーは入力トークン数に応じて増加し続けます。さらに、context window では解決できない問題もあります。例えばアクセス制御とデータの継続的な変更です。以下のセクションでは各ポイントについて説明します。
4. 数値から始める:トークン予算の推定
アーキテクチャを選ぶ前に、ドキュメント集合が何トークン「重いか」を知る必要があります。以下の例は標準Javaのみを使用しています。
import java.util.List;
public class TokenBudget {
// 概算:実際のトークナイザーは異なる結果を生成します。特にベトナム語では
private static final double CHARS_PER_TOKEN = 4.0;
public static long estimateTokens(String text) {
return Math.round(text.length() / CHARS_PER_TOKEN);
}
public static long estimateCorpus(List<String> documents) {
return documents.stream().mapToLong(TokenBudget::estimateTokens).sum();
}
public static void main(String[] args) {
List<String> policies = List.of(
"払戻ポリシー:顧客は注文にエラーがある場合、払戻を受け取ります。",
"送料は重量と配送距離に基づいて計算されます。",
"顧客アカウントはログイン失敗が複数回発生するとロックされます。"
);
long total = estimateCorpus(policies);
long budget = 8_000; // ドキュメント用に割り当てるコンテキストの部分
System.out.printf("Corpus ~%d tokens, budget %d, fits=%b%n",
total, budget, total <= budget);
}
}
各部分の説明:
CHARS_PER_TOKENは非常に大ざっぱなヒューリスティックです。ベトナム語のアクセント記号は通常英語よりも多くのトークンを消費します。正確な数値が必要な場合は、使用しているモデルの実際のトークナイザーを使用すべきです。budgetはコンテキストウィンドウ全体ではありません。システムプロンプト、会話履歴、応答のためにスペースを確保する必要があります。- 3つの短い段落の結果:
Corpus ~48 tokens, budget 8000, fits=true
fits=true でドキュメント集合がほとんど変わらない場合、long context は真剣な候補です。ドキュメント集合が予算より大きい場合、context window がどんなに長くても、retrieval は依然として必要です。
5. メカニズムを理解するための最小限のベクトルストア
retrieval が実際に何をしているかを見るために、メモリ内ベクトルストアを書きます。ここの埋め込みは「ハッシング bag-of-words」タイプで、説明のためだけに使用されます。実システムは埋め込みモデルを呼び出します。
import java.util.*;
public record Document(String id, String text) {}
public record ScoredDocument(Document document, double score) {}
public class HashingEmbedder {
private final int dimensions;
public HashingEmbedder(int dimensions) { this.dimensions = dimensions; }
public double[] embed(String text) {
double[] vector = new double[dimensions];
for (String token : text.toLowerCase().split("\\P{L}+")) {
if (token.isBlank()) continue;
vector[Math.floorMod(token.hashCode(), dimensions)] += 1.0;
}
double norm = Math.sqrt(Arrays.stream(vector).map(v -> v * v).sum());
if (norm > 0) for (int i = 0; i < dimensions; i++) vector[i] /= norm;
return vector;
}
}
public class InMemoryVectorStore {
private record Entry(Document document, double[] vector) {}
private final HashingEmbedder embedder;
private final List<Entry> entries = new ArrayList<>();
public InMemoryVectorStore(HashingEmbedder embedder) { this.embedder = embedder; }
public void add(Document document) {
entries.add(new Entry(document, embedder.embed(document.text())));
}
public List<ScoredDocument> search(String query, int topK) {
double[] q = embedder.embed(query);
return entries.stream()
.map(e -> new ScoredDocument(e.document(), dot(q, e.vector())))
.sorted(Comparator.comparingDouble(ScoredDocument::score).reversed())
.limit(topK)
.toList();
}
private static double dot(double[] a, double[] b) {
double sum = 0;
for (int i = 0; i < a.length; i++) sum += a[i] * b[i];
return sum;
}
}
重要なポイント:
embedはアルファベット以外の文字で単語をトークン化します(\\P{L}+)。ベトナム語のアクセント記号を保持します。各単語はベクトルの1つの次元にハッシュされます。- ベクトルは長さ1に正規化されます。この場合、内積(
dot)はコサイン類似度そのものです。 searchはすべてのエントリを走査します。これは線形探索で、時間計算量は O(n) です。実システムは HNSW のような近似インデックスを使用して、ベクトル数が非常に多い場合も速度を保ちます。
実行例:
var store = new InMemoryVectorStore(new HashingEmbedder(1024));
store.add(new Document("refund", "払戻ポリシー:顧客は注文にエラーがある場合、払戻を受け取ります。"));
store.add(new Document("shipping", "送料は重量と配送距離に基づいて計算されます。"));
store.add(new Document("account", "顧客アカウントはログイン失敗が複数回発生するとロックされます。"));
store.search("顧客が払戻をしたい", 2)
.forEach(r -> System.out.printf("%s -> %.3f%n", r.document().id(), r.score()));
refund -> 0.6xx
shipping -> 0.1xx
具体的な数値はハッシュ関数に依存します。順序は安定しています:refund が最初に来ます。質問と多くの単語を共有しているためです。このチャンクだけがプロンプトに含められ、3つ全てではありません。
注意: 実際の埋め込みは意味論を理解します(「お金を返す」は「払戻」に近い)。ハッシング bag-of-words はそうではありません。本番環境ではこの例を使用しないでください。
本番環境で既に PostgreSQL を使用している場合、pgvector 拡張機能は通常、専用のvector databaseを検討する前の合理的な最初のステップです:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE policy_chunk (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
content TEXT NOT NULL,
embedding vector(768)
);
CREATE INDEX ON policy_chunk USING hnsw (embedding vector_cosine_ops);
-- :query_embedding はアプリケーションから渡されます
SELECT id, content
FROM policy_chunk
WHERE tenant_id = :tenant_id
ORDER BY embedding <=> :query_embedding
LIMIT 5;
次元数 768 は使用しているembedding modelと一致する必要があります。演算子 <=> はコサイン距離を計算します。WHERE tenant_id = ... 句は、retrieval がまだ必要である重要な理由をもたらします。これについては第7章で説明されています。
6. 2つの方法を組み合わせる:データに基づいて戦略を選択する
実際には、答えは通常「ドキュメント集合による」です。この判断をコード化して、チーム全体が基準を統一できるようにすることができます。
public enum ContextStrategy { FULL_CONTEXT, RETRIEVAL }
public record CorpusProfile(long totalTokens,
boolean changesFrequently,
boolean needsPerUserAccess) {}
public class ContextStrategySelector {
private final long documentBudgetTokens;
public ContextStrategySelector(long documentBudgetTokens) {
this.documentBudgetTokens = documentBudgetTokens;
}
public ContextStrategy choose(CorpusProfile profile) {
if (profile.needsPerUserAccess()) return ContextStrategy.RETRIEVAL;
if (profile.totalTokens() > documentBudgetTokens) return ContextStrategy.RETRIEVAL;
if (profile.changesFrequently() && profile.totalTokens() > documentBudgetTokens / 2) {
return ContextStrategy.RETRIEVAL; // データが頻繁に変わるとプロンプトキャッシュは活用しにくい
}
return ContextStrategy.FULL_CONTEXT;
}
}
優先順位の順序に従うロジック:
- ユーザーがデータの一部のみを見ることを許可される場合、プロンプトに含める前にフィルタリングする必要があります。Retrieval はこれをフィルター機能で自然に行います。
- ドキュメント集合がトークン予算より大きい場合、retrieval 以外に選択肢はありません。
- データが頻繁に変わり、既に予算のかなりの部分を占めている場合、プロンプトキャッシングは効果的でなく、retrieval の方が安いことが多いです。
- その他すべてのケース:シンプルさを求めて long context を使用します。
閾値 / 2 は単なる例です。あなたのデータについてコストと品質を測定し、それに応じて調整する必要があります。
7. 主要な動作をテストする
依存関係テスト。プロジェクトが Spring Boot parent を使用している場合、バージョンは既に管理されています:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RetrievalAndStrategyTest {
@Test
void givenRefundQuestion_whenSearch_thenRefundPolicyRanksFirst() {
var store = new InMemoryVectorStore(new HashingEmbedder(1024));
store.add(new Document("refund", "払戻ポリシー:顧客は注文にエラーがある場合、払戻を受け取ります。"));
store.add(new Document("shipping", "送料は重量と配送距離に基づいて計算されます。"));
var results = store.search("顧客が払戻をしたい", 1);
assertThat(results).hasSize(1);
assertThat(results.getFirst().document().id()).isEqualTo("refund");
}
@Test
void givenSmallStaticCorpus_whenChoose_thenFullContext() {
var selector = new ContextStrategySelector(50_000);
var profile = new CorpusProfile(10_000, false, false);
assertThat(selector.choose(profile)).isEqualTo(ContextStrategy.FULL_CONTEXT);
}
@Test
void givenPerUserAccess_whenChoose_thenRetrievalEvenIfSmall() {
var selector = new ContextStrategySelector(50_000);
var profile = new CorpusProfile(1_000, false, true);
assertThat(selector.choose(profile)).isEqualTo(ContextStrategy.RETRIEVAL);
}
}
3番目のテストはビジネスの観点から最も重要です。ドキュメント集合は小さくても、アクセス制御要件により retrieval を使用する必要があります。これはコンテキストウィンドウの長さで解決できない制約です。
8. Retrieval が必要な場合と必要でない場合
Retrieval を使用する必要がある場合(vector database、または既存の DB 内のベクトルインデックス):
- ドキュメント集合がコンテキストウィンドウより大幅に大きい場合。例えば、サポートチケット全体または内部 wiki 全体。
- ユーザーまたはテナントごとのアクセス制御があります。ユーザーが権限を持たないデータをモデルに見せてはいけません。プロンプト内だけでも。
- データが継続的に更新され、ほぼリアルタイムに反映される必要があります。
- クエリ数が多い。巨大なプロンプトをすべてのリクエストに送信するとコストとレイテンシーが増加します。
- 明確に出典を引用する必要があります。つまり、答えがどのチャンクに基づいているかを知ります。
Vector database を削除できる場合:
- ドキュメント集合は小さく、安定しており、トークン予算内に収まります。例えば、製品のポリシーセットまたは1つのモジュールのドキュメント。
- 質問は通常、全ドキュメントを統合する必要があります。例えば「2つのコントラクト間のすべての違いを要約してください」。Top-K retrieval はこれで苦しみます。
- プロトタイプを作成しており、インフラに投資する前に価値を検証したいです。
Vector search が必須ではない場合もあります クエリが注文番号、SKU、関数名のような正確性が必要な場合。フルテキスト検索または SQL クエリは通常より良いです。多くのシステムはハイブリッド検索を使用し、キーワードとベクトルを組み合わせます。
9. 一般的なエラー
- コンテキストウィンドウをドキュメント予算と見なす。 システムプロンプト、チャット履歴、出力のためのスペースを確保し忘れるため、リクエストが本番環境で切り詰められまたはエラーになります。
- 全てをプロンプトに詰め込むが、品質を評価しない。 プロンプトが長いほど、モデルが大きなテキストブロックの中に埋もれている詳細を見落とす可能性が高くなります。標準的な答えを持つテスト質問のセットを作成し、あなたのデータで測定します。
- 専用のvector databaseを早すぎるタイミングで構築する。 数万チャンクの場合、PostgreSQL 内のベクトルインデックスで十分なことが多く、運用する必要があるシステムが1つ少なくなります。
- Retrieval 後に権限をフィルタリングする。 Top-K を取得してから権限でフィルタリングすると、すべての有効な結果が削除される可能性があります。権限条件をクエリ自体に含めます。例えば、第5章のセクションの
WHERE tenant_id句のように。 - Embedding モデルを変更したが再インデックスしない。 2つの異なるモデルから生成されたベクトルは互いに比較できません。
注意: Long context と retrieval は相互に排他的ではありません。一般的なパターンは、retrieval を「寛大に」行い、以前よりも多くのチャンクを取得してから、モデル自体が選別できるようにコンテキストを長く使用することです。これにより、見落とし失敗のリスクが低減され、コストはコントロール下に保たれます。
10. 結論
より長いコンテキストウィンドウはバランスポイントを変えますが、retrieval の必要性を排除しません。ドキュメント集合が小さく安定している場合、「全てをプロンプトに詰め込む」は通常シンプルで十分です。データが大きく、頻繁に変わり、アクセス制御が必要であるか、トラフィックが高い場合、retrieval は依然として必要です。ただし、それが専用のvector databaseである必要はありません:既存のデータベース内のベクトルインデックスで通常は十分です。
実践的なアプローチは測定して決定することです。ドキュメント集合のトークン数を推定し、アクセス制御要件と更新頻度を特定し、実際のテスト質問セットで品質を評価します。ContextStrategySelector のようにこれらの基準をコード化すると、チーム全体が明確で検証可能な判断を下すことができます。