장르: 설계 논의 + 구축 기록. 어제 쓴 글의 2장을 오늘 지웠다. 그 원칙이 왜 비용이 됐는지, 무엇으로 바꿨는지, 옮기는 과정에서 실측으로 확인한 것들을 남긴다.

RAG 관리자 콘솔 만들기의 2장 제목은 "설계 원칙 — 백엔드를 언제든 교체할 수 있어야 한다"였다. 콘솔은 RAG 백엔드의 구현을 모르고 표준 계약(MCP admin_* 도구 12개)만 알도록 만들었고, 그 계약을 Zod로 한 곳에 못 박아 화면은 계약에서 파생된 타입만 쓰게 했다.

오늘 그 장을 통째로 지웠다. 파이프라인 전체가 콘솔 안으로 들어왔다.

1. 교체 가능성은 실현되지 않았고 비용만 남았다

계약을 유지한 이유는 명확했다. 임베딩 모델을 바꾸거나 벡터 저장소를 교체하거나 아예 다른 RAG 구현으로 갈아탈 때 화면을 다시 짜지 않으려는 것이었다.

문제는 그 유연성을 한 번도 쓰지 않았다는 것이다. 백엔드는 처음부터 끝까지 하나였다. 그동안 계약은 이런 마찰을 만들었다.

증상 구체적으로
한 변경이 세 곳을 건드린다 청킹 파라미터 하나를 추가하려면 파이썬 도구 + Zod 스키마 + provider 인터페이스를 함께 고쳐야 했다
계약에 없는 것은 다룰 수 없다 색인 진행률을 보여주려 해도 백엔드가 신고하지 않으면 화면이 만들 방법이 없었다
같은 개념이 두 번 산다 청킹 전략 카탈로그가 파이썬과 타입스크립트에 각각 있었다
저장소가 갈려 있었다 문서·청크는 백엔드의 SQLite, 평가·실행 이력은 콘솔의 PostgreSQL. 조인이 불가능했다

마지막 항목이 특히 아팠다. "이 청크에 몇 명이 👎를 남겼나"를 SQL 한 줄로 물을 수 없었다.

교체 가능성은 실현되지 않은 이익이었고 위의 마찰은 매일의 비용이었다. 유연성은 공짜가 아니고, 쓰지 않는 유연성은 그냥 비용이다.

2. 무엇을 어디로 옮겼나

[이전]
콘솔 ──MCP admin_* 12개──> RAG 서버 ──> SQLite (문서·청크·벡터)
  └──> PostgreSQL (평가·실행 이력)

[지금]
콘솔 (추출·청킹·임베딩·검색·리랭크 전부 인프로세스)
  └──> PostgreSQL + pgvector (전부)
         ▲ 읽기 전용
       RAG MCP 서버 (LLM용 검색 도구 3개)
         ▲ MCP
       Hermes Agent

RAG MCP 서버는 사라지지 않았다. 소비자용 검색 창구로 축소됐다. search_docs / list_docs / read_article 세 개만 남기고 관리 도구 12개, SQLite 저장소, 인제스트, 추출기, 청킹을 전부 걷어냈다. 코드로는 39개 파일에서 3187줄이 지워지고 860줄이 남았다.

동시에 Project라는 최상위 스코프를 넣었다. 문서·청크·프로필·실험·평가·벤치마크가 전부 projectId로 묶이고, DB 하나가 여러 프로젝트를 호스팅한다. MCP 도구는 프로젝트를 식별자로 지목한다.

3. 저장소를 하나로 — pgvector가 필요했다

콘솔이 이미 PostgreSQL을 쓰고 있었으니 벡터만 얹으면 됐다. 그런데 로컬 개발 DB가 PostgreSQL 14였고 pgvector는 15 이상 빌드만 배포된다.

그 인스턴스에는 다른 프로젝트 DB가 9개 올라가 있었다. 메이저 업그레이드는 이 작업의 범위를 훨씬 넘어선다. 그래서 pgvector/pgvector:pg17 컨테이너를 별도 포트로 띄우고 기존 DB를 통째로 옮겼다.

pg_dump -h localhost -p 5432 -F c -f dump.sql <db>
pg_restore -h localhost -p 5433 -d <db> --no-owner --no-privileges dump.sql

사용자 계정과 마이그레이션 이력이 그대로 살아났고, 앱 설정은 포트 한 자리만 바뀌었다. 기존 인스턴스는 건드리지 않았으므로 다른 프로젝트는 영향이 없다.

