Back to Blog

Building AI Product Features in 2026: Embeddings, Semantic Search UX, and Content Generation

Build AI features users actually love: embeddings-based similarity and recommendations, semantic search with ranking UI, AI content generation with streaming, and evaluation frameworks.

Viprasol Tech Team
September 3, 2026
13 min read

Building AI Product Features in 2026: Embeddings, Semantic Search UX, and Content Generation

Most AI features added to products in 2025โ€“2026 fail not because the technology is wrong, but because the product design is wrong. A chatbot bolted onto an existing product confuses users. Semantic search that returns semantically-similar-but-wrong results damages trust. AI-generated content that's generic and uneditable frustrates writers.

This post covers the implementation patterns for AI features that actually improve the product: embeddings-based similarity and recommendations, semantic search with a trust-building UX, and LLM content generation with streaming and user control.


Embeddings: The Foundation

Embeddings convert text (or any content) into a high-dimensional vector. Items with similar meaning have vectors that are close together in this space. This enables: "find items similar to this," "recommend based on history," and "search by meaning, not exact words."

// src/lib/embeddings.ts
import OpenAI from 'openai';
import { db } from '@/lib/db';

const openai = new OpenAI();

// Batch embedding generation (more efficient than one-at-a-time)
export async function generateEmbeddings(
  texts: string[],
  model = 'text-embedding-3-small',  // 1536 dimensions, cheap and fast
): Promise<number[][]> {
  if (texts.length === 0) return [];

  // OpenAI accepts up to 2048 inputs per request
  const chunks: string[][] = [];
  for (let i = 0; i < texts.length; i += 100) {
    chunks.push(texts.slice(i, i + 100));
  }

  const embeddings: number[][] = [];
  for (const chunk of chunks) {
    const response = await openai.embeddings.create({ model, input: chunk });
    embeddings.push(...response.data.map((d) => d.embedding));
  }

  return embeddings;
}

// Store content embedding in PostgreSQL with pgvector
export async function indexContent(content: {
  id: string;
  text: string;          // The text to embed
  metadata: Record<string, unknown>;
}): Promise<void> {
  const [embedding] = await generateEmbeddings([content.text]);

  await db.query(
    `INSERT INTO content_embeddings (content_id, embedding, metadata, indexed_at)
     VALUES ($1, $2, $3, now())
     ON CONFLICT (content_id) DO UPDATE
     SET embedding = EXCLUDED.embedding,
         metadata  = EXCLUDED.metadata,
         indexed_at = now()`,
    [content.id, JSON.stringify(embedding), JSON.stringify(content.metadata)],
  );
}

Recommendations: "Similar to This"

// src/features/recommendations/similar-items.ts

export async function findSimilarItems(
  contentId: string,
  options: {
    limit?: number;
    minSimilarity?: number;  // 0โ€“1; higher = more similar
    filterCategory?: string;
  } = {},
): Promise<SimilarItem[]> {
  const { limit = 10, minSimilarity = 0.7, filterCategory } = options;

  // Get the source item's embedding
  const { rows: [source] } = await db.query<{ embedding: string }>(
    'SELECT embedding FROM content_embeddings WHERE content_id = $1',
    [contentId],
  );

  if (!source) return [];

  // Find similar items by cosine similarity
  const { rows } = await db.query<SimilarItem>(
    `SELECT
       ce.content_id,
       c.title,
       c.category,
       c.thumbnail_url,
       1 - (ce.embedding <=> $1::vector) AS similarity
     FROM content_embeddings ce
     JOIN content c ON c.id = ce.content_id
     WHERE ce.content_id != $2                          -- Exclude source
       AND ($3::text IS NULL OR c.category = $3)        -- Optional filter
       AND 1 - (ce.embedding <=> $1::vector) >= $4      -- Min similarity threshold
     ORDER BY ce.embedding <=> $1::vector               -- Closest first
     LIMIT $5`,
    [source.embedding, contentId, filterCategory ?? null, minSimilarity, limit],
  );

  return rows;
}

