Przejdź do treści
Technologie

RAG + Postgres (pgvector) w e-commerce - Next.js + .NET

Opublikowano:
·Autor: MDS Software Solutions Group
RAG + Postgres (pgvector) w e-commerce - Next.js + .NET

RAG + Postgres (pgvector) w e-commerce: Next.js + .NET

Sztuczna inteligencja zmienia oblicze e-commerce, a RAG (Retrieval-Augmented Generation) w połączeniu z bazami wektorowymi otwiera nowe możliwości wyszukiwania, rekomendacji i obsługi klienta. W tym artykule pokażemy, jak zbudować system RAG oparty na PostgreSQL z rozszerzeniem pgvector, zintegrowany z Next.js i .NET API.

Po co RAG w e-commerce?

Tradycyjne wyszukiwanie pełnotekstowe (full-text search) ma swoje granice. Użytkownicy często szukają produktów używając naturalnego języka, synonimów czy opisów funkcji, zamiast dokładnych nazw. RAG rozwiązuje ten problem, oferując:

1. Wyszukiwanie semantyczne produktów

Zamiast dopasowania słów kluczowych, system rozumie intencję użytkownika:

  • "tanie buty do biegania" → znajduje produkty opisane jako "ekonomiczne obuwie sportowe"
  • "laptop do programowania" → dopasowuje parametry (RAM, procesor) bez dosłownych fraz
  • "prezent dla mamy" → sugeruje kategorie na podstawie kontekstu

2. Podobne produkty ("Similar Items")

Wektorowa reprezentacja produktów umożliwia znalezienie rzeczywiście podobnych itemów - nie tylko po kategorii, ale na podstawie semantycznego podobieństwa opisów, parametrów i recenzji.

3. Inteligentne FAQ i Q&A o zamówieniach

RAG pozwala na budowę chatbota, który:

  • Odpowiada na pytania o status zamówienia na podstawie danych z bazy
  • Generuje odpowiedzi FAQ używając wiedzy z dokumentacji i historii
  • Redukuje obciążenie supportu przez automatyczne odpowiedzi

4. Personalizowane rekomendacje

Embeddingi pozwalają na zaawansowane rekomendacje uwzględniające historię przeglądania, preferencje i kontekst zakupowy.

Architektura systemu

Nasz system składa się z czterech głównych komponentów:

Frontend: Next.js 15 App Router

  • Server Components dla SEO i wydajności
  • API Routes do komunikacji z backendem
  • Streaming UI dla progresywnego wyświetlania wyników

Backend: .NET Minimal API

  • Generowanie embeddingów (OpenAI/Azure/Local)
  • Ranking i reranking wyników
  • Orchestracja zapytań do bazy

Baza danych: PostgreSQL 16 + pgvector

  • Przechowywanie wektorów embeddingów
  • HNSW index dla szybkiego wyszukiwania
  • Hybrid search (BM25 + vector)

Cache: Redis

  • Buforowanie embeddingów zapytań
  • Przechowywanie wyników wyszukiwania
  • Session storage dla chatbota
┌─────────────┐      ┌─────────────────┐      ┌──────────────────┐
│  Next.js    │─────▶│  .NET API       │─────▶│  PostgreSQL      │
│  (UI/SSR)   │      │  (Embeddings)   │      │  + pgvector      │
└─────────────┘      └─────────────────┘      └──────────────────┘
       │                      │                         │
       │                      ▼                         │
       │              ┌─────────────┐                   │
       └─────────────▶│   Redis     │◀──────────────────┘
                      │   (Cache)   │
                      └─────────────┘

Implementacja krok po kroku

Krok 1: Konfiguracja PostgreSQL + pgvector

Zainstaluj rozszerzenie pgvector w PostgreSQL 16:

-- Włącz rozszerzenie pgvector
CREATE EXTENSION IF NOT EXISTS vector;

-- Tabela produktów z embeddingami
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    category VARCHAR(100),
    price DECIMAL(10, 2),
    metadata JSONB,
    -- Wektor embeddings (OpenAI ada-002 = 1536 wymiarów)
    embedding vector(1536),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Index HNSW dla szybkiego wyszukiwania wektorowego
