はじめに
現在、Firebase Genkitをバックエンドに採用し、グループ・個人チャットを中心としたAIアプリを開発しています。
このアプリでは、ユーザーとのやり取りの一部を「長期記憶」として蓄積し、後続の会話に活かすRAGアーキテクチャを採用しています。
記憶の保存や検索にはVertex AI Vector Search、ベクトル変換にはGoogleが提供する text-embedding-004(当時の推奨モデル)を利用していました。
しかし、検証中に「ユーザーが明示的に『覚えておいて』と伝えた内容が、後続のスレッドで一切出てこない」という不具合に気づきました。
調査を進めると、ASCII文字を1文字も含まない日本語テキストに対して、text-embedding-004 と text-embedding-005 が入力に関わらず常に同一のベクトルを返す という挙動を確認しました。
この記事では、その発見の経緯・被害範囲の調査・修復スクリプトの設計・移行先モデルの選定まで、一連の対応をまとめます。
対象読者
-
Vertex AI Vector Searchを使った日本語RAGを構築・運用している方 -
text-embedding-004/text-embedding-005を利用中で、検索精度の怪しさを感じている方 - LLMアプリで「記憶されているはずのデータが返ってこない」サイレント劣化に悩んでいる方
課題:重複チェックが「永遠に全件重複と判定する」状態になっていた
本アプリでは、同じ内容の記憶を何度も保存しないよう、新しい記憶を保存する前に既存データとの類似度を確認する重複チェックを入れています。
「コサイン距離がある閾値以下なら重複と見なし、保存をスキップする」という実装です。
ところがある時点から、ほぼすべての記憶が「重複」と判定されて保存されない状態になっていることに気づきました。
ログを確認すると、コサイン距離が入力に関わらず常に 1.0 前後を示していました。
[PersonalMemory/Vector] 類似度: 1.000 / 上限: 0.85 → 重複のため保存をスキップ
[PersonalMemory/Vector] 類似度: 0.997 / 上限: 0.85 → 重複のため保存をスキップ
[PersonalMemory/Vector] 類似度: 1.000 / 上限: 0.85 → 重複のため保存をスキップ
「距離が常に1.0」ということは、すべてのベクトルが同一の1点に収束していることを意味します。
検証:再現する条件と再現しない条件
実際に ai.embed() を呼び出して確認したところ、以下の結果が得られました。
// 検証コード(抜粋)
const result1 = await ai.embed({
embedder: vertexAI.embedder('text-embedding-004'),
content: 'ユーザーはツナたまごクロワッサンを食べた。', // ASCII文字なし
});
const result2 = await ai.embed({
embedder: vertexAI.embedder('text-embedding-004'),
content: 'ユーザーはハンバーガーを食べた。', // ASCII文字なし
});
const result3 = await ai.embed({
embedder: vertexAI.embedder('text-embedding-004'),
content: 'ユーザーは2026-08-07にツナたまごクロワッサンを食べた。', // 日付の数字あり
});
// result1 と result2 の全要素が完全一致(同一ベクトル)
// result1 と result3 は正常に異なるベクトル
| 入力テキスト | ASCII文字 | ベクトル |
|---|---|---|
| 「〜を食べた。」 | なし | 定数ベクトル(全件同一) |
| 「〜が好きです。」 | なし | 定数ベクトル(同上) |
| 「2026-08-07に〜を食べた。」 | あり(日付) | 正常 |
| 「ユーザーID: abc123 の記録」 | あり(英数字) | 正常 |
task_type やリージョンを変えても挙動は変わりませんでした。
間欠障害ではなく、決定的な仕様上の挙動です。
なぜ 2.6% の被害に留まったのか(偶然の保険)
全データを確認すると、影響を受けたのは 3,603件中95件(約2.6%) でした。
被害が少なかった理由は、記憶を保存する際のプロンプト設計にありました。
記憶の要約テキストには「いつの出来事か」を必ず絶対日付で含める設計にしていたため、ほとんどの記憶文字列は 2026-08-07に〜 のような形式になっており、偶然にも ASCII の数字が含まれていました。
✅ 保存済み(正常): 「2026-08-07、ユーザーはツナたまごクロワッサンを食べた」
❌ 壊れていた : 「ユーザーはツナたまごクロワッサンを食べた」(日付なし)
日付を入れる設計が、意図せずembeddingモデルのバグを回避する保険になっていました。
しかし、これは偶然であり、日付のない記憶(気持ちの記録、定性的なメモなど)はすべて同一ベクトルに潰れていたことになります。
この性質の怖さは、エラーが一切発生しないことです。
ベクトルの保存は成功し、ログにも警告が出ません。
「検索結果がおかしい」という症状で初めて気づける類の劣化でした。
解決策:モデルの移行と選定
移行先の選定
今回は緊急対応としてtext-multilingual-embedding-002に移行しました。
選定理由は以下の通りです。
- 日本語専用テキストでも正常なベクトルを返すことを実測で確認
- ベクトルの次元数が768次元で
text-embedding-004と同一のため、既存のインデックス構成を変更せずに済む - ノルムが1.0に正規化されており、コサイン距離の計算がそのまま使える
なお、プラットフォームによってサポート期間が異なる点に注意が必要です。
Google AI Studio(Gemini API)では2026年1月14日にシャットダウン済みですが、Vertex AI上では2027年4月1日まで提供継続されています。
今から移行するなら gemini-embedding-001 も有力
現時点でGoogleが提供する最新モデルはgemini-embedding-001です。
英語・多言語・コードを統合した最高精度モデルですが、デフォルトの出力次元数が3072次元のため「既存インデックスを作り直さなければならない」と思われがちです。
しかし、このモデルはMRL(Matryoshka Representation Learning)を採用しており、outputDimensionality: 768 を指定するだけで768次元に絞って出力できます。
既存のインデックス構成を変えずに最新モデルへ移行できるため、これから新規で構築する場合や次の移行機会には検討する価値があります。
// gemini-embedding-001 を768次元で使う例
const embedding = await ai.embed({
embedder: vertexAI.embedder('gemini-embedding-001'),
content: query,
options: { outputDimensionality: 768 },
});
task_type の指定でRAG精度を上げる
Vertex AIのembeddingモデルは、テキストの「目的」に応じてベクトル空間を最適化する task_type パラメータを持っています。
RAGシステムでは以下の使い分けが推奨されます。
| 用途 | task_type |
|---|---|
| 記憶・ドキュメントの保存時 | RETRIEVAL_DOCUMENT |
| ユーザー発言からの検索時 | RETRIEVAL_QUERY |
// 保存時(ドキュメント側)
const docEmbedding = await ai.embed({
embedder: vertexAI.embedder(EMBEDDING_MODEL),
content: memoryText,
options: { taskType: 'RETRIEVAL_DOCUMENT' },
});
// 検索時(クエリ側)
const queryEmbedding = await ai.embed({
embedder: vertexAI.embedder(EMBEDDING_MODEL),
content: userQuery,
options: { taskType: 'RETRIEVAL_QUERY' },
});
保存側と検索側で同じ空間に最適化された異なるベクトルを生成するため、意味的な類似度の精度が向上します。
モデル名は必ず定数経由で管理する
今回のような移行を最小コストで行うために、モデル名はコード上に直書きせず、定数経由で一元管理します。
// constants/config.ts
export const EMBEDDING_MODEL = 'text-multilingual-embedding-002';
モデルを切り替えた場合、古い空間と新しい空間のベクトルが混在した状態では検索結果が意味をなしません。
既存の全 datapoint を新しいモデルで貼り直す必要があります。
実装:全 datapoint 張り替えスクリプト
以下のような修復スクリプトを作成しました。
--apply なしで実行するとドライランになり、影響範囲を事前に確認できます。
// scripts/reembedAllVectors.ts(抜粋)
import { ai } from '../core/bootstrap';
import { vertexAI } from '@genkit-ai/google-genai';
import { EMBEDDING_MODEL, MEMORY_DATE_NAMESPACE } from '../constants/config';
const TARGETS = [
{ collection: 'memories', withDate: true }, // toC個人記憶
{ collection: 'personalMemories', withDate: true }, // toB個人記憶
{ collection: 'information', withDate: false }, // 組織ナレッジ
];
const isDryRun = !process.argv.includes('--apply');
const datesOnly = process.argv.includes('--dates-only');
for (const target of TARGETS) {
const snapshot = await db.collection(target.collection).get();
for (const docSnap of snapshot.docs) {
const data = docSnap.data();
const text = data.summary ?? data.content ?? '';
if (!text) continue;
if (!datesOnly) {
// 通常モード:新しいモデルでベクトルを再生成して貼り直す
// ★ バッチ処理版は後述。ここでは1件ずつの処理例として記載
const embedding = await ai.embed({
embedder: vertexAI.embedder(EMBEDDING_MODEL),
content: text,
options: { taskType: 'RETRIEVAL_DOCUMENT' }, // 保存側はDOCUMENT
});
if (isDryRun) {
console.log(`[DryRun] 対象: ${target.collection}/${docSnap.id}`);
continue;
}
await vectorSearchClient.upsertDatapoints({
// ...既存のrestricts/crowdingTagを読み出した値のまま書き戻す
featureVector: embedding,
});
} else {
// --dates-only モード:featureVectorはそのまま、memory_dateだけ補完する
const hasMemoryDate = /* numericRestrictsを確認 */;
if (hasMemoryDate) continue; // 既に付いていればスキップ
const dateInt = resolveMemoryDate(data); // FirestoreのmemoryDate or createdAtから変換
if (!isDryRun) {
await vectorSearchClient.upsertDatapoints({
// featureVectorは元の値をそのまま使用(再embeddingコストなし)
numericRestricts: [{ namespace: MEMORY_DATE_NAMESPACE, valueInt: String(dateInt) }],
});
}
}
}
}
★ポイント:restricts(namespaceフィルタ)と crowdingTag は既存の datapoint から読み出した値をそのまま書き戻しています。
ここを省略すると、再 upsert で既存のフィルタ設定が消えてしまい、他アプリのデータ分離が崩れます。
--dates-only オプションの効果
通常モードで3,600件を処理すると、embedding API の呼び出しが3,600回発生し、クォータを消費しながら数十分かかります。
一方で --dates-only は featureVector を一切変えず、numericRestricts(日付)だけを補完するため、embedding APIを1度も呼ばずに処理が完了します。
移行後に「ベクトルは正しいが日付だけ付いていない datapoint がある」といったケースで有効です。
# ドライランで影響範囲を確認
npx tsx src/scripts/reembedAllVectors.ts
# 通常モード:全件ベクトルを再生成(モデル変更時)
npx tsx src/scripts/reembedAllVectors.ts --apply
# 日付だけ補完(ベクトル変更なし)
npx tsx src/scripts/reembedAllVectors.ts --dates-only --apply
モデルを差し替えた直後に --dates-only を使ってはいけません。
古い空間のベクトルをそのまま温存してしまいます。
バッチ処理で高速化する(ai.embedMany)
上記スクリプトは1件ずつ ai.embed() を呼び出しているため、3,600件の処理に数十分かかります。
Firebase Genkitには複数テキストをまとめて処理できるai.embedMany()が用意されており、Vertex AIのembedding APIは1リクエストで最大250件を同時処理できます。
// scripts/reembedAllVectors.ts(バッチ処理版・抜粋)
const BATCH_SIZE = 250; // Vertex AI の1リクエスト上限
const docs = snapshot.docs.filter(d => d.data().summary ?? d.data().content);
// 250件ずつ分割して処理
for (let i = 0; i < docs.length; i += BATCH_SIZE) {
const batch = docs.slice(i, i + BATCH_SIZE);
const texts = batch.map(d => d.data().summary ?? d.data().content ?? '');
const embeddings = await ai.embedMany({
embedder: vertexAI.embedder(EMBEDDING_MODEL),
content: texts,
options: { taskType: 'RETRIEVAL_DOCUMENT' },
});
for (let j = 0; j < batch.length; j++) {
if (!isDryRun) {
await vectorSearchClient.upsertDatapoints({
featureVector: embeddings[j],
// ...restricts/crowdingTagを書き戻す
});
}
}
console.log(`[Reembed] ${Math.min(i + BATCH_SIZE, docs.length)} / ${docs.length} 件処理済み`);
}
ネットワークのオーバーヘッドが大幅に減り、同じ3,600件でも処理時間を数分単位に短縮できます。
おわりに
今回は、「embeddingモデルの障害はエラーを出さない」という事象でした。
ベクトルの保存は成功し、検索も一応返ってきます。
ただし全件が同じ1点に収束しているため、「何を入れても同じ結果が返る」という症状として現れます。
RAGシステムに組み込んでいる場合、LLMが「記憶を参照できていない」という見た目は、単純にデータが少ない状態と区別がつきません。
本番で気づかずに放置されるリスクが高い種類の不具合です。
対策として有効なのは以下の3点です。
- 距離だけで重複を判定しない
「距離が閾値以下なら重複」のロジックに加え、対象の本文を読んで内容が本当に一致しているかを確認する。距離が常に1.0になっていれば、モデルが壊れているサインとして検知できます。 - モデル名を定数に閉じ込める
vertexAI.embedder('text-embedding-004')のような直書きを禁止し、EMBEDDING_MODEL定数経由にする。切り替え時に1箇所だけ直せばよく、漏れが起きません。 - 保存と検索で
task_typeを使い分ける
RETRIEVAL_DOCUMENT(保存時)とRETRIEVAL_QUERY(検索時)を指定するだけで精度が向上します。実装コストがほぼゼロなので最初から入れておくのがおすすめです。
Firebase GenkitやVertex AIを使った日本語RAGを構築する際の参考になれば幸いです。