검색을 개선하려면 어떤 실패를 줄일지부터 정한다. 동의 표현을 놓치는 문제, 티켓 번호를 헷갈리는 문제, 예외 조항이 후보에서 빠지는 문제는 서로 다른 조치가 필요하다. 올바른 교육비 문서를 넘겼는데 숙박비 금액을 만들어 낸 실패는 검색 순위를 바꿔서 고칠 수 없다. 아래 실험은 의미 검색과 키워드 검색을 같은 자료에서 돌리고 순위를 합친 결과다. 개선이 보이지 않은 것도 그대로 적는다.
두 검색기
키워드 구현은 Python에 들어 있는 SQLite FTS5다. FTS5는 전문 검색용 가상 테이블과 질의 문법, 토크나이저, BM25 순위 함수를 제공한다. BM25는 검색어의 빈도와 문서 길이를 반영한 점수인데 FTS5의 반환값은 작은 쪽이 앞이라, 코사인처럼 내림차순으로 정렬하면 순서가 뒤집힌다. SQLite FTS5 공식 문서
from hybrid_search import keyword_search
hits = keyword_search(
"IT-1042",
[("ticket-1042", "티켓 IT-1042는 노트북 수리 요청이다."),
("ticket-1043", "티켓 IT-1043은 계정 요청이다.")],
)
keyword_search는 (id, text) 목록을 메모리 색인에 넣고 상위 결과를 돌려준다. 권한과 시행일은 호출자가 먼저 걸러서 넘긴다. 원시 질문을 FTS 질의 문법으로 바로 실행하지 않고, 항목을 추출해 각각 따옴표로 묶은 뒤 OR로 잇는다. 사용자가 쓴 OR나 괄호가 검색 연산자로 해석되면 안 되기 때문이다. SQL 값은 매개변수 바인딩으로 넘기는데, SQL 바인딩과 전문 검색 질의 문법은 별개의 처리다.
첫 실행에서 IT-1042 질의는 아무것도 못 찾았다. 문서의 IT-1042는에서 숫자와 조사가 붙어 있어서다. 단어를 어떻게 나누는지가 검색 기능의 일부라는 것을 보여 준 실패다. 질의와 문서의 숫자·한글 경계에 공백을 넣자 찾았다. 이 처리는 형태소 분석이 아니다. ‘지원된다’와 ‘지원되나요’를 같은 형태로 바꾸거나 ‘암호’와 ‘비밀번호’를 동의어로 이어 주지 않고, 검색용 텍스트에만 적용하며 인용할 원문은 그대로 둔다. 현재 함수는 부를 때마다 메모리 색인을 만든다. 운영 저장소에 붙이면 청크 갱신·삭제와 전문 검색 색인을 함께 맞춰서, 오래된 색인이 삭제 문서를 다시 찾지 않게 해야 한다.
순위 융합
두 검색기의 원시 점수를 더하면 어떻게 될까. 코사인은 클수록 가깝고 BM25는 작을수록 앞이며 범위와 분포도 다르다. 부호만 바꿔서 둘의 영향력이 맞춰지지는 않는다. 점수 결합을 쓰려면 검증 자료에서 정규화와 가중치를 함께 정해야 한다. 이 실습은 점수 대신 순위를 합치는 Reciprocal Rank Fusion, RRF를 쓴다. 각 목록에서 문서의 순위가 이면 를 더하고, 목록에 없는 문서는 그 목록에서 받는 것이 없다. RRF 원 논문
from hybrid_search import reciprocal_rank_fusion
combined = reciprocal_rank_fusion(
[["a", "b"], ["b", "c"]], constant=0
)
# b: 1/2 + 1/1 = 1.5, a: 1, c: 1/2
구현은 순위 1부터 시작하고 같은 목록에 같은 ID가 반복되면 거부한다. 중복 청크가 자기 점수를 여러 번 더하는 것을 막기 위해서다. 동점은 ID 순서다. 실험 상수 60은 이 실험의 값이고 보편적인 최적값이 아니다.
재순위화는 별도의 모델이나 규칙으로 후보를 다시 매기는 단계다. 질문과 후보 문서를 함께 읽는 cross-encoder는 두 입력의 관계를 직접 계산하지만, 문서 벡터를 미리 만들어 두는 방식과 달리 질문마다 후보 쌍의 연산이 필요해서 후보 수와 길이가 비용이 된다. 이 장의 실행은 RRF까지고 재순위화 모델은 돌리지 않았다. 추가한다면 먼저 후보 집합의 재현율을 본다. 필요한 문서가 후보 밖에 있으면 재순위화로는 못 살린다. Passage Re-ranking with BERT
후보 예산과 문맥 예산
실험은 세 단계로 나눈다. 고정 청크에서 후보 수만 바꿔 정답 근거가 들어오는지 보고, 후보 집합을 고정한 채 정렬 방법을 비교하고, 마지막으로 생성 문맥의 토큰 예산과 중복 제거를 바꾼다. 질문과 정답 근거의 정의는 내내 같다. 청크 크기를 바꿀 때는 문서 수준과 청크 수준을 함께 본다. 교육비 문서가 검색됐어도 한도만 담은 청크와 예외 조건을 담은 청크는 역할이 달라서, ‘정답 문서 ID가 있음’만으로 필요한 조건이 전달됐다고 볼 수 없다.
실험 표에는 설정, 후보 재현율, 실제 전송 근거의 완전성, 입력 토큰, 단계별 시간을 적고 근거 없는 질문은 따로 집계한다. 생성 오류가 남아 있는 동안은 답변 성공률만으로 검색기를 평가하지 말고 후보 지표를 함께 본다. 이번 실측은 청크와 후보 수를 고정했다. 문서 일곱 개는 모두 짧은 단일 청크고 검색기마다 상한은 3이다.
프레임워크와 관리형 검색
| 방식 | 맡기는 것 | 그래도 남는 것 |
|---|---|---|
| 직접 구현 | 없음. 단계마다 입력과 반환값이 드러난다 | 파서·저장소·모델을 바꿀 때 어댑터 관리 |
| 프레임워크 | 로더·분할기·임베딩·저장소·검색기를 잇는 공통 추상화 | 권한·원문 판·인용 규칙, 숨은 기본값(분할 크기·재시도·모델 호출) 확인 |
| 관리형 검색 | 색인 구축과 저장소 운영 | 무엇을 올리고 누가 읽는가, 삭제 반영 시점, 원문·식별자·판을 돌려받는가, 필터 강제 |
LangChain의 검색 안내는 문서 로더, 분할기, 임베딩, 벡터 저장소, 검색기를 구성 요소로 설명하고, LlamaIndex의 RAG 안내도 수집·색인·질의 단계를 다룬다. 참고 자료일 뿐 두 프레임워크를 이 예제에 설치하거나 통합하지는 않았다. 도입은 코드 줄 수로 정하지 않는다. 필요한 저장소와 파서의 지원, 비동기 실행과 관측 지점, 버전 변경의 영향, 팀이 오류 경로를 따라갈 수 있는지를 본다. 옮기더라도 검색기 부분만 먼저 바꿔서 같은 질문·권한·기준일에 같은 형식의 청크가 나오는지 기존 검사로 확인하고, 원문 구간과 리비전이 표준 객체로 바뀌면서 사라지지 않게 한다. LangChain 검색 안내, LlamaIndex RAG 안내
외부 재순위화도 자료 전송이다. 생성 모델에는 허용 문서만 보냈더라도 그 앞의 재순위화 서비스에 권한 밖 후보를 보내면 통제가 깨진다. 검색·계수·재순위화·생성의 외부 전송을 다 함께 본다. 비용도 생성 호출만 세지 않고 색인 갱신과 검색·재순위화 요청을 넣는다. 관리형 검색이 실패했을 때 권한 필터가 없는 로컬 전체 검색으로 우회하면 안 된다. 대체 경로가 같은 권한·시행일·삭제 규칙을 못 지키면 요청을 실패로 처리한다.
여섯 질문의 실측
python books/ai-engineering/examples/hybrid_demo.py > /tmp/hybrid-new.json
python -m unittest discover -s books/ai-engineering/examples -p 'test_*.py' -q
hybrid_demo.py는 다섯 정책 문서에 가상 티켓 두 개를 더해 질문 여섯 개를 돌린다. E5 모델과 리비전은 그대로고 SQLite 3.53.2의 FTS5를 썼다. 모든 자료가 허용된 가상 사용자 한 명으로 실행했다.
| 사례 | 요구 | 의미 1위 | 키워드 1위 | RRF 1위 |
|---|---|---|---|---|
| H01 강의 결제 지원 | 교육비 문서 | edu-v2 | 결과 없음 | edu-v2 |
| H02 암호를 잊음 | 계정 문서 | account-v1 | 결과 없음 | account-v1 |
| H03 IT-1042 | 해당 티켓 | ticket-1042 | ticket-1042 | ticket-1042 |
| H04 IT-1043 상태 | 해당 티켓 | ticket-1043 | ticket-1043 | ticket-1043 |
| H05 교육비 한도 | 교육비 문서 | edu-v2 | edu-v2 | edu-v2 |
| H06 해외 호텔 숙박비 | 근거 없음 | travel-v1 | travel-v1 | travel-v1 |
숫자·한글 경계를 고치기 전 결과는 experiments/hybrid-unsegmented.json, 고친 뒤는 hybrid-baseline.json에 있다. 수정은 키워드 검색의 H03·H04를 살렸지만 의미 검색은 이미 둘을 맞히고 있었다. H06에서는 세 방법 모두 답이 없는 국내 교통비 문서를 돌려줬다. 목록 사이의 합의가 정답의 존재를 증명하지 않는다. 자료가 작고 주제가 뚜렷해서, 더 어려운 보류 질문, 비슷한 정책 판과 식별자, 두 근거를 동시에 요구하는 질문을 더해야 채택 판단에 쓸 수 있다. 결과를 보고 전처리를 고쳤으니 현재 사례는 독립적인 최종 평가 집합과 다르다.
시간은 범위를 나눠 읽는다. 의미 검색 시간에는 질문 임베딩이, 키워드 시간에는 매번 메모리 색인을 만드는 비용이 들어가고, RRF 시간은 두 목록을 합치는 연산만이다. 셋을 같은 범위의 종단 지연으로 비교하면 안 된다. 이번 판단은 “RRF 구현과 비교 기준선은 확보했지만 이 자료에서 추가 검색 품질 이득은 없었다”다. 운영 채택에는 권한이 반영된 지속 색인과 더 어려운 질문에서 후보 개선이 추가 비용을 정당화하는지가 필요하다.
실습: 개선 주장을 반증할 수 있게 만들기
- 비슷한 티켓 번호를 더 넣고 번호만 있는 질문과 증상만 있는 질문을 나눠 돌린다. 어느 검색기가 강할 것이라는 예상과 결과가 다르면 그대로 남긴다.
- 두 검색기의 후보 수를 각각 1·3·5로 바꾸고 RRF가 새 정답 후보를 얻는지 본다. 두 목록에 등장한 문서가 늘어난 것과 정답 근거가 늘어난 것을 구별한다.
- 권한·시행일을 통과한 후보를 넘기는 어댑터를 만들고 삭제·권한 회수 테스트를 적용한다. 키워드 색인에 과거 자료가 남아 있어도 최종 검색에 나오면 안 된다. 현재 메모리 비교 함수에는 그 관리 경로가 없다.
검색 방법을 더하는 것은 수단이다. 필요한 근거를 빠뜨리지 않고, 허용된 자료만 쓰고, 개선 효과를 반복해서 확인할 수 있는 상태가 기준이다.