// Personalized recommendations based on user history
export async function getPersonalizedRecommendations(
  userId: string,
  limit = 10,
): Promise<SimilarItem[]> {
  // Get embeddings of items the user recently engaged with
  const { rows: history } = await db.query<{ embedding: string }>(
    `SELECT ce.embedding
     FROM user_interactions ui
     JOIN content_embeddings ce ON ce.content_id = ui.content_id
     WHERE ui.user_id = $1 AND ui.interaction_type = 'view'
     ORDER BY ui.created_at DESC
     LIMIT 10`,
    [userId],
  );

  if (history.length === 0) return getTrendingContent(limit);

  // Average the embeddings to create a user taste profile
  // (Simple but effective: centroid of recent interest vectors)
  const embeddingDim = 1536;
  const avg = new Array(embeddingDim).fill(0);

  for (const { embedding } of history) {
    const vec = JSON.parse(embedding) as number[];
    vec.forEach((v, i) => { avg[i] += v / history.length; });
  }

  // Find content closest to the user's average taste vector
  const { rows } = await db.query<SimilarItem>(
    `SELECT
       ce.content_id,
       c.title,
       c.category,
       1 - (ce.embedding <=> $1::vector) AS similarity
     FROM content_embeddings ce
     JOIN content c ON c.id = ce.content_id
     WHERE ce.content_id NOT IN (
       SELECT content_id FROM user_interactions
       WHERE user_id = $2 AND interaction_type = 'view'
     )
     ORDER BY ce.embedding <=> $1::vector
     LIMIT $3`,
    [JSON.stringify(avg), userId, limit],
  );

  return rows;
}

๐Ÿค– AI Is Not the Future โ€” It Is Right Now

Businesses using AI automation cut manual work by 60โ€“80%. We build production-ready AI systems โ€” RAG pipelines, LLM integrations, custom ML models, and AI agent workflows.

  • LLM integration (OpenAI, Anthropic, Gemini, local models)
  • RAG systems that answer from your own data
  • AI agents that take real actions โ€” not just chat
  • Custom ML models for prediction, classification, detection

Semantic Search UX: Building Trust

Semantic search fails in UX when users can't understand why a result was returned. The trust-building pattern: always explain relevance and provide fallback to keyword search.

// src/features/search/SemanticSearchResults.tsx
'use client';

import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';

interface SearchResult {
  id: string;
  title: string;
  excerpt: string;
  score: number;         // 0โ€“1 similarity score
  matchType: 'semantic' | 'keyword' | 'hybrid';
  highlightedTerms?: string[];  // For keyword component of match
}

export function SemanticSearchResults({ query }: { query: string }) {
  const [showExplanations, setShowExplanations] = useState(false);

  const { data, isLoading } = useQuery({
    queryKey: ['search', query],
    queryFn: () => fetch(`/api/search?q=${encodeURIComponent(query)}`).then((r) => r.json()),
    enabled: query.length > 2,
  });

  if (isLoading) return <SearchSkeleton />;

  return (
    <div>
      {/* Search mode indicator + trust signal */}
      <div className="flex items-center justify-between mb-4">
        <p className="text-sm text-gray-500">
          {data?.total} results for &ldquo;{query}&rdquo;
          {data?.semanticEnabled && ' ยท including conceptually related content'}
        </p>
        <button
          onClick={() => setShowExplanations(!showExplanations)}
          className="text-sm text-blue-600 hover:underline"
        >
          {showExplanations ? 'Hide' : 'Why these results?'}
        </button>
      </div>

      {/* Results */}
      {data?.results.map((result: SearchResult) => (
        <SearchResultCard
          key={result.id}
          result={result}
          showExplanation={showExplanations}
          query={query}
        />
      ))}

      {/* Fallback: didn't find what you expected? */}
      {data?.results.length > 0 && (
        <div className="mt-6 p-4 bg-gray-50 rounded-lg">
          <p className="text-sm text-gray-600">
            Not finding what you expected?{' '}
            <a href={`/search?q=${encodeURIComponent(query)}&mode=exact`}
               className="text-blue-600 hover:underline">
              Search for exact phrase
            </a>
          </p>
        </div>
      )}
    </div>
  );
}

function SearchResultCard({
  result,
  showExplanation,
  query,
}: {
  result: SearchResult;
  showExplanation: boolean;
  query: string;
}) {
  return (
    <div className="border rounded-lg p-4 mb-3 hover:border-blue-200 transition-colors">
      <h3 className="font-semibold text-gray-900">{result.title}</h3>
      <p className="text-sm text-gray-600 mt-1">{result.excerpt}</p>

      {/* Match type badge */}
      <div className="flex items-center gap-2 mt-2">
        <span className={`text-xs px-2 py-0.5 rounded-full ${
          result.matchType === 'keyword' ? 'bg-green-100 text-green-700' :
          result.matchType === 'semantic' ? 'bg-purple-100 text-purple-700' :
          'bg-blue-100 text-blue-700'
        }`}>
          {result.matchType === 'keyword' ? 'Exact match' :
           result.matchType === 'semantic' ? 'Related topic' :
           'Best match'}
        </span>

        {/* Relevance explanation (when requested) */}
        {showExplanation && result.matchType === 'semantic' && (
          <span className="text-xs text-gray-500">
            Conceptually similar to &ldquo;{query}&rdquo;
            ({Math.round(result.score * 100)}% relevance)
          </span>
        )}
      </div>
    </div>
  );
}

AI Content Generation with Streaming

// src/features/writing/AISuggestionPanel.tsx
'use client';

import { useState, useCallback } from 'react';

interface GeneratedContent {
  text: string;
  isComplete: boolean;
}

export function AISuggestionPanel({
  existingContent,
  onAccept,
}: {
  existingContent: string;
  onAccept: (text: string) => void;
}) {
  const [generated, setGenerated] = useState<GeneratedContent | null>(null);
  const [isGenerating, setIsGenerating] = useState(false);
  const [prompt, setPrompt] = useState('');

  const generateContent = useCallback(async () => {
    if (!prompt.trim()) return;
    setIsGenerating(true);
    setGenerated({ text: '', isComplete: false });

    try {
      const response = await fetch('/api/ai/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt, existingContent }),
      });

      if (!response.body) throw new Error('No response body');

      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value);
        // Parse SSE format: "data: <text>\n\n"
        const lines = chunk.split('\n');
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const text = line.slice(6);
            if (text === '[DONE]') {
              setGenerated((prev) => prev ? { ...prev, isComplete: true } : null);
            } else {
              setGenerated((prev) => prev
                ? { ...prev, text: prev.text + text }
                : { text, isComplete: false }
              );
            }
          }
        }
      }
    } catch (err) {
      console.error('Generation failed:', err);
      setGenerated(null);
    } finally {
      setIsGenerating(false);
    }
  }, [prompt, existingContent]);

  return (
    <div className="border rounded-lg p-4 bg-purple-50">
      <h3 className="font-medium text-purple-900 mb-3">โœจ AI Writing Assistant</h3>

      <div className="flex gap-2 mb-4">
        <input
          type="text"
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && generateContent()}
          placeholder='e.g., "Make this more concise" or "Add a call to action"'
          className="flex-1 text-sm border rounded px-3 py-2"
          disabled={isGenerating}
        />
        <button
          onClick={generateContent}
          disabled={isGenerating || !prompt.trim()}
          className="btn-primary text-sm"
        >
          {isGenerating ? 'Generating...' : 'Generate'}
        </button>
      </div>

      {generated && (
        <div className="bg-white border rounded p-3 mb-3">
          <p className="text-sm text-gray-800 whitespace-pre-wrap">
            {generated.text}
            {!generated.isComplete && (
              <span className="inline-block w-1 h-4 bg-purple-500 ml-0.5 animate-pulse" />
            )}
          </p>
        </div>
      )}

      {generated?.isComplete && (
        <div className="flex gap-2">
          <button
            onClick={() => onAccept(generated.text)}
            className="btn-primary text-sm"
          >
            Use this
          </button>
          <button
            onClick={() => setGenerated(null)}
            className="btn-secondary text-sm"
          >
            Discard
          </button>
          <button
            onClick={generateContent}
            className="text-sm text-purple-600 hover:underline"
          >
            Try again
          </button>
        </div>
      )}
    </div>
  );
}

