[PostgreSQL 12/12] 업무 DB에서 RAG까지: PGMQ·문서 버전·하이브리드 검색

심대용·5일 전
post-thumbnail

이 글에서 다룰 주제

  • 처리 흐름: 업로드와 인덱싱 작업을 어떻게 연결하는가?
  • 일관성: 재시도·부분 완료·문서 수정 중 어떤 결과를 공개하는가?
  • 검색: 키워드와 벡터 경로에 권한·버전 조건을 어떻게 맞추는가?

주요 단어 · OLTP · PGMQ · Visibility Timeout · Idempotency · Generation · RRF · RAG


문서를 벡터로 저장하는 것만으로 RAG 운영이 완성되지는 않는다. 인덱싱 도중 문서가 바뀌거나 Worker가 죽을 수 있고, 사용자의 접근 권한이 취소될 수도 있다. 마지막 편에서는 앞서 공부한 트랜잭션·검색·연결 관리 지식을 문서 업로드부터 검색 공개까지의 한 흐름에 적용한다.

PGMQ는 PostgreSQL 기반 메시지 큐다. Generation은 특정 문서 버전과 처리 설정으로 만든 검색 데이터 묶음, RRF는 여러 검색 결과의 순위를 합치는 방법이다.

자료와 예제 기준 — PostgreSQL 18을 중심으로 개인 학습 노트를 재구성했다. SQL·실행 계획·설정값은 설명 및 재현용 예제이며 이 글을 위해 운영 DB에서 새로 측정한 결과는 아니다. DDL/DML 예제는 독립적인 테스트 환경에서 사용한다.

1. 업무 데이터와 검색 데이터 모델

설계 원칙

FastAPI·PostgreSQL·Object Storage·모델 서버를 사용하는 설명용 문서 처리 서비스를 가정한다. 작업 전달은 기존 Celery+Redis 구성 또는 PGMQ를 사용할 수 있으며, 둘을 반드시 함께 두는 구성은 아니다. 원본 업무 데이터가 기준이며, 청크와 벡터는 다시 만들 수 있는 파생 데이터다. 한 DB의 app/rag 스키마 분리는 논리적 분리이지 자원 격리가 아니다.

다음 두 경로를 구분하면 트랜잭션 경계가 명확해진다. 외부 브로커를 사용하면 업무 변경과 Outbox 이벤트를 함께 저장하고 Relay가 발행한다. 같은 DB의 PGMQ를 직접 소비하면 업무 변경과 pgmq.send를 같은 트랜잭션으로 묶을 수 있어 별도 Outbox Relay를 줄일 수 있다. 아래 app.outbox_events는 전자의 선택에 해당한다.

데이터 모델

테이블주요 필드책임
app.projectsid, name프로젝트
app.project_membersproject_id, user_id, role접근 권한
app.documentsid, project_id, current_version, deleted_at현재 문서 상태
app.document_versionsdocument_id, version, object_key, content_hash불변 원본 버전
app.translation_jobsid, status, progress작업 진행
app.outbox_eventsid, type, payload, published_at발행할 이벤트
rag.index_generationsid, document_id, source_version, pipeline_version, status인덱싱 세대
rag.document_headsdocument_id, active_generation_id검색 공개 포인터
rag.chunksid, generation_id, chunk_no, content, page_no, embedding근거와 검색 벡터

Generation은 특정 원본 버전과 처리 설정으로 만든 검색 데이터 묶음이다. pipeline_version은 파서·청킹·임베딩 모델 변경을 추적한다. 모델 변경은 별도 테이블·컬렉션 또는 명확히 분리된 검색 대상을 사용한다.

유일성 제약 예: document_versions(document_id, version), index_generations(document_id, source_version, pipeline_version), chunks(generation_id, chunk_no).

2. PGMQ로 작업 전달하기

개념과 위치

PGMQ는 PostgreSQL 테이블과 SQL 함수로 메시지를 저장·전달하는 큐다. 별도 브로커 서버를 줄일 수 있지만 실제 작업을 실행하는 Worker는 필요하다. 번역·OCR·문서 인덱싱·외부 호출 재시도 등에 활용할 수 있다.

