개발이야기/RAG

Postgres 키워드 검색 결과 0건 해결기: plainto_tsquery AND를 OR와 접두 매칭으로 바꿔 33/40 회수

돈까스연구소 2026. 10. 28. 16:40

  • katsubee의 RAG 검색에서 Postgres 키워드 갈래가 자연어 질문 40개 모두에 빈 결과를 냈습니다.
  • plainto_tsquery가 질문 속 모든 단어를 AND 조건으로 묶는 것이 원인이었습니다.
  • 핵심어만 추려 OR와 접두 매칭으로 바꾸자 정답 청크가 40문항 중 33문항에서 잡혔습니다.

katsubee와 katsubox를 소개한 앞선 글에서 개선점을 몇 가지 남겨 두었는데, 이 글은 그 후속 시리즈의 두 번째 편입니다. 이번에는 문서 기반 답변을 담당하는 RAG 검색부터 손봤습니다.

벤치마크에서 드러난 빈 결과

katsubee에는 올려 둔 문서에서 답을 찾아 주는 RAG 기능이 있습니다. 검색은 벡터 검색과 Postgres 키워드 검색을 함께 쓰는 하이브리드 구조입니다. 벤치마크를 돌려 보니 키워드 쪽이 자연어 질문에서 단 한 건도 결과를 내지 못했습니다.

Postgres 키워드 검색 결과 0건 해결기: plainto_tsquery AND를 OR와 접두 매칭으로 바꿔 33/40 회수
katsubee의 답변 화면. 참고 자료 칩에 파일명과 인용 위치 수가 붙는다.

위 화면은 수정 후의 모습입니다. 샘플 문서에 '재개발 조합 설립 동의율은 얼마로 바뀌었어?'라고 물었더니, 75%에서 70%로 완화된다는 답과 출처 파일명이 함께 나왔습니다. 수정 전에는 키워드 검색이 이런 유형의 질문에서 아무것도 찾지 못했습니다.

이런 문제가 오래 눈에 띄지 않은 데는 이유가 있습니다. 한쪽 갈래가 비어도 다른 쪽이 결과를 채워 주기 때문에 화면에는 정상적인 답변처럼 보입니다. 하이브리드 검색이 실제로는 벡터 검색 하나로만 돌고 있었던 셈입니다.

검색 파이프라인 구성

구조를 먼저 정리하면 이렇습니다. 벡터 갈래는 질문을 임베딩한 뒤 코사인 거리가 가까운 청크를 뽑습니다. 키워드 갈래는 Postgres tsvector에서 단어가 맞는 청크를 ts_rank 순서로 가져옵니다.

두 순위는 RRF로 합칩니다. 각 순위에서 1/(60+순위)만큼 점수를 받아 더하는 방식이어서, 서로 다른 점수 스케일을 맞추는 작업이 따로 필요하지 않습니다.

리랭킹 측정이 가리킨 곳

이 문제를 발견한 계기는 리랭킹이었습니다. 검색 상위 후보를 LLM에게 다시 정렬시키는 기능을 붙이고 측정했는데, 리랭킹 없이는 hit@4가 10%, 켜면 15%였고 질문 하나당 6.3초가 더 들었습니다. 40문항 중 두 개를 더 맞힌 정도를 효과라고 부르기는 어려웠습니다.

리랭킹은 이미 뽑힌 후보 안에서 순서만 바꿀 수 있습니다. 정답이 후보에 들어 있지 않으면 정렬을 아무리 잘해도 소용이 없습니다. 그래서 순서를 매기는 단계보다 후보를 뽑는 단계를 의심했고, 키워드 갈래만 따로 돌려 보는 진단을 벤치마크에 넣었습니다. 거기서 0건이라는 사실이 확인됐습니다.

원인: 모든 단어를 요구하는 AND 쿼리

키워드 쪽 쿼리는 plainto_tsquery('simple', 질문)이었습니다. 이 함수는 질문에 나온 단어를 전부 AND로 연결합니다. 앞의 질문은 '재개발' & '조합' & '설립' & '동의율은' & '얼마로' & '바뀌었어'로 바뀝니다.

simple 설정은 형태소 분석을 하지 않습니다. 문서에 있는 '동의율'과 질문의 '동의율은'은 서로 다른 단어로 취급됩니다. '얼마로'와 '바뀌었어'는 문서에 아예 등장하지 않습니다. 하나라도 어긋나면 전체 조건이 실패하니 결과가 비는 것이 당연했습니다.

그림의 위쪽이 기존 쿼리를 psql에서 실행한 결과로, 청크 19개짜리 샘플 프로젝트에서 0개가 나왔습니다. 키워드 검색은 검색어 두세 개를 입력하는 사용자를 기준으로 만든 도구입니다. 그런데 실제 사용자는 문장 형태로 질문합니다.

