글 목록으로
2026년 7월 27일
6분 소요

벡터 DB를 따로 둘 이유가 사라지고 있다 — 마지막 남은 문제는 필터고, pgContext는 여길 파고들었다

RAG를 시작하면 제일 먼저 부딪히는 질문이 '벡터 DB를 따로 둬야 하나'다. 2026년 중반의 답은 대체로 '아니, 일단 Postgres'로 기울었는데, 이 답에는 아직 구멍이 하나 있다. 메타데이터 필터를 거는 순간 pgvector의 recall이 조용히 무너진다는 것. 이번 주 GitHub에서 눈에 띈 pgContext는 정확히 이 약점을 파고든 Rust 확장이다. 왜 사람들이 자꾸 '전부 Postgres로'를 원하는지(DB를 하나 더 운영하는 부담, 초심자의 진입장벽), pgvector의 post-filtering 함정이 뭔지, pgContext는 뭘 다르게 했고 뭘 스스로 진다고 인정하는지, 그리고 내 워크로드가 이 함정에 걸려 있는지 판별하는 법까지 정리했다.

트렌딩 오픈소스를 까보는 시리즈다. headroom(#18) 때처럼 이번에도 출발점은 “이 코드가 잘났나”가 아니라 “사람들이 왜 이걸 원했나”다. 이번 주 GitHub에서 눈에 띈 pgContext는 “A full AI search engine, built into Postgres”를 표방하는 PostgreSQL 17/18 확장이다. 별표 수는 아직 세 자리 초반이지만, 벡터 검색에서 지금 제일 뜨거운 질문을 정면으로 건드리는 물건이라 들여다볼 가치가 있다.


왜 자꾸 “전부 Postgres로”가 나오나

RAG나 시맨틱 검색을 처음 붙이는 팀을 떠올려보자. 앱 DB는 이미 Postgres다. 임베딩을 저장할 곳이 필요해지면 선택지가 갈린다. Pinecone 같은 관리형 서비스를 계약하거나, Qdrant나 Weaviate를 직접 띄우거나, 아니면 그냥 pgvector 확장을 켜거나.

전용 벡터 DB를 고르는 순간 청구서에 없는 비용이 따라온다. 원본 데이터를 벡터 DB로 복사해서 동기화하는 파이프라인이 하나 생긴다. 원본이 바뀌면 임베딩도 다시 밀어넣어야 하고, 지연되면 검색 결과와 실제 데이터가 어긋난다. 백업과 장애 복구를 두 시스템에서 따로 챙겨야 하고, “이 사용자는 이 문서를 볼 수 없다” 같은 권한 규칙은 Postgres의 RLS로 한 번, 벡터 DB의 페이로드 필터로 또 한 번, 이중으로 구현해야 한다. 운영할 줄 아는 사람도 귀찮은데, 사이드 프로젝트로 RAG를 처음 만들어보는 사람에게는 이 두 번째 시스템이 진입장벽 그 자체다. 배워야 할 쿼리 언어가 하나 늘고, 띄워야 할 컨테이너가 하나 늘고, 뭔가 안 맞을 때 의심할 곳이 두 배가 된다.

그래서 “just use Postgres” 진영이 이겼다. 2026년 중반 기준 프로덕션 워크로드는 Pinecone·Qdrant·Weaviate·pgvector 4강으로 정리됐고, “이미 Postgres를 쓰고 있고 벡터가 수천만 개 이하면 pgvector가 기본값”이 업계 통념이 됐다. Timescale의 pgvectorscale이 5천만 벡터에서 recall 99% 기준 Qdrant보다 나은 QPS를 찍었다는 자체 벤치마크도 이 통념을 밀어줬다. 벡터 검색은 이제 별도 제품이 아니라 이미 있는 DB의 기능이라는 쪽으로 무게가 실린 거다.

pgContext 같은 물건이 자꾸 나오는 이유가 여기 있다. 수요는 “더 빠른 벡터 DB”가 아니라 “시스템 하나 줄이기”에 있고(#20 TurboVec의 “1천만 벡터를 노트북에서”가 잡아챈 것도 결국 같은 수요였다), 그 수요가 상업적으로도 성립한다. pgContext를 만든 Evokoa는 개인 개발자가 아니라 관리형 호스팅(Polygres)까지 붙여 나온 오픈코어 스타트업이다. Apache-2.0 확장을 공개하고 호스팅으로 돈을 버는, Supabase가 닦아놓은 바로 그 길이다.


그런데 “Postgres 하나로”에는 구멍이 있다

통념대로 pgvector를 켜고 잘 쓰다가, 어느 날 이런 쿼리를 만나면 문제가 시작된다.

SELECT * FROM docs
WHERE tenant_id = 'acme'          -- 필터
ORDER BY embedding <=> $query     -- 벡터 검색
LIMIT 10;

멀티테넌트 SaaS라면 모든 검색 쿼리가 이렇게 생겼을 거다. pgvector의 HNSW 인덱스는 필터를 모른 채 벡터 공간에서 가까운 이웃부터 뽑고, WHERE 절은 그 결과를 사후에 거른다(post-filtering). acme 테넌트의 문서가 전체의 1%라면, 인덱스가 뽑아온 후보 40개(기본 ef_search) 중 필터를 통과하는 건 평균 0.4개다. 쿼리는 에러 없이 돌고, 결과도 나온다. 다만 정답이 아닐 뿐이다. 더 가까운 acme 문서가 분명히 있는데 후보에 못 들어서 누락된다. recall이 조용히 무너지는 거고, RAG라면 “관련 문서를 못 찾아서 이상한 답을 하는” 형태로 사용자에게 도달한다.

이건 드문 엣지케이스가 아니다. pgvector의 약점을 짚는 글마다 첫손에 꼽는 항목이고(Franck Pachot의 pre-filtering 부재 분석, ParadeDB의 한계 정리), pgvector 0.8이 iterative index scan을 넣은 것도 이 때문이다. 후보가 모자라면 그래프를 더 걷는 식의 완화책인데, 그만큼 CPU와 꼬리 지연시간을 낸다. 전용 벡터 DB들이 “우린 필터 인식 검색이 된다”를 세일즈 포인트로 삼는 것도 같은 이유다. 요컨대 “Postgres 하나로”가 마지막으로 막히는 지점이 필터고, 벡터 DB를 따로 두는 비용을 정당화해온 것도 필터다.


pgContext는 뭘 다르게 했나

코드를 받아서 확인했다. Rust(pgrx) 약 11만 줄, fuzz 타깃 10개에 property 테스트까지 갖춘, 장난감이라 부르기 어려운 규모다. 설계의 핵심은 세 가지다.

첫째, 필터를 검색의 일부로 만들었다. Qdrant 스타일의 필터 문법(must/should/must_not, 범위, JSONB 경로)을 받아서, post-filtering이 아니라 필터 마스크를 HNSW 그래프 순회에 밀어넣는다. 필터에 걸러진 노드도 그래프의 연결자 역할은 유지시켜서 선택도가 낮아도 recall이 버티게 했고, 선택도가 일정 수준 아래로 떨어지면 아예 정확 스캔으로 전환한다. 필터용 인덱스를 따로 만들 필요도 없다.

둘째, 근사 결과를 원본 행으로 되돌려 정확히 재검증한다. HNSW가 뽑은 후보를 반환 전에 라이브 행에서 다시 채점하고, 그 과정에서 MVCC 가시성과 RLS를 통과시킨다. 권한 규칙을 이중으로 구현하는 문제가 구조적으로 사라진다 — 검색 결과가 곧 “이 사용자가 볼 수 있는 행”이다. 데이터는 옮기지 않고, HNSW 같은 가속 상태는 전부 재구축 가능한 파생 인덱스로만 취급한다. 동기화 파이프라인도, 어긋날 복사본도 없다.

셋째, 하이브리드 검색을 확장 안에 넣었다. 밀집 벡터와 Postgres 전문 검색을 reciprocal-rank fusion으로 융합하는 것까지 SQL 함수 하나로 처리한다. 보통 애플리케이션 코드에서 손으로 짜던 부분이다.

자체 벤치마크 수치는 이렇다. 표준 GloVe-100-angular(118만 벡터)에서 pgvector와 같은 recall 곡선을 그리면서 3.8~5.3배 빠르고, 문제의 필터 검색에서는 선택도 150% 전 구간에서 pgvector가 recall 0.340.49에 갇히는 동안(위에서 말한 필터 기아 현상 그대로다) pgContext의 컬렉션 API는 1.000을 유지했다.

숫자보다 인상적인 건 벤치마크 문서의 태도다. “pgContext가 지는 레인”을 문서에 명시해놨다. 인덱스 빌드는 pgvector보다 레인에 따라 1.73.5배 느리고, 필터 검색의 recall은 이기지만 지연시간은 Qdrant가 압도한다(full recall 기준 12ms 대 22~71ms). 초기 프로젝트의 자체 벤치마크는 원래 반쯤 걸러 읽는 게 맞지만, 자기가 지는 표까지 실어놓은 문서라면 조금은 더 믿어줄 만하다. 포지셔닝도 그래서 정직하게 읽힌다 — “Qdrant를 이겼다”가 아니라 “Postgres 안에서, pgvector가 못 막던 구멍을 막았다”다.


실전: 내 워크로드가 이 함정에 걸려 있나

pgContext를 쓰든 말든, post-filtering 함정 자체는 지금 pgvector를 쓰는 사람이라면 오늘 점검할 가치가 있다.

1. 필터 선택도부터 계산한다. 검색 쿼리에 붙는 대표 필터에 대해 SELECT count(*) FILTER (WHERE tenant_id = 'acme') * 1.0 / count(*) FROM docs; 한 줄이면 된다. 이 값이 10%를 밑돌면 함정 후보다. 선택도 1%에 ef_search 40이면 기대 후보가 1개도 안 된다는 산수를 기억하자.

2. 증상은 “적게 나오는 것”이다. LIMIT 10을 걸었는데 7개만 돌아오거나, 필터를 빼면 나오던 문서가 필터를 걸면 사라진다면 이미 겪고 있는 거다. RAG 품질 저하로 먼저 나타나는 경우가 많아서, 검색 계층이 아니라 프롬프트를 의심하며 시간을 쓰기 쉽다.

3. pgvector 안에서의 처방 두 가지. SET hnsw.ef_search = 100;(기본값 40은 프로덕션에 낮다)과, 0.8 이상이라면 SET hnsw.iterative_scan = relaxed_order;로 iterative scan을 켜는 것. 대부분의 중간 선택도 워크로드는 이걸로 산다. 대가는 CPU와 꼬리 지연시간이다.

4. 그걸로 안 되는 지점이 갈림길이다. 선택도가 한 자릿수 이하로 떨어지는 필터가 핵심 경로에 있다면(멀티테넌트가 전형적이다), 선택지는 셋이다. 테넌트별 파티셔닝 같은 스키마 수술, Qdrant류 전용 엔진 도입, 아니면 pgContext처럼 필터 인식 ANN을 하는 확장. pgContext는 아직 v0.1.0이고 PG 17/18 전용이라 프로덕션에 바로 얹으라고 하기는 이르지만, “확장 교체”가 “시스템 추가”보다 되돌리기 쉬운 선택지라는 점은 분명하다.


관전 포인트

repo에서 하나 더 눈에 띈 건 사람용 README 옆에 놓인 AGENTS.md다. AI 코딩 에이전트가 비대화형으로 설치·검증·연동까지 마칠 수 있게 쓴 문서인데, 이 프로젝트가 상정하는 소비자가 누군지 보여준다. “초심자가 RAG를 붙인다”의 2026년판은 사람이 문서를 읽는 게 아니라 에이전트가 스택을 조립하는 것이고, 그 에이전트는 컨테이너 두 개짜리 스택보다 CREATE EXTENSION 한 줄을 고를 거다. DB를 하나로 줄이려는 수요는 사람보다 에이전트 쪽에서 더 강하게 올 수 있다.

물건 자체는 초기다. 별표 세 자리, 커밋 80개, 검증되지 않은 자체 벤치마크. 하지만 이 글의 관심사는 pgContext의 성공 여부보다 이런 물건이 계속 나온다는 사실 쪽이다. pgvector 0.8의 iterative scan, pgvectorscale, ParadeDB, 그리고 pgContext까지 — 전부 “Postgres 하나로 가되, 필터 걸린 검색의 recall을 누가 책임지나”라는 같은 질문에 다른 답을 내는 중이다. 벡터 DB를 따로 둬야 할 마지막 이유가 지워지는 중이고, 그 마지막 이유가 필터다.