4. 스키마와 인덱스에서 내린 결정

기본 프로필을 컬럼이 아니라 실제 행으로

청킹 프로필이 여러 개일 때 "기본 인덱스"를 어떻게 표현할지 문제다. profileId를 nullable로 두고 NULL을 기본으로 해석하는 방법이 먼저 떠오르지만, 그러면 이 제약을 걸 수 없다.

@@unique([documentId, profileId, ordinal])

PostgreSQL은 NULL을 서로 다른 값으로 취급하므로 nullable 컬럼에는 유일 제약이 사실상 걸리지 않는다. 그래서 프로젝트를 만들 때 isDefault: true 프로필을 같은 트랜잭션에서 함께 만든다. 청크는 항상 어떤 프로필에 속하고, "기본이냐 프로필이냐" 분기가 코드 전체에서 사라졌다.

벡터 컬럼은 선언하되 쓰지는 않는다

ORM이 vector(1024) 타입을 모르므로 생성된 클라이언트에서 이 필드는 빠진다. 읽기·쓰기는 raw SQL로 한다. 그런데 스키마에서 선언 자체를 빼면 안 된다. 마이그레이션 도구가 DB에만 있고 스키마에 없는 컬럼을 "차이"로 보고 다음 마이그레이션에 DROP COLUMN을 넣는다.

벡터 인덱스(HNSW)는 두지 않기로 했다

같은 이유가 인덱스에도 적용되는데, 이쪽은 표현할 방법이 아예 없다. HNSW 인덱스는 매 마이그레이션마다 DROP INDEX가 생성된다. 한 번만 놓치면 조용히 사라진다.

없애기로 한 데는 성능 판단도 있었다.

  • 현 규모(프로젝트당 수백~수천 청크)에서 순차 스캔이 수 밀리초다
  • HNSW는 WHERE projectId = ... 필터와 함께 쓰면 post-filter로 동작한다. 근사 검색이 뽑은 상위 k개가 필터에서 다 걸러지면 결과가 비어버린다

청크가 10만 건을 넘어 벡터 단계가 느려지면 그때 iterative scan과 함께 넣기로 하고, 그 조건을 마이그레이션 주석에 남겼다.

한국어 키워드 검색은 pg_trgm으로

SQLite 시절 FTS5도 trigram 토크나이저를 썼으므로 pg_trgm이 의미상 가장 가까운 대체다. tsvectorsimple 설정은 공백 분리라 한국어 조사에서 깨진다("경조금을" ≠ "경조금").

임계값 조정에 함정이 하나 있었다. GIN 인덱스를 타는 것은 <% 연산자뿐인데 그 임계값은 세션 변수로만 조절된다. 커넥션 풀에서 SET을 쓰면 다른 쿼리에까지 남으므로 트랜잭션 안에서만 유효하게 걸어야 하는데, SET LOCAL은 파라미터 바인딩이 안 된다.

-- 안 됨: SET LOCAL pg_trgm.word_similarity_threshold = $1
SELECT set_config('pg_trgm.word_similarity_threshold', $1, true)

함수는 파라미터를 받는다. 세 번째 인자 trueSET LOCAL과 같은 의미다.

5. 파이프라인 이식 — 골든 테스트가 있어서 가능했다

파이썬으로 쓰인 추출·청킹을 타입스크립트로 옮겨야 했다. 청킹은 한국어 규정 문서에 특화된 로직이라(조문 제N조뿐 아니라 별표·별지·세칙·부칙도 섹션 경계로 잡고, 장문 섹션은 항 단위로 재분할한다) 눈으로 옮기고 "잘 되는 것 같다"로 끝낼 수 없는 종류였다.

다행히 원본에 골든 파일 테스트가 있었다. 픽스처 3개와 기대 출력 JSON 3개를 그대로 가져와 이식본에 물렸다.

청크 175개, 조문 라벨 174개, 고유 라벨 153개 — 완전 일치

전문·별표·부칙·항 재분할("(계속)" 접두)까지 모든 분기가 같은 출력을 냈다. HWP 추출기도 실제 문서 2건에서 바이트 단위로 일치했다. OLE 컨테이너를 열고 zlib raw 압축을 풀어 레코드를 직접 파싱하는 코드라 가장 걱정했는데, 86줄짜리 원본을 줄 단위로 옮기고 실물로 대조하니 끝났다.

옮기기 전에 등가를 증명할 방법부터 마련해야 한다. 골든 테스트가 없었다면 이 이식은 "아마 같을 것"으로 끝났을 것이다.

