Retrieval-Augmented Self-Recall — 파트 2: 오직 Postgres만으로 구현하는 하이브리드 RAG
요약
전용 벡터 데이터베이스 대신 Postgres를 활용하여 밀집 벡터, 전체 텍스트 인덱스, 메타데이터를 통합 관리하는 하이브리드 RAG 구현 방법을 소개합니다. 단일 저장소를 통해 트랜잭션 일관성을 유지하고 시스템 복잡성을 줄이는 것이 핵심입니다.
핵심 포인트
- Postgres를 활용해 밀집 및 희소 검색을 단일 트랜잭션 내에서 수행
- 별도의 벡터 DB 없이 메타데이터와 벡터 간의 데이터 일관성 확보
- pgvector를 이용한 효율적인 근사 최근접 이웃(ANN) 검색 구현
- 에이전트 메모리 시스템의 과잉 엔지니어링 방지 및 운영 단순화
Retrieval-Augmented Self-Recall의 파트 2입니다. 코드: RE-call. 파트 1: 셀프 리콜(self-recall) 문제.
"벡터 검색 (vector search)"이라는 말을 들으면 Pinecone, Weaviate, Qdrant와 같은 전용 벡터 데이터베이스 (vector database)를 떠올리는 것이 일반적입니다. 하지만 저는 설치하지 않았습니다. RE-call은 밀집 벡터 (dense vectors), 전체 텍스트 인덱스 (full-text index), 그리고 필터링에 사용하는 메타데이터 (metadata)를 단일 Postgres에 모두 유지합니다.
이는 지름길을 택한 것도 아니고, 새로운 인프라에 대한 거부감 때문도 아닙니다. 에이전트 메모리 (agent memory)를 위해 별도의 벡터 저장소 (vector store)를 사용하는 것은 잘못된 "형태 (shape)"이며, 이 시스템 전체가 의존하는 단 하나의 속성을 조용히 앗아가기 때문입니다.
그 근거와 이를 통해 구축할 수 있는 검색 파이프라인 (retrieval pipeline)을 소개합니다.
왜 전용 벡터 DB를 사용하지 않는가
에이전트 메모리는 Postgres가 자연스럽게 적합할 수밖에 없는 세 가지 속성을 가지고 있습니다:
-
이미 관계형(relational)입니다. 메모에는 타임스탬프 (timestamps), 소스 유형 (source types), 태그 (tags), 결정 상태 (decision status)가 포함됩니다. 이는 필터링하고 조인 (join)하고 싶은 구조화된 메타데이터이며, 바로 관계형 데이터베이스가 존재하는 이유입니다. 별도의 벡터 저장소를 사용한다는 것은 두 시스템을 동기화해야 함을 의미하며, 벡터와 메타데이터 사이의 트랜잭션 일관성 (transactional consistency)을 잃게 된다는 뜻입니다.
-
pgvector를 통해 Postgres "내부"에서 실제 벡터 검색을 수행할 수 있습니다. 행 (rows)과 동일한 데이터베이스 내에서 근사 최근접 이웃 (Approximate-nearest-neighbor) 코사인 유사도 검색을 수행합니다. 또한 Postgres는 이미 전체 텍스트 검색 (full-text search) 기능을 제공합니다. 따라서 동기화 계층이나 운영해야 할 두 번째 시스템 없이, 하나의 트랜잭션 저장소에서 밀집 검색 (dense retrieval)과 희소 검색 (sparse retrieval)을 모두 수행할 수 있습니다. -
규모가 복잡성을 정당화하지 못합니다. 제 코퍼스 (corpus)는 약 700개의 메모, 약 5MB 규모이며 매일 재인덱싱됩니다. 규모가 몇 배 더 커지더라도, 읽기 위주이며 지연 시간 (latency)에 관대한 메모리 시스템은 분산 벡터 DB (distributed vector DB)가 운영 비용을 정당화할 수 있는 영역에 전혀 도달하지 않습니다. 여기서 벡터 DB를 찾는 것은 과잉 엔지니어링 (over-engineering)입니다.
단일 저장소, 단일 진실 공급원 (source of truth), 그리고 이미 익숙한 운영 방식. 이제 파이프라인을 살펴보겠습니다.
검색 파이프라인 (The retrieval pipeline)
RE-call은 최대 4단계에 걸쳐 검색을 수행합니다. 처음 두 단계는 병렬로 실행되며, 마지막 두 단계는 결과를 정제합니다.
1. 밀집 검색 (Dense retrieval)
쿼리를 임베딩 (Embedding)하고, pgvector 코사인 유사도 (cosine-similarity) 검색을 실행하여 상위 k개를 추출합니다. 이는 의미론적 매칭 (semantic matching)입니다. 즉, 공유하는 단어가 없더라도 쿼리와 의미가 같은 메모를 찾아냅니다. 개념 파악에는 강력하지만, 정확한 토큰 (token) 매칭에는 약합니다.
2. 희소 검색 (Sparse retrieval)
동일한 코퍼스 (corpus)에 대해 Postgres 전문 검색 (tsvector/tsquery)을 실행합니다. 이는 어휘적 매칭 (lexical matching)입니다. 특정 에러 코드, 티커 (ticker), 도메인 전문 용어, 고유 명사와 같이 정확한 용어를 잡아냅니다. 정밀도 (precision)는 높지만, 의역 (paraphrase)에는 취약합니다.
3. RRF를 통한 융합 (Fusion with RRF)
이제 서로 결과가 다른 두 개의 순위 리스트를 갖게 됩니다. 이를 상호 순위 융합 (Reciprocal Rank Fusion, RRF) (k=60) 방식으로 융합합니다.
score(doc) = Σ 1 / (k + rank_in_list_i(doc))
각 문서의 점수는 두 리스트 전체에 걸쳐, 해당 문서의 순위(상수 k를 더함)의 역수를 모두 합산한 값입니다. 어느 한 쪽 리스트에서라도 순위가 높은 문서는 위로 떠오르며, 두 리스트 모두에서 순위가 높은 문서는 압도적인 우위를 점합니다.
왜 하필 RRF인가 하면, 밀집 코사인 점수 (dense cosine scores)와 전문 검색 점수 (full-text scores)는 동일한 척도 (scale)를 사용하지 않기 때문입니다. 임의의 정규화 (normalization) 과정 없이는 단순히 두 점수를 더하거나 평균을 낼 수 없습니다. RRF는 원시 점수 (raw score) 대신 순위 (rank)를 기준으로 융합함으로써 이 문제를 완전히 우회합니다. k=60은 잘 확립된 기본값이며 견고합니다. 이를 튜닝할 필요는 거의 없습니다.
왜 굳이 융합하는 걸까요? 밀집 검색과 희소 검색은 서로 반대 방향으로 실패하기 때문입니다. 하나는 정확한 토큰을 놓치고, 다른 하나는 의미를 놓칩니다. 이 둘을 결합하면 임베딩의 개념 매칭 능력과 키워드 검색의 정밀도를 모두 얻을 수 있습니다. (응용 시리즈를 읽어보셨다면, 이것이 바로 "하이브리드가 단일 방식보다 뛰어나다"는 교훈입니다. 여기 그 이면에 작동하는 실제 메커니즘이 있습니다.)
4. 크로스 인코더 재순위화 (Cross-encoder rerank, 선택 사항)
결합된 쇼트리스트(shortlist)는 크로스 인코더 (cross-encoder) (ms-marco-MiniLM)를 통해 재정렬될 수 있습니다. 1단계의 바이 인코더 (bi-encoder) 임베딩이 쿼리와 문서를 각각 인코딩하고 벡터를 비교하는 것과 달리, 크로스 인코더는 쿼리와 후보군을 함께 인코딩하여 그 관련성을 공동으로 점수화합니다. 이는 유의미하게 더 정확하지만 유의미하게 더 느리기 때문에, 전체 코퍼스(corpus)가 아닌 상위 소수의 후보군에 대해서만 실행해야 합니다.
4단계가 비용을 들일 가치가 있는지는 사용 중인 임베더 (embedder)에 크게 좌우되는 것으로 나타났으며, 이는 파트 4의 벤치마크 주제입니다.
이 단계를 처음 초안을 작성했을 때 깨달았던 것보다 더 중요한 특성이 하나 있습니다. 크로스 인코더는 _재정렬(reorder)_을 수행하지만, 결과물로 내놓는 것은 여전히 다운스트림(downstream) 어딘가에서 임계값(thresholding)을 설정해야 하는 **점수 (score)**라는 점입니다. 튜닝해야 하는 점수와 신뢰할 수 있는 결정 사이의 이러한 차이는 이 시리즈의 후반부에서 강력하게 다시 다뤄질 것입니다.
플러그형 임베더 (Pluggable embedders)
RE-call은 임베더를 교체 가능한 컴포넌트로 취급하며, 세 가지가 기본 제공됩니다:
HashingEmbedder— 결정론적(deterministic)이며 오프라인에서 작동하고, 모델 다운로드가 필요 없습니다. 테스트 스위트와 CI가 외부 의존성 없이 벡터 검색을 실행할 수 있도록 존재합니다. 성능은 약하지만 재현 가능합니다.- FastEmbed (
bge-small) — API 호출이 없는 강력한 로컬 모델입니다. 개인정보 보호나 폐쇄망(air-gapped) 환경에서의 실행을 위한 좋은 기본값입니다. - Voyage (
voyage-3) — 클라우드 모델로, 세 가지 중 가장 강력합니다.
플러그형 구조는 단순히 깔끔함을 위한 것이 아닙니다. 이를 통해 오프라인에서 결정론적으로 테스트하거나, 개인정보 보호를 위해 로컬에서 실행하거나, 최대 품질을 위해 클라우드를 호출할 수 있습니다. 즉, 파이프라인은 동일하지만 트레이드오프(tradeoff)만 달라집니다. 그리고 이는 이 시리즈에서 가장 중요한 발견을 위한 토대가 됩니다: 선택한 임베더에 따라 다운스트림의 모든 것을 보정(calibrate)하는 방식이 달라진다는 점입니다 (파트 5). 이 점을 기억해 두세요.
"오직 Postgres만으로" 정말 충분할까?
타당한 도전이며, 이를 대충 얼버무리고 싶지 않습니다. 이 주장은 막연한 느낌이 아니라 테스트 환경(harness)에 의해 뒷받침됩니다. RE-call의 150개 테스트 스위트 중 49개의 통합 테스트(integration tests)는 CI 환경에서 모의 데이터베이스(mock database) 없이 실제 pgvector 컨테이너를 대상으로 실행됩니다. 방금 읽으신 검색(retrieval) 과정은 매 커밋마다 실제 엔진을 대상으로 테스트됩니다.
그리고 경계선에 대해 솔직하게 말씀드리자면, 언제 전용 벡터 데이터베이스(vector DB)를 사용하고 싶을까요? 수십억 개의 벡터, 높은 동시 QPS(Queries Per Second) 환경에서의 10ms 미만 p99 응답 속도, 노드 간의 분산 샤딩(distributed sharding) 등이 필요할 때입니다. 에이전트 메모리(Agent memory)는 이 중 어디에도 해당하지 않습니다. 에이전트 메모리는 규모가 작고, 읽기 위주이며, 수십 밀리초(ms) 단위의 검색 속도로도 충분히 만족스럽습니다. 도구를 사용 환경(regime)에 맞추십시오. 그리고 이 환경은 Postgres에 최적화되어 있습니다.
다음 단계
이제 검색(retrieval)은 가장 유사한 메모(memos)를 반환합니다. 하지만 "가장 유사하다"는 것이 "관련이 있다"는 뜻은 아닙니다. 답이 없는 질문에 대해 가장 유사한 매칭 결과는 여전히 높은 유사도 점수를 입은 노이즈일 뿐입니다. 파트 3에서는 시스템이 그 차이를 구분할 수 있게 해주는 가드(guards)에 대해 다룹니다. 즉, RE-call이 어떻게 _"모르겠습니다"_라고 말하는 법을 배우는지에 대한 내용입니다.
Retrieval-Augmented Self-Recall의 파트 2입니다. 코드: RE-call. 만약 Claude Code, Beyond the Prompt를 보고 오셨다면, 이것은 파트 5의 시맨틱 검색(semantic search) 하단에 위치한 검색 계층(retrieval layer)입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기