메시지에는 큰 파일 전체보다 job_id·document_id·source_version·pipeline_version을 넣는다. 작업 상태 테이블과 메시지는 다른 책임이다. 큐에서 사라진 메시지를 업무 진행 상태의 유일한 기록으로 삼지 않는다.

Visibility Timeout

read는 메시지를 삭제하지 않고 일정 시간 다른 소비자에게 보이지 않게 한다. 처리 성공 시 delete/archive하고, 제거되지 않은 채 시간이 만료되면 다시 읽힐 수 있다.

문서의 visibility timeout 안에서 exactly-once delivery라는 표현은 외부 API까지 포함한 exactly-once 처리가 아니다. A가 LLM 호출 후 삭제 전에 죽으면 B가 다시 호출할 수 있다. timeout 만료는 A 프로세스를 종료시키지 않는다.

SQL 예제

PGMQ가 설치되어 있고 확장·큐 생성 권한이 있는 학습 환경을 전제로 한다.

CREATE EXTENSION IF NOT EXISTS pgmq;
SELECT pgmq.create('study_rag_indexing');
SELECT pgmq.send(
    'study_rag_indexing',
    '{"document_id":42,"source_version":3}'::jsonb
);
-- 읽기 트랜잭션은 짧게 커밋
SELECT * FROM pgmq.read('study_rag_indexing', 60, 1);
-- 성공 후: 123은 실제 반환 msg_id로 교체
SELECT pgmq.delete('study_rag_indexing', 123);
-- 또는 pgmq.archive('study_rag_indexing', 123)
함수역할
send발행, 지원 인자로 지연 가능
read가시성 제한을 설정하며 읽기
set_vt가시성 시각 조정·연장
delete처리한 메시지 제거
archive메시지 보관 테이블로 이동
pop읽으면서 제거

재처리가 필요한 긴 작업은 pop보다 read → 처리 → delete/archive가 적합하다. archive를 쓴다고 재실행이 자동 구현되는 것은 아니다. 보존·재발행 정책을 만든다.

업무 변경과 발행의 원자성

동일 DB·동일 연결·동일 트랜잭션에서 업무 INSERT와 pgmq.send를 실행하면 함께 커밋하거나 롤백할 수 있다.

-- 아래는 app.translation_jobs 및 translation 큐가 있다는 구조 예시
BEGIN;
INSERT INTO app.translation_jobs (id, status) VALUES (101, 'pending');
SELECT pgmq.send('translation', '{"job_id":101}'::jsonb);
COMMIT;

위의 ID 입력은 실제 스키마가 허용할 때만 사용한다. GENERATED ALWAYS ID라면 INSERT RETURNING으로 발급 ID를 받아 메시지를 만든다.

PGMQ 직접 소비 구성은 별도 Outbox Relay를 줄일 수 있다. 별도 DB의 PGMQ에 보내면 이 단일 트랜잭션 이점은 사라진다. 결과 저장과 메시지 삭제도 같은 DB라면 함께 커밋할 수 있지만 외부 LLM 호출은 포함되지 않는다.

안전한 Worker 흐름

  1. 짧은 트랜잭션으로 메시지를 읽고 커밋한다.
  2. 업무 키로 이미 완료된 요청인지 확인한다.
  3. 시도 ID·lease를 확보하고 파싱·LLM 호출을 수행한다.
  4. 필요하면 제한 시간을 연장한다. 시간 만료 직전의 경쟁도 고려한다.
  5. 짧은 트랜잭션으로 소유권·원본 버전을 확인하고 결과 저장 및 메시지 제거를 수행한다.
  6. 반복 실패는 backoff·최대 시도·실패 큐 또는 보류 상태로 관리한다.

큐 메시지 ID만으로 오래된 Worker의 쓰기를 차단할 수 있다고 가정하지 않는다. 업무 테이블에 실행 시도 ID나 fencing token을 두고 조건부 UPDATE한다. 외부 시스템이 멱등성 키를 지원하면 활용한다.

Celery·Redis와의 차이

요소계층
Redis/RabbitMQ브로커
Celery작업 실행 프레임워크
PGMQPostgreSQL 메시지 큐
PGMQ 소비 프로그램Worker 구현

Celery의 Redis URL을 PGMQ URL로 바꾸는 방식으로 전환되는 것은 아니다. Celery의 브로커 인터페이스와 호환되는 통합 여부를 별도로 확인해야 한다. 기존 Celery 재시도·스케줄·운영 도구 활용도가 높다면 교체 비용을 계산한다.

