Головна / Статті / Практичні нотатки: семантичний пошук з PostgreSQL: прагматизм перевершує хайп — більшість

Практичні нотатки: семантичний пошук з PostgreSQL: прагматизм перевершує хайп — більшість

Практичний посібник з семантичного пошуку за допомогою PostgreSQL: прагматизм переважає над хайпом — у більшості випадків: контракти, перевірки та готові фрагменти коду для команд, які використовують цю модель.

2581 слів

Використовуйте цей документ як оновлену версію ідей з статті „Semantic Search with PostgreSQL: Pragmatism Beats Hype – Most of the Time“, призначену для операторів: чіткі етапи, впорядковані блоки коду та примітки щодо відновлення, які залишаються при передачі обов’язків. Етап Огляду найкраще функціонує, якщо його розглядати як вимірювану поверхню. Запишіть один ідеальний запис, один випадок збою та примітки щодо скасування змін перед розширенням обсягу роботи. Віддавайте перевагу невеликим, тестованим одиницям перед об’ємними скриптами. Коли якийсь крок зазнає невдачі, причина має вказувати на конкретну відповідальність, а не на заплутану послідовність дій.

Семантичний пошук у одному реченні

Для семантичного пошуку в одному етапі необхідно перед зміною коду визначити вхідні дані, відповідальну особу за цей крок та критерії завершення. Оператори повинні мати можливість перезапустити крок з відомої точки контролю, не намагаючись вгадати прихований стан. Розглядайте цей етап як контракт між вхідними даними та перевіреними результатами. Позначте всі елементи, визначте критерії успіху та не допускайте беззвучного часткового завершення роботи. Наводьте уривки тексту, які фактично лягли в основу відповіді. Без посилань оператори не зможуть відрізнити галюцинації від проблем із індексуванням.

Найважливіше рішення щодо дизайну: модель ембеддингів

На найважливішому етапі проектування необхідно визначити вхідні дані, відповідальну особу за кожен крок та критерії завершення перед зміною коду. Оператори повинні мати можливість перезапустити крок з відомої точки контролю, не намагаючись визначити прихований стан. Записуйте час виконання та витрати на токени чи запити поруч із функціональними результатами. Чітке відображення витрат заздалегідь запобігає несподіваним рахункам під час переходу від демо-середовища до спільних середовищ. У разі, коли наступним кроком є написання коду чи виклик інструменту, краще використовувати структуровані результати з перевіркою схеми, ніж вільний текст.

Встановлення pgvector

На етапі встановлення pgvector необхідно визначити вхідні дані, власника кроку та критерії завершення ще до змін у коді. Оператори мають мати можливість перезапустити крок з відомої точки контролю, не намагаючись вгадати прихований стан. Конфігурацію слід тримати окремо від коду додатку. Файли середовища, сховища конфіденційних даних та флаги функціоналу мають знаходитися в одному місці, яке оператори можуть перевірити, не читаючи весь код. Наводьте ті уривки, які фактично лежать в основі відповіді. Без посилань оператори не зможуть відрізнити галюцинацію від проблем із індексуванням. На етапі встановлення pgvector необхідно визначити вхідні дані, власника кроку та критерії завершення ще до змін у коді. Оператори мають мати можливість перезапустити крок з відомої точки контролю, не намагаючись вгадати прихований стан. Віддавайте перевагу невеликим, тестованим одиницям перед об’ємними скриптами. Коли крок зазнає невдачі, причина має вказувати на конкретну відповідальність, а не на заплутану систему обробки даних.

CREATE EXTENSION IF NOT EXISTS vector;

Схема: зберігати фрагменти, а не лише документи

Під час роботи на етапі «Зберігати фрагменти, а не лише документи» спочатку складіть контракт: необхідні вхідні дані, сигнал про успіх та те, що відбувається у разі часткової невдачі. Цей перелік допомагає зберігати чесність пізніших змін у коді. Розглядайте цей етап як контракт між вхідними даними та перевіреними результатами. Дайте назви елементам, визначте критерії успіху та не допускайте беззвучного часткового виконання. Перед налаштуванням запитів вимірюйте рівень відтворення інформації за фіксованим набором запитань. Часта зміна запитів рідко виправляє слабкі алгоритми пошуку.

