Skip to content

fix(core): 하이브리드 검색의 키워드 절반이 실제 질의에서 한 번도 매칭되지 않던 문제 - #275

Merged
i3months merged 1 commit into
devfrom
fix/rag-hybrid-or-tsquery
Sep 29, 2026
Merged

i3months merged 1 commit into
devfrom
fix/rag-hybrid-or-tsquery

Conversation

@i3months

Copy link
Copy Markdown
Member

문제

searchHybrid 는 벡터 랭킹과 full-text 랭킹을 RRF 로 융합하도록 구현돼 있습니다. 그런데 full-text 브랜치가 실제 질의에서 한 번도 매칭되지 않았습니다.

plainto_tsquery 는 토큰을 전부 &(AND)로 묶습니다. 이 검색의 질의는

  • 꼬리질문: 직전질문 + 답변 → 토큰 13~22개
  • 질문 풀: 모드 + 직군 + 자기소개 600자 → 토큰 9~96개

이고, 그 전부를 담은 청크는 존재하지 않습니다.

운영 DB 에서 질의를 그대로 재구성해 측정했습니다:

경로 chunk_text_tsv @@ plainto_tsquery(...) 매칭
꼬리질문 질의 47건 중 47건이 0건
질문 풀 질의 30건 중 30건이 0건

즉 RRF 가 조용히 벡터 단독 검색으로 퇴화한 상태였습니다. 고장이 "결과가 없다"가 아니라 "벡터 결과와 똑같다" 로 나타나서 눈에 띄지 않았습니다.

수정

tsquery 를 OR 로 결합합니다. 어휘소 사이의 구분자만 치환하므로 어휘소 내부는 건드리지 않습니다(& 는 구분자라 어휘소가 되지 못합니다).

운영과 같은 헤딩 청킹 인덱스, 질의 47개, top_k=5:

구성 Recall@5 MRR@5 Top-1
벡터 단독 0.936 0.791 0.681
현행 하이브리드 0.936 0.791 0.681
OR 하이브리드 0.979 0.885 0.830

현행이 벡터 단독과 47/47 완전히 동일(승0/패0/무47)한 것이 무효의 직접 증거입니다.

paired bootstrap 95% CI (20k): MRR Δ+0.093 [+0.029, +0.162] (12승/3패), Top-1 Δ+0.149 [+0.043, +0.277] (8승/1패), Recall Δ+0.043 [+0.000, +0.106].

top_k=10 에서도 같은 방향입니다(MRR .797→.841, Top-1 .681→.745, Recall .979→1.000).

손대지 않은 것

후보 풀 :cand(= top_k)는 그대로 뒀습니다. 측정한 구성이 cand = top_k 였고, 여기서 바꾸면 측정하지 않은 것을 배포하는 셈입니다. 별도로 다룰 항목입니다.

경계 조건

운영 PG 에서 직접 확인했습니다 — 빈 질의·구두점만 있는 질의는 빈 tsquery 가 되어 0건(현행과 동일, 벡터 단독으로 폴백), 단일 토큰은 그대로, 본문의 & 문자는 구분자라 안전합니다.

테스트

DocumentEmbeddingSearchTest 에 실제 PG(@PostgresRepositoryTest) 기준 2건 추가.

핵심은 변별력입니다. 처음 쓴 버전은 청크가 2개뿐이라 벡터만으로도 둘 다 상위 5에 들어 AND 에서도 통과했습니다. 미끼를 top_k 만큼 채워 "벡터만으로는 top_k 밖인 청크를 키워드로만 끌어올리는" 상황을 만들었습니다.

또 기존 헬퍼 vector(head) 는 0번 차원의 크기만 바꿔 모든 벡터가 평행이라 코사인 거리가 언제나 0이었습니다(순위가 갈리지 않음). 직교 단위벡터 헬퍼 axis(dim) 를 추가했습니다.

plainto_tsquery 로 되돌리면 hybridMatchesOnAnySignificantTokenNotAllOfThem 이 실패하는 것을 확인했습니다. ./gradlew test 전체 통과.

🤖 Generated with Claude Code

plainto_tsquery 는 토큰을 전부 AND 로 묶는다. 그런데 이 검색의 질의는
꼬리질문이면 "직전질문 + 답변", 질문 풀이면 "모드 + 직군 + 자기소개 600자"라
토큰이 10~90개다. 그 전부를 담은 청크는 존재하지 않으므로 full-text 브랜치가
항상 0건이었고, RRF 가 조용히 벡터 단독 검색으로 퇴화했다.

운영 실측: 꼬리질문 질의 47/47, 질문 풀 질의 30/30 이 0건. 구현돼 있고
테스트도 통과하지만 실제 질의 형태에서는 무효인 상태였다.

tsquery 를 OR 로 결합한다. 운영과 같은 헤딩 청킹 인덱스에서 질의 47개,
top_k=5 기준:

  MRR    0.791 → 0.885  (paired bootstrap 95% CI +0.029~+0.162, 12승/3패)
  Top-1  0.681 → 0.830  (CI +0.043~+0.277, 8승/1패)
  Recall 0.936 → 0.979

후보 풀 :cand 은 손대지 않았다 — 측정한 구성이 cand = top_k 였고, 바꾸면
측정하지 않은 것을 배포하는 셈이 된다.

회귀 테스트는 벡터만으로는 top_k 밖인 청크를 키워드로만 끌어올리게 만들어
두 절반을 구분한다. 미끼를 top_k 만큼 채우지 않으면 벡터가 어차피 전부
반환해 AND 에서도 통과한다(처음에 그렇게 써서 변별력이 없었다). 기존 헬퍼
vector(head) 는 0번 차원의 크기만 바꿔 모든 벡터가 평행이라 코사인 거리가
언제나 0이었다 — 직교 단위벡터 헬퍼 axis(dim) 를 추가했다.

AND 로 되돌리면 이 테스트가 실패하는 것을 확인했다.
@i3months
i3months merged commit 69ae099 into dev Sep 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant