제 10 장

검색 품질 개선과 구현 대안

직원이 “IT-1042 어떻게 됐나요”라고 물었는데 봇이 엉뚱한 티켓을 가져왔다. 번호까지 대 준 질문이라 개발팀도 이건 변명할 여지가 없다고 봤다. 그 자리에서 검색부터 손보기로 했다.

그런데 손보기 전에 어떤 실패를 줄일지부터 정해야 한다. 봇이 동의 표현을 놓치기도 하고, 티켓 번호를 헷갈리기도 하고, 예외 조항을 후보에서 빠뜨리기도 하는데 손볼 자리는 저마다 따로 있다. 올바른 교육비 문서를 넘겼는데 숙박비 금액을 만들어 낸 실패는 검색 순위를 바꿔서 고칠 수 없다. 개발팀은 의미 검색과 키워드 검색을 같은 자료에서 돌리고 두 순위를 합쳐 봤다. 나아지지 않은 결과도 덮지 않고 기록에 그대로 남겼다.

두 검색기

두 검색기가 보는 것의미 검색임베딩 · 코사인 (큰 값이 앞)표현이 달라도 찾는다식별자·숫자는 약하다키워드 검색 · FTS5 BM25검색어의 등장과 분포 (작은 값이 앞)식별자·정확한 단어에 강하다어휘가 다르면 놓친다질문의미키워드“강의 결제 지원”✓✕문서에 ‘강의’·‘결제’가 없다 → 키워드 결과 없음“IT-1042”✓△“IT-1042는”에 조사가 붙어 처음엔 못 찾음 → 숫자·한글 경계에 공백

키워드 구현은 Python에 들어 있는 SQLite FTS5다. FTS5는 전문 검색용 가상 테이블과 질의 문법, 토크나이저, BM25 순위 함수를 제공한다.

BM25는 세 가지로 점수를 낸다. 질의어가 그 문서에 몇 번 나오는지, 그 단어가 전체 문서 집합에서 얼마나 드문지, 문서가 얼마나 긴지다. 그중에서도 드문 단어가 일치할수록 점수가 크게 오른다. 그래서 ‘신청’이 겹칠 때보다 IT-1042가 겹칠 때 훨씬 많이 오른다. 같은 단어가 열 번 나왔다고 점수가 열 배가 되지는 않게 빈도를 눌러 주고, 긴 문서가 단어를 많이 담았다는 이유만으로 앞에 오지 않게 길이로 나눈다. 다만 뜻을 보는 계산은 어디에도 없어서, 질의어와 같은 토큰이 문서에 있어야 비로소 점수가 생긴다. 토큰이 하나도 겹치지 않으면 점수가 낮아지는 것이 아니라 결과에서 아예 빠진다.

FTS5의 반환값은 작은 쪽이 앞이라, 코사인처럼 내림차순으로 정렬하면 순서가 뒤집힌다. SQLite FTS5 공식 문서

키워드 검색기는 문서 목록을 색인에 넣고 상위 결과를 돌려준다. 권한과 시행일은 호출자가 먼저 걸러서 넘긴다. 원시 질문을 FTS 질의 문법으로 바로 실행하지 않고 항목을 추출해 각각 따옴표로 묶은 뒤 OR로 연결한다. 사용자가 쓴 OR나 괄호가 검색 연산자로 해석되면 안 되기 때문이다. SQL 값은 매개변수 바인딩으로 넘긴다. 바인딩은 값을 SQL에 안전하게 옮겨 주지만 그 값 안에 든 전문 검색 질의 문법은 FTS5가 따로 해석한다.

첫 실행에서 개발팀이 IT-1042를 넣어 보니 아무것도 나오지 않았다. 문서에 적힌 IT-1042는에서 숫자와 조사가 한 단어로 붙어 있어 겹치는 토큰이 하나도 없었기 때문이다. 질의와 문서의 숫자·한글 경계에 공백을 넣자 그제야 티켓이 나왔다. 단어를 어떻게 나누는지부터가 검색 기능이라는 것을 개발팀은 이 실패로 알았다. 다만 이 처리는 형태소 분석이 아니라서, ‘지원된다’와 ‘지원되나요’를 같은 형태로 바꾸거나 ‘암호’와 ‘비밀번호’를 동의어로 묶어 주지는 않는다. 검색용 텍스트에만 적용하고 인용할 원문은 그대로 둔다. 지금 구현은 부를 때마다 메모리 색인을 만드는데, 운영 저장소에 붙이면 청크 갱신·삭제와 전문 검색 색인을 함께 맞춰서, 오래된 색인이 삭제 문서를 다시 찾지 않게 해야 한다.

순위 융합