CREATE TABLE documents (
    id          BIGSERIAL PRIMARY KEY,
    title       TEXT NOT NULL,
    source      TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE document_chunks (
    id           BIGSERIAL PRIMARY KEY,
    document_id  BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    chunk_index  INTEGER NOT NULL,
    content      TEXT NOT NULL,
    embedding    vector(1536) NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (document_id, chunk_index)
);

Інтеграція в .NET з Npgsql

Під час роботи над інтеграцією в NET з використанням етапів спочатку запишіть умови взаємодії: необхідні вхідні дані, сигнал про успіх та те, що відбувається у разі часткової невдачі. Такий перелік допомагає зберігати чесність пізніших змін у коді. Запишіть час виконання та витрати на токени або запити поруч із функціональними результатами. Відомі заздалегідь витрати запобігають несподіваним рахункам, коли процес переходить від демо-середовища до спільних. Вимірюйте рівень точності відповідей на фіксований набір запитань перед налаштуванням формулювань запитів. Часта зміна формулювань рідко допомагає покращити якість отримання інформації.

dotnet add package Npgsql
dotnet add package Pgvector
dotnet add package Pgvector.Dapper
using Npgsql;
using Pgvector;

var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.UseVector();

await using var dataSource = dataSourceBuilder.Build();

Створення ембеддингів за допомогою OpenAI

Під час роботи над етапом створення ембеддингів з OpenAI спочатку запишіть умови використання: необхідні вхідні дані, сигнал про успіх та те, що відбувається у разі часткової невдачі. Такий перелік допомагає зберігати чесність у подальших змінах коду. Зберігайте конфігурацію окремо від коду додатку. Файли середовища, сховища конфіденційних даних та флаги функцій мають знаходитися в одному місці, де оператори можуть їх перевіряти, не читаючи весь код. Перед налаштуванням запитів вимірюйте рівень відтворення інформації на фіксованому наборі запитань. Часта зміна формулювань запитів рідко допомагає покращити ефективність пошуку. Під час роботи над етапом створення ембеддингів з OpenAI спочатку запишіть умови використання: необхідні вхідні дані, сигнал про успіх та те, що відбувається у разі часткової невдачі. Такий перелік допомагає зберігати чесність у подальших змінах коду. Віддавайте перевагу невеликим, тестованим одиницям коду перед величезними скриптами. Якщо якийсь крок зазнає невдачі, причина має бути пов’язана з конкретною функцією, а не з заплутаною структурою обробки даних.

using OpenAI.Embeddings;

var embeddingClient = new EmbeddingClient("text-embedding-3-small", apiKey);

async Task<float[]> GetEmbeddingAsync(string text)
{
    var result = await embeddingClient.GenerateEmbeddingAsync(text);
    return result.Value.ToFloats().ToArray();
}

Зберігання фрагмента документа

Етап зберігання фрагмента документа працює найкраще, якщо його розглядати як вимірювану характеристику. Запишіть один ідеальний зразок результату, один випадок невдачі та примітки щодо скасування змін перед розширенням обсягу роботи. Розглядайте цей етап як угоду між вхідними даними та перевіреними результатами. Позначте всі елементи, визначте критерії успіху та не допускайте мовчазного часткового виконання завдань. Розділіть політику формування фрагментів від політики їх отримання. Зміна однієї з них не повинна змушувати переписувати іншу при зміні показників якості.

async Task InsertChunkAsync(
    long documentId,
    int chunkIndex,
    string content,
    CancellationToken cancellationToken = default)
{
    var embedding = await GetEmbeddingAsync(content);
    var vector = new Vector(embedding);

    await using var conn = await dataSource.OpenConnectionAsync(cancellationToken);
    await using var cmd = new NpgsqlCommand("""
        INSERT INTO document_chunks (document_id, chunk_index, content, embedding)
        VALUES (@documentId, @chunkIndex, @content, @embedding)
        ON CONFLICT (document_id, chunk_index)
        DO UPDATE SET
            content = EXCLUDED.content,
            embedding = EXCLUDED.embedding
        """, conn);

    cmd.Parameters.AddWithValue("documentId", documentId);
    cmd.Parameters.AddWithValue("chunkIndex", chunkIndex);
    cmd.Parameters.AddWithValue("content", content);
    cmd.Parameters.AddWithValue("embedding", vector);

    await cmd.ExecuteNonQueryAsync(cancellationToken);
}

Семантичний пошук

Етап семантичного пошуку працює найкраще, якщо його розглядати як вимірювану характеристику. Збережіть один ідеальний зразок результату, один випадок невдачі та примітку про скасування змін перед розширенням обсягу роботи. Записуйте час виконання та витрати на обробку токенів чи запитів поруч із функціональними результатами. Візуальний контроль витрат заздалегідь запобігає несподіваним рахункам під час переходу від демо-середовища до спільних середовищ. Розділіть політику часткової обробки даних від політики їх пошуку. Зміна однієї з них не повинна змушувати переписувати іншу при зміні показників якості.

public sealed record SearchResult(
    long DocumentId,
    long ChunkId,
    string Title,
    string Content,
    double Distance);

async Task<List<SearchResult>> SearchAsync(
    string query,
    int limit = 5,
    CancellationToken cancellationToken = default)
{
    var queryEmbedding = await GetEmbeddingAsync(query);
    var queryVector = new Vector(queryEmbedding);

    await using var conn = await dataSource.OpenConnectionAsync(cancellationToken);
    await using var cmd = new NpgsqlCommand("""
        SELECT d.id,
               c.id,
               d.title,
               c.content,
               c.embedding <=> @queryVector AS distance
        FROM document_chunks c
        JOIN documents d ON d.id = c.document_id
        ORDER BY c.embedding <=> @queryVector
        LIMIT @limit
        """, conn);

    cmd.Parameters.AddWithValue("queryVector", queryVector);
    cmd.Parameters.AddWithValue("limit", limit);

    var results = new List<SearchResult>();

    await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
    while (await reader.ReadAsync(cancellationToken))
    {
        results.Add(new SearchResult(
            DocumentId: reader.GetInt64(0),
            ChunkId: reader.GetInt64(1),
            Title: reader.GetString(2),
            Content: reader.GetString(3),
            Distance: reader.GetDouble(4)));
    }

    return results;
}

Оператори відстаней

Етап Distance Operators працює найкраще, коли його розглядають як вимірювану поверхню. Збережіть один ідеальний запис, один випадок збою та примітку про скасування змін перед розширенням обсягу роботи. Тримайте конфігурацію окремо від коду додатку. Файли середовища, сховища конфіденційних даних та флаги функцій мають знаходитися в одному місці, де оператори можуть їх перевіряти, не читаючи весь граф. Розділяйте політику часткової обробки даних та політику їх отримання. Зміна однієї з них не повинна змушувати переписувати іншу при зміні показників якості. Етап Distance Operators працює найкраще, коли його розглядають як вимірювану поверхню. Збережіть один ідеальний запис, один випадок збою та примітку про скасування змін перед розширенням обсягу роботи. Віддавайте перевагу малим, тестованим одиницям перед величезними скриптами. Коли якийсь крок зазнає невдачі, причина має вказувати на конкретну відповідальність, а не на заплутану послідовність операцій.

Створення індексу за допомогою HNSW

Для створення індексу з використанням певної стадії необхідно спочатку визначити вхідні дані, власника кроку та критерії завершення перед зміною коду. Оператори повинні мати можливість перезапустити крок з відомої точки контролю, не намагаючись вгадати прихований стан. Розглядайте цю стадію як контракт між вхідними даними та перевіреними результатами. Призначте назви елементів, визначте критерії успіху та не допускайте мовчазного часткового завершення. Наводьте конкретні уривки, які лежать в основі відповіді. Без посилань оператори не зможуть відрізнити галюцинації від прогалин у процесі індексації.

CREATE INDEX document_chunks_embedding_hnsw_idx
ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
SET hnsw.ef_search = 100;
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT ...
COMMIT;

IVFFlat як альтернатива

Для IVFFlat як альтернативної стадії необхідно визначити вхідні дані, власника кроку та критерії завершення перед зміною коду. Оператори повинні мати можливість перезапустити крок з відомої точки контролю, не намагаючись визначити прихований стан. Записуйте час виконання та витрати на токени чи запити поруч із функціональними результатами. Відображення витрат заздалегідь запобігає несподіваним рахункам під час переходу з демо-середовища у спільні. Наводьте уривки тексту, які фактично лягли в основу відповіді. Без посилань оператори не зможуть відрізнити галюцинації від проблем з індексуванням.

CREATE INDEX document_chunks_embedding_ivfflat_idx
ON document_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
SET ivfflat.probes = 10;

Фільтрація та гібридний пошук

На етапі фільтрації та гібридного пошуку необхідно визначити вхідні дані, власника кроку та критерії завершення перед зміною коду. Оператори повинні мати можливість перезапустити крок з відомої точки контролю, не здогадуючись про прихований стан. Зберігайте конфігурацію поза кодом додатку. Файли середовища, сховища секретів та флаги функцій мають знаходитися в одному місці, яке оператори можуть перевірити, не читаючи весь код. Наводьте уривки тексту, які фактично лежать в основі відповіді. Без посилань оператори не зможуть відрізнити галюцинацію від проблем із індексуванням. На етапі фільтрації та гібридного пошуку необхідно визначити вхідні дані, власника кроку та критерії завершення перед зміною коду. Оператори повинні мати можливість перезапустити крок з відомої точки контролю, не здогадуючись про прихований стан. Віддавайте перевагу невеликим, тестованим одиницям перед об’ємними скриптами. Коли крок зазнає невдачі, причина має вказувати на конкретну відповідальність, а не на щось загальне.

Процес обробки даних.

SELECT d.id, d.title, c.content, c.embedding <=> @queryVector AS distance
FROM document_chunks c
JOIN documents d ON d.id = c.document_id
WHERE d.created_at > NOW() - INTERVAL '30 days'
  AND d.source = 'documentation'
ORDER BY c.embedding <=> @queryVector
LIMIT 10;
WITH semantic AS (
    SELECT c.id,
           row_number() OVER (ORDER BY c.embedding <=> @queryVector) AS semantic_rank
    FROM document_chunks c
    LIMIT 100
),
keyword AS (
    SELECT c.id,
           row_number() OVER (
               ORDER BY ts_rank_cd(
                   to_tsvector('english', c.content),
                   plainto_tsquery('english', @query)
               ) DESC
           ) AS keyword_rank
    FROM document_chunks c
    WHERE to_tsvector('english', c.content) @@ plainto_tsquery('english', @query)
    LIMIT 100
)
SELECT d.id AS document_id,
       c.id AS chunk_id,
       d.title,
       c.content,
       COALESCE(1.0 / (60 + semantic.semantic_rank), 0) +
       COALESCE(1.0 / (60 + keyword.keyword_rank), 0) AS score
FROM semantic
FULL OUTER JOIN keyword ON keyword.id = semantic.id
JOIN document_chunks c ON c.id = COALESCE(semantic.id, keyword.id)
JOIN documents d ON d.id = c.document_id
ORDER BY score DESC
LIMIT 10;

Коли pgvector є правильним вибором

Під час роботи на етапі використання pgvector спочатку складіть контракт: необхідні вхідні дані, сигнал про успіх та те, що відбувається у разі часткової невдачі. Цей перелік допомагає зберігати чесність пізніших змін у коді. Розглядайте цей етап як контракт між вхідними даними та перевіреними результатами. Дайте назви елементам, визначте критерії успіху та не допускайте беззвучного часткового виконання завдань. Вимірюйте рівень відтворення інформації на фіксованому наборі запитань перед налаштуванням підказок. Часта зміна підказок рідко вирішує проблеми слабкого пошуку.

Коли спеціалізований сховище векторів може бути кращим

Під час роботи над етапом «Коли використовується спеціалізований вектор» спочатку запишіть умови контракту: необхідні вхідні дані, сигнал про успіх та те, що відбувається у разі часткової невдачі. Такий перелік допомагає зберігати чесність пізніших змін у коді. Запишіть час виконання та витрати на токени або запити поруч із функціональними результатами. Чітке бачення витрат заздалегідь запобігає несподіваним рахункам, коли процес переходить від демо-версії до спільних середовищ. Вимірюйте рівень відтворення інформації на фіксованому наборі запитань перед налаштуванням підказок. Часта зміна підказок рідко допомагає покращити ефективність пошуку.

Повний приклад ASP.NET Core

Під час виконання етапу Complete ASP NET Core спочатку запишіть контракт: необхідні вхідні дані, сигнал про успіх та те, що відбувається при частковій невдачі. Цей перелік допомагає зберігати чесність у подальших змінах коду. Тримайте конфігурацію окремо від коду додатку. Файли середовища, сховища секретів та флаги функцій мають знаходитися в одному місці, де оператори можуть їх перевіряти, не читаючи весь код. Вимірюйте рівень відтворення інформації за фіксованим набором запитань перед налаштуванням запрошень. Часта зміна запрошень рідко виправляє проблеми з недостатньою ефективністю пошуку.

app.MapGet("/search", async (
    string q,
    NpgsqlDataSource db,
    CancellationToken cancellationToken) =>
{
    var queryEmbedding = await GetEmbeddingAsync(q);
    var queryVector = new Vector(queryEmbedding);

    await using var conn = await db.OpenConnectionAsync(cancellationToken);
    await using var cmd = new NpgsqlCommand("""
        SELECT d.id,
               c.id,
               d.title,
               c.content,
               c.embedding <=> @v AS distance
        FROM document_chunks c
        JOIN documents d ON d.id = c.document_id
        ORDER BY c.embedding <=> @v
        LIMIT 5
        """, conn);

    cmd.Parameters.AddWithValue("v", queryVector);

    var results = new List<object>();

    await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
    while (await reader.ReadAsync(cancellationToken))
    {
        results.Add(new
        {
            documentId = reader.GetInt64(0),
            chunkId = reader.GetInt64(1),
            title = reader.GetString(2),
            content = reader.GetString(3),
            distance = reader.GetDouble(4)
        });
    }

    return Results.Ok(results);
});

Висновок

Під час роботи над етапом Висновків спочатку запишіть умови контракту: необхідні вхідні дані, сигнал про успіх та те, що відбувається у разі часткової невдачі. Такий перелік допомагає зберігати чесність пізніших змін у коді. Одночасно задокументуйте шлях успішного виконання та шлях відновлення. Повторні спроби, людський контроль та обробка некоректних повідомлень є частиною продукту, а не етапом подальшої доробки. Перед налаштуванням запитів вимірюйте рівень відтворення інформації за фіксованим набором запитань. Зміна запитів рідко вирішує проблеми слабкої системи пошуку.

Посилання

Під час роботи над етапом «Джерела» спочатку запишіть умови використання: необхідні вхідні дані, сигнал про успіх та те, що відбувається у разі часткової невдачі. Цей перелік допомагає зберігати чесність пізніших змін у коді.

Перелік операційних кроків

Під час роботи над етапом «Перелік операційних кроків» спочатку запишіть умови використання: необхідні вхідні дані, сигнал про успіх та те, що відбувається у разі часткової невдачі. Цей перелік допомагає зберігати чесність пізніших змін у коді.

Одночасно задокументуйте шлях успішного виконання та шлях відновлення. Повторні спроби, людський контроль та обробка некоректних повідомлень є частиною продукту, а не етапом подальшої доробки.

Вимірюйте рівень відтворення інформації на фіксованому наборі запитань перед налаштуванням підказок. Часта зміна підказок рідко виправляє проблеми зі слабким механізмом пошуку.

Фіксуйте версії залежностей та записуйте резюме зображення, яке використовувалося під час демонстрації. Відтворюваність результатів краща за інтуїтивне розуміння.

Віддавайте перевагу невеликим, тестованим одиницям коду перед складними скриптами. Якщо якийсь крок зазнає невдачі, проблема має вказувати на конкретну відповідальність, а не на заплутану послідовність дій.

Вимірюйте рівень відтворення інформації на фіксованому наборі запитань перед налаштуванням підказок. Часта зміна підказок рідко виправляє проблеми зі слабким механізмом пошуку.

Перед тим, як впроваджувати нові компоненти, заморожуйте версії, створюйте остаточну запись кроків для критичних процесів та перевіряйте кроки скасування змін. У спільних середовищах необхідні обмеження на частоту використання, перевірки прав доступу та чіткий власник для обробки конфіденційних даних. Віддавайте перевагу надійності перед креативними, одноразовими демонстраціями.

Примітка до пакету 5e57ac6d3d33: не включайте ключі постачальника до репозиторію, встановіть ліміт токенів на сеанс та зберігайте транскрипції поруч із фіксами для оцінки, щоб подальша заміна моделей залишалася порівнянною.