Streaming API Route

// src/api/ai/generate.ts (Next.js App Router)
import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic();

export async function POST(req: Request): Promise<Response> {
  const { prompt, existingContent } = await req.json();

  const stream = anthropic.messages.stream({
    model: 'claude-haiku-4-5',  // Fast model for UI streaming
    max_tokens: 1024,
    messages: [
      {
        role: 'user',
        content: `You are a writing assistant. Here is the existing content:

<content>
${existingContent}
</content>

Task: ${prompt}

Provide only the improved/new text. No explanations or preamble.`,
      },
    ],
  });

  // Return SSE stream
  const encoder = new TextEncoder();
  const readable = new ReadableStream({
    async start(controller) {
      for await (const chunk of stream) {
        if (
          chunk.type === 'content_block_delta' &&
          chunk.delta.type === 'text_delta'
        ) {
          controller.enqueue(
            encoder.encode(`data: ${chunk.delta.text}\n\n`),
          );
        }
      }
      controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      controller.close();
    },
  });

  return new Response(readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

โšก Your Competitors Are Already Using AI โ€” Are You?

We build AI systems that actually work in production โ€” not demos that die in a Colab notebook. From data pipeline to deployed model to real business outcomes.

  • AI agent systems that run autonomously โ€” not just chatbots
  • Integrates with your existing tools (CRM, ERP, Slack, etc.)
  • Explainable outputs โ€” know why the model decided what it did
  • Free AI opportunity audit for your business

Evaluation: Knowing If Your AI Feature Works

// src/__tests__/ai/search-quality.test.ts
// Offline evaluation: does semantic search return expected results?

describe('Semantic search quality', () => {
  const testCases = [
    {
      query: 'how to reset my password',
      expectedTopSlug: 'password-reset-guide',
      description: 'Direct intent match',
    },
    {
      query: 'cant login to my account',
      expectedTopSlug: 'login-troubleshooting',
      description: 'Typo + synonym match',
    },
    {
      query: 'billing credit card update',
      expectedTopSlug: 'update-payment-method',
      description: 'Semantic near-match',
    },
  ];

  for (const { query, expectedTopSlug, description } of testCases) {
    it(`finds "${expectedTopSlug}" for query: "${query}" (${description})`, async () => {
      const results = await searchContent(query, { limit: 5 });
      const topResult = results[0];
      expect(topResult.slug).toBe(expectedTopSlug);
    });
  }
});

Working With Viprasol

We build AI product features that improve user experience โ€” from embeddings infrastructure through semantic search UX, recommendations, and streaming content generation.

What we deliver:

  • pgvector embeddings pipeline for content indexing and similarity search
  • Personalized recommendation engine based on user interaction history
  • Semantic search implementation with trust-building UX patterns
  • LLM content generation with streaming and user editing controls
  • Offline evaluation framework for AI feature quality testing

โ†’ Discuss your AI product features โ†’ AI and machine learning services


Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Want to Implement AI in Your Business?

From chatbots to predictive models โ€” harness the power of AI with a team that delivers.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Ready to automate your business with AI agents?

We build custom multi-agent AI systems that handle sales, support, ops, and content โ€” across Telegram, WhatsApp, Slack, and 20+ other platforms. We run our own business on these systems.