라이온스클럽 회칙 RAG 도입기
시작은 회원용 AI였다
출발은 대표가 던진 질문이었습니다. 라이온스 회원들을 위한 AI 기능을 만들 수 없겠느냐는 이야기였고, 먼저 잡은 대상이 회칙이었습니다.
회원이 “회비는 언제까지 내나요”나 “총재 후보 등록비가 얼마인가요”처럼 물었을 때 관련 조항을 찾아 답할 수 있으면, 두꺼운 헌장·부칙 문서를 직접 뒤지는 수고를 줄일 수 있습니다. 모델 교체는 목표가 아니라, 회칙 RAG를 실제로 올리는 과정에서 잠깐 깊게 빠진 삽질이었습니다.
흐름은 거의 붙어 있습니다. 20개 지구를 대상으로 회칙 원문을 찾고, 확보한 문서를 조항 단위로 잘라 데이터베이스에 넣었습니다.
다음 날에는 사용자가 질문을 던지면 관련 조항을 찾아주는 검색 기능을 붙이고, 그 검색 결과를 근거로 답변을 만드는 기능까지 붙였습니다.
그림으로 줄이면 이런 구조입니다.
실제로 기대한 화면은 이런 쪽에 가까웠습니다.
질문: 355-C지구 총재 후보로 등록하려면 등록비를 얼마 내야 하나요?
답변: 총재 후보 등록비는 800만원입니다.
근거: 355-C지구 선거관리규정 제5조
질문: 355-C지구는 어느 지역을 관할하나요?
답변: 창원시, 김해시, 밀양시, 거제시, 통영시, 고성군, 함안군, 창녕군, 의령군입니다.
근거: 355-C지구 지구헌장 제6조
위 예시의 800만원은 355-C지구 선거관리규정 제5조에서, 관할 지역 목록은 355-C지구 지구헌장 제6조에서 가져왔습니다.
처음에는 키워드 검색만으로는 표현이 조금만 달라도 놓치는 문서가 있어서, 의미 검색을 같이 돌리고 RRF로 합치는 하이브리드 검색으로 만들었습니다.
기능을 붙인 뒤에 남은 불안은 “답변이 자연스러운가”가 아니라 “근거로 넘긴 조항이 맞는가”였습니다. LLM은 근거가 조금 빗나가도 그럴듯한 문장을 만들 수 있어서, 검색 단계가 틀리면 답변 단계에서 보기 좋게 틀릴 수 있습니다.
이 불안 때문에 다음날 바로, 도입 때 붙인 Xenova/multilingual-e5-small이 실제 회칙 질문에서 얼마나 맞는지 다시 재보기로 했습니다. 같은 조건에서 후보 두 모델을 나란히 붙여 봤고, 그 결과 bge-m3로 갈아탔습니다.
이 코퍼스는 일반적인 검색 데이터셋과 달랐습니다. 지구 헌장과 부칙은 같은 표현이 반복되고, “회원”, “총재”, “클럽”, “위원회” 같은 단어가 문서마다 나오고, 질문은 사용자가 생활 문장으로 던지는데 정답은 조항 번호와 문서 구조 안에 숨어 있습니다.
이런 코퍼스에서는 막연히 “한국어 벤치마크에서 좋다니까 괜찮겠지”로 넘기기 어려웠습니다.
비교 대상은 Transformers.js(@huggingface/transformers)에서 바로 쓸 수 있는 ONNX 빌드가 이미 있는 모델로 좁혔습니다.
onnx-community/bge-m3-ONNX와 onnx-community/gte-multilingual-base가 후보로 남았고, 한국어 벤치마크 1위인 nlpai-lab/KURE-v1은 ONNX 변환본이 없어 후보에서 뺐습니다.
채점 함정
운영에 들어간 조항 묶음 전체, 그러니까 20개 지구 헌장·부칙 등 1,034건을 코퍼스로 두고 질문 30개를 만들었습니다. 각 모델이 내놓은 top-5 안에 정답 조항이 들어오는지 기계로 채점했습니다.
일반 벤치마크 점수 대신 운영 코퍼스로 다시 잰 이유도 여기에 있습니다. 회칙 검색에서는 넓은 의미의 유사 문서보다, 그 질문에 답할 수 있는 조항이 근거 안에 들어오는지가 더 중요했습니다.
처음엔 정답 조건을 mustInclude(본문에 이 문자열이 다 들어있으면 정답) 하나로만 뒀는데, 윤리강령처럼 항목이 여러 개인 문서에서 문제가 났습니다.
세 모델 다 윤리강령의 2·3·5·6·7·8항을 정확히 찾아왔는데도, 채점 조건이 1항 문구 하나로 좁혀 있어서 “전원 미스”로 잘못 기록된 게 두 건이었습니다.
const isCorrectHit = (node, condition) => {
if (condition.mustDocument) {
const docs = Array.isArray(condition.mustDocument)
? condition.mustDocument
: [condition.mustDocument]
return docs.includes(node.document_title)
}
return condition.mustInclude!.every((s) => buildText(node).includes(s))
}
문서 소속만 맞으면 정답으로 치는 mustDocument 조건을 추가하고, 질문 30개 전부 “이 조건을 만족하는 노드가 코퍼스에 실제로 있는가”부터 다시 확인했습니다.
조건을 만족하는 노드가 아예 없으면 무슨 모델을 넣어도 항상 실패하는 유령 정답이 되기 때문입니다.
모델을 평가한다고 해놓고 평가지가 먼저 틀려 있었습니다. 그 상태에서는 모델을 바꾸든 안 바꾸든 결론이 그럴듯하게 틀릴 수 있었습니다.
모델을 바꾸자 검색 결과도 바뀌었다
다시 돌린 결과는 이렇습니다.
e5-small (현재 prod) hit@1 60% hit@3 70% hit@5 77%
bge-m3 hit@1 63% hit@3 90% hit@5 90%
gte-multilingual-base hit@1 70% hit@3 77% hit@5 77%
1등만 보면 gte가 제일 정확한데, top-3부터는 e5·gte 둘 다 70%대에서 멈추는 반면 bge-m3는 top-1 63%에서 top-3 90%로 크게 뜁니다.
지금 검색 결과 top-5를 통째로 LLM 근거로 넘기는 구조라, 1등을 못 맞히더라도 상위 몇 등 안에 정답을 담아두는 쪽이 실제 답변 품질에 더 유리합니다.
점수가 흩어지는 모양도 달랐는데, e5-small은 무관한 결과도 0.820.89로 다 비슷하게 높게 나오는 반면 bge-m3는 진짜 정답에 0.60.7, 애매한 자리엔 0.4대를 줘서 확실히 갈립니다.
이 차이가 숫자보다 더 안심됐습니다. 검색 결과를 사람이 눈으로 볼 때도 전부 높은 점수로 붙어 있으면 어디서 잘라야 할지 감이 안 오는데, bge-m3는 맞는 것과 애매한 것이 조금 더 벌어져 보였습니다.
코드와 의존성 교체
bge-m3로 옮기면서 코드도 같이 고쳤습니다. 모델 이름만 바꾸면 끝날 줄 알았는데, 실제로는 입력 형식과 벡터를 꺼내는 방식이 같이 바뀌었습니다.
기존 e5 계열 모델은 질문과 문서를 구분해서 넣어야 했습니다. 질문에는 query: 를, 문서 조항에는 passage: 를 앞에 붙여서 “이 문장은 찾는 말이고, 이 문장은 검색 대상”이라는 힌트를 줬습니다.
bge-m3는 이 프리픽스를 쓰지 않는 쪽이 기본이라, 같은 전처리를 그대로 두면 오히려 모델이 학습 때 보지 않은 모양의 문장을 받게 됩니다. 그래서 질문과 조항 앞에 붙이던 역할 표시를 뺐습니다.
문장 벡터를 꺼내는 방식도 바꿨습니다. 기존에는 토큰 벡터를 평균내는 mean pooling을 썼는데, bge-m3의 BAAI 설정 파일(1_Pooling/config.json)에는 pooling_mode_cls_token: true가 들어 있습니다. 모델이 권장하는 방식에 맞춰 CLS 토큰 벡터를 쓰도록 바꿨습니다.
const embed = async (text: string) => {
const extractor = await getExtractor()
const output = await extractor(text, { pooling: "cls", normalize: true })
return Array.from(output.data as Float32Array)
}
여기까지가 모델 사용법을 맞춘 작업이었다면, 그다음은 런타임 문제였습니다.
브라우저가 아니라 Node.js 서버에서 ONNX 모델을 돌리려면 @huggingface/transformers가 내부적으로 onnxruntime-node를 불러옵니다. 그래서 transformers.js 버전을 올리는 일은 단순한 JS 패키지 업데이트가 아니라, 네이티브 런타임까지 같이 바꾸는 일이었습니다.
처음에는 최신 4.x로 올렸는데 import 시점에 바로 죽었습니다. 4.x가 의존하는 onnxruntime-node@1.24.3을 npm pack으로 직접 열어보니 darwin-x64(인텔 맥) 네이티브 바인딩이 아예 안 들어 있었고, arm64만 있었습니다.
device: "wasm"을 주면 네이티브 바인딩을 피해 갈 수 있을 거라 기대했지만 소용없었습니다. 실행 옵션을 보기 전에, Node.js 진입점이 import되는 순간 이미 onnxruntime-node를 require하고 있었기 때문입니다.
darwin-x64를 담고 있는 onnxruntime-node@1.21.0을 쓰는 @huggingface/transformers@3.8.1로 캐럿 없이 고정했습니다.
캐럿 없이 고정한 이유도 여기에 있습니다. ^3.8.1처럼 열어두면 누군가 새로 설치할 때 하위 의존성이 달라질 수 있고, 그러면 어떤 맥에서는 뜨고 어떤 맥에서는 import도 못 하는 상태가 됩니다. 모델만 바꾸는 일이라고 생각했는데, 실제로는 런타임과 네이티브 바인딩까지 같이 따라왔습니다.
메모리는 계산보다 더 먹었다
fp32 그대로 쓰면 568M 파라미터짜리 bge-m3가 API 서버 메모리를 크게 잡아먹을 게 뻔해서 dtype: "q8" 양자화를 붙였습니다.
배포 전엔 e5-small 로드만으로 api 프로세스가 302MB에서 1.1GB로 뛴 값에, bge-m3가 e5보다 5배 가까이 크다는 비율을 곱해 1.3~1.4GB 정도로 늘 거라 추정했습니다.
실제로 배포하고 재보니 1.88GB까지 올라가 있었습니다.
시스템 전체 available 메모리는 1.7GB에서 1.1GB로 줄었고 swap도 83MB에서 117MB로 조금 늘었는데, t3.medium(총 3.7GB) 안에서 아직 장애로 이어지진 않았습니다.
장애로 이어지진 않았지만 예상보다 큰 값이었습니다. q8로 줄였고 대략 계산도 했지만, 모델 메모리는 파라미터 수만 곱해서 맞힐 수 있는 값이 아니었습니다.
재임베딩과 배포
차원이 384에서 1024로 바뀌어서 content_hash 기반 증분 백필이 안 통했고, canon_node_embeddings를 TRUNCATE한 뒤 1,034건을 통째로 다시 임베딩했습니다.
findStaleNodeIds()가 content_hash만 비교하고 model 컬럼은 안 봐서, 차원이 그대로인 모델 교체였다면 이 함수가 바뀐 걸 stale로 못 잡았을 자리였습니다.
WHERE cne.canon_node_id IS NULL
OR cne.content_hash != cn.content_hash
OR cne.model != $CANON_EMBEDDING_MODEL
모델 교체 때 같은 문제가 반복되지 않도록 조건을 하나 더 붙였습니다.
차원이 384에서 1024로 바뀌어서 어차피 전체를 다시 만들 수밖에 없었지만, 차원이 같은 모델로 바꿨다면 오래된 임베딩을 새 모델 결과처럼 들고 있었을 수 있습니다.
모델을 실제로 돌린 뒤 dispose()까지 부르고서도 process.exit()으로 강제 종료하면 exit code 134(mutex lock failed)로 죽는 것도 이 환경에서 재현됐습니다.
DB 반영은 이미 끝난 뒤라 데이터는 멀쩡했지만, cron이나 pm2가 이 종료 코드만 보면 실패로 오판할 자리라 이벤트 루프가 자연히 빌 때까지 두고 process.exit()을 아예 안 부르는 쪽으로 바꿨습니다.
prod에서 기존 임베딩을 비우고 1,034건을 다시 만드는 데는 5~6분 정도 걸렸습니다. 끝난 뒤에는 데이터가 빠짐없이 새 모델 기준으로 들어갔는지, 사용자가 회칙을 검색하고 답변 가능 여부를 확인하는 경로가 살아 있는지, 테스트가 새 벡터 차원에 맞게 통과하는지를 순서대로 봤습니다.
- 임베딩 테이블을 다시 세어
model=onnx-community/bge-m3-ONNX·dims=1024·count=1034확인 - 회칙 검색과 답변 가능 여부 확인 경로를 직접 호출해 200 응답 확인
- 테스트에 박혀 있던 384차원 합성 벡터를 1024차원으로 고쳐 canon 모듈 테스트 19개 재통과
한국어 벤치마크로는 bge-m3보다도 근소하게 높은 nlpai-lab/KURE-v1은 ONNX 변환본이 없어 건드리지 않았습니다.
지금 상태
bge-m3는 top-5 근거를 넘기는 지금 구조에는 더 맞았습니다. 다만 채점 조건을 잘못 만들면 그 판단도 틀리고, 모델은 바뀌어도 stale 판정이 못 따라오면 DB에는 예전 벡터가 남고, 메모리는 단순 비례 계산보다 더 먹었습니다.
LLM으로 청킹까지 하지는 않았습니다. 회칙은 조·항·호라는 사람이 이미 만들어 둔 구조가 있었고, 각 조항을 잎 노드로 만들면서 제목과 상위 경로를 같이 들고 갔습니다.
“총재 후보 등록비”라는 질문은 조항 본문만 보면 짧게 끝나지만, 그 조항이 어느 지구의 어떤 선거관리규정에 속하는지가 답변의 신뢰도를 만들었습니다.
지금은 bge-m3를 q8로 올린 상태이고, t3.medium 안에서 돌기는 하지만 메모리 여유는 이전보다 확실히 줄었습니다.
용어 짧게 정리
- RAG: 검색한 문서를 LLM 답변의 근거로 붙이는 구조입니다. 이 글에서는 회칙 조항을 찾아 답변 근거로 넘깁니다.
- 하이브리드 검색: 키워드 검색과 의미 검색을 함께 돌려 결과를 섞는 방식입니다.
- RRF: 여러 검색 결과의 순위를 합칠 때 쓰는 방식입니다. 점수 자체보다 각 결과의 순위를 이용합니다.
- Transformers.js: Hugging Face 모델을 JavaScript 환경에서 돌릴 수 있게 해주는 라이브러리입니다.
- ONNX: 여러 런타임에서 모델을 실행할 수 있게 만든 공통 모델 포맷입니다.
- mean pooling·CLS pooling: 토큰별 벡터를 문장 하나의 벡터로 줄이는 방식입니다.
- q8 양자화: 모델 가중치를 8비트 쪽으로 줄여 메모리 사용량을 낮추는 방식입니다.
- content_hash: 본문이 바뀌었는지 비교하기 위한 해시값입니다. 본문이 같아도 모델이 바뀌면 임베딩은 다시 만들어야 합니다.