6. 표를 살리는 일

규정 문서에서 실제 수치(휴가 일수, 금액)는 대개 본문 문단이 아니라 별표의 표에 있다. 표를 버리는 라이브러리를 쓰면 정작 답이 있는 부분이 색인에서 사라진다.

docx는 문서 XML을 직접 걸어 문단과 표를 문서 순서대로 뽑았다. 그런데 원본과 대조하니 표에서 차이가 났다.

[원본]  결   혼 | 자    녀 | 1일 | 300,000원 | О
[이식]         | 자    녀 | 1일 | 300,000원 | О

세로 병합된 셀의 연장 칸이 비어 있었다. 이러면 그 금액이 무슨 사유인지 알 수 없는 줄이 된다. vMergegridSpan 속성을 읽어 병합 셀의 값을 반복해 채우도록 고쳤다.

스캔 PDF의 OCR 해상도

37쪽짜리 스캔 문서는 텍스트 레이어가 아예 없어 페이지를 이미지로 렌더링해 OCR해야 한다. 원본과 같은 300 DPI로 맞췄는데 조문 검출이 눈에 띄게 적었다.

렌더러를 바꿔 A/B를 돌렸다.

조건 목차 페이지 "제N조" 검출
300 DPI 17건
300 DPI + 스무딩 끄기 + 그레이스케일 16건
400 DPI 20건
참고: 외부 도구 300 DPI 19건

스무딩과 색상은 무관했고 해상도만이 유효한 변수였다. 본문 페이지에서도 일관되게 1건씩 더 잡았다. 조문 표지를 놓치면 그 조문이 앞 청크에 붙어 검색 단위가 통째로 거칠어지므로, OCR 시간이 1.8배 늘어나는 것을 감수하고 400 DPI로 올렸다. 인제스트는 문서당 한 번뿐이다.

7. 임베딩은 Ollama, 리랭커는 인프로세스 — 왜 다른가

둘 다 모델인데 실행 방식이 다르다.

임베딩 리랭커
실행 Ollama HTTP /api/embed 인프로세스 ONNX
모델 파일 Ollama가 소유 직접 관리 (약 280MB)
준비 ollama pull <model> 다운로드 스크립트

이유는 단순하다. Ollama에 리랭킹 API가 없다. 확인해 보면 /api/rerank는 404다. Ollama는 생성·임베딩 모델을 서빙하지, 질문과 문서를 함께 넣어 점수를 내는 크로스인코더는 취급하지 않는다.

그래서 리랭커 모델만 우리가 다뤄야 하는 파일이 되고, 280MB는 git에 넣을 크기가 아니다 (히스토리에 영구히 남아 이후 모든 clone이 그만큼을 받는다). 저장소에서 빼고 다운로드 스크립트를 뒀다. 받지 않아도 검색은 동작한다. 리랭킹 없이 융합 순위를 쓰고 화면에 "리랭커 사용 불가" 플래그가 뜬다.

컨테이너 쪽은 다르다. 이미지 빌드 시점에 모델을 구워 런타임 네트워크 의존이 없다.

8. 같은 검색이 두 언어에 있다

콘솔이 검색을 직접 하게 되면서 같은 하이브리드 검색이 타입스크립트와 파이썬 양쪽에 생겼다. 콘솔에서 튜닝한 결과와 챗봇의 답이 갈리면 콘솔이 무의미해진다.

두 장치로 묶었다.

첫째, 상수를 코드가 아니라 DB에 뒀다. 후보 수, RRF k, 게이트 임계값이 프로젝트 행에 있고 양쪽이 그 값을 읽는다. 한쪽 코드만 고쳐서는 동작이 갈리지 않는다.

둘째, parity 테스트를 붙였다. 콘솔이 기준선을 내보내고 MCP 서버 쪽 테스트가 같은 질의를 돌려 비교한다. 다만 무엇을 어느 강도로 비교할지가 문제였다.

9. 토큰 배열은 같은데 점수가 다르다

처음에는 최종 순위 정확 일치를 요구했다. 여섯 질의 중 다섯이 실패했다.

질의 '연차휴가는 며칠인가요'
  기대 [62308a9f, 714d9969, e765dcf9, ...]
  실제 [714d9969, 62308a9f, e765dcf9, ...]   ← 1·2위가 뒤바뀜

인접 순위가 뒤집히는 패턴이라 처음엔 양자화 노이즈로 봤다. 로짓을 직접 비교하니 최대 0.135 차이가 났는데, 그건 노이즈로 설명하기엔 컸다.

