PostgreSQL 테이블에 저장된 데이터를 내장하는 것은 의미론적 검색 및 추천 시스템부터 생성 AI 애플리케이션 및 검색 증강 생성에 이르기까지 다양한 애플리케이션에 의심할 여지 없이 유용합니다. 그러나 PostgreSQL 테이블의 데이터에 대한 임베딩을 생성하고 관리하는 것은 테이블 업데이트 및 삭제를 통해 임베딩을 최신 상태로 유지하고 장애에 대한 복원력을 보장하며 기존 시스템에 종속되는 영향과 같이 고려해야 할 많은 고려 사항과 엣지 케이스로 인해 까다로울 수 있습니다. 탁자.
이 블로그 게시물에서는 단순성, 탄력성 및 고성능을 보장하기 위해 PgVectorizer를 구축하는 동안 기술적 설계 결정과 절충점에 대해 논의하겠습니다. 또한 자신만의 디자인을 만들고 싶다면 대체 디자인에 대해서도 논의할 것입니다.
그것에 뛰어 들어 봅시다.
먼저, 우리가 구축하고 있는 시스템이 어떻게 작동하는지 설명하겠습니다. 이미 해당 섹션을 읽으셨다면 이 섹션을 건너뛰셔도 됩니다.
예시로서 다음과 같이 정의된 테이블을 사용하여 PostgreSQL에 데이터를 저장하는 간단한 블로그 애플리케이션을 사용하겠습니다.
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 );
우리는 나중에 의미 검색 및 전력 검색 증강 생성에 사용할 수 있도록 블로그 게시물의 콘텐츠에 임베딩을 생성하려고 합니다. 임베딩은 게시된 블로그( published_time
NOT NULL
경우)에 대해서만 존재하고 검색 가능해야 합니다.
이 임베딩 시스템을 구축하는 동안 우리는 임베딩을 생성하는 간단하고 탄력적인 시스템이 가져야 하는 여러 목표를 식별할 수 있었습니다.
원본 테이블을 수정하지 않습니다. 이를 통해 이미 이 테이블을 사용하는 시스템과 애플리케이션이 임베딩 시스템 변경으로 인해 영향을 받지 않게 됩니다. 이는 레거시 시스템에 특히 중요합니다.
테이블과 상호 작용하는 애플리케이션은 수정되지 않습니다. 레거시 시스템에서는 테이블을 변경하는 코드를 수정하는 것이 불가능할 수 있습니다. 또한 임베딩을 생성하는 코드와 임베딩을 사용하지 않는 시스템을 결합하기 때문에 소프트웨어 설계가 좋지 않습니다.
소스 테이블(이 경우 블로그 테이블)의 행이 변경되면 임베딩을 자동으로 업데이트합니다 . 이는 유지 관리 부담을 줄이고 걱정 없는 소프트웨어에 기여합니다. 동시에 이 업데이트는 즉각적이거나 동일한 커밋 내에서 이루어질 필요는 없습니다. 대부분의 시스템에서는 "최종 일관성"이 괜찮습니다.
네트워크 및 서비스 오류에 대한 복원력 보장: 대부분의 시스템은 OpenAI API와 같은 외부 시스템에 대한 호출을 통해 임베딩을 생성합니다. 외부 시스템이 다운되거나 네트워크 오작동이 발생하는 시나리오에서는 데이터베이스 시스템의 나머지 부분이 계속 작동하는 것이 중요합니다.
이러한 지침은 우리가 다음을 사용하여 구현한 강력한 아키텍처의 기초였습니다.
우리가 결정한 아키텍처는 다음과 같습니다.
이 디자인에서는 먼저 변경 사항을 모니터링하는 트리거를 블로그 테이블에 추가하고, 수정 사항이 확인되면 블로그 테이블의 행이 포함된 내용에 따라 오래되었음을 나타내는 작업을 blog_work_queue 테이블에 삽입합니다.
고정된 일정에 따라 임베딩 생성자 작업은 blog_work_queue 테이블을 폴링하고 수행할 작업을 찾으면 루프에서 다음을 수행합니다.
이 시스템이 실제로 작동하는 모습을 보려면 다음 사용법의 예를 참조하세요.
블로그 애플리케이션 테이블의 예로 돌아가서 높은 수준에서 PgVectorizer는 두 가지 작업을 수행해야 합니다.
블로그 행의 변경 사항을 추적하여 어떤 행이 변경되었는지 알아보세요.
임베딩을 생성하기 위해 변경 사항을 처리하는 방법을 제공합니다.
이 두 가지 모두 동시성과 성능이 뛰어나야 합니다. 그것이 어떻게 작동하는지 봅시다.
다음을 사용하여 간단한 작업 대기열 테이블을 만들 수 있습니다.
CREATE TABLE blog_embedding_work_queue ( id INT ); CREATE INDEX ON blog_embedding_work_queue(id);
이것은 매우 간단한 테이블이지만 한 가지 주의할 점이 있습니다. 이 테이블에는 고유 키가 없습니다. 이는 대기열을 처리할 때 잠금 문제를 방지하기 위해 수행되었지만 이는 중복이 있을 수 있음을 의미합니다. 우리는 나중에 대안 1에서 트레이드오프에 대해 논의합니다.
그런 다음 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;
트리거는 blog_work_queue에 변경된 블로그의 ID를 삽입합니다. 트리거를 설치한 다음 기존 블로그를 work_queue에 삽입합니다. 이 순서는 ID가 삭제되지 않도록 하는 데 중요합니다.
이제 몇 가지 대체 디자인과 이를 거부한 이유를 설명하겠습니다.
이 키를 도입하면 중복 항목 문제가 해결됩니다. 그러나 문제가 없는 것은 아닙니다. 특히 이러한 키를 사용하면 INSERT…ON CONFLICT DO NOTHING
절을 사용하여 테이블에 새 ID를 삽입해야 하고 해당 절은 B-트리의 ID를 잠그기 때문입니다.
딜레마는 다음과 같습니다. 처리 단계에서는 동시 처리를 방지하기 위해 작업 중인 행을 삭제해야 합니다. 그러나 이 삭제 커밋은 해당 임베딩이 blog_embeddings에 배치된 후에만 수행할 수 있습니다. 이렇게 하면 중간에 중단이 발생하더라도 ID가 손실되지 않습니다. 예를 들어 포함 생성이 삭제 후 임베딩이 기록되기 전에 충돌하는 경우입니다.
이제 고유 키 또는 기본 키를 생성하면 삭제를 감독하는 트랜잭션이 계속 열려 있습니다. 결과적으로 이는 특정 ID에 대한 잠금 역할을 하여 포함 생성 작업 전체 기간 동안 blog_work_queue에 다시 삽입되는 것을 방지합니다. 일반적인 데이터베이스 트랜잭션보다 임베딩을 생성하는 데 시간이 더 오래 걸린다는 점을 고려하면 이는 문제를 야기합니다. 잠금은 기본 '블로그' 테이블에 대한 트리거를 정지시켜 기본 애플리케이션의 성능을 저하시킵니다. 설상가상으로 여러 행을 일괄 처리하는 경우 교착 상태도 잠재적인 문제가 됩니다.
그러나 가끔 중복된 항목으로 인해 발생하는 잠재적인 문제는 나중에 설명하는 것처럼 처리 단계에서 관리할 수 있습니다. 여기저기서 산발적인 중복이 발생하면 임베딩 작업이 수행하는 작업량이 약간만 늘어나므로 문제가 되지 않습니다. 이것은 위에서 언급한 잠금 문제를 해결하는 것보다 확실히 더 맛있습니다.
예를 들어, 수정 시 false로 설정되고 포함이 생성될 때 true로 전환되는 embedded
부울 열을 추가할 수 있습니다. 이 디자인을 거부하는 세 가지 이유는 다음과 같습니다.
위에서 이미 언급한 이유로 blog
테이블을 수정하고 싶지 않습니다.
포함되지 않은 블로그 목록을 효율적으로 가져오려면 블로그 테이블에 추가 인덱스(또는 부분 인덱스)가 필요합니다. 이로 인해 다른 작업 속도가 느려질 수 있습니다.
PostgreSQL의 MVCC 특성으로 인해 이제 모든 수정 사항이 두 번(embedding=false로 한 번, embedding=true로 한 번) 기록되므로 테이블 이탈이 증가합니다.
별도의 work_queue_table이 이러한 문제를 해결합니다.
이 접근 방식에는 다음과 같은 몇 가지 문제가 있습니다.
임베딩 서비스가 다운된 경우 트리거가 실패하거나(트랜잭션 중단) 대기열에 임베드할 수 없는 ID를 저장하는 백업 코드 경로를 생성해야 합니다. 후자의 솔루션을 사용하면 제안된 설계로 돌아가지만 그 위에 더 많은 복잡성이 추가됩니다.
이 트리거는 외부 서비스에 연결하는 데 필요한 대기 시간으로 인해 나머지 데이터베이스 작업보다 훨씬 느릴 수 있습니다. 이로 인해 테이블의 나머지 데이터베이스 작업 속도가 느려집니다.
이는 사용자가 생성 임베딩 코드를 데이터베이스에 직접 작성하도록 강제합니다. AI의 공용어가 Python이고 임베딩 생성에 다른 많은 라이브러리가 필요한 경우가 많다는 점을 고려하면 이는 항상 쉽거나 가능한 것은 아닙니다(특히 호스팅된 PostgreSQL 클라우드 환경 내에서 실행되는 경우). 데이터베이스 내부 또는 외부에 임베딩을 생성하도록 선택할 수 있는 디자인을 갖는 것이 훨씬 좋습니다.
이제 삽입해야 할 블로그 목록이 생겼습니다. 목록을 처리해 보겠습니다.
임베딩을 생성하는 방법에는 여러 가지가 있습니다. 외부 Python 스크립트를 사용하는 것이 좋습니다. 이 스크립트는 작업 대기열과 관련 블로그 게시물을 스캔하고 외부 서비스를 호출하여 임베딩을 만든 다음 이러한 임베딩을 다시 데이터베이스에 저장합니다. 이 전략에 대한 우리의 추론은 다음과 같습니다.
Python 선택 : Python은 강력한 LLM 개발 및 다음과 같은 데이터 라이브러리를 통해 강조된 AI 데이터 작업을 위한 풍부하고 비교할 수 없는 생태계를 제공하므로 Python을 권장합니다.
PL/Python 대신 외부 스크립트 선택 : 우리는 사용자가 데이터 삽입 방법을 제어할 수 있기를 원했습니다. 그러나 동시에 많은 Postgres 클라우드 제공업체는 보안 문제로 인해 데이터베이스 내부에서 임의의 Python 코드 실행을 허용하지 않습니다. 따라서 사용자가 내장 스크립트와 데이터베이스 호스팅 위치 모두에서 유연성을 가질 수 있도록 외부 Python 스크립트를 사용하는 디자인을 선택했습니다.
작업은 성능이 뛰어나고 동시성이 안전해야 합니다. 동시성은 작업이 뒤쳐지기 시작하면 스케줄러가 더 많은 작업을 시작하여 시스템이 로드를 따라잡고 처리할 수 있도록 보장합니다.
나중에 각 메서드를 설정하는 방법을 살펴보겠습니다. 먼저 Python 스크립트가 어떻게 생겼는지 살펴보겠습니다. 기본적으로 스크립트는 세 부분으로 구성됩니다.
작업 대기열 및 블로그 게시물 읽기
블로그 게시물에 대한 임베딩 만들기
blog_embedding 테이블에 임베딩을 작성합니다.
2단계와 3단계는 다음에서 정의한 embed_and_write
콜백에 의해 수행됩니다.
먼저 코드를 보여주고 실행 중인 핵심 요소를 강조하겠습니다.
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)
위 코드 조각의 SQL 코드는 성능과 동시성 안전성을 모두 고려하여 설계되었기 때문에 미묘합니다. 따라서 살펴보겠습니다.
작업 대기열에서 항목 가져오기 : 처음에 시스템은 일괄 대기열 크기 매개변수에 따라 결정된 작업 대기열에서 지정된 수의 항목을 검색합니다. FOR UPDATE 잠금은 동시에 실행되는 스크립트가 동일한 대기열 항목 처리를 시도하지 않도록 하기 위해 사용됩니다. SKIP LOCKED 지시문은 항목이 현재 다른 스크립트에 의해 처리되고 있는 경우 시스템이 이를 기다리지 않고 건너뛰도록 하여 불필요한 지연을 방지합니다.
블로그 ID 잠금 : 작업 대기열 테이블 내에서 동일한 blog_id에 대한 중복 항목 가능성으로 인해 단순히 해당 테이블을 잠그는 것만으로는 충분하지 않습니다. 다른 작업에서 동일한 ID를 동시에 처리하면 해로울 수 있습니다. 다음과 같은 잠재적 경쟁 조건을 고려하십시오.
작업 1은 블로그를 시작하고 액세스하여 버전 1을 검색합니다.
블로그에 대한 외부 업데이트가 발생합니다.
이어서 Job 2가 시작되어 버전 2를 얻습니다.
두 작업 모두 임베딩 생성 프로세스를 시작합니다.
작업 2는 블로그 버전 2에 해당하는 임베딩을 저장하면서 완료됩니다.
결론적으로 작업 1은 버전 2 임베딩을 오래된 버전 1로 잘못 덮어씁니다.
명시적인 버전 추적을 도입하여 이 문제에 대응할 수 있지만 성능 이점 없이 상당한 복잡성이 발생합니다. 우리가 선택한 전략은 이 문제를 완화할 뿐만 아니라 스크립트를 동시에 실행하여 중복 작업과 낭비되는 작업을 방지합니다.
다른 잠금과의 잠재적인 중복을 피하기 위해 테이블 식별자가 앞에 붙은 Postgres 권고 잠금이 사용됩니다. SKIP LOCKED의 이전 적용과 유사한 try
변형은 시스템이 잠금 대기를 방지하도록 합니다. ORDER BY blog_id 절을 포함하면 잠재적인 교착 상태를 방지하는 데 도움이 됩니다. 아래에서 몇 가지 대안을 다루겠습니다.
작업 대기열 정리 : 그런 다음 스크립트는 성공적으로 잠긴 블로그에 대한 모든 작업 대기열 항목을 삭제합니다. 이러한 대기열 항목이 MVCC(다중 버전 동시성 제어)를 통해 표시되는 경우 해당 업데이트는 검색된 블로그 행에 나타납니다. 행을 선택할 때 읽은 항목뿐만 아니라 지정된 블로그 ID를 가진 모든 항목을 삭제합니다. 이는 동일한 블로그 ID에 대한 중복 항목을 효과적으로 처리합니다. 이 삭제는 embed_and_write() 함수와 업데이트된 포함의 후속 저장을 호출한 후에만 커밋된다는 점에 유의하는 것이 중요합니다. 이 순서는 포함 생성 단계에서 스크립트가 실패하더라도 업데이트가 손실되지 않도록 보장합니다.
처리할 블로그 가져오기: 마지막 단계에서는 처리할 블로그를 가져옵니다. 왼쪽 조인을 사용하면 블로그 행이 없는 삭제된 항목에 대한 블로그 ID를 검색할 수 있습니다. 임베딩을 삭제하려면 해당 항목을 추적해야 합니다. embed_and_write
콜백에서는 게시된_시간이 삭제되는(또는 게시 취소되는 경우 포함도 삭제하려는 경우) 블로그에 대한 센티널로 NULL인 게시된 시간을 사용합니다.
시스템에서 이미 권고 잠금을 사용하고 있고 충돌이 걱정되는 경우 블로그 ID가 있는 테이블을 기본 키로 사용하고 행을 잠글 수 있습니다. 실제로 이러한 잠금으로 인해 다른 시스템의 속도가 느려지지 않는다고 확신하는 경우 이는 블로그 테이블 자체일 수 있습니다(이러한 잠금은 포함 프로세스 전반에 걸쳐 유지되어야 하며 시간이 걸릴 수 있음을 기억하십시오).
또는 이 목적으로만 blog_embedding_locks 테이블을 가질 수 있습니다. 우리는 공간 측면에서 상당히 낭비가 될 수 있고 권고 잠금을 사용하면 이러한 오버헤드를 피할 수 있다고 생각하기 때문에 해당 테이블을 생성하는 것을 제안하지 않았습니다.
이 블로그 게시물에서는 복원력을 자랑하고 임베딩 생성 서비스의 잠재적인 가동 중지 시간을 효과적으로 처리하는 시스템을 구축한 방법에 대한 비하인드 스토리를 제공했습니다. 해당 설계는 높은 비율의 데이터 수정을 관리하는 데 적합하며 동시 임베딩 생성 프로세스를 원활하게 사용하여 증가된 로드를 수용할 수 있습니다.
또한 데이터를 PostgreSQL에 커밋하고 데이터베이스를 사용하여 백그라운드에서 임베딩 생성을 관리하는 패러다임은 데이터 수정 중에 임베딩 유지를 감독하는 쉬운 메커니즘으로 나타납니다. AI 분야의 수많은 데모와 튜토리얼은 문서에서 데이터를 처음 생성하는 과정에만 초점을 맞추고 있으며, 데이터 동기화가 진행됨에 따라 보존하는 것과 관련된 복잡한 뉘앙스를 간과하고 있습니다.
그러나 실제 생산 환경에서는 데이터가 항상 변경되므로 이러한 변화를 추적하고 동기화하는 복잡성을 해결하는 것은 결코 쉬운 일이 아닙니다. 하지만 이것이 바로 데이터베이스가 수행하도록 설계된 것입니다! 그냥 사용하면 어떨까요?
Matvey Arye가 작성했습니다.