AI Personalization at Scale — User Profiles, Preference Learning, and Context Injection
Advertisement
Introduction
Generic AI responses lose users fast. Personalization — tailoring LLM outputs to individual preferences, history, and context — is the difference between a product people return to and one they abandon after the first session. This post covers the architecture of a production AI personalization system: user profile stores, embedding-based preference learning, context injection strategies, cold-start handling, and privacy constraints.
Why This Matters
Studies consistently show that personalized recommendations drive 20-35% higher engagement than generic ones. For AI-powered products, personalization means the model knows the user's expertise level, preferred communication style, past decisions, and domain context — without requiring the user to re-explain themselves every session.
The engineering challenge is doing this at scale, in real-time, without bloating every prompt with megabytes of user history.
User Profile Store
Build a lightweight profile store that summarizes user preferences:
interface UserProfile {
userId: string;
expertiseLevel: 'beginner' | 'intermediate' | 'expert';
preferredTone: 'formal' | 'casual' | 'technical';
interests: string[];
recentTopics: string[];
interactionCount: number;
lastActive: string;
customInstructions?: string;
}
class UserProfileStore {
private profiles = new Map<string, UserProfile>();
upsert(profile: UserProfile): void {
this.profiles.set(profile.userId, profile);
}
get(userId: string): UserProfile | null {
return this.profiles.get(userId) ?? null;
}
recordInteraction(userId: string, topic: string): void {
const profile = this.profiles.get(userId);
if (!profile) return;
profile.recentTopics = [topic, ...profile.recentTopics].slice(0, 10);
profile.interactionCount++;
profile.lastActive = new Date().toISOString();
// Promote expertise after sufficient interactions
if (profile.interactionCount > 100 && profile.expertiseLevel === 'beginner') {
profile.expertiseLevel = 'intermediate';
}
}
buildSystemPromptContext(userId: string): string {
const profile = this.profiles.get(userId);
if (!profile) return '';
return [
`User expertise: ${profile.expertiseLevel}`,
`Preferred tone: ${profile.preferredTone}`,
`Recent interests: ${profile.recentTopics.slice(0, 5).join(', ')}`,
profile.customInstructions ? `User instructions: ${profile.customInstructions}` : '',
].filter(Boolean).join('\n');
}
}Embedding-Based Preference Learning
Use vector embeddings to capture implicit preferences from interaction history:
interface InteractionEvent {
userId: string;
contentId: string;
contentEmbedding: number[];
engagement: 'click' | 'read' | 'save' | 'share' | 'skip';
dwellTimeSeconds: number;
}
interface UserPreferenceVector {
userId: string;
preferenceEmbedding: number[];
lastUpdated: string;
sampleCount: number;
}
class PreferenceLearner {
private preferences = new Map<string, UserPreferenceVector>();
private readonly ENGAGEMENT_WEIGHTS: Record<string, number> = {
skip: -0.5,
click: 0.3,
read: 0.6,
save: 0.9,
share: 1.0,
};
updatePreference(event: InteractionEvent): void {
const weight = this.ENGAGEMENT_WEIGHTS[event.engagement] ?? 0;
const dwellWeight = Math.min(event.dwellTimeSeconds / 60, 1.0); // cap at 1 min
const totalWeight = weight * (1 + dwellWeight);
const existing = this.preferences.get(event.userId);
if (!existing) {
this.preferences.set(event.userId, {
userId: event.userId,
preferenceEmbedding: event.contentEmbedding.map((v) => v * totalWeight),
lastUpdated: new Date().toISOString(),
sampleCount: 1,
});
return;
}
// Exponential moving average with decay factor 0.95
const DECAY = 0.95;
existing.preferenceEmbedding = existing.preferenceEmbedding.map(
(v, i) => DECAY * v + (1 - DECAY) * event.contentEmbedding[i] * totalWeight
);
existing.sampleCount++;
existing.lastUpdated = new Date().toISOString();
}
getPreferenceEmbedding(userId: string): number[] | null {
return this.preferences.get(userId)?.preferenceEmbedding ?? null;
}
rankContentByPreference(userId: string, candidates: Array<{ id: string; embedding: number[] }>) {
const pref = this.getPreferenceEmbedding(userId);
if (!pref) return candidates; // cold start: return unranked
return candidates
.map((c) => ({ ...c, score: this.dot(pref, c.embedding) }))
.sort((a, b) => b.score - a.score);
}
private dot(a: number[], b: number[]): number {
return a.reduce((sum, v, i) => sum + v * b[i], 0);
}
}Context Injection into LLM Prompts
Inject the right amount of user context without bloating the prompt:
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
interface PersonalizedRequest {
userId: string;
userMessage: string;
taskType: 'chat' | 'summarize' | 'recommend' | 'explain';
}
class PersonalizedLLMService {
constructor(
private profileStore: UserProfileStore,
private preferenceStore: PreferenceLearner,
) {}
async respond(req: PersonalizedRequest): Promise<string> {
const profileContext = this.profileStore.buildSystemPromptContext(req.userId);
const taskInstruction = this.getTaskInstruction(req.taskType);
// Keep context compact — max 300 tokens of user context
const systemPrompt = [
taskInstruction,
profileContext ? `\nUser context:\n${profileContext}` : '',
].join('');
const response = await client.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
system: systemPrompt,
messages: [{ role: 'user', content: req.userMessage }],
});
// Record interaction for preference learning
this.profileStore.recordInteraction(req.userId, req.userMessage.slice(0, 50));
return response.content[0].type === 'text' ? response.content[0].text : '';
}
private getTaskInstruction(taskType: string): string {
const instructions: Record<string, string> = {
chat: 'You are a helpful assistant. Adapt your tone and depth to the user context below.',
summarize: 'Summarize content at the expertise level indicated in user context.',
recommend: 'Make recommendations tailored to the interests listed in user context.',
explain: 'Explain concepts at the expertise level indicated in user context.',
};
return instructions[taskType] ?? instructions.chat;
}
}Cold Start Handling
New users have no history. Use onboarding signals and defaults gracefully:
interface OnboardingAnswers {
role: string;
experienceYears: number;
primaryGoal: string;
preferredStyle: 'detailed' | 'concise';
}
function buildColdStartProfile(userId: string, answers: OnboardingAnswers): UserProfile {
const expertiseLevel: UserProfile['expertiseLevel'] =
answers.experienceYears < 2 ? 'beginner' :
answers.experienceYears < 5 ? 'intermediate' : 'expert';
return {
userId,
expertiseLevel,
preferredTone: answers.preferredStyle === 'concise' ? 'casual' : 'formal',
interests: [answers.primaryGoal],
recentTopics: [],
interactionCount: 0,
lastActive: new Date().toISOString(),
customInstructions: `User role: ${answers.role}. Goal: ${answers.primaryGoal}.`,
};
}
// For users who skip onboarding, use a neutral default
function buildDefaultProfile(userId: string): UserProfile {
return {
userId,
expertiseLevel: 'intermediate',
preferredTone: 'casual',
interests: [],
recentTopics: [],
interactionCount: 0,
lastActive: new Date().toISOString(),
};
}Privacy-Preserving Personalization
Minimize stored PII while still enabling personalization:
interface PrivacyCompliantProfile {
userId: string; // pseudonymous ID, not email or name
preferenceVector: number[]; // embedding, not raw text
topicCluster: string; // cluster ID, not specific queries
sessionCount: number;
consentVersion: string;
}
class PrivacyPreservingStore {
// Store embeddings, not raw text
storePreference(userId: string, embedding: number[], consentVersion: string): void {
// Never store: query text, PII, exact browsing history
const profile: PrivacyCompliantProfile = {
userId,
preferenceVector: embedding,
topicCluster: this.assignCluster(embedding),
sessionCount: 1,
consentVersion,
};
// Persist to encrypted store
console.log('Stored privacy-compliant profile:', profile.userId);
}
deleteUserData(userId: string): void {
// GDPR right to erasure
console.log(`Deleted all data for user: ${userId}`);
}
private assignCluster(embedding: number[]): string {
// k-means cluster assignment — stores cluster ID, not content
const clusterIndex = Math.floor(Math.abs(embedding[0]) * 10) % 10;
return `cluster-${clusterIndex}`;
}
}Common Mistakes
- Injecting full conversation history: Including every past session balloons the context window and degrades performance. Summarize history into a compact profile.
- Personalizing without consent: Storing behavioral data without explicit consent violates GDPR and CCPA. Always gate personalization on user consent.
- No preference decay: Old interests dominate the preference vector over time. Apply exponential decay so recent behavior has higher weight.
- Hard-coding expertise thresholds: Promoting a user from beginner to expert after N interactions is brittle. Use behavioral signals (question depth, vocabulary) instead.
- Single preference vector per user: Users have context-dependent preferences. A user who is a developer at work may want casual explanations for personal use.
Best Practices
- Keep injected context under 300 tokens per request to avoid crowding out the user's actual message.
- Store preference embeddings, not raw text, to minimize PII exposure in your vector store.
- Use exponential moving average (decay 0.9-0.95) to update preference vectors so recent signals dominate stale history.
- Implement a right-to-erasure endpoint that deletes all user personalization data to comply with GDPR Article 17.
- A/B test personalization variants: measure whether your personalization actually improves task completion, not just engagement.
- Always provide an explicit "reset preferences" option in the product UI so users can escape a bad personalization loop.
Key Takeaways
- AI personalization requires three data structures: a user profile (discrete attributes), a preference vector (learned from behavior), and recent interaction history (for short-term context).
- Context injection must be selective — inject only the most relevant subset of a user's profile to keep prompt size under control.
- Cold-start is handled by short onboarding questionnaires or neutral defaults, not by waiting for enough interaction data before showing any personalization.
- Preference embeddings computed via exponential moving average over engagement-weighted content embeddings outperform simple click counts.
- Privacy-compliant personalization stores embeddings and cluster IDs, never raw query text or personally identifiable information.
- Preference decay is not optional — without it, a user who read about Python years ago will keep receiving Python recommendations forever.
- GDPR right-to-erasure must delete not just profile records but also preference vectors, interaction logs, and any derived embeddings for that user ID.
- Measuring personalization effectiveness requires controlled A/B experiments, not just watching aggregate engagement metrics go up.
Advertisement