리랭커(Reranker)에게 보안 티켓의 언어를 가르치기 (+41% MRR@10)
요약
SOC RAG 파이프라인의 검색 정확도를 높이기 위해 리랭커를 도메인 특화 데이터로 미세 조정한 사례를 소개합니다. 기존 모델 대비 MRR@10 지표를 41% 향상시켰으며, 별도의 레이블링 없이 분석가들의 종료 노트를 활용해 학습 데이터를 구축했습니다.
핵심 포인트
- 도메인 특화 리랭커 미세 조정으로 MRR@10 41% 향상
- 명시적 레이블 없이 기존 보안 티켓 데이터를 활용한 학습
- 표준 2단계 RAG(Embedder + Reranker) 구조 최적화
- 하드 네거티브를 포함한 24K개의 양성 쌍 데이터 활용
요약 (TL;DR)
저희 SOC(Security Operations Center)의 RAG(Retrieval-Augmented Generation) 파이프라인은 조사 답변의 근거를 마련하기 위해 142,000개 이상의 종료된 XSOAR 보안 티켓을 검색합니다. 청킹(Chunking), Top-k, 리랭커(Reranker) 선택과 같은 쉬운 방법들을 모두 시도해 보았지만, 여전히 적절한 과거 티켓이 순위 5~10위에 머무는 경우가 너무 많았고, LLM(Large Language Model)이 정답과 매우 유사하지만 틀린 이웃 데이터를 근거로 답변을 생성하는 문제가 발생했습니다.
저희는 자체 데이터를 사용하여 리랭커를 미세 조정(Fine-tuning)했습니다. 시간 기반 분할(Time-based split)을 적용한 홀드아웃(Held-out) 테스트 세트 결과는 다음과 같습니다:
| MRR@10 | |
|---|---|
BAAI/bge-reranker-v2-m3 (기성 모델) | 0.598 |
| 24K개의 XSOAR 쌍으로 미세 조정함 | 0.846 |
+41% 향상. 모델 아키텍처를 변경하거나 임베딩 모델(Embedding model)을 교체하지 않았습니다. 단지 동일한 베이스 리랭커를 도메인 특화(Domain-specific) 방식으로 미세 조정했을 뿐입니다.
<table><tbody><tr><td width="33%"><h2 style="margin:0;">+41%</h2>홀드아웃 시간 기반 분할 테스트 세트에서의 MRR@10 향상</td><td width="33%"><h2 style="margin:0;">24,213 + 10,848</h2>종료 노트(Close-notes)에서 추출한 양성 쌍(Positive pairs) + 깨끗한 하드 네거티브(Hard negatives)</td><td width="33%"><h2 style="margin:0;">0</h2>수집된 명시적 관련성 레이블 없음 — 모든 신호는 기존 분석가 텍스트에서 추출됨</td></tr></tbody></table>흥미로운 점은 결과가 아니라 학습 데이터가 어디에서 왔느냐 하는 것입니다. 저희는 단 하나의 명시적인 관련성 판단(Relevance judgement)도 기록하지 않았습니다. 24,000개의 양성 쌍은 아무도 작성하라고 요청하지 않은 분석가들의 종료 노트(Close-notes) 안에 숨겨져 있었습니다.
설정: 임베더(Embedder) + 리랭커(Reranker), 표준 2단계 RAG
flowchart LR
Q[사용자 질의] --> E[임베더(Embedder)<br/>Qwen3-Embedding-8B<br/>4-bit DWQ]
E --> Top50[코사인 유사도(Cosine similarity) 기준<br/>Top-50]
...
저희의 검색 파이프라인은 표준적인 캐스케이드(Cascade) 구조입니다:
- Stage 1 — Embedder (bi-encoder). vllm-mlx를 통해 서빙되는
Qwen3-Embedding-8B-4bit-DWQ입니다. 쿼리(Query)를 독립적으로 인코딩(Encoding)하며, 코사인 유사도(Cosine similarity)를 기준으로 ChromaDB에서 상위 50개의 후보를 추출합니다. 속도는 빠르지만, 쿼리와 문서를 각각 분리하여 점수를 매깁니다. - Stage 2 — Reranker (cross-encoder). Apple Silicon (MPS)에서 실행되는
BAAI/bge-reranker-v2-m3입니다.(query, document)쌍을 결합하여 어텐션(Attention)을 수행하며, 상위 50개를 상위 5개로 재점수화(Re-score)하여 LLM에 전달합니다. 항목당 속도는 느리지만, 임베더(Embedder)만 사용하는 랭킹보다 훨씬 더 정확합니다.
멘탈 모델(Mental model): 임베더는 제목 유사성을 바탕으로 서가에서 50권의 책을 빠르게 꺼내오는 빠른 사서와 같습니다. 리랭커(Reranker)는 각 책을 실제로 펼쳐보고 당신의 구체적인 질문과의 관련성에 따라 순서를 다시 정하는 꼼꼼한 독자입니다.
bge-reranker-v2-m3와 같은 기성(Off-the-shelf) 리랭커들은 일반적인 영어 지문 검색(MS MARCO 및 유사 데이터셋)을 기반으로 학습되었습니다. 이들은 XSOAR 티켓을 본 적이 없습니다. 이들은 _"INBLRPRDDKNF01: ML via Cloud-based ML"_와 같은 내용이 일반적인 영어 의미론적 유사도(Semantic similarity)로는 포착할 수 없는 방식으로 중요하다는 사실을 알지 못합니다. 파인튜닝(Fine-tuning)은 바로 이들을 가르치는 방법입니다.
학습 데이터의 출처
크로스 인코더(Cross-encoder) 학습에는 (query, positive, negative) 트리플(Triples)이 필요합니다. 저희에게는 클릭, 좋아요/싫어요 등 명시적인 관련성 라벨(Relevance labels)이 전혀 없었습니다. 그래서 분석가의 종료 노트(Close-notes)에서 암시적인 라벨을 채굴(Mining)했습니다.
142,000개의 종료된 티켓 속에는 분석가들이 항상 입력하는 문장들이 숨겨져 있었습니다:
- "XSOAR #289008과 관련하여, 지역 팀에서 확인했습니다..."
- "마스터 티켓 #158126을 참조하십시오."
- "XSOAR #463428에 따라, 사용자가 확인했습니다..."
각 문장은 두 티켓 사이의 인간이 큐레이션한 링크입니다. 무료로 얻은 관련성 라벨인 셈입니다. 저희는 단지 이를 추출하기만 하면 되었습니다.
일반화 가능한 교훈. 라벨 비용을 지불하기 전에, 사용자들이 이미 무엇을 입력하고 있는지 살펴보세요. 종료 노트, 댓글, JIRA 설명에 포함된 자유 형식의 텍스트에는 아무도 기록하라고 요청하지 않았지만 암시적인 관련성 판단이 가득 차 있습니다. {: .prompt-tip }
노이즈 필터링: 모든 #N 참조가 동일한 것은 아니다
close-notes에 대한 정규 표현식(regex)을 적용한 결과 61,500개의 #N 참조가 추출되었습니다. 대부분은 쓸모가 없었습니다:
| 풀 (Pool) | 도입 문구 (Lead-in phrase) | 개수 | 신호 품질 (Signal quality) |
|---|---|---|---|
| A | "#N으로 중복 (Duplicate to #N)" | 52,782 | 강력하지만 사소함 — 동일한 경고, 다른 호스트. 임베더 (Embedder)가 이미 이를 파악함. |
| ... |
풀 A는 대부분 이미 임베더의 영역입니다. 리랭커 (Reranker)는 거의 동일한 중복 항목(near-duplicates)을 처리하는 데 도움을 받을 필요가 없습니다. 풀 B가 흥미로운 신호입니다: "이 두 티켓은 관련이 있지만 동일하지는 않음" — 이것이 바로 리랭커가 제 역할을 다하는 정확한 사례입니다. 정규 표현식 필터링을 거치고 두 엔드포인트가 모두 우리 DB에 존재하는지 확인한 후, 4,260개의 고유한 직접 (src → tgt) 쌍을 확보했습니다.
이행적 형제 관계(transitive siblings)를 통한 무료 양성 샘플 (그리고 다항식 폭발의 함정)
다섯 개의 티켓이 모두 동일한 마스터 티켓을 인용한다면, 그 다섯 개는 서로에게도 관련이 있습니다. 이는 폭발을 제한하기만 한다면, 훈련 쌍을 O(n²)로 늘릴 수 있는 무료인 셈입니다.
우리는 형제 관계를 생성하기 전에 각 마스터 티켓당 자식 티켓을 20개로 제한했습니다. 특히 자식이 많은 한 마스터 티켓은 553개의 자식을 가지고 있었는데, 제한을 두지 않았다면 약 150,000개의 사소한 형제 쌍을 생성하여 훈련 분포를 지배했을 것입니다. 서로 다른 규칙 간의 층화 추출 (Stratified sampling)을 통해 규칙 간 쌍(cross-rule pairs)을 전면에 배치함으로써, 모델이 규칙 내의 동일성이 아닌 일반화 가능한 (generalizable) 관계를 학습하도록 했습니다.
| 소스 (Source) | 개수 |
|---|---|
직접적인 #N 참조 | 4,260 |
| ... |
이행적 쌍의 72%가 규칙 간(cross-rule) 관계였습니다. 이는 우리의 제한(cap)과 샘플링이 효과적이었다는 강력한 신호입니다.
일반화 가능한 교훈. 이행성(transitivity) 또는 기타 구조적 추론을 통해 새로운 훈련 예시를 도출할 때마다, 밀집된 클러스터에서의 다항식 폭발 (polynomial blow-up)을 주의하십시오. 층화 추출 (Stratified sampling)이 대개 올바른 대응책입니다.
{: .prompt-tip }
초보자들이 가장 많이 실수하는 부분: 하드 네거티브 마이닝 (hard negative mining)
네거티브(Negatives)는 포지티브(Positives)만큼이나 중요합니다. 모델은 대조(contrast)를 통해 학습하며, 무작위(random) 네거티브는 거의 아무것도 가르쳐주지 못합니다. 이미 명백하게 다르기 때문입니다. 흥미로운 네거티브는 임베더(embedder)에게는 유사해 보이지만 실제로는 관련이 없는 것들입니다. 이것들이 바로 임베더가 틀리는 사례이며, 리랭커(reranker)가 이들을 서로 밀어내도록 학습해야 하는 바로 그 지점입니다.
레시피: 각 소스 티켓(source ticket)에 대해 기존 임베딩 인덱스(embedding index)에서 상위 50개의 근접 이웃(nearest neighbors)을 쿼리합니다. 이미 알려진 포지티브(직접적, 전이적, 또는 마스터를 공유하는 경우)는 모두 제외합니다. 남은 것들이 바로 임베더는 일치한다고 생각하지만 분석가는 결코 연결하지 않은 것들, 즉 _하드 네거티브(hard negatives)_입니다.
첫 번째 실행에서 미묘한 함정을 발견했습니다: 동일 규칙에 의한 유사 중복(same-rule near-duplicates)은 하드 네거티브가 아닙니다. INBLRPRDDKNF01: ML via Cloud-based ML에 의해 발생한 두 티켓이 0.997의 코사인 유사도(cosine similarity)를 가진다면, 이들은 동일한 자동 탐지 규칙의 형제 경고(sibling alerts)입니다. 즉, 분석가의 #N 참조를 통해서는 아니더라도 서로 관련이 있는 것입니다. 이들을 네거티브로 학습시키면 모델은 실제로 관련이 있는 것들을 서로 밀어내도록 학습하게 됩니다. 네거티브 풀(pool)에 추가하기 전에 규칙(rule)별로 필터링했더니 후보군의 33%가 제거되었습니다.
| 단계 | 개수 |
|---|---|
| 임베더로부터 추출된 원시 상위 50개 후보 | 16,137 |
| ... | |
| 보존된 네거티브의 중앙값 코사인 유사도: 0.955 — 즉, 임베더는 이것들이 관련이 있다고 매우 강력하게 믿었습니다. 하지만 관련이 없었습니다. 이것이 바로 리랭커가 메워야 할 간극입니다. |
데이터 규율: 무작위가 아닌 시간으로 분할하라
무작위 train/val/test 분할은 미래의 신호(future signal)를 학습 과정에 유출(leak)시키며, 홀드아웃(held-out) 품질에 대해 거짓 정보를 제공합니다. 데이터에 시간 차원(time dimension)이 포함되어 있다면 — 사기 탐지(fraud), 보안(security), 판매 예측(sales forecasting) 등 프로덕션 ML의 거의 모든 분야가 그러합니다 — 반드시 시간 단위로 분할하십시오. 프로덕션 환경에서 모델은 결코 미래를 볼 수 없으므로, 평가(evaluation) 역시 마찬가지여야 합니다.
| 분할 (Split) | 날짜 범위 (Date range) | 행 (Rows) | 양성 / 음성 (Pos / Neg) |
|---|---|---|---|
| 학습 (Train) | 2025-09-01 이전 | 27,604 | 18,745 / 8,859 |
| ... |
거의 한 줄로 끝나는 부분: 학습 루프 (training loop)
모든 데이터 작업을 마친 후, 실제 학습(fit) 과정은 짧습니다:
from sentence_transformers import CrossEncoder, InputExample
from torch.utils.data import DataLoader
...
중요했던 몇 가지 세부 사항:
- BCE-with-logits loss:
(query, passage, label ∈ {0, 1})에 적용했습니다. 단일 점수 출력(Single-score output)이며, 이진 교차 엔트로피(binary cross-entropy)를 사용합니다. lr=2e-5에서의 AdamW: BERT 계열 미세 조정(fine-tune)의 표준 학습률(learning rate)입니다. 너무 깊게 고민할 필요 없습니다.- 첫 10% 단계에서의 선형 웜업 (Linear warmup): 학습률이 0에서 2e-5로 상승한 후, 다시 0으로 선형 감소(linear decay)합니다. 이는 모델이 새로운 라벨 분포를 배우는 초기 단계에서 불안정한 업데이트를 방지합니다.
- 주기적인 검증 (val evaluation): 약 862 단계마다 수행합니다. 언제 멈춰야 할지 알기 위해 평균 정밀도(Average Precision)를 추적했습니다.
성과 (The payoff)
| Baseline MRR@10 | Fine-tuned MRR@10 | Δ | |
|---|---|---|---|
| 검증 (Validation) | 0.626 | 0.811 | +30% |
| 테스트 (held-out time) | 0.598 | 0.846 | +41% |
MRR@10은 표준적인 순위 지표(ranking metric)입니다. 각 쿼리에 대해 첫 번째 관련 결과의 순위(rank)를 찾습니다. 만약 순위가 _k_라면 점수는 _1/k_가 되며, 이를 모든 쿼리에 대해 평균냅니다. 우리의 베이스라인(baseline) 0.598은 첫 번째 관련 티켓이 평균적으로 약 1.7위 순위에 위치함을 의미합니다. 미세 조정된 모델의 0.846은 그것이 약 1.18위 순위에 위치함을 의미하며, 이는 거의 항상 최상단에 위치한다는 뜻입니다.
결론적으로, 이제 LLM은 거의 매번 올바른 과거 티켓을 근거로 답변을 생성합니다. 이는 미미한 개선이 아닙니다. 에이전트의 제안이 *유용(useful)*할지, 아니면 그럴듯해 보이지만 틀린(plausible-but-wrong) 것일지를 결정짓는 변화입니다.
전투의 상흔 (아무도 문서화하지 않는 주의사항)
이 과정을 실제로 실행하면서 수정해야 했던 몇 가지 사항들입니다:
Corp SSL. 훈련을 수행 중인 Mac은 시스템 레벨에서 기업용 CA(Certificate Authority)를 신뢰하고 있었지만(따라서 curl과 OS 키체인은 정상 작동함), Python의 requests / urllib3는 시스템 저장소가 아닌 certifi의 CA 번들을 사용합니다. 이로 인해 pip install과 HuggingFace 모델 다운로드가 CERTIFICATE_VERIFY_FAILED 오류와 함께 실패했습니다. 해결 방법은 통합 CA 번들을 구축하고 두 환경 변수 모두를 해당 번들로 지정하는 것입니다(라이브러리마다 서로 다른 번들을 읽기 때문입니다):
export REQUESTS_CA_BUNDLE=~/corp-ca-bundle.pem
export SSL_CERT_FILE=~/corp-ca-bundle.pem
임베딩 모델 이름 강제 적용 (Embedding model name enforcement). vllm-mlx는 고정된 모델 ID로 서비스를 제공하며, 이름이 일치하지 않는 모든 요청에 대해 422 오류를 반환합니다. 일부 라이브러리의 기본값인 text-embedding-ada-002 폴백(fallback)은 이와 일치하지 않습니다. 임베딩 함수가 임포트(import)되기 전에 EMBEDDING_MODEL을 명시적으로 설정하십시오. 프로덕션 환경의 systemd는 EnvironmentFile을 통해 이를 로드하며, 임시 스크립트는 직접 .env를 소싱(source)해야 합니다.
MPS 메모리 계정 (MPS memory accounting). PyTorch의 MPS 할당기(allocator)는 macOS 파일 캐시와 비활성 페이지를 _"기타 할당(other allocations)"_으로 계산합니다. 비록 해당 페이지들이 회수 가능한 상태일지라도 말입니다. 이미 다른 32B 모델이 로드된 상태에서, 물리적으로 88GB가 남아 있음에도 불구하고 MPS 할당량이 19GB에 도달했을 때 훈련 중 OOM(Out of Memory)이 발생했습니다. 해결 방법은 기본적으로는 안전하지 않지만 대개는 올바른 방법입니다:
export PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0
이 설정은 워터마크(watermark) 확인을 비활성화합니다. 실제 여유 메모리가 있는지 확인했다면(vm_stat을 먼저 실행) 안전합니다. 하지만 물리적 RAM이 실제로 고갈된 시스템에서는 macOS가 충돌할 수 있습니다.
launchctl의 기묘한 특성 (launchctl quirks). macOS 서비스 관리는 실수하기 쉬운 요소(footgun)가 가득합니다: launchctl unload는 더 이상 사용되지 않으며(deprecated), bootout은 때때로 gui/UID에서 I/O 에러를 반환하지만 user/UID에서는 정상 작동합니다. KeepAlive=true는 종료된 프로세스를 다시 실행하므로, 단순히 프로세스를 죽이는 것이 아니라 launchd에서 서비스를 제거해야 합니다. 한 번은 이 문제로 저녁 시간을 통째로 날린 적이 있습니다.
이 작업을 고려해야 하는 경우
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기