-- m = liczba połączeń (16-64, wyższe = lepsza jakość, wolniejsze budowanie)
-- ef_construction = rozmiar dynamicznej listy (64-200, wyższe = lepsza jakość)
CREATE INDEX ON products 
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- Index dla hybrid search (tekst + wektor)
CREATE INDEX ON products USING GIN(to_tsvector('english', name || ' ' || description));

-- Funkcja do aktualizacji timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER update_products_updated_at 
    BEFORE UPDATE ON products 
    FOR EACH ROW 
    EXECUTE FUNCTION update_updated_at_column();

Krok 2: Pipeline generowania embeddingów

W .NET API tworzymy serwis do generowania i zarządzania embeddingami:

// EmbeddingService.cs
using Azure.AI.OpenAI;
using Npgsql;
using System.Text.Json;

public class EmbeddingService
{
    private readonly OpenAIClient _openAIClient;
    private readonly IConfiguration _config;
    private readonly ILogger<EmbeddingService> _logger;
    private readonly string _connectionString;
    
    public EmbeddingService(OpenAIClient openAIClient, IConfiguration config, ILogger<EmbeddingService> logger)
    {
        _openAIClient = openAIClient;
        _config = config;
        _logger = logger;
        _connectionString = config.GetConnectionString("PostgreSQL");
    }
    
    // Generowanie embeddings dla pojedynczego tekstu
    public async Task<float[]> GenerateEmbeddingAsync(string text)
    {
        var options = new EmbeddingsOptions("text-embedding-ada-002", new[] { text });
        var response = await _openAIClient.GetEmbeddingsAsync(options);
        
        return response.Value.Data[0].Embedding.ToArray();
    }
    
    // Batch processing dla wielu produktów
    public async Task GenerateProductEmbeddingsAsync(int batchSize = 100)
    {
        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync();
        
        // Znajdź produkty bez embeddingów
        var query = @"
            SELECT id, name, description, category 
            FROM products 
            WHERE embedding IS NULL 
            LIMIT @batchSize";
            
        await using var cmd = new NpgsqlCommand(query, conn);
        cmd.Parameters.AddWithValue("batchSize", batchSize);
        
        await using var reader = await cmd.ExecuteReaderAsync();
        var products = new List<(int id, string text)>();
        
        while (await reader.ReadAsync())
        {
            var id = reader.GetInt32(0);
            var name = reader.GetString(1);
            var description = reader.IsDBNull(2) ? "" : reader.GetString(2);
            var category = reader.IsDBNull(3) ? "" : reader.GetString(3);
            
            // Połącz informacje o produkcie w jeden tekst
            var combinedText = $"{name}. {description}. Kategoria: {category}";
            products.Add((id, combinedText));
        }
        
        await reader.CloseAsync();
        
        // Generuj embeddingi dla każdego produktu
        foreach (var (id, text) in products)
        {
            try
            {
                var embedding = await GenerateEmbeddingAsync(text);
                
                // Zapisz embedding do bazy
                await UpdateProductEmbeddingAsync(conn, id, embedding);
                
                // Rate limiting - OpenAI ma limity 3000 RPM
                await Task.Delay(20); // ~50 req/sec
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error generating embedding for product {ProductId}", id);
            }
        }
    }
    
    private async Task UpdateProductEmbeddingAsync(
        NpgsqlConnection conn, 
        int productId, 
        float[] embedding)
    {
        var updateQuery = "UPDATE products SET embedding = @embedding WHERE id = @id";
        await using var cmd = new NpgsqlCommand(updateQuery, conn);
        cmd.Parameters.AddWithValue("id", productId);
        cmd.Parameters.AddWithValue("embedding", embedding);
        
        await cmd.ExecuteNonQueryAsync();
    }
}

Krok 3: Endpoint wyszukiwania semantycznego

Zbuduj Minimal API endpoint do wyszukiwania:

// Program.cs - Minimal API
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// Dodaj serwisy
builder.Services.AddSingleton<OpenAIClient>(sp => 
    new OpenAIClient(builder.Configuration["OpenAI:ApiKey"]));
builder.Services.AddScoped<EmbeddingService>();
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration["Redis:Connection"];
});

var app = builder.Build();

// Endpoint wyszukiwania semantycznego
app.MapPost("/api/search", async (
    [FromBody] SearchRequest request,
    [FromServices] EmbeddingService embeddingService,
    [FromServices] IDistributedCache cache,
    [FromServices] IConfiguration config) =>
{
    try
    {
        // 1. Sprawdź cache
        var cacheKey = $"search:{request.Query}:{request.Limit}";
        var cachedResult = await cache.GetStringAsync(cacheKey);
        
        if (!string.IsNullOrEmpty(cachedResult))
        {
            return Results.Ok(JsonSerializer.Deserialize<SearchResponse>(cachedResult));
        }
        
        // 2. Generuj embedding dla zapytania
        var queryEmbedding = await embeddingService.GenerateEmbeddingAsync(request.Query);
        
        // 3. Wyszukaj podobne produkty w PostgreSQL
        var connectionString = config.GetConnectionString("PostgreSQL");
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();
        
        // Vector search z operatorem <-> (cosine distance)
        var query = @"
            SELECT 
                id,
                name,
                description,
                price,
                category,
                metadata,
                1 - (embedding <-> @queryEmbedding) as similarity
            FROM products
            WHERE embedding IS NOT NULL
            ORDER BY embedding <-> @queryEmbedding
            LIMIT @limit";
        
        await using var cmd = new NpgsqlCommand(query, conn);
        cmd.Parameters.AddWithValue("queryEmbedding", queryEmbedding);
        cmd.Parameters.AddWithValue("limit", request.Limit);
        
        var results = new List<ProductResult>();
        await using var reader = await cmd.ExecuteReaderAsync();
        
        while (await reader.ReadAsync())
        {
            results.Add(new ProductResult
            {
                Id = reader.GetInt32(0),
                Name = reader.GetString(1),
                Description = reader.IsDBNull(2) ? null : reader.GetString(2),
                Price = reader.GetDecimal(3),
                Category = reader.IsDBNull(4) ? null : reader.GetString(4),
                Metadata = reader.IsDBNull(5) ? null : 
                    JsonSerializer.Deserialize<Dictionary<string, object>>(reader.GetString(5)),
                Similarity = reader.GetFloat(6)
            });
        }
        
        var response = new SearchResponse { Results = results };
        
        // 4. Zapisz w cache (5 minut)
        await cache.SetStringAsync(
            cacheKey, 
            JsonSerializer.Serialize(response),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }
        );
        
        return Results.Ok(response);
    }
    catch (Exception ex)
    {
        return Results.Problem($"Search failed: {ex.Message}");
    }
});

app.Run();

// DTOs
public record SearchRequest(string Query, int Limit = 20);
public record SearchResponse { public List<ProductResult> Results { get; set; } }
public record ProductResult
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string? Description { get; set; }
    public decimal Price { get; set; }
    public string? Category { get; set; }
    public Dictionary<string, object>? Metadata { get; set; }
    public float Similarity { get; set; }
}

Krok 4: Integracja z Next.js

W Next.js tworzymy API route i komponent wyszukiwania:

// app/api/products/search/route.ts
import { NextRequest, NextResponse } from 'next/server';

const DOTNET_API_URL = process.env.DOTNET_API_URL || 'http://localhost:5000';

export async function POST(request: NextRequest) {
  try {
    const { query, limit = 20 } = await request.json();
    
    if (!query || typeof query !== 'string') {
      return NextResponse.json(
        { error: 'Query is required' },
        { status: 400 }
      );
    }
    
    // Wywołaj .NET API
    const response = await fetch(`${DOTNET_API_URL}/api/search`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ query, limit }),
    });
    
    if (!response.ok) {
      throw new Error(`API error: ${response.statusText}`);
    }
    
    const data = await response.json();
    
    return NextResponse.json(data);
  } catch (error) {
    console.error('Search error:', error);
    return NextResponse.json(
      { error: 'Search failed' },
      { status: 500 }
    );
  }
}
// components/semantic-search.tsx
'use client';

