pgvector와 Amazon Bedrock을 활용한 RAG 및 벡터 검색 (Part 4)
요약
별도의 벡터 데이터베이스 구독 없이 pgvector 확장을 사용하여 기존 Postgres 데이터베이스 내에서 RAG(검색 증강 생성)를 구축하는 방법을 설명합니다. Amazon Bedrock의 Titan Embed v2 모델을 활용하여 텍스트를 임베딩하고, pgvector를 통해 유사도 검색을 수행하며, Claude를 통해 인용이 포함된 답변을 생성하는 전체 파이프라인을 다룹니다.
핵심 포인트
- pgvector를 사용하면 기존 Postgres의 행 수준 보안(RLS) 정책을 유지하며 벡터 검색이 가능함
- RAG 구현 시 데이터 수집과 쿼리 시점에 반드시 동일한 임베딩 모델을 사용해야 함
- Amazon Bedrock의 Titan Embed v2는 256, 512, 1024 등 다양한 차원을 지원하며 차원이 높을수록 검색 품질이 향상됨
- 외부 벡터 저장소(Pinecone, Chroma 등) 도입 시 발생하는 추가 비용과 관리 복잡성을 줄일 수 있음
pgvector와 Amazon Bedrock을 활용한 RAG 및 벡터 검색 (Part 4)
벡터 데이터베이스 구독 없이도 실제로 출처를 인용하는 검색 증강 생성 (RAG, Retrieval-Augmented Generation)을 구축하는 방법입니다. 대부분의 RAG 튜토리얼은 벡터 저장소(Vector Store)로 Pinecone, Chroma 또는 Weaviate를 사용합니다. 이들은 모두 훌륭한 서비스이지만, 추가적인 비용 항목, 또 다른 인증 경계, 그리고 직접 제어할 수 없는 의존성을 추가합니다. 만약 이미 Postgres를 실행 중이라면 — 멀티 테넌트 SaaS (Multi-tenant SaaS)를 운영 중이라면 반드시 그래야 합니다 — pgvector 확장을 통해 기존 데이터베이스 내부에서 이미 보유하고 있는 행 수준 보안 (Row-Level Security, RLS) 정책의 보호를 받으며 벡터 유사도 검색 (Vector Similarity Search)을 수행할 수 있습니다. 이 포스트에서는 Sift에서의 전체 쿼리 경로를 다룹니다: 사용자의 질문이 어떻게 벡터가 되는지, pgvector가 어떻게 가장 유사한 문서 청크 (Document Chunks)를 찾는지, 그리고 Claude가 어떻게 그 청크들을 인용된 답변으로 변환하는지를 설명합니다.
RAG가 실제로 하는 일
핵심 아이디어는 간단합니다. 쿼리 시점에:
- 문서를 임베딩 (Embedding)할 때 사용한 것과 동일한 모델로 사용자의 질문을 임베딩합니다.
- 질문 임베딩과 가장 가까운 임베딩을 가진 문서 청크를 찾습니다.
- 해당 청크들을 LLM (Large Language Model)에 보내고, 오직 해당 컨텍스트 (Context)만을 사용하여 질문에 답하도록 지시합니다.
- 원문 텍스트로 연결되는 번호가 매겨진 인용 (Citations)과 함께 답변을 반환합니다.
그게 전부입니다. 정교함은 각 단계의 세부 사항에 있습니다.
Bedrock Titan Embed v2를 사용한 임베딩
파이프라인 (데이터 수집 시점)과 채팅 핸들러 (쿼리 시점) 모두 동일한 임베딩 모델인 amazon.titan-embed-text-v2:0을 사용합니다. 검색의 양쪽 측면 모두에 동일한 모델을 사용하는 것은 필수 요구 사항입니다. 서로 다른 모델에서 생성된 임베딩은 호환되지 않는 벡터 공간 (Vector Spaces)에 존재하기 때문입니다.
파이프라인의 공유 모듈에 구현된 Python 코드:
EMBED_MODEL_ID = " amazon.titan-embed-text-v2:0 "
def embed ( text : str ) -> list [ float ]:
payload = json . dumps ({
" inputText " : text ,
" dimensions " : 1024 ,
" normalize " : True
})
response = _get_client (). invoke_model (
modelId = EMBED_MODEL_ID ,
contentType = " application/json " ,
accept = " application/json " ,
body = payload ,
)
return json .
loads ( response [ " body " ]. read ()) [ " embedding " ]
주의 깊게 살펴볼 두 가지 파라미터가 있습니다.
- dimensions: 1024 — Titan Embed v2는 여러 출력 크기(256, 512 또는 1024 차원)를 지원합니다. 차원 수가 적을수록 저장 공간은 줄어들고 검색 속도는 빨라지지만, 어느 정도의 정밀도(precision)를 희생해야 합니다. 1024는 최대치이며 가장 좋은 검색 품질(retrieval quality)을 제공합니다. 이 정도 규모의 데모를 진행할 때는 이를 희생할 이유가 없습니다.
- normalize: True — 이는 Bedrock에 단위 길이 벡터(unit-length vector)를 반환하도록 요청하는 것입니다. 정규화된 임베딩(Normalized embeddings)은 코사인 유사도(cosine similarity)가 내적(dot product)과 동일함을 의미합니다. pgvector는 코사인 거리(cosine distance)보다 내적을 약간 더 빠르게 계산할 수 있으며, 점수(scores)에 대한 추론을 단순화합니다. 더 중요한 점은 수동으로 정규화할 필요가 없다는 것입니다. 만약 이를 생략하고 임베딩의 크기(magnitude)가 서로 다르다면, 유사도 점수가 의미론적 의미(semantic meaning)가 아닌 벡터 길이에 의해 왜곡될 것입니다.
인증(Authentication)은 IAM을 사용합니다. Lambda 실행 역할(execution role)은 연결된 정책을 통해 bedrock:InvokeModel 권한을 가집니다. API 키나 교체해야 할 비밀 값(secrets)이 필요 없습니다.
스키마: Postgres에 벡터 저장하기
document_chunks 테이블에는 pgvector의 네이티브 타입인 vector(1024) 컬럼이 있습니다:
CREATE EXTENSION IF NOT EXISTS vector ;
CREATE TABLE document_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
document_id UUID NOT NULL REFERENCES documents ( id ) ON DELETE CASCADE ,
tenant_id UUID NOT NULL ,
chunk_index INT NOT NULL ,
content TEXT NOT NULL ,
embedding vector ( 1024 ),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW ()
);
컬럼 타입의 (1024)는 엄격한 제약 조건(hard constraint)입니다. Postgres는 다른 차원을 가진 벡터의 삽입(insert)을 거부합니다. 이는 유용한 가드레일(guardrail) 역할을 합니다. 만약 임베딩 모델이 변경되어 차원이 함께 변경된다면, 잘못된 차원의 벡터를 조용히 저장하는 대신 삽입 실패를 명확하게 알려줍니다.
IVFFlat 인덱스
정확한 최근접 이웃 검색(exact nearest-neighbor search)은 테이블의 모든 벡터를 스캔하고 쿼리 벡터와의 거리를 계산합니다. 데이터셋이 작다면 괜찮습니다. 하지만 청크(chunks)가 수천만 개에 달하면 비용이 많이 들게 됩니다.
IVFFlat (Inverted File Flat)은 근사 최근접 이웃 (Approximate Nearest-Neighbor, ANN) 인덱스입니다. 이는 인덱스 구축 시점에 벡터들을 여러 그룹("리스트"라고 불림)으로 클러스터링합니다. 쿼리 시점에는 테이블 전체를 검색하는 대신 가장 유망한 리스트들만 검색합니다: CREATE INDEX ON document_chunks USING ivfflat ( embedding vector_cosine_ops ) WITH ( lists = 100 ); 여기서 vector_cosine_ops는 인덱스가 코사인 거리 (Cosine Distance)를 측정 지표로 사용하도록 지시하며, 이는 쿼리의 <=> 연산자와 일치합니다. lists = 100 파라미터는 생성할 클러스터의 수를 제어합니다. pgvector 문서에서는 시작점으로 대략 sqrt(rows)를 권장합니다. IVFFlat의 주의사항(gotcha)은 인덱스를 구축할 때 데이터가 존재해야 한다는 점입니다. 빈 테이블에 구축된 IVFFlat 인덱스는 무용지물입니다. Sift에서는 초기 마이그레이션(migration) 시 스키마가 설정된 후 인덱스를 생성하며, 시드 데이터 (seed data)가 동일한 마이그레이션 내에서 실행됩니다. 테이블이 지속적으로 성장하는 프로덕션 시스템의 경우 HNSW가 더 나은 선택입니다. HNSW는 재구축 없이도 데이터가 삽입됨에 따라 양호한 검색 품질을 유지합니다.
Python에서 벡터 삽입하기. psycopg2 드라이버는 pgvector 타입을 기본적으로 이해하지 못합니다. pgvector Python 패키지를 추가하는 대신 (이는 컴파일된 확장 기능이 필요하며 배포 복잡성을 증가시킵니다), 파이프라인은 Postgres 벡터 리터럴 (vector literal)을 일반 문자열로 구성하여 캐스팅(cast)합니다:
vector_literal = " [ " + " , " . join ( str ( v ) for v in embedding ) + " ] "
cur . execute ( """ INSERT INTO document_chunks (document_id, tenant_id, chunk_index, content, embedding) VALUES (%s, %s, %s, %s, %s::vector) ON CONFLICT DO NOTHING """ , ( document_id , tenant_id , chunk_index , content , vector_literal ), )
SQL의 ::vector 캐스팅은 삽입 시점에 문자열을 네이티브 벡터 타입으로 변환합니다. 이는 네이티브 확장 기능 없이도 어떤 Postgres 드라이버나 어떤 Lambda 아키텍처(x86 또는 ARM)에서도 작동합니다. ON CONFLICT DO NOTHING은 Step Functions Map 상태의 최소 한 번 전달 (at-least-once delivery) 방식을 처리합니다. 즉, EmbedChunk Lambda가 재시도하더라도 중복된 청크를 생성하지 않습니다.
유사도 검색 (Similarity Search)
쿼리 시점에 C# ChatService는 사용자의 질문을 임베딩 (Embedding)하고 검색을 실행합니다. .NET 측에서도 동일한 벡터 리터럴 (Vector literal) 접근 방식을 사용할 수 있습니다:
private async Task<List<ChunkResult>> SearchChunksAsync(Guid tenantId, float[] embedding)
{
var vectorLiteral = $"[{string.Join(",", embedding)}]"
await using var conn = await db.CreateAsync();
await TenantContext.SetAsync(conn, tenantId);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT dc.id, dc.document_id, dc.chunk_index, dc.content, d.filename,
dc.embedding <=> $1::vector AS distance
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
ORDER BY distance
LIMIT $2
""";
cmd.Parameters.AddWithValue(NpgsqlTypes.NpgsqlDbType.Text, vectorLiteral);
cmd.Parameters.AddWithValue(TopK);
// ...
}
<=> 연산자는 pgvector의 코사인 거리 (Cosine distance) 연산자입니다. 이 연산자는 0과 2 사이의 값을 반환하며, 0은 벡터가 동일함을 의미하고 2는 서로 반대 방향을 가리킴을 의미합니다. 거리를 오름차순으로 정렬하면 의미적으로 가장 유사한 청크 (Chunk)가 가장 먼저 나타납니다.
TenantContext.SetAsync가 쿼리 실행 전에 실행된다는 점에 주목하십시오. 이는 행 수준 보안 (RLS, Row-Level Security) 정책이 읽어들이는 Postgres 세션 변수를 설정합니다. 유사도 검색은 자동으로 테넌트 범위 (Tenant-scoped)로 제한됩니다. 이 쿼리에는 WHERE tenant_id = $3와 같은 조건이 없지만, Postgres가 보이지 않게 정책을 적용합니다. Acme Corp의 사용자는 <=> 거리 계산이 모든 테넌트의 데이터를 아우르는 인덱스 전체에서 실행됨에도 불구하고, 오직 자신의 문서에 포함된 청크만 찾을 수 있습니다.
왜 8개의 청크인가요?
TopK = 8은 ChatService.cs에 정의된 상수입니다. 각각 약 512 토큰 (Token)인 8개의 청크는 대략 4,000 토큰의 컨텍스트 (Context)가 되며, 이는 모델이나 지연 시간 (Latency) 예산을 초과하지 않으면서 대부분의 질문에 답변하기에 충분한 양입니다. 여기에는 실제적인 트레이드오프 (Tradeoff)가 존재합니다. 청크가 많아질수록 재현율 (Recall, 올바른 정보가 포함될 확률)은 높아지지만, 생성 속도가 느려지고 프롬프트 (Prompt) 내의 노이즈가 증가하는 비용이 발생합니다. 8개는 이론적으로 도출된 최적값이 아니라 실용적인 기본값입니다.
RAG 프롬프트 (RAG Prompt)
상위 8개의 청크 (Chunk)가 검색되면, 서비스는 다음과 같이 프롬프트 (Prompt)를 구성합니다:
var context = string.Join("\n\n", chunks.Select((c, i) => $"[ { i + 1 } ] From " { c.Filename } " (chunk { c.ChunkIndex } ):\n { c.Content } "));
var systemPrompt = """ 당신은 유능한 문서 보조 도구입니다. 제공된 문서 발췌본만을 사용하여 사용자의 질문에 답하세요. [1], [2] 등을 사용하여 출처를 인용하세요. 만약 발췌본에서 답을 찾을 수 없다면, 명확하게 그렇게 말하세요. """;
var userMessage = $"Document excerpts:\n { context } \n\nQuestion: { question } " ;
각 청크 (Chunk)에는 파일 이름과 청크 인덱스 (Chunk Index)와 함께 [1], [2] 등의 번호가 매겨진 라벨이 붙습니다. 시스템 프롬프트 (System Prompt)는 Claude가 동일한 번호를 인라인 인용 (Inline Citation)으로 사용하도록 지시합니다. 모델은 다음과 같은 형태를 보게 됩니다:
[1] From "Q3_Report.pdf" (chunk 4): 3분기 매출은 전년 대비 18% 증가한 420만 달러였으며, 이는 기업 계약에 의해 주도되었습니다... [2] From "Q3_Report.pdf" (chunk 5): 이러한 증가는 31% 성장한 헬스케어 수직 시장에 집중되었습니다... 질문: 3분기 매출 증가를 주도한 요인은 무엇입니까?
그리고 [1]과 [2]를 인라인으로 인용하여 답변함으로써, 독자가 각 주장이 정확히 어느 구절에서 왔는지 알 수 있게 합니다. 이 단계에서 사용되는 모델은 Claude Haiku 4.5입니다. 이는 지식 검색 (Knowledge Retrieval)이나 추론 (Reasoning)보다는 주로 제공된 컨텍스트 (Context)를 요약하고 정리하는 작업에 적합하며, 빠르고 저렴합니다. max_tokens: 1024 제한을 통해 응답 시간을 예측 가능하게 유지합니다.
일급 데이터로서의 인용 (Citations as First-Class Data)
응답은 단순히 답변 문자열만 반환하지 않습니다. ChatResponse 모델은 병렬적인 인용 (Citations) 배열을 포함합니다:
public class ChatResponse { public string Answer { get; set; } = "" ; public List<ChatCitation> Citations { get; set; } = []; }
public class ChatCitation { public Guid DocumentId { get; set; } public string Filename { get; set; } = "" ; public string Excerpt { get; set; } = "" ; public int ChunkIndex { get; set; } }
각 인용 (Citation)에는 해당 청크 (Chunk) 내용의 처음 200자가 포함됩니다.
React 프론트엔드는 답변 아래에 이들을 확장 가능한 카드 형태로 렌더링합니다. 사용자는 [1]을 클릭하여 해당 답변의 근거가 된 정확한 발췌문을 확인할 수 있으며, 이때 출처 문서와 청크 (Chunk) 위치가 함께 표시됩니다. 이는 신뢰성 측면에서 매우 중요합니다. 검증할 방법 없이 확신에 찬 답변만을 내놓는 RAG 시스템은 자신의 작업 과정을 보여주는 시스템보다 더 나쁩니다.
한계점 및 프로덕션 환경에서의 변경 사항
위의 구현 방식은 데모 규모에서는 잘 작동합니다. 실제 프로덕션 배포를 위해 제가 변경할 몇 가지 사항은 다음과 같습니다.
청킹 전략 (Chunking strategy). Part 3에서 사용한 슬라이딩 윈도우 청커 (Sliding-window chunker)는 의미적 경계가 아닌 문자 수 기준으로 분할합니다. 512-토큰 (Token) 윈도우는 문장 중간, 표 중간, 또는 리스트 중간에서 끊길 수 있습니다. 더 나은 접근 방식으로는 문단 경계를 보존하려고 시도하는 재귀적 문장 분할기 (Recursive sentence splitter)나, 임베딩 모델 (Embedding model)을 사용하여 주제 전환을 감지하는 시맨틱 청커 (Semantic chunker)가 있습니다. 다만, 이 경우 복잡도와 데이터 수집 지연 시간 (Ingest latency)이라는 트레이드오프 (Trade-off)가 발생합니다.
인덱스 유형 (Index type). IVFFlat은 정적이거나 서서히 증가하는 데이터셋에는 좋지만, 인덱스가 구축된 후 데이터가 삽입됨에 따라 성능이 저하되므로 주기적인 재인덱싱 (Reindexing)이 필요합니다. HNSW (Hierarchical Navigable Small World)는 메모리 사용량이 더 높다는 비용이 따르지만, 데이터가 증가함에 따라 검색 품질을 동적으로 유지합니다. 지속적인 데이터 수집이 이루어지는 프로덕션 시스템의 경우, HNSW가 적절한 기본값입니다.
리랭킹 (Reranking). 벡터 유사도 (Vector similarity)는 훌륭한 1차 필터이지만 완벽하지는 않습니다. (질문, 청크) 쌍을 입력받아 관련성을 직접 점수 매기는 작은 모델인 크로스 인코더 리랭커 (Cross-encoder reranker)를 사용하면 최종 컨텍스트 윈도우 (Context window)의 정밀도를 크게 향상할 수 있습니다. 전형적인 패턴은 다음과 같습니다: 벡터 검색으로 상위 20~50개의 청크를 검색하고, 크로스 인코더로 리랭킹한 후, 상위 8개를 LLM에 전달합니다.
스트리밍 (Streaming). 현재 API는 Claude가 전체 답변 생성을 마칠 때까지 기다린 후에 응답을 반환합니다. 3~5초가 걸릴 수 있는 긴 답변의 경우, 이는 눈에 띄는 지연을 유발합니다. Lambda Function URL은 응답 스트리밍 (Response streaming)을 지원하므로, 이를 통해 프론트엔드에서 토큰이 도착하는 대로 즉시 표시할 수 있습니다.
API Gateway HTTP APIs는 스트리밍 (Streaming)을 지원하지 않으므로, 채팅 엔드포인트 (Chat endpoint)를 위해 Function URLs로 전환하는 것이 그 방법이 될 것입니다. '다음 단계 Part 5'에서는 React 프론트엔드(Frontend)를 다룹니다. 업로드 흐름이 어떻게 작동하는지, 문서 상태 카드를 구동하는 폴링 패턴 (Polling pattern), 그리고 Amplify의 인증 (Auth) 통합이 Cognito 토큰 흐름을 어떻게 연결하는지를 살펴봅니다. 본 포스트의 코드: backend/shared/bedrock.py — 임베딩 (Embedding) 호출, normalize flag migrations/001_initial_schema.sql — vector(1024) 컬럼, IVFFlat 인덱스 (Index), backend/pipeline/embed/embed_handler.py — vector 리터럴 삽입, ON CONFLICT DO NOTHING, backend/src/Sift.Api/Services/ChatService.cs — 전체 쿼리 경로: embed → search → generate, backend/src/Sift.Api/Models/Chat.cs — response sha
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기