토큰화부터 의심하고 같은 쌍의 토큰 배열을 양쪽에서 뽑아 비교했다.

[파이썬]  ... 174353, 688, 62697, 10993, 2      ← 종료 토큰
[TS]      ... 174353, 688, 62697, 10993, 993    ← 본문 토큰

진짜 버그였다. 자바스크립트 토크나이저는 특수 토큰을 붙인 최대 길이로 자른다. 그래서 긴 청크에서 마지막 종료 토큰이 잘려 나가고 본문 토큰으로 끝난다. 파이썬 쪽은 특수 토큰 자리를 미리 빼고 자르므로 항상 종료 토큰으로 끝난다. 모델은 종료 토큰이 있는 형태로 학습돼 있어서, 이 한 자리가 점수를 0.135까지 흔들었다.

잘린 자리를 종료 토큰으로 되돌리는 처리를 넣고 다시 쟀다. 토큰 배열이 완전히 같아졌는데도 로짓은 여전히 0.08~0.14 벌어졌다.

여기서 멈췄다. 원인은 같은 int8 양자화 모델을 서로 다른 ONNX 런타임에서 돌리는 것이고, 양자화 커널의 반올림이 구현마다 다르다. 런타임을 통일하지 않는 한 줄일 수 없다.

그래서 테스트가 요구하는 것을 현실에 맞췄다.

대상 강도 이유
융합 단계까지 정확 일치 SQL과 산술뿐이라 결정적이다. 어긋나면 후보 수나 RRF k가 갈린 것
게이트 판정(결과가 비었는가) 정확 일치 임계값이 DB에서 오므로 양쪽이 같은 값을 봐야 한다
최종 순위 기대 1위 생존 + 겹침 60% 부동소수 완전 일치는 달성 가능한 목표가 아니다

달성할 수 없는 것을 요구하는 테스트는 결국 꺼진다. 결정적인 구간은 정확히 잠그고, 근본적 비결정성이 있는 구간은 드리프트를 잡을 만큼만 조인다. 그 경계를 실측으로 그은 것이 이 작업의 소득이다.

10. 인제스트를 요청에서 떼어내기

스캔 PDF 한 건의 OCR이 십수 분 걸린다. 요청 안에서 끝낼 수 없다.

업로드 → 원본 저장 → 202 즉시 응답 → 큐가 처리 → 화면이 5초 폴링
         uploaded → extracting → chunking → embedding → ready | error

별도 워커 프로세스를 두지 않았다. 두면 운영 대상이 하나 늘고 같은 DB에 붙는 두 번째 배포 단위가 생긴다. 개발·도커 환경의 Node는 오래 사는 프로세스이므로 인프로세스 FIFO 큐 하나로 충분하다. 대신 서버리스에서는 처리를 보장하지 않는다는 것을 문서에 명시했다.

큐는 메모리에만 있어 재시작하면 사라지지만 DB의 상태는 "처리 중"으로 남는다. 목록을 열 때마다 15분 이상 방치된 비종결 문서를 실패로 마감한다.

여기서 어제 글의 UI 원칙 하나가 뒤집혔다. 그때는 "백엔드가 색인 진척을 보고하지 않으므로 진행률을 만들 수 없고, 억지로 만들면 영원히 0 / 24인 가짜 표시가 된다"고 썼다. 엔진이 콘솔 안으로 들어오면서 그 제약이 사라졌다. 이제 단계 전이와 "OCR 12/37쪽"을 실제로 안다.

계약이 없앤 것은 유연성이 아니라 정보였다.

11. 빌드하지 않았으면 몰랐을 것

파이썬 쪽 코드는 로컬 가상환경으로 실제 DB에 붙여 검증했고 parity 테스트도 통과했다. 그런데 컨테이너 이미지를 다시 굽자 서버가 첫 줄 import에서 죽었다.

ModuleNotFoundError: No module named 'mcp.server.fastmcp'

의존성에 상한이 없어(>=1.9) 최신 2.0이 설치됐는데, 2.0에서 그 모듈이 사라졌다. 로컬 가상환경은 1.x라 모든 테스트가 통과했다. 이미지를 다시 굽지 않았으면 배포에서야 발견했을 문제다. 상한을 걸고 이관은 별도 작업으로 남겼다.

같은 성격의 것들이 몇 개 더 있었다.

