포스트

RAG 연습 (2) — 하이브리드 검색은 왜 두 번 검색하나

RAG 연습 (2) — 하이브리드 검색은 왜 두 번 검색하나

RAG 연습 (1)에서 직접 짠 파이프라인이라고 적었는데, 그중 검색 단계가 어떻게 돌아가는지 풀어 적어봅니다. core/rag/retriever.py 한 파일 얘기입니다.

검색을 왜 두 번 하나

도서관에서 책을 찾는다고 생각해 보세요. 두 가지 방법이 있습니다.

하나는 “비슷한 내용의 책”을 찾는 것입니다. 사서한테 “공급 시기가 언제냐”고 물으면 “용역 제공 시점” 같은 비슷한 표현이 들어간 책도 같이 골라 줍니다. 뜻이 비슷한 책을 잘 찾지만, “법 제47조의3” 같은 정확한 번호는 놓칠 수 있습니다.

다른 하나는 “내가 말한 단어가 그대로 들어 있는 책”을 찾는 것입니다. 색인 카드를 뒤지듯 단어를 그대로 매칭합니다. 조문 번호나 “0.022%” 같은 숫자는 정확히 잡지만, 같은 의미를 다른 말로 적은 책은 놓칩니다.

컴퓨터도 똑같습니다. 앞쪽이 의미 검색(dense), 뒤쪽이 키워드 검색(sparse)입니다. 한쪽만 쓰면 놓치는 게 생깁니다. 그래서 둘을 같이 돌리고 결과를 합칩니다. 이게 하이브리드 검색입니다.

의미 검색 — BGE-M3 + pgvector

문장을 숫자 묶음(벡터)으로 바꾸는 게 출발점입니다. BGE-M3(BAAI General Embedding, Multi-Lingual·Multi-Functionality·Multi-Granularity)라는 모델이 한 문장을 1024개의 숫자로 만들어 줍니다. 베이징 인공지능 연구원(BAAI)에서 2024년에 공개한 다국어 임베딩 모델인데, 한국어를 포함해 100여 개 언어를 지원하고 의미 검색·키워드 검색·다중 벡터를 한 모델에서 동시에 뽑을 수 있어서 “M3”라고 부릅니다. 뜻이 비슷한 문장은 비슷한 숫자가 됩니다.

질문도 같은 방식으로 숫자로 바꾸고, 데이터베이스에 저장된 청크들의 숫자와 거리를 잽니다. 가까울수록 비슷한 뜻입니다. pgvector가 PostgreSQL에서 이 거리 계산을 해 줍니다.

1
2
3
vecs = await self._embedding.embed([query.query])
query_vec = vecs[0]   # 1024개 숫자
# ORDER BY dense_vec <=> $1::vector  (<=> 가 거리 연산자)

200만 건이 넘으면 모든 청크와 거리를 다 재기엔 느립니다. 그래서 HNSW(Hierarchical Navigable Small World)라는 자료구조로 인덱스를 만들어 둡니다. 벡터들을 여러 층으로 쌓아 연결한 그래프인데, 위층에서 대충 가까운 영역으로 점프한 뒤 아래층으로 내려오면서 후보를 좁힙니다. 모든 벡터를 다 안 보고도 빠르게 가까운 이웃을 찾을 수 있어서, 정확한 1등은 아니더라도 거의 1등에 해당하는 후보를 수 ms 안에 찾아 줍니다.