핵심어 추출과 to_tsquery 전환

수정 방식은 단순합니다. 질문을 글자와 숫자가 아닌 문자 기준으로 쪼개고 조사를 떼어 냅니다. 의문사와 요청 표현, 한 글자 토큰은 버립니다. 남은 단어를 최대 12개까지 OR로 잇고, 각 단어 뒤에 :*를 붙여 to_tsquery에 넘깁니다.

그림 아래쪽이 그 결과입니다. 같은 질문에서 청크 4개가 잡혔고, 정답이 담긴 청크는 키워드 순위 2위와 3위에 올랐습니다.

psql로 기존 쿼리와 새 쿼리를 비교한 결과

SQL 쪽 변경은 작습니다. plainto_tsquery를 to_tsquery로 바꾸고, 바인딩하는 값을 질문 원문 대신 가공한 핵심어 쿼리로 교체한 것이 전부입니다. 조사 제거는 어미 목록으로 거칠게 처리합니다. 그래도 접두 매칭이 단어 뒤쪽을 받아 주므로 검색에는 문제가 없습니다. 입력에 글자와 숫자만 남기기 때문에 tsquery 연산자 문자가 섞여 들어갈 일도 없습니다.

다른 질문으로 확인한 동작

'스트레스 DSR이 한도에 미치는 영향은 뭐야?'도 기존 쿼리에서는 0개였습니다. 핵심어 다섯 개(스트레스, dsr, 한도, 미치, 영향)로 바꾸자 상위 5개가 잡혔습니다. 그중 세 개는 대출 DSR 문서에서 나왔고 ts_rank는 0.1290, 0.0834, 0.0608 순이었습니다. 조사 때문에 잘린 '미치' 같은 어색한 토큰이 섞여도 접두 매칭과 OR 덕에 결과에 영향이 없었습니다.

KnowledgeKeywordQuery 발췌

변환 함수와 테스트

위 그림은 이 변환을 맡은 코드입니다. 순수 함수여서 단위 테스트로 고정하기 쉬웠습니다. 핵심어가 하나도 남지 않으면 null을 돌려주고, 이때는 키워드 검색을 건너뛰어 벡터 검색만 사용합니다.

테스트도 함께 추가했습니다. 단위 테스트는 의문사만 있는 입력이 null을 돌려주는지, 같은 단어를 반복해도 하나로 줄어드는지, 긴 입력이 12개에서 끊기는지, tsquery 연산자를 섞은 입력이 글자와 숫자로만 바뀌는지를 확인합니다. DB를 붙인 통합 테스트 네 개는 자연어 질문이 실제 청크에 걸리는지 검증합니다.

같은 질문 40개로 다시 잰 결과

같은 질문 40개와 같은 코퍼스(문서 66개, 청크 435개)로 비교했습니다. 정답 청크가 키워드 결과 상위 12개 안에 든 질문은 기존 AND 방식에서 0개, OR와 접두 매칭에서 33개였습니다. trigram(word_similarity)도 시도했지만, 긴 질문 전체를 통째로 비교하는 방식이라 0개가 나와서 접었습니다.

키워드 방식별 정답 회수 비교

전체 흐름으로 보면 리랭킹 없이 hit@4가 47.5%, hit@1이 17.5%이고 질문당 64ms가 걸립니다. 리랭킹을 켜면 hit@4가 72.5%, hit@1이 70%로 오르지만 6.4초가 듭니다. 그래서 기본값은 꺼짐으로 두었습니다. 후보 회수가 먼저 살아난 뒤에야 순서를 바꾸는 리랭킹도 의미를 갖는다는 것을 이번 측정에서 확인했습니다.

키워드 복구 후 리랭킹 효과

수치를 해석할 때의 한계

처음 측정한 hit@4 10%와 이번의 47.5%는 직접 비교할 수 없습니다. 그 사이에 질문 세트를 새로 뽑았기 때문입니다. 믿을 만한 근거는 한 번의 실행 안에서 나란히 잰 0/40 대 33/40입니다.

코퍼스는 저장소 문서이고 질문도 LLM이 만든 것이라, 다른 환경에서는 수치가 달라질 수 있습니다. 표본이 40개뿐이라 중간 지표는 흔들릴 여지가 있습니다. 33/40이라는 말은 7개는 여전히 놓친다는 뜻이기도 합니다. 스크린샷과 psql 예시는 샘플 프로젝트에서 한 번 실행해 본 것으로, 성능 근거로 쓰기보다 동작을 보여 주는 용도입니다.