AI Personalization at Scale — User Profiles, Preference Learning, and Context Injection

Sanjeev SharmaSanjeev Sharma
7 min read

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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading