Incrustar datos almacenados en una tabla PostgreSQL es sin duda útil, con aplicaciones que van desde sistemas de recomendación y búsqueda semántica hasta aplicaciones de inteligencia artificial generativa y generación aumentada de recuperación. Pero crear y administrar incrustaciones de datos en tablas de PostgreSQL puede ser complicado, con muchas consideraciones y casos extremos a tener en cuenta, como mantener las incrustaciones actualizadas con actualizaciones y eliminaciones de tablas, garantizar la resiliencia contra fallas y el impacto en los sistemas existentes que dependen de la mesa.
En esta publicación de blog, analizaremos las decisiones de diseño técnico y las compensaciones que hicimos al crear PgVectorizer para garantizar la simplicidad, la resiliencia y el alto rendimiento. También discutiremos diseños alternativos si desea crear el suyo propio.
Saltemos a ello.
Primero, describamos cómo funcionará el sistema que estamos construyendo. No dudes en saltarte esta sección si ya leíste el
Como ejemplo ilustrativo, usaremos una aplicación de blog simple que almacena datos en PostgreSQL usando una tabla definida de la siguiente manera:
CREATE TABLE blog ( id SERIAL PRIMARY KEY NOT NULL, title TEXT NOT NULL, author TEXT NOT NULL, contents TEXT NOT NULL, category TEXT NOT NULL, published_time TIMESTAMPTZ NULL --NULL if not yet published );
Queremos crear incrustaciones en el contenido de la publicación del blog para poder usarlo más tarde para la búsqueda semántica y la generación aumentada de recuperación de energía. Las incrustaciones solo deben existir y permitir búsquedas en blogs que se hayan publicado (donde el published_time
NOT NULL
).
Mientras construíamos este sistema de incrustaciones, pudimos identificar una serie de objetivos que debería tener cualquier sistema sencillo y resistente que cree incrustaciones:
Sin modificaciones a la tabla original. Esto permite que los sistemas y aplicaciones que ya utilizan esta tabla no se vean afectados por los cambios en el sistema de integración. Esto es especialmente importante para los sistemas heredados.
No hay modificaciones en las aplicaciones que interactúan con la mesa. Es posible que tener que modificar el código que altera la tabla no sea posible para los sistemas heredados. También es un diseño de software deficiente porque combina sistemas que no utilizan incrustaciones con el código que genera la incrustación.
Actualice automáticamente las incrustaciones cuando cambien las filas de la tabla de origen (en este caso, la tabla del blog). Esto reduce la carga de mantenimiento y contribuye a un software sin preocupaciones. Al mismo tiempo, esta actualización no tiene por qué ser instantánea ni estar dentro de la misma confirmación. Para la mayoría de los sistemas, la “consistencia final” está bien.
Garantice la resiliencia contra fallas de la red y del servicio: la mayoría de los sistemas generan incorporaciones mediante una llamada a un sistema externo, como la API OpenAI. En escenarios en los que el sistema externo no funciona o se produce un mal funcionamiento de la red, es imperativo que el resto del sistema de base de datos continúe funcionando.
Estas pautas fueron la base de una arquitectura robusta que implementamos utilizando el
Aquí está la arquitectura que elegimos:
En este diseño, primero agregamos un activador a la tabla del blog que monitorea los cambios y, al ver una modificación, inserta un trabajo en la tabla blog_work_queue que indica que una fila en la tabla del blog está desactualizada con su incrustación.
Según un cronograma fijo, un trabajo de creador de incrustaciones sondeará la tabla blog_work_queue y, si encuentra trabajo que hacer, hará lo siguiente en un bucle:
Para ver este sistema en acción, vea un ejemplo del uso para
Volviendo al ejemplo de la tabla de aplicaciones de nuestro blog, en un nivel alto, PgVectorizer tiene que hacer dos cosas:
Realice un seguimiento de los cambios en las filas del blog para saber qué filas han cambiado.
Proporcione un método para procesar los cambios para crear incrustaciones.
Ambos deben ser altamente concurrentes y eficientes. Vamos a ver cómo funciona.
Puede crear una tabla de cola de trabajos simple con lo siguiente:
CREATE TABLE blog_embedding_work_queue ( id INT ); CREATE INDEX ON blog_embedding_work_queue(id);
Esta es una tabla muy simple, pero hay un elemento a tener en cuenta: esta tabla no tiene una clave única. Esto se hizo para evitar problemas de bloqueo al procesar la cola, pero sí significa que es posible que tengamos duplicados. Analizaremos la compensación más adelante en la Alternativa 1 a continuación.
Luego creas un disparador para rastrear cualquier cambio realizado en blog
:
CREATE OR REPLACE FUNCTION blog_wq_for_embedding() RETURNS TRIGGER LANGUAGE PLPGSQL AS $$ BEGIN IF (TG_OP = 'DELETE') THEN INSERT INTO blog_embedding_work_queue VALUES (OLD.id); ELSE INSERT INTO blog_embedding_work_queue VALUES (NEW.id); END IF; RETURN NULL; END; $$; CREATE TRIGGER track_changes_for_embedding AFTER INSERT OR UPDATE OR DELETE ON blog FOR EACH ROW EXECUTE PROCEDURE blog_wq_for_embedding(); INSERT INTO blog_embedding_work_queue SELECT id FROM blog WHERE published_time is NOT NULL;
El activador inserta el ID del blog que ha cambiado a blog_work_queue. Instalamos el activador y luego insertamos los blogs existentes en work_queue. Este orden es importante para garantizar que no se pierda ninguna identificación.
Ahora, describamos algunos diseños alternativos y por qué los rechazamos.
La introducción de esta clave eliminaría el problema de las entradas duplicadas. Sin embargo, no está exento de desafíos, particularmente porque dicha clave nos obligaría a usar la cláusula INSERT…ON CONFLICT DO NOTHING
para insertar nuevos ID en la tabla, y esa cláusula bloquea el ID en el árbol B.
Aquí está el dilema: durante la fase de procesamiento, es necesario eliminar las filas en las que se está trabajando para evitar el procesamiento simultáneo. Sin embargo, esta eliminación solo se puede realizar después de que se haya colocado la incrustación correspondiente en blog_embeddings. Esto garantiza que no se pierdan ID si hay una interrupción a medio camino, por ejemplo, si la creación de la incrustación falla después de la eliminación pero antes de que se escriba la incrustación.
Ahora, si creamos una clave única o primaria, la transacción que supervisa la eliminación permanece abierta. En consecuencia, esto actúa como un bloqueo en esos ID específicos, evitando su inserción nuevamente en blog_work_queue durante toda la duración del trabajo de creación de incrustación. Dado que se necesita más tiempo para crear incrustaciones que una transacción típica de base de datos, esto significa problemas. El bloqueo detendría el activador de la tabla principal del 'blog', lo que provocaría una caída en el rendimiento de la aplicación principal. Para empeorar las cosas, si se procesan varias filas en un lote, los interbloqueos también se convierten en un problema potencial.
Sin embargo, los problemas potenciales que surgen de entradas duplicadas ocasionales se pueden gestionar durante la etapa de procesamiento, como se ilustra más adelante. Un duplicado esporádico aquí y allá no es un problema ya que sólo aumenta marginalmente la cantidad de trabajo que realiza el trabajo de incrustación. Sin duda, esto es más aceptable que lidiar con los desafíos de bloqueo mencionados anteriormente.
Por ejemplo, podríamos agregar una columna booleana embedded
establecida en falso al realizar la modificación y convertida a verdadera cuando se crea la incrustación. Hay tres razones para rechazar este diseño:
No queremos modificar la tabla blog
por los motivos que ya mencionamos anteriormente.
Obtener de manera eficiente una lista de blogs no integrados requeriría un índice adicional (o índice parcial) en la tabla de blogs. Esto ralentizaría otras operaciones.
Esto aumenta la rotación en la tabla porque cada modificación ahora se escribiría dos veces (una con embedding=false y otra con embedding=true) debido a la naturaleza MVCC de PostgreSQL.
Un work_queue_table separado resuelve estos problemas.
Este enfoque tiene varios problemas:
Si el servicio de incrustación no funciona, el activador debe fallar (anulando su transacción) o necesita crear una ruta de código de respaldo que... almacene las identificaciones que no se pudieron incrustar en una cola. La última solución nos devuelve a nuestro diseño propuesto pero con más complejidad incorporada.
Este activador probablemente será mucho más lento que el resto de las operaciones de la base de datos debido a la latencia necesaria para contactar con un servicio externo. Esto ralentizará el resto de las operaciones de su base de datos en la tabla.
Obliga al usuario a escribir el código de creación directamente en la base de datos. Dado que la lengua franca de la IA es Python y que la creación de incrustaciones a menudo requiere muchas otras bibliotecas, esto no siempre es fácil o incluso posible (especialmente si se ejecuta dentro de un entorno de nube alojado de PostgreSQL). Es mucho mejor tener un diseño en el que tenga la opción de crear incrustaciones dentro o fuera de la base de datos.
Ahora que tenemos una lista de blogs que deben insertarse, ¡procesemos la lista!
Hay muchas formas de crear incrustaciones. Recomendamos utilizar un script Python externo. Este script escaneará la cola de trabajo y las publicaciones de blog relacionadas, invocará un servicio externo para crear las incrustaciones y luego almacenará estas incrustaciones nuevamente en la base de datos. Nuestro razonamiento para esta estrategia es el siguiente:
Elección de Python : recomendamos Python porque ofrece un ecosistema rico e inigualable para tareas de datos de IA, destacado por un potente desarrollo de LLM y bibliotecas de datos como
Optar por un script externo en lugar de PL/Python : queríamos que los usuarios tuvieran control sobre cómo incrustan sus datos. Sin embargo, al mismo tiempo, muchos proveedores de nube de Postgres no permiten la ejecución de código Python arbitrario dentro de la base de datos por motivos de seguridad. Entonces, para permitir a los usuarios tener flexibilidad tanto en sus scripts de inserción como en el lugar donde alojan su base de datos, optamos por un diseño que utilizaba scripts Python externos.
Los trabajos deben ser eficaces y seguros para la simultaneidad. La concurrencia garantiza que si los trabajos comienzan a retrasarse, los programadores pueden iniciar más trabajos para ayudar al sistema a ponerse al día y manejar la carga.
Veremos cómo configurar cada uno de esos métodos más adelante, pero primero, veamos cómo se vería el script de Python. Fundamentalmente, el guión consta de tres partes:
Lea la cola de trabajo y la publicación del blog.
Crear una incrustación para la publicación del blog.
Escriba la incrustación en la tabla blog_embedding
Los pasos 2 y 3 se realizan mediante una devolución de llamada embed_and_write
que definimos en el
Primero le mostraremos el código y luego resaltaremos los elementos clave en juego:
def process_queue(embed_and_write_cb, batch_size:int=10): with psycopg2.connect(TIMESCALE_SERVICE_URL) as conn: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: cursor.execute(f""" SELECT to_regclass('blog_embedding_work_queue')::oid; """) table_oid = cursor.fetchone()[0] cursor.execute(f""" WITH selected_rows AS ( SELECT id FROM blog_embedding_work_queue LIMIT {int(batch_size)} FOR UPDATE SKIP LOCKED ), locked_items AS ( SELECT id, pg_try_advisory_xact_lock( {int(table_oid)}, id) AS locked FROM ( SELECT DISTINCT id FROM selected_rows ORDER BY id ) as ids ), deleted_rows AS ( DELETE FROM blog_embedding_work_queue WHERE id IN ( SELECT id FROM locked_items WHERE locked = true ORDER BY id ) ) SELECT locked_items.id as locked_id, {self.table_name}.* FROM locked_items LEFT JOIN blog ON blog.id = locked_items.id WHERE locked = true ORDER BY locked_items.id """) res = cursor.fetchall() if len(res) > 0: embed_and_write_cb(res) return len(res) process_queue(embed_and_write)
El código SQL en el fragmento anterior es sutil porque está diseñado para ser eficaz y seguro para la concurrencia, así que repasémoslo:
Sacar elementos de la cola de trabajos : inicialmente, el sistema recupera una cantidad específica de entradas de la cola de trabajos, determinada por el parámetro de tamaño de la cola de lotes. Se utiliza un bloqueo FOR UPDATE para garantizar que los scripts que se ejecutan simultáneamente no intenten procesar los mismos elementos de la cola. La directiva SKIP LOCKED garantiza que si alguna entrada está siendo manejada actualmente por otro script, el sistema la omitirá en lugar de esperar, evitando demoras innecesarias.
Bloqueo de ID de blog : debido a la posibilidad de que haya entradas duplicadas para el mismo blog_id dentro de la tabla de cola de trabajo, simplemente bloquear dicha tabla no es suficiente. El procesamiento simultáneo de la misma identificación por parte de diferentes trabajos sería perjudicial. Considere la siguiente posible condición de carrera:
El trabajo 1 inicia y accede a un blog, recuperando la versión 1.
Se produce una actualización externa del blog.
Posteriormente se inicia el Trabajo 2 obteniendo la versión 2.
Ambos trabajos inician el proceso de generación de incrustaciones.
El trabajo 2 concluye, almacenando la incrustación correspondiente a la versión 2 del blog.
El trabajo 1, al finalizar, sobrescribe erróneamente la versión 2 incrustada con la versión 1 desactualizada.
Si bien se podría contrarrestar este problema introduciendo un seguimiento explícito de versiones, introduce una complejidad considerable sin beneficio de rendimiento. La estrategia por la que optamos no solo mitiga este problema sino que también evita operaciones redundantes y trabajo desperdiciado al ejecutar scripts simultáneamente.
Se emplea un bloqueo de aviso de Postgres, con el prefijo del identificador de tabla para evitar posibles superposiciones con otros bloqueos similares. La variante try
, análoga a la aplicación anterior de SKIP LOCKED, garantiza que el sistema evite esperar en las cerraduras. La inclusión de la cláusula ORDER BY blog_id ayuda a evitar posibles bloqueos. Cubriremos algunas alternativas a continuación.
Limpiar la cola de trabajo : el script luego elimina todos los elementos de la cola de trabajo de los blogs que hemos bloqueado con éxito. Si estos elementos de la cola son visibles a través del Control de simultaneidad de versiones múltiples (MVCC), sus actualizaciones se manifiestan en la fila del blog recuperada. Tenga en cuenta que eliminamos todos los elementos con el ID de blog proporcionado, no solo los elementos leídos al seleccionar las filas: esto maneja de manera efectiva entradas duplicadas para el mismo ID de blog. Es crucial tener en cuenta que esta eliminación solo se confirma después de invocar la función embed_and_write() y el posterior almacenamiento de la incrustación actualizada. Esta secuencia garantiza que no perdamos ninguna actualización incluso si el script falla durante la fase de generación de incrustación.
Hacer que los blogs se procesen: en el último paso, recuperamos los blogs para procesar. Tenga en cuenta el uso de la unión izquierda: eso nos permite recuperar los ID del blog para los elementos eliminados que no tendrán una fila de blog. Necesitamos rastrear esos elementos para eliminar sus incrustaciones. En la devolución de llamada embed_and_write
, utilizamos Published_time siendo NULL como centinela para el blog que se elimina (o no se publica, en cuyo caso también queremos eliminar la incrustación).
Si el sistema ya utiliza bloqueos de aviso y le preocupan las colisiones, es posible utilizar una tabla con un ID de blog como clave principal y bloquear las filas. De hecho, esta puede ser la propia tabla del blog si está seguro de que estos bloqueos no ralentizarán ningún otro sistema (recuerde, estos bloqueos deben mantenerse durante todo el proceso de integración, lo que puede llevar un tiempo).
Alternativamente, puede tener una tabla blog_embedding_locks solo para este propósito. No sugerimos crear esa tabla porque creemos que puede ser un desperdicio en términos de espacio, y el uso de bloqueos de aviso evita esta sobrecarga.
En esta publicación de blog, le brindamos una mirada detrás de escena de cómo creamos un sistema que cuenta con resiliencia y maneja de manera efectiva posibles tiempos de inactividad del servicio de generación integrada. Su diseño es experto en gestionar una alta tasa de modificaciones de datos y puede utilizar sin problemas procesos simultáneos de generación de incrustaciones para adaptarse a cargas elevadas.
Además, el paradigma de enviar datos a PostgreSQL y utilizar la base de datos para gestionar la generación de incrustaciones en segundo plano surge como un mecanismo sencillo para supervisar el mantenimiento de la incrustación en medio de modificaciones de datos. Una gran cantidad de demostraciones y tutoriales en el espacio de la IA se centran singularmente en la creación inicial de datos a partir de documentos, pasando por alto los intrincados matices asociados con la preservación de la sincronización de datos a medida que evoluciona.
Sin embargo, en entornos de producción reales, los datos cambian invariablemente y lidiar con las complejidades de rastrear y sincronizar estos cambios no es una tarea trivial. ¡Pero para eso está diseñada una base de datos! ¿Por qué no simplemente usarlo?
Escrito por Matvey Arye.
También publicado aquí.