Inicio / Artículos / Notas prácticas: Búsqueda semántica con PostgreSQL: El pragmatismo supera al sensacionalismo — La mayoría

Notas prácticas: Búsqueda semántica con PostgreSQL: El pragmatismo supera al sensacionalismo — La mayoría

Guía práctica para utilizar la búsqueda semántica con PostgreSQL: El pragmatismo supera al bombo publicitario, la mayoría de las veces; contratos, verificaciones y espacios para código listo para uso para los equipos que implementan este patrón.

2581 palabras

Úselo como una versión reestructurada dirigida a operadores de las ideas presentadas en “Búsqueda semántica con PostgreSQL: El pragmatismo supera al sensacionalismo, la mayoría de las veces”: etapas claras, secciones de código ordenadas y notas de recuperación que perduran tras el traspaso de tareas. La etapa de Resumen funciona mejor cuando se considera una superficie medible. Capture una transcripción clave, un caso de fallo y la nota de reversión antes de ampliar el alcance. Prefiera unidades pequeñas y verificables en lugar de scripts extensos. Cuando un paso falla, el fallo debe apuntar a una única responsabilidad y no a un proceso complicado.

Búsqueda semántica en una frase

Para la Búsqueda Semántica en una sola etapa, defina las entradas, el responsable de la fase y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar la fase a partir de un punto de control conocido sin tener que adivinar el estado oculto. Trate esta etapa como un contrato entre las entradas y los resultados validados. Asigne nombres a los artefactos, defina verificaciones de éxito y rechace las completaciones parciales silenciosas. Cite los pasajes que realmente sirvieron de base para la respuesta. Sin citaciones, los operadores no pueden distinguir entre alucinaciones y brechas en el indexado.

La decisión de diseño más importante: el modelo de embedding

En la etapa de Diseño Más Importante, defina las entradas, el responsable de cada paso y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar el paso a partir de un punto de control conocido, sin tener que adivinar el estado oculto. Registre los tiempos de ejecución y el costo de tokens o consultas junto con los resultados funcionales. La visibilidad temprana del costo evita facturas inesperadas cuando el proceso pasa de entornos de demostración a entornos compartidos. Prefiera salidas estructuradas con validación de esquema sobre textos en formato libre cuando el siguiente paso sea la escritura de código o una llamada a una herramienta.

Instalación de pgvector

En la etapa de instalación de pgvector, defina las entradas, el responsable del paso y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar el paso a partir de un punto de control conocido sin tener que adivinar el estado oculto. Guarde la configuración fuera del código de la aplicación. Los archivos de entorno, los almacenes de datos confidenciales y las banderas de funcionalidad deben encontrarse en un lugar que los operadores puedan auditar sin necesidad de leer todo el sistema. Cite los pasajes que realmente sustentan la respuesta. Sin citaciones, los operadores no pueden distinguir entre alucinaciones y fallos en el indexado. En la etapa de instalación de pgvector, defina las entradas, el responsable del paso y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar el paso a partir de un punto de control conocido sin tener que adivinar el estado oculto. Prefiera unidades pequeñas y verificables en lugar de scripts extensos. Cuando un paso falla, el error debe indicar una única responsabilidad y no un proceso complejo y entrelazado.

CREATE EXTENSION IF NOT EXISTS vector;

Estructura: Almacenar fragmentos, no solo documentos

Al trabajar en la fase de “Almacenar fragmentos, no solo documentos” de la estructura, anote primero el contrato: entradas requeridas, señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación mantiene honestas las futuras modificaciones del código. Trate esta fase como un contrato entre las entradas y los resultados validados. Asigne nombres a los artefactos, defina comprobaciones de éxito y rechace las completaciones parciales silenciosas. Mida la capacidad de recuperación con un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente.

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)
);

Integración en .NET con Npgsql

Al trabajar en la integración en .NET con etapas, anote primero el contrato: las entradas requeridas, la señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación mantiene honestas las futuras modificaciones del código. Registre los tiempos y el costo de tokens o consultas junto a los resultados funcionales. La visibilidad temprana del costo evita facturas inesperadas cuando el proceso pasa de la fase de demostración a entornos compartidos. Mida la capacidad de recuperación con un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente.

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();

