「会話の続きから話したい」というのは、Claude を本気でアプリに組み込もうとした時に最初にぶつかる壁です。私自身、2014 年から iOS / Android のアプリ開発を続けてきた中で、累計 5,000 万ダウンロードに達したアプリ群でも「ユーザーの好みを覚えておけない」ことが体験品質の足かせになっていました。MCP(Model Context Protocol)を使った長期記憶は、その制約をようやく取り払える設計だと感じています。
ただし運用に入ると、公式ドキュメントには書かれていない落とし穴がいくつもあります。以下では、ベクトル DB の実運用で見えてきたメトリクス、規模別の DB 選定、そして Stripe 課金を持つ 4 サイト(Claude Lab・Gemini Lab・Antigravity Lab・Rork Lab)を並行運営している立場から見えた、長期記憶を「ユーザー体験」として設計する判断軸を整理していきます。
長期記憶の仕組み
MCP による統合アーキテクチャ
MCP は Claude と外部システムをやり取りするための標準プロトコルです。長期記憶を実装する場合、以下の層を統合します。
┌─────────────────────────────────────────┐
│ Claude API │
│ (Message + Context Window) │
└──────────────────┬──────────────────────┘
│
┌──────────▼──────────┐
│ MCP Protocol │
│ (Tool Definitions) │
└──────────┬──────────┘
│
┌──────────────┼──────────────┐
│ │ │
┌───▼───┐ ┌──────▼──────┐ ┌────▼─────┐
│ Memory│ │ Vector DB │ │ User DB │
│ Store │ │ (Pinecone) │ │(PostgreSQL)
└───────┘ └─────────────┘ └──────────┘
Claude API層: メッセージ送信とトークン管理
MCP層: ツール定義、メモリ操作の仕様
永続化層: ベクトルDB、ユーザーメタデータ、監査ログ
メモリの全ライフサイクル
// lib/memory-lifecycle.ts
interface MemoryLifecycle {
create: (userId: string, memory: MemoryEntry) => Promise<void>;
retrieve: (userId: string, query: string) => Promise<MemoryEntry[]>;
update: (memoryId: string, newContent: string) => Promise<void>;
delete: (memoryId: string) => Promise<void>;
expire: (memoryId: string) => Promise<void>;
}
class MemoryManager implements MemoryLifecycle {
private vectorDb: VectorDatabase;
private userDb: UserDatabase;
private cache: CacheService;
async create(userId: string, memory: MemoryEntry): Promise<void> {
// 1. テキストをベクトル化
const embedding = await this.embedText(memory.content);
// 2. ベクトルDB に保存
const vectorId = await this.vectorDb.insert({
userId,
embedding,
metadata: {
createdAt: new Date(),
importance: memory.importance,
category: memory.category,
expiresAt: this.calculateExpiration(memory.ttl),
},
});
// 3. ユーザーDB に参照を保存
await this.userDb.insertMemoryRef({
userId,
vectorId,
originalContent: memory.content,
category: memory.category,
createdAt: new Date(),
});
// 4. キャッシュを無効化
this.cache.invalidate(`memories:${userId}`);
}
async retrieve(userId: string, query: string): Promise<MemoryEntry[]> {
// 1. キャッシュを確認
const cached = this.cache.get(`memories:${userId}:${query}`);
if (cached) return cached;
// 2. クエリをベクトル化
const queryEmbedding = await this.embedText(query);
// 3. ベクトルDB で類似度検索(top-k=5)
const matches = await this.vectorDb.search({
embedding: queryEmbedding,
userId,
limit: 5,
filter: {
// 期限切れメモリを除外
expiresAt: { $gt: new Date() },
},
});
// 4. スコア順にメモリを返す
const memories = matches.map((m) => ({
id: m.id,
content: this.decryptContent(m.metadata.content),
importance: m.metadata.importance,
relevanceScore: m.similarity,
category: m.metadata.category,
}));
// 5. キャッシュに保存(TTL: 5分)
this.cache.set(`memories:${userId}:${query}`, memories, 5 * 60);
return memories;
}
private calculateExpiration(ttl?: number): Date {
// デフォルト: 30日間のTTL
const days = ttl || 30;
const expiry = new Date();
expiry.setDate(expiry.getDate() + days);
return expiry;
}
}
MCP ツール定義
メモリ操作ツールの実装
Claude が長期記憶にアクセスするため、以下の MCP ツールを定義します。
// mcp-tools/memory-tools.ts
const memoryTools = {
// 重要な会話内容をメモリに保存
save_memory: {
name: "save_memory",
description:
"Save important information to long-term memory for future reference",
inputSchema: {
type: "object",
properties: {
content: {
type: "string",
description: "The memory content (up to 1000 chars)",
},
category: {
type: "string",
enum: [
"user_preference",
"project_context",
"skill_profile",
"past_conversation",
"decision_log",
],
description: "Memory category",
},
importance: {
type: "number",
enum: [1, 2, 3, 4, 5],
description: "Importance level (1=low, 5=critical)",
},
ttl_days: {
type: "number",
description: "Time to live in days (default: 30)",
},
},
required: ["content", "category", "importance"],
},
},
// メモリを検索して内容を取得
retrieve_memory: {
name: "retrieve_memory",
description:
"Retrieve relevant memories based on a query or topic",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query for memories",
},
category: {
type: "string",
description: "Optional filter by category",
},
limit: {
type: "number",
description: "Maximum number of results (default: 5)",
},
},
required: ["query"],
},
},
// メモリを更新
update_memory: {
name: "update_memory",
description: "Update an existing memory entry",
inputSchema: {
type: "object",
properties: {
memory_id: {
type: "string",
description: "ID of the memory to update",
},
content: {
type: "string",
description: "New content",
},
importance: {
type: "number",
enum: [1, 2, 3, 4, 5],
description: "New importance level",
},
},
required: ["memory_id", "content"],
},
},
// メモリを削除
delete_memory: {
name: "delete_memory",
description: "Delete a memory entry",
inputSchema: {
type: "object",
properties: {
memory_id: {
type: "string",
description: "ID of the memory to delete",
},
},
required: ["memory_id"],
},
},
// メモリサマリーを取得
get_memory_summary: {
name: "get_memory_summary",
description: "Get a concise summary of all memories for this user",
inputSchema: {
type: "object",
properties: {
category: {
type: "string",
description: "Filter by category",
},
},
},
},
};
MCP ハンドラーの実装
// mcp-handlers/memory-handler.ts
import { Tool } from "@anthropic-ai/sdk/resources/messages";
class MemoryMCPHandler {
private memoryManager: MemoryManager;
private auditLog: AuditLogger;
async handleToolCall(
userId: string,
toolName: string,
toolInput: Record<string, any>
): Promise<string> {
switch (toolName) {
case "save_memory":
return this.handleSaveMemory(userId, toolInput);
case "retrieve_memory":
return this.handleRetrieveMemory(userId, toolInput);
case "update_memory":
return this.handleUpdateMemory(userId, toolInput);
case "delete_memory":
return this.handleDeleteMemory(userId, toolInput);
case "get_memory_summary":
return this.handleGetSummary(userId, toolInput);
default:
throw new Error(`Unknown tool: ${toolName}`);
}
}
private async handleSaveMemory(
userId: string,
input: Record<string, any>
): Promise<string> {
const { content, category, importance, ttl_days } = input;
// バリデーション
if (!content || content.length > 1000) {
throw new Error("Content must be 1-1000 characters");
}
if (!["user_preference", "project_context", "skill_profile",
"past_conversation", "decision_log"].includes(category)) {
throw new Error("Invalid category");
}
// メモリを保存
const memoryId = await this.memoryManager.create(userId, {
content,
category,
importance,
ttl: ttl_days,
});
// 監査ログ
await this.auditLog.log({
userId,
action: "save_memory",
memoryId,
category,
importance,
timestamp: new Date(),
});
return JSON.stringify({
success: true,
memoryId,
message: `Memory saved with importance level ${importance}`,
});
}
private async handleRetrieveMemory(
userId: string,
input: Record<string, any>
): Promise<string> {
const { query, category, limit = 5 } = input;
const memories = await this.memoryManager.retrieve(
userId,
query,
category,
limit
);
// 監査ログ(検索は記録するが詳細は記録しない)
await this.auditLog.log({
userId,
action: "retrieve_memory",
resultCount: memories.length,
timestamp: new Date(),
});
return JSON.stringify({
count: memories.length,
memories: memories.map((m) => ({
id: m.id,
content: m.content,
category: m.category,
importance: m.importance,
relevanceScore: m.relevanceScore,
})),
});
}
private async handleUpdateMemory(
userId: string,
input: Record<string, any>
): Promise<string> {
const { memory_id, content, importance } = input;
// 権限確認
const owner = await this.memoryManager.getMemoryOwner(memory_id);
if (owner !== userId) {
throw new Error("Unauthorized");
}
await this.memoryManager.update(memory_id, { content, importance });
await this.auditLog.log({
userId,
action: "update_memory",
memoryId: memory_id,
timestamp: new Date(),
});
return JSON.stringify({ success: true, message: "Memory updated" });
}
private async handleDeleteMemory(
userId: string,
input: Record<string, any>
): Promise<string> {
const { memory_id } = input;
const owner = await this.memoryManager.getMemoryOwner(memory_id);
if (owner !== userId) {
throw new Error("Unauthorized");
}
await this.memoryManager.delete(memory_id);
await this.auditLog.log({
userId,
action: "delete_memory",
memoryId: memory_id,
timestamp: new Date(),
});
return JSON.stringify({ success: true, message: "Memory deleted" });
}
private async handleGetSummary(
userId: string,
input: Record<string, any>
): Promise<string> {
const { category } = input;
const summary = await this.memoryManager.getSummary(userId, category);
return JSON.stringify({
totalMemories: summary.totalCount,
byCategory: summary.byCategory,
summary: summary.contentSummary,
lastUpdated: summary.lastUpdated,
});
}
}
ベクトルデータベース連携
Pinecone との統合
Pinecone はスケーラブルなベクトルDB で、高速な類似度検索を実現します。
// lib/vector-db/pinecone-adapter.ts
import { Pinecone } from "@pinecone-database/pinecone";
interface VectorStoreConfig {
apiKey: string;
indexName: string;
environment: string;
}
class PineconeVectorStore {
private client: Pinecone;
private indexName: string;
constructor(config: VectorStoreConfig) {
this.client = new Pinecone({
apiKey: config.apiKey,
environment: config.environment,
});
this.indexName = config.indexName;
}
async insert(memoryData: {
userId: string;
embedding: number[];
metadata: Record<string, any>;
}): Promise<string> {
const index = this.client.Index(this.indexName);
const vectorId = this.generateVectorId();
await index.upsert([
{
id: vectorId,
values: memoryData.embedding,
metadata: {
userId: memoryData.userId,
createdAt: new Date().toISOString(),
...memoryData.metadata,
},
},
]);
return vectorId;
}
async search(query: {
embedding: number[];
userId: string;
limit: number;
filter?: Record<string, any>;
}): Promise<
Array<{
id: string;
similarity: number;
metadata: Record<string, any>;
}>
> {
const index = this.client.Index(this.indexName);
const results = await index.query({
vector: query.embedding,
topK: query.limit,
filter: {
userId: { $eq: query.userId },
...query.filter,
},
includeMetadata: true,
});
return results.matches.map((match) => ({
id: match.id,
similarity: match.score,
metadata: match.metadata,
}));
}
async delete(vectorId: string): Promise<void> {
const index = this.client.Index(this.indexName);
await index.deleteOne(vectorId);
}
async deleteByUserId(userId: string): Promise<void> {
const index = this.client.Index(this.indexName);
// ユーザーIDで全メモリを削除(GDPR対応)
await index.deleteMany([
{
userId: { $eq: userId },
},
]);
}
private generateVectorId(): string {
return `vec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
テキスト埋め込みの最適化
// lib/embeddings/embedding-service.ts
import { Anthropic } from "@anthropic-ai/sdk";
class EmbeddingService {
private client: Anthropic;
private cache: Map<string, number[]>;
private batchSize = 100;
constructor(apiKey: string) {
this.client = new Anthropic({ apiKey });
this.cache = new Map();
}
async embedText(text: string): Promise<number[]> {
// キャッシュを確認
if (this.cache.has(text)) {
return this.cache.get(text)!;
}
// テキストを正規化
const normalized = this.normalizeText(text);
// Anthropic の Embeddings API を使用
const response = await this.client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
system: "You are an embedding generator. Convert the input to a semantic vector.",
messages: [
{
role: "user",
content: normalized,
},
],
});
// Claude の出力をベクトルに変換
const embedding = this.extractEmbedding(response);
// キャッシュに保存
this.cache.set(text, embedding);
return embedding;
}
async embedBatch(texts: string[]): Promise<number[][]> {
const results: number[][] = [];
for (let i = 0; i < texts.length; i += this.batchSize) {
const batch = texts.slice(i, i + this.batchSize);
const embeddings = await Promise.all(
batch.map((text) => this.embedText(text))
);
results.push(...embeddings);
}
return results;
}
private normalizeText(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/\s+/g, " ")
.substring(0, 2000); // 最大2000文字
}
private extractEmbedding(response: any): number[] {
// Claude の出力から埋め込みベクトルを抽出
const content = response.content[0].text;
// JSON形式の埋め込みを解析
try {
const data = JSON.parse(content);
return data.embedding || data.vector || [];
} catch {
// デフォルト: ランダムベクトル(実装用)
return Array(1536)
.fill(0)
.map(() => Math.random());
}
}
clearCache(): void {
this.cache.clear();
}
}
プライバシーと セキュリティ
暗号化とアクセス制御
// lib/security/privacy-engine.ts
import crypto from "crypto";
class PrivacyEngine {
private encryptionKey: string;
private accessControl: AccessControlManager;
async encryptMemory(
memoryId: string,
content: string,
userId: string
): Promise<{ iv: string; encryptedContent: string }> {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
"aes-256-cbc",
Buffer.from(this.encryptionKey),
iv
);
let encrypted = cipher.update(content, "utf-8", "hex");
encrypted += cipher.final("hex");
// 監査ログ
await this.auditLog.logEncryption({
memoryId,
userId,
timestamp: new Date(),
});
return {
iv: iv.toString("hex"),
encryptedContent: encrypted,
};
}
async decryptMemory(
userId: string,
iv: string,
encryptedContent: string
): Promise<string> {
// アクセス権限を確認
const hasAccess = await this.accessControl.checkAccess(userId);
if (!hasAccess) {
throw new Error("Access denied");
}
const decipher = crypto.createDecipheriv(
"aes-256-cbc",
Buffer.from(this.encryptionKey),
Buffer.from(iv, "hex")
);
let decrypted = decipher.update(encryptedContent, "hex", "utf-8");
decrypted += decipher.final("utf-8");
return decrypted;
}
// GDPR: 右利用者のデータをすべて削除
async deleteUserData(userId: string): Promise<void> {
// ベクトルDB からユーザーのメモリを削除
await this.vectorDb.deleteByUserId(userId);
// PostgreSQL からユーザーの参照を削除
await this.userDb.deleteMemoriesForUser(userId);
// 監査ログに削除を記録
await this.auditLog.logDeletion({
userId,
action: "full_data_deletion",
timestamp: new Date(),
});
}
// 監視: メモリアクセス監査
async auditMemoryAccess(userId: string, memoryId: string): Promise<void> {
await this.auditLog.log({
userId,
memoryId,
action: "memory_access",
timestamp: new Date(),
ipAddress: this.getCurrentIpAddress(),
});
}
}
class AccessControlManager {
async checkAccess(userId: string): Promise<boolean> {
// ユーザーのサブスクリプション状態を確認
const subscription = await this.db.getSubscription(userId);
// 無料ユーザーは限定的なメモリアクセス
if (subscription.tier === "free") {
return subscription.memoryCount < 10; // 最大10個
}
// プレミアムユーザーは無制限
return subscription.tier === "premium";
}
async getAccessLevel(userId: string): Promise<AccessLevel> {
const subscription = await this.db.getSubscription(userId);
return {
canSaveMemory: subscription.tier !== "free" || subscription.memoryCount < 10,
canRetrieveMemory: true,
memoryRetentionDays:
subscription.tier === "premium" ? 365 : subscription.tier === "pro" ? 90 : 30,
maxMemorySize: subscription.tier === "premium" ? 5000 : 1000,
};
}
}
メモリの品質管理
記憶の鮮度と関連性
// lib/memory-quality/relevance-engine.ts
class RelevanceEngine {
async scoreRelevance(
memory: MemoryEntry,
context: ConversationContext
): Promise<number> {
let score = 0;
// 1. 時間的減衰(古いメモリは関連性が低い)
const ageInDays =
(Date.now() - memory.createdAt.getTime()) / (1000 * 60 * 60 * 24);
const timeDecay = Math.exp(-ageInDays / 30); // 30日で ~37%に減衰
score += timeDecay * 0.3;
// 2. 明示的な重要度
score += (memory.importance / 5) * 0.3;
// 3. コンテキストマッチング
const semanticSimilarity = await this.calculateSimilarity(
memory.content,
context.currentTopic
);
score += semanticSimilarity * 0.4;
return Math.min(score, 1.0);
}
async pruneMemories(userId: string): Promise<void> {
// 関連性スコアが低いメモリを定期的に削除
const allMemories = await this.db.getUserMemories(userId);
for (const memory of allMemories) {
const score = await this.scoreRelevance(memory, {});
if (score < 0.1) {
// 関連性がほぼない
await this.deleteMemory(memory.id);
}
}
}
private async calculateSimilarity(
memoryContent: string,
currentTopic: string
): Promise<number> {
const embedding1 = await this.embedText(memoryContent);
const embedding2 = await this.embedText(currentTopic);
// コサイン類似度
return this.cosineSimilarity(embedding1, embedding2);
}
private cosineSimilarity(a: number[], b: number[]): number {
const dotProduct = a.reduce((sum, x, i) => sum + x * b[i], 0);
const magnitudeA = Math.sqrt(a.reduce((sum, x) => sum + x * x, 0));
const magnitudeB = Math.sqrt(b.reduce((sum, x) => sum + x * x, 0));
return dotProduct / (magnitudeA * magnitudeB);
}
}
メモリ統合
重複や矛盾するメモリを自動的に統合します。
// lib/memory-quality/consolidation.ts
class MemoryConsolidation {
async consolidateMemories(userId: string): Promise<void> {
const memories = await this.db.getUserMemories(userId);
// 1. 重複検出
const duplicates = await this.findDuplicates(memories);
for (const group of duplicates) {
// 最も関連性の高いメモリを保持、他を削除
const primary = group.sort(
(a, b) => b.importance - a.importance
)[0];
for (const duplicate of group.slice(1)) {
// 情報を統合してから削除
primary.content = await this.mergeContent(
primary.content,
duplicate.content
);
await this.db.deleteMemory(duplicate.id);
}
}
// 2. 矛盾検出
const contradictions = await this.findContradictions(memories);
for (const pair of contradictions) {
// 新しい情報でメモリを更新
if (pair.new.createdAt > pair.old.createdAt) {
await this.db.updateMemory(pair.old.id, pair.new.content);
}
}
}
private async findDuplicates(
memories: MemoryEntry[]
): Promise<MemoryEntry[][]> {
const groups: MemoryEntry[][] = [];
for (let i = 0; i < memories.length; i++) {
for (let j = i + 1; j < memories.length; j++) {
const similarity = await this.calculateSimilarity(
memories[i].content,
memories[j].content
);
// 類似度 > 0.85 は重複と判断
if (similarity > 0.85) {
// グループに追加
let found = false;
for (const group of groups) {
if (group.some((m) => m.id === memories[i].id)) {
group.push(memories[j]);
found = true;
break;
}
}
if (!found) {
groups.push([memories[i], memories[j]]);
}
}
}
}
return groups;
}
private async mergeContent(content1: string, content2: string): Promise<string> {
// Claude を使って コンテンツを統合
const response = await this.client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
messages: [
{
role: "user",
content: `Merge these two memory entries into a single, coherent summary:
Entry 1: ${content1}
Entry 2: ${content2}
Return only the merged content.`,
},
],
});
return response.content[0].type === "text" ? response.content[0].text : "";
}
}
本番運用とスケーリング
メモリの効率的検索
// lib/memory-search/hybrid-search.ts
class HybridMemorySearch {
// キーワード検索 + セマンティック検索
async search(
userId: string,
query: string,
options?: SearchOptions
): Promise<SearchResult[]> {
// 1. キーワード検索(BM25 など)
const keywordResults = await this.keywordSearch(userId, query);
// 2. セマンティック検索(ベクトルDB)
const semanticResults = await this.semanticSearch(userId, query);
// 3. スコアを統合(加重平均)
const merged = this.mergeResults(
keywordResults,
semanticResults,
{
keywordWeight: 0.3,
semanticWeight: 0.7,
}
);
// 4. 関連性でソートして返す
return merged
.sort((a, b) => b.score - a.score)
.slice(0, options?.limit || 5);
}
private async keywordSearch(
userId: string,
query: string
): Promise<SearchResult[]> {
return this.db.query(
`
SELECT id, content, importance,
ts_rank(content_vec, plainto_tsquery('english', $1)) as rank
FROM memories
WHERE user_id = $2 AND content @@ plainto_tsquery('english', $1)
ORDER BY rank DESC
`,
[query, userId]
);
}
private async semanticSearch(
userId: string,
query: string
): Promise<SearchResult[]> {
const embedding = await this.embedText(query);
return this.vectorDb.search({
embedding,
userId,
limit: 10,
});
}
private mergeResults(
keywordResults: SearchResult[],
semanticResults: SearchResult[],
weights: { keywordWeight: number; semanticWeight: number }
): SearchResult[] {
const merged = new Map<string, SearchResult>();
// キーワード検索結果を追加
keywordResults.forEach((result, index) => {
merged.set(result.id, {
...result,
score: weights.keywordWeight * (1 - index / keywordResults.length),
});
});
// セマンティック検索結果をマージ
semanticResults.forEach((result) => {
if (merged.has(result.id)) {
const existing = merged.get(result.id)!;
existing.score += weights.semanticWeight * result.score;
} else {
merged.set(result.id, {
...result,
score: weights.semanticWeight * result.score,
});
}
});
return Array.from(merged.values());
}
}
コスト最適化
// lib/cost-optimization/cost-manager.ts
class MemoryCostManager {
async optimizeStorage(userId: string): Promise<CostReport> {
const memories = await this.db.getUserMemories(userId);
let report = {
totalMemories: memories.length,
estimatedCost: 0,
optimizations: [],
};
// 1. 低優先度メモリの削除
const lowPriority = memories.filter((m) => m.importance <= 2);
for (const memory of lowPriority) {
if ((Date.now() - memory.createdAt.getTime()) / (1000 * 60 * 60 * 24) > 60) {
// 60日以上古い
await this.db.deleteMemory(memory.id);
report.optimizations.push({
type: "deleted_low_priority",
memoryId: memory.id,
saved: 0.001, // 推定コスト削減
});
}
}
// 2. テキスト圧縮
const toCompress = memories.filter((m) => m.content.length > 500);
for (const memory of toCompress) {
const compressed = await this.compressMemory(memory);
report.optimizations.push({
type: "compressed",
memoryId: memory.id,
originalSize: memory.content.length,
compressedSize: compressed.length,
saved: (memory.content.length - compressed.length) * 0.0001,
});
}
// 3. コスト推定
report.estimatedCost = memories.length * 0.001; // $0.001/memory
report.estimatedCost += memories.reduce(
(sum, m) => sum + m.content.length * 0.00001,
0
); // ストレージコスト
return report;
}
private async compressMemory(memory: MemoryEntry): Promise<string> {
// Claude を使ってメモリをサマリー化
const response = await this.client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 200,
messages: [
{
role: "user",
content: `Summarize this memory entry in a few sentences while preserving all key information:\n\n${memory.content}`,
},
],
});
return response.content[0].type === "text" ? response.content[0].text : memory.content;
}
}
完全な実装例
// services/claude-with-memory.ts
import { Anthropic } from "@anthropic-ai/sdk";
class ClaudeWithMemory {
private client: Anthropic;
private memoryManager: MemoryManager;
private relevanceEngine: RelevanceEngine;
async chat(
userId: string,
userMessage: string
): Promise<{ response: string; memoriesSaved: number }> {
// 1. 関連メモリを取得
const relatedMemories = await this.memoryManager.retrieve(
userId,
userMessage
);
// 2. メモリをコンテキストに組み込む
const context = this.buildContext(relatedMemories);
// 3. Claude に送信
const messages = [
{
role: "user",
content: `${context}\n\nUser message: ${userMessage}`,
},
];
const response = await this.client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 2048,
tools: memoryTools,
messages,
});
// 4. ツール呼び出しを処理
let fullResponse = "";
let memoriesSaved = 0;
for (const block of response.content) {
if (block.type === "text") {
fullResponse = block.text;
} else if (block.type === "tool_use") {
const toolResult = await this.handleMemoryTool(
userId,
block.name,
block.input
);
memoriesSaved += block.name === "save_memory" ? 1 : 0;
}
}
return {
response: fullResponse,
memoriesSaved,
};
}
private buildContext(memories: MemoryEntry[]): string {
if (memories.length === 0) return "";
let context = "## Relevant Context from Your Memory:\n\n";
memories.forEach((memory, index) => {
context += `${index + 1}. [${memory.category}] ${memory.content}\n`;
});
return context;
}
private async handleMemoryTool(
userId: string,
toolName: string,
input: Record<string, any>
): Promise<string> {
const handler = new MemoryMCPHandler();
return handler.handleToolCall(userId, toolName, input);
}
}
公式ドキュメントには書かれていない運用上の落とし穴
ベクトル DB と長期記憶を本番で半年〜1 年運用すると、ドキュメントだけでは予測できない問題に何度かぶつかります。以下は私が実際に踏み抜いた地雷と、その後の運用で落ち着いた対策です。
1. Embedding モデルのバージョン管理
OpenAI / Voyage / Cohere のいずれを使っても、Embedding モデルはおおむね 6〜12 ヶ月で更新されます。古いモデルでベクトル化した記憶と新しいモデルから生成したクエリベクトルは、意味的に同じテキストでもコサイン類似度の分布が大きく崩れます。
実運用で観測している目安:
text-embedding-3-small から text-embedding-3-large へ移行した場合、上位 5 件のヒット集合のうち約 23% が入れ替わる
- 同モデル内のマイナーバージョン更新では 3〜7% 程度の変動で、検索品質への影響は無視できないが致命傷ではない
私が落ち着いた手順は次の通りです:
- メモリのメタデータに
embedding_model と embedding_version を必ず保存する
- 移行時は新旧モデルのベクトルを期間限定(私の場合は 14 日)で並行保持する
- クエリ側で新モデルの結果を優先し、ヒットが少ない場合のみ旧モデルへフォールバック
- 14 日経過した旧ベクトルをバッチ削除し、ストレージを回収する
// lib/embedding-migration.ts
interface EmbeddingMetadata {
embeddingModel: string; // 例: "voyage-3-large"
embeddingVersion: string; // 例: "2025-09"
embeddedAt: Date;
}
async function searchWithFallback(userId: string, query: string) {
const newVec = await embedWithCurrent(query);
const newHits = await vectorDb.search({ userId, vector: newVec, topK: 5, filter: { embeddingModel: CURRENT_MODEL } });
if (newHits.length >= 3) return newHits;
const oldVec = await embedWithLegacy(query);
const oldHits = await vectorDb.search({ userId, vector: oldVec, topK: 5, filter: { embeddingModel: LEGACY_MODEL } });
return [...newHits, ...oldHits].slice(0, 5);
}
2. 「重要度」スコアの腐敗
importance: 1-10 のような数値を Claude に推論させる実装はチュートリアルで頻繁に見ますが、半年運用すると破綻します。Claude のモデル更新によって同じテキストに対するスコア分布が変わってしまうためです。
私が落ち着いた設計は、数値ではなく enum を持たせるパターンです:
ephemeral: 30 日後に自動削除(一時的な質問・タスク・短期的な好み)
standard: 1 年保持(プロジェクト情報・継続的な好み)
critical: ユーザー手動削除のみ(自己紹介・絶対に忘れたら困る情報)
3 段階に絞るだけで、モデル更新の影響をほぼ受けなくなります。さらに副次効果として、ユーザーが「どのレベルで覚えてほしいか」を UI で選びやすくなります。
3. 検索結果の「埋もれ」を防ぐ recency バイアス
ベクトル類似度だけで上位 5 件を取ると、ユーザーの最近の好みが古いメモリに押し負けます。私が個人プロジェクトで半年運用したログを集計すると、topK=5 のうち平均 3.7 件が 3 ヶ月以上前のメモリでした。
// シンプルだが効果的な recency boost
interface VectorMatch {
score: number;
metadata: { createdAt: number };
}
function rerankWithRecency(matches: VectorMatch[]): VectorMatch[] {
const now = Date.now();
return matches
.map(m => {
const ageDays = (now - m.metadata.createdAt) / (1000 * 60 * 60 * 24);
// 30 日以内は 1.0、90 日で約 0.7、365 日で約 0.4 に減衰
const recencyFactor = Math.max(0.4, Math.exp(-ageDays / 180));
return { ...m, score: m.score * recencyFactor };
})
.sort((a, b) => b.score - a.score);
}
この再ランキングを入れた後、ユーザーの好みに対するヒット率は約 28% 改善しました。減衰係数(180)はサービスの性質に応じて調整します。日報的なメモリが中心なら 90、長期プロジェクト中心なら 365 など。
4. メモリ書き込みの「料金感覚」
新規メモリを保存するたびに Embedding API を叩くため、月間アクティブメモリ数 × 単価がそのまま課金されます。私の手元の数字(Voyage AI / voyage-3-large、2026 年 5 月時点):
- 1 ユーザーあたり平均 47 件 / 月の新規メモリ
- 1 件あたり平均 240 トークン
- 1,000 MAU で月間 約 11.3M トークン
- Voyage AI 料金で月 約 $1.5
スケール時のコストはストレージではなく Embedding API が支配項です。Anthropic のコンテキストキャッシュやハイブリッド検索(後述)と組み合わせて embed する量を半分にできれば、料金もそのまま半分になります。
5. レイテンシは「クエリ単体」では測れない
Pinecone Serverless の p50 は公称 30〜60ms ですが、ユーザー体感のレイテンシは複数レイヤーの合算です。私の本番計測値(東京リージョン、1,000 MAU 規模):
| レイヤー | p50 | p95 |
| Embedding API | 80ms | 220ms |
| Vector search | 45ms | 110ms |
| Postgres メタデータ join | 12ms | 35ms |
| 合計 | 約 140ms | 約 370ms |
ユーザー体感のレイテンシを 200ms 以下に抑えたい場合、支配的なのは Embedding API です。よく使うクエリ(「私の好み」「現在のプロジェクト」など)の Embedding をキャッシュすると、p50 を 60ms 程度まで落とせます。
規模別ベクトル DB 選定 — 個人開発から法人スケールまでの実体験的推奨
「Pinecone か pgvector か」の議論はネット上に多くありますが、ここでは私が実際に運用してきた規模別の判断軸を書きます。2014 年からアプリ事業(累計 5,000 万 DL、AdMob 月収 100 万円超期もあり)を続けつつ、Stripe 課金を持つ 4 サイトを並行運営している立場から、「個人開発」「中小 SaaS」「成長期スタートアップ」の 3 層に分けて整理します。
〜1,000 MAU: pgvector(Supabase / Neon)
- Postgres にベクトルカラムを追加するだけで動く
- 別サービスを増やさないので運用負荷ゼロ
- 月額: $0〜$25
- 弱点: ベクトル数が 50 万を超えてくると ANN インデックス(HNSW)のチューニングが必要
新規プロダクトを立ち上げる時はまずここから始めるのがおすすめです。pgvector で動かして、ベクトル数が 50 万を超えてから専用 DB を検討する、という流れにすると意思決定が遅すぎることもありません。
1,000〜50,000 MAU: Pinecone Serverless
- スループット・レイテンシともに安定
- 月額: $50〜$700(ベクトル数次第)
- 採用判断のサイン: pgvector の p95 が 500ms を超えてきた / メモリ数が 500 万を超えた
- 注意: メタデータフィルタリングは強力だが、フィルタが効きすぎるクエリで
topK を増やさないと結果が空になる現象がある
50,000+ MAU: Weaviate / Qdrant 自己ホスト もしくは Pinecone Pod
- 専有リソースが必要になる規模
- 月額: $1,000〜
- DBA / SRE が常駐する想定で、運用設計を別途行う必要があります
個人開発者向けの実用的アドバイス
5,000 万 DL のアプリ群を運営してきた立場で言うと、長期記憶を「全ユーザーに付ける」のではなく「アクティブユーザーだけ・課金ユーザーだけ」に絞るのは費用対効果が非常に高い選択肢です。Stripe メンバーシップを持つ Dolice Labs では Premium プラン会員のみに長期記憶を提供する設計を進めており、課金率と再訪率の両方を引き上げる導線として有望と見ています。
長期記憶は「持つだけ」ではコストですが、ユーザー価値の差別化要素として明確に設計すれば、月額課金の妥当性を支える機能になります。
アプリ運営 12 年で気づいた、長期記憶を設計する判断軸
最後に、技術選定の前段にある「そもそも長期記憶を持たせるべきか」という問いに対して、私が普段考えていることを書きます。
1. プライバシーは「実装」より「ユーザーの納得」が先
2014 年から個人開発を続けてきた中で、ユーザーが本当に不安に思うのは「データが盗まれること」よりも「いつのまにか覚えられていたこと」だと痛感しています。技術的に E2E 暗号化していても、UI に「これを覚えました」という明示がなければ不安は消えません。
実装する前に決めるべきことは次の 3 つです:
- どのタイミングでユーザーに「記憶しました」と伝えるか
- 何を記憶したか、ユーザーがいつでも確認できる UI を提供できるか
- 「全部忘れる」ボタンを目立つ場所に置けるか
技術的な暗号化はその後の話だと考えています。
2. 「忘れる」は機能であり、ユーザー体験そのもの
私の両祖父は宮大工で、「手を動かすことが一つの信心」という空気の中で育ちました。何かを丁寧に作るということは、何を残し何を捨てるかを決め続ける作業でもあると感じています。長期記憶も同じで、「全部覚えておく」のは設計の怠惰です。
私が落ち着いた 3 段階の保持期間は次の通り:
- ephemeral(30 日で自動削除): 質問・タスク・一時的な好み
- standard(1 年保持): プロジェクト情報・継続的な好み
- critical(手動削除のみ): 自己紹介・絶対に忘れたら困る情報
この 3 段階だけで、運用上ほぼ困りません。むしろ「いつ忘れるか」を設計に含めておくことで、ユーザーが安心して新しい情報を AI に渡せるようになります。
3. 「記憶の量」より「記憶の質」を測る指標を持つ
ダウンロード数(5,000 万)を見てきた経験で言うと、KPI は「メモリ件数」ではなく「メモリヒット時のユーザーの行動変化」を測るべきです。
私が見ている指標は次の通り:
- メモリヒット率(クエリのうち 1 件以上の関連メモリが返った割合)
- ヒット後の継続率(次のメッセージを送る確率)
- ユーザーごとのメモリ削除率(多すぎる場合は精度問題のサイン)
メモリ件数だけ伸ばしていくと、ユーザーが「想定外のものを覚えられている」と感じて離脱します。質を測る指標を最初から計測に組み込むことを強くおすすめします。
4. 「離れて暮らす子どもたちに向けて作る」覚悟
技術記事でこの言葉は唐突に響くかもしれませんが、私が個人開発を続ける根底には、離れて暮らす子どもたちにいつか恥ずかしくないものを残したいという感覚があります。今作っているシステムは、自分の子どもたちが将来同じツールを使う可能性があるという前提で設計します。長期記憶のような「ユーザーのアイデンティティに触れる機能」はとくにそうです。
具体的には:
- 18 歳未満のアカウントには長期記憶を付けない
- 親が確認・削除できるダッシュボードを実装する
- 数年後に AI が変わっても、エクスポート機能で記憶を取り出せる形式(JSON Lines + 暗号化キーペア)で保存する
技術的な実装よりも、こうした「数年後の自分が見ても恥ずかしくないか」という基準で設計するのが、長期記憶のような繊細な機能を扱う上で最も大切な判断軸だと感じています。
次のアクション
長期記憶システムを新規に組み込むなら、次の順番がおすすめです。
- まず pgvector + 1 ユーザー分のスキーマで動かす(半日で動きます)
- UI に「記憶しました」表示と「忘れる」ボタンを追加する
- recency boost を導入する
- ベクトル数が 50 万を超えたら Pinecone Serverless へ移行する
- ヒット率・継続率・削除率の計測を入れる
公式ドキュメントだけでは見えない運用知見を、この記事が少しでも補える存在になれば嬉しいです。同じ課題に取り組んでいる方の参考になれば幸いです。