Базы данных

Векторный поиск в PostgreSQL с pgvector: семантический поиск без внешних сервисов в 2026 году

Ruslan Ismailov Опубликовано 12 мин чтения
В

Введение: зачем нужен семантический поиск и когда не нужен отдельный сервис

Классический полнотекстовый поиск работает по точному совпадению слов. Запрос «купить ноутбук» не найдёт документ со словами «приобрести лэптоп», даже если смысл идентичен. Семантический поиск решает эту проблему, сравнивая не слова, а векторные представления смысла — эмбеддинги.

Традиционный ответ на эту задачу — поднять Elasticsearch с kNN-плагином, Pinecone, Weaviate или Qdrant. Но для большинства продуктов это означает: новую инфраструктуру, дополнительные затраты, синхронизацию данных и новый источник отказов. Если у вас уже есть PostgreSQL — расширение pgvector позволяет добавить полноценный векторный поиск прямо в существующую базу данных, без внешних сервисов.

В 2026 году pgvector достиг зрелости: поддержка HNSW-индексов, параллельные запросы, совместимость с облачными управляемыми PostgreSQL (Amazon RDS, Supabase, Neon, Google Cloud SQL). Это статья для backend-разработчиков и архитекторов, которые хотят прагматичное решение без раздувания стека.

Что такое pgvector: установка и поддерживаемые типы

pgvector — это расширение с открытым исходным кодом для PostgreSQL, добавляющее тип данных vector, операторы сравнения векторов и индексы для приближённого поиска ближайших соседей (ANN).

Установка расширения

На Ubuntu/Debian с PostgreSQL 16:

sudo apt install postgresql-16-pgvector

-- В psql:
CREATE EXTENSION IF NOT EXISTS vector;

Через Docker (рекомендуется для разработки):

docker run -d \
  --name pgvector-dev \
  -e POSTGRES_PASSWORD=secret \
  -p 5432:5432 \
  pgvector/pgvector:pg16

После установки проверьте версию:

SELECT extversion FROM pg_extension WHERE extname = 'vector';
-- 0.8.0 (актуально в 2026)

Тип vector(N) принимает фиксированную размерность N (максимум 16 000 измерений для HNSW, 2 000 для IVFFLAT). Поддерживаются также halfvec (16-битные float, вдвое меньше места) и sparsevec для разреженных векторов.

Генерация эмбеддингов: модели и интеграция в 2026 году

Эмбеддинг — это числовой вектор, кодирующий смысл текста. Популярные модели в 2026 году:

  • text-embedding-3-large (OpenAI) — 3072 измерения, высокое качество, платно.
  • text-embedding-3-small (OpenAI) — 1536 измерений, хороший баланс цены и качества.
  • Mistral Embed — 1024 измерения, конкурентное качество для европейских данных.
  • nomic-embed-text-v2 (локально через Ollama) — 768 измерений, полностью офлайн.
  • multilingual-e5-large — отлично работает с русским языком.

Получение эмбеддинга через REST API на Go

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
)

type EmbeddingRequest struct {
    Input string `json:"input"`
    Model string `json:"model"`
}

type EmbeddingResponse struct {
    Data []struct {
        Embedding []float32 `json:"embedding"`
    } `json:"data"`
}

func GetEmbedding(text, apiKey string) ([]float32, error) {
    payload := EmbeddingRequest{
        Input: text,
        Model: "text-embedding-3-small",
    }
    body, _ := json.Marshal(payload)

    req, _ := http.NewRequest("POST",
        "https://api.openai.com/v1/embeddings",
        bytes.NewBuffer(body),
    )
    req.Header.Set("Authorization", "Bearer "+apiKey)
    req.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    var result EmbeddingResponse
    json.NewDecoder(resp.Body).Decode(&result)
    return result.Data[0].Embedding, nil
}

Получение эмбеддинга на PHP (для Laravel)

<?php
use Illuminate\Support\Facades\Http;