Generación de embeddings con OpenAI

Al trabajar en la etapa de Generación de embeddings con OpenAI, anote primero el contrato: entradas requeridas, señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación mantiene honestas las futuras modificaciones del código. Guarde la configuración fuera del código de la aplicación. Los archivos de entorno, los almacenes de secretos y las banderas de funcionalidad deben estar en un lugar donde los operadores puedan auditarlos sin tener que leer todo el sistema. Mida la tasa de recuperación en un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente. Al trabajar en la etapa de Generación de embeddings con OpenAI, anote primero el contrato: entradas requeridas, señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación mantiene honestas las futuras modificaciones del código. Prefiera unidades pequeñas y verificables en lugar de scripts extensos. Cuando un paso falla, el fallo debe apuntar a una única responsabilidad y no a un proceso complicado.

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();
}

Almacenamiento de un fragmento de documento

La etapa de almacenamiento de un fragmento de documento funciona mejor cuando se trata como una superficie medible. Capture una transcripción ideal, un caso de fallo y la nota de reversión antes de ampliar el alcance. Trate esta etapa como un contrato entre las entradas y las salidas validadas. Asigne nombres a los artefactos, defina verificaciones de éxito y rechace las completaciones parciales silenciosas. Separe la política de fragmentación de la política de recuperación. Cambiar una no debe obligar a reescribir la otra cuando cambian las métricas de calidad.

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);
}

Búsqueda semántica

La etapa de búsqueda semántica funciona mejor cuando se trata como una superficie medible. Capture un registro ideal, un caso de fallo y la nota de reversión antes de ampliar el alcance. Registre los tiempos y el costo por token o consulta junto a los resultados funcionales. La visibilidad temprana del costo evita facturas inesperadas cuando el proceso pasa de la versión de demostración a entornos compartidos. Separe la política de fragmentación de la política de recuperación; cambiar una no debe obligar a reescribir la otra cuando cambian las métricas de calidad.

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;
}

Operadores de distancia

La etapa de Distance Operators funciona mejor cuando se trata como una superficie medible. Capture un registro ideal, un caso de fallo y la nota de reversión antes de ampliar el alcance. Mantenga la configuración fuera del código de la aplicación. Los archivos de entorno, los almacenes de secretos y las banderas de funcionalidad deben estar en un lugar donde los operadores puedan auditarlos sin tener que leer todo el grafo. Separe la política de particionamiento de la política de recuperación. Cambiar una no debe obligar a reescribir la otra cuando cambian las métricas de calidad. La etapa de Distance Operators funciona mejor cuando se trata como una superficie medible. Capture un registro ideal, un caso de fallo y la nota de reversión antes de ampliar el alcance. Prefiera unidades pequeñas y probables sobre scripts extensos. Cuando un paso falla, el fallo debe apuntar a una única responsabilidad en lugar de a un proceso complicado.

Creación de un índice con HNSW

Para crear un índice con etapas, defina las entradas, el responsable de cada paso y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar el paso a partir de un punto de control conocido, sin tener que adivinar el estado oculto. Trate esta etapa como un contrato entre las entradas y los resultados validados. Asigne nombres a los artefactos, defina verificaciones de éxito y rechace las completaciones parciales silenciosas. Cite los pasajes que realmente sustentan la respuesta. Sin citaciones, los operadores no pueden distinguir entre alucinaciones y lagunas en el indexado.

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 como alternativa

Para IVFFlat como etapa alternativa, defina las entradas, el responsable de la fase y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar la fase a partir de un punto de control conocido sin tener que adivinar el estado oculto. Registre los tiempos de ejecución y el costo en tokens o consultas junto con los resultados funcionales. La visibilidad temprana de los costos evita facturas inesperadas cuando el proceso pasa de entornos de demostración a entornos compartidos. Cite los pasajes que realmente sustentan la respuesta; sin citas, los operadores no pueden distinguir entre alucinaciones y brechas en el indexado.

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

Filtrado y búsqueda híbrida