1
2
3
CREATE INDEX idx_chunk_dense ON document_chunk
  USING hnsw (dense_vec vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

키워드 검색 — BM25와 PostgreSQL tsvector

BM25(Best Match 25)는 키워드 검색에서 가장 유명한 알고리즘입니다. 1994년에 나왔고, 지금도 Elasticsearch가 기본으로 씁니다. 두 가지를 봅니다.

첫째, 이 단어가 이 문서에 몇 번 나오나(TF, Term Frequency). “하도급”이 5번 나오면 점수가 높아집니다. 단, 100번 나와도 50번보다 점수가 크게 높진 않게 만들었습니다(포화 점수). 같은 단어를 도배해서 점수를 부풀리는 걸 막으려고 둔 장치입니다.

둘째, 이 단어가 다른 문서엔 얼마나 드물게 나오나(IDF, Inverse Document Frequency). “하도급”이 전체 문서 중 3개에만 나온다면 그 단어는 희귀한 만큼 가치가 큽니다. “있다”, “그리고” 같은 흔한 단어는 IDF가 낮아 점수에 거의 기여하지 않습니다.

PostgreSQL은 정확히 BM25는 아니지만 tsvector + ts_rank_cd(ts는 text search의 줄임)로 비슷하게 동작합니다. 별도 검색 엔진을 띄울 필요가 없는 게 장점입니다. tsvector는 문서를 단어 목록으로 쪼갠 형태, tsquery는 검색어를 쪼갠 형태, ts_rank_cd는 그 둘이 얼마나 잘 맞는지 점수를 매기는 함수입니다.

1
2
3
4
5
6
7
8
SELECT to_tsvector('simple', '하도급 대금 지연이자율');
-- → '대금':2 '지연이자율':3 '하도급':1

SELECT plainto_tsquery('simple', '하도급 이자율');
-- → '하도급' & '이자율'

WHERE content_tsv @@ plainto_tsquery('simple', '하도급 이자율')
ORDER BY ts_rank_cd(content_tsv, query, 32) DESC

@@는 매칭 연산자, ts_rank_cd가 점수입니다. 마지막 인자 32는 문서 길이 보정 옵션인데, 긴 문서가 단순히 단어를 많이 포함했다는 이유로 유리해지지 않게 막아줍니다.

simple 딕셔너리를 쓰는 이유는 PostgreSQL에 한국어 형태소 분석기가 기본으로 안 들어 있기 때문입니다. simple은 공백·구두점만 보고 단어를 자릅니다. 다행히 법령 텍스트는 “부가가치세법”, “제16조”처럼 이미 잘 끊어져 있어서 이걸로도 잘 잡힙니다. 일반 글이었다면 별도 형태소 분석기를 붙였을 것입니다.

새 문서를 넣을 때마다 content_tsv 컬럼을 손으로 채우긴 번거로워서, 트리거로 자동 갱신되게 했습니다.

1
2
3
4
5
6
7
8
9
10
CREATE OR REPLACE FUNCTION fn_chunk_tsv_update() RETURNS trigger AS $$
BEGIN
  NEW.content_tsv := to_tsvector('simple', NEW.content);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_chunk_tsv
  BEFORE INSERT OR UPDATE OF content ON document_chunk
  FOR EACH ROW EXECUTE FUNCTION fn_chunk_tsv_update();

두 결과를 어떻게 합치나 — RRF

의미 검색의 점수는 0~1 사이 코사인 유사도입니다. 키워드 검색의 점수는 ts_rank_cd 값으로, 단위가 다릅니다. 그냥 더하면 점수가 큰 쪽이 작은 쪽을 묻어버립니다.

순위의미 검색 (코사인)키워드 검색 (ts_rank_cd)
1위부가가치세법 제16조 (0.87)부가가치세법 시행령 제28조 (0.92)
2위부가가치세법 제15조 (0.85)부가가치세법 제16조 (0.75)
3위소득세법 청크 (0.80)부가세 유권해석 청크 (0.60)

0.87과 0.92는 단위가 다릅니다. 직접 비교할 근거가 없습니다.

해법은 점수를 버리고 순위만 보는 것입니다. 1위·2위·3위만 가지고 합산합니다. 이걸 RRF(Reciprocal Rank Fusion, Cormack et al. 2009)라고 부릅니다. 식은 단순합니다.

1
RRF score = Σ 1 / (k + rank_i)    (k = 60)

각 검색에서 등장한 순위마다 1 / (60 + 순위)를 더해 줍니다. 1위면 1/61, 2위면 1/62. 둘 다에서 등장하면 두 값을 더합니다. 실제로는 각 검색에서 20개씩 뽑아 최대 40개 청크에 대해 계산하지만, 예시로 각 검색의 상위 3위까지만 보면 이렇습니다.

청크의미 검색 순위키워드 검색 순위RRF 점수
부가가치세법 제16조1위2위1/61 + 1/62 ≈ 0.0325
시행령 제28조1위0 + 1/61 ≈ 0.0164
부가가치세법 제15조2위1/62 + 0 ≈ 0.0161
소득세법 청크3위1/63 ≈ 0.0159
부가세 유권해석3위1/63 ≈ 0.0159

두 곳 모두에서 잡힌 부가가치세법 제16조가 자연스럽게 1위로 올라갑니다. 한쪽에서만 1위였던 청크들보다도 위입니다. “두 곳에서 다 보였으니 더 믿을 만하다”는 직관이 그대로 점수에 반영됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
_RRF_K = 60

def _rrf_fusion(dense, sparse, top_k):
    rrf_scores = {}
    all_chunks = {}
    for rank, chunk in enumerate(dense, 1):
        rrf_scores[chunk.chunk_id] = rrf_scores.get(chunk.chunk_id, 0.0) \
                                     + 1.0 / (_RRF_K + rank)
        all_chunks[chunk.chunk_id] = chunk
    for rank, chunk in enumerate(sparse, 1):
        rrf_scores[chunk.chunk_id] = rrf_scores.get(chunk.chunk_id, 0.0) \
                                     + 1.0 / (_RRF_K + rank)
        all_chunks.setdefault(chunk.chunk_id, chunk)
    sorted_ids = sorted(rrf_scores, key=rrf_scores.get, reverse=True)
    return [all_chunks[cid] for cid in sorted_ids[:top_k]]

RRF는 의미 검색과 키워드 검색을 똑같이 취급합니다. 둘 다 1위면 더해지는 값(1/61)이 같습니다. 한쪽에 가산점을 주지 않습니다.

k=60은 검색 논문에서 자주 쓰는 기본값입니다. k가 작으면 1위와 2위 점수 차이가 커져 순위에 예민해지고, 크면 차이가 무뎌져 “등장했냐 안 했냐”만 중요해집니다. 60 근처가 둘 사이 균형이 적당하다고 알려져 있어서 그대로 썼습니다.

도메인에 따라 한쪽을 더 믿고 싶을 땐 weighted RRF를 씁니다. 식이 w_d / (k + 의미순위) + w_s / (k + 키워드순위)로 바뀝니다. 법령처럼 조문 번호·세율이 정확히 맞아야 하는 분야는 키워드 쪽 가중치를 키우고, 자유 서술이 많은 일반 글은 의미 쪽을 키웁니다. 지금은 1:1로 두고, 마지막 reranker가 정밀도를 잡게 했습니다.

그 뒤에 reranker

RRF로 합친 상위 20개를 그대로 LLM에 넣지 않습니다. 한 번 더 거릅니다. bge-reranker-v2-m3 모델이 질문과 각 청크를 한 쌍씩 보고 다시 점수를 매깁니다. 상위 5개만 프롬프트에 넣습니다.

검색은 빠른 대신 거칠고, reranker는 느린 대신 정확합니다. 처음부터 reranker를 모든 청크에 돌리면 너무 느려서, 검색으로 후보를 20개로 줄인 뒤에야 reranker를 켭니다. 그래서 순서가 이렇게 됩니다.

전체 파이프라인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
질문
 ├── BGE-M3 임베딩
 │     ├── pgvector HNSW (cosine)         → 의미 검색 후보 20
 │     └── PostgreSQL tsvector (ts_rank_cd) → 키워드 검색 후보 20
 │                       │
 │                  RRF (k=60)
 │                       │
 │                  융합 상위 20
 │                       │
 │             bge-reranker-v2-m3
 │                       │
 │                  최종 상위 5
 │                       │
 └────── Solar LLM 프롬프트 조립 → 답변

각 단계가 다른 문제를 풉니다. 의미 검색은 다른 말로 표현된 같은 뜻, 키워드 검색은 정확한 단어·숫자, RRF는 점수 단위 차이, reranker는 정밀도, LLM은 자연어 답변. 단계 하나만 빠져도 어딘가에서 답이 어긋납니다.

(클로드 코드의 도움을 받았습니다.)

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.