PGMQ는 내구성 있는 작업 큐로 평가하되 Kafka의 장기 로그·다중 소비자 offset 모델과 동일하다고 보지 않는다. 새 버전에는 그룹 FIFO·토픽 라우팅 기능도 있으므로 설치 버전과 사용 API를 확인한다. 기본 read를 병렬 사용한다고 업무 완료 순서가 자동 보장되지는 않는다.

관측과 Trade-off

  • 발행·read·삭제는 PostgreSQL WAL·I/O·VACUUM 부하를 만든다.
  • 빈 큐를 과도하게 폴링하지 않는다.
  • 대기 메시지 수, 가장 오래된 가시 메시지의 나이, 처리시간, 재시도·실패량, Worker 가용성을 관찰한다.
  • read_ct는 읽힌 횟수이며 비즈니스 실패 횟수와 반드시 같지는 않다.
  • 아카이브 보존량과 DB 백업·복원 범위를 정한다.
  • DB 장애가 업무 저장과 큐 전달에 함께 영향을 준다.

3. 인덱싱과 공개 버전 전환

업로드와 인덱싱

  1. Object Storage 업로드 완료를 확인한다.
  2. 짧은 DB 트랜잭션으로 원본 버전과 Outbox 이벤트를 저장한다. 같은 DB의 PGMQ 직접 소비 구성이라면 대신 큐 발행을 함께 커밋할 수 있다.
  3. Relay가 메시지를 발행하고 재시도한다.
  4. Worker가 멱등성 키와 lease로 Generation을 선점한다.
  5. 파싱/OCR → 정제 → 청크 분할 → 메타데이터 → 임베딩을 수행한다.
  6. 제한된 배치로 청크·벡터를 저장한다.
  7. 완전성을 검증한 뒤 공개 포인터를 전환한다.

DB 저장 실패로 남은 미참조 파일은 별도 정리한다. 메시지는 중복 전달될 수 있다. 이벤트 발행 성공과 published_at 기록 사이의 장애도 중복을 만들므로 소비자가 멱등해야 한다.

긴 모델 호출 동안 트랜잭션과 DB 연결을 붙잡지 않는다. Worker가 잃은 lease로 늦게 결과를 공개하지 못하도록 시도 ID 또는 fencing token을 검사한다.

부분 인덱싱 결과를 노출하지 않는다

단계검색 공개비공개 처리
V1 운영G1없음
V2 인덱싱G1 또는 정책상 검색 중단G2
검증 완료포인터를 G2로 전환G1 정리 대기
보존 기간 경과G2G1 배치 삭제

공개 트랜잭션에서 원본의 current_version, 삭제 여부, Worker 소유권을 확인한다. V3 처리 후 늦게 완료된 V2가 덮어쓰지 못하도록 조건부 갱신한다.

일반 참고자료는 이전 버전임을 표시하고 제공할 수 있다. 최신 규정만 허용하는 문서는 현재 버전 준비 전까지 제외한다. 삭제·권한 철회는 벡터 삭제 완료를 기다리지 않고 검색 자격에서 제외한다.

4. 질의와 하이브리드 검색

질의 경로

질문경로
작업 101의 진행률업무 SQL
오늘 실패한 작업 수SQL 집계
번역 가이드의 권장 표현RAG
비슷한 장애와 해결책RAG + 필요 시 현재 상태 SQL

RAG 흐름: 인증 → 질문 처리 → 질문 임베딩 → 권한·버전 조건을 포함한 검색 → 병합·중복 제거 → 선택적 rerank → 근거 조립 → 생성 → 평가.

후보 top-k와 최종 컨텍스트 개수를 구분한다. 예를 들어 후보 30개에서 5~8개를 선택할 수 있지만 실제 수치는 데이터와 컨텍스트 예산으로 평가한다. 낮은 후보 재현율을 reranker가 복구할 수는 없다.

검색 SQL 구조 예시

다음은 전체 DDL이 제공된 실행 스크립트가 아니라 테이블 관계를 설명하는 SQL이다.

