장르: 구축 기록 + 설계 논의 + 발견 기록. 세 번에 걸쳐 쓴 작업을 하나로 합쳤다. 관측 도구를 만들고, 그 도구의 측정값이 무의미했다는 것을 발견하고, 다시 세워서 이제 실험을 돌릴 수 있게 되기까지다. 실측 수치와 그 수치가 설계를 어떻게 바꿨는지를 함께 남긴다.
앞선 글에서 사내 규정 문서를 하이브리드 검색과 크로스인코더 리랭커로 검색하는 MCP 서버를 만들었다. 그 서버는 잘 동작했지만 품질을 사람이 확인할 창구가 없었다. 검색 도구를 호출하면 최종 결과 문자열만 돌아올 뿐, 그 결과가 어떤 청크에서 나왔는지, 리랭커가 순위를 어떻게 바꿨는지는 보이지 않았다.
RAG를 붙이고 나면 다음 질문은 늘 같다. "이게 지금 잘 되고 있는 게 맞나?"
1. 왜 관리자 콘솔인가 — RAG 품질은 단계의 곱
RAG의 품질은 각 단계 품질의 곱으로 결정된다. 파싱이 표를 깨뜨리면 그 뒤 청킹과 임베딩과 검색이 아무리 좋아도 답이 틀린다. 문제는 최종 답변만 봐서는 어느 단계가 무너졌는지 알 수 없다는 것이다.
그래서 관리 도구의 존재 이유는 하나로 요약된다. 단계별 관측 가능성(observability)이다.
| 단계 | 무엇이 잘못될 수 있나 | 콘솔에서 검토할 것 |
|---|---|---|
| 파싱 | 표 깨짐, OCR 오류, 헤더 노이즈 | 추출된 텍스트를 원본과 대조 |
| 청킹 | 문장·표 중간 절단, 제목-본문 분리 | 청크와 원문 매핑, 크기 분포 |
| 임베딩 | 도메인 용어 미반영, 모델 불일치 | 모델·차원·커버리지 |
| 검색 | 유사하지만 답이 없는 청크 | 질의별 top-k와 점수 |
| 리랭킹 | 1차 검색이 놓친 문서는 못 살림 | 리랭킹 전후 순위 변화 |
이 표가 이 글 전체의 뼈대다. 처음에는 각 단계를 보는 도구를 만들었고, 나중에는 각 단계를 바꿔가며 재는 도구가 됐다.
2. 설계 원칙 — 백엔드를 언제든 교체할 수 있어야 한다
시작하면서 하나를 못 박았다. 이 콘솔은 특정 RAG 서버에 묶이면 안 된다. 지금은 사내 규정 RAG 서버를 붙이지만, 나중에 임베딩 모델을 바꾸거나 벡터 저장소를 교체하거나 아예 다른 RAG 구현으로 갈아탈 수 있다. 그때마다 화면을 다시 짜는 것은 낭비다.
그래서 콘솔은 백엔드의 구체적 구현을 모르고 오직 표준 계약(contract)만 안다. 백엔드는 그 계약만 지키면 어떤 RAG 시스템이든 연결된다.
계약을 단일 진실 공급원으로
계약은 MCP 도구 한 묶음으로 정의했다. 백엔드가 이 도구들을 노출하면 콘솔이 인식한다.
| 도구 | 역할 |
|---|---|
admin_get_capabilities |
파이프라인 단계·모델·지원 기능을 알린다 (유일한 필수 도구) |
admin_list_documents · admin_get_document |
문서 목록과 상세, 추출 텍스트 |
admin_get_chunks |
청크 목록 (조항 라벨, 임베딩 유무 포함) |
admin_search_debug |
질의를 단계별로 실행하고 각 단계 후보와 점수를 반환 |
admin_upload_document · admin_reindex_document · admin_delete_document |
관리 |
이 중 admin_search_debug가 콘솔의 심장이다. 기존 검색 함수는 최종 결과만 돌려주고 중간 단계를
버렸다. 하이브리드 검색은 BM25 순위, 벡터 순위, RRF 융합 점수, 리랭커 로짓을 내부적으로 다
계산하고도 마지막에 버린다. 특히 리랭커가 청크의 점수를 RRF 값에서 로짓으로 덮어쓰기 때문에,
융합 단계의 점수를 따로 보존하지 않으면 전후 비교가 불가능하다.
그래서 기존 검색 로직은 그대로 두고(운영 동작을 바꾸면 안 되니까) 각 단계 중간 결과를 보존하는 디버그 버전을 새로 추가했다.
{
"query": "연차 휴가는 며칠까지 쓸 수 있나요?",
"embedding": { "model": "bge-m3", "dim": 1024, "duration_ms": 120 },
"stages": {
"bm25": [{ "chunk_ref","article_ref","rank","score","text_preview" }],
"vector": [{ "...", "score": "코사인 거리" }],
"fusion": [{ "...", "score": "RRF", "bm25_rank": 3, "vector_rank": 1 }],
"rerank": [{ "...", "score": "로짓", "prev_rank": 5, "gate": "pass" }],
"final": [{ "...", "filename", "text" }]
},
"flags": ["관련도가 낮습니다 ..."],
"timings_ms": { "total": 6786 }
}
gate는 리랭커 로짓이 임계값 아래로 떨어졌는지를 표시한다. 통과 / 저신뢰 / 제외 세 단계다.
2계층 디커플링
콘솔은 이 계약을 두 겹으로 감쌌다.
[화면 / 서비스] 계약에서 추론된 타입만 사용
|
[Provider 인터페이스] 프로토콜 독립
|
[MCP 구현체] streamable-http로 백엔드 호출
|
[임의의 RAG 백엔드] 연결 설정(DB)의 URL로 결정
계약 스키마는 한 파일에 Zod로 정의했고 도구 응답은 전부 이 스키마로 검증한다. 백엔드가 필드를 빠뜨리거나 형식을 어기면 화면이 아니라 이 경계에서 걸린다. 새 프로토콜(REST든 gRPC든)로 백엔드를 바꿔도 Provider 인터페이스만 구현하면 화면은 한 줄도 안 바뀐다.
기능 자동 감지
연결을 걸면 콘솔이 도구 목록과 capabilities를 조회해서 그 백엔드가 뭘 지원하는지 파악한다. 업로드 도구가 없으면 업로드 버튼이 사라지고, 리랭크 단계가 없으면 리랭크 탭이 빠진다. 계약을 아예 만족하지 않는 서버를 연결해도 크래시 없이 우아하게 접힌다.
판정 규칙에서 하나가 중요하다. 도구 목록이 선언보다 강한 신호다. capabilities에 upload: true가
있어도 실제 도구 목록에 없으면 비활성이다. 반대로 capabilities를 아예 안 주는 백엔드는 도구 존재만으로
판단한다. 계약상 필수는 capabilities 하나뿐이므로, 도구만 주는 백엔드를 막아버리면 안 된다.
3. 프론트엔드 — 에이전트가 스펙을 조회할 수 있는 디자인 시스템
콘솔은 Next.js 16(App Router)으로 세웠다. 스캐폴딩하는데 생성된 프로젝트가 대뜸 "This is NOT the Next.js you know"라는 안내 파일을 넣어 뒀다. 메이저 버전이 오르며 API와 규약이 훈련 데이터와 달라졌으니 코드를 쓰기 전에 프레임워크가 배포한 로컬 문서를 읽으라는 경고다.
실제로 걸렸다. 라우트 보호를 미들웨어로 짜려는데 최신 버전이 middleware 파일 규약을 proxy로
개명해 두었고, 이 proxy는 Node 런타임에서만 돈다(엣지 미지원). 처음엔 제약처럼 보였는데 오히려
인증 로직을 엣지-세이프하게 분리할 필요가 없어져서 코드가 단순해졌다. 세션 검증에 DB 클라이언트를
그대로 써도 되기 때문이다.
UI는 흔한 컴포넌트 킷 대신 "에이전트 친화(agent-ready)"를 내세운 디자인 시스템을 골랐다. 컴포넌트 문서를 CLI와 MCP로 노출해서, AI가 컴포넌트 props를 지어내는 대신 실제 스펙을 조회하게 한다.
효과가 컸다. 컴포넌트를 쓰기 전에 CLI로 실제 prop 목록을 확인하고 짜니 없는 prop을 지어내 헛도는 일이 사라졌다. 문서가 "이 세 질문은 문서 없이는 정답률 0%"라고 대놓고 적어둘 만큼 추측으로 짜면 틀리는 API가 많았는데, 조회해서 짜니 대부분 한 번에 통과했다.
이 시스템은 사실 Tailwind 기반이 아니라 StyleX(atomic CSS-in-JS) 기반이고, 모든 디자인 값을 CSS
변수(토큰)로 노출한다. 여기에 Tailwind 브리지를 얹으면 bg-surface 같은 유틸리티 클래스가 실제로는
그 토큰으로 해석된다. 유틸리티는 문법일 뿐이고 값의 진실은 토큰에 있다. 브리지의 유일한 함정은 CSS
레이어 순서였다. reset → theme → base → 컴포넌트 → utilities 순서를 명시적으로 선언해야 유틸리티가
컴포넌트 기본 스타일을 의도대로 덮어쓴다.
테스트 환경을 붙일 때는 의존성이 충돌했다. 디자인 시스템 CLI가 코드 변환 도구를 통해 구형 바벨을
끌어오는데 테스트 러너의 React 플러그인은 신형 바벨을 요구했다. --force로 뭉개는 대신 바벨을
아예 안 쓰는 SWC 기반 플러그인으로 갈아탔다. 충돌이 근본에서 사라졌고 빌드도 빨라졌다.
4. 화면 — 질의 중심과 문서 중심 두 축
관리자 콘솔이니 로그인이 필요하다. 역할은 두 단계다. admin은 전체 관리, viewer는 조회와 RAG
평가만 할 수 있다. 평가는 뷰어에게 허용된 유일한 쓰기 권한이다. 규정 담당 실무자가 검색 품질을
평가하되 계정이나 백엔드 설정은 못 건드리게 하려는 의도다.
검색 실험 콘솔이 핵심 UX다. 질의를 넣고 실행하면 BM25 → 벡터 → 융합(RRF) → 리랭크 → 최종을 탭으로 훑을 수 있다. 융합 탭은 각 후보가 BM25 몇 위, 벡터 몇 위였는지 출처를 보여준다. 리랭크 탭은 융합 대비 순위 변화를 ▲/▼로 표시하고 게이트 상태를 붙인다. 검색은 recall을, 리랭킹은 precision을 담당하므로 이 전후 비교가 리랭커가 실제로 도움이 됐는지를 보여준다.
"연차 휴가는 며칠까지 쓸 수 있나요?"를 넣으니 인사규정의 연차휴가 조항과 취업규칙의 연차유급휴가 조항이 상위로 올라왔고, 리랭커가 4위 후보를 5계단 끌어올리고(▲5) 저신뢰 후보 두 개에 경고를 붙였다. 이게 눈에 보이니 튜닝할 지점이 바로 잡힌다.
문서 상세는 문서 중심 흐름이다. 파싱 탭은 추출된 텍스트를 그대로 보여준다. 스캔 PDF 하나를 열어
보니 OCR 결과가 눈에 띄게 지저분했는데, 이게 바로 이 화면의 존재 이유다. 청킹 탭은 왼쪽에 청크
목록, 오른쪽에 선택한 청크의 전문을 띄운다. 규정 문서라 제13조[근로계약기간] 같은 조항 라벨이
청크마다 붙어 구조가 한눈에 보인다.
모든 실행은 스냅샷으로 저장된다. 과거 실행을 다시 열 수 있고, 이건 백엔드가 꺼져 있거나 교체된 뒤에도 동작한다. 저장된 응답을 그대로 보여주기 때문이다.
5. 계약이 실제로 지켜지는지 증명하기
가장 뿌듯했던 순간. 콘솔을 다 만들고 아직 관리 도구를 구현하지 않은 백엔드에 연결해 봤다. 콘솔은 도구 목록에서 기존 도구 세 개만 발견하고 "관리 계약을 만족하지 않는다"고 정확히 표시했다. 그 다음 백엔드에 관리 도구를 구현해 재배포하고 다시 연결하니, 똑같은 화면이 이번엔 업로드·재색인·삭제·단계 디버그 기능을 자동으로 켰다.
같은 UI가 백엔드의 능력에 따라 접혔다 펴진다. 교체 가능성이 말이 아니라 실제로 동작한다는 걸 눈으로 확인한 셈이다.
여기까지가 1차 구축이었다. 그리고 며칠 뒤 이 도구를 실제로 써 보다가, 도구가 재고 있던 것이 실은 아무것도 아니었다는 걸 알게 됐다.
6. 전환점 — 측정값이 붕 떠 있었다
"관리자 입장에서 문서·검색실험·평가를 검토하고 추가·수정하는 게 실제로 편한가?"를 물으며 콘솔을
계층별로 훑었다. 구조는 contract → provider → API 라우트 → 페이지 → 클라이언트 5계층인데,
아래 4개는 완성돼 있고 맨 위 UI에 호출자가 없는 형태가 반복됐다.
| 문제 | 상태 |
|---|---|
| 문서 삭제·재인덱싱 | 계약·프로바이더·API 전부 존재. 호출하는 화면이 0개 |
| 평가 상태 복원 | prop이 설계돼 있는데 넘기는 곳이 없음 |
| 페이지네이션 | 하드코딩, 초과분은 경고 없이 잘림 |
| 문서 검색 | 서버가 지원하는데 화면은 메모리 필터만 수행 |
평가 상태 미복원이 특히 나빴다. 문서를 다시 열면 모든 👍👎가 초기 상태로 보이니 평가자는 자기가 이미 본 청크인지 알 수 없다. 중복 평가와 누락이 동시에 발생한다.
이것들을 고치는 건 배선 작업이라 어렵지 않았다. 문제는 그 다음이었다.
발견 1: 같은 문서가 인덱스에 두 번 들어가 있었다
문서 목록을 커서 페이지네이션으로 바꾸려고 실제 데이터를 세어봤다. 인덱스가 28행인데 코퍼스 파일은 24개였다.
GROUP BY filename HAVING COUNT(*) > 1로는 중복이 안 잡혔다. 그런데 정렬해서 출력하면 같은
파일명이 두 번 나온다. 눈으로는 완전히 같아 보이는데 DB는 다른 값으로 취급하고 있었다.
한글 유니코드 정규화 문제였다.
"04. 인사규정" 두 형태가 각각 별도 행으로:
[NFD] len=21 bytes=41 ← 자모 분리 (ㅇ+ㅣ+ㄴ+ㅅ+ㅏ ...)
[NFC] len=15 bytes=23 ← 조합형 (인사)
macOS 파일시스템은 파일명을 NFD(자모 분리)로 저장한다. 코퍼스 볼륨은 NFC로 읽히는데 콘솔에서
업로드하면 브라우저의 File.name이 NFD로 온다. 문서 식별자가 파일명이라 두 형태가 서로 다른
문자열이 되어 기본키 충돌 없이 별도 행으로 들어갔다. 화면에는 똑같이 보인다. 그래서 몇 주 동안
아무도 눈치채지 못했다.
검색 품질에 직접 영향을 준다. 같은 문서의 청크가 두 배로 인덱스에 있으니 후보 슬롯을 갉아먹는다.
"연차 휴가는 며칠까지 쓸 수 있나요?" 정리 전 top-5:
1. <별표14> 징계효과 ← 질문과 무관
2. <별표14> 징계효과 ← 1위와 중복
3. 제14조(년차휴가)
4. 제14조(년차휴가) ← 3위와 중복
5. <별표14> 징계효과 ← 또 중복
top-8 전체가 인사규정 한 문서에서만 나왔고 절반이 중복이었다. 정리 후에는 취업규칙의
제48조[연차유급휴가]가 5위로 진입했다. 그 질문의 정답 조항이 중복에 밀려 아예 안 보이던
상태였다.
조치는 세 갈래다. 인제스트 시 문서 식별자를 NFC로 통일하고(파일명은 정규화하지 않는다. 디스크의 실제 바이트열이어야 재인덱싱 때 파일을 찾는다), 조회는 어느 형태로 와도 매칭하게 하고, 점검 도구로 기존 중복을 제거했다.
정리 도구를 만들면서 배운 것이 있다. 리포트에 정규화 형태와 바이트 수를 찍어야 한다. NFC와 NFD는 화면에 똑같이 렌더되므로 "남김"과 "삭제"의 식별자를 그냥 출력하면 운영자 눈에는 같은 값을 두 번 보여주는 것으로 보인다. 그게 이 버그가 오래 숨어 있던 이유이기도 하다.
함정이 하나 더 있었다. 인제스트에는 코드 경로가 두 개였고, 파일 단위 함수와 CLI 루프의 멱등 검사가 각각 식별자를 만든다. 전자만 고쳤다면 sha256 검사가 매번 빗나가 기동할 때마다 24개 문서를 전부 재추출·재임베딩할 뻔했다. 같은 규칙을 쓰는 곳을 전부 찾아야 한다.
발견 2: 점수 상승은 개선의 근거가 아니었다
중복을 제거하고 나서 "품질이 좋아졌다"고 쓸 뻔했다. 최고 점수가 -1.514에서 -1.283으로 올랐으니까.
그런데 크로스인코더 로짓은 (질의, 청크) 쌍에만 의존해야 한다. 같은 청크인데 점수가 왜 바뀌나?
컨테이너에서 직접 재봤다.
A. 완전 동일 입력 3회
-1.195807 / -1.195807 / -1.195807 무작위성 없음
B. 같은 청크, 배치 구성만 변경
단독 : -1.195807
+ 짧은 문서 동반 : -1.086863
+ 긴 문서 동반 : -1.293375 최대 0.21 차이
C. 같은 청크, 문서명 인코딩만 변경
NFC 제목: -1.195807
NFD 제목: -2.452637 1.26 차이
B의 원인은 토크나이저의 패딩과 int8 양자화 조합이다. 배치 내 최장 길이로 패딩되는데, 양자화 모델은 패딩 길이에 따라 수치가 미세하게 달라진다. 후보 집합이 바뀌면 배치 구성이 바뀌고 같은 청크의 점수가 움직인다.
C는 리랭커 입력에 문서명이 포함되기 때문이다. 자모 분리된 NFD 제목은 토크나이저가 완전히 다르게 쪼개 관련도를 크게 깎는다. 중복 사본들이 서로 다른 점수를 받고 있던 이유도 이것이다.
이게 왜 중요하냐면 파이프라인이 로짓을 임계값과 비교해 결과를 거르기 때문이다. 측정된 흔들림이 ±0.21인데, 최고 로짓이 경계 근처인 질의는 후보 구성에 따라 경고가 켜졌다 꺼졌다 하고, 하한 근처면 답변이 나왔다 안 나왔다 한다.
정리하면 이렇다. 중복 슬롯이 사라진 건 확실한 개선이다. 점수가 올라간 건 개선의 근거가 아니다. 품질을 재려면 다른 신호가 필요하다.
발견 3: 화면의 경고 문구는 콘솔이 만든 것이 아니었다
검색 실험 화면에 경고 배너가 떴다.
관련도가 낮습니다 — 아래 조항이 질문과 직접 관련이 없을 수 있습니다. 질문에 답할 근거가 실제로 담겨 있는지 확인하고, 없으면 규정에 없다고 답하세요.
계약상 flags는 자유 문자열 배열이라 콘솔은 의미를 해석할 수 없다. 그대로 띄우고 있었다. 게다가
문구를 보면 관리자용 진단이 아니라 답변 생성 LLM에게 주는 프롬프트 지시문이다. "…없으면 규정에
없다고 답하세요"는 관리자에게 할 말이 아니다.
왜 그 경고가 떴는지는 페이로드에서 직접 계산할 수 있었다. 질의는 "나의 결혼식 축하금은?"이었다.
| 축하금 질의 (경고 발생) | 연차 질의 (경고 없음) | |
|---|---|---|
| BM25 | 0건 | 13건 |
| 벡터 | 20건 | 20건 |
| 리랭크 게이트 | 통과 0 / 저신뢰 4 / 제외 8 | 통과 5 / 저신뢰 6 / 제외 1 |
BM25가 0건이었다. 문서는 전부 "경조금"으로 표기돼 있는데 질의어는 "축하금"이다. 어휘가 하나도 겹치지 않아 후보가 벡터 검색만으로 만들어졌고 리랭커가 12개 중 하나도 통과시키지 못했다. 동의어 미등록 문제다.
그런데 이 진단 신호가 화면에 전혀 안 보였다. 탭 필터를 "결과가 있는 단계만" 표시하도록 짰기
때문이다. 계약에서 각 단계는 Candidate[] | null이고 null(백엔드가 이 단계를 안 씀)과
[](실행했는데 0건)은 의미가 전혀 다른데, ?.length로 둘을 뭉갠 것이 내 실수였다.
0건이라는 사실 자체가 가장 중요한 진단인 경우가 많다. 키를 보고한 단계는 0건이어도 탭을
노출하도록 고치고 진단 패널을 붙였다. 백엔드 flags는 "백엔드가 보고한 경고"로 출처를 명시하고,
판단 근거는 페이로드에서 계산해 따로 보여준다.
7. 재설계 — 평가는 측정 시스템이지 학습 루프가 아니었다
여기서 근본적인 질문이 나왔다. "MCP가 바뀌어 모델이나 처리 방식이 달라지면 측정 정보가 붕 뜨는 것 아닌가?"
맞았다. 그리고 생각보다 심했다.
실행과 평가에 남는 것은 프로바이더 이름 하나였고, 그 값은 사용자가 지은 연결 이름이었다. 백엔드는 임베딩 모델·차원·청킹 전략·리랭커 모델을 전부 보고하는데 평가에도 실행 이력에도 하나도 저장하지 않았다. 대시보드의 "👍 78%"가 어느 구성의 수치인지 알 방법이 없었다.
게다가 평가의 결정적 키가 연결이름:stage:docId:chunkRef:runId:rank였다. 임베딩 모델을 바꿔도
연결 이름이 그대로면 키가 동일하다. upsert라 예전 평가가 조용히 덮어써진다. 교체 전후 비교가
원천적으로 불가능했다.
청크 식별자도 문제였다. chunk_ref가 SQLite rowid였는데, 문서를 재인덱싱하면 청크를 지우고 다시
넣으므로 rowid가 전부 새로 발급된다. 재인덱싱 버튼 한 번이면 그 문서의 청킹 평가가 화면에서 전부
사라진다. 방금 만든 "평가 상태 복원" 기능을 정면으로 깎아먹는 구조였다.
정리하면 "무엇을 보고 평가했는가"는 보존되는데 "어떤 파이프라인이었는가"는 보존되지 않았다. 감사 추적은 되지만 시계열 비교는 안 되는 상태였다.
파이프라인 지문
capabilities에서 검색 결과에 영향을 주는 필드만 추려 해시하고 레지스트리에 해시당 1행으로 저장한다. 모든 실행·평가·벤치마크가 이 해시를 참조한다.
두 가지 함정을 막아야 했다.
- 키 정렬 canonical JSON으로 해시한다.
JSON.stringify는 삽입 순서를 쓰므로, 백엔드가 같은 구성을 다른 순서로 보내기만 해도 "구성이 바뀌었다"고 오판한다. - 계약에 정의된 필드만 명시적으로 추린다. 스키마 검증기가 미지 키를 통과시키므로, 페이로드를 통째로 해시하면 백엔드가 검색과 무관한 필드 하나 추가하는 것만으로 과거 지표와의 연결이 끊긴다.
지문을 못 읽으면 "구성 미상"으로 진행한다. 지표 귀속은 부가 정보이지 전제가 아니다. 백엔드가 잠깐 죽었다고 검색이나 평가 저장이 막히면 안 된다.
안정 식별자
MCP 서버 쪽도 고쳤다. rowid 대신 내용에서 파생한다.
sha1(NFC(doc_id) + "\0" + NFC(article_ref) + "\0" + NFC(text))[:16]
스키마는 그대로 두고 응답을 만들 때 계산한다. 계약상 chunk_ref는 불투명 문자열이라 형식 변경은
호환된다. 실측으로 확인했다. 재인덱싱 후 rowid는 3278에서 3435로 바뀌었지만 chunk_ref는 동일했고,
옛 rowid를 가진 평가도 본문 해시로 복원됐다.
평가 키에도 지문을 넣어 구성이 바뀌면 같은 청크라도 별도 행이 되게 했다. 대상 식별자는 단계마다 다르게 잡았다. 청킹은 본문 해시(재인덱싱으로 참조가 갈려도 같은 본문이면 이어진다), 검색·리랭크는 청크 참조(실행 스냅샷은 불변이라 안전하고 순위별 구분이 필요하다), 파싱·임베딩은 문서 단위다.
골든셋 벤치마크
평가를 소비하는 메커니즘이 없다는 게 마지막 문제였다. 백엔드에는 평가 관련 코드가 한 줄도 없었다. 측정만 하고 반영 경로가 없었다.
온라인 부스팅(👍👎를 검색 순위에 직접 반영)도 후보였지만 이번엔 넣지 않았다. 소수 평가자의 편향이 전체 검색을 왜곡할 위험이 있다. 대신 회귀 테스트로 갔다.
👍 받은 (질의, 청크) 쌍을 기대값으로 삼아 현재 백엔드를 다시 돌린다. 케이스는 저장하지 않고 실행할 때마다 최신 평가에서 파생하므로 평가가 늘면 골든셋도 저절로 자란다.
리랭커 로짓은 어떤 지표에도 쓰지 않는다. 발견 2에서 실측한 대로 배치 구성에 따라 흔들리기 때문이다. 순위와 포함 여부만 본다.
측정 도구는 자기가 무엇을 못 재는지도 알려줘야 한다
여기까지 만들고 나서 구멍을 하나 발견했다. 적중 판정에 쓰는 청크 참조와 콘텐츠 해시가 둘 다 본문에서 파생한다. 청킹 전략이나 최대 길이를 바꾸면 청크 경계가 달라져 본문 자체가 달라지고, 기대 청크가 아예 존재하지 않게 된다. 파서를 교체해도 같다.
그러면 Recall이 0으로 떨어진다. 검색이 나빠져서가 아니라 잣대가 사라져서다. 하필 청킹과 파싱은 RAG 품질에 가장 큰 영향을 주는 단계인데, 그 변경을 판정할 수 없는 벤치마크였다.
문서 단위 지표를 따로 뒀다. 청크가 아니라 그 청크가 속한 문서 기준으로 세는 것이다. 청크 경계가 바뀌어도 문서는 그대로이므로 살아남는다.
기존 Recall에 합치지 않은 것이 중요하다. 합치면 청크는 틀렸는데 문서만 맞은 경우까지 주 지표에 들어가 값이 부풀려진다. 두 값을 나란히 놓아야 "청크 Recall은 0인데 문서 Recall은 그대로"라는 해석이 가능하다. 그게 곧 "청킹이 바뀐 것이지 검색이 나빠진 게 아니다"라는 뜻이다.
8. 골든셋이란 무엇인가
용어를 정확히 해두는 편이 낫겠다. 골든셋은 정답이 확정된 평가용 데이터셋이다. 정보검색에서 오래 쓰인 개념이고 ground truth, relevance judgments, qrels가 거의 같은 것을 가리킨다.
구성은 단순하다. 질의 하나와, 그 질의에 대해 사람이 "맞다"고 판정한 대상 집합의 쌍이다. 골든이 붙는 이유는 그 정답이 잣대 역할을 하기 때문이다.
| 조건 | 이유 |
|---|---|
| 정답이 고정된다 | 시스템을 바꿔도 잣대는 그대로여야 비교가 성립한다 |
| 판정이 시스템 밖에서 온다 | 사람이 정한다. 시스템 출력으로 시스템을 채점하면 순환 논증이다 |
| 재사용된다 | 매번 새로 만들면 시계열이 없다 |
보통 골든셋은 사람이 따로 만들어 파일이나 테이블로 관리한다. 이번 구현은 저장하지 않고 실행할 때마다 최신 평가에서 계산한다. 평가를 남기면 골든셋이 저절로 자라고 별도 관리 화면도 필요 없다.
대신 대가가 있다. 라벨 품질을 통제할 장치가 없다. 실제로 그렇게 됐다. 기능을 확인하려고 대충 눌러둔 판정이 그대로 골든셋이 되어 있었다. "연차 휴가는 며칠까지 쓸 수 있나요?"에 대해 무관한 징계효과 표에 👍, 정작 정답인 연차휴가 조문에 👎가 붙어 있었다. 그 상태의 침입률 50%는 정답이 다시 나오는 것을 벌점으로 세는 중이었다.
틀린 라벨은 라벨이 없는 것보다 나쁘다. 없으면 판단을 보류하지만, 틀린 라벨은 확신을 갖고 틀린 방향으로 민다.
벤치마크는 개선을 만들지 않는다
만들어 놓고 스스로 물었다. 이걸로 검색 품질을 개선할 방법이 실제로 생기나?
벤치마크는 무엇을 바꿀지 알려주지 않는다. 바꾼 것이 나아졌는지를 알려준다. 이 구분이 중요하다.
그런데 그게 개선의 전제다. RAG 튜닝 노브는 전부 "이렇게 하면 좋아질 것 같다"는 그럴듯한 이야기를 갖고 있다. 청크 크기를 줄이면, 후보를 늘리면, 임베딩 모델을 바꾸면. 전부 말이 된다. 어느 것이 실제로 먹혔는지는 재봐야 안다.
그리고 이번에 확인한 것이 정확히 그것이다. 중복 인덱스를 제거하고 나서 "최고 점수가 올랐다"를 개선 근거로 삼을 뻔했는데, 그 점수가 재현되지 않는다는 걸 실측했다. 눈에 보이는 가장 자연스러운 신호가 무효였다. 그러면 남는 판정 수단이 골든셋뿐이다.
이름이 회귀 테스트셋인 이유도 있다. 좋아졌는지를 재는 것이 절반, 안 나빠졌는지를 지키는 것이 나머지 절반이다. 이번 NFC/NFD 중복도 몇 주 동안 아무도 몰랐다. 골든셋이 있었다면 Recall 하락으로 진작 잡혔을 것이다.
9. 그래서 단계별로 바꿔가며 잴 수 있나
측정 체계를 세우고 나니 다음 질문이 자연스럽게 나왔다. 맨 앞의 표로 돌아가서, 각 단계에서 다른 수단으로 바꿔가며 테스트할 수 있나?
그때까지는 아니었다. 콘솔은 파이프라인을 관측할 수 있었지만 바꿀 수는 없었다. 알고리즘을 바꾸려면 사람이 백엔드 설정(환경변수)을 고치고 컨테이너를 재기동해야 했다.
비용에 맞춘 두 갈래
5단계를 살펴보니 비용이 두 부류로 갈렸다.
| 청킹 | 검색 · 리랭킹 | |
|---|---|---|
| 메커니즘 | 지속 프로필 | 질의 시점 오버라이드 |
| 저장 | 프로필당 별도 인덱스 파일 | 없음 (무상태) |
| 비용 | 생성 시 전체 문서 재청킹·재임베딩 | 없음 |
검색과 리랭킹은 인덱스를 건드리지 않으므로 드롭다운을 바꾸고 즉시 재검색할 수 있다. 청킹은 재색인이 필요하므로 "프로필"이라는 지속 개념이 필요하다. 이 차이를 무시하고 하나로 통일하면 가장 자주 할 실험이 가장 불편해진다. (파싱과 임베딩은 이번 범위에서 뺐다. 임베딩은 차원이 달라지면 벡터 테이블이 분리돼야 해서 조합 수가 곱셈으로 늘어난다.)
카탈로그를 백엔드가 신고한다
핵심은 콘솔에 알고리즘 이름을 하나도 넣지 않는 것이다.
백엔드가 신고 콘솔이 렌더/선택 양쪽이 합의한 정체성
───────────────── ────────────────── ────────────────────
capabilities 사용자가 드롭다운에서 유효 구성
.stage_options → 알고리즘 선택 → = capabilities + 오버라이드
(알고리즘 카탈로그) (하드코딩 0) → 지문 해시
백엔드가 "내가 지원하는 전략과 각 파라미터의 범위"를 신고하면 콘솔이 그것으로 폼을 만든다. 세 단계를 모두 "전략 + 파라미터" 한 가지 모양으로 통일했다. 검색의 방식과 융합 알고리즘을 따로 두면 "융합은 하이브리드일 때만 유효" 같은 조건부 의존이 생기고 콘솔이 그 규칙을 알아야 한다. 그건 곧 하드코딩이다. 전략 하나로 접으면 조건부가 사라지고 폼 렌더러 하나로 세 단계를 다 그린다.
파라미터 타입은 느슨한 문자열로 받는다. 열거형으로 좁히면 백엔드가 새 타입을 추가했을 때 capabilities 응답 전체가 거부되어 RAG 화면이 통째로 죽는다. 문자열로 두면 그 파라미터 하나만 렌더에서 빠진다.
프로필의 정체성은 이름이 아니라 구성이다
프로필을 지문에 넣을 때 프로필 ID를 그대로 쓰지 않고 그 프로필의 실제 청킹 구성으로 치환했다. 그래서 이름만 다르고 설정이 같은 두 프로필은 같은 해시를 받고, 프로필을 지웠다 같은 설정으로 다시 만들면 과거 지표와 이어진다.
그리고 프로필 색인은 재파싱하지 않는다. 이미 저장된 추출 텍스트를 읽는다. OCR을 다시 돌지 않아 빠를 뿐 아니라, 파싱 결과가 프로필 간에 동일함이 보장되어 청킹만이 유일한 변수가 된다. 파싱을 범위에서 뺀 결정이 여기서 실험 격리로 되돌아왔다.
프로필 인덱스는 별도 파일로 격리했다. 공유 테이블에 프로필 컬럼을 추가하는 방식은 FTS5 가상 테이블에서 막힌다. 외부 컬럼으로 필터링할 수 없어 프로필 A로 검색해도 B의 청크가 BM25 후보에 섞인다.
10. 만들면서 부딪힌 것들
계약 버전이 지문을 오염시키고 있었다
계약을 v1.1로 올리자 지문이 전부 바뀌었다. 계약 버전이 해시 대상에 들어 있었기 때문이다.
동일한 파이프라인 구성, 계약 버전만 다름
1.0 → 47ce779262de296e
1.1 → b34b05497fafcd2f ← 구성은 그대로인데 해시가 갈림
제거 후
버전 무관 → 022e6337de34cccf
계약 버전은 프로토콜 속성이지 파이프라인 구성이 아니다. 임베딩·청킹·검색·리랭커가 같으면 계약 버전과 무관하게 검색 결과도 같다. 이를 지문에 넣은 것은 원래 설계의 결함이었고, 프로토콜 변경을 구성 변경처럼 취급해 지표를 갈라놓았다.
이 결함이 무서운 건 증상이 전혀 없다는 점이다. 에러도 안 나고 화면도 정상이다. 백엔드를 배포하는 순간 그 이후 지표가 조용히 다른 그룹으로 쌓인다. 그리고 저장소 하나만 봐서는 절대 안 보인다. 백엔드는 계약대로 버전을 올렸을 뿐이고, 문제는 콘솔이 그 값을 구성으로 취급한 데 있었다.
제거하기로 했다. 과거 데이터는 이번 한 번 갈라지지만 이후 계약 버전을 올려도 지표가 다시 갈리지 않는다. 마이그레이션은 하지 않았다. 평가 키가 해시를 포함하고 유일 제약이 걸려 있어, 해시를 바꾸면 키를 다시 써야 하고 같은 구성이 두 계약 버전에 걸쳐 있었다면 하나로 합쳐지며 충돌한다.
콘솔은 계약 에러를 한 번도 받아본 적이 없었다
백엔드가 구조적 에러를 돌려주도록 만들고 실제 응답을 떠 봤다.
Error executing tool admin_delete_chunk_profile: {"code": "NOT_FOUND", "message": "프로필을 찾을 수 없습니다: prof_nope"}
JSON은 온전한데 앞에 접두사가 붙는다. MCP 프레임워크가 예외 메시지를 감쌀 때 붙이는 것이다. 콘솔은 본문 전체를 파싱하려다 실패하고 있었고, 그래서 계약에 정의된 404/501/400 구분이 코드에는 있었지만 한 번도 실행된 적이 없었다. 전부 500으로 뭉개져 왔다.
첫 중괄호부터 파싱하도록 고쳤다. 접두사 길이는 도구 이름에 따라 달라지므로 고정 오프셋으로 자르면 다른 도구에서 깨진다.
이 처리를 백엔드가 아니라 콘솔에 둔 이유가 있다. 접두사는 그 프레임워크의 동작이지 특정 구현의 버그가 아니다. 같은 프레임워크로 만든 백엔드는 전부 같은 형태를 내보내고, 이 계약의 전제가 "백엔드는 언제든 교체 가능하다"이다. 맨 JSON만 이해하는 클라이언트는 다음 백엔드에도 똑같이 취약하다.
진행률을 만들 수 없다는 것을 만들다가 알았다
프로필 화면에 "24문서 중 N문서" 진행 표시를 넣으려고 했다. 백엔드가 문서 수와 청크 수를 보고하니 가능해 보였다.
그런데 백엔드 코드를 열어보니 그 값을 채우는 함수가 색인 루프가 끝난 뒤에 한 번만 불린다. 진행 중에는 둘 다 0이다. 그대로 만들었다면 영원히 "0 / 24"를 보여주는 가짜 진행 표시가 됐을 것이다. 멈춘 것처럼 보여 사용자가 실패로 오판하는데, 그건 진행률을 넣으려던 이유 그 자체다.
정직한 수단으로 바꿨다. 불확정 표시와 경과 시간이다. 경과 시간은 생성 시각에서 계산할 수 있고 실제로 아는 정보다. 완료 후에는 값이 채워지므로 그때 문서·청크 수를 결과로 보인다.
필드가 계약에 있다는 것과 그 값이 언제 채워지는가는 다른 질문이었다.
무효한 지표는 흐리게 하는 것으로 부족하다
청킹 프로필로 벤치마크를 돌리면 청크 단위 Recall이 0에 가깝게 떨어진다. 앞서 설명한 대로 잣대가 사라진 것이지 검색이 나빠진 게 아니다.
처음에는 그 숫자를 흐리게 표시하려 했는데, 그것으로는 부족하다는 결론에 도달했다. 흐린 0.02와 선명한 0.87을 나란히 놓으면 사람은 비교한다. 흐림은 "덜 중요함"을 뜻하지 "비교 불가"를 뜻하지 않는다.
숫자 자리에 —를 넣고 행 옆에 한 줄로 이유를 적었다. 툴팁에 숨기지도 않았다. 그리고 전 실행 대비
증감 표시도 함께 감췄다. 남겨두면 "0.87 → 0.02, 85%p 하락"이라는 명백히 틀린 서사가 만들어진다.
이 화면의 목적이 "어느 알고리즘이 나은가"를 사람이 판단하는 것이라, 여기서의 오독은 다른 어떤 결함보다 비싸다. 코드가 틀리면 테스트가 잡지만 측정 도구가 사람을 오도하면 잘못된 결론이 나오고 그건 아무도 잡지 못한다.
조회 실패가 저장된 지표를 오염시킬 뻔했다
프로필 조회가 실패하면 청킹 오버라이드를 생략하도록 짜여 있었다. 검색 경로에서는 옳은 정책이다. 지표 귀속은 부가 정보이지 검색의 전제가 아니니까.
그런데 벤치마크에서는 다르다. 오버라이드가 생략되면 그 실행이 기본 구성 해시로 영구 저장되고, 벤치마크 표가 그 행을 "청킹을 안 바꾼 실행"으로 판단해 0에 가까운 Recall을 정상 숫자로 렌더한다. 바로 위에서 막으려던 오독이 저장된 데이터에 박히는 것이다.
검색은 결과가 화면에 잠깐 뜨고 사라지지만 벤치마크는 지표를 영구 저장한다는 차이를 아무도 저울질하지 않았다. 조회에 실패해도 생략하지 않고 안정적인 센티넬 값을 심어 해시를 갈라놓도록 고쳤다.
수정이 과교정될 수도 있다
프로필 색인 폴링에 30분 타임아웃을 넣었는데, 타임아웃 이후 도착한 응답이 "상태를 확인할 수 없습니다" 메시지를 지워버리는 경합이 있었다. 불리언 플래그로 막았고 그건 진짜 경합을 잡은 좋은 수정이었다.
다만 그 플래그가 한 번 켜지면 꺼지지 않았다. 프로필 생성과 삭제도 마지막에 목록을 다시 읽는데, 그 읽기가 영구히 무력화됐다. 결과적으로 타임아웃 이후에는 백엔드에 프로필이 실제로 만들어지는데 표에는 나타나지 않는 상태가 됐다. 작업은 됐는데 화면은 안 됐다고 말한다.
세대 카운터로 바꿨다. 타임아웃 시점에 떠 있던 응답만 무효화하고 이후 호출은 새 세대로 정상 동작한다. 가드의 역할은 "이번 폴링 세션의 뒤늦은 응답을 막는 것"이지 "훅을 죽이는 것"이 아니었다.
11. 남은 것
- 골든셋을 제대로 깐다. 지금 것은 기능 확인용 클릭이라 쓸 수 없다. 유형을 나눠(규정 조회, 수치 확인, 절차 문의, 존재하지 않는 규정) 20~30개를 설계하고 판정 기준을 문서로 정해야 한다. 이게 다른 모든 항목의 선행 조건이다. 잣대 없이 튜닝하면 이번처럼 잘못된 근거로 확신하게 된다.
- 실제 실험을 돌린다. 청킹 프로필을 두세 개 만들어 비교하는 것이 만든 것의 첫 용도다. 지금 코퍼스가 작아 신호가 약할 수 있어 골든셋 규모와 함께 봐야 한다.
- 색인 진척 증분 보고. 백엔드가 루프 안에서 상태를 갱신하면 진짜 진행률을 보일 수 있다.
- 프로필 최신성 신호. 기본 인덱스에 문서를 추가·삭제해도 프로필 인덱스는 갱신되지 않는다. 이미 없어진 코퍼스를 상대로 벤치마크를 돌릴 수 있다.
- 동의어 사전. "축하금 ↔ 경조금" 같은 사내 용어 매핑이 있으면 그 질의는 어휘 검색에서 바로 잡힌다.
- 온라인 피드백 루프. 반영 방식이 하드 부스트냐, 리랭커 파인튜닝이냐, 게이트 임계값 자동 튜닝이냐에 따라 난이도와 위험이 크게 다르다.
- 리랭커 로짓 안정화. 배치 크기 1이나 고정 길이 패딩으로 완화할 수 있다. 로짓을 학습 신호로 쓰려면 이게 선행되어야 한다.
12. 교훈
화면에 똑같이 보이는 것이 같은 값이라는 보장은 없다. NFC와 NFD는 눈으로 구별할 수 없고
GROUP BY로도 안 잡힌다. 한글을 다루는 시스템에서 문자열을 식별자로 쓴다면 정규화 지점을 먼저
정해야 한다.
정체성을 저장소 내부 값에 의존하지 않는다. rowid는 재인덱싱 한 번에 바뀐다. 내용에서 파생한 식별자는 저장소 교체와 재구축을 견딘다. 같은 원리로, 프로필의 정체성도 이름이 아니라 구성이어야 한다.
지표는 항상 무엇에 대한 것인지와 함께 저장한다. 구성이 기록되지 않은 "👍 78%"는 다음 번 모델 교체와 함께 해석 불가능한 숫자가 된다.
점수가 올랐다는 것만으로 좋아졌다고 말하지 않는다. 그 점수가 재현되는지부터 확인해야 한다. 이번엔 재현되지 않았다.
측정 도구는 자기가 무엇을 못 재는지도 알려줘야 한다. 청크 단위 Recall은 청킹 변경 앞에서 무력한데, 그 사실을 모르면 0%를 보고 검색이 망가졌다고 오판한다. 지표를 하나 더 두는 것보다 각 지표의 유효 범위를 화면에 적어두는 편이 실제로 더 도움이 됐다.
필드가 존재한다는 것과 쓸 수 있다는 것은 다르다. 진행률 필드는 계약에 있었지만 색인이 끝난 뒤에야 채워졌다. 컴포넌트도 마찬가지여서, 이름이 있을 것 같은 컴포넌트가 실제로는 없어서 계획을 고쳐야 했다. 쓰기 전에 실물을 확인하는 습관이 두 번 다 한 라운드를 절약했다.
가장 비싼 결함은 저장소 하나 안에서 보이지 않는다. 계약 버전이 지문을 오염시킨 문제는 백엔드 쪽에서 보면 "계약대로 버전을 올렸을 뿐"이고, 콘솔 쪽에서 보면 "해시 함수는 그대로"다. 두 저장소의 경계에서만 드러났고, 증상도 전혀 없었다. 경계를 가로질러 보는 검토 단계가 따로 필요하다.
자동으로 자라는 데이터셋은 품질 관리 절차가 함께 있어야 한다. 평가가 쌓이면 골든셋이 저절로 자라게 만든 것은 편했지만, 아무 클릭이나 잣대가 되는 구조이기도 했다. 편의와 신뢰도를 바꾼 셈인데 그 대가를 설계 시점에 적어두지 않았다.
관련 문서
- 사내 규정 문서 RAG — MCP 서버부터 크로스인코더 리랭커까지 — 이 콘솔이 관리하는 RAG 백엔드를 만든 기록
- 사내 규정 RAG, 한 줄씩 되묻다 — 하이브리드 검색과 리랭커의 내부 동작 — 콘솔이 단계별로 꺼내 보이는 검색 내부 동작