RSS

한국어 RAG에서 벡터만 쓰면 안 되는 이유: 하이브리드 검색 설계

처음 RAG를 짤 때는 벡터 검색만 썼습니다. 코사인 유사도 하나로 충분할 줄 알았죠. 그런데 한국어 질문에서 이상한 일이 잦았습니다. 분명 문서에 있는 단어를 물었는데 검색이 안 됩니다. 비슷한 의미의 다른 문서만 계속 위에 뜹니다. 원인을 찾다 보니, 벡터 검색이 "의미가 비슷한가"는 잘 재는데 "그 단어가 실제로 들어있는가"는 못 본다는 걸 알게 됐습니다.

이 글은 벡터 검색에 키워드 검색을 더한 하이브리드 검색을 직접 짠 이유와, 거기서 부딪힌 한국어 특유의 문제들을 적은 겁니다.

PostgreSQL simple 사전의 한계

키워드 검색을 붙이려고 PostgreSQL의 풀텍스트 검색을 썼습니다. to_tsvectorplainto_tsquery를 쓰면, 문서 본문을 토큰으로 쪼개고 질문의 단어가 들어있는 문서를 찾아줍니다. 그런데 한국어에서 이게 잘 안 됐습니다.

PostgreSQL의 simple 사전은 한국어 형태소를 분리하지 않습니다. 질문 "거실 철제장에 뭐 있어"를 통째로 plainto_tsquery에 넣으면, "거실 철제장에 뭐 있어"라는 문장 전체가 일치하는 문서만 찾습니다. 당연히 거의 안 걸립니다.

그래서 질문에서 의미 있는 단어만 빼서 OR 조건으로 묶기로 했습니다. "거실", "철제장" 같은 명사만 뽑아 거실 | 철제장 형태의 to_tsquery로 만드는 겁니다. 이걸 위해 불용어 집합을 하나 뒀습니다. "어디", "있어", "찾아줘", "알려줘", "관련", "대한" 같은 단어는 질문에 자주 나오지만 검색에 의미가 없으니 빼는 겁니다. 한 글자 한글 명사("옷", "책", "컵")는 의미가 있어서 버리지 않게 했습니다.

이건 한국어 RAG에서 흔히 부딪히는 문제입니다. 영어면 PostgreSQL이나 ElasticSearch가 알아서 형태소를 잡아주지만, 한국어는 그렇지 않습니다. 형태소 분석기를 따로 붙이거나(MeCab 같은), 아니면 이렇게 불용어를 손으로 다듬어야 합니다. 저는 단순함을 위해 후자를 택했습니다.

벡터와 키워드를 RRF로 묶기

벡터 검색과 키워드 검색을 같이 돌리면, 두 결과를 어떻게 섞을지가 문제입니다. 점수의 단위가 다릅니다. 코사인은 0~1, 키워드 점수는 ts_rank라 0부터 수십까지 갑니다. 그냥 더하면 단위가 큰 쪽이 이깁니다.

그래서 점수가 아니라 순위로 묶었습니다. Reciprocal Rank Fusion(RRF)라는 방법입니다. 각 검색에서 문서가 몇 등인지를 보고, 1 / (k + 순위) 꼴의 점수로 바꿔 더합니다. 순위가 높을수록 점수가 크고, 두 검색에서 다 높게 나오면 점수가 더 커집니다.

여기서 가중치를 하나 조절했습니다. 벡터쪽은 1.0 / (60 + rank), 키워드쪽은 1.35 / (60 + rank)로 줬습니다. 키워드에 더 높은 가중치를 둔 겁니다. 이유는, 한국어에서 키워드가 정확히 일치했다는 건 그 단어가 문서에 실제로 있다는 강한 신호이기 때문입니다. 벡터는 비슷한 의미를 잡지만 정작 그 단어가 없을 수 있습니다. 정확한 단어가 들어있는 문서를 더 신뢰하는 쪽으로 맞췄습니다.

여기에 보너스를 하나 더 뒀습니다. 키워드 점수가 1 이상이면(즉 제목이나 본문에 정확히 일치하는 부분이 있으면) 점수에 0.02를 더합니다. 작은 값이지만, 두 검색이 비슷할 때 키워드 일치 쪽을 살짝 밀어주는 역할을 합니다.

제목에 더 큰 가중치를 두기

키워드 검색 안에서도 가중치를 나눴습니다. to_tsvector로 문서를 인덱싱할 때 제목(title)은 A, 제목 경로(heading_path)는 B, 본문(content)은 C 가중치를 줬습니다. setweight로 설정하는 건데, A가 C보다 키워드 일치 시 더 높은 점수를 받습니다.

이유는 단순합니다. 제목에 단어가 들어있으면 그 문서가 그 주제를 다루고 있을 확률이 높습니다. 본문 한 구석에 한 번 나오는 것과는 다릅니다. 질문의 핵심 단어가 제목에 있는 문서를 먼저 보여주는 게, 대부분의 경우 더 맞았습니다.