두 검색기의 원시 점수는 그냥 더할 수 없다. 코사인은 클수록 가깝고 BM25는 작을수록 앞인데, 범위와 분포도 서로 맞지 않는다. 부호만 바꿔서 둘의 영향력이 맞춰지지는 않는다. 점수 결합을 쓰려면 검증 자료에서 정규화와 가중치를 함께 정해야 한다. 그래서 점수 대신 순위를 합치는 Reciprocal Rank Fusion, RRF를 쓴다. 각 목록에서 문서의 순위가 rr이면 1/(c+r)1/(c+r)를 더하고, 목록에 없는 문서는 그 목록에서 점수를 받지 못한다. RRF 원 논문

RRF⁡(d)=∑j:d∈Lj1c+rank⁡Lj(d)\operatorname{RRF}(d)=\sum_{j:d\in L_j}\frac{1}{c+\operatorname{rank}_{L_j}(d)}
순위 융합 RRF · 상수 c = 0인 예목록 A · 의미목록 B · 키워드1위 a2위 b1위 b2위 c1/(c+순위)를 더한다최종 순위1위 b1/2 + 1/1 = 1.5A 2위 · B 1위2위 a1/1 = 1A 1위3위 c1/2 = 0.5B 2위합집합을 재정렬할 뿐이다. 어느 목록에도 없는 문서는 새로 못 찾고, 둘이 같은 오답을 높게 보면 그 오답을 더 밀어 올린다. 실험 상수는 60

순위는 1부터 매긴다. 같은 목록에 같은 ID가 두 번 들어오면 오류를 낸다. 중복 청크가 자기 점수를 여러 번 더하는 것을 막기 위해서다. 동점은 ID 순서다. cc는 이 실험에서 60이고 어디서나 맞는 최적값이 아니다.

재순위화는 별도의 모델이나 규칙으로 후보의 순위를 다시 매기는 단계다. 질문과 후보 문서를 함께 읽는 cross-encoder는 두 입력의 관계를 직접 계산한다. 다만 문서 벡터를 미리 만들어 두는 방식과 달리 질문마다 후보 쌍을 계산해야 해서, 후보 수와 길이가 그대로 비용이 된다. 개발팀은 RRF까지만 돌렸고 재순위화 모델은 쓰지 않았다. 추가한다면 먼저 후보 집합의 재현율을 본다. 필요한 문서가 후보 밖에 있으면 재순위화로는 못 살린다. Passage Re-ranking with BERT

후보 예산과 문맥 예산

후보 발견 예산과 최종 문맥 예산의미 검색 상위 20키워드 검색 상위 20RRF · 합집합 ≤ 40문맥 예산 → 상위 5생성후보 발견 예산 · 20최종 문맥 예산 · 5두 값을 동시에 바꾸면 개선이 어느 단계에서 났는지 알 수 없다. 후보 수 → 정렬 방법 → 문맥 예산 순으로 하나씩 바꾼다

실험은 세 단계로 나눈다. 고정 청크에서 후보 수만 바꿔 정답 근거가 들어오는지 보고, 후보 집합을 고정한 채 정렬 방법을 비교하고, 마지막으로 생성 문맥의 토큰 예산과 중복 제거를 바꾼다. 질문과 정답 근거의 정의는 내내 같다. 청크 크기를 바꿀 때는 문서 수준과 청크 수준을 함께 본다. 교육비 문서가 검색됐어도 한도만 담은 청크와 예외 조건을 담은 청크는 역할이 달라서, ‘정답 문서 ID가 있음’만으로 필요한 조건이 전달됐다고 볼 수 없다.

실험 표에는 설정, 후보 재현율, 실제 전송 근거의 완전성, 입력 토큰, 단계별 시간을 적고 근거 없는 질문은 따로 집계한다. 생성 오류가 남아 있는 동안은 답변 성공률만으로 검색기를 평가하지 말고 후보 지표를 함께 본다. 이번에는 개발팀이 청크와 후보 수를 고정하고 정렬 방법만 비교했다. 문서 일곱 개는 모두 짧은 단일 청크고 검색기마다 상한은 3이다.

프레임워크와 관리형 검색

방식맡기는 것그래도 남는 것
직접 구현없음. 단계마다 입력과 반환값이 드러난다파서·저장소·모델을 바꿀 때 어댑터 관리
프레임워크로더·분할기·임베딩·저장소·검색기를 연결하는 공통 추상화권한·원문 리비전·인용 규칙, 숨은 기본값(분할 크기·재시도·모델 호출) 확인
관리형 검색색인 구축과 저장소 운영무엇을 올리고 누가 읽는가, 삭제 반영 시점, 원문·식별자·리비전을 돌려받는가, 필터 강제

