지난 글은 기준선을 동결하기 전에 비교기부터 고친 기록이었고, 끝에 M5-A가 공유 스키마에 첫 스키마를 추가할 때 기준선이 비교를 거부하지 않고 스키마 바뀜이라고 보고하는지가 M5-0의 첫 확인이 된다고 적었다. 이번 PR #23이 그 M5-A다. 확인 결과부터 적으면, 비교기는 compatible: true, compared: 28, schemaChanged: true를 돌려줬다. 지난 슬라이스가 한 일이 다음 슬라이스에서 바로 쓰였다.
M5-A는 근거 코퍼스와 어휘 검색이다. 모델 호출은 없고, 검색은 아직 판정 경로에 붙지 않았다. 이 PR이 만든 것은 근거를 조각으로 나누고, 색인하고, 얼마나 잘 찾는지를 재는 층이다. 왜 붙이기 전에 재는지가 이 글의 중심이다.
못 찾는 곳을 먼저 알아야 하는 이유
M5 계획에서 임베딩 검색은 승인 게이트 뒤로 미뤘다. 별도의 모델과 제공자, 코퍼스의 추가 전송, 비용이 따라오기 때문이다. 대신 어휘 검색만 먼저 만들고 그 수치를 본 뒤에 정하기로 했다. 그러려면 수치가 있어야 한다. 어휘 검색이 어디서 실패하는지를 모르면 임베딩이 필요하다는 말도, 필요 없다는 말도 근거가 없다.
그래서 이번 슬라이스에서는 검색 기능과 함께, 어디서 근거를 놓치는지 확인할 평가도 만들었다. 합성 코퍼스 하나, 저작한 질의 서른여섯 개, 그리고 pnpm eval:retrieval이라는 명령이다. 코퍼스는 구조화 프로필 하나와 문서 열 개다. 프로젝트 글 셋, 블로그 글 셋, 그리고 방해 문서 넷. 방해 문서는 프로필과 단어를 공유하지만 후보자의 근거가 아닌 글이다. ML 엔지니어의 노트에 React Native가 나오고, 백엔드 on-call 핸드북에 Node.js 서비스와 롤백이 나온다. 정답 문서만으로는 관련 없는 문서 사이에서 근거를 골라내는 능력을 확인하기 어려워, 방해 문서를 넣었다.
어휘 검색이 실패하는 자리
검색은 BM25를 프로세스 안에서 돌린다. 새 서비스는 없다. 토크나이저는 문자 정규화와 소문자 변환 후 문자와 숫자가 아닌 곳에서 나누는 것이 전부다. 스테밍도 불용어 제거도 없다. 한계를 알면서 그렇게 했다. 어디서 실패하는지를 보려면 실패가 가려지지 않아야 한다. 대신 색인에 없는 질의어는 misses로 보고하고, 어떤 질의어도 매칭되지 않으면 결과 목록을 비워 둔다. 없는 조각을 지어내지 않는다.
질의는 채용 요건 문장처럼 썼다. M5-B에서 요건 문장이 그대로 검색 질의가 되기 때문이다. 재현율은 조각 나누기 방식 둘로 쟀다. 프로필 필드나 문서 문단 하나를 조각으로 보는 field, 문장 하나를 조각으로 보는 sentence.
| 조각 나누기 방식 | 조각 수 | Recall@3 | Recall@5 |
|---|---|---|---|
| field | 35 | 47/55 | 49/55 |
| sentence | 79 | 46/60 | 51/60 |
Recall@K는 전체 정답 조각 가운데 상위 K개 검색 결과에서 찾은 조각의 비율이다. 표는 질의별 정답 조각 수를 합산한 값이며, 문장을 나누거나 묶는 방식에 따라 분모가 달라진다.
숫자보다 중요한 것은 정답을 하나도 찾지 못한 질의 셋이다. “Independent deployments across teams”는 코퍼스에 “deploys independently”로 있다. “Rollback procedure for frontend releases”는 “Rollbacks”로 있다. “Content management system implementation”은 “internal CMS”로, 그것도 “Implemented”라는 동사 뒤에 있다. 세 질의 모두 결과는 돌아왔다. 롤백을 묻자 “frontend”라는 단어 때문에 채용 루프와 아키텍처 리뷰 문장이 올라왔고, CMS를 묻자 “system”이라는 단어 때문에 디자인 시스템 문장이 올라왔다. 이번 구현은 형태 변화나 약어를 연결하지 못해, 이 세 질의에서 정답을 상위 다섯 개에 올리지 못했다. 이 셋이 임베딩을 열지 말지 결정할 때 볼 첫 근거다. 다만 이 결과만으로 임베딩의 필요성이나 개선 효과가 입증된 것은 아니고, 아직 결정하지 않았다.
정답을 해시로 적지 않은 이유
재현율을 재려면 질의마다 정답 조각이 필요하다. 해시를 정답으로 직접 적으면 사람이 내용을 알아보기 어렵고, 코퍼스가 바뀔 때 관리하기도 번거롭다. 대신 정답은 문장 위치로 적었다. 어느 문서의 몇 번째 문단 몇 번째 문장, 또는 프로필의 어느 필드 몇 번째 항목. 실행기가 로드할 때 그 위치의 문장이 적어 둔 텍스트와 같은지 대조하고, 나누기 방식별로 해당하는 조각 id로 풀어낸다. 문장 하나가 field 조각에서는 그 문장을 포함한 문단 조각이 되고, sentence 조각에서는 그 문장 조각이 된다. 코퍼스를 고치면 대조가 깨지고, 깨지면 점수를 내지 않는다.
프로필 조각의 위치 표기는 M4-B1의 인용 경로 문법과 같게 했다. roles[0].evidence[1] 같은 형태다. M5-B가 검색 조각을 인용으로 연결할 때 새 문법을 배우지 않아도 된다. 다만 이것은 분할되지 않은 문장 단위 프로필 조각에만 해당한다. 필드 단위 조각은 배열 전체를 가리키고, 긴 조각은 나뉜 번호가 붙는다.
리뷰가 잡은 세 가지
제목 바로 뒤의 본문이 사라졌다. 문서를 조각으로 나누는 코드는 빈 줄로 문단을 나누고 #으로 시작하는 문단을 제목으로 보고 버렸다. 그런데 문단을 먼저 한 줄로 합친 뒤 첫 글자만 봤다. # Project 다음 줄에 바로 본문이 오면 본문까지 통째로 사라졌다. 리뷰어가 합성 입력으로 재현했다. 합성 코퍼스는 제목 뒤에 빈 줄이 있어서 수치에는 드러나지 않았다. 줄 단위로 제목 줄만 빼고 본문은 남기도록 고쳤고, 제목 뒤에 개행만 있는 경우와 빈 줄이 있는 경우와 제목만 있는 경우를 대조군으로 넣었다.
잘못된 정답이 섞여도 점수가 나왔다. 정답 위치가 코퍼스에 없으면 문제로 기록하고 실패로 끝내긴 했다. 그런데 남은 정답만으로 계산한 재현율이 그대로 출력됐다. 리뷰어는 실제 질의 하나에 코퍼스에 없는 위치를 더해 보였다. 실패 표시는 났지만 field 재현율은 원래와 같은 47/55였다. 저장된 보고서의 숫자만 읽는 사람은 잘못된 평가를 인용하게 된다. 정답을 채점 전에 전부 해석하고, 하나라도 풀리지 않으면 그 질의와 그 방식의 재현율을 해당 없음으로 바꿨다. 마크다운 보고서는 문제 목록을 점수 표보다 앞에 둔다.
해시가 보장하지 않는 것을 보장한다고 적었다. 나는 스키마 주석과 PR 본문에 “조각 id가 내용 해시라서 실행에 없던 조각을 가리킬 수 없다”고 썼다. 해시는 누구나 계산할 수 있다. 다른 실행의 조각이든 지어낸 입력이든 64자리 해시는 나온다. 해시가 보장하는 것은 같은 입력이면 같은 id라는 안정성뿐이고, 인용된 조각이 이번 실행에 있었는지는 M5-B가 실행의 검색 기록과 대조해서 확인할 일이다. 주석과 계획서와 본문을 그렇게 고쳤다. 다음 슬라이스가 무엇을 맡는지를 이번 슬라이스가 대신 약속하고 있었다.
확인한 것과 남은 것
확인한 것은 합성 입력뿐이다. 테스트가 서른 개 가까이 늘었고, 서른여섯 질의의 정답이 두 방식에서 전부 풀리며, 두 번 실행한 점수와 순위가 같고, 기준선 비교가 스키마 변경을 보고하면서 28건을 그대로 비교했다. 모델 호출은 없었다.
남은 것은 검색을 판정 경로에 넣는 M5-B다. 그 전에 내가 정할 것이 셋 있다. 위의 미스 셋을 보고 임베딩을 열지, 호스트와 지연이 미지수인 채로 도구 사용을 범위에 둘지, 코퍼스는 합성만 커밋할지. 이번 슬라이스는 그중 첫 번째 결정에 숫자를 하나 보탰다. 영어 코퍼스로 평가했으므로, 한국어 형태소 분석이 없는 한계는 이번 수치로 확인하지 못했다.
질의와 정답 전문은 저장소의 queries.ts에 있다. 실패하도록 쓴 질의에는 왜 실패할지를 주석으로 적어 두었다.