서버 전용 모듈이 브라우저 번들을 오염시켰다. 업로드 다이얼로그가 지원 확장자 목록 하나를 쓰려고 추출기 모듈을 import했더니, 그 안의 zip·OLE·PDF 라이브러리가 통째로 클라이언트 번들로 끌려 들어가 빌드가 죽었다. 순수 값은 무거운 import가 없는 별도 모듈로 분리하고, 서버 전용 모듈에는 server-only를 붙여 실수가 조용히 통과하지 않게 했다.

빌드 산출물이 프로젝트 전체를 삼킬 뻔했다. 번들러의 파일 추적기가 process.cwd()가 섞인 경로를 보면 "무엇이든 읽을 수 있다"고 판단해 프로젝트 전부를 배포 산출물에 넣는다. 빌드는 성공하고 경고만 뜨므로 놓치기 쉽다. 해당 경로에 무시 주석을 달아 해결했다.

스키마를 바꿨는데 클라이언트가 갱신되지 않았다. 번들러 캐시가 이전 클라이언트를 붙들고 있어서, 새로 추가한 모델이 런타임에 undefined였다. 캐시 디렉터리를 지우면 끝나는 문제인데 증상이 "코드는 맞는데 동작하지 않는다"라 원인에 도달하기까지 시간이 걸렸다.

부수적으로 이미지가 크게 가벼워졌다. 문서 추출 도구(PDF 렌더러, OCR 엔진, 오피스 변환기)가 전부 콘솔로 옮겨가면서 컨테이너에서 빠졌다.

12. 확인

실제 문서 3건(docx / doc / hwp)을 화면으로 올려 162개 청크가 전부 임베딩까지 완료되는 것을 확인했다. 검색은 이렇게 나온다.

질의 벡터 최근접 거리 결과
경조금은 얼마인가요 0.2857 별표(금액 표) 1·2위
연차휴가는 며칠인가요 0.3354 해당 조문 1위
점심 메뉴 추천해줘 0.6053 거리 게이트 발동, 결과 없음

게이트 임계값 0.50은 이전 구조에서 실측으로 정한 값인데, 저장소를 SQLite에서 pgvector로 옮긴 뒤에도 그대로 유효했다. 관련 질의와 무관 질의의 거리 분포가 저장소 구현과 무관하다는 뜻이라 예상한 결과지만, 확인하고 넘어갈 가치는 있었다.

리랭커는 융합 3·4위였던 별표를 최종 1·2위로 끌어올렸다. 평가를 남기고 벤치마크를 돌리는 경로까지 이어지는 것도 확인했다. 👍와 👎를 오가며 눌러도 행이 늘지 않는다(같은 대상에 대한 평가는 한 행을 갱신한다).

13. 남은 것

  • 코퍼스 24건 중 3건만 올렸다. 나머지는 실제 운영 시 업로드해야 하고, 스캔 PDF는 문서당 20분쯤 걸린다
  • 구 URL에 리다이렉트가 없어 예전 북마크는 404가 된다
  • MCP SDK 2.x 이관. 지금은 상한으로 막아 둔 상태다

14. 교훈

쓰지 않는 유연성은 비용이다. 교체 가능한 백엔드라는 설계는 그 자체로 틀리지 않았지만, 교체가 실제로 일어나지 않는 동안 계약은 매일 마찰을 만들었다. 유연성의 가치는 그것을 쓸 확률에 비례한다.

추상화는 정보도 함께 가린다. 계약이 없앤 것은 결합만이 아니었다. 백엔드가 신고하지 않는 정보는 화면이 만들 방법이 없었고, 그래서 "진행률은 만들 수 없다"가 설계 원칙이 됐다. 엔진이 안으로 들어오자 그 원칙이 필요 없어졌다.

옮기기 전에 등가를 증명할 방법부터 마련한다. 골든 테스트가 없었다면 이식은 "아마 같을 것"으로 끝났을 것이고, 검색 품질이 미묘하게 나빠진 것을 몇 주 뒤에나 알았을 것이다.

테스트가 요구하는 것은 달성 가능해야 한다. 부동소수 완전 일치를 요구하는 parity 테스트는 결국 꺼졌을 것이다. 결정적인 구간과 그렇지 않은 구간의 경계를 실측으로 긋고, 각각에 맞는 강도를 준 것이 이 작업에서 가장 오래 갈 결정이다.

로컬에서 도는 것과 배포되는 것은 다르다. 파이썬 코드는 전부 통과했지만 컨테이너는 첫 줄에서 죽었다. 의존성 버전이 달랐기 때문이고, 그 차이는 이미지를 실제로 굽기 전까지 보이지 않았다.

관련 문서