LangChain의 검색 안내는 문서 로더, 분할기, 임베딩, 벡터 저장소, 검색기를 구성 요소로 설명하고, LlamaIndex의 RAG 안내도 수집·색인·질의 단계를 다룬다. 참고 자료일 뿐 개발팀이 두 프레임워크를 설치해 붙여 보지는 않았다. 도입은 코드 줄 수로 정하지 않는다. 필요한 저장소와 파서의 지원, 비동기 실행과 관측 지점, 버전 변경의 영향, 팀이 오류 경로를 따라갈 수 있는지를 본다. 옮기더라도 검색기 부분만 먼저 바꿔, 같은 질문·권한·기준일에 같은 형식의 청크가 나오는지 기존 검사로 확인한다. 원문 구간과 리비전이 표준 객체로 바뀌면서 사라지지 않게 한다. LangChain 검색 안내, LlamaIndex RAG 안내

외부 재순위화도 자료 전송이다. 생성 모델에는 허용 문서만 보냈더라도 그 앞의 재순위화 서비스에 권한 밖 후보를 보내면 통제가 뚫리므로, 검색·세기·재순위화·생성의 외부 전송을 다 함께 본다. 비용도 생성 호출만 세지 않고 색인 갱신과 검색·재순위화 요청을 넣는다. 관리형 검색이 실패했다고 권한 필터가 없는 로컬 전체 검색으로 우회하면 안 된다. 대체 경로가 같은 권한·시행일·삭제 규칙을 못 지키면 요청을 실패로 처리한다.

두 검색기를 같은 질문으로

개발팀은 정책 문서 다섯 개에 가상 티켓 두 개를 더해 질문 여섯 개를 돌렸다. E5 모델과 리비전은 그대로고 SQLite 3.53.2의 FTS5를 썼다. 모든 자료가 허용된 가상 사용자 한 명으로 실행했다.

여섯 질문에서 정답 문서가 1위였나 · 검색기별 상한 3질문 → 필요한 문서의미키워드RRFH01강의 결제 지원→ edu-v2✓–✓H02암호를 잊음→ account-v1✓–✓H03IT-1042→ ticket-1042✓△✓H04IT-1043 상태→ ticket-1043✓△✓H05교육비 한도→ edu-v2✓✓✓H06해외 호텔 숙박비→ 근거 없음✕✕✕– = 결과 없음 (어휘 불일치) · △ = 숫자·한글 경계를 고친 뒤 찾음 · ✕ = 근거가 없는데 travel-v1을 돌려줌정답이 있는 다섯 건은 의미 검색이 이미 1위. 이 자료에서 RRF의 추가 이득은 없었고, 목록 간 합의가 정답의 존재를 증명하지도 않는다
사례요구의미 1위키워드 1위RRF 1위
H01 강의 결제 지원교육비 문서edu-v2결과 없음edu-v2
H02 암호를 잊음계정 문서account-v1결과 없음account-v1
H03 IT-1042해당 티켓ticket-1042ticket-1042ticket-1042
H04 IT-1043 상태해당 티켓ticket-1043ticket-1043ticket-1043
H05 교육비 한도교육비 문서edu-v2edu-v2edu-v2
H06 해외 호텔 숙박비근거 없음travel-v1travel-v1travel-v1

경계를 나눠 주자 키워드 검색도 H03·H04를 찾아냈지만 의미 검색은 고치기 전부터 둘 다 맞히고 있었다. H06에서는 세 방법 모두 답이 없는 국내 교통비 문서를 돌려줬다. 세 방법이 같은 문서를 1위에 올렸다고 그 문서가 정답이 되지는 않는다. 자료가 작고 주제가 뚜렷해서, 더 어려운 보류 질문, 비슷한 정책 판과 식별자, 두 근거를 동시에 요구하는 질문을 더해야 채택 판단에 쓸 수 있다. 결과를 보고 전처리를 고쳤으니 현재 사례를 최종 평가에 쓸 수 없다.

시간은 범위를 나눠 읽는다. 의미 검색 시간에는 질문 임베딩이, 키워드 시간에는 매번 메모리 색인을 만드는 비용이 들어가고, RRF 시간에는 두 목록을 합치는 연산만 들어간다. 셋을 같은 범위의 종단 지연으로 비교하면 안 된다. 개발팀은 RRF를 구현하고 비교 기준선까지 잡았지만 이 자료에서 검색 품질이 나아지지는 않았다고 판단했다. 운영에 채택하려면 권한이 반영된 지속 색인이 있어야 하고, 더 어려운 질문에서 후보가 나아져 늘어난 비용만큼 값을 하는지도 봐야 한다.

직원이 IT-1042를 다시 물으면 키워드 검색도 그 티켓을 찾아낸다. 그런데 의미 검색은 고치기 전부터 그 티켓을 1위에 올려놓고 있었다. 검색 품질은 그대로였다. 대신 개발팀은 숫자와 한글이 붙어 있으면 키워드 검색이 번호를 통째로 놓친다는 것을 알았다.

문서를 지식으로 연결하기10 / 21