import { useState } from 'react';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

interface Product {
  id: number;
  name: string;
  description?: string;
  price: number;
  category?: string;
  similarity: number;
}

export function SemanticSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Product[]>([]);
  const [loading, setLoading] = useState(false);
  
  const handleSearch = async () => {
    if (!query.trim()) return;
    
    setLoading(true);
    try {
      const response = await fetch('/api/products/search', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, limit: 20 }),
      });
      
      if (!response.ok) throw new Error('Search failed');
      
      const data = await response.json();
      setResults(data.results || []);
    } catch (error) {
      console.error('Search error:', error);
      // Handle error (show toast, etc.)
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div className="w-full max-w-4xl mx-auto">
      <div className="flex gap-2 mb-6">
        <Input
          type="text"
          placeholder="Szukaj produktów naturalnym językiem..."
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
          className="flex-1"
        />
        <Button onClick={handleSearch} disabled={loading}>
          <Search className="h-4 w-4 mr-2" />
          {loading ? 'Szukam...' : 'Szukaj'}
        </Button>
      </div>
      
      {results.length > 0 && (
        <div className="grid gap-4 md:grid-cols-2">
          {results.map((product) => (
            <div
              key={product.id}
              className="p-4 border rounded-lg hover:shadow-md transition-shadow"
            >
              <div className="flex justify-between items-start mb-2">
                <h3 className="font-semibold">{product.name}</h3>
                <span className="text-sm text-muted-foreground">
                  {(product.similarity * 100).toFixed(0)}% match
                </span>
              </div>
              {product.description && (
                <p className="text-sm text-muted-foreground mb-2">
                  {product.description}
                </p>
              )}
              <div className="flex justify-between items-center">
                <span className="text-lg font-bold">{product.price.toFixed(2)} zł</span>
                {product.category && (
                  <span className="text-xs bg-secondary px-2 py-1 rounded">
                    {product.category}
                  </span>
                )}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Strategie aktualizacji embeddingów

Embeddingi muszą być na bieżąco, gdy zmieniają się dane produktów:

1. Triggerowe aktualizacje

-- Trigger do oznaczania produktów do ponownego embedowania
CREATE OR REPLACE FUNCTION mark_for_reembedding()
RETURNS TRIGGER AS $$
BEGIN
    -- Jeśli zmieniono name, description lub category, wyzeruj embedding
    IF (NEW.name != OLD.name OR 
        NEW.description != OLD.description OR 
        NEW.category != OLD.category) THEN
        NEW.embedding = NULL;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER products_reembed_trigger
    BEFORE UPDATE ON products
    FOR EACH ROW
    EXECUTE FUNCTION mark_for_reembedding();

2. Batch processing z kolejką

Użyj Redis Queue lub Background Job dla asynchronicznego przetwarzania:

// BackgroundEmbeddingService.cs
public class BackgroundEmbeddingService : BackgroundService
{
    private readonly IServiceProvider _services;
    private readonly ILogger<BackgroundEmbeddingService> _logger;
    
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _services.CreateScope();
            var embeddingService = scope.ServiceProvider.GetRequiredService<EmbeddingService>();
            
            try
            {
                // Przetwarzaj 100 produktów co 5 minut
                await embeddingService.GenerateProductEmbeddingsAsync(100);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error in background embedding service");
            }
            
            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }
}

Hybrid Search: BM25 + Vector

Dla najlepszych wyników, połącz wyszukiwanie pełnotekstowe z wektorowym:

-- Hybrid search z wagami
WITH vector_results AS (
    SELECT 
        id,
        name,
        description,
        price,
        (1 - (embedding <-> @queryEmbedding)) * 0.7 as vector_score
    FROM products
    WHERE embedding IS NOT NULL
    ORDER BY embedding <-> @queryEmbedding
    LIMIT 50
),
text_results AS (
    SELECT 
        id,
        ts_rank(to_tsvector('english', name || ' ' || description), 
                plainto_tsquery('english', @queryText)) * 0.3 as text_score
    FROM products
    WHERE to_tsvector('english', name || ' ' || description) @@ 
          plainto_tsquery('english', @queryText)
)
SELECT 
    p.id,
    p.name,
    p.description,
    p.price,
    COALESCE(v.vector_score, 0) + COALESCE(t.text_score, 0) as combined_score
FROM products p
LEFT JOIN vector_results v ON p.id = v.id
LEFT JOIN text_results t ON p.id = t.id
WHERE v.id IS NOT NULL OR t.id IS NOT NULL
ORDER BY combined_score DESC
LIMIT 20;

UX i optymalizacja kosztów

Latency (p95)

  • Target: < 500ms dla 95% zapytań
  • Cache hit rate: > 60% (Redis)
  • HNSW ef_search: 40-100 (wyższe = lepsza jakość, wolniejsze)

Koszty embeddingów

Dla OpenAI text-embedding-ada-002:

  • Cena: $0.0001 / 1K tokenów
  • Przykład: 10,000 produktów × 200 tokenów średnio = 2M tokenów = $0.20
  • Aktualizacje: ~$0.01 dziennie przy 5% produktów zmienianych

Bezpieczeństwo danych

  1. Szyfrowanie: PostgreSQL SSL, encrypted backups
  2. Access control: Role-based access (RBAC) w .NET
  3. Rate limiting: Max 100 req/minute per user
  4. Input sanitization: Walidacja zapytań, max 500 znaków

Checklist wdrożeniowa

  • [ ] Zainstaluj PostgreSQL 16 + pgvector
  • [ ] Utwórz tabele i indeksy (HNSW)
  • [ ] Skonfiguruj .NET API z OpenAI Client
  • [ ] Zaimplementuj EmbeddingService
  • [ ] Uruchom batch processing dla istniejących produktów
  • [ ] Skonfiguruj Redis cache
  • [ ] Zbuduj Next.js API routes
  • [ ] Dodaj UI komponent wyszukiwania
  • [ ] Ustaw background job dla aktualizacji
  • [ ] Testuj latency i accuracy
  • [ ] Monitoruj koszty i użycie API
  • [ ] Dodaj logging i metryki (Application Insights / Grafana)

Przykładowe zastosowania

1. FAQ Chatbot

Embeddingi dokumentacji + RAG = inteligentny bot odpowiadający na pytania klientów.

2. Rekomendacje "Może Ci się spodobać"

-- Produkty podobne do ID=123
SELECT id, name, price,
       1 - (embedding <-> (SELECT embedding FROM products WHERE id = 123)) as similarity
FROM products
WHERE id != 123 AND embedding IS NOT NULL
ORDER BY embedding <-> (SELECT embedding FROM products WHERE id = 123)
LIMIT 6;

3. Automatyczna kategoryzacja

Nowe produkty można automatycznie kategoryzować porównując ich embeddingi z istniejącymi kategoriami.

Podsumowanie

RAG z PostgreSQL pgvector oferuje potężne możliwości dla e-commerce:

  • Wyszukiwanie semantyczne zamiast keyword matching
  • Skalowalne do milionów produktów (HNSW index)
  • Niskie koszty w porównaniu do managed vector DB
  • Łatwa integracja z istniejącym stackiem Next.js + .NET

Gotowy na wdrożenie? Skontaktuj się z nami dla audytu wyszukiwania lub proof-of-concept w 1-2 tygodnie.

Dalsze zasoby

Autor
MDS Software Solutions Group

Zespół ekspertów programistycznych specjalizujących się w nowoczesnych technologiach webowych.

RAG + Postgres (pgvector) w e-commerce - Next.js + .NET | MDS Software Solutions Group | MDS Software Solutions Group