function getEmbedding(string $text): array
{
    $response = Http::withToken(config('services.openai.key'))
        ->post('https://api.openai.com/v1/embeddings', [
            'input' => $text,
            'model' => 'text-embedding-3-small',
        ]);

    return $response->json('data.0.embedding');
}

Создание таблиц с векторными колонками и хранение эмбеддингов

Создадим таблицу документов с векторной колонкой для хранения эмбеддингов PostgreSQL:

CREATE TABLE documents (
    id          BIGSERIAL PRIMARY KEY,
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    embedding   vector(1536),          -- размерность text-embedding-3-small
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

Вставка документа с эмбеддингом (пример из Go):

import "github.com/pgvector/pgvector-go"

embedding, _ := GetEmbedding("Ноутбуки для разработки", apiKey)

_, err = db.Exec(
    `INSERT INTO documents (title, content, embedding)
     VALUES ($1, $2, $3)`,
    "Лучшие ноутбуки 2026",
    "Обзор топовых лэптопов для программистов...",
    pgvector.NewVector(embedding),
)

Операторы поиска: cosine similarity, L2 distance, inner product

pgvector поддерживает три оператора расстояния. Выбор зависит от задачи:

  • Cosine distance (<=>) — измеряет угол между векторами, не зависит от длины. Лучший выбор для текстовых эмбеддингов. Значение 0 = идентичные, 2 = противоположные.
  • L2 (Euclidean) distance (<->) — евклидово расстояние. Подходит, когда важна абсолютная близость в пространстве, например для изображений.
  • Inner product (<#>) — скалярное произведение (со знаком минус для сортировки). Оптимален для нормализованных векторов — математически эквивалентен cosine similarity, но быстрее.

Пример запроса с cosine similarity SQL:

-- Найти 10 документов, ближайших по смыслу к запросу
SELECT
    id,
    title,
    1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10;

Где $1 — эмбеддинг поискового запроса пользователя.

Индексы для векторного поиска: IVFFLAT vs HNSW

Без индекса PostgreSQL выполняет полное сканирование таблицы (kNN exact search). При миллионах записей это неприемлемо медленно. pgvector предлагает два типа индексов приближённого поиска.

IVFFLAT — инвертированный файл с плоскими кластерами

CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

-- Перед поиском установите количество проверяемых кластеров:
SET ivfflat.probes = 10;

Параметр lists: рекомендуется rows / 1000 для таблиц до 1M строк. probes: чем больше — тем точнее, но медленнее. IVFFLAT строится быстро, но требует наличия данных до создания индекса.

HNSW — Hierarchical Navigable Small World

CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- При поиске:
SET hnsw.ef_search = 40;

HNSW-индекс pgvector превосходит IVFFLAT по скорости поиска при сопоставимой точности. Параметры:

  • m (8–64) — количество связей на слой. Больше = точнее, больше памяти.
  • ef_construction (4–400) — качество построения индекса. 64 — разумный дефолт.
  • ef_search — точность во время поиска, можно менять на лету.

HNSW требует больше памяти при построении и более длительного создания, но обеспечивает лучший recall при меньшей задержке. В 2026 году HNSW — выбор по умолчанию для большинства продакшен-систем.

Гибридный поиск: комбинирование полнотекстового и векторного поиска

Чистый семантический поиск иногда «промахивается» по точным терминам (артикулы, имена собственные). Гибридный поиск PostgreSQL объединяет tsvector-поиск и векторный с помощью RRF (Reciprocal Rank Fusion):

WITH
-- Полнотекстовый поиск
fulltext AS (
    SELECT id, ts_rank(to_tsvector('russian', content),
                       plainto_tsquery('russian', $2)) AS ft_score
    FROM documents
    WHERE to_tsvector('russian', content) @@ plainto_tsquery('russian', $2)
    LIMIT 60
),
-- Векторный поиск
vector_search AS (
    SELECT id, 1 - (embedding <=> $1::vector) AS vs_score
    FROM documents
    ORDER BY embedding <=> $1::vector
    LIMIT 60
),
-- Объединение через RRF
ranked AS (
    SELECT
        COALESCE(f.id, v.id) AS id,
        COALESCE(1.0 / (60 + ROW_NUMBER() OVER (ORDER BY f.ft_score DESC)), 0) +
        COALESCE(1.0 / (60 + ROW_NUMBER() OVER (ORDER BY v.vs_score DESC)), 0) AS rrf_score
    FROM fulltext f
    FULL OUTER JOIN vector_search v ON f.id = v.id
)
SELECT d.id, d.title, r.rrf_score
FROM ranked r
JOIN documents d ON d.id = r.id
ORDER BY r.rrf_score DESC
LIMIT 10;

Производительность при больших объёмах: бенчмарки и ограничения

Ориентировочные показатели pgvector с HNSW на сервере с 32 ГБ RAM, PostgreSQL 16, векторы 1536 измерений:

  • 100K записей: ~2 мс на запрос, recall 95%+
  • 1M записей: ~15–30 мс на запрос, recall 90%+
  • 10M записей: ~80–150 мс, рекомендуется партиционирование

Для таблиц свыше 5M строк используйте партиционирование по диапазону дат или категориям:

CREATE TABLE documents (
    id         BIGSERIAL,
    category   TEXT NOT NULL,
    embedding  vector(1536),
    created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY LIST (category);

CREATE TABLE documents_tech
    PARTITION OF documents FOR VALUES IN ('tech');

CREATE TABLE documents_finance
    PARTITION OF documents FOR VALUES IN ('finance');

-- Индексы создаются на каждую партицию отдельно
CREATE INDEX ON documents_tech
USING hnsw (embedding vector_cosine_ops);

Важное ограничение: HNSW-индекс хранится в памяти при поиске. Настройте maintenance_work_mem для построения и shared_buffers для кэширования.

Кэширование результатов поиска с Redis

Семантический поиск дорог: нужно получить эмбеддинг запроса через API и выполнить векторный запрос. Redis позволяет кэшировать результаты по хэшу эмбеддинга запроса.

Пример на Go с библиотекой go-redis:

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "time"
    "github.com/redis/go-redis/v9"
)

func SearchWithCache(ctx context.Context, rdb *redis.Client, query string, embedding []float32) ([]Document, error) {
    // Ключ кэша на основе хэша эмбеддинга
    h := sha256.Sum256([]byte(fmt.Sprintf("%v", embedding)))
    cacheKey := "search:" + hex.EncodeToString(h[:])

    // Проверить кэш
    cached, err := rdb.Get(ctx, cacheKey).Result()
    if err == nil {
        var docs []Document
        json.Unmarshal([]byte(cached), &docs)
        return docs, nil
    }

    // Выполнить поиск в PostgreSQL
    docs, err := SearchDocuments(ctx, embedding)
    if err != nil {
        return nil, err
    }

    // Сохранить в Redis на 10 минут
    data, _ := json.Marshal(docs)
    rdb.Set(ctx, cacheKey, data, 10*time.Minute)

    return docs, nil
}

Для часто повторяющихся запросов (поиск по популярным темам) hit rate Redis достигает 60–80%, что существенно снижает нагрузку на базу данных.

Сравнение pgvector с Elasticsearch kNN и специализированными векторными базами

pgvector vs Elasticsearch kNN-поиск:

  • Инфраструктура: pgvector — ноль новых компонентов. Elasticsearch — отдельный кластер, Kibana, мониторинг.
  • Консистентность: pgvector — ACID-транзакции. ES — eventual consistency.
  • Гибридный поиск: оба поддерживают, но в PostgreSQL это нативный SQL без дополнительных DSL.
  • Производительность на 10M+: ES выигрывает за счёт горизонтального масштабирования шардами.

pgvector vs Pinecone/Weaviate/Qdrant:

  • Специализированные базы выигрывают в пиковой производительности при сотнях миллионов векторов.
  • pgvector выигрывает в простоте, стоимости и интеграции с реляционными данными (JOIN-ы, транзакции, привычный SQL).
  • Для большинства B2B и SaaS-продуктов с объёмами до 10M документов pgvector — прагматичный выбор.

Правило практика: начинайте с pgvector. Мигрируйте на специализированную базу только тогда, когда PostgreSQL перестанет справляться с измеренными, а не воображаемыми нагрузками.

Практический кейс: семантический поиск документов в Laravel-приложении

Рассмотрим реализацию pgvector Laravel для поиска статей в базе знаний.

Миграция

<?php
// database/migrations/2026_01_01_add_embeddings_to_articles.php
public function up(): void
{
    DB::statement('CREATE EXTENSION IF NOT EXISTS vector');

    Schema::table('articles', function (Blueprint $table) {
        // Добавляем кастомный тип через raw statement
    });

    DB::statement('ALTER TABLE articles ADD COLUMN embedding vector(1536)');
    DB::statement(
        'CREATE INDEX articles_embedding_hnsw
         ON articles USING hnsw (embedding vector_cosine_ops)
         WITH (m = 16, ef_construction = 64)'
    );
}

Сервис для поиска

<?php
namespace App\Services;

use App\Models\Article;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;

class SemanticSearchService
{
    public function search(string $query, int $limit = 10): array
    {
        $embedding = $this->getEmbedding($query);
        $vectorStr = '[' . implode(',', $embedding) . ']';

        return Article::selectRaw(
            'id, title, excerpt,
             1 - (embedding <=> ?::vector) AS similarity',
            [$vectorStr]
        )
        ->whereNotNull('embedding')
        ->orderByRaw('embedding <=> ?::vector', [$vectorStr])
        ->limit($limit)
        ->get()
        ->toArray();
    }

    private function getEmbedding(string $text): array
    {
        return Cache::remember(
            'embedding:' . md5($text),
            now()->addDay(),
            fn () => Http::withToken(config('services.openai.key'))
                ->post('https://api.openai.com/v1/embeddings', [
                    'input' => $text,
                    'model' => 'text-embedding-3-small',
                ])
                ->json('data.0.embedding')
        );
    }
}

Индексирование статей командой Artisan

<?php
namespace App\Console\Commands;

use App\Models\Article;
use App\Services\SemanticSearchService;
use Illuminate\Console\Command;

class IndexArticleEmbeddings extends Command
{
    protected $signature = 'articles:index-embeddings {--chunk=100}';

    public function handle(SemanticSearchService $service): void
    {
        Article::whereNull('embedding')
            ->chunkById((int) $this->option('chunk'), function ($articles) use ($service) {
                foreach ($articles as $article) {
                    $embedding = $service->getEmbeddingPublic(
                        $article->title . ' ' . $article->content
                    );
                    $vectorStr = '[' . implode(',', $embedding) . ']';

                    $article->updateQuietly(['embedding' => $vectorStr]);
                    $this->info("Indexed: {$article->id}");

                    // Уважаем rate limit API
                    usleep(100_000); // 100ms
                }
            });
    }
}

Заключение

pgvector в 2026 году — это зрелое, production-ready решение для семантического поиска прямо внутри PostgreSQL. Вы получаете ACID-транзакции, JOIN-ы с реляционными данными, привычный SQL и нулевую дополнительную инфраструктуру.

Ключевые рекомендации:

  • Используйте HNSW-индекс как дефолт — он быстрее IVFFLAT при сопоставимой точности.
  • Для текстов применяйте cosine distance (<=>).
  • Комбинируйте векторный поиск с tsvector через RRF — гибридный поиск PostgreSQL значительно лучше каждого метода по отдельности.
  • Кэшируйте эмбеддинги запросов в Redis — это снизит стоимость API-вызовов в 2–5 раз.
  • Партиционируйте таблицы при объёме свыше 5M документов.
  • Мигрируйте на специализированные векторные базы только при реальной, измеренной необходимости.

pgvector + PostgreSQL — это разумный прагматизм: вы решаете реальную задачу семантического поиска с минимальным усложнением архитектуры.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →