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
- Szyfrowanie: PostgreSQL SSL, encrypted backups
- Access control: Role-based access (RBAC) w .NET
- Rate limiting: Max 100 req/minute per user
- 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
Zespół ekspertów programistycznych specjalizujących się w nowoczesnych technologiach webowych.