며칠 전 PostgreSQL 안에서 BM25 검색이 네이티브로 돌아간다는 소식을 보고 바로 테스트해봤다. 그동안 하이브리드 검색을 하려면 BM25는 PostgreSQL의 tsvector로 어설프게 흉내내거나, 아예 Elasticsearch를 따로 띄우는 수밖에 없었다. 그런데 이제 PostgreSQL 익스텐션 하나로 진짜 BM25 스코어링이 인덱스 레벨에서 돈다는 거다. 마침 LangGraph 에이전트의 검색 노드를 손보던 시기여서, 바로 적용해봤다.
이 글은 그 적용 과정과 결과를 정리한 거다. 개념 설명은 공식 문서에 다 있으니 짧게만 짚고, 실제로 LangGraph 그래프 안에 어떻게 끼워 넣었는지, 그리고 기존에 Qdrant로 운영하던 하이브리드 검색과 비교했을 때 뭐가 다른지를 중심으로 쓴다.
왜 지금 PostgreSQL BM25가 의미 있나
기존에 PostgreSQL에서 텍스트 검색을 하려면 tsvector와 ts_rank를 썼다. 동작은 했지만 진짜 BM25 알고리즘이 아니라 PostgreSQL 자체의 랭킹 방식이었고, 정밀도가 아쉬웠다. 그래서 검색 품질이 중요한 프로젝트는 결국 Elasticsearch나 Typesense를 별도로 띄우는 쪽으로 갔다.
지금 나온 pg_textsearch나 pg_search 같은 익스텐션은 다르다. Tantivy(Rust 기반 검색 엔진 라이브러리)를 PostgreSQL 안에 통합해서, 진짜 BM25 스코어링을 인덱스 레벨에서 처리한다. k1과 b라는 BM25 핵심 파라미터도 인덱스 생성 시점에 직접 조정할 수 있다. 한국어 같은 경우는 별도 토크나이저 설정이 필요하지만, 기존 PostgreSQL 텍스트 검색 설정을 그대로 활용할 수 있다는 점도 매력적이었다.
내가 이걸 써보고 싶었던 이유는 단순하다. 검색 인프라를 하나 더 늘리지 않고, 이미 운영 중인 PostgreSQL 안에서 진짜 BM25 품질을 얻을 수 있다면 인프라 복잡도를 줄일 수 있기 때문이다. 이전에 Qdrant와 PostgreSQL을 비교했을 때 가장 큰 고민이 인프라가 하나 늘어나는 비용이었는데, PostgreSQL 안에서 BM25를 제대로 쓸 수 있다면 그 고민의 무게가 달라진다.
설치하고 인덱스 만드는 과정
설치 자체는 생각보다 간단했다. 익스텐션을 활성화하고, 검색 대상 컬럼에 BM25 인덱스를 만드는 게 전부다.
CREATE EXTENSION pg_textsearch;
CREATE INDEX idx_documents_bm25
ON documents
USING bm25(content)
WITH (text_config = 'public.korean', k1 = 1.2, b = 0.75);
쿼리도 직관적이다. <@> 연산자 하나로 BM25 점수 기반 정렬이 끝난다.
SELECT id, content
FROM documents
ORDER BY content <@> to_bm25query('RAG 파이프라인 디버깅')
LIMIT 10;
여기서 한 가지 헷갈렸던 부분이 있다. 이 연산자가 반환하는 점수가 음수라서, 낮을수록 더 관련도가 높다는 점이다. PostgreSQL이 <@> 연산자에 대해 오름차순 인덱스 스캔만 지원하기 때문에 생긴 설계라고 하는데, 처음에는 이걸 모르고 점수를 그대로 정렬해서 결과가 거꾸로 나온 적이 있었다. 문서를 다시 읽고 나서야 이유를 이해했다.
LangGraph 검색 노드에 끼워 넣기
기존 LangGraph 그래프는 검색 노드 하나에서 pgvector 벡터 검색만 호출하고 있었다. 여기에 BM25 검색을 병렬로 추가하고, 두 결과를 RRF(Rank Reciprocal Fusion)로 합치는 구조로 바꿨다.
async def hybrid_retrieval_node(state: AgentState):
query = state["query"]
vector_task = vector_search(query, limit=20)
bm25_task = bm25_search(query, limit=20)
vector_results, bm25_results = await asyncio.gather(
vector_task, bm25_task
)
fused = reciprocal_rank_fusion(vector_results, bm25_results)
return {"retrieved_docs": fused[:10]}
두 검색을 asyncio.gather로 병렬 실행한 게 핵심이다. 순차로 실행하면 두 쿼리의 레이턴시가 그대로 더해지는데, 병렬로 묶으면 둘 중 느린 쪽의 시간만 든다. 이전에 FastAPI RAG 서버에서 비동기 블로킹 문제를 디버깅하면서 배운 교훈이 여기서도 그대로 적용됐다. async로 짠다고 끝나는 게 아니라, 실제로 병렬화할 수 있는 작업은 명시적으로 묶어줘야 한다.
RRF 결합 로직 자체는 단순하다. 각 검색 방식에서의 순위를 기준으로 점수를 합치는데, BM25 점수와 벡터 코사인 유사도처럼 분포가 다른 두 점수를 직접 더하지 않고 순위로 변환해서 합치는 방식이다. 이렇게 하면 한쪽 점수 스케일이 다른 쪽을 압도하는 문제가 없다.
실제로 붙여보니 달라진 것들
가장 먼저 체감한 건 별도 동기화 문제가 사라졌다는 점이다. Qdrant를 쓸 때는 PostgreSQL의 메타데이터와 Qdrant의 벡터 인덱스를 따로 관리해야 했고, 문서 삭제할 때 한쪽만 반영되는 사고도 겪었다. BM25 인덱스를 PostgreSQL 안에 두니까 이 문제 자체가 구조적으로 사라졌다. 문서 하나를 트랜잭션 안에서 삽입, 수정, 삭제하면 벡터 인덱스와 BM25 인덱스가 같은 트랜잭션 안에서 함께 처리된다.
검색 품질은 기존 tsvector 기반 텍스트 검색보다 확실히 나아졌다. 특히 에러 코드나 특정 식별자처럼 정확한 키워드 매칭이 중요한 쿼리에서 차이가 컸다. “PG-1234 에러” 같은 쿼리는 벡터 검색만으로는 비슷한 에러 문서들을 두루뭉술하게 가져오는데, BM25를 결합하니 정확한 코드가 포함된 문서가 상위로 확실히 올라왔다.
다만 아직 조심스러운 부분도 있다. pg_textsearch나 pg_search 모두 비교적 최근에 나온 익스텐션이라, Qdrant나 Elasticsearch만큼 오랜 프로덕션 검증을 거치진 않았다. 대규모 트래픽 환경에서 장기간 안정성을 본 사례가 아직 많지 않다. 그래서 지금은 운영 데이터의 일부에만 우선 적용해서 지켜보는 중이다.
Qdrant와 비교하면 어떤 선택이 맞나
이전 글에서 Qdrant의 강점으로 꼽았던 게 하이브리드 검색이 기능으로 내장돼 있고, ColBERT reranking까지 한 파이프라인에서 처리된다는 점이었다. PostgreSQL BM25 익스텐션은 이 부분에서는 아직 Qdrant만큼 통합돼 있지 않다. RRF 결합 로직을 애플리케이션 레벨에서 직접 짜야 하고, reranking은 별도로 붙여야 한다.
그런데 인프라 단순함이라는 측면에서는 분명한 이점이 있다. 이미 PostgreSQL을 쓰고 있다면 새 서비스를 띄우지 않고도 BM25 품질을 얻을 수 있다. 나는 지금 두 가지를 다 운영하면서, 검색 품질이 절대적으로 중요한 인덱스는 여전히 Qdrant에 두고, 상대적으로 가벼운 검색 노드는 PostgreSQL BM25로 옮기는 식으로 역할을 나누고 있다. 모든 걸 한 기술로 통일하려고 하기보다, 검색 노드별로 요구 수준에 맞춰 도구를 고르는 게 결국 더 합리적이라는 결론에 도달했다.
적용해보면서 남은 숙제
지금 가장 신경 쓰이는 부분은 한국어 토크나이징 품질이다. 영어는 스테밍이 잘 정립돼 있는데, 한국어는 형태소 분석기에 따라 BM25 검색 품질이 꽤 달라진다. 지금은 기본 설정으로 돌리고 있는데, 한국어 전용 토크나이저를 더 정교하게 튜닝하는 작업이 다음 단계로 남아 있다.
또 하나는 인덱스 크기가 커졌을 때의 성능이다. 지금은 문서 수가 그렇게 많지 않아서 체감이 크지 않은데, 수백만 건 단위로 늘어났을 때 BM25 인덱스 빌드 시간과 쿼리 레이턴시가 어떻게 변하는지는 아직 직접 검증하지 못했다. 이 부분은 데이터가 더 쌓이면 다시 글로 정리할 생각이다.
당장은 결론을 내리기보다, LangGraph 에이전트의 검색 레이어를 설계할 때 더 이상 “벡터DB냐 검색엔진이냐”의 이분법이 아니라 “PostgreSQL 하나로 어디까지 갈 수 있는가”라는 세 번째 선택지가 생겼다는 것 자체가 의미 있는 변화라고 생각한다. 인프라를 늘리지 않고도 검색 품질을 끌어올릴 수 있는 방법이 하나 더 생긴 셈이다.