En la etapa de filtrado y búsqueda híbrida, defina las entradas, el responsable del paso y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar el paso a partir de un punto de control conocido sin tener que adivinar el estado oculto. Guarde la configuración fuera del código de la aplicación. Los archivos de entorno, los almacenes de secretos y las banderas de funcionalidad deben encontrarse en un lugar que los operadores puedan auditar sin necesidad de leer todo el sistema. Cite los pasajes que realmente sustentan la respuesta. Sin citaciones, los operadores no pueden distinguir entre alucinaciones y lagunas en el indexado. En la etapa de filtrado y búsqueda híbrida, defina las entradas, el responsable del paso y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar el paso a partir de un punto de control conocido sin tener que adivinar el estado oculto. Prefiera unidades pequeñas y verificables en lugar de scripts extensos. Cuando un paso falla, el error debe indicar una única responsabilidad en lugar de ser difuso.

Examine la tubería de procesamiento.

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;

Cuándo pgvector es la elección adecuada

Al trabajar en la etapa de determinar cuándo usar pgvector, anote primero el contrato: entradas requeridas, señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación mantiene honestas las futuras modificaciones del código. Trate esta etapa como un contrato entre las entradas y los resultados validados. Asigne nombres a los artefactos, defina comprobaciones de éxito y evite completaciones parciales silenciosas. Mida la capacidad de recuperación con un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente.

Cuándo un almacén vectorial dedicado puede ser mejor

Al trabajar en la etapa de “When a Dedicated Vector”, anote primero el contrato: las entradas requeridas, la señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación ayuda a mantener honestos los cambios posteriores en el código. Registre los tiempos y el costo de tokens o consultas junto con los resultados funcionales. Tener visibilidad del costo desde el principio evita facturas inesperadas cuando el proceso pasa de la fase de demostración a entornos compartidos. Mida el rendimiento en un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente.

Ejemplo completo de ASP.NET Core

Al trabajar en la etapa de Complete ASP NET Core, primero escribe el contrato: las entradas requeridas, la señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación mantiene honestas las futuras modificaciones del código. Mantén la configuración fuera del código de la aplicación. Los archivos de entorno, los almacenes de secretos y las banderas de funcionalidad deben estar en un lugar donde los operadores puedan auditarlos sin tener que leer todo el sistema. Mide la tasa de recuperación con un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente.

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);
});

Conclusión

Al trabajar en la etapa de Conclusión, anote primero el contrato: los datos necesarios, la señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación mantiene honestas las futuras modificaciones del código. Documente junto con ello el camino óptimo y el camino de recuperación. Las reintentos, los controles humanos y el manejo de mensajes no entregados forman parte del producto, no de mejoras posteriores. Mida la capacidad de recuperación con un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente.

Referencias

Al trabajar en la etapa de Referencias, anote primero el contrato: los datos de entrada requeridos, la señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación mantiene honestas las futuras modificaciones del código.

Lista de verificación operativa

Al trabajar en la etapa de la Lista de verificación operativa, anote primero el contrato: los datos de entrada requeridos, la señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación mantiene honestas las futuras modificaciones del código.

Documente tanto el camino óptimo como el de recuperación. Las reintentos, los controles humanos y el manejo de mensajes no entregados forman parte del producto, no son mejoras posteriores.

Mida el rendimiento de recuperación en un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente.

Fije las versiones de dependencias y registre el resumen de la imagen que se utilizó en la demostración. La reproducibilidad es mejor que el conocimiento basado en prácticas internas.

Prefiera unidades pequeñas y verificables en lugar de scripts extensos. Cuando falla un paso, el problema debe atribuirse a una única responsabilidad y no a un proceso complicado.

Mida el rendimiento de recuperación en un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente.

Antes de promocionar la solución, congele las versiones, guarde una transcripción de referencia para el proceso crítico y confirme los pasos para revertir cambios. Los entornos compartidos requieren límites de uso, verificaciones de asignación y un responsable claro para el cambio de credenciales. Prefiera una fiabilidad sencilla a demostraciones ingeniosas pero puntuales.

Nota por lotes para 5e57ac6d3d33: mantener las claves del proveedor fuera del repositorio, establecer un límite para los tokens por sesión y almacenar las transcripciones junto a los fixtures de evaluación para que los cambios posteriores en el modelo sigan siendo comparables.