SELECT c.id, c.content, c.page_no, d.id AS document_id, g.source_version
FROM rag.chunks c
JOIN rag.index_generations g ON g.id = c.generation_id
JOIN rag.document_heads h ON h.active_generation_id = g.id
JOIN app.documents d ON d.id = g.document_id
WHERE d.deleted_at IS NULL
  AND EXISTS (
    SELECT 1 FROM app.project_members m
    WHERE m.project_id = d.project_id AND m.user_id = $1
  )
ORDER BY c.embedding <=> $2
LIMIT $3;

복잡한 JOIN·필터가 있을 때 ANN 사용과 충분한 결과 수가 자동 보장되지는 않는다. 실제 실행 계획·Recall을 검증한다. project_id를 청크에 복제하면 검색이 단순해질 수 있지만 무결성 규칙이 필요하다.

RLS를 사용할 경우 일반 앱 계정에 테이블 소유자·BYPASSRLS 권한을 주지 않는다. 엄격한 권한 철회 정책은 진행 중 요청·캐시·스트리밍 응답까지 정의해야 한다. 이미 시작한 스냅샷이 즉시 무효화되는 것은 아니다.

하이브리드 검색

BM25는 구체적인 오류명과 제품명을 포착하고 벡터 검색은 표현이 다른 관련 사례를 보완한다. 두 경로에 동일한 권한·공개 버전·삭제 조건을 적용한다.

RRF: 점수 대신 순위를 결합하기

RRF(d) = 각 검색 목록에 대해 1 / (k + rank(d))를 더한 값

해당 목록에 없으면 기여는 0이다. k는 순위별 점수 차이를 완화하는 상수이며 반환 개수 top-k와 다르다. 예시 k=60일 때 문서 A가 BM25 1위·벡터 4위라면 1/61+1/64≈0.03202이다. 문서 B가 두 목록 모두 2위면 2/62≈0.03226으로 약간 높다.

RRF는 BM25 점수와 cosine 척도를 직접 더하지 않고 순위를 합친다. 품질 개선은 실험으로 확인해야 하며, 무관한 후보를 추가하면 오히려 나빠질 수 있다.

5. 운영·격리와 검증 시나리오

운영과 격리

  • 업무 API, RAG, 인덱싱 Worker의 역할·풀·동시성을 구분한다.
  • 모든 Pod·프로세스의 연결 예산을 합산한다.
  • 번역 큐와 인덱싱 큐의 실행 자원을 분리한다.
  • 검색에 statement_timeout, 잠금 대기에 lock_timeout을 적용한다.
  • 임베딩 배치가 온라인 번역과 같은 GPU를 사용하면 GPU 우선순위도 조절한다.
  • 오래된 Generation을 배치 삭제하고 dead tuple·VACUUM·WAL을 관측한다.
  • 원본, 처리 설정, 모델 버전을 보존해 재구축 가능하게 한다. 재생성 비용·복구 시간도 계산한다.

별도 DB로 분리하는 기준

업무 p95 지연, 인덱싱 적체, WAL·I/O, 검색 메모리, 백업·복원 시간이 목표를 벗어나면 분리를 검토한다. 읽기 Replica는 검색 읽기를 분산하지만 Primary의 인덱스 유지·벡터 쓰기를 제거하지 않는다.

DB를 분리하면 단일 트랜잭션·JOIN·외래 키의 장점을 잃는다. 권한·문서 버전의 동기화와 지연 정책을 별도로 구현한다.

검증 시나리오

  1. DB 커밋 직후 Redis 장애 → 이벤트가 나중에 발행되는가?
  2. 동일 메시지 중복 → 동일 Generation과 청크가 중복되지 않는가?
  3. V3가 V2보다 먼저 완료 → 최종 공개가 V3인가?
  4. 문서 삭제·권한 철회 → 신규 요청에 노출되지 않는가?
  5. 대량 재임베딩 → 업무 p95를 지키는가?
  6. 파싱 누락·표 오류·중복 청크 → 근거 품질 지표에서 탐지되는가?

자료 기준과 참고 문서

개인 PostgreSQL 학습 노트를 바탕으로 정리했다. 버전이나 설정에 따른 조건은 본문에 덧붙였다.

이어서 읽기 · ← 이전 편 · 전체 시리즈 목차

profile
어제보다 더 성장하는 나

0개의 댓글