여기에 LIKE 보완을 하나 더 뒀습니다. to_tsquery가 한국어 형태소를 못 잡아서 놓치는 부분 일치를, title LIKE '%단어%'로 잡아냅니다. tsvector에 안 걸려도 LIKE로 잡히면 키워드 점수를 받습니다. 느리지만 색인이 4,500개 청크 수준이라 감당이 됩니다.

메타데이터로 검색 범위 좁히기

질문에 특정 의도가 있으면 검색 범위를 미리 좁힙니다. "물건", "재고", "옷", "신발" 같은 단어가 질문에 있으면, 문서(Doc)가 아니라 아이템(Item) 청크에서만 찾습니다. "옷 어디 있어?" 같은 질문을 문서 전체에서 찾으면 쓸데없는 회의록이 섞이니까요.

이건 메타데이터 필터의 가벼운 버전입니다. source_type이라는 메타데이터로 검색 대상을 줄이는 겁니다. 진짜 메타데이터 필터(날짜 범위, 작성자, 태그)까지는 안 갔습니다. 혼자 쓰는 시스템이라 그 정도 정밀도는 필요 없었고, 의도어로 대략 좁히는 것으로 충분했습니다.

리랭크: cross-encoder 대신 작은 모델 우회

하이브리드 검색 결과를 그대로 쓰면 여전히 문제가 있습니다. 검색 점수는 "질문과 비슷한가"를 봅니다. 하지만 필요한 건 "이 질문의 답이 여기 있는가"입니다. 둘은 자주 갈립니다. 배포 절차를 물으면, 배포를 언급하기만 한 회의록이 정작 절차 문서보다 높은 점수를 받습니다.

정석은 cross-encoder 리랭커를 쓰는 겁니다. 질문과 각 후보를 짝지어 관련도를 다시 매기는 모델이죠. 그런데 제가 쓰는 Ollama에는 rerank 엔드포인트가 없습니다. cross-encoder를 직접 올릴 수가 없었습니다.

그래서 우회했습니다. 후보 20개의 미리보기를 작은 모델에게 한 번에 보여주고, 질문에 답이 되는 후보의 번호만 고르게 합니다. 후보마다 모델을 부르면 스무 번 왕복이지만, 한꺼번에 주면 한 번이라 1~2초로 끝납니다. cross-encoder만큼 정확하지는 않겠지만, 없는 것보다는 훨씬 낫습니다.

작은 모델의 네 가지 출력을 받아주기

리랭크를 작은 모델로 돌리니 출력 형태가 제각각이었습니다. {"picks": [2, 4]}로 오면 좋은데, 실제로는 이런 것들이 다 나왔습니다.

2, 4 — 번호만. 2 — 숫자 하나(JSON.parse 하면 배열이 아니라 숫자). 2. [캐시 퍼지 순서] … — 번호를 고르고 거기에 제목까지 붙인 경우. format: "json" 옵션을 줘도 "JSON이어라"까지만 강제하고 모양은 강제하지 못합니다.

그래서 파싱 함수를 네 가지 경우를 다 받아주게 짰습니다. JSON 객체면 picks를 읽고, 배열이면 그대로 쓰고, 숫자면 하나짜리로 보고, JSON이 아니면 앞쪽의 숫자와 쉼표만 읽고 그 뒤는 버립니다. 제목이 붙어 오면 본문 속 숫자까지 번호로 오해하지 않게, 맨 앞의 숫자·쉼표만 취합니다.

이건 "프롬프트로 가르치다 포기하고 코드로 받아들인" 사례입니다. 큰 모델이면 {"picks": [...]} 하나로 깔끔하게 나왔을 텐데, 작은 모델은 아무리 시켜도 흐트러집니다. 그 흐트러짐을 감당하는 쪽이, 더 비싼 모델로 바꾸는 쪽보다 쌌습니다.

실패하면 원래 순서로

리랭크가 실패하면 어떻게 될까요. 모델이 응답하지 않거나, 타임아웃이 나거나, 이상한 값을 돌려주면. 이때 답변이 아예 안 나오면 곤란합니다.

그래서 리랭크는 관문이 아니라 보조 장치로 짰습니다. 실패하면 원래 하이브리드 검색 순서를 그대로 돌려줍니다. 또 모델이 후보를 전부 버리고 빈 배열을 내놓는 경우도 있는데, 작은 모델은 질문이 추상적일 때 그런 버릇이 있습니다. 하나도 안 고르면 답할 근거가 사라지니, 이때도 원래 순위로 되돌립니다. 리랭크가 도움을 주면 좋고, 못 주면 없는 셈 치고 가는 구조입니다.

하이브리드 검색과 리랭크를 직접 짠 건, 정교한 검색 엔진(ElasticSearch, Vespa)을 올리기엔 인프라가 과하고, 그렇다고 벡터만 쓰기엔 한국어에서 너무 많이 빠진다는 절충이었습니다. PostgreSQL 하나에서 벡터와 키워드를 같이 돌리고, 그 위에 작은 모델을 한 번 끼얹는 구조가, 지금 데이터 규모에서는 가장 가성비가 좋았습니다.