장르: 구축 기록 + 설계 논의. 재현 절차와 그 절차에 도달하기까지의 판단을 함께 담았습니다. 같은 날 있었던 봇 전체 무응답 장애는 별도 글(장애 기록)을 참고하세요. 이 글의 8절에서 다시 만납니다.
- 대상: M1 Mac(개발 환경), 컨테이너
hermes+ 신규 컨테이너rag-mcp, 프로필dev- 결과: Mattermost에서
@dev-bot을 멘션하면 사규 조항을 인용해 답변- 비고: 사내 규정의 실제 수치는 가공했습니다. 표의 구조와 검색 동작은 실제 그대로입니다.
1. 요약
| 항목 | 내용 |
|---|---|
| 목표 | 사내 문서를 RAG로 인덱싱 → MCP 서버로 노출 → Mattermost dev 봇이 문서 기반으로 답변 |
| 코퍼스 | 인사·총무 규정 24종 (docx 13, 레거시 doc 8, hwp 2, 스캔 PDF 1) → 775 청크 |
| 임베딩 | 호스트 Ollama bge-m3 (1024차원, 무료·로컬) |
| 인덱스 | SQLite 한 파일 (FTS5 trigram + sqlite-vec) |
| 검색 | 벡터 + BM25 → RRF 병합 → 크로스인코더 리랭킹 |
| 전송 | MCP streamable-http (:8765/mcp), stdio 아님 |
| 응답 시간 | 질의당 1.6~2.4초 |
| 가장 큰 발견 | 한국 규정 문서는 실제 수치가 조문이 아니라 별표에 있다 |
| 가장 큰 사고 | Docker VM 메모리를 8GB로 올렸다가 호스트 스왑이 고갈되어 데몬이 멎음 |
기존 문서 RAG 파이프라인 계획의 축소 MVP입니다. 부서별 격리, Google Drive 연동, ERP 실시간 조회는 아직 없고 단일 코퍼스와 봇 하나로 시작했습니다.
2. 코퍼스 실측 — 포맷이 문제였지 규모가 아니었다
인덱싱 대상을 먼저 훑었습니다.
| 확장자 | 개수 | 성격 |
|---|---|---|
.docx |
13 | 최신 Word |
.doc |
8 | 레거시 Word (OLE, 코드페이지 949) |
.hwp |
2 | 한글 5.x |
.pdf |
1 | 42.6MB, 37쪽, 텍스트 레이어 없음 (스캔본) |
문서 수는 24개로 작지만 합계 43MB 중 42.6MB가 스캔 PDF 하나입니다. 즉 작업량은 문서 개수가 아니라 포맷 다양성과 OCR에 있었습니다.
여기서 방향이 정해졌습니다. 벡터 DB 서버를 따로 띄울 이유가 없고(청크 수백 개), 대신 파서 4종과 OCR을 제대로 만들어야 했습니다.
3. 임베딩을 로컬 무료로 바꾼 이유
원래 계획은 API 임베딩이었습니다. Docker VM이 좁아 로컬 모델을 못 돌린다는 판단이었죠. 그런데 확인해보니 Hermes가 이미 호스트 Ollama를 쓰고 있었습니다.
model:
base_url: http://host.docker.internal:11434/v1
Ollama가 이미 호스트에 떠 있으니 임베딩 모델 하나만 더 받으면 됩니다. 컨테이너 안에서 모델을 돌리는 게 아니라 호스트에 요청하는 구조라 VM 메모리와 무관합니다.
ollama pull bge-m3
curl -s http://localhost:11434/api/embed -d '{"model":"bge-m3","input":"연차휴가"}' \
| python3 -c "import sys,json;print(len(json.load(sys.stdin)['embeddings'][0]))"
# 1024
bge-m3는 다국어 임베딩 모델이라 한국어 성능이 좋습니다. API 키도 비용도 없앴습니다.
계획 문서의 미확정 항목 중 "임베딩 API 모델(다국어 권장)"이 이렇게 정리됐습니다.
4. 파서 4종 — .hwp는 pyhwp 없이 풀렸다
| 포맷 | 방법 | 비고 |
|---|---|---|
.docx |
python-docx | 문단 + 표 셀을 파이프로 이어붙임 |
.doc |
LibreOffice headless로 docx 변환 후 위와 동일 | antiword는 cp949 한글이 불안정 |
.hwp |
olefile로 BodyText 스트림 직접 파싱 | 아래 참고 |
| 스캔 PDF | pdftotext 시도 → 실패 시 Tesseract kor+eng |
sha256 기준 캐시 |
.hwp가 예상 밖이었습니다. 처음엔 pyhwp 의존성을 넣고, 실패하면 미리보기 스트림(PrvText)으로
떨어지는 폴백까지 준비했습니다. 그런데 HWP 5.x는 OLE 복합문서라 구조가 공개되어 있습니다.
BodyText/Section* 스트림을 zlib raw로 풀고 HWPTAG_PARA_TEXT(태그 67) 레코드만 골라
UTF-16 유닛을 디코드하면 본문이 그대로 나옵니다. 확장 컨트롤 문자(8유닛 차지)만 건너뛰면 됩니다.
결과 품질이 좋아서 준비했던 폴백은 한 번도 쓰이지 않았습니다.
02. 취업규칙비교표.hwp hwp-bodytext 8158자 28청크
17. 노사협의회규정.hwp hwp-bodytext 4633자 33청크
olefile 하나만 의존성으로 남기고 pyhwp는 뺐습니다.
5. OCR — 본문은 잘 읽혔고, 목차가 문제였다
스캔 PDF 37쪽을 300DPI 그레이스케일로 렌더해 Tesseract 한국어 모델에 넣었습니다. 본문 조문은 충분히 읽힙니다.
제 1 조 [목적]
본 규칙은 근로기준법 및 노동관계법에 따라 (주)... 에서 근무하
는 직원의 근로조건 및 취업에 관한 사항을 정함으로서 안정된 직장의 보장과 합리적인 회사
의 운영을 도모함을 목적으로 한다.
일부 단어는 깨집니다(꾸밈 글꼴이 들어간 구간에서 "사회적 신분을"이 다른 글자로 인식되는 식). 검색에는 큰 지장이 없어 그대로 두었습니다.
진짜 문제는 목차였습니다. 점선 리더(……)가 이렇게 인식됩니다.
제 5 조 [성실 의무] cesseseeessesseeseeseeeneeneesneeneennennennesssssnssnssss
제 8 조 [채용 발령] eeeeesscsccsssesseeenseeetnneeesnnseeennnerensseeensseenssnesusnn
이 줄들이 조문 정규식에 걸려 본문 없는 유령 조문 청크 47개를 만들었습니다. 한국어 규정 문서에 12자 이상 연속 라틴 문자열은 정상적으로 나타나지 않으므로, 그 패턴과 같은 글자 5회 이상 반복을 잡아 제거했습니다. 이어서 제목만 있고 본문이 25자 미만인 청크를 버리니 목차가 깨끗이 사라졌습니다.
한 가지 설계를 바꿨습니다. 처음엔 잡음을 제거한 뒤 캐시에 저장했는데, 그러면 필터를 조정할 때마다 37쪽을 다시 OCR해야 합니다. 비싼 OCR 원본만 캐시하고 값싼 후처리는 읽을 때마다 적용하도록 뒤집었습니다. 이후 필터를 두 번 더 손봤는데 재OCR은 없었습니다.
6. 핵심 발견 — 한국 규정 문서는 조문 단위 청킹만으로 부족하다
여기가 이 글에서 가장 중요한 절입니다.
인덱싱을 마치고 처음 봇에게 물었습니다. "경조휴가는 며칠까지 쓸 수 있어?"
제4조에 "지급기준은 <별표>와 같다"고 돼 있고, 검색 결과에는 별표의 상세 일수표는 노출되지 않고 있습니다. … 구체 수치는 확인 불가입니다.
할루시네이션 없이 한계를 정확히 말한 좋은 답입니다. 하지만 원하던 답은 아니었습니다.
인덱스를 열어보니 별표는 있었습니다. 다만 제8조(지급방법 및 순위) 청크에 딸려
들어가 있었습니다. 조문 정규식(제N조)만으로 잘랐기 때문입니다.
한국 규정 문서의 구조적 특성이 여기 있습니다. 조문은 "기준은 별표와 같다"고만 하고, 실제 숫자(휴가 일수, 금액, 등급)는 전부 별표에 있습니다. 조문만 잘 잘라봐야 정작 사람이 궁금해하는 값은 검색되지 않습니다.
세 가지를 고쳤습니다.
첫째, 별표·별지·세칙·부칙을 조문과 동등한 섹션 경계로 인식합니다.
단 <별표>가 문장 중간에 나오는 것은 참조일 뿐이므로 줄머리 표지만 경계로 봅니다.
("지급기준은 <별표>와 같다"에서 자르면 안 됩니다.)
둘째, 섹션 라벨을 색인 텍스트에 포함합니다.
별표 본문은 숫자와 이름뿐이라, [별표1] 경조휴가 및 경조금 지급기준이라는 라벨이
색인에 없으면 주제어로 찾을 수 없습니다. 라벨을 별도 컬럼에만 두었더니 임베딩에도
FTS에도 들어가지 않고 있었습니다.
셋째, 고정 길이 분할은 줄 경계를 우선합니다. 800자에서 기계적으로 자르면 표의 행이 중간에서 끊어져 그 행을 읽을 수 없게 됩니다.
적용 전후 청킹 결과입니다.
[적용 전] 제8조(지급방법 및 순위) len=800
제8조 (지급방법 및 순위) / 이 지침에 의한 경조금의 지급은 ... /
부 칙 / [ 별 표 1 ] / 경조휴가 및 경조금 지급기준 / 구분 | 대상 | 기간 ...
[적용 후] 제8조(지급방법 및 순위) len=178
[별표1] 경조휴가 및 경조금 지급기준 len=612 ← 독립 청크
[별표2] 경조금 지급신청서 len=1105
같은 질문에 대한 답이 이렇게 바뀌었습니다.
규정에 따르면 최대 N일까지 쓸 수 있습니다. 본인 결혼 N일 / 자녀 결혼 N일 / 배우자 사망 N일 … 근거:
08. 경조금및경조휴가지급규정[별표1],01. 취업규칙제59조,04. 인사규정제17조
7. 하이브리드 검색과 관련도 게이트
7-1. 두 갈래를 RRF로 합친다
| 갈래 | 방법 | 강점 |
|---|---|---|
| 의미 | bge-m3 임베딩 코사인 (sqlite-vec) |
표현이 달라도 찾음 |
| 키워드 | FTS5 BM25, trigram 토크나이저 | 정확한 용어에 강함 |
FTS5 토크나이저를 기본 unicode61이 아니라 trigram으로 둔 이유가 있습니다.
한국어는 조사가 붙기 때문에("연차휴가는") 공백 분리 방식으로는 질의어("연차휴가")와
매칭되지 않습니다. trigram은 부분 문자열로 잡히지만 3자 이상만 가능해서, 2자 이하
질의는 벡터 검색에만 의존합니다.
두 결과는 RRF(Reciprocal Rank Fusion, k=60)로 합칩니다. 각 갈래에서의 순위만 보고
1/(60+순위)를 더하는 방식이라, 코사인 거리와 BM25 점수처럼 스케일이 전혀 다른 두
결과를 정규화 없이 섞을 수 있습니다.
두 갈래가 각자 일하는 게 확인됩니다. "해외 출장 갈 때 하루에 얼마 받나"는 문서에 그런 표현이 한 글자도 없는데 국외출장 일비 기준표를 찾아냈고(의미 검색), "경조휴가" 같은 정확한 용어는 키워드 검색이 상위로 끌어올립니다.
7-2. 임계값은 측정해서 정했다
RRF에는 함정이 있습니다. 순위 기반이라 아무리 엉뚱한 질문에도 무조건 상위 N개를 반환합니다. "축구 경기 결과"를 물어도 사규 조각이 나옵니다. 그래서 최근접 코사인 거리로 게이트를 뒀습니다.
임계값은 추측하지 않고 실제 질의 분포를 쟀습니다.
| 색인 방식 | 사규 관련 질의 | 무관한 질의 | 채택 |
|---|---|---|---|
| 본문만 색인 | 0.27 ~ 0.36 | 0.49 ~ 0.61 | 0.45 |
| 문서명·라벨 포함(최종) | 0.26 ~ 0.47 | 0.50 ~ 0.64 | 0.50 |
여기서 놓치기 쉬운 점이 있습니다. 색인 텍스트를 바꾸면 거리 분포가 바뀝니다. 문서명 접두사를 넣자 모든 청크가 서로 조금씩 더 비슷해져 경계가 좁아졌습니다(0.47 대 0.50). 기존 임계값 0.45를 그대로 뒀다면 "결혼 축의금 얼마"(0.473) 같은 정상 질의가 차단될 뻔했습니다. 색인 방식을 바꾸면 임계값을 다시 재야 합니다.
8. Hermes 연동과, 그 자리에서 만난 모델 404
8-1. MCP 설정은 프로필별 config.yaml에 있다
Hermes는 네이티브 MCP 클라이언트를 갖고 있습니다. 프로필별 config.yaml에 이렇게 넣습니다.
mcp_servers:
company_docs:
url: "http://host.docker.internal:8765/mcp"
timeout: 120
connect_timeout: 30
dev 게이트웨이만 재시작하면(default 프로필 프로세스는 건드리지 않습니다) 도구가 붙습니다.
MCP server 'company_docs' (HTTP): registered 7 tool(s):
mcp__company_docs__search_docs, mcp__company_docs__list_docs,
mcp__company_docs__read_article, mcp__company_docs__list_resources, ...
도구 이름은 mcp__<서버명>__<도구명>으로 언더스코어 2개입니다. 등록된 7개 중 4개는
Hermes가 MCP 서버마다 자동으로 붙이는 래퍼이고, 직접 구현한 것은 3개입니다.
한 가지 예상이 빗나갔습니다. 이전 글에서
이미지에 numpy가 없어 HRR이 죽었던 전례 때문에 python mcp 패키지도 없을 것으로 보고
설치 절차까지 준비했는데, 이미 들어 있었습니다.
docker exec -u hermes hermes /opt/hermes/.venv/bin/python -c "import mcp; print(mcp.__file__)"
# /opt/hermes/.venv/lib/python3.13/site-packages/mcp/__init__.py
하나가 없었다고 해서 비슷한 것도 없으리라 추정하면 불필요한 우회 설계를 하게 됩니다.
8-2. 그리고 모델이 죽어 있었다
연동을 마치고 봇을 호출했더니 이렇게 나왔습니다.
API call failed after 3 retries: HTTP 404: Couldn't find that, sorry.
같은 날 서버에서 겪은 무료 모델 소멸이 개발 환경에도 그대로 해당됐습니다. 장애 기록에서 서버 5개 프로필은 교체했지만, 개발 환경은 죽은 모델을 그대로 보고 있었습니다.
5개 프로필 전부 교체했습니다. 컨테이너 안에서 Hermes CLI를 실행할 때는 반드시 -u hermes를
붙입니다. root로 실행하면 auth.json 소유권이 바뀌어 게이트웨이가 못 읽는 2차 사고가 납니다.
for p in dev sales finance qna; do
docker exec -u hermes hermes /opt/hermes/.venv/bin/hermes -p $p \
config set model.default <새-모델>
done
부수적으로 발견한 것이 있습니다. /opt/data/context_length_cache.yaml이 root 소유였습니다.
최초 셋업 때 root로 만들어진 파일인데, world-readable이라 읽기는 되지만 hermes 사용자가
쓸 수 없어 새 모델의 context length를 기록하지 못합니다. 같은 권한 계열의 함정입니다.
그리고 config set은 주석을 지웁니다. mcp_servers 블록에 달아둔 설명 주석이 모델
교체 과정에서 사라졌습니다(설정 값 자체는 보존됩니다). 중요한 맥락은 config 주석이 아니라
문서에 남겨야 합니다.
9. 리랭커 — 붙이는 과정이 곧 실측의 연속이었다
RRF만으로는 "경조휴가 며칠"에서 정작 일수가 담긴 별표가 6위에 머물렀습니다. 크로스인코더를 붙이기로 했습니다.
임베딩은 질문과 문서를 각각 벡터로 만들어 비교하는 바이인코더입니다. 미리 계산할 수 있어 빠르지만 정밀도가 낮습니다. 리랭커는 질문과 문서를 함께 넣어 상호작용을 보므로 정확하지만 미리 계산할 수 없어, 1차 검색이 추린 후보에만 적용합니다.
9-1. Ollama에는 리랭킹 API가 없다
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:11434/api/rerank -d '{...}'
# 404
임베딩과 달리 리랭커는 Ollama에 맡길 수 없습니다. 리랭커는 시퀀스 분류 모델이라 Ollama가 지원하는 형태가 아닙니다. RAG 컨테이너 안에서 직접 돌려야 했고, torch를 넣지 않기 위해 ONNX Runtime을 골랐습니다. 모델은 이미지에 구워 넣어 런타임 네트워크 의존을 없앴습니다.
9-2. ARM64에서 fp32가 int8보다 5배 느리다
"양자화는 품질을 깎으니 fp32가 안전하다"는 직관으로 fp32(2.2GB)를 먼저 시험했습니다.
| 정밀도 | 긴 청크 1건 | 4건 |
|---|---|---|
| fp32 | 5.68초 | 20.68초 |
| int8 | (아래 표 참고) | 6.86초 |
정반대였습니다. CPU 추론에서 int8은 속도와 크기 양쪽에서 유리합니다. fp32 실험을 위해 2.2GB를 받았다가 그대로 지웠습니다.
9-3. 모델 크기가 곧 지연이다
임베딩과 같은 계열이라 1순위였던 568M 모델(bge-reranker-v2-m3)을 측정했습니다.
4코어 VM 기준, 가장 긴 청크들로 잰 최악값입니다.
| 최대 토큰 | 후보 20건 | 후보 12건 | 후보 8건 |
|---|---|---|---|
| 512 | 25.37초 | 9.41초 | 8.65초 |
| 384 | 13.01초 | 12.32초 | 5.71초 |
| 256 | 7.12초 | 4.85초 | 2.09초 |
채팅 봇에 쓸 수 없는 수치입니다. 스레드를 늘려도 소용없었습니다.
| 스레드 | 1 | 2 | 4 | 8 |
|---|---|---|---|---|
| 4건 소요 | 11.50초 | 6.98초 | 6.86초 | 11.02초 |
2스레드에서 이미 포화되고 8스레드에서는 오히려 나빠집니다(코어가 4개뿐이라 경합).
278M 다국어 모델로 낮추고 최대 토큰을 320으로, 후보를 12개로 줄였습니다.
| 설정 | 후보 20건 | 후보 12건 |
|---|---|---|
| 278M / 512토큰 | 14.91초 | 7.58초 |
| 278M / 320토큰 | 6.31초 | 3.31초 |
토큰을 320으로 줄여도 되는 이유가 있습니다. 리랭킹은 관련도 판정만 하면 되고, 전체 본문은 그대로 LLM에 전달됩니다. 앞부분 320토큰이면 이 청크가 무엇에 관한 것인지 판단하기 충분합니다.
실제 질의(길이가 섞인 후보)로 재니 질의당 1.6~2.4초가 나왔습니다.
그리고 목표했던 효과가 확인됐습니다. "경조휴가 며칠"에서 별표가 6위에서 1위로 올라왔습니다.
9-4. 게이트 임계값 — 분포가 겹치면 단일 컷오프를 만들지 않는다
리랭커 점수는 절대적 관련도라 "근거 없음" 판정에도 쓸 수 있습니다. 임베딩과 독립이라 Ollama가 죽어도 작동한다는 장점도 있습니다. 그래서 임계값을 재봤습니다.
| 질의 종류 | top-1 점수 범위 |
|---|---|
| 근거가 실제로 있는 사규 질의 | -0.81 ~ +0.57 |
| 사규처럼 들리지만 문서에 근거가 없는 질의 | -1.32 ~ -0.70 |
두 번째 줄은 "재택근무 신청 절차", "주 4일제 근무 가능해?", "안식년 휴직 조건"처럼 그럴듯하지만 이 문서집합에 답이 없는 질문들입니다.
분포가 겹칩니다. "주 4일제 근무 가능해?"(-0.70)가 진짜 관련 질의 두 개(-0.81, -0.74)보다 높게 나왔습니다. 어떤 값을 잡아도 정상 질의를 자르거나 무근거를 허용합니다.
그래서 단일 컷오프를 포기했습니다.
| 구간 | 처리 |
|---|---|
| -2.0 미만 | 차단. "근거 문서 없음" 반환 |
| -2.0 ~ -0.9 | 결과를 주되 "관련도가 낮습니다" 경고를 붙임 |
| -0.9 이상 | 정상 |
애매한 구간의 판단을 실제 본문을 볼 수 있는 LLM에 넘긴 것입니다. 도구 설명에 "근거가 없으면 없다고 답하라"는 지시가 있으므로, 약한 결과가 통과해도 LLM 단에서 걸러집니다.
10. Docker VM 메모리 — 늘렸더니 오히려 시스템이 멎었다
이 절은 실패 기록입니다.
리랭커 모델을 로드하려니 컨테이너가 exit 137로 죽었습니다. OOM입니다.
Total Memory: 1.942GiB
VM 총량이 1.94GiB인데 Mattermost·Postgres·Hermes가 이미 1.16GB를 쓰고 있었습니다. 계획 글에서 "Docker VM 메모리 상향(최소 4GB, 권장 8GB)"이 미해결 항목으로 남아 있던 바로 그 문제입니다.
호스트는 16GB이고 여유가 46%로 보였습니다. 권장값대로 8GB로 올렸습니다.
그리고 시스템이 무너졌습니다.
vm.swapusage: total = 13312.00M used = 12535.44M free = 776.56M
호스트가 이미 스왑을 상시 12GB 쓰고 있었습니다. 8GB 할당이 남은 스왑을 고갈시켰고,
Docker 데몬이 docker start조차 타임아웃하고 Mattermost가 unhealthy로 떨어졌습니다.
docker system df 같은 단순 조회도 응답하지 않았습니다.
4GB로 되돌리니 정상화됐습니다. 컨테이너 실사용 합계가 약 2.4GB이므로 4GB면 충분합니다.
| VM 메모리 | 결과 |
|---|---|
| 2GB | 리랭커 로드 중 OOM (exit 137) |
| 8GB | 호스트 스왑 고갈, Docker 데몬 마비 |
| 4GB | 정상 |
컨테이너 상한도 조정했습니다. 900MB로 뒀더니 리랭커 상주와 ONNX 아레나 때문에 99%(891MB)까지 차올랐습니다. OOM은 나지 않았지만 여유가 없어 1500MB로 올렸고, 실사용 1.07GB에서 평탄해졌습니다.
교훈: 리소스는 "크게 잡으면 안전"이 아닙니다. 물리 메모리가 아니라 호스트의 실제 여유(이 경우 스왑 잔량)를 보고 정해야 하고, 올린 뒤에는 반드시 부하를 걸어 확인해야 합니다.
11. 조용한 실패 — 봇이 MCP 대신 파일을 뒤졌다
구축을 마치고 사용자가 Mattermost에서 봇을 멘션했는데, 사규 답변 대신 엉뚱한 파일 검색 결과가 왔습니다.
로그가 원인을 정확히 말해줍니다.
10:07:33 MCP server 'company_docs' failed initial connection after 3 attempts, parking...
10:07:33 MCP: registered 0 tool(s) from 0 server(s) (1 failed)
10:18:26 tool terminal completed ← 사용자의 멘션. 도구가 없으니 파일을 뒤짐
게이트웨이가 기동될 때 RAG 서버가 떠 있지 않으면 도구가 0개로 등록되고 그대로 굳습니다.
Hermes는 초기 연결을 약 4초 안에 3회만 시도하고 포기합니다. 도구가 없으면 에이전트는
terminal로 파일을 뒤지는 쪽으로 흘러가고, 에러 없이 그럴듯한 오답을 냅니다.
앞 절의 Docker 재시작 소동 중에 RAG 컨테이너가 Created 상태로 15분간 멈춰 있었고,
그 사이 게이트웨이가 먼저 올라온 것이 직접 원인이었습니다.
진단은 한 줄입니다.
docker exec hermes sh -c "grep 'MCP: registered' /opt/data/profiles/dev/logs/agent.log | tail -1"
# "registered 7 tool(s) from 1 server(s)" 여야 정상
실험으로 확인한 좋은 소식도 있습니다. 도구가 한 번 등록된 뒤라면 RAG 서버를 껐다 켜도 괜찮습니다. 전송이 stateless HTTP라 호출마다 새로 연결하기 때문입니다. 문제가 되는 건 오직 게이트웨이가 시작하는 순간입니다. RAG 서버는 컨테이너 start 후 3초면 응답하므로 평상시 재부팅에서는 경합이 잘 일어나지 않습니다.
11-1. 페르소나로 조용한 오답을 막았다
근본 대책은 도구가 없을 때 다른 수단으로 대체하지 않게 하는 것입니다. dev 프로필의
SOUL.md에 지침을 넣었습니다.
## 사내 규정 질문 지침
사규·인사·복리후생 질문에는 반드시 `mcp__company_docs__search_docs`를 먼저 호출하고,
그 결과만을 근거로 답하라.
- 답변에는 항상 문서명과 조항을 인용하라.
- 결과가 "근거 문서 없음"이거나 "관련도가 낮습니다" 경고가 붙으면 추측하지 말고
규정에서 근거를 찾지 못했다고 답하라.
- 도구가 목록에 없으면 terminal·파일 검색으로 대체하지 마라. 그 경우 문서 검색 도구를
사용할 수 없다고 분명히 답하라. 조용히 틀리는 것이 가장 나쁘다.
RAG 서버를 일부러 내리고 게이트웨이를 재시작해 도구 0개 상태를 재현한 뒤 검증했습니다.
| 이전 | 이후 | |
|---|---|---|
| 도구 0개 상태에서 사규 질문 | terminal로 파일 검색 → 그럴듯한 오답 |
도구를 하나도 호출하지 않고 "문서 검색 도구를 사용할 수 없어 규정 근거를 확인할 수 없습니다" |
덤으로, 정상 경로 테스트에서 봇이 별표1과 별표2가 특정 항목의 일수에서 서로 다르다는 것을 스스로 잡아내 "인사팀 확인이 필요하다"고 덧붙였습니다. 규정 문서 자체의 불일치를 RAG가 찾아낸 셈입니다.
12. 오가며 정리한 개념들
작업 중 나온 질문과 답을 남깁니다. 같은 걸 다시 헷갈릴 만한 지점들입니다.
12-1. 이 RAG는 하이브리드인가
그렇습니다. 검색 한 번에 의미 검색과 키워드 검색을 항상 병렬로 돌린 뒤 RRF로 합치고, 그 위에 크로스인코더 리랭킹을 얹었습니다.
순수 하이브리드가 아닌 지점이 두 곳 있습니다. 2자 이하 질의는 trigram이 매칭되지 않아 벡터 검색만 동작하고, Ollama가 죽으면 키워드 검색만으로 결과를 내되 그 사실을 결과에 표시합니다(도구를 에러로 실패시키지 않습니다).
12-2. HTTP streaming인가 stdio인가
streamable-http입니다. 서버가 8765 포트에 뜨고 Hermes가 URL로 붙습니다.
stdio를 쓰지 않은 이유는 RAG 서버가 별도 컨테이너이기 때문입니다. stdio는 Hermes가 자식 프로세스로 직접 띄우는 방식이라, RAG 코드와 모델과 파서(LibreOffice, Tesseract, ONNX 리랭커)가 전부 hermes 이미지 안에 들어가야 합니다.
| stdio | streamable-http (채택) | |
|---|---|---|
| 배치 | Hermes 이미지 안 | 독립 컨테이너 |
| 빌드·재시작 | Hermes와 함께 | 독립적 |
| 다른 프로필과 공유 | 프로필마다 프로세스 | 서버 하나를 공유 |
| 단점 | 이미지 비대 | 기동 순서 문제(11절) |
11절의 사고가 바로 이 선택의 대가입니다. 그래도 파서와 모델을 봇 이미지에 밀어 넣는 것보다는 낫다고 판단했습니다.
12-3. 임베딩 모델과 리랭커 모델이 달라도 되는가
상관없습니다. 두 모델은 벡터를 주고받지 않습니다.
임베딩은 문서를 벡터로 바꿔 저장하고 거리로 후보를 추립니다. 리랭커는 그 벡터를 아예 보지 않고 후보 청크의 원문 텍스트를 질문과 함께 다시 읽어 점수를 매깁니다. 두 단계가 주고받는 것은 "후보 목록"이라는 텍스트뿐이라, 차원이 달라도 계열이 달라도 무방합니다.
같은 계열로 맞추는 조합이 흔한 것은 성능 보장이라기보다 한 팀이 같은 데이터로 함께 학습시켜 검증해 뒀다는 편의 때문입니다. 이 구축에서는 계열이 다른 조합을 쓰고 있고 잘 동작합니다.
다만 정말로 일치해야 하는 짝이 하나 있습니다.
| 짝 | 일치 필요? | 이유 |
|---|---|---|
| 인덱싱 임베딩 ↔ 질의 임베딩 | 필수 | 같은 벡터 공간이어야 거리 비교가 성립 |
| 임베딩 ↔ 리랭커 | 불필요 | 리랭커는 벡터가 아니라 원문을 읽음 |
임베딩 모델을 바꾸면 전체 재임베딩이 필수입니다. 그래서 문서 테이블에 사용한 임베딩 모델명을 기록해 두고, 불일치하면 인제스트가 거부하도록 막았습니다.
13. 최종 구성
문서 코퍼스 (읽기전용 마운트)
└→ [rag-mcp 컨테이너]
추출(docx / doc / hwp / OCR) → 조문·별표 단위 청킹 → bge-m3 임베딩
└→ /data/index.db (SQLite: chunks + FTS5 trigram + sqlite-vec)
└→ 질의: 벡터 + BM25 → RRF → 크로스인코더 리랭킹 → 관련도 게이트
└→ FastMCP streamable-http :8765/mcp
↑ host.docker.internal:8765
[hermes 컨테이너] dev 프로필 게이트웨이 → mcp__company_docs__* 도구
↑ 멘션
Mattermost 개발 채널 @dev-bot
호스트 Ollama :11434 (bge-m3) ← 임베딩 (인덱싱 시 + 질의 시)
노출한 도구는 셋입니다.
| 도구 | 용도 |
|---|---|
search_docs(query, top_k=8) |
하이브리드 검색. 【문서명 › 조항】 + 본문 형식 |
list_docs() |
인덱싱된 문서 목록(청크 수·파서·상태) |
read_article(doc_id, article) |
특정 문서의 특정 조문 전문 |
운영 명령입니다.
docker compose run --rm rag-mcp python -m rag_mcp.ingest --dry-run # 추출·청킹만 검증
docker compose run --rm rag-mcp python -m rag_mcp.ingest # 인덱싱(sha256 기준 멱등)
docker compose run --rm rag-mcp python -m rag_mcp.ingest --force # 전체 재인덱싱
docker compose up -d
curl -s localhost:8765/healthz
# {"status":"ok","reranker":true,"docs":24,"errors":0,"chunks":775,"sqlite_vec":true}
청킹이나 색인 로직을 바꾸면 --force가 필요합니다. OCR 후처리만 조정할 때도 --force면
충분합니다(OCR 원본은 캐시되어 재OCR하지 않습니다).
14. 남은 일과 위험
- 스캔 PDF 품질이 상한입니다. 텍스트 레이어가 없어 OCR에 의존하므로 인용문에 깨진 글자가 섞일 수 있습니다. 텍스트 원본을 확보하는 것이 근본 해결책입니다.
- 인덱스 갱신이 수동입니다. 문서가 바뀌어도 자동 반영되지 않습니다.
- 단일 코퍼스·단일 봇입니다. 계획했던 부서별 격리, Drive 연동, 첨부파일 수집, ERP 실시간 조회는 아직입니다.
- 무료 모델 단일 의존이 여전합니다. 모델이 또 사라지면 RAG 서버가 정상이어도 봇이 답을 못 합니다.
- 컨테이너 2개 의존입니다. RAG 서버가 내려가 있으면 봇은 도구 호출에 실패합니다. 다만 11절의 페르소나 지침 덕분에 이제는 조용한 오답 대신 명시적 거절이 나옵니다.
15. 정리하며
이번 작업에서 반복적으로 확인한 것은 추측한 값과 측정한 값이 자주 다르다는 점입니다.
| 추측 | 실측 |
|---|---|
이미지에 mcp 패키지가 없을 것이다 |
이미 있었다 |
.hwp는 전용 라이브러리가 필요하다 |
olefile로 충분했다 |
| OCR이 최대 난관이다 | 청킹(별표)이 더 컸다 |
| fp32가 int8보다 안전하다 | ARM CPU에서 fp32가 5배 느렸다 |
| 메모리는 크게 잡으면 안전하다 | 8GB가 호스트를 마비시켰다 |
| 임계값 하나로 관련·무관을 가를 수 있다 | 분포가 겹쳐 불가능했다 |
특히 마지막 항목이 설계에 남긴 흔적이 큽니다. 깨끗한 경계가 없다는 사실을 인정하고 나니 "애매하면 LLM에 넘긴다"는 구조가 자연스럽게 나왔습니다. 억지로 컷오프를 정했다면 정상 질문을 조용히 차단하는 시스템이 됐을 것입니다.
관련 글
- Hermes 문서 RAG 파이프라인 — 실행계획서 — 이 구축은 그 축소 MVP입니다
- Hermes RAG 논의 — 옵션 비교와 초기 결정 — 계획서 이전 단계. 어떤 선택지를 놓고 비교해 여기 도달했는지
- Hermes dev 채널 holographic provider 도입 — 대화 메모리. 문서 RAG와는 별개 계층입니다
- 봇 전체 무응답 — 무료 모델 소멸(404) + auth.json 권한 사고(401) — 8절에서 다시 만난 그 장애입니다