<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Dongjun's Blog]]></title><description><![CDATA[AI Solutions Architect & Full-stack Engineer의 기술 블로그.
AI/LLM 엔지니어링, RAG 시스템 설계, 바이브코딩, 그리고 실전에서 배운 문제 해결 경험을 공유합니다.]]></description><link>https://blog.dongjun.win</link><image><url>https://cdn.hashnode.com/uploads/logos/6889d261565bc76a2da0e4d8/00f6ab62-a04e-48d1-bb7f-50ab415aafad.png</url><title>Dongjun&apos;s Blog</title><link>https://blog.dongjun.win</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 06 Jun 2026 11:02:50 GMT</lastBuildDate><atom:link href="https://blog.dongjun.win/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[STT 모델 실제 성능 비교: 한국어 회의 녹음 35분, 7개 모델 테스트]]></title><description><![CDATA[2026년 2월, 한국어 개발 회의 녹음 하나를 가지고 로컬 STT(Speech-to-Text) 모델 7개를 비교했다.
테스트 오디오는 약 35분 45초 길이의 2인 개발 회의 녹음이다. 정제된 벤치마크 데이터셋이 아니라 실제 회의 녹음이었다. 발화는 비격식 대화체였고, 중간중간 Claude, TDD, CRUD, agent.md, Cursor, Codex,]]></description><link>https://blog.dongjun.win/korean-stt-7-model-comparison</link><guid isPermaLink="true">https://blog.dongjun.win/korean-stt-7-model-comparison</guid><category><![CDATA[AI]]></category><category><![CDATA[STT]]></category><category><![CDATA[whisper]]></category><category><![CDATA[Benchmark]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Wed, 03 Jun 2026 21:24:45 GMT</pubDate><content:encoded><![CDATA[<hr />
<p>2026년 2월, 한국어 개발 회의 녹음 하나를 가지고 로컬 STT(Speech-to-Text) 모델 7개를 비교했다.</p>
<p>테스트 오디오는 약 35분 45초 길이의 2인 개발 회의 녹음이다. 정제된 벤치마크 데이터셋이 아니라 실제 회의 녹음이었다. 발화는 비격식 대화체였고, 중간중간 Claude, TDD, CRUD, agent.md, Cursor, Codex, vector DB 같은 개발 용어가 섞여 있었다.</p>
<p>이 글은 최신 STT 모델 순위가 아니다. 당시 내 환경에서 실제 회의록 자동화에 어떤 모델이 쓸 만한지 확인한 실험 기록이다. 단일 오디오 기준이므로 모든 상황에 일반화할 수는 없지만, 실제 한국어 회의 녹음에서 모델별 차이가 꽤 뚜렷하게 드러났다.</p>
<h2>테스트 조건</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>테스트 시점</td>
<td>2026년 2월</td>
</tr>
<tr>
<td>실행 환경</td>
<td>macOS, Apple Silicon</td>
</tr>
<tr>
<td>테스트 오디오</td>
<td>한국어 개발자 회의 녹음</td>
</tr>
<tr>
<td>오디오 길이</td>
<td>약 35분 45초</td>
</tr>
<tr>
<td>화자</td>
<td>2명</td>
</tr>
<tr>
<td>발화 특성</td>
<td>비격식 회의체, 개발 기술 용어 다수 포함</td>
</tr>
<tr>
<td>비교 대상</td>
<td>STT 모델 7개</td>
</tr>
<tr>
<td>평가 기준</td>
<td>속도, 정확도, 핵심 용어 인식, 환각, 타임스탬프, 안정성</td>
</tr>
</tbody></table>
<p>테스트한 모델은 아래와 같다.</p>
<table>
<thead>
<tr>
<th>모델</th>
<th>프레임워크</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>SenseVoice-Small</td>
<td>Python, FunASR</td>
<td>알리바바 다국어 모델</td>
</tr>
<tr>
<td>Whisper Small</td>
<td>Python, faster-whisper</td>
<td>OpenAI Whisper 경량 모델, 244M</td>
</tr>
<tr>
<td>Whisper Turbo</td>
<td>Python, faster-whisper</td>
<td>Large-v3 기반 경량 최적화 변형</td>
</tr>
<tr>
<td>Whisper Medium</td>
<td>Python, faster-whisper</td>
<td>OpenAI Whisper 중형 모델, 769M</td>
</tr>
<tr>
<td>Whisper Large-v3</td>
<td>Python, faster-whisper</td>
<td>OpenAI Whisper 대형 모델, 1.55B</td>
</tr>
<tr>
<td>CoreML Large-v3 Turbo FP16</td>
<td>Swift, WhisperKit</td>
<td>Apple Neural Engine 최적화</td>
</tr>
<tr>
<td>CoreML Large-v3 Turbo 4-bit</td>
<td>Swift, WhisperKit</td>
<td>632MB 양자화 경량 모델</td>
</tr>
</tbody></table>
<h2>한눈에 보는 결과</h2>
<p>결론부터 보면, 가장 좋은 모델은 CoreML Large-v3 Turbo FP16이었다. 속도만 보면 Whisper Small과 CoreML 4-bit이 빨랐지만, 회의록에 필요한 정확도와 타임스탬프까지 고려하면 CoreML FP16이 가장 안정적이었다.</p>
<table>
<thead>
<tr>
<th>순위</th>
<th>모델</th>
<th>종합 점수</th>
<th>한줄 평가</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>CoreML Large-v3 Turbo FP16</td>
<td>4.5/5</td>
<td>정확도, 용어 인식, 타임스탬프, 안정성 모두 최상위</td>
</tr>
<tr>
<td>2</td>
<td>Whisper Medium</td>
<td>4.0/5</td>
<td>Python 환경에서 가장 안정적인 선택</td>
</tr>
<tr>
<td>2</td>
<td>CoreML Large-v3 Turbo 4-bit</td>
<td>4.0/5</td>
<td>빠른 처리와 준수한 품질의 균형</td>
</tr>
<tr>
<td>4</td>
<td>Whisper Small</td>
<td>3.3/5</td>
<td>가장 빠르지만 기술 용어 오류가 많음</td>
</tr>
<tr>
<td>5</td>
<td>Whisper Turbo</td>
<td>3.1/5</td>
<td>속도는 괜찮지만 용어 인식과 안정성이 기대 이하</td>
</tr>
<tr>
<td>6</td>
<td>Whisper Large-v3</td>
<td>2.1/5</td>
<td>큰 모델이지만 환각과 타임스탬프 문제가 큼</td>
</tr>
<tr>
<td>6</td>
<td>SenseVoice-Small</td>
<td>2.1/5</td>
<td>한국어 긴 회의 녹음에는 부적합</td>
</tr>
</tbody></table>
<h2>속도 비교</h2>
<p>속도는 모델 로드 시간, 추론 시간, 총 소요 시간을 나눠서 봤다. RTF는 Real-Time Factor로, 추론 시간 ÷ 오디오 길이다. 1.0x보다 낮으면 실시간보다 빠르게 처리한 것이다.</p>
<table>
<thead>
<tr>
<th>모델</th>
<th>모델 로드</th>
<th>추론 시간</th>
<th>총 소요 시간</th>
<th>RTF</th>
</tr>
</thead>
<tbody><tr>
<td>SenseVoice-Small</td>
<td>8.1초</td>
<td>3분 20.8초</td>
<td>3분 28.9초</td>
<td>-</td>
</tr>
<tr>
<td>Whisper Small</td>
<td>56.9초</td>
<td>2분 31.4초</td>
<td>3분 28.3초</td>
<td>0.07x</td>
</tr>
<tr>
<td>Whisper Turbo</td>
<td>3분 54.9초</td>
<td>6분 48.0초</td>
<td>10분 42.9초</td>
<td>0.19x</td>
</tr>
<tr>
<td>Whisper Medium</td>
<td>3분 24.6초</td>
<td>7분 46.0초</td>
<td>11분 10.6초</td>
<td>0.22x</td>
</tr>
<tr>
<td>Whisper Large-v3</td>
<td>17분 53.8초</td>
<td>32분 46.7초</td>
<td>50분 40.5초</td>
<td>0.92x</td>
</tr>
<tr>
<td>CoreML Large-v3 Turbo FP16</td>
<td>10.8초</td>
<td>10분 46.3초</td>
<td>10분 57.1초</td>
<td>0.30x</td>
</tr>
<tr>
<td>CoreML Large-v3 Turbo 4-bit</td>
<td>5.6초</td>
<td>4분 47.0초</td>
<td>4분 52.6초</td>
<td>0.13x</td>
</tr>
</tbody></table>
<p>총 소요 시간 기준으로 보면 Whisper Small과 SenseVoice-Small이 약 3분 28초로 가장 빨랐다. 하지만 이 둘은 정확도에서 크게 밀렸다.</p>
<p>실무적으로 가장 눈에 띈 모델은 CoreML 4-bit이었다. 35분 45초 오디오를 약 4분 53초에 처리했고, 모델 로드도 5.6초로 가장 빨랐다. 회의 내용을 빠르게 훑기 위한 초안 생성 용도로는 가장 효율적인 모델이었다.</p>
<p>반대로 Whisper Large-v3는 로드에만 17분 53.8초, 추론에 32분 46.7초가 걸렸다. 총 소요 시간은 50분 40.5초였다. 35분짜리 오디오를 처리하는 데 50분이 걸렸으므로, 이번 환경에서는 실무용으로 쓰기 어려웠다.</p>
<h2>핵심 용어 인식 비교</h2>
<p>회의록에서 중요한 것은 문장이 자연스러운지만이 아니다. 개발 회의에서는 특정 용어를 정확히 받아 적는 것이 중요하다. 예를 들어 TDD를 PDD로 적거나, CRUD를 CR 요기로 적으면 나중에 검색과 요약 단계에서 문제가 생긴다.</p>
<p>아래 표는 원본 결과 파일에서 핵심 기술 용어를 직접 대조한 결과다. <code>✅</code>는 정확, <code>⚠️</code>는 부분 오류, <code>❌</code>는 오인식을 뜻한다.</p>
<table>
<thead>
<tr>
<th>원본 용어</th>
<th>SenseVoice</th>
<th>W-Small</th>
<th>W-Turbo</th>
<th>W-Medium</th>
<th>W-Large-v3</th>
<th>CoreML FP16</th>
<th>CoreML 4-bit</th>
</tr>
</thead>
<tbody><tr>
<td>클로드 (Claude)</td>
<td>✅ 클로드</td>
<td>❌ 클로드 안 드시겠</td>
<td>❌ 클로즈</td>
<td>✅ 클로드</td>
<td>❌ code</td>
<td>✅ 클로드</td>
<td>❌ 클로즈</td>
</tr>
<tr>
<td>TDD</td>
<td>❌ 필리비</td>
<td>❌ PDV</td>
<td>❌ pdd</td>
<td>✅ TDD</td>
<td>✅ TDD</td>
<td>✅ TDD</td>
<td>❌ PDD</td>
</tr>
<tr>
<td>CRUD</td>
<td>❌ 시알요디</td>
<td>❌ CR 요기</td>
<td>❌ cr-od</td>
<td>✅ CRUD</td>
<td>⚠️ crd</td>
<td>✅ CRUD</td>
<td>⚠️ CR,UD</td>
</tr>
<tr>
<td>agent.md</td>
<td>❌ 에전트</td>
<td>❌ 에전템디</td>
<td>✅ agent.md</td>
<td>✅ Agent.md</td>
<td>✅ agent.md</td>
<td>✅ agent.md</td>
<td>✅ Agent.md</td>
</tr>
<tr>
<td>커서 (Cursor)</td>
<td>✅ 커서</td>
<td>✅ 커서</td>
<td>✅ 커서</td>
<td>✅ 커서</td>
<td>✅ 커서</td>
<td>✅ 커서</td>
<td>⚠️ 커서/컷</td>
</tr>
<tr>
<td>커서 룰스</td>
<td>⚠️ 커서롤수</td>
<td>⚠️ 커서 루스</td>
<td>⚠️ 커서 루스</td>
<td>⚠️ 커서 롤소</td>
<td>⚠️ 커서 룰수</td>
<td>⚠️ 커서 룰 수</td>
<td>⚠️ 커서 루스</td>
</tr>
<tr>
<td>오픈클로 (OpenClaw)</td>
<td>✅ 오픈클로</td>
<td>⚠️ 오픈 클로</td>
<td>✅ OpenClaw</td>
<td>✅ 오픈클로</td>
<td>✅ 오픈클로</td>
<td>✅ 오픈클로</td>
<td>✅ 오픈클로</td>
</tr>
<tr>
<td>해피코더</td>
<td>⚠️ 해피</td>
<td>⚠️ 해피</td>
<td>⚠️ 해피</td>
<td>⚠️ 해피 코더</td>
<td>✅ 해피코더</td>
<td>✅ 해피코더</td>
<td>⚠️ 해피 코더</td>
</tr>
<tr>
<td>코덱스 (Codex)</td>
<td>✅ 코덱스</td>
<td>✅ 코덱스</td>
<td>✅ 코덱스</td>
<td>✅ 코덱스</td>
<td>✅ 코덱스</td>
<td>✅ 코덱스</td>
<td>✅ 코덱스</td>
</tr>
<tr>
<td>벡터 DB</td>
<td>❌ 벡터디이</td>
<td>⚠️ 백터디비</td>
<td>✅ 벡터 DB</td>
<td>✅ vectorDB</td>
<td>✅ 벡터 DB</td>
<td>✅ 벡터 DB</td>
<td>⚠️ 벡터디비</td>
</tr>
<tr>
<td>노션 (Notion)</td>
<td>✅ 노션</td>
<td>✅ 노션</td>
<td>✅ 노션</td>
<td>✅ 노션</td>
<td>✅ notion</td>
<td>✅ notion</td>
<td>✅ 노션</td>
</tr>
<tr>
<td>옵시디언 (Obsidian)</td>
<td>⚠️ 옵시디안</td>
<td>✅ 옵시디언</td>
<td>✅ 옵시디언</td>
<td>✅ 옵시디언</td>
<td>✅ obsidian</td>
<td>✅ obsidian</td>
<td>✅ obsidian</td>
</tr>
<tr>
<td>텔레그램</td>
<td>⚠️ 텔레그룸</td>
<td>✅ 텔레그램</td>
<td>✅ 텔레그램</td>
<td>✅ 텔레그램</td>
<td>✅ 텔레그램</td>
<td>✅ 텔레그램</td>
<td>✅ 텔레그램</td>
</tr>
<tr>
<td>젠마 (Gemma)</td>
<td>✅ 젠마</td>
<td>✅ 젠마</td>
<td>✅ 젠마</td>
<td>✅ 젠마</td>
<td>✅ 젠마</td>
<td>✅ 젠마</td>
<td>✅ 젠마</td>
</tr>
</tbody></table>
<p>점수로 환산하면 다음과 같다. 정확 인식은 1점, 부분 오류는 0.5점, 오인식은 0점으로 계산했다.</p>
<table>
<thead>
<tr>
<th>모델</th>
<th>정확</th>
<th>부분 오류</th>
<th>오인식</th>
<th>용어 인식 점수</th>
</tr>
</thead>
<tbody><tr>
<td>CoreML Large-v3 Turbo FP16</td>
<td>11</td>
<td>2</td>
<td>0</td>
<td>92%</td>
</tr>
<tr>
<td>Whisper Medium</td>
<td>10</td>
<td>3</td>
<td>0</td>
<td>88%</td>
</tr>
<tr>
<td>Whisper Large-v3</td>
<td>9</td>
<td>2</td>
<td>2</td>
<td>77%</td>
</tr>
<tr>
<td>Whisper Turbo</td>
<td>8</td>
<td>2</td>
<td>3</td>
<td>69%</td>
</tr>
<tr>
<td>CoreML Large-v3 Turbo 4-bit</td>
<td>7</td>
<td>4</td>
<td>2</td>
<td>69%</td>
</tr>
<tr>
<td>Whisper Small</td>
<td>7</td>
<td>2</td>
<td>4</td>
<td>62%</td>
</tr>
<tr>
<td>SenseVoice-Small</td>
<td>4</td>
<td>4</td>
<td>5</td>
<td>46%</td>
</tr>
</tbody></table>
<p>CoreML FP16과 Whisper Medium이 확실히 좋았다. 특히 CoreML FP16은 TDD, CRUD, Claude, agent.md를 모두 정확히 인식한 유일한 모델이었다.</p>
<p>Whisper Turbo는 이름만 보면 기대가 컸지만, 이번 테스트에서는 TDD를 pdd로, CRUD를 cr-od로, Claude를 클로즈로 인식했다. 문장 흐름은 어느 정도 자연스러웠지만 핵심 용어 인식에서는 Medium보다 낮았다.</p>
<h2>동일 구간 문장 비교</h2>
<p>동일한 발화 구간을 비교하면 모델별 차이가 더 잘 보인다. 원본 발화는 대략 다음과 같은 내용이었다.</p>
<blockquote>
<p>URL을 호출해서 컨트롤러를 실행하는 방식으로 TDD를 만들었어</p>
</blockquote>
<table>
<thead>
<tr>
<th>모델</th>
<th>변환 결과</th>
<th>평가</th>
</tr>
</thead>
<tbody><tr>
<td>CoreML Large-v3 Turbo FP16</td>
<td>URL을 호출해서 컨트롤러를 실행하는 방식으로 TDD를 만들었어</td>
<td>✅ 정확</td>
</tr>
<tr>
<td>Whisper Medium</td>
<td>URL을 호출해서 컨트롤러를 실행하는 방식으로 TDD를 만들었어</td>
<td>✅ 정확</td>
</tr>
<tr>
<td>Whisper Large-v3</td>
<td>url을 호출해서 컨트롤러를 실행하는 방식으로 TDD를 만들었어</td>
<td>✅ 정확</td>
</tr>
<tr>
<td>Whisper Turbo</td>
<td>url을 호출해서 컨트롤러를 실행하는 방식으로 pdd를 만들었어</td>
<td>❌ TDD 오류</td>
</tr>
<tr>
<td>CoreML 4-bit</td>
<td>URL을 호출해서 컨트럴러를 실햌하는 방식으로 PDD를 만들었어</td>
<td>⚠️ 양자화 오타</td>
</tr>
<tr>
<td>Whisper Small</td>
<td>URL을 호출해서 컨트롤러를 실행하는 방식으로 PDV를 만들었어</td>
<td>❌ TDD 오류</td>
</tr>
<tr>
<td>SenseVoice-Small</td>
<td>유아를 호해서 컨트롤를 실행하는 방식으로든지 필리비를 만들었어</td>
<td>❌ 심각한 오류</td>
</tr>
</tbody></table>
<p>이 구간만 보면 CoreML FP16과 Whisper Medium이 가장 안정적이었다. CoreML 4-bit은 전체 맥락은 유지했지만 양자화로 인한 오타가 눈에 띄었다. Whisper Small은 문장은 자연스럽게 만들었지만 핵심 용어를 틀렸다.</p>
<h2>환각 및 결함 비교</h2>
<p>정확도보다 더 위험한 문제는 환각이었다. 회의록에서 없는 말을 만들어내면 후속 요약이나 의사결정 기록까지 오염될 수 있다.</p>
<table>
<thead>
<tr>
<th>모델</th>
<th>환각/결함 유형</th>
<th>심각도</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>Whisper Large-v3</td>
<td>외국어 무작위 삽입</td>
<td>심각</td>
<td>한국어 문장 중간에 영어, 태국어, 아랍어, 타밀어 등이 섞임</td>
</tr>
<tr>
<td>Whisper Turbo</td>
<td>마지막 구간 반복</td>
<td>중간</td>
<td>녹음 끝부분에서 "고마워요"를 여러 번 반복</td>
</tr>
<tr>
<td>Whisper Medium</td>
<td>세그먼트 반복</td>
<td>경미</td>
<td>6분 17초 부근 일부 세그먼트 중복</td>
</tr>
<tr>
<td>SenseVoice-Small</td>
<td>중국어 문자 혼입</td>
<td>중간</td>
<td>한국어 텍스트에 중국어 문자가 섞임</td>
</tr>
<tr>
<td>CoreML FP16</td>
<td>공백 구간</td>
<td>중간</td>
<td>20분 10초 부근 약 30초 누락</td>
</tr>
<tr>
<td>CoreML 4-bit</td>
<td>빈 세그먼트, 양자화 오타</td>
<td>중간</td>
<td>일부 빈 세그먼트와 "컨트럴러", "실햌" 같은 오타</td>
</tr>
<tr>
<td>Whisper Small</td>
<td>오인식</td>
<td>낮음</td>
<td>환각은 없었지만 기술 용어 오인식이 많음</td>
</tr>
</tbody></table>
<p>Whisper Large-v3의 환각은 특히 심각했다. 한국어 회의 녹음 중간에 여러 언어가 무작위로 섞였고, 해당 구간은 회의록으로 사용할 수 없었다. 또한 전체 텍스트가 하나의 블록으로 출력되어 타임스탬프 기반으로 문제 구간을 찾기도 어려웠다.</p>
<p>CoreML FP16도 완벽하지는 않았다. 약 30초 누락 구간이 있었다. 다만 없는 내용을 생성하는 환각은 발견되지 않았고, 타임스탬프 품질이 좋아서 후처리 가능성이 높았다.</p>
<h2>타임스탬프 품질 비교</h2>
<p>회의록 자동화에서는 타임스탬프도 중요하다. 텍스트만 있으면 전체 요약은 가능하지만, 특정 발화가 나온 구간으로 돌아가기는 어렵다.</p>
<table>
<thead>
<tr>
<th>모델</th>
<th>세그먼트 수</th>
<th>세그먼트 크기</th>
<th>정밀도</th>
<th>평가</th>
</tr>
</thead>
<tbody><tr>
<td>CoreML Large-v3 Turbo FP16</td>
<td>약 563</td>
<td>2~8초</td>
<td>밀리초</td>
<td>매우 우수</td>
</tr>
<tr>
<td>Whisper Medium</td>
<td>약 493</td>
<td>2~6초</td>
<td>초</td>
<td>우수</td>
</tr>
<tr>
<td>CoreML Large-v3 Turbo 4-bit</td>
<td>약 524</td>
<td>2~10초</td>
<td>밀리초</td>
<td>양호</td>
</tr>
<tr>
<td>Whisper Turbo</td>
<td>약 1,481</td>
<td>약 1초</td>
<td>초</td>
<td>과분할</td>
</tr>
<tr>
<td>Whisper Small</td>
<td>약 977</td>
<td>약 2초</td>
<td>초</td>
<td>과분할</td>
</tr>
<tr>
<td>Whisper Large-v3</td>
<td>1</td>
<td>전체 블록</td>
<td>없음</td>
<td>사용 불가</td>
</tr>
<tr>
<td>SenseVoice-Small</td>
<td>1</td>
<td>전체 블록</td>
<td>없음</td>
<td>사용 불가</td>
</tr>
</tbody></table>
<p>타임스탬프는 CoreML FP16이 가장 좋았다. 2~8초 단위로 자연스럽게 나뉘었고, 밀리초 단위 정밀도를 제공했다.</p>
<p>Whisper Medium도 충분히 좋았다. 초 단위 정밀도이긴 하지만 문장 경계에 맞춘 2~6초 세그먼트라 회의록 용도로 사용하기 좋았다.</p>
<p>반면 Whisper Turbo와 Whisper Small은 세그먼트가 너무 잘게 쪼개졌다. 한 문장이 여러 조각으로 나뉘어서 후처리가 필요했다. Whisper Large-v3와 SenseVoice-Small은 사실상 타임스탬프를 사용할 수 없었다.</p>
<h2>종합 점수</h2>
<p>속도, 정확도, 용어 인식, 타임스탬프, 안정성을 5점 만점으로 평가했다.</p>
<table>
<thead>
<tr>
<th>모델</th>
<th>속도</th>
<th>정확도</th>
<th>용어 인식</th>
<th>타임스탬프</th>
<th>안정성</th>
<th>종합</th>
</tr>
</thead>
<tbody><tr>
<td>CoreML Large-v3 Turbo FP16</td>
<td>3.0</td>
<td>5.0</td>
<td>5.0</td>
<td>5.0</td>
<td>4.5</td>
<td>4.5</td>
</tr>
<tr>
<td>Whisper Medium</td>
<td>3.0</td>
<td>4.5</td>
<td>4.5</td>
<td>4.0</td>
<td>4.0</td>
<td>4.0</td>
</tr>
<tr>
<td>CoreML Large-v3 Turbo 4-bit</td>
<td>5.0</td>
<td>3.5</td>
<td>3.5</td>
<td>4.0</td>
<td>4.0</td>
<td>4.0</td>
</tr>
<tr>
<td>Whisper Turbo</td>
<td>3.0</td>
<td>3.5</td>
<td>3.0</td>
<td>3.0</td>
<td>3.0</td>
<td>3.1</td>
</tr>
<tr>
<td>Whisper Small</td>
<td>5.0</td>
<td>2.0</td>
<td>2.5</td>
<td>3.0</td>
<td>4.0</td>
<td>3.3</td>
</tr>
<tr>
<td>Whisper Large-v3</td>
<td>1.0</td>
<td>3.5</td>
<td>3.5</td>
<td>1.0</td>
<td>1.5</td>
<td>2.1</td>
</tr>
<tr>
<td>SenseVoice-Small</td>
<td>5.0</td>
<td>1.0</td>
<td>1.5</td>
<td>1.0</td>
<td>2.0</td>
<td>2.1</td>
</tr>
</tbody></table>
<p>여기서 중요한 점은 속도 점수만으로 최종 순위가 정해지지 않았다는 것이다. Whisper Small과 SenseVoice-Small은 빠르지만 정확도가 낮았다. CoreML 4-bit도 빠르고 쓸 만했지만, 기술 용어 정확도에서는 FP16과 Medium보다 낮았다.</p>
<p>회의록 자동화에서는 "빠르게 대충 알아듣는 모델"보다 "중요한 용어를 틀리지 않고, 타임스탬프가 안정적인 모델"이 더 가치 있었다.</p>
<h2>상황별 추천</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>추천 모델</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>최고 품질 회의록 작성</td>
<td>CoreML Large-v3 Turbo FP16</td>
<td>용어 인식, 타임스탬프, 안정성이 가장 좋음</td>
</tr>
<tr>
<td>빠른 초안 생성</td>
<td>CoreML Large-v3 Turbo 4-bit</td>
<td>5분 이내 처리, 모델 로드도 빠름</td>
</tr>
<tr>
<td>대량 반복 처리</td>
<td>CoreML Large-v3 Turbo 4-bit</td>
<td>속도와 용량 측면에서 효율적</td>
</tr>
<tr>
<td>Windows/Linux/서버 환경</td>
<td>Whisper Medium</td>
<td>Python 기반으로 범용성이 높고 품질이 안정적</td>
</tr>
<tr>
<td>품질 타협 가능한 빠른 검색 인덱싱</td>
<td>Whisper Small</td>
<td>가장 빠르지만 기술 용어 오류 감수 필요</td>
</tr>
<tr>
<td>이번 테스트 기준 피하고 싶은 선택</td>
<td>Whisper Large-v3</td>
<td>느리고, 환각이 심하고, 타임스탬프가 없음</td>
</tr>
<tr>
<td>한국어 긴 회의 녹음</td>
<td>SenseVoice-Small 비추천</td>
<td>단어 분절 오류와 중국어 문자 혼입 발생</td>
</tr>
</tbody></table>
<p>Apple Silicon Mac을 쓴다면 CoreML 계열을 먼저 보는 것이 좋았다. 최고 품질이 필요하면 CoreML FP16, 빠른 초안이 필요하면 CoreML 4-bit이 현실적인 선택이었다.</p>
<p>Apple Silicon이 아닌 환경에서는 Whisper Medium이 가장 무난했다. 속도는 CoreML 4-bit보다 느리지만, 용어 인식과 문장 품질이 안정적이었다.</p>
<h2>핵심 인사이트</h2>
<p>이번 실험에서 가장 크게 배운 것은 세 가지다.</p>
<p>첫째, 모델 크기가 실제 품질을 보장하지 않았다. 가장 큰 Whisper Large-v3는 이번 환경에서 심각한 환각과 타임스탬프 문제를 보였다.</p>
<p>둘째, 런타임 구현의 차이가 컸다. 같은 Large-v3 Turbo 계열이라도 Python faster-whisper에서 돌린 Whisper Turbo와 Swift WhisperKit 기반 CoreML 모델의 결과는 달랐다. 특히 CoreML FP16은 용어 인식, 타임스탬프, 안정성에서 가장 좋은 결과를 냈다.</p>
<p>셋째, 한국어 개발 회의에서는 일반 문장 품질보다 기술 용어 인식이 중요했다. TDD, CRUD, Claude, agent.md 같은 용어가 틀리면 회의록 검색과 요약 품질이 바로 떨어진다.</p>
<h2>실무 적용에서 추가로 확인한 점</h2>
<p>이 비교 이후 실제 녹음 앱에서는 CoreML Large-v3 Turbo를 사용했다. 다만 모델 선택만으로 문제가 끝나지는 않았다.</p>
<p>처음에는 mic.wav와 speaker.wav를 따로 전사하는 방식도 중요하게 봤다. 내 음성과 상대 음성을 나눠 기록할 수 있으니 회의록 UI에서는 장점이 있었다. 그런데 speaker 트랙에 긴 선행 무음이 있으면 hallucination이나 timestamp 오류가 발생하는 경우가 있었다. 모델은 같아도 입력 오디오 구성이 달라지면 결과 품질이 달라졌다.</p>
<p>그래서 앱에서는 전사 방식을 <code>전체 대화 전사(merged)</code>와 <code>화자 구분 전사(separated)</code>로 나눴고, 기본값은 merged 오디오 전사로 두었다. 회의 내용 요약과 기록이 목적이면 merged가 더 단순하고 안정적이고, 누가 말했는지 구분해야 할 때만 separated를 쓰는 구조다.</p>
<p>중요한 것은 merged 전사 결과에서 mic 결과를 빼서 speaker만 복원하는 방식은 채택하지 않았다는 점이다. 동시 발화가 있으면 분리가 어렵고, Whisper가 같은 발화를 두 파일에서 항상 같은 텍스트로 전사한다는 보장도 없었다.</p>
<p>결국 실무에서 STT 품질은 모델만의 문제가 아니었다. 어떤 오디오를 넣는지, 무음을 어떻게 다루는지, timestamp를 얼마나 신뢰할 수 있는지가 함께 중요했다.</p>
<h2>최종 결론</h2>
<p>이번 한국어 회의 녹음 35분 테스트 기준 최종 선택은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>목적</th>
<th>최종 선택</th>
</tr>
</thead>
<tbody><tr>
<td>가장 좋은 품질</td>
<td>CoreML Large-v3 Turbo FP16</td>
</tr>
<tr>
<td>가장 좋은 속도와 품질 균형</td>
<td>CoreML Large-v3 Turbo 4-bit</td>
</tr>
<tr>
<td>크로스 플랫폼 대안</td>
<td>Whisper Medium</td>
</tr>
<tr>
<td>빠른 초안 확인</td>
<td>Whisper Small</td>
</tr>
</tbody></table>
<p>개인적으로 회의록 자동화 파이프라인을 만든다면, 먼저 CoreML 4-bit으로 빠르게 초안을 만들고, 중요한 구간이나 오류가 의심되는 구간만 CoreML FP16으로 다시 처리하는 방식이 가장 현실적이라고 봤다.</p>
<p>이 실험은 단일 오디오 기준이기 때문에 절대적인 결론은 아니다. 하지만 실제 한국어 개발 회의 녹음에서는 "큰 모델을 쓰면 무조건 좋아진다"는 가정이 맞지 않았다. STT 모델을 고를 때는 모델 크기보다 실제 입력 데이터, 런타임, 타임스탬프 품질, 기술 용어 인식률을 함께 봐야 한다.</p>
]]></content:encoded></item><item><title><![CDATA[내 문서를 읽는 작은 에이전트를 다시 만들며]]></title><description><![CDATA[내 사이트 dongjun.win에 붙어 있던 작은 AI 어시스턴트를 최근에 다시 손봤다. 방문자가 AI 어시스턴트 페이지에서 질문을 던지면, 내 이력서와 프로젝트 문서, 강점 진단, 리더십 리포트, 버크만(Birkman) 리포트를 바탕으로 답하는 기능이다.
겉으로는 단순하다. "최근 프로젝트는?", "어떤 기술을 쓰나요?", "일하는 방식은 어떤가요?" 같]]></description><link>https://blog.dongjun.win/small-agent-that-reads-my-documents</link><guid isPermaLink="true">https://blog.dongjun.win/small-agent-that-reads-my-documents</guid><category><![CDATA[AI]]></category><category><![CDATA[agents]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Wed, 27 May 2026 00:15:12 GMT</pubDate><content:encoded><![CDATA[<p>내 사이트 <a href="https://www.dongjun.win/">dongjun.win</a>에 붙어 있던 작은 AI 어시스턴트를 최근에 다시 손봤다. 방문자가 <a href="https://www.dongjun.win/assistant">AI 어시스턴트 페이지</a>에서 질문을 던지면, 내 이력서와 프로젝트 문서, 강점 진단, 리더십 리포트, 버크만(Birkman) 리포트를 바탕으로 답하는 기능이다.</p>
<p>겉으로는 단순하다. "최근 프로젝트는?", "어떤 기술을 쓰나요?", "일하는 방식은 어떤가요?" 같은 질문에 답하는 채팅창이다. 하지만 구현 관점에서는 단순한 챗봇보다 작은 에이전트 런타임에 더 가까웠다.</p>
<p>이번 작업의 핵심은 새 챗봇을 만드는 것도, 모델을 바꾸거나 프롬프트를 더 길게 쓰는 것도 아니었다. <strong>기존 어시스턴트가 문서를 언제, 어떤 경로로, 어떤 형태로 모델에게 보여줄 것인가</strong>를 다시 설계하는 일이었다.</p>
<p>기존 구현은 가장 쉬운 방식이었다. 모든 문서를 시스템 프롬프트에 넣었다. LLM 컨텍스트가 충분히 길어졌고, 문서도 몇 개 안 됐다. 별도 RAG를 붙이고 싶지도 않았다. 그래서 이력서, 프로젝트 문서, 성향 자료를 통째로 prompt에 넣고 질문만 덧붙였다.</p>
<p>동작은 했다. 하지만 운영하기 좋은 구조는 아니었다.</p>
<hr />
<h2>프롬프트를 데이터 저장소로 썼을 때</h2>
<p>초기 구조는 이렇게 단순했다.</p>
<pre><code class="language-plaintext">system prompt
  - 답변 원칙
  - 이력서 전체
  - 사이드 프로젝트 문서
  - CliftonStrengths 요약
  - 리더십 리포트
  - 버크만(Birkman) 리포트

user question
  - "최근 프로젝트는?"
</code></pre>
<p>이 방식의 장점은 분명하다. 검색 실패가 없다. chunk 설계도 필요 없다. embedding 모델도 고르지 않아도 된다. 문서가 적고 고정되어 있다면 Full Context는 꽤 합리적인 선택처럼 보인다.</p>
<p>문제는 제품의 기본 경로가 매번 무거워진다는 점이었다. 질문이 가벼워도 모델은 항상 모든 문서를 들고 출발했다. 정확한 벤치마크를 따로 남겨두지는 않았지만, 첫 응답이 무겁게 늦어지는 체감이 분명했다.</p>
<p>더 큰 문제는 노이즈였다. 기술 스택 질문에 성향 진단 문맥이 같이 들어오고, 프로젝트 질문에 리더십 리포트의 표현이 섞였다. 모델이 완전히 틀린 답을 한다기보다, 필요 없는 문맥까지 참고하면서 답변의 초점이 흐려졌다.</p>
<p>이 구조에서는 시스템 프롬프트가 세 가지 역할을 동시에 맡고 있었다.</p>
<ul>
<li><p>assistant의 행동 원칙</p>
</li>
<li><p>내 문서 전체</p>
</li>
<li><p>문서 선택 전략</p>
</li>
</ul>
<p>이 셋이 한 덩어리로 섞이면 변경의 단위가 흐려진다. 말투를 고친 것인지, 지식을 바꾼 것인지, 문서 접근 방식을 바꾼 것인지 추적하기 어렵다. 프롬프트가 길어진 것이 문제가 아니라, <strong>프롬프트가 런타임의 모든 책임을 떠안은 것</strong>이 문제였다.</p>
<p>그래서 방향을 바꿨다.</p>
<p>RAG를 새로 만들지는 않는다. 하지만 모든 문서를 항상 넣지도 않는다. 중간 지점으로, LLM의 <code>tools</code>를 이용해 필요한 문서만 열도록 했다.</p>
<hr />
<h2>RAG 대신 문서 접근 도구를 둔 이유</h2>
<p>이 작업에서 일반적인 벡터 RAG는 과했다.</p>
<p>문서 수는 적고, 종류는 명확했다. 이력서, 사이드 프로젝트, 강점 진단, 리더십 리포트, 버크만 리포트. 이런 규모에서는 "비슷한 chunk를 검색한다"보다 "어떤 문서 범주를 열어야 하는지 결정한다"가 더 중요한 문제였다.</p>
<p>그래서 검색 엔진을 새로 붙이는 대신, 기존 어시스턴트의 문서 접근을 tools 기반으로 분리했다.</p>
<pre><code class="language-plaintext">사용자 질문
-&gt; LLM
-&gt; 필요한 문서 범주 선택
-&gt; searchDocuments
-&gt; 필요한 documentId 선택
-&gt; readDocument
-&gt; 답변 생성
</code></pre>
<p><code>searchDocuments</code>는 벡터 검색이 아니다. 키워드 검색도 아니다. 질문에 필요한 문서 범주를 선택하게 하는 category router에 가깝다. <code>readDocument</code>는 선택한 문서 본문 일부를 읽는다.</p>
<p>이 구조의 의도는 명확했다.</p>
<ul>
<li><p>문서는 system prompt에서 분리한다.</p>
</li>
<li><p>문서 선택은 모델에게 맡기되, 선택 가능한 경로는 제한한다.</p>
</li>
<li><p>검색 실패 가능성은 줄이되, 모든 문서를 매번 넣는 비용은 피한다.</p>
</li>
<li><p>RAG 인프라를 만들지 않고도 문서 접근을 lazy하게 만든다.</p>
</li>
</ul>
<p>즉, Full Context와 RAG 사이의 좁은 해법이다. 문서가 수백 개라면 부족한 구조다. 하지만 <a href="https://www.dongjun.win/">dongjun.win</a>의 공개 프로필 AI에는 이 정도가 더 맞았다.</p>
<hr />
<h2>도구 설명은 문서 접근 계약이다</h2>
<p>이 구조에서 품질을 결정한 것은 도구 개수가 아니라 도구 설명이었다.</p>
<p>도구는 두 개뿐이다.</p>
<ul>
<li><p><code>searchDocuments</code>: 필요한 문서 범주를 고르고 후보와 snippet을 반환한다.</p>
</li>
<li><p><code>readDocument</code>: 선택한 documentId로 본문 일부를 읽는다.</p>
</li>
</ul>
<p>중요한 것은 <code>searchDocuments</code>의 description이다. 단순히 "문서를 검색한다"가 아니라, 어떤 질문에서 어떤 문서를 열어야 하는지를 명시했다.</p>
<ul>
<li><p>이력서: 경력, 기술 스택, 주요 프로젝트, 연락처</p>
</li>
<li><p>사이드 프로젝트: Pikt, bunqldb 같은 개인 제품과 오픈소스</p>
</li>
<li><p>CliftonStrengths: 강점, 커뮤니케이션, 팀 역할</p>
</li>
<li><p>리더십 리포트: 대인관리, 성과관리, 변화관리, 자기관리</p>
</li>
<li><p>버크만(Birkman) 리포트: 흥미, 욕구, 스트레스 행동, 선호 환경</p>
</li>
</ul>
<p>여기서 도구 description은 문서실의 안내 표지판에 가깝다. 모델이 무엇을 할 수 있는지보다, <strong>언제 무엇을 열어야 하는지</strong>가 더 중요했다.</p>
<p>이 판단은 Hermes Agent 코드를 읽으며 정리했던 내용과도 맞닿아 있다. 도구는 시스템 프롬프트 안의 자연어 지시가 아니라 별도 채널로 들어간다. 시스템 프롬프트는 원칙과 전략을 담고, tools parameter는 호출 가능한 행동과 입력 schema를 담는다.</p>
<p>이 분리를 하자 책임이 선명해졌다.</p>
<table>
<thead>
<tr>
<th>책임</th>
<th>위치</th>
</tr>
</thead>
<tbody><tr>
<td>답변 원칙과 톤</td>
<td>system prompt</td>
</tr>
<tr>
<td>현재 날짜와 경력 계산</td>
<td>runtime context</td>
</tr>
<tr>
<td>문서 접근 경로</td>
<td>tools</td>
</tr>
<tr>
<td>대화 상태</td>
<td>messages</td>
</tr>
<tr>
<td>장기 지식</td>
<td>documents table</td>
</tr>
</tbody></table>
<p>프롬프트 하나에 모든 것을 밀어 넣을 때보다 훨씬 다루기 쉬운 구조가 됐다.</p>
<hr />
<h2>대화 저장은 부가 기능이 아니라 런타임이다</h2>
<p>에이전트 런타임에서 대화 저장은 "채팅 기록 보기"를 위한 부가 기능이 아니다. 다음 호출의 입력을 복원하기 위한 핵심 경로다.</p>
<p>LLM은 호출 사이에 기억을 갖지 않는다. 매번 <code>system + tools + messages</code>를 새로 전달해야 한다. 따라서 어떤 메시지를 어떤 단위로 저장하느냐가 다음 호출의 동작을 결정한다.</p>
<p><a href="https://www.dongjun.win/">dongjun.win</a>의 assistant-agent는 세 테이블로 상태를 나눴다.</p>
<pre><code class="language-plaintext">assistant_agent_threads
assistant_agent_sessions
assistant_agent_messages
</code></pre>
<p>메시지는 <code>user</code>, <code>assistant</code>, <code>tool</code> role을 그대로 저장한다.</p>
<pre><code class="language-plaintext">user:      "주요 강점이 뭔가요?"
assistant: searchDocuments tool-call
tool:      문서 후보 결과
assistant: readDocument tool-call
tool:      문서 본문 일부
assistant: 최종 답변
</code></pre>
<p>이 구조에서 중요한 것은 assistant의 텍스트만 저장하지 않는다는 점이다. tool-call과 tool-result의 관계를 보존해야 한다.</p>
<p>assistant가 어떤 도구를 어떤 인자로 호출했는지, tool result가 어떤 call id에 대응하는지, 그 결과를 다음 모델 호출에 어떤 형태로 복원할지를 저장소가 잃어버리면 안 된다.</p>
<p>그래서 메시지 저장에는 다음 필드를 둔다.</p>
<pre><code class="language-plaintext">parts
tool_calls
tool_call_id
tool_name
</code></pre>
<p>AI SDK가 provider별 변환을 상당 부분 맡아주더라도, 저장소는 provider가 바뀌어도 복원 가능한 중립 구조를 유지해야 한다. OpenAI 계열은 <code>tool</code> role을 쓰고, Anthropic 계열은 user/assistant content block 안에 tool result를 묶는다. 표면 형식은 달라도 보존해야 하는 의미는 같다.</p>
<blockquote>
<p>assistant가 어떤 도구를 요청했고, 앱이 어떤 결과를 돌려줬는가.</p>
</blockquote>
<p>이 페어를 보존하는 것이 메시지 저장의 핵심이었다.</p>
<hr />
<h2>최종 답변과 진행 상태를 분리했다</h2>
<p>스트리밍 UI에서 흔히 어색해지는 지점이 있다.</p>
<blockquote>
<p>먼저 문서를 검색해볼게요. 관련 내용을 확인해보겠습니다. 이제 답변드리겠습니다.</p>
</blockquote>
<p>콘솔에서는 괜찮다. 하지만 제품 UI에서는 내부 진행 로그가 최종 답변 안에 섞인다. 사용자는 답변을 읽고 싶은데, 메시지는 도구 실행 일지를 보여준다.</p>
<p>그래서 응답 본문과 진행 상태를 분리했다.</p>
<p>백엔드는 SSE 이벤트를 별도로 보낸다.</p>
<pre><code class="language-plaintext">start
activity
text-delta
finish
error
</code></pre>
<p>도구 호출은 <code>activity</code> 이벤트로 흘리고, assistant 본문에는 최종 답변만 남긴다. 프론트에서는 내부 도구명을 그대로 보여주지 않고 사용자에게 자연스러운 상태로 바꾼다.</p>
<pre><code class="language-plaintext">자료 확인 중
자료 확인 완료
답변 정리 중
</code></pre>
<p>시스템 프롬프트에도 같은 원칙을 넣었다. <code>searchDocuments</code>, <code>readDocument</code>를 사용했다는 사실을 답변 본문에서 과정 설명처럼 쓰지 않는다. 진행 상태는 activity 이벤트가 담당하고, 최종 답변은 결론과 근거만 담는다.</p>
<p>이 분리는 작지만 제품감에 영향을 크게 줬다. 도구 호출이 드러나지 않는 것이 아니라, <strong>도구 호출이 있어야 할 채널로 이동한 것</strong>이다.</p>
<hr />
<h2>개인 소개 AI는 톤도 기능이다</h2>
<p>이 어시스턴트는 일반 지식 챗봇이 아니다. 공개 프로필 사이트에 붙어 있고, 사용자는 나에 대해 묻는다. 따라서 답변 품질은 사실성만으로 결정되지 않는다.</p>
<p>예를 들어 "단점이 뭐예요?"라는 질문은 단순 정보 검색이 아니다. 너무 방어적으로 답하면 신뢰가 떨어지고, 자기비하처럼 답하면 공개 사이트에 붙은 AI로서 부적절하다.</p>
<p>그래서 단점 답변의 원칙을 별도로 잡았다.</p>
<pre><code class="language-plaintext">강점의 반대편
-&gt; 주의할 점
-&gt; 보완 방식
-&gt; 잘 맞는 환경
</code></pre>
<p>단점을 숨기지는 않는다. 다만 결함처럼 단정하지 않고, 업무 스타일과 강점의 반대편에 있는 특성으로 설명한다. 그리고 실제 보완 방식을 함께 말한다.</p>
<p>이건 미화가 아니라 맥락화다. 개인 소개 AI는 일종의 대리 커뮤니케이션이다. 사용자는 "이 사람이 어떤 사람인가"를 묻고 있고, 답변은 문서 기반이어야 하면서도 공개 프로필의 맥락을 잃지 않아야 한다.</p>
<hr />
<h2>시간에 따라 변하는 정보는 문서에서 빼냈다</h2>
<p>이력서 기반 AI에서 자주 어긋나는 값이 경력 연차다.</p>
<p>문서에는 "14년", "16년차" 같은 정적 표현이 들어갈 수 있다. 하지만 시간이 지나면 틀린 정보가 된다. 문서를 수정하지 않는 한 모델은 오래된 숫자를 계속 인용한다.</p>
<p>그래서 현재 날짜와 경력 계산은 문서가 아니라 runtime context로 분리했다.</p>
<pre><code class="language-plaintext">현재 날짜: Asia/Seoul 기준 YYYY-MM-DD
경력 시작 연도: 2010년
현재 날짜 기준 계산된 경력: 현재 연도 - 2010
</code></pre>
<p>그리고 답변 규칙은 이렇게 잡았다.</p>
<pre><code class="language-plaintext">경력 연차를 답변할 때는 문서의 정적 표현보다
runtime context의 계산값을 우선 사용한다.
가능하면 "2010년부터 현재까지 약 N년"처럼 기준을 함께 설명한다.
</code></pre>
<p>stable한 지식과 volatile한 실행 정보를 분리한 것이다. 모든 정보를 documents table에 넣어두면 편하지만, 시간이 흐르면서 틀어지는 값은 호출 시점에 주입하는 편이 낫다.</p>
<hr />
<h2>공개 페이지에 필요한 최소 운영 장치</h2>
<p>작은 기능이어도 공개 페이지에 붙는 AI라면 최소 운영 장치가 필요하다.</p>
<p>이번 재설계에서 넣은 운영 장치는 크지 않다.</p>
<ul>
<li><p>IP 기준 요청 제한</p>
</li>
<li><p>IP 기준 동시 스트림 제한</p>
</li>
<li><p>세션별 active run lock</p>
</li>
<li><p>SSE heartbeat</p>
</li>
<li><p>스트림 취소 시 guard release</p>
</li>
<li><p>provider/model/usage/duration 저장</p>
</li>
<li><p>대화가 80,000자 이상 길어졌을 때 새 대화 권장 warning</p>
</li>
</ul>
<p>특히 세션별 active run lock은 중요했다. 같은 thread에서 동시에 두 답변이 생성되면 메시지 순서가 꼬일 수 있다. 그래서 session에 <code>active_run_id</code>, <code>active_run_started_at</code>을 두고, 이미 생성 중인 답변이 있으면 409로 막았다.</p>
<p>모델 메타데이터도 메시지에 남긴다. 어떤 provider와 model이 답했는지, 얼마나 걸렸는지, usage는 어땠는지 남겨야 나중에 품질과 비용을 감으로 보지 않는다.</p>
<p>처음에는 metadata에 대충 넣을 수도 있었다. 하지만 운영에서 반복해서 볼 값은 컬럼으로 빼는 편이 낫다. 최근에는 메시지 metadata를 단순화하고 <code>provider</code>, <code>model</code>, <code>duration_ms</code>, <code>elapsed_ms</code>, <code>usage</code> 같은 필드만 명확히 남기는 쪽으로 정리했다.</p>
<hr />
<h2>걷어낸 것과 넣지 않은 것</h2>
<p>이번 작업에서는 오래된 Mastra 기반 career agent 경로를 걷어내고, 기존 어시스턴트를 assistant-agent 독립 런타임으로 정리했다.</p>
<p>Mastra가 나쁘다는 뜻은 아니다. 처음 실험하기에는 좋은 추상화다. 다만 <a href="https://www.dongjun.win/">dongjun.win</a>의 요구사항은 작았다.</p>
<pre><code class="language-plaintext">1. 질문을 받는다.
2. 필요한 문서를 도구로 연다.
3. 답변을 스트리밍한다.
4. 메시지와 도구 결과를 저장한다.
5. UI에 진행 상태를 보낸다.
</code></pre>
<p>이 정도라면 AI SDK의 <code>streamText</code>, 작은 tool set, DAO 몇 개로 직접 구성하는 편이 더 설명 가능했다.</p>
<p>걷어낸 것만큼, 일부러 넣지 않은 것도 있다. 대표적으로 컨텍스트 압축이다.</p>
<p>Hermes Agent에서 컨텍스트 압축은 꽤 정교한 기능이다. 오래된 대화를 요약하고, 앞뒤 메시지를 보존하고, role alternation과 tool-call/result 페어를 깨지 않도록 처리해야 한다. 구현할 가치는 있지만, 모든 에이전트에 필요한 기능은 아니다.</p>
<p><a href="https://www.dongjun.win/">dongjun.win</a>의 어시스턴트는 사용자가 하루 종일 붙잡고 작업하는 코딩 에이전트가 아니다. 방문자가 몇 가지 질문을 던지고 떠나는 공개 프로필 기능이다. 그래서 자동 압축 대신 80,000자 이상이면 새 대화를 권장하는 warning만 둔다.</p>
<p>공부한 것을 전부 넣지 않는 것도 설계다. 필요한 전제만 가져오고, 기능은 문제 크기에 맞게 자른다.</p>
<hr />
<h2>최종 구조</h2>
<p>결과적으로 남은 흐름은 이렇게 정리된다.</p>
<pre><code class="language-plaintext">사용자 질문
-&gt; Vue chat store
-&gt; /api/assistant/chat SSE 요청
-&gt; request guard
-&gt; thread/session resolve
-&gt; session run lock
-&gt; user message 저장
-&gt; active config + system prompt 로드
-&gt; runtime context 추가
-&gt; streamText 호출
   -&gt; searchDocuments
   -&gt; readDocument
   -&gt; 최대 5 step
-&gt; assistant/tool messages 저장
-&gt; text-delta/activity 이벤트 스트리밍
-&gt; finish
-&gt; run lock release
</code></pre>
<p>제품으로 보면 채팅창 하나다. 내부적으로는 에이전트 런타임의 기본 요소가 거의 다 들어 있다.</p>
<ul>
<li><p>stateless LLM 호출</p>
</li>
<li><p>누적 messages</p>
</li>
<li><p>tool calling</p>
</li>
<li><p>tool-call/result 페어 보존</p>
</li>
<li><p>thread/session/message 저장</p>
</li>
<li><p>runtime context</p>
</li>
<li><p>streaming</p>
</li>
<li><p>activity event</p>
</li>
<li><p>request guard</p>
</li>
<li><p>model/usage/duration 관측</p>
</li>
</ul>
<p>규모는 작지만 구조는 작지 않았다.</p>
<hr />
<h2>작게 만들수록 선명해진 것</h2>
<p>이번 작업에서 남은 결론은 단순하다.</p>
<blockquote>
<p>에이전트는 모델 하나가 아니라, stateless LLM 앞뒤에서 messages와 tools를 정확히 관리하는 런타임이다.</p>
</blockquote>
<p>여기서 중요한 단어는 "정확히"다.</p>
<p>messages를 대충 저장하면 기억이 흔들린다. tool-call과 tool-result를 대충 다루면 다음 호출이 깨진다. 문서 접근 경로를 prompt에 묻어두면 느리고 흐려진다. 진행 상태와 최종 답변을 섞으면 UI가 지저분해진다. 시간이 흐르는 값을 문서에 박아두면 답변이 낡는다.</p>
<p>반대로 경계를 잘 나누면 작은 에이전트도 안정적으로 동작한다.</p>
<ul>
<li><p>문서는 prompt가 아니라 tool로 연다.</p>
</li>
<li><p>대화 상태는 messages로 복원한다.</p>
</li>
<li><p>현재 시점 정보는 runtime context로 넣는다.</p>
</li>
<li><p>진행 상태는 activity event로 보낸다.</p>
</li>
<li><p>운영 판단에 필요한 값은 컬럼으로 남긴다.</p>
</li>
</ul>
<p>이번 <a href="https://www.dongjun.win/assistant">dongjun.win AI</a>는 범용 에이전트가 아니다. 내 문서를 읽고, 내 경력과 프로젝트에 대해 답하는 좁은 에이전트다. 그런데 오히려 그 좁음 덕분에 구조가 선명해졌다.</p>
<p>큰 에이전트를 잘 만들려면 작은 에이전트부터 설명 가능해야 한다. 이번 작업은 그 기준을 맞추는 과정이었다.</p>
<p><a href="https://www.dongjun.win/">www.dongjun.win</a>은 이제 단순한 포트폴리오가 아니라, 내가 최근에 어떤 구조를 공부했고 그것을 제품 안에 어떻게 녹였는지 보여주는 작은 데모가 됐다. 직접 확인해보고 싶다면 <a href="https://www.dongjun.win/assistant">AI 어시스턴트 페이지</a>에서 질문을 던져보면 된다.</p>
]]></content:encoded></item><item><title><![CDATA[내 실생활에 AI 더하기 (1) — 사진, 영상 하이라이트 만들기]]></title><description><![CDATA[폰 사진 앱을 켜다가 여행 영상 폴더 앞에서 매번 멈춘다.
문제는 "안 본다"가 아니라 "안 보게 된다"였다. 분명히 좋아서 찍었는데, 시간이 지나니 불필요한 컷이 너무 많아서 다시 들어가기가 부담스러운 폴더가 된다. 핵심 장면만 추린 2~3분짜리 메모리 필름이 있다면 한 번에 그 시간을 다시 만날 수 있을 것 같았다.
업무에서는 AI를 매일 많이 쓴다. ]]></description><link>https://blog.dongjun.win/real-life-ai-1-media-highlight</link><guid isPermaLink="true">https://blog.dongjun.win/real-life-ai-1-media-highlight</guid><category><![CDATA[AI]]></category><category><![CDATA[skills]]></category><category><![CDATA[media-highlight]]></category><category><![CDATA[workflow]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Wed, 13 May 2026 20:58:37 GMT</pubDate><content:encoded><![CDATA[<p>폰 사진 앱을 켜다가 여행 영상 폴더 앞에서 매번 멈춘다.</p>
<p>문제는 "안 본다"가 아니라 "안 보게 된다"였다. 분명히 좋아서 찍었는데, 시간이 지나니 불필요한 컷이 너무 많아서 다시 들어가기가 부담스러운 폴더가 된다. 핵심 장면만 추린 2~3분짜리 메모리 필름이 있다면 한 번에 그 시간을 다시 만날 수 있을 것 같았다.</p>
<p>업무에서는 AI를 매일 많이 쓴다. 코드 리뷰, 설계 토론, 디버깅, 문서 정리. 거의 모든 작업이 AI와 함께 굴러간다. 그런데 <em>실생활</em>은 그렇지 않다. 사진 검색이나 메모 정리에 가끔 도움을 받는 정도, 일회성으로 묻고 답을 받는 수준에서 멈춰 있다.</p>
<p>이 간극이 이상했다. 같은 사람이 같은 도구를 쓰는데, 업무에서는 <em>체계적으로</em> 굴리고 실생활에서는 <em>그때그때 한 번씩만</em> 부른다. 도구의 문제도 아니고 능력의 문제도 아니다. <em>어디에 어떻게 적용할지를 한 번도 진지하게 생각해본 적이 없는</em> 게 문제였다.</p>
<p>이 글은 그 선을 처음 넘은 기록이다. 여행 영상 폴더부터 본격적으로 AI에게 맡기기 시작하면서, 어떤 데이터부터 시작할지 정하고, 어떻게 AI와 일할지 다듬은 과정. 그리고 그 과정에서 작은 피드백 한 줄이 <em>재사용 가능한 규칙</em>으로 굳어진 이야기다.</p>
<hr />
<h2>0번 후보 — 안 보면 잃는 데이터부터</h2>
<p>후보는 여러 개였다. 가계부 자동화, 이메일 정리, 일정 관리, 사진 백업, 손글씨 메모 정리. 우선순위를 정해 보니 답이 빨리 나왔다.</p>
<p>가계부와 이메일은 <em>이미 안 본 채로도</em> 시스템이 돌아간다. 안 보면 약간 불편하지만 잃는 게 없다. 사진은 이미 클라우드에 백업돼 있다. 그런데 여행 영상은 안 보면 <em>기억이 사라진다.</em> 한 번의 여행은 다시 못 오고, 그 기록이 풀리지 않은 채 폴더에 쌓이면 결국 잊힌다.</p>
<p>AI를 실생활에 도입하자는 막연한 동기를 구체화할 때 가장 단단한 기준은 <em>그것이 없으면 잃는 게 무엇인가</em>였다. 여행 영상이 0번 후보로 떠올랐다.</p>
<hr />
<h2>들고 들어간 설계 윤곽</h2>
<p>백지에서 시작하지는 않았다. 영상의 후보 컷을 <em>시각 신호</em>(구도·노출·중복 제거·활동 분포)와 <em>음성 신호</em>(STT 의미)로 점수 매겨 고르고, GPS 메타데이터로 위치 라벨을 묶는다 — 이 정도 설계 윤곽은 이미 머릿속에 있었다. 특히 STT를 <em>자막용</em>만이 아니라 <em>어느 컷이 의미 있는지를 판단하는 선별 신호</em>로도 쓴다는 것까지.</p>
<p>그 위에서 AI에게 맡긴 것은 디테일이다. 어느 컷이 <em>진짜</em> 좋은가. BGM은 어디서 어떤 톤으로 가져올까. 자막은 어떤 호흡으로 띄울까. 위치 라벨은 어디까지 광범위해야 <em>맵 앱</em>처럼 보이지 않을까. 이런 디테일은 <em>문서 한 줄로 정의되지 않는다.</em> 결과를 보고 <em>"이건 이상해"</em> 라는 한 줄을 던지면서 깎아내야 한다.</p>
<p>이 글의 본문은 그 <em>깎아내기</em>의 기록이다. 설계는 들고 들어갔고, 다듬기는 AI와 함께 했다.</p>
<hr />
<h2>도구 선택 — 평소에 쓰는 환경 안에서</h2>
<p>도구는 짧게 결정됐다. 평소에 쓰는 Codex CLI 안에 <em>스킬</em> 형태로 만든다. 매일 켜는 도구가 아니면 결국 안 쓰게 된다. 새 UI를 따로 만들 이유가 없었다.</p>
<p>스킬 형태가 주는 이점은 명확했다. 의존성 관리(Homebrew, ffmpeg-full, whisper-cpp)를 스킬 안에 박아두니 다른 컴퓨터에서도 그대로 돈다. 매 단계 결과를 프롬프트로 검증할 수 있다. <em>"이 장면은 왜 골랐어?"</em> 하면 Codex가 답한다. 그리고 한 번 만든 규칙이 <em>재사용 가능한 자산</em>으로 남는다. 다음 폴더, 그 다음 폴더에서도 같은 규칙이 자동으로 작동한다.</p>
<p>이게 일회성 작업과 <em>스킬 만들기</em>의 결정적 차이다. 일회성으로 한 폴더 처리하는 건 누구나 한다. 같은 작업을 <em>다음 폴더에도</em> 자동으로 시키려면 규칙을 어딘가에 박아둬야 한다. 스킬은 그 어딘가의 가장 깔끔한 형태였다.</p>
<hr />
<h2>작업 방식 — Diagnose → Execute → Verify</h2>
<p>여러 폴더에 반복 적용하면서 같은 루프가 매번 돌았다. 각 폴더마다 한 사이클씩.</p>
<pre><code class="language-plaintext">1. Diagnose  — 결과를 보고, 무엇이 잘못됐는지 한 줄로 말한다
2. Execute   — Codex에게 그 한 줄을 던진다. 구현은 맡긴다
3. Verify    — 다시 렌더링해서 같은 문제가 해결됐는지, 다른 게 망가지지 않았는지 본다
</code></pre>
<p>한 루프가 평균 30분 안에 돌았다. 이 사이클이 잘 돌아가게 만드는 건 사실 <em>한 줄로 문제를 말할 수 있는 능력</em>이다. 길게 설명할수록 AI는 길게 헤맨다. 짧고 정확한 진단이 가장 빠른 코드를 만든다.</p>
<p>이 루프를 돌면서 세 가지가 분명해졌다.</p>
<h3>1. 구현을 맡길수록 결과가 더 좋아졌다</h3>
<p>*"BGM을 어떻게 깔지"*는 결정하지 않았다. *"BGM이 너무 이상해"*만 말했다. ffmpeg 옵션을 어떻게 짤지, 어떤 트랙을 어디서 받을지는 Codex가 골랐다. 다만 결과 영상이 <em>어떻게 들리는가</em>는 내가 판단했다.</p>
<p>이게 의외로 중요했다. 구현까지 내가 지시하면 AI는 그 지시 안에서만 움직였다. *"BGM 볼륨을 0.1로 설정해줘"*라고 하면 0.1로 설정해 줬는데, 그게 좋은 결과인지는 따로 확인해야 했다. 반대로 *"BGM이 너무 시끄러워"*라고 하면 AI가 톤·맥락·믹스까지 같이 봤다. 그 쪽에서 더 좋은 결과가 자주 나왔다.</p>
<p>결정은 외주했지만, 결과를 평가하는 책임은 내가 가지고 있었다. 결과를 평가할 능력만 있으면 됐고, 구현 디테일까지 다 알 필요는 없었다.</p>
<h3>2. 내가 알려주지 않으면 AI도 모르는 영역이 있었다</h3>
<p>생성형 AI에게 <em>완전히 모르는 영역</em>을 맡기면 결과가 흔들렸다. BGM 선택이 그랬다. Codex가 무료 BGM을 검색해서 깔면 매번 어색했다. 그래서 직접 후보 7곡을 던졌다.</p>
<blockquote>
<p><em>Ikson - Sunny, Scandinavianz - Vacation, Scandinavianz - Sunny Island, MBB - Feel Good, LiQWYD - Feel, LiQWYD - Free, Joakim Karud - Dreams.</em></p>
</blockquote>
<p>모두 YouTube Audio Library에서 라이선스 확인 가능한 트랙이었다. 이 7곡을 스킬의 <em>기본 풀</em>로 박았다. 이후로는 Codex가 영상 톤을 보고 풀에서 한 곡 골라서 다운받고 렌더에 넣었다. 추가 승인 없이.</p>
<p>AI가 못 하는 게 아니라, <em>내가 알고 있는 것을 안 알려주면 AI도 모르는</em> 거였다. 도메인 지식을 처음에 한 번 명시적으로 주입하는 게 작업의 질을 결정했다. 그리고 그걸 <em>스킬에 박아두면</em> 다음 작업부터는 다시 안 알려줘도 됐다.</p>
<h3>3. 숫자로 받은 자기보고가 가장 단단했다</h3>
<p>가장 위험한 패턴이 *"네, 다 처리했습니다"*였다. 정확히 무엇을 어떻게 처리했는지 알 수 없었다.</p>
<p>그래서 스킬 안에 <em>숫자로 보고하라</em>는 규칙을 박았다. 사진 800장을 처리했으면 <em>"812장 중 readable 810장, 컨택트 시트 13장 생성, 47장 선택, 8장 강한 후보로 표시"</em> 같이. 영상 QA를 했으면 <em>"ffprobe 결과 12분 34초, blackdetect에서 1.5초 fade 외 검은 프레임 없음, silencedetect 0건"</em> 같이.</p>
<p>숫자로 말하면 거짓말이 어려웠다. 내가 의심할 지점도 명확해졌다. <em>"검토했어요"</em> 다음에는 더 물어볼 게 없었는데, <em>"812장 중 47장 선택"</em> 다음에는 "왜 47장만?" 같은 다음 질문이 자연스럽게 따라왔다. 검증 가능한 진술만 받는 것 — 이게 AI에게 일을 맡길 때 가장 단단한 안전망이었다.</p>
<hr />
<h2>짧은 피드백이 영구 규칙으로 — 다섯 가지 변곡점</h2>
<p>위 작업 방식이 만든 가장 중요한 결과는, <em>한 줄짜리 피드백이 SKILL.md의 한 단락으로 영구히 박힌다</em>는 점이었다. 한 폴더에서 본 문제가 다음 폴더에서 안 보이는 규칙이 된다. 통영, 정리, 북한산, 페낭, 랑카위, 심천, 여수, 세부까지 10개 가까운 폴더를 거치며 다섯 개의 큰 변곡점이 그렇게 박혔다.</p>
<h3>변곡점 1 — 구도 거부 규칙</h3>
<p>첫 진짜 피드백은 화면 전환 자체였다.</p>
<blockquote>
<p>"1분에서 2분 정도까지는 구도가 이상한데도 하이라이트에 들어갔고, 화면 전환이 너무 자주 깜빡이면서 되니깐 보기가 힘드네."</p>
</blockquote>
<p>이 한 줄에서 두 규칙이 나왔다. 가린 렌즈, 다리만 찍힌 컷, 어두운 주머니 샷 같은 <em>카메라 핸들링 흔적</em>은 기술적으로 선명해도 거부한다. 짧은 클립이 연속해서 깜빡이는 걸 막기 위해 <code>xfade</code>와 <code>acrossfade</code>로 전환을 부드럽게 잡는다.</p>
<p>두 규칙이 그날 SKILL.md에 박혔다. 다음 폴더(정리)부터는 같은 문제가 안 나왔다. 한 폴더에서 던진 한 줄이, 다음 폴더에서 자동으로 작동했다.</p>
<h3>변곡점 2 — STT 자막을 단어 단위로</h3>
<p>영상을 보는데 자막 타이밍이 어긋났다.</p>
<blockquote>
<p>"자막이 보이스 나오는 속도보다 빠르게 나오는 거 같은데." "STT 단어 단위로 뽑고 하는 게 가능해?"</p>
</blockquote>
<p>문장 단위 STT는 타이밍이 어색했다. 단어 단위 타이밍을 받아서 <em>문장 자막의 시작·끝을 재조정</em>하는 방식으로 바꿨다.</p>
<p>STT 모델도 그날 굳었다. <code>whisper-cpp</code>의 <code>ggml-medium.bin</code> 멀티링구얼. 한국어 품질이 <code>tiny</code>나 <code>base</code>로는 부족했다. 스킬에는 *"silently downgrade 금지"*까지 명시했다. Codex가 임의로 가벼운 모델로 바꿔서 한국어 자막 품질을 떨어뜨리는 일을 막기 위해서다.</p>
<p>자막에 대한 또 하나의 원칙도 같이 박혔다. <strong>자막은 메모리 큐이지 전사가 아니다.</strong> 음성을 다 자막으로 박지 않는다. 지명, 반응, 결정, 감정적 코멘트만 짧게 남긴다. 나머지는 그냥 원본 오디오로 듣는다.</p>
<h3>변곡점 3 — BGM은 만들지 않는다, 골라서 쓴다</h3>
<p>세 번째 피드백은 BGM이었다.</p>
<blockquote>
<p>"BGM 너무 이상해." "아예 생성 BGM은 쓰지 마."</p>
</blockquote>
<p>LLM이 BGM 분위기를 <em>생성</em>해주려는 시도는 전부 어색했다. 그래서 도메인 지식 원칙대로 <em>직접 후보 7곡</em>을 던졌고, 그 풀이 스킬의 기본 자산으로 박혔다. 이후로는 Codex가 영상 톤을 보고 풀에서 한 곡 골라서 다운받고 렌더에 넣는다.</p>
<p>볼륨 정책도 박혔다. 원본 오디오/스피치가 중요한 영상은 <code>bgm_volume 0.08~0.14</code>, 음악 중심 몽타주는 <code>0.14~0.22</code>. 숫자로 박으니 Codex가 매번 헤매지 않는다.</p>
<p>여기서 더 큰 원칙이 굳었다. <strong>원본 오디오를 보존한다.</strong> 파도 소리, 발걸음, 도시 소음, 웃음, 반응. 이게 추억의 <em>진짜 앵커</em>다. BGM은 그 위에 얇게 깔리는 보조 레이어지 주역이 아니다.</p>
<h3>변곡점 4 — 광범위 위치 자막</h3>
<blockquote>
<p>"장소가 있는 건 장소를 자막으로 보여주면 좋을 것 같거든. 상세한 장소까지는 아니고 지역 이름 정도만."</p>
</blockquote>
<p>GPS 메타데이터가 있는 영상은 위치를 자막으로 띄울 수 있었다. 다만 정확한 식당 이름이나 호텔 이름까지 박으면 메모리 필름이 <em>맵 앱</em>처럼 느껴진다. 광범위 라벨, 즉 <em>Da Lat, Nha Trang, Seoul, Osaka</em> 수준만 쓴다.</p>
<p>근거 우선순위도 정했다. 영상 GPS → 인접 사진 EXIF(타임스탬프 매칭) → STT 지명 언급 → 파일명 → 화면 속 표지판. <code>(0,0)</code> 좌표는 무효. GPS 없는 영상은 인접 시각의 사진 GPS로 추론한다.</p>
<h3>변곡점 5 — 작은 실패가 만든 메타데이터 규칙</h3>
<p>통영 영상에서 한 컷에 *"통영 2일차"*라는 자막이 박혔다. 보고 던진 말은 짧았다.</p>
<blockquote>
<p>"1일차 영상은 없는 거야?"</p>
</blockquote>
<p>LLM이 임의로 *"2일차"*라는 내러티브를 만들어 박은 거였다. 1일차 폴더는 처음부터 없었으니 2일차도 있을 수 없다. <strong>소스 메타데이터에 없는 표현은 제목·자막에 넣지 않는다.</strong> 이 규칙이 그날 박혔다.</p>
<p>이런 작은 실패가 가장 중요했다. 큰 구조는 며칠 만에 잡혔지만, <em>디테일 한 줄</em>은 매번 실패해야 보였다. 한 줄이 박힐 때마다 스킬은 다음 폴더에서 더 조용히 일했다.</p>
<hr />
<h2>2단계 QA — "파일 만들어졌어요"가 곧 완료는 아니다</h2>
<p>마지막에 박힌 게 <em>QA 단계</em>였다.</p>
<p>처음에는 렌더링이 끝나면 그게 완료였다. 그러면 안 됐다. 중간에 무음 구간이 끼었고, 마지막 1초가 잘렸고, 자막이 영상 끄트머리를 잘랐다. 매번 영상을 끝까지 봐야 잡혔다. 그러면 <em>재사용 가능한 스킬</em>의 의미가 없다.</p>
<p>QA를 두 층으로 박았다.</p>
<p><strong>Technical QA.</strong> <code>ffprobe</code>로 코덱·해상도·fps·오디오 채널 확인. <code>blackdetect</code>로 의도치 않은 검은 프레임. <code>silencedetect</code>로 긴 무음 구간. <code>volumedetect</code>로 클리핑이나 너무 작은 오디오. 그리고 <em>타임라인 컨택트 시트</em>, 12초마다 한 프레임씩 뽑아 4×6 격자로 한 장에 모은다.</p>
<p><strong>Memory-highlight QA.</strong> 소스 시간 순서와 결과 클립 순서가 일치하는가. 모든 중요한 시점·장소가 표현됐는가. 위치 라벨이 GPS·인접 증거에 근거하는가. 자막이 메모리 큐인가 전사인가. 반복되는 컷이 빠졌는가.</p>
<p>두 단계 모두 <em>명시적으로 보고</em>하게 했다. *"QA 했어요"*가 아니라 <em>"ffprobe 결과는 X, blackdetect에서 1.5초 fade 외에는 검은 프레임 없음, 12개 클립 모두 시간순 일치"</em> 같이. 앞서 정리한 <em>"자기보고는 숫자로"</em> 원칙이 QA에서도 그대로 작동했다.</p>
<p>이 QA가 박힌 뒤로는 한 폴더당 <em>내가 봐야 하는 시간</em>이 5분에서 30초로 줄었다. AI에게 일을 맡길 때 결국 가장 중요한 게 <em>검증 자동화</em>다. 자동화된 검증이 없으면 매번 사람이 결과를 끝까지 봐야 하고, 그러면 도구가 아니라 <em>수동 작업의 다른 이름</em>에 가까워진다.</p>
<hr />
<h2>사진까지 — media-highlight로 분기</h2>
<p><code>video-highlight</code>가 안정되자 다음 한계가 보였다. 여행은 영상만이 아니다. <strong>사진이 훨씬 많다.</strong> 베트남 폴더에는 영상 30개에 사진 800장. 영상만으로 만든 하이라이트는 분명히 작동했지만, <em>내가 가장 잘 찍은 한 장의 풍경</em>은 한 번도 메모리 필름에 들어가지 못했다.</p>
<blockquote>
<p><em>"여행이라는 게 동영상뿐만 아니라 사진도 엄청 많이 찍자나… 둘 다 합쳐서 하나의 하이라이트… 일단 기존 스킬은 냅두고 새롭게 만들어보자."</em></p>
</blockquote>
<p>이 한 줄에서 <a href="https://github.com/mayajuni/mayajuni-harness/tree/main/catalog/skills/media-highlight"><code>media-highlight</code> 스킬</a>이 분기됐다. <em>기존 스킬을 망가뜨리지 않기 위해</em> 새 스킬로 갔다. 검증된 워크플로우를 건드리지 않는 게 가장 안전한 확장이었다.</p>
<p>새 스킬은 폴더를 보고 세 모드 중 하나를 고른다. photo-only, video-only, 또는 mixed. mixed 모드에서는 한 타임라인에 사진과 영상 아이템이 시간 순으로 섞인다. 베트남 첫째 날 풍경 사진 3장 → 시장 영상 8초 → 음식 사진 2장. 이 흐름이 자연스럽게 짜인다.</p>
<p>사진 정책의 핵심은 <strong>exhaustive accounting</strong>이다. 모든 사진을 적어도 기술 메타데이터·품질 점수·중복 그룹·컨택트 시트로 한 번씩 본다. <em>"800장 모두 의미적으로 이해했다"</em> 같은 모호한 자기보고는 금지. 대신 <em>"812장 중 47장 선택"</em> 같은 숫자 보고만 허용. 앞서 정리한 <em>"자기보고는 숫자로"</em> 원칙이 여기서도 그대로 적용됐다.</p>
<p>오디오 정책도 갱신됐다. 영상 구간에는 원본 오디오 위에 BGM을 얇게 깔고, 사진 구간에서는 BGM이 살짝 올라온다. 전환은 <code>acrossfade</code>로 0.5~0.8초 겹쳐서 오디오 컷을 없앤다. <em>"현장음이 있을 땐 현장음, 사진이 나올 땐 BGM, 자연스럽게."</em> 이 한 줄이 정책이 됐다.</p>
<p>부수 효과도 있었다. 영상은 GPS가 박힌 경우가 드문데, 사진은 거의 모든 폰 사진에 EXIF GPS가 있다. <em>영상 GPS가 없으면 인접 시각의 사진 GPS로 위치를 추론</em>하는 흐름이 자연스럽게 강해졌다. 사진을 같이 보면 영상의 위치 추론도 더 정확해진다.</p>
<hr />
<h2>반복 사이클이 만든 것</h2>
<p><code>video-highlight</code> SKILL.md는 251줄, <code>media-highlight</code>는 208줄이다. 그중 다수가 위 변곡점들로부터 한 줄씩 박혀 만들어졌다. 한 폴더에서 본 한 가지 문제가, 다른 폴더에 가서도 안 보이게 만드는 규칙이 된다.</p>
<p>지금은 이렇다. 폴더 하나 던지면 2~3분짜리 메모리 필름이 나온다. 베트남, 몰디브, 인도네시아, 페낭, 여수, 세부, 다 만들어졌다. 그리고 그것들을 실제로 <em>다시 본다.</em> 가족에게도 보여준다. 폴더에 잠들어 있던 800장 사진과 30개 영상이, 3분짜리 한 편이 되어 <em>기억으로 돌아왔다.</em></p>
<p>업무에서 쓰던 AI 활용 패턴(Diagnose → Execute → Verify, 짧은 피드백, 숫자 기반 검증)이 실생활에서도 그대로 작동했다. 다만 <em>측정 지표가 달라졌다.</em> 업무에서는 정답률이나 recall이지만, 여기서는 <em>내가 다시 보는가</em>다. 더 단순하고 더 정직한 지표였다.</p>
<p>이게 LLM 시대의 <em>스킬 만들기</em>에서 가장 흥미로운 점이었다. 처음부터 완벽한 SKILL.md를 짤 수는 없다. <strong>한 줄짜리 피드백 30개가 폴더를 거치며 쌓여 만드는 것</strong>에 가깝다. 그러려면 한 가지가 필요하다. <em>한 줄로 문제를 말할 수 있는 사람.</em> 그게 거의 전부다.</p>
<hr />
<h2>끝나고 보니 — 다섯 가지가 남았다</h2>
<p>이 작업을 거치면서 내가 알게 된 게 다섯 가지였다. 권유라기보다는, 같은 자리에서 시작해 보려는 사람과 나누고 싶은 회고에 가깝다.</p>
<p><strong>1. 안 보면 잃는 게 가장 큰 데이터가 0번 후보였다.</strong> *"AI를 어디에 쓸까"*보다 *"안 보면 무엇을 잃는가"*가 더 단단한 기준이었다. 기능이 많은 데이터가 아니라 잃을 게 큰 데이터가 먼저였다.</p>
<p><strong>2. 평소에 쓰는 도구 안에 들어가지 않으면 결국 안 쓰게 됐다.</strong> 별도 앱은 그것까지 따로 켜야 하는 도구가 된다. 매일 켜는 환경 안에 자연스럽게 들어왔을 때 비로소 굴러갔다.</p>
<p><strong>3. 한 줄로 진단할 수 있을 때 사이클이 가장 빠르게 돌았다.</strong> *"BGM을 0.1로 설정해줘"*보다 *"BGM이 너무 시끄러워"*가 더 좋은 결과를 냈다. 구현은 맡기고, 결과를 평가할 능력만 내가 가지고 있으면 됐다.</p>
<p><strong>4. 숫자가 박힌 자기보고가 가장 단단한 안전망이었다.</strong> *"검토했어요"*는 검증할 수 없었지만, *"812장 중 47장 선택"*은 검증할 수 있었고 다음 질문도 따라왔다.</p>
<p><strong>5. 한 번의 작업을 스킬로 굳히는 순간, AI가 진짜 내 도구가 됐다.</strong> 한 폴더에서 효과를 본 규칙이 다음 폴더에서도 자동으로 작동했을 때, 비로소 <em>AI 도입</em>이라는 말이 실체를 가졌다. 그 전까지는 매번 새로 시작하는 일회성에 가까웠다.</p>
<blockquote>
<p>스킬은 한 번에 완성되지 않는다. 짧은 피드백 한 줄이 규칙으로 굳어지면서 만들어진다. 그러려면 한 줄로 문제를 말할 수 있어야 한다.</p>
</blockquote>
<hr />
<h2>앞으로의 방향</h2>
<p>여행 영상은 0번 후보였을 뿐이다. 살아가면서 또 어디에 AI가 필요한지, 무엇을 어떻게 맡기게 될지는 그때그때 마주치게 될 것 같다. 이 시리즈는 그 기록이다.</p>
<hr />
<p><strong>참고 — 실제 스킬 코드:</strong></p>
<ul>
<li><a href="https://github.com/mayajuni/mayajuni-harness/tree/main/catalog/skills/media-highlight"><code>media-highlight</code> (영상·사진 통합 하이라이트)</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (12) — Lane-based Retrieval 설계와 전체 회고]]></title><description><![CDATA[법률 QA 검색기를 만들면서 거쳐 온 설계 여정의 마지막 이야기다. 벡터 검색의 한계를 마주한 순간부터, 임베딩 선택, selector, rewriter, graph, source-router, 그리고 lane-based retrieval까지. 이 글에서는 최종 단계인 lane 구조 설계를 정리하고, 시리즈 전체를 돌아본다.

검색기 운영 설계의 최종 단계
query-prep 단계를 마무리하면서 자연스럽게 다음 질문이 떠올랐다. prerewri...]]></description><link>https://blog.dongjun.win/legal-ai-search-12-lane-based-retrieval-retrospective</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-12-lane-based-retrieval-retrospective</guid><category><![CDATA[AI]]></category><category><![CDATA[architecture]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[System Design]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Mon, 11 May 2026 23:40:56 GMT</pubDate><content:encoded><![CDATA[<p>법률 QA 검색기를 만들면서 거쳐 온 설계 여정의 마지막 이야기다. 벡터 검색의 한계를 마주한 순간부터, 임베딩 선택, selector, rewriter, graph, source-router, 그리고 lane-based retrieval까지. 이 글에서는 최종 단계인 lane 구조 설계를 정리하고, 시리즈 전체를 돌아본다.</p>
<hr />
<h2 id="heading-6rka7ioj6riwioyatoyygsdshktqs4tsnzgg7lwc7kkfioulqoqzha">검색기 운영 설계의 최종 단계</h2>
<p>query-prep 단계를 마무리하면서 자연스럽게 다음 질문이 떠올랐다. prerewriter와 source-router가 질문을 정리하고 어떤 소스를 열지 결정했다면, 그 다음은 무엇인가. 실제로 열린 소스들에서 문서를 가져오고, 정리하고, 합치는 구조를 어떻게 만들 것인가.</p>
<p>이전까지는 하나의 벡터 쿼리로 모든 컬렉션을 한 번에 검색하는 구조였다. 법령 조문, 판례, 해석례, 행정해석이 모두 같은 쿼리, 같은 점수축 위에서 경쟁했다. 이 방식의 문제는 명확했다. 조문은 요건과 효과 중심이고, 판례는 사실관계와 책임 귀속 중심이다. 같은 질문이라도 소스마다 잘 맞는 검색 표현이 다르다. 하나의 쿼리로 모든 소스를 커버하려는 시도는 결국 어딘가에서 recall 손실을 낳았다.</p>
<p>이 문제를 풀기 위해 도달한 구조가 lane-based retrieval이다.</p>
<hr />
<h2 id="heading-lane-based-retrieval">Lane-based Retrieval 개념</h2>
<p>lane-based retrieval의 핵심 아이디어는 단순하다. 소스마다 독립적인 검색 경로(lane)를 두고, 각 lane이 자기 소스에 맞는 방식으로 문서를 가져온 뒤, 후단에서 역할 기반으로 합치는 것이다.</p>
<p>전체 흐름은 아래와 같다.</p>
<pre><code>질문
-&gt; 공통 router (어떤 lane을 열지 결정)
-&gt; 병렬 lane 실행
   -&gt; lane별 쿼리 변환
   -&gt; lane별 벡터 검색
   -&gt; 필요시 lane별 그래프 검색
   -&gt; lane별 rerank 또는 selection
-&gt; 역할 기반 merge
-&gt; 조건부 final rerank
-&gt; answer
</code></pre><p>여기서 중요한 설계 판단이 몇 가지 있었다.</p>
<p>첫째, 법령 조문을 primary anchor로 둔다. 법률 QA에서 조문은 가장 기본적인 근거다. 다른 소스들은 이 anchor를 보강하는 support 역할이다.</p>
<p>둘째, 전체 후보를 하나의 점수축으로 flat merge하지 않는다. 예를 들어 여러 lane에서 각각 수십 개씩 회수하면 수백 개 이상의 raw 후보가 나온다. 이걸 한 번에 rerank하는 것은 비효율적일 뿐 아니라, 소스 역할이 다른 문서를 같은 기준으로 비교하는 것 자체가 부적절하다.</p>
<p>셋째, lane별로 먼저 정리하고, 후단에서 quota merge를 한다. 각 lane은 자기 소스 안에서 relevance를 판단한 뒤 topN을 내놓는다. 후단 merge는 이 topN들을 역할(anchor/support) 기준으로 조합한다.</p>
<p>이 구조는 RAG 분야에서 흔히 논의되는 multi-retriever fusion 패턴과 맥이 닿는다. LangChain의 MergerRetriever나 Pinecone의 two-stage retrieval 같은 접근법도 여러 검색 전략의 결과를 Reciprocal Rank Fusion(RRF) 등으로 합치는 구조를 쓴다. 다만 우리 설계에서 다른 점은, 단순히 semantic/lexical 같은 검색 방법의 차이가 아니라 소스 자체의 성격 차이를 lane 분리의 기준으로 삼았다는 것이다.</p>
<hr />
<h2 id="heading-lane-unit">Lane Unit 실험</h2>
<p>lane 구조를 세우면서 가장 먼저 부딪힌 질문은 "실험 단위를 어떻게 잡을 것인가"였다.</p>
<p>8개 lane 전부에 대해 lane-specific rewriter를 한 번에 만드는 방향을 먼저 검토했다. 결론부터 말하면, 이 방향은 잘못됐다. 여러 lane을 동시에 바꾸면 어떤 lane이 좋아졌고 어떤 lane이 망가졌는지 분리가 안 된다. rewrite만 따로 만들어서는 실제 retrieval이 개선됐는지 확인하기도 어렵다.</p>
<p>결론은 명확하다. 실험 단위는 lane 하나다. lane 내부의 rewrite, vector retrieval, 정리(selection/rerank)를 하나의 unit으로 묶어서 end-to-end로 평가한다.</p>
<pre><code>질문 <span class="hljs-number">1</span>개
-&gt; 특정 lane
-&gt; rewrite
-&gt; vector
-&gt; 정리
-&gt; lane 결과 평가
</code></pre><p>이 구조에서 비교하는 것은 세 가지다.</p>
<ol>
<li>raw query를 그대로 넣었을 때</li>
<li>공통 rewrite를 넣었을 때</li>
<li>lane-specific rewrite를 넣었을 때</li>
</ol>
<p>그리고 비교 지점도 나눈다. vector 직후 후보의 품질, lane 정리 후 후보의 품질, merge 전 lane별 recall과 coverage.</p>
<p>lane 단위 실험 프레임을 먼저 만들고, 한 lane씩 검증해 나가는 것. 8개 rewrite를 한꺼번에 만드는 것보다 이 순서가 훨씬 현실적이었다.</p>
<hr />
<h2 id="heading-precedents-lane">Precedents Lane 설계</h2>
<p>첫 실험 대상으로 precedents(판례) lane을 골랐다. 이유는 명확했다. 현재 공통 prerewriter가 law_articles 중심 성격이 강해서, 판례와의 성격 차이가 가장 크다. lane-specific rewrite의 효과를 관찰하기 가장 좋은 대상이다.</p>
<h3 id="heading-rewriter">공통 rewriter의 한계</h3>
<p>공통 prerewriter를 판례 검색에 그대로 쓰면 한계가 보였다. 공통 rewriter는 조문명, 요건, 효과, 절차 중심으로 질의를 정리하는 경향이 있다. 판례 검색에서 중요한 사실관계, 책임 귀속, 위법성 판단, 예외 적용 같은 표현이 따로 강조되지 않았다.</p>
<p>실제 검색을 확인해 보면, raw 질문을 그대로 넣으면 generic한 손해배상/구상금류 결과가 많이 섞였다. 공통 searchQuery를 넣으면 약간 더 정돈되지만 여전히 generic 판례가 많았다. 쟁점을 분리한 subQuery를 넣으면 의미가 있었지만, 잘못 만든 subQuery는 noise를 크게 늘렸다.</p>
<h3 id="heading-precedents-prerewriter">Precedents Prerewriter</h3>
<p>이 관찰에서 도출한 방향은, 판례 전용 prerewriter를 완전히 독립된 rewriter로 만드는 것이 아니라 공통 의미 구조를 받아서 판례형으로 변환하는 adapter로 두는 것이었다.</p>
<p>입력: 원문 질문 + 공통 prerewriter 결과 (queryType, searchQuery, subQueries, keywords)
출력: precedents 전용 searchQuery, keywords, 조건부 subQueries</p>
<p>판례용 searchQuery의 원칙은 조문형과 다르다. 핵심 사실관계, 책임 주체, 위법성/책임 귀속 포인트, 손해 범위 순으로 반영한다. "택배 기사 개인과 회사의 배상 책임"이나 "운송 중 사고에서 사용자 책임 성립 여부" 같은 판례형 표현이 허용된다.</p>
<p>subQueries는 더 보수적이다. single 질문이면 기본 0개, multi_issue일 때만 1~2개, 최대 2개. 판례 subQuery는 적고 날카로워야 한다.</p>
<h3 id="heading-rerank">Rerank의 필요성</h3>
<p>precedents lane에서 rerank가 특히 중요한 이유는 판례 title이 generic하기 때문이다. "손해배상(기)" 같은 제목만으로는 질문과의 관련도를 판단할 수 없다. summary와 holding을 읽어야 실제 관련도를 알 수 있다.</p>
<p>따라서 precedents lane은 small-window rerank를 기본으로 두되, 비용이 부담되면 support 모드에서는 light selection, expand 모드에서만 full rerank를 거는 fallback을 설계했다.</p>
<hr />
<h2 id="heading-7iuc66as7kaiioyghoyytcdtmozqs6a">시리즈 전체 회고</h2>
<p>이 시리즈를 시작한 건 벡터 검색의 한계를 마주한 순간이었다. 법률 도메인의 질문을 벡터 DB에 그대로 넣으면, 의미적으로 비슷해 보이지만 법적으로는 전혀 다른 문서가 상위에 올라왔다. "임대차 보증금 반환"을 질문했는데 "매매 대금 반환" 판례가 나오는 식이다. 벡터 유사도만으로는 법률 도메인의 정밀한 검색 요구를 충족할 수 없었다.</p>
<p>그래서 시작한 여정이 결국 여기까지 왔다.</p>
<p><strong>임베딩 모델 선택.</strong> 도메인 특화 임베딩을 쓸지, 범용 모델을 쓸지 고민했다. 결국 pplx-embed-v1-4b를 선택했고, 모델 자체보다 쿼리 표현의 품질이 더 중요하다는 것을 확인했다.</p>
<p><strong>Prerewriter.</strong> raw 질문을 그대로 검색에 넣는 대신, 질문의 의도를 정리하고 보조 신호를 생성하는 전처리층을 두었다. 중요한 판단은 prerewriter가 질문을 "대체"하는 것이 아니라 "보조"하는 것이라는 점이었다. raw 질문을 버리지 않는다.</p>
<p><strong>Source-Router.</strong> 여러 법률 소스 중 어떤 것을 열지 결정하는 router를 설계했다. 여러 버전을 실험하면서 확인한 핵심은 recall-priority 관점이다. 불필요한 소스를 여는 것보다, 필요한 소스를 닫아버리는 것이 훨씬 치명적이다. 최종 버전을 선택한 이유도 critical lane miss가 가장 낮았기 때문이다.</p>
<p><strong>Lane-based Retrieval.</strong> 그리고 마지막으로, 소스마다 독립적인 검색 경로를 두고 역할 기반으로 합치는 구조에 도달했다.</p>
<h3 id="heading-7zw17iusioyepoqzhcdsm5dsuzk">핵심 설계 원칙</h3>
<p>이 과정에서 확인한 설계 원칙을 정리한다.</p>
<p><strong>한 번에 모든 것을 바꾸지 않는다.</strong> prerewriter, router, filter, retrieval, rerank를 동시에 바꾸면 무엇이 효과를 냈는지 알 수 없다. 한 축만 바꾸고, 전체를 다시 평가하고, 고정하고, 다음으로 넘어간다. 이것이 유일하게 신뢰할 수 있는 방법이다.</p>
<p><strong>Recall이 precision보다 먼저다.</strong> 법률 QA에서 관련 근거를 놓치는 것은 잘못된 근거를 포함하는 것보다 위험하다. 잘못된 근거는 answer 단계에서 걸러낼 수 있지만, 놓친 근거는 복구가 안 된다.</p>
<p><strong>소스의 역할이 다르면 검색도 달라야 한다.</strong> 조문, 판례, 해석례는 같은 법률 도메인이지만 문서 구조와 검색 의도가 다르다. 하나의 쿼리로 모든 소스를 커버하려는 시도는 결국 어딘가에서 타협을 낳는다. lane 분리는 이 근본적 차이를 인정하는 설계다.</p>
<p><strong>실험 프레임이 실험보다 먼저다.</strong> 좋은 아이디어가 있어도 그것을 검증할 틀이 없으면 소용없다. lane unit 실험 러너를 먼저 만들고, 비교 가능한 평가 포맷을 정의한 뒤에야 실제 실험이 의미를 갖는다.</p>
<p><strong>비용과 구조는 트레이드오프다.</strong> lane별 LLM rewrite, lane별 rerank는 논리적으로 맞지만, 모든 lane에 다 붙이면 비용과 latency가 감당이 안 된다. 현실적 제약 안에서 필요한 곳에만 선택적으로 적용하는 것이 운영 설계의 핵심이다.</p>
<hr />
<h2 id="heading-7jwe7jy866gc7j2yiouwqe2wpq">앞으로의 방향</h2>
<p>현재 시점에서 query-prep은 일단 마감이다. prerewriter와 source-router v1_6이 고정됐고, filter는 payload 기준 재설계가 필요할 때 다시 연다.</p>
<p>다음 핵심 작업은 query-prep 결과를 downstream에 안정적으로 넘기는 것이다. 구체적으로는 prerewriter + source-router 출력 계약을 고정하고, retrieval이 merged plan만 보도록 인계 기준을 정리하고, retrieval에서 answer까지 end-to-end로 검증하는 것이다.</p>
<p>lane-based retrieval 구조 자체는 precedents lane부터 한 lane씩 검증해 나갈 계획이다. precedents lane에서 lane-specific rewrite와 small-window rerank의 효과가 확인되면, 해석례와 행정해석 lane으로 확장한다.</p>
<p>더 먼 미래에는 lane-aware query-prep, 즉 handoff 자체를 lane별로 분화하는 방향도 열려 있다. 공통 query에 lane별 override를 얹는 구조가 가장 현실적인 확장 경로로 보인다.</p>
<p>이 시리즈를 통해 확인한 결론은 명확하다. 법률 도메인 RAG에서 검색 품질을 올리는 핵심은 더 좋은 임베딩 모델이 아니라, 질문의 의도를 정확히 파악하고 소스의 성격에 맞게 검색 전략을 분화하는 구조적 설계다. 모델은 바뀌어도 이 구조적 판단은 남는다.</p>
<hr />
<p><em>이 글은 법률 QA 검색기 시리즈의 마지막 글입니다.</em></p>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (11) — 오답 분석: 법률 RAG는 왜 자신 있게 틀리는가]]></title><description><![CDATA[틀린 답 하나가 열어준 토끼굴
"중소기업 특별세액감면이 최저한세 적용 대상인가요?"
단순해 보이는 질문이었다. 법령 QA 시스템은 자신 있게 답했다. "조세특례제한법 제132조가 해당 감면 조문을 열거하므로, 최저한세 적용 대상입니다."
조문 번호도 있고, 논리 구조도 있고, 결론도 명확했다. 문제는 하나뿐이었다. 틀렸다는 것.
실제로 제132조의 열거 조문과 해당 감면 조문의 관계를 확인하면, 시스템이 내린 결론과 실제 적용이 달랐다. 세무 ...]]></description><link>https://blog.dongjun.win/legal-ai-search-11-law-qa-error-analysis</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-11-law-qa-error-analysis</guid><category><![CDATA[AI]]></category><category><![CDATA[debugging]]></category><category><![CDATA[llm]]></category><category><![CDATA[RAG ]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Tue, 05 May 2026 23:40:59 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-7yua66awioultsdtlzjrgpjqsiag7je07ja07ksaio2gooubvoq1ta">틀린 답 하나가 열어준 토끼굴</h2>
<p>"중소기업 특별세액감면이 최저한세 적용 대상인가요?"</p>
<p>단순해 보이는 질문이었다. 법령 QA 시스템은 자신 있게 답했다. "조세특례제한법 제132조가 해당 감면 조문을 열거하므로, 최저한세 적용 대상입니다."</p>
<p>조문 번호도 있고, 논리 구조도 있고, 결론도 명확했다. 문제는 하나뿐이었다. <strong>틀렸다는 것.</strong></p>
<p>실제로 제132조의 열거 조문과 해당 감면 조문의 관계를 확인하면, 시스템이 내린 결론과 실제 적용이 달랐다. 세무 실무에서 이런 오답은 납세자에게 직접적인 피해로 이어질 수 있다.</p>
<p>이 오답을 추적하면서 법률 RAG 시스템이 "자신 있게 틀리는" 세 가지 구조적 원인을 발견했다.</p>
<hr />
<h2 id="heading-1">원인 1: 데이터 전파 과정에서 정보가 소실된다</h2>
<p>가장 먼저 의심한 건 원본 데이터였다. 확인해 보니 원본은 정상이었다.</p>
<p>한국 법률에는 <code>제N조의M</code> 형태의 조문이 많다. 원본 데이터는 이걸 기본 번호와 분기 번호로 분리해서 저장하고 있었고, 이 구조 자체에는 문제가 없었다. 문제는 이 분리된 번호를 파이프라인 하류에서 제대로 합쳐 쓰지 못한 것이었다.</p>
<p>RAG 시스템은 보통 여러 계층을 거친다. 원본 저장소 → 벡터 DB → 그래프 DB → LLM 컨텍스트. 이 과정에서 작은 필드 하나가 누락되면, 모든 하류 계층에서 동일한 오류가 반복된다. 이번 경우에도 분기 번호가 답변 생성, 검색 결과, 그래프 노드 세 곳 모두에서 빠져 있었다.</p>
<p>이런 문제의 핵심은 <strong>겉으로 드러나지 않는다</strong>는 것이다. 시스템은 여전히 자신 있게 답을 내고, 결과물만 보면 깔끔해 보인다. 원본과 대조해야 비로소 보인다.</p>
<p><strong>판단</strong>: RAG 파이프라인에서 데이터가 여러 저장소를 거칠 때, 각 계층에서 필드가 보존되는지 end-to-end로 검증해야 한다. 특히 한국 법률처럼 복합 식별자(<code>제N조의M</code>)가 있는 도메인에서는 식별자 전파가 특히 중요하다.</p>
<hr />
<h2 id="heading-2-llm">원인 2: LLM은 "없다"를 증명하지 못한다</h2>
<p>조문 번호 누락만으로는 이번 오답이 완전히 설명되지 않았다. 더 근본적인 문제가 있었다.</p>
<p>시스템은 "제132조가 해당 감면 조문을 열거한다"고 단정했다. 실제로 제132조 원문을 확인하면 해당 조문이 열거 목록에 없거나, 시스템이 주장하는 방식과 다르게 적용되는 경우였다. LLM이 <strong>원문에 명시적으로 존재하지 않는 연결을 만들어 낸 것</strong>이다.</p>
<p>이것은 법률 도메인에서 특히 위험한 유형의 hallucination이다. Stanford의 연구에 따르면 LexisNexis, Thomson Reuters 같은 선도적 법률 AI 도구들도 <a target="_blank" href="https://onlinelibrary.wiley.com/doi/full/10.1111/jels.12413">17~33%의 hallucination 비율</a>을 보인다. 범용 LLM을 법률 작업에 쓰면 <a target="_blank" href="https://dho.stanford.edu/wp-content/uploads/Legal_RAG_Hallucinations.pdf">hallucination 비율이 58~80%</a>까지 올라간다는 보고도 있다. RAG가 이 문제를 "해결"한다는 주장이 있지만, Harvard JOLT의 분석이 지적하듯 RAG는 hallucination을 줄일 뿐 <a target="_blank" href="https://jolt.law.harvard.edu/digest/retrieval-augmented-generation-rag-towards-a-promising-llm-architecture-for-legal-work">제거하지는 못한다</a>.</p>
<h3 id="heading-67aa7kcvioqygoymnsdsi6ttl5g">부정 검증 실험</h3>
<p>"A 조문이 B 조문을 참조하고 있는가?"를 검증하는 실험을 해봤다. 원문 대조 결과 "참조 없음"이라는 사실을 prompt에 명시적으로 넣어 줬다.</p>
<p>결과는 3회 실행 중 1회만 올바른 "불확실"을 냈다. 나머지 2회는 prompt에 "없다"고 적어 줬음에도 여전히 "있다"고 hallucination했다.</p>
<p><strong>부정(absence) 검증은 prompt 지시만으로는 LLM이 안정적으로 따르지 않는다.</strong></p>
<p>결국 코드 레벨에서 원문 대조를 먼저 수행하고, 그 결과를 LLM의 판단보다 우선하는 구조를 만들어야 했다. LLM에게 "이것이 없다는 걸 확인해 줘"라고 시키면 안 된다. 코드로 먼저 확정하고, LLM에게는 확정된 사실만 전달해야 한다. 이것은 탐색/대조 문제이지, 추론 문제가 아니기 때문이다.</p>
<hr />
<h2 id="heading-3-corpus">원인 3: corpus 경계 밖의 질문이 존재한다</h2>
<p>이 질문은 애초에 조문 원문만으로는 답이 완결되지 않는 유형이었다.</p>
<ul>
<li><strong>감면 조문</strong>: 해당 세액감면의 직접 근거. 하지만 최저한세 적용 여부를 단독으로 확정하는 조문은 아니다.</li>
<li><strong>조세특례제한법 제132조</strong>: 최저한세 일반 규정. 하지만 감면 조문과의 관계가 단순하지 않다.</li>
<li><strong>국세청 공식 안내</strong>: 실제 적용 방식을 명시. 하지만 corpus에 이 데이터가 없다.</li>
</ul>
<p>법령 조문 중심으로 설계된 시스템은 조문 기반 질문에는 강하지만, 세무 실무형 질문에서는 구조적 한계가 있다. 국세청 안내 페이지 같은 기관 공식 자료는 법령도 아니고 판례도 아니다. 별도의 source 카테고리로 수집해야 하는 성격의 데이터다.</p>
<p>시스템이 "모른다"고 답하려면, 자기가 무엇을 모르는지 알아야 한다. corpus에 기관 공식 안내가 없다는 사실을 시스템 스스로 인식할 수 없으므로, 없는 근거를 만들어내는 대신 불확실성을 표현하도록 설계해야 한다.</p>
<hr />
<h2 id="heading-rag">결론: 법률 RAG가 자신 있게 틀리는 구조</h2>
<p>이번 오답 하나를 추적하면서 세 겹의 문제가 드러났다.</p>
<ol>
<li><strong>데이터 전파의 정합성</strong> — 원본은 정상이지만 파이프라인을 거치면서 정보가 소실된다. 작은 필드 하나가 빠져도 결과가 틀어진다.</li>
<li><strong>LLM의 과추론</strong> — 법률 도메인에서 LLM은 "있다"고 과추론하는 경향이 있다. prompt만으로는 억제할 수 없고, deterministic한 코드 검증과 결합해야 한다.</li>
<li><strong>corpus 경계의 한계</strong> — 조문만으로 답이 완결되지 않는 질문이 있다. 시스템이 답할 수 없는 영역을 인식하고 불확실성을 표현하는 설계가 필요하다.</li>
</ol>
<blockquote>
<p>RAG 시스템에서 오답의 원인은 하나가 아니다. 데이터 전파 누락, LLM의 과추론, corpus 범위의 한계가 겹칠 때, 시스템은 자신 있게 틀린 답을 낸다.</p>
</blockquote>
<p>화려한 모델 교체나 파라미터 튜닝보다, 데이터가 끝까지 올바르게 흐르는지 확인하는 게 먼저다.</p>
<hr />
<p><strong>참고 자료:</strong></p>
<ul>
<li><a target="_blank" href="https://onlinelibrary.wiley.com/doi/full/10.1111/jels.12413">Hallucination-Free? Assessing the Reliability of Leading AI Legal Research Tools (Stanford, 2025)</a></li>
<li><a target="_blank" href="https://dho.stanford.edu/wp-content/uploads/Legal_RAG_Hallucinations.pdf">Legal RAG Hallucinations - Journal of Empirical Legal Studies</a></li>
<li><a target="_blank" href="https://jolt.law.harvard.edu/digest/retrieval-augmented-generation-rag-towards-a-promising-llm-architecture-for-legal-work">RAG: Towards a Promising LLM Architecture for Legal Work? - Harvard JOLT</a></li>
<li><a target="_blank" href="https://hai.stanford.edu/news/ai-trial-legal-models-hallucinate-1-out-6-or-more-benchmarking-queries">AI on Trial: Legal Models Hallucinate in 1 out of 6 or More - Stanford HAI</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[AI한테 내 프롬프트 14,390개를 주고 물어봤다 — 내가 너를 어떻게 쓰고 있냐고]]></title><description><![CDATA[시작
호기심이 생겨서 내가 지난 두 달 동안 Claude Code와 Codex에게 던진 프롬프트를 전부 긁어모아 분석해봤다. ~/.claude/projects/에는 22개 프로젝트, 234개 세션, 6,232개 프롬프트가 쌓여 있었고, ~/.codex/에는 15개 프로젝트, 204개 세션, 8,158개 프롬프트가 있었다.
처음에는 그냥 "내가 토큰을 얼마나 썼나" 정도가 궁금했다. 그런데 데이터를 펼쳐 놓고 보니 토큰 얘기보다 더 흥미로운 게 ...]]></description><link>https://blog.dongjun.win/ai-usage-analysis-2-ai-team-manager</link><guid isPermaLink="true">https://blog.dongjun.win/ai-usage-analysis-2-ai-team-manager</guid><category><![CDATA[AI]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[codex]]></category><category><![CDATA[workflow]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Fri, 01 May 2026 03:56:02 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-7iuc7j6r">시작</h2>
<p>호기심이 생겨서 내가 지난 두 달 동안 Claude Code와 Codex에게 던진 프롬프트를 전부 긁어모아 분석해봤다. <code>~/.claude/projects/</code>에는 22개 프로젝트, 234개 세션, 6,232개 프롬프트가 쌓여 있었고, <code>~/.codex/</code>에는 15개 프로젝트, 204개 세션, 8,158개 프롬프트가 있었다.</p>
<p>처음에는 그냥 "내가 토큰을 얼마나 썼나" 정도가 궁금했다. 그런데 데이터를 펼쳐 놓고 보니 토큰 얘기보다 더 흥미로운 게 보였다. <strong>나는 AI 한 대를 잘 쓰는 사람이 아니라, AI 두 대를 역할로 나눠 굴리는 사람이었다.</strong></p>
<blockquote>
<p>미리 밝혀두면 — 이 분석과 보고서는 내가 직접 쓴 게 아니라 <strong>AI가 써줬다.</strong> 내가 한 일은 "내 <code>~/.claude/</code>와 <code>~/.codex/</code> 데이터 전부 읽고 내 사용 스타일을 분석해줘"라고 던진 것뿐이다. 즉 이 글은 <em>내가 AI를 어떻게 쓰는지를 AI에게 분석시킨 결과</em>다. 그래서 더 재미있었다 — AI가 나를 거울처럼 비춰준 셈이니까.</p>
</blockquote>
<p>이 글은 그 분석 결과를 정리한 회고다.</p>
<hr />
<h2 id="heading-7zwcioykhcdsmptslb0">한 줄 요약</h2>
<blockquote>
<p>AI를 잘 쓰는 사람이 아니라, AI 팀을 굴리는 매니저.</p>
</blockquote>
<p>처음에는 AI 한 대를 어떻게든 잘 써보려고 했다. 그런데 어느 순간부터 두 대를 <strong>역할로 나눠</strong> 쓰고 있었다. Claude Code는 같이 사고하는 PM/Architect, Codex는 손발 빠른 Senior Engineer. 의도하고 그렇게 한 게 아닌데, 데이터로 보니 그렇게 굳어져 있었다.</p>
<hr />
<h2 id="heading-7yag7ygwio2aqoycqoydtcdqt7nri6jsoihsnlzrozwg64as7j2aioycroueja">토큰 효율이 극단적으로 높은 사람</h2>
<p>가장 먼저 눈에 띈 건 토큰 사용 패턴이었다. 한 줄로 요약하면 이거다.</p>
<blockquote>
<p>직접 타자는 평균의 1/3로 치고, 컨텍스트는 평균의 2배를 읽히고, 출력은 평균의 5배를 받아낸다.</p>
</blockquote>
<p>세 개의 숫자가 이 프레임을 그대로 증명한다.</p>
<blockquote>
<p>비교 기준에 대해 미리 한 줄: 이 글의 <em>"평균"</em> 표현은 공식 통계가 아니라, 분석을 맡은 AI가 일반적인 사용 패턴을 기준으로 추정한 값이다. 정확한 기준선이 아니라 <em>"내 사용 스타일이 어느 쪽으로 쏠려 있나"</em> 를 보기 위한 참조선 정도로 읽어주시면 좋겠다.</p>
</blockquote>
<h3 id="heading-1-92">(1) 캐시 적중률 92%</h3>
<p>캐시 read 444M / (캐시 write 39M + read 444M) = 92%. AI 추정 기준 일반적인 분포는 70~80%대라고 한다. 같은 프로젝트로 다시 돌아오는 빈도가 높고, 한 세션 안에서 컨텍스트를 적게 갈아엎는다는 뜻이다. 코드를 갈아엎기보다 <strong>위에 쌓는 스타일</strong>이다.</p>
<h3 id="heading-2-46">(2) 출력/직접입력 비율 4.6배</h3>
<p>내가 73 토큰 던지면 AI가 335 토큰으로 답한다. 일반적으로는 1.5~2배 정도라는 게 분석 결과였다. 4.6배는 AI에게 <em>결정/판단</em>을 외주하는 비율에 가깝다. <strong>짧게 묻고 길게 답을 받는다.</strong></p>
<h3 id="heading-3-50k">(3) 응답당 컨텍스트 부피 50K</h3>
<p>한 응답에 약 50K 토큰의 컨텍스트가 들어간다. 일반 대비 1.5~2배 정도로 큰 편이라고 한다. 파일 첨부와 히스토리 참조가 많아서 그렇다. 자연어 설명은 짧지만 그 앞뒤에 실제 자료를 듬뿍 붙인다.</p>
<h3 id="heading-7zwcioykhcdsojxrpqw">한 줄 정리</h3>
<p>토큰을 적게 쓰는 게 아니라 <strong>토큰 효율이 극단적으로 높은 사람</strong>이었다. 본인이 칠 비용은 아끼고, AI한테는 풍부한 자료와 길게 생각할 여유를 주는 — 시간당 의사결정 단가가 가장 낮은 운영 방식.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>항목</td><td>평균 사용자</td><td>나</td></tr>
</thead>
<tbody>
<tr>
<td>직접 입력 토큰</td><td>1×</td><td><strong>1/3×</strong></td></tr>
<tr>
<td>컨텍스트 부피</td><td>1×</td><td><strong>2×</strong></td></tr>
<tr>
<td>출력 토큰</td><td>1×</td><td><strong>5×</strong></td></tr>
<tr>
<td>캐시 적중률</td><td>70~80%</td><td><strong>92%</strong></td></tr>
<tr>
<td>출력/입력 비율</td><td>1.5~2배</td><td><strong>4.6배</strong></td></tr>
</tbody>
</table>
</div><hr />
<h2 id="heading-64e6rws67oeio2omoultoygjoucmoqwgcdri6trpbtri6q">도구별 페르소나가 다르다</h2>
<p>같은 사람인데 어휘가 완전히 다르다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>단어</td><td>Claude (6,232개 중)</td><td>Codex (8,158개 중)</td><td>비율</td></tr>
</thead>
<tbody>
<tr>
<td>진행해</td><td>0.34%</td><td>1.74%</td><td>Codex 5.0×</td></tr>
<tr>
<td>오케이</td><td>0.24%</td><td>1.41%</td><td>Codex 5.8×</td></tr>
<tr>
<td>해줘</td><td>1.56%</td><td>4.13%</td><td>Codex 2.6×</td></tr>
<tr>
<td>체크해줘</td><td>0.35%</td><td>0.88%</td><td>Codex 2.5×</td></tr>
<tr>
<td>토론</td><td>0.22%</td><td>0.21%</td><td><strong>거의 동일</strong></td></tr>
</tbody>
</table>
</div><p>Codex 단골 멘트는 <code>Implement the plan.</code> (36회), <code>진행해줘.</code> 계열이다. Claude한테는 "같이 보자" 모드, Codex한테는 "실행해라" 모드.</p>
<p>재미있는 건 <strong>"토론"이라는 단어 비율은 도구가 바뀌어도 거의 똑같다</strong>는 점이다 (0.22% vs 0.21%). 이건 도구의 문제가 아니라 내 체질이다. 어떤 AI를 쓰든 같은 비율로 토론을 건다. 다만 토론 <em>후</em> 실행을 어디서 하느냐가 다를 뿐.</p>
<hr />
<h2 id="heading-7zse66gc7kcd7yq464eioyekoyxsoykpoufveqyjcdrtotrpqzrkjjslrqg7j6i7jei64uk">프로젝트도 자연스럽게 분리되어 있었다</h2>
<div class="hn-table">
<table>
<thead>
<tr>
<td>프로젝트</td><td>Claude 세션</td><td>Codex 세션</td></tr>
</thead>
<tbody>
<tr>
<td>embedding-test (RAG)</td><td>11</td><td><strong>60</strong></td></tr>
<tr>
<td>coin-autopilot</td><td>5</td><td><strong>35</strong></td></tr>
<tr>
<td>aura</td><td>4</td><td><strong>32</strong></td></tr>
<tr>
<td>blog</td><td><strong>7</strong></td><td>0</td></tr>
<tr>
<td>atlassian-cli, hiworks-report</td><td>0</td><td>9</td></tr>
</tbody>
</table>
</div><p>Claude는 글쓰기/문서/메타작업, Codex는 실제 코드 실행. 이걸 의식적으로 정한 적이 한 번도 없는데도 분기가 깔끔하게 갈려 있었다.</p>
<hr />
<h2 id="heading-3">운영 방식 — 3단 루프</h2>
<p>데이터에서 반복적으로 보이는 흐름은 결국 이 3단계로 압축된다.</p>
<pre><code><span class="hljs-number">1.</span> 판단(Diagnose)  — <span class="hljs-string">"수정하지 말고 분석만 해줘 / 너의 생각은?"</span>
<span class="hljs-number">2.</span> 실행(Execute)   — <span class="hljs-string">"오케이 진행해줘 / 그렇게 해줘"</span>
<span class="hljs-number">3.</span> 검증(Verify)    — <span class="hljs-string">"다시 체크해줘 / 원본이랑 비교해줘"</span>
</code></pre><p>이 루프가 내 시그니처에 가까웠다. 1번(판단) 없이 바로 실행으로 가거나 3번(검증) 없이 결과를 그냥 수용하는 패턴도 흔한데, 내 데이터에는 둘 다 꽤 일관되게 찍혀 있었다. 그리고 이 루프 안에 권한을 단계적으로 푸는 게이트가 하나 더 깔려 있었다.</p>
<pre><code><span class="hljs-number">1.</span> <span class="hljs-string">"한번 분석해줘"</span> / <span class="hljs-string">"체크만해줘"</span>          ← 읽기 권한
<span class="hljs-number">2.</span> <span class="hljs-string">"너의 생각은 어때? / 동의할까?"</span>         ← 의견 권한
<span class="hljs-number">3.</span> <span class="hljs-string">"오케이 진행해줘 / 그렇게 해줘"</span>          ← 실행 권한
<span class="hljs-number">4.</span> <span class="hljs-string">"커밋하고 푸쉬해줘"</span>                      ← 커밋 권한
</code></pre><p>이 4단 게이트가 거의 모든 중요한 의사결정에서 반복된다. 평소엔 의식하지 못했는데, 데이터로 보니 이게 hallucination 방어선이자 잘못된 방향으로 빠르게 달리는 걸 막는 안전장치였다.</p>
<hr />
<h2 id="heading-7">7가지 핵심 강점</h2>
<h3 id="heading-1">(1) 모드 분리 능력</h3>
<p>토론과 구현을 명시적으로 분리해서 통보한다. <em>"아직 수정하지 말고 나랑 토론을 하자"</em> 같은 문장이 데이터에 꽤 자주 박혀 있었다. AI를 굴려본 시간이 만든 운영 감각인 것 같다.</p>
<h3 id="heading-2">(2) 결정 외주, 책임 보유</h3>
<p>AI에게 <em>"딱 정해줘"</em>, <em>"판단해서 알려줘"</em>라고 자주 시킨다. 결정 <em>과정</em>은 외주하지만, 결정 <em>책임</em>은 내가 진다. 그래서 잘못된 방향이 나오면 <em>"다 날리고 git 초기화"</em>. 매몰비용 0.</p>
<h3 id="heading-3-1">(3) 컨텍스트 밀도 운영</h3>
<p>자연어 설명보다 실물 자료(파일, 로그, 이미지, 통화 스크립트, 다른 AI 답변)를 던진다. 입력 토큰은 평균의 1/3인데 컨텍스트는 평균의 2배. AI가 거짓말하기 어려운 환경을 <strong>체질적으로</strong> 만든다.</p>
<h3 id="heading-4-0">(4) 회복력 — 매몰비용 0</h3>
<p>방향이 틀렸다고 판단되면 즉시 리셋. <em>"모두 버려"</em>, <em>"git 상태로 초기화"</em>. 도구를 잘 쓰는 마인드라기보단, 시스템을 굴려본 시간이 만든 습관에 가까운 듯하다.</p>
<h3 id="heading-5">(5) 의인화된 관계 형성</h3>
<p><em>"너였으면 어떻게 할래?"</em>, <em>"나 혼자 하기에는 더 힘든 상황이자나"</em> — AI에게 내 처지를 공유한다. 비합리적인 의인화가 아니라, 내 제약을 알려주면 답이 내 상황에 맞게 좁혀진다. <strong>의인화가 효율을 만든다.</strong></p>
<h3 id="heading-6">(6) 자연 발생적 이중 검증</h3>
<p>Claude가 만든 plan을 Codex가 실제 실행. Claude의 거짓말이 Codex 실행 단계에서 잡히는 구조가 의도하지 않게 작동 중이었다. "AI 거짓말을 잘 못 느낀다"는 감각의 절반은 사실 이 구조 덕분이다.</p>
<h3 id="heading-7-1">(7) 토큰 경제 운영</h3>
<p>Max 구독 $200으로 환산하면 약 $810 상당의 가치를 쓰고 있었다(약 4×). Codex까지 합치면 1인 운영자가 굴리기엔 꽤 효율적인 편이었다.</p>
<hr />
<h2 id="heading-64uk7j2mioulqoqzhcdigjqg642uioyemo2vocdsijgg7j6i64quiou2gou2houtpa">다음 단계 — 더 잘할 수 있는 부분들</h2>
<p>약점이 아니라 <strong>다음 레벨로 넘어갈 때 챙길 만한 것들</strong>로 정리했다.</p>
<h3 id="heading-1-ai">(1) AI의 "자기 보고"까지 검증 루프에 넣기</h3>
<p>결과물 검증은 강하다. 다만 AI가 <em>"138 pass / 0 fail"</em>, <em>"6개 컬렉션 다 확인했어"</em> 같이 자기 행동을 보고할 때, 그 보고 자체를 한 번 더 들여다보면 검증 루프가 더 단단해진다.</p>
<p>보완 문장은 한 줄이면 충분하다.</p>
<pre><code>실제로 실행한 명령과 핵심 출력도 같이 보여줘.
</code></pre><h3 id="heading-2-1">(2) 프로젝트 메모리로 인지 부담 옮기기</h3>
<p>22개 프로젝트(Claude) + 15개(Codex). 토큰은 잘 절약되고 있는데, 프로젝트 간 컨텍스트 전환 비용을 내 머리가 부담하고 있다. 메모리 시스템에 프로젝트별 상태(현재 목표, 마지막 결정, 다음 액션)를 저장해두면 가장 큰 ROI가 나올 영역. 토큰 효율을 인지 효율까지 확장하는 단계다.</p>
<h3 id="heading-3-second-opinion">(3) Second Opinion을 의식적으로 설계</h3>
<p>Gemini 같은 다른 AI 답변을 가져와 교차검증하는 패턴은 1인 운영자에게 강력한 보완 장치다. 다만 모든 판단에 다 쓰면 시간 비용이 누적되니, <em>어떤 결정에 second opinion을 붙일지</em>만 미리 정해두면 강점이 그대로 유지되면서 비용은 줄어든다 (예: 아키텍처/보안/투자 결정에만).</p>
<h3 id="heading-4">(4) 도구 간 핸드오프 명문화</h3>
<p>Claude에서 정리한 plan을 Codex로 옮길 때 요약이 살짝 손실된다. 두 AI 사이에 내 머리가 끼어 있어서 — 나만 안다. plan을 옮길 때 작은 템플릿 한 줄(목표 / 제약 / 검증 기준)만 정해두면, 매니저로서의 운영 비용이 더 줄어든다.</p>
<hr />
<h2 id="heading-7ja065a76rkmiou2hoyene2wioucmcdigjqg7keb7kcrio2vtouzvcdsijgg7j6i64quiouwqeuylq">어떻게 분석했나 — 직접 해볼 수 있는 방법</h2>
<p>이 글의 모든 숫자는 내 로컬 파일에서 나왔다. 외부 서비스가 아니라 누구나 자기 컴퓨터에서 똑같이 돌려볼 수 있는 데이터다.</p>
<h3 id="heading-642w7j207ysw6rcaioyeiouklcdqs7m">데이터가 있는 곳</h3>
<pre><code>~<span class="hljs-regexp">/.claude/</span>projects/&lt;프로젝트별 폴더&gt;/&lt;세션ID&gt;.jsonl
~<span class="hljs-regexp">/.codex/</span>sessions/&lt;연도&gt;/...
~<span class="hljs-regexp">/.codex/</span>history.jsonl
</code></pre><p>Claude Code는 프로젝트 폴더마다 세션이 <code>.jsonl</code>로 한 줄에 한 이벤트씩 쌓인다. 사용자 프롬프트, 어시스턴트 응답, 토큰 사용량(<code>usage</code>), 캐시 read/write, 모델 이름까지 다 들어 있다. Codex는 <code>~/.codex/sessions/</code>에 비슷한 구조로 쌓이고 <code>history.jsonl</code>에 사용자 입력 히스토리가 누적된다.</p>
<h3 id="heading-7lau7lac7zwgio2vteylrcdtlytrk5w">추출할 핵심 필드</h3>
<p>세션 JSONL을 한 줄씩 파싱해서 아래 항목만 뽑으면 충분하다.</p>
<ul>
<li><strong>사용자 프롬프트 텍스트</strong>: <code>type == "user"</code> 메시지의 본문</li>
<li><strong>토큰 사용량</strong>: 어시스턴트 응답의 <code>usage</code> (input / output / cache_creation / cache_read)</li>
<li><strong>타임스탬프 / 세션 ID / 프로젝트 경로</strong>: 시계열·프로젝트 분포용</li>
<li><strong>모델 이름</strong>: 단가 환산용</li>
</ul>
<h3 id="heading-7zw17iusioyngo2rncdqs4tsgrdsi50">핵심 지표 계산식</h3>
<pre><code>캐시 적중률      = cache_read / (cache_read + cache_write)
출력/입력 비율   = output / input(직접 타이핑분만, 캐시 제외)
세션당 컨텍스트  = (input + cache_read) / 응답 수
프롬프트 평균길이 = sum(len(prompt)) / 프롬프트 수
어휘 빈도        = <span class="hljs-string">"진행해"</span>, <span class="hljs-string">"해줘"</span>, <span class="hljs-string">"토론"</span> 등 키워드 카운트 / 전체 프롬프트 수
비용 환산        = 모델별 단가 × (input/output/cache 각각)
</code></pre><p>캐시 적중률과 출력/입력 비율 두 개만 봐도 본인의 사용 스타일이 뚜렷하게 드러난다.</p>
<blockquote>
<p>참고: 본문에 나온 <em>"평균"</em> 비교치(70~80%, 1.5~2배 등)는 별도의 공식 데이터셋에서 뽑은 게 아니라, 분석을 맡긴 AI가 일반적인 사용 패턴을 기준으로 추정해준 값이다. 절대 기준이 아니라 <em>"내가 어느 쪽으로 쏠려 있나"</em> 를 보는 참조선으로 봐주시면 좋겠다. 본인 숫자 자체는 위 계산식으로 정확히 재현 가능하다.</p>
</blockquote>
<h3 id="heading-64k06rcaioylpoygnouhncdrjzjsp4qg7zse66gs7zse7yq4">내가 실제로 던진 프롬프트</h3>
<p>미리 잡아둔 4단계 플로우 같은 건 없었다. 홈 디렉토리에서(<code>cd ~</code>) Claude Code를 그냥 띄워놓고, 떠오르는 대로 짧게 던졌다. 세션 히스토리에 남은 실제 프롬프트는 14개 정도였고, 분석을 시작한 첫 마디는 이거였다.</p>
<pre><code>클로드 코드를 보면 히스토리를 가지고 있자나 각 폴더별로...
그 히스토리를 보면 나랑 대화를 나누는 것들이 있고..
그것들을 전체다 분석해서 나의 프롬프팅 성향과 방법 등을 분석 해볼수 있어?
</code></pre><p>답이 나오면 다음을 던졌다.</p>
<pre><code>좀더 자세히 분석해줘.
AI를 대하는 자세라든가..
나는 AI의 거짓말을 많이 못느낌점..
다른 사람들보다 토큰을 많이 안쓰는점 기타 등등
</code></pre><pre><code>나는 다른 사람에 비해 토큰 사용량이 어떻게되?
</code></pre><pre><code>통합적으로 내가 AI를 다루는 총평을 만들어줘.
</code></pre><p>Codex 데이터도 같이 보고 싶어졌을 때는 이렇게 물었다.</p>
<pre><code>여기에서 실제 코덱스의 히스토리도 같이 볼수 있나??
</code></pre><p>마지막에는 결과물을 파일로 떨어뜨렸다.</p>
<pre><code>이제 클로코드에서 총평을 한것과 클로드코드와 코덱스를 합쳐서
총평을 한것을 문서로 만들어줘 .md 파일로 해서..
@Documents/ 안에 만들면되.
</code></pre><p>그리고 — 이게 마음에 드는 부분인데 — 결과를 한 번 더 의심하는 프롬프트도 끼어 있었다.</p>
<pre><code>실제 전체 다 히스토리를 다 보고 애기한거 맞지?
실제 전체를 보고 결론을 내려줘!
</code></pre><p>본문에서 <em>"AI 자기 보고를 한 번 더 들여다보면 검증 루프가 더 단단해진다"</em> 고 적었는데, 분석 과정에서도 무의식적으로 이걸 하고 있었다는 게 데이터에서 다시 드러났다. 회고하면서 내가 가장 놀란 부분이다.</p>
<h3 id="heading-7zwcioykhcdsmptslb0-1">한 줄 요약</h3>
<p>특별한 플로우를 세팅한 게 아니다. 홈에서 Claude Code 띄우고, 짧은 자연어 14줄을 던진 게 전부다. 그 결과물을 읽고, 마음에 드는 프레임은 받아들이고, 의심스러운 부분은 <em>"전체 다 보고 한 거 맞지?"</em> 로 다시 물었다.</p>
<p><strong>분석조차 자기가 평소에 쓰는 운영 방식 그대로 굴리는 것</strong> — 그게 데이터에서 자기를 발견하는 가장 정직한 방법이었다. 그리고 그 운영 방식 자체가 이 글의 본문이다.</p>
<hr />
<h2 id="heading-66ma66as7isciouzucdqt7jrprw">멀리서 본 그림</h2>
<p>여기까지 정리하고 보니 내 작업 방식이 한 장의 그림으로 보였다.</p>
<pre><code><span class="hljs-number">1.</span> 머릿속에서 가설 발생
        ↓
<span class="hljs-number">2.</span> Claude한테 짧게 토론 — <span class="hljs-string">"이 방향 어때?"</span>
        ↓
<span class="hljs-number">3.</span> 합의 → plan 정리
        ↓
<span class="hljs-number">4.</span> Codex한테 plan 던짐 — <span class="hljs-string">"Implement the plan."</span>
        ↓
<span class="hljs-number">5.</span> Codex 실행 결과 확인
        ↓
<span class="hljs-number">6.</span> 이상하면 → 리셋 / 다시 Claude로 돌아가 토론
</code></pre><p>CTO + 시니어 엔지니어 + PM 세 자리짜리 미니 팀이 돌아가는 모양에 가까웠다. 나 혼자서, 두 AI를 빌려서.</p>
<hr />
<h2 id="heading-66ei7kea66ejio2vncdspiq">마지막 한 줄</h2>
<blockquote>
<p>프롬프트를 잘 쓰는 것과, AI를 굴려서 일이 굴러가게 만드는 건 다른 일인 것 같다.</p>
</blockquote>
<p>데이터를 까보기 전에는 <em>"AI를 잘 활용하는 편"</em> 정도로 막연하게 생각했다. 까보고 나니 표현이 살짝 바뀌었다 — <em>AI 팀을 굴리는 매니저에 가깝게 일하고 있었구나.</em></p>
<p>특별한 의도로 그렇게 만든 게 아니라, 64일 동안 6,232번을 두드리면서 자연스럽게 자리 잡은 운영 방식이라는 게 가장 흥미로운 부분이었다. 자기 데이터를 한 번씩 까보는 건 누구한테나 권할 만한 일인 것 같다.</p>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (10) — Query Prep 마무리: 무엇을 남기고 무엇을 버렸나]]></title><description><![CDATA[전처리 파이프라인을 "마무리"한다는 것
RAG 파이프라인에서 전처리(query preparation)는 사용자 질문과 검색 엔진 사이의 번역 계층이다. 질문을 그대로 벡터 검색에 넣는 것과, 질문을 구조화하고 어떤 소스를 열지 먼저 정하는 것은 검색 품질에서 체감할 수 있는 차이를 만든다.
이 프로젝트에서는 법률 QA를 다루고 있고, 검색 대상이 조문, 판례, 유권해석, 행정심판 등 8개 소스 레인에 걸쳐 있다. 그만큼 전처리 단계가 감당해야 ...]]></description><link>https://blog.dongjun.win/legal-ai-search-10-query-prep-wrap-up</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-10-query-prep-wrap-up</guid><category><![CDATA[AI]]></category><category><![CDATA[architecture]]></category><category><![CDATA[Prompt Engineering]]></category><category><![CDATA[RAG ]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Tue, 28 Apr 2026 23:21:33 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-7kce7lky66asio2mjoydto2uhoudvoyduoydhcai66ei66y066asiu2vnoulpouklcdqsom">전처리 파이프라인을 "마무리"한다는 것</h2>
<p>RAG 파이프라인에서 전처리(query preparation)는 사용자 질문과 검색 엔진 사이의 번역 계층이다. 질문을 그대로 벡터 검색에 넣는 것과, 질문을 구조화하고 어떤 소스를 열지 먼저 정하는 것은 검색 품질에서 체감할 수 있는 차이를 만든다.</p>
<p>이 프로젝트에서는 법률 QA를 다루고 있고, 검색 대상이 조문, 판례, 유권해석, 행정심판 등 8개 소스 레인에 걸쳐 있다. 그만큼 전처리 단계가 감당해야 할 범위가 넓었다. 처음에는 세 축을 세웠다.</p>
<ul>
<li><strong>prerewriter</strong>: 질문을 구조화하고 검색 힌트를 생성</li>
<li><strong>source-router</strong>: 어떤 소스 레인을 활성화할지 결정</li>
<li><strong>filter</strong>: payload 기준으로 검색 범위를 추가로 좁힘</li>
</ul>
<p>세 축 모두를 운영 가능한 상태로 올리는 것이 원래 목표였다. 하지만 결론적으로 두 축만 남기고 하나는 보류했다. 이 글은 그 결정의 과정과 근거, 그리고 "마무리"라는 것이 실제로 무엇을 의미하는지를 정리한다.</p>
<hr />
<h2 id="heading-prerewriter-source-router">최종 운영 구조: prerewriter + source-router</h2>
<p>현재 query-prep의 운영 구조는 다음과 같다.</p>
<pre><code>질문
-&gt; prerewriter
-&gt; source-router
-&gt; query-prep handoff
-&gt; retrieval
-&gt; merge / rerank
-&gt; answer
</code></pre><p><strong>prerewriter</strong>는 raw 질문을 대체하지 않으면서, retrieval에 필요한 구조화된 힌트를 만든다. queryType, 벡터 검색용 searchQuery와 subQueries, 키워드, 그래프 검색용 법률명 등이 여기서 나온다. 원래 질문을 버리지 않는다는 점이 중요한데, 전처리가 잘못될 경우에도 원본이 살아 있으므로 fallback이 가능하다.</p>
<p><strong>source-router</strong>는 8개 소스 레인 중 어떤 것을 활성화할지 정한다. recall-priority 기준까지 반영한 실험을 거쳐 v1_6이 최종 선택됐다. 예를 들어 "국가공무원법상 징계 관련 판례"라는 질문이 들어오면, 조문 레인과 판례 레인을 동시에 열되 해석례나 위원회 결정은 열지 않는 식의 판단을 한다.</p>
<p>이 두 축의 조합만으로도 검색 단계에 넘길 계획(retrieval plan)은 충분히 만들어진다. 중요한 것은 downstream이 prerewriter와 router의 내부 구현을 알 필요가 없다는 점이다. 다음 단계는 <code>buildQueryPrepHandoff</code>라는 하나의 진입점만 보면 된다.</p>
<hr />
<h2 id="heading-filter">Filter를 보류한 이유</h2>
<p>세 번째 축인 filter는 보류했다. "아직 안 만들었다"가 아니라 "만들 수 있지만 지금은 빼는 것이 낫다"는 판단이었다. 이유는 크게 세 가지다.</p>
<h3 id="heading-1-payload">1. 실제 payload와 설계가 맞지 않았다</h3>
<p>filter의 원래 역할은 Qdrant payload filter로 이어지는 것이었다. 처음 설계할 때는 <code>lawNames</code>, <code>regions</code>, <code>institutions</code> 같은 공통 필드를 모든 소스에 걸쳐 사용할 수 있으리라 가정했다.</p>
<p>하지만 실제 payload를 까보니 현실은 달랐다.</p>
<ul>
<li>조문(<code>law_articles</code>)은 <code>lawName</code> 필드가 있어 자연스럽게 filter 가능</li>
<li>조례(<code>ordinance_articles</code>)는 지역이 중요하지만 payload에 <code>region</code> 필드 자체가 없음</li>
<li>판례(<code>precedents</code>)는 <code>courtName</code>, <code>caseType</code>, <code>referenceLaws</code>가 더 자연스러운 filter 후보</li>
<li>유권해석, 행정심판, 헌재결정 등은 <code>title</code>과 <code>summary</code> 정도만 있어 filter를 걸 근거가 부족</li>
</ul>
<p>8개 레인에 공통 스키마를 씌우려 했지만, 실제로는 소스마다 쓸 수 있는 필드가 완전히 다른 상황이었다. 추상 스키마를 먼저 만들고 payload를 나중에 보는 순서가 거꾸로였던 셈이다.</p>
<h3 id="heading-2-hard-filter-recall">2. 잘못된 hard filter는 recall을 직접 떨어뜨린다</h3>
<p>법률 검색에서 filter는 양날의 검이다. 정확한 filter는 noise를 줄여주지만, 잘못된 hard filter는 정답 문서를 아예 검색 결과에서 빼버린다. 특히 판례, 유권해석, 행정심판처럼 메타데이터가 빈약한 소스에서 hard filter를 거는 것은 retrieval miss를 직접 만드는 행위다.</p>
<p>2025년 이후의 RAG 파이프라인 설계에서도 이 점은 공통적으로 언급된다. query expansion이나 다중 paraphrase를 통해 검색 범위를 넓히는 접근이 일반적인 추세이고, filter로 범위를 좁히는 것은 충분한 recall이 확보된 이후에 정밀하게 적용하는 것이 권장된다. 잘못된 전처리가 RAG 실패의 주요 원인이라는 분석도 여러 연구에서 반복적으로 나온다.</p>
<h3 id="heading-3-router-1">3. router가 이미 1차 필터 역할을 하고 있다</h3>
<p>source-router가 레인을 선택하는 것 자체가 넓은 의미의 filtering이다. 8개 레인 전부를 여는 것이 아니라, 질문에 맞는 레인만 활성화하므로 불필요한 소스에서의 noise는 이미 상당 부분 걸러진다. 여기에 payload filter까지 추가하면 이득보다 위험이 더 크다고 봤다.</p>
<p>결론적으로, filter를 보류한 것은 "filter가 불필요하다"는 판단이 아니라 "지금 상태에서 안전하게 붙일 수 있는 기반이 갖춰지지 않았다"는 판단이다. 나중에 다시 시작한다면 <code>law_articles</code>의 <code>lawName</code> filter부터, 레인별로 하나씩 시작하는 것이 맞다.</p>
<hr />
<h2 id="heading-handoff">인계 기준 설계: 모듈형 handoff</h2>
<p>query-prep을 마무리하면서 가장 신경 쓴 부분은 downstream과의 경계를 어떻게 그을 것인가였다. 문서로만 "이 단계는 끝났다"고 적어두면, 다음 단계에서 결국 내부 구조를 다시 들여다보게 된다.</p>
<p>그래서 <code>buildQueryPrepHandoff</code>라는 함수를 실제 진입점으로 만들었다. 이 함수는 내부적으로 prerewriter와 source-router를 실행하고, 그 결과를 하나의 handoff 객체로 합쳐서 반환한다.</p>
<p>downstream이 받는 계약은 이것이다.</p>
<ul>
<li><code>queryType</code>: 질문 유형</li>
<li><code>vector.searchQuery</code>, <code>vector.subQueries</code>, <code>vector.keywords</code>: 벡터 검색 힌트</li>
<li><code>graph.keywords</code>, <code>graph.lawNames</code>: 그래프 검색 힌트</li>
<li><code>sourceHints</code>: 활성화할 소스 레인 목록</li>
<li><code>confidence</code>: 전처리 신뢰도</li>
</ul>
<p><code>filterPlan</code>은 계약에 optional로 존재하지만 기본적으로 포함하지 않는다.</p>
<p>이 구조의 핵심은 모듈 merge가 아니라 출력 계약 merge라는 점이다. prerewriter와 router는 내부적으로 여전히 분리되어 있고, 각각의 버전을 독립적으로 교체할 수 있다. 하지만 downstream은 그 내부 구조를 알 필요 없이 handoff 하나만 보면 된다.</p>
<hr />
<h2 id="heading-iuyzhoujjclsnzgg6riw7ksa7j2aioustoyxhyduoqwga">"완료"의 기준은 무엇인가</h2>
<p>소프트웨어에서 "완료"라는 단어는 항상 조심스럽다. 특히 실험 기반 프로젝트에서는 더 그렇다. 여기서 query-prep의 "완료"는 다음을 의미한다.</p>
<p><strong>완료인 것:</strong></p>
<ul>
<li>prerewriter와 source-router의 운영 버전 선정</li>
<li>downstream에 넘길 출력 계약 고정</li>
<li>handoff 모듈의 코드 구현</li>
<li>filter 보류 결정과 그 근거 문서화</li>
</ul>
<p><strong>완료가 아닌 것:</strong></p>
<ul>
<li>filter를 영구적으로 폐기한 것</li>
<li>prerewriter나 router를 다시는 건드리지 않겠다는 것</li>
<li>전처리 파이프라인 전체의 최적화가 끝난 것</li>
</ul>
<p>즉, "이 단계에서 더 실험하는 것보다 다음 단계로 넘어가는 것이 전체 시스템에 더 이롭다"는 판단이 완료의 기준이었다. query-prep 내부를 계속 다듬는 것보다, retrieval과 answer까지의 end-to-end 흐름을 먼저 검증하는 편이 병목을 더 빠르게 찾을 수 있다.</p>
<hr />
<h2 id="heading-6rwq7zuioidsmytrsr3rs7tri6qg7jq07jibioqwgoukpe2vncdqtazsoba">교훈: 완벽보다 운영 가능한 구조</h2>
<p>이 과정에서 몇 가지 배운 것을 정리한다.</p>
<p><strong>첫째, payload를 먼저 보고 설계해야 한다.</strong> filter 설계를 추상 스키마에서 시작한 것이 가장 큰 실수였다. "어떤 필드를 추출할지"보다 "실제로 어떤 필드가 존재하고 filter로 쓸 수 있는지"를 먼저 봤어야 했다. 설계가 코드보다 앞서가면, 나중에 코드가 설계를 못 따라간다.</p>
<p><strong>둘째, 빼는 것도 결정이다.</strong> filter를 보류한 것은 소극적인 선택처럼 보일 수 있다. 하지만 실제로는 "recall risk를 감수하면서 불완전한 filter를 유지하는 것"과 "filter 없이 넓게 회수하는 것" 사이의 능동적 선택이었다. 특히 법률 도메인에서 검색 누락은 답변 품질에 치명적이므로, recall 확보가 precision보다 우선이라는 판단에는 지금도 변함이 없다.</p>
<p><strong>셋째, 인계 가능한 상태가 완료의 진짜 기준이다.</strong> 문서만 있고 코드가 없으면 다음 단계에서 다시 내부를 파야 한다. 반대로 코드만 있고 계약이 명확하지 않으면 통합할 때 혼란이 생긴다. handoff 모듈과 출력 계약 문서를 함께 만든 것이 이번 마무리에서 가장 유용한 작업이었다.</p>
<p><strong>넷째, 실험 프로젝트에서의 "완료"는 snapshot이다.</strong> 지금 시점에서 가장 합리적인 고정점을 찍은 것이지, 영구적인 결론을 내린 것이 아니다. filter는 source별 payload enrichment가 진행되면 다시 열릴 것이고, prerewriter나 router도 downstream 검증 결과에 따라 조정될 수 있다.</p>
<hr />
<h2 id="heading-64uk7j2m7j2aioywtouulouhna">다음은 어디로</h2>
<p>query-prep을 마무리한 이상, 다음은 이 결과를 실제로 쓰는 쪽이다. retrieval에 handoff를 연결하고, merge와 rerank를 거쳐 answer까지 이어지는 end-to-end 흐름을 검증해야 한다.</p>
<p>전처리를 오래 다듬는 것보다, 전체 파이프라인을 한 번이라도 끝까지 돌려보는 것이 지금 시점에서는 더 가치 있다. 부분 최적화에 매몰되면 전체 시스템의 병목을 놓치기 쉽다.</p>
<p>결국 query-prep은 "질문 구조화 + source lane 결정"이라는 역할로 정리됐다. 크지 않은 역할처럼 보일 수 있지만, 이 두 가지가 안정적으로 동작한다는 확신이 있어야 그 뒤의 모든 단계가 의미를 갖는다. 기초가 흔들리면 그 위에 무엇을 쌓아도 불안하다.</p>
<hr />
<p>Sources:</p>
<ul>
<li><a target="_blank" href="https://lakefs.io/blog/what-is-rag-pipeline/">RAG Pipeline: Example, Tools &amp; How to Build It</a></li>
<li><a target="_blank" href="https://www.kapa.ai/blog/how-to-build-a-rag-pipeline-from-scratch-in-2026">How to Build a RAG Pipeline from Scratch in 2026</a></li>
<li><a target="_blank" href="https://ragflow.io/blog/rag-review-2025-from-rag-to-context">From RAG to Context - A 2025 year-end review</a></li>
<li><a target="_blank" href="https://www.dhiwise.com/post/build-rag-pipeline-guide">Complete Guide to Building a Robust RAG Pipeline 2025</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (9) — Source Router: 8개 컬렉션을 지능적으로 라우팅하기]]></title><description><![CDATA[법률 QA 시스템을 만들면서 가장 먼저 부딪힌 현실이 있다. 우리가 다루는 법률 데이터는 하나의 벡터 DB에 넣고 검색하면 끝나는 구조가 아니라는 것이다. 법령 조문, 판례, 공식 법령해석, 부처 실무 해석, 행정심판 재결례, 헌재 결정, 위원회 결정, 지자체 조례까지 --- 성격이 완전히 다른 8개 컬렉션, 총 300만 건 이상의 문서가 Qdrant에 올라가 있다.
이 글에서는 사용자 질문 하나가 들어왔을 때 어떤 컬렉션을 열고, 어떤 컬렉션...]]></description><link>https://blog.dongjun.win/legal-ai-search-09-source-router-design</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-09-source-router-design</guid><category><![CDATA[AI]]></category><category><![CDATA[architecture]]></category><category><![CDATA[qdrant]]></category><category><![CDATA[System Design]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Sun, 26 Apr 2026 23:11:09 GMT</pubDate><content:encoded><![CDATA[<p>법률 QA 시스템을 만들면서 가장 먼저 부딪힌 현실이 있다. 우리가 다루는 법률 데이터는 하나의 벡터 DB에 넣고 검색하면 끝나는 구조가 아니라는 것이다. 법령 조문, 판례, 공식 법령해석, 부처 실무 해석, 행정심판 재결례, 헌재 결정, 위원회 결정, 지자체 조례까지 --- 성격이 완전히 다른 8개 컬렉션, 총 300만 건 이상의 문서가 Qdrant에 올라가 있다.</p>
<p>이 글에서는 사용자 질문 하나가 들어왔을 때 어떤 컬렉션을 열고, 어떤 컬렉션은 닫아야 하는지를 판단하는 <strong>Source Router</strong>의 설계 과정과 최종 버전 선정까지의 여정을 정리한다.</p>
<hr />
<h2 id="heading-db-8">하나의 벡터 DB가 아닌 8개 컬렉션</h2>
<p>우리 시스템의 8개 컬렉션은 각각 역할이 다르다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>컬렉션</td><td>내용</td><td>역할</td></tr>
</thead>
<tbody>
<tr>
<td><code>law_articles</code></td><td>법령 조문</td><td>답변의 기본 근거 축</td></tr>
<tr>
<td><code>ordinance_articles</code></td><td>지자체 조례/규칙</td><td>지역/인허가 질문 보강</td></tr>
<tr>
<td><code>precedents</code></td><td>판례</td><td>실제 분쟁 적용과 판단 경향</td></tr>
<tr>
<td><code>legal_interpretations</code></td><td>공식 법령해석</td><td>조문 문언의 공식 해석</td></tr>
<tr>
<td><code>ministry_interpretations</code></td><td>부처 실무 해석</td><td>행정 실무 적용과 민원 회신</td></tr>
<tr>
<td><code>administrative_trials</code></td><td>행정심판 재결례</td><td>처분/불복/구제 절차</td></tr>
<tr>
<td><code>constitutional_decisions</code></td><td>헌재 결정</td><td>위헌성/기본권/헌법소원</td></tr>
<tr>
<td><code>committee_decisions</code></td><td>위원회/권익위 결정</td><td>위원회 판단/민원 해결</td></tr>
</tbody>
</table>
</div><p>같은 질문이라도 <code>law_articles</code>는 주근거이고, <code>precedents</code>는 보조근거다. 이렇게 성격이 다른 문서를 raw score 하나로 일렬 정렬하면 답변 구조가 쉽게 흔들린다. 법령 조문이 답변의 뼈대가 되어야 하는데, 의미적 유사도가 높은 판례가 상위권을 독점해버리는 식이다.</p>
<hr />
<h2 id="heading-65287jqw7yyf7j20io2vhoyalo2vncdsnbtsnka">라우팅이 필요한 이유</h2>
<p>RAG 시스템에서 query routing은 이미 널리 알려진 패턴이다. Towards Data Science의 글이나 최근 arxiv에 올라온 RAGRouter 논문에서도 확인할 수 있듯이, 복수의 데이터 소스를 가진 RAG 시스템에서는 사용자 질문의 의도를 분석해 적합한 데이터 소스로 라우팅하는 것이 핵심이다. 모든 소스를 동일 강도로 검색하면 노이즈가 늘고, 검색 비용도 올라간다.</p>
<p>우리 시스템에서 라우팅이 특히 중요한 이유는 세 가지였다.</p>
<p><strong>첫째, 컬렉션 간 점수 비교가 불가능하다.</strong> 법령 조문과 판례의 임베딩 유사도 점수는 같은 의미가 아니다. 문서 길이, 표현 방식, 구조가 완전히 다르기 때문이다. 따라서 단순 점수 정렬이 아니라 lane 정책 중심의 병합이 필요하다.</p>
<p><strong>둘째, 질문 유형에 따라 필요한 컬렉션이 다르다.</strong> "근로기준법상 해고 절차가 어떻게 되나요?"라는 질문에는 법령 조문과 판례가 중요하지만, "서울시 주차장 조례"라는 질문에는 조례 컬렉션이 핵심이다. 헌법소원에 대한 질문에는 헌재 결정이 열려야 한다.</p>
<p><strong>셋째, 비용과 latency 문제다.</strong> 질문 하나에 8개 컬렉션을 전부 깊게 검색하면 불필요한 비용이 발생한다. 실제로 대부분의 질문은 2~4개 레인이면 충분하다.</p>
<hr />
<h2 id="heading-source-router">Source Router 설계 과정</h2>
<h3 id="heading-lane">Lane 구조 설계</h3>
<p>가장 먼저 8개 컬렉션을 세 가지 등급으로 분류했다.</p>
<p><strong>Anchor Lane</strong> --- <code>law_articles</code>는 항상 검색하고, 가장 깊게 검색한다. 법률 QA에서 법령 조문은 답변의 중심 근거다.</p>
<p><strong>Always-on Support Lane</strong> --- <code>precedents</code>, <code>legal_interpretations</code>, <code>ministry_interpretations</code>는 대부분의 질문에서 도움이 될 가능성이 높다. 항상 켜되, 기본 검색량은 작게 유지하고 질문 힌트가 강할 때만 더 깊게 검색한다.</p>
<p><strong>Triggered Lane</strong> --- <code>ordinance_articles</code>, <code>administrative_trials</code>, <code>constitutional_decisions</code>, <code>committee_decisions</code>는 특정 질문 유형에서만 의미가 있다. 기본은 off이고, 질문에 직접적인 단서가 있을 때만 확장한다.</p>
<p>이 구조를 기반으로, Source Router는 질문을 받아 각 컬렉션의 활성 상태(<code>off</code>, <code>support</code>, <code>expand</code>)를 결정하는 역할을 맡게 되었다.</p>
<h3 id="heading-prompt-only">Prompt-only 접근</h3>
<p>Source Router는 별도의 분류 모델을 학습시키지 않고, LLM 프롬프트만으로 구현했다. <code>gemini-3.1-flash-lite-preview</code> 모델에 질문을 넣으면 각 컬렉션의 활성화 수준과 confidence를 JSON으로 돌려주는 구조다. 이 접근을 택한 이유는 빠른 실험 반복이 가능하고, 법률 도메인의 미묘한 판단을 규칙 기반으로 커버하기 어렵기 때문이다.</p>
<hr />
<h2 id="heading-67ke7kce67oeio2pieqwgoyzgcdshkdtg50g6re86rgw">버전별 평가와 선택 근거</h2>
<p>48개 golden dataset 문항을 기준으로 v1부터 v1_6까지 총 7개 버전을 실험했다. 각 버전은 이전 버전의 실패 패턴을 분석한 뒤 프롬프트를 개선하는 방식으로 진행되었다.</p>
<h3 id="heading-7kce7lk0ioqysoqzvcdruytqtza">전체 결과 비교</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Version</td><td>Exact Score</td><td>Recall Score</td><td>Critical Miss</td><td>Core FP</td><td>Perfect</td><td>판단</td></tr>
</thead>
<tbody>
<tr>
<td><code>v1</code></td><td>66.7%</td><td>86.8%</td><td>7</td><td>86</td><td>0/48</td><td>기준선, 과발화 심각</td></tr>
<tr>
<td><code>v1_1</code></td><td>90.2%</td><td>90.2%</td><td>20</td><td>16</td><td>27/48</td><td>과발화 크게 감소</td></tr>
<tr>
<td><code>v1_2</code></td><td>94.0%</td><td>92.5%</td><td>13</td><td>14</td><td>31/48</td><td>triggered lane 개선</td></tr>
<tr>
<td><code>v1_3</code></td><td>96.4%</td><td>93.8%</td><td>10</td><td>11</td><td>37/48</td><td>실사용 후보 진입</td></tr>
<tr>
<td><code>v1_4</code></td><td>96.4%</td><td>93.3%</td><td>12</td><td>11</td><td>37/48</td><td>exact 동점, recall 약간 후퇴</td></tr>
<tr>
<td><code>v1_5</code></td><td>92.3%</td><td>94.8%</td><td>5</td><td>22</td><td>25/48</td><td>recall 실험, 너무 많이 열림</td></tr>
<tr>
<td><code>v1_6</code></td><td>96.4%</td><td>96.0%</td><td>4</td><td>10</td><td>37/48</td><td><strong>최종 선택</strong></td></tr>
</tbody>
</table>
</div><h3 id="heading-6rcbiouyhoyghoydmcdsl63tlaa">각 버전의 역할</h3>
<p><strong>v1 -&gt; v1_1</strong>: 초기 버전은 <code>legal_interpretations</code>, <code>ministry_interpretations</code>, <code>precedents</code>를 지나치게 자주 확장하는 과발화 문제가 심각했다. v1_1에서 source-specific 단서가 없으면 닫는 방향으로 전환하면서 core 과발화를 크게 줄였다.</p>
<p><strong>v1_1 -&gt; v1_2</strong>: <code>위원회</code>, <code>심판청구</code> 같은 단어의 오인식을 줄이고, direct source cue가 있는 경우 <code>expand</code>를 더 적극적으로 올렸다. triggered lane pass가 87.5%까지 상승했다.</p>
<p><strong>v1_2 -&gt; v1_3</strong>: <code>precedents</code>를 더 강하게 억제하고, 행정심판/헌재/위원회를 각각 독립 source로 읽도록 유도했다. 96.4%로 실사용 후보 수준에 도달했다.</p>
<p><strong>v1_4</strong>: v1_3과 동일 최고점. prompt-only 최적화가 사실상 plateau에 도달했음을 확인했다.</p>
<p><strong>v1_5</strong>: recall을 끌어올리기 위해 support lane을 공격적으로 열었다. critical miss는 5건으로 줄었지만, core false positive가 22건으로 급증하며 exact score가 92.3%로 떨어졌다. 방향은 맞았지만 균형이 깨졌다.</p>
<p><strong>v1_6</strong>: v1_5의 recall 개선은 일부 유지하면서, core expansion을 다시 direct-request 중심으로 조였다. exact 최고점 구간을 회복하면서 recall도 96.0%로 가장 높았다.</p>
<hr />
<h2 id="heading-recall">Recall 우선 기준의 의미</h2>
<p>최종 선택에서 가장 중요했던 기준 전환이 있다. 단순히 exact-match 총점만 보는 것이 아니라, <strong>recall-priority</strong>를 함께 보기로 한 것이다.</p>
<p>이유는 명확하다. 법률 QA에서 불필요한 source를 조금 더 여는 것과, 중요한 source를 놓쳐서 답변 근거를 아예 못 찾는 것은 심각도가 다르다. 판례가 핵심인 질문에서 판례 컬렉션이 닫혀 있으면 아무리 법령 조문을 잘 찾아도 부실한 답변이 된다. 반면 판례를 약간 과하게 열었더라도 후단의 merge와 rerank에서 걸러낼 수 있다.</p>
<p>이것은 RAG 시스템 전반에서 적용되는 원칙이기도 하다. 검색 단계에서는 precision보다 recall이 우선이다. 놓친 문서는 후단에서 복구할 수 없지만, 과하게 가져온 문서는 후단에서 걸러낼 수 있다. Aurelio Labs의 semantic-router나 LangChain 기반 routing 구현들도 결국 "적합한 소스를 빠짐없이 커버하는 것"을 최우선으로 둔다.</p>
<p>Recall-priority score는 이 원칙을 반영한 점수 체계다.</p>
<ul>
<li>열어야 하는 lane을 안 연 경우를 더 크게 감점한다</li>
<li><code>expand</code>가 필요한데 <code>support</code>로만 준 경우도 약한 miss로 집계한다</li>
<li>false positive는 상대적으로 덜 무겁게 본다</li>
</ul>
<p>이 기준으로 보면 v1_6는 exact-match 최고점 구간을 유지하면서 critical lane miss가 4건으로 가장 적었다. 놓치지 않으면서도 과하게 열지 않는 균형점이었다.</p>
<hr />
<h2 id="heading-64ko7j2aioqzvoygnoyzgcdtlzzqs4q">남은 과제와 한계</h2>
<p>v1_6를 최종 선택으로 고정했지만 완벽하지는 않다. 세 가지 잔존 리스크가 있다.</p>
<p><strong>confidence calibration</strong>: 현재 confidence 출력이 거의 항상 <code>high</code>다. 라우터가 확신이 낮을 때 이를 후단에 알릴 수 있어야 하는데, 아직 그 역할을 못 하고 있다.</p>
<p><strong>precedents expand 편향</strong>: 남은 오차의 대부분이 판례 컬렉션을 약간 과하게 여는 데 집중되어 있다. source 종류를 크게 잘못 읽는 문제는 줄었지만, 보조 판례 lane을 공격적으로 여는 경향이 남아 있다.</p>
<p><strong>prerewriter hint 정합성</strong>: 일부 문항에서 <code>graphLawNames</code>가 질문 주제와 맞지 않게 섞이는 현상이 있다. 이것은 source-router 자체보다 상위 단계인 prerewriter의 품질 문제다.</p>
<p>이 문제들은 prompt를 더 만지는 것보다 시스템의 다른 단계에서 해결하는 것이 더 효율적이라고 판단했다. confidence calibration 보정, prerewriter hint 정합성 점검, 필요하면 얇은 post-router guardrail을 추가하는 것이 다음 우선순위다.</p>
<hr />
<h2 id="heading-6rkw66gg">결론</h2>
<p>Source Router 설계에서 얻은 교훈을 정리하면 이렇다.</p>
<p><strong>이질적인 근거를 같은 점수축으로 섞지 않는다.</strong> 8개 컬렉션을 flat search로 한 줄 정렬하는 대신, lane 역할을 나누고 source-aware merge를 하는 구조가 맞다.</p>
<p><strong>검색 단계에서는 recall이 우선이다.</strong> 놓친 문서는 후단에서 복구할 수 없다. 과하게 가져온 문서는 걸러낼 수 있다. 이 비대칭이 recall-priority 기준의 핵심이다.</p>
<p><strong>prompt-only 최적화에도 plateau가 있다.</strong> v1부터 v1_6까지 6번의 반복으로 66.7%에서 96.4%까지 올렸지만, 그 이후에는 프롬프트를 더 만지는 것보다 시스템의 다른 병목을 해결하는 것이 더 생산적이다. 언제 멈추고 넘어갈지 판단하는 것도 실험 설계의 일부다.</p>
<p>Source Router는 전체 파이프라인의 한 조각일 뿐이다. 하지만 이 조각이 잘못 판단하면 아무리 좋은 검색기와 답변 생성기가 있어도 소용없다. 300만 건의 법률 문서 앞에서, 어떤 문을 열지 결정하는 것이 결국 첫 번째 품질 관문이다.</p>
<hr />
<p>Sources:</p>
<ul>
<li><a target="_blank" href="https://towardsdatascience.com/routing-in-rag-driven-applications-a685460a7220/">Routing in RAG Driven Applications - Towards Data Science</a></li>
<li><a target="_blank" href="https://arxiv.org/abs/2505.23052">RAGRouter: Learning to Route Queries to Multiple Retrieval-Augmented Language Models</a></li>
<li><a target="_blank" href="https://app.daily.dev/posts/dynamic-routing-in-rag-directing-user-queries-to-the-right-vector-store-with-open-source-models-wi50bfyo3">Dynamic Routing in RAG: Directing User Queries to the Right Vector Store</a></li>
<li><a target="_blank" href="https://towardsdatascience.com/rags-with-query-routing-5552e4e41c54/">How to Build Helpful RAGs with Query Routing - Towards Data Science</a></li>
<li><a target="_blank" href="https://github.com/aurelio-labs/semantic-router">Aurelio Labs semantic-router - GitHub</a></li>
<li><a target="_blank" href="https://learn.microsoft.com/en-gb/answers/questions/2239952/optimizing-rag-dynamic-query-routing-for-multi-sou">Optimizing RAG: Dynamic Query Routing for Multi-Source Answer Generation - Microsoft Q&amp;A</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (8) — 1차 아키텍처 확정: 실험에서 운영으로]]></title><description><![CDATA[실험이 끝나는 순간은 생각보다 조용하다. 극적인 성능 점프가 아니라, "더 이상 구조를 바꿔도 의미 있는 차이가 나지 않는다"는 판단이 쌓이면서 자연스럽게 온다. 법률 검색 서비스의 RAG 파이프라인을 약 한 달간 실험한 끝에, 나는 1차 아키텍처를 확정하고 운영 전환 준비에 들어갔다. 이 글은 그 과정에서 내린 결정들과 그 이유를 정리한 기록이다.

최종 아키텍처: 여섯 단계의 파이프라인
확정된 구조는 다음과 같다.
질문
-> PreRewri...]]></description><link>https://blog.dongjun.win/legal-ai-search-08-architecture-to-production</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-08-architecture-to-production</guid><category><![CDATA[AI]]></category><category><![CDATA[architecture]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[System Design]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Tue, 21 Apr 2026 04:54:45 GMT</pubDate><content:encoded><![CDATA[<p>실험이 끝나는 순간은 생각보다 조용하다. 극적인 성능 점프가 아니라, "더 이상 구조를 바꿔도 의미 있는 차이가 나지 않는다"는 판단이 쌓이면서 자연스럽게 온다. 법률 검색 서비스의 RAG 파이프라인을 약 한 달간 실험한 끝에, 나는 1차 아키텍처를 확정하고 운영 전환 준비에 들어갔다. 이 글은 그 과정에서 내린 결정들과 그 이유를 정리한 기록이다.</p>
<hr />
<h2 id="heading-7lwc7kkfioyvho2cpo2fjeyymdog7jes7isvioulqoqzhoydmcdtjizsnbttlitrnbzsnbg">최종 아키텍처: 여섯 단계의 파이프라인</h2>
<p>확정된 구조는 다음과 같다.</p>
<pre><code>질문
-&gt; PreRewriter (질문 변환)
-&gt; Vector 검색 / Graph 검색 병렬 수행
-&gt; Hybrid Merge
-&gt; 조건부 Rerank (multi_issue만)
-&gt; Answer 생성
</code></pre><p>일반적인 RAG 시스템이 query -&gt; retrieve -&gt; generate의 세 단계로 설명되는 것과 비교하면, 단계가 더 세분화되어 있다. 업계에서도 프로덕션 RAG 시스템은 단순한 세 단계 구조를 넘어서, 쿼리 변환(query rewriting), 하이브리드 검색(hybrid retrieval), 리랭킹(reranking) 같은 중간 레이어를 독립적으로 관측하고 교체할 수 있는 모듈형 구조로 진화하고 있다. 내가 도달한 구조도 결국 같은 방향이었다.</p>
<p>각 단계를 왜 이렇게 나눴는지 설명한다.</p>
<h3 id="heading-prerewriter">PreRewriter: 질문을 검색용 표현으로 변환</h3>
<p>사용자의 자연어 질문을 그대로 벡터 검색에 넣으면 잘 되는 경우도 있지만, 복합 질문에서는 한계가 뚜렷했다. PreRewriter는 질문을 "법적 결론"이 아니라 "검색을 잘하기 위한 표현"으로 바꾸는 역할을 한다.</p>
<p>출력은 단일 구조다. 질문 유형(single, multi_issue, calculation, procedure)을 분류하고, 벡터 검색용 대표 문장과 서브쿼리, 그래프 검색용 키워드와 법률명을 함께 생성한다. 하나의 질문에서 벡터와 그래프가 각각 다른 표현을 받는 셈이다.</p>
<p>다만 운영에서는 PreRewriter의 위치를 "기본값"이 아니라 "조건부 보조"로 잡기로 했다. 실험 결과, 원문 질문(raw query)을 그대로 쓰는 것이 가장 안정적인 baseline이었고, PreRewriter가 항상 baseline을 넘지는 못했기 때문이다. raw query를 완전히 치환하는 것은 금지하고, 병렬 후보 생성용으로 활용하는 방향이 안전하다.</p>
<h3 id="heading-67kh7yswioqygoydieqzvcdqt7jrnpjtliqg6rka7ioj7j2yiouzkeugrcdqtazsoba">벡터 검색과 그래프 검색의 병렬 구조</h3>
<p>검색은 두 개의 독립된 경로(lane)로 동시에 수행된다.</p>
<p>벡터 검색은 <code>pplx-embed-v1-4b</code> 임베딩 모델 기반의 dense + sparse 하이브리드 방식이다. 질문의 의미와 유사한 조문을 넓게 회수하는 역할이고, 1차 anchor를 잡는 기본축이다. 프로덕션 RAG에서도 벡터 검색과 BM25 같은 sparse 검색을 병렬로 돌리고 결과를 합치는 하이브리드 접근이 recall을 높이는 표준적인 방법으로 자리 잡고 있다.</p>
<p>그래프 검색은 Neo4j 기반이다. 조문 간 의미 관계를 Concept 노드와 typed edge(NEXT_STEP, REQUIRES, CALCULATION_INPUT, LIMITS 등)로 표현해서, 벡터 검색이 놓치는 보조 조문을 회수한다. 예를 들어 전세 보증금 관련 질문이 들어오면, 벡터는 대항력 조문을 잡고, 그래프는 우선변제권이나 임차권등기명령처럼 함께 필요한 조문들을 끌어온다.</p>
<p>중요한 것은 그래프를 "벡터의 후처리"가 아니라 "독립적인 retrieval lane"으로 취급했다는 점이다. 벡터가 anchor를 잡고, 그래프가 그 anchor를 보강하는 구조다.</p>
<h3 id="heading-hybrid-merge-recall">Hybrid Merge: recall을 해치지 않는 합치기</h3>
<p>두 lane의 결과를 병합할 때 가장 조심한 원칙은 recall 보존이었다. 특정 lane 하나가 나머지를 압도하지 않도록, weighted RRF(Reciprocal Rank Fusion) 계열 방식에 graph reserve를 결합했다. 그래프가 찾아온 보조 조문이 tail에서 완전히 사라지지 않게 하는 것이 핵심이었다.</p>
<p>이 단계는 실험 과정에서 과적합 위험이 가장 컸다. 특정 질문 하나를 살리려고 수치를 조정하면 다른 질문에서 깨지는 패턴이 반복됐기 때문에, 특정 문제 해결용 튜닝 대신 일반화 가능한 구조 정책만 채택했다.</p>
<h3 id="heading-rerank">조건부 Rerank: 필요한 곳에만 쓴다</h3>
<p>Rerank를 모든 질문에 적용하는 것이 아니라, <code>multi_issue</code> 유형에만 적용하기로 확정했다. 이 결정의 근거는 명확했다.</p>
<ul>
<li>single, scenario, tax 유형: retrieval만으로 이미 충분히 안정적. rerank를 걸어도 이득이 거의 없고 latency만 늘어남</li>
<li>multi_issue 유형: 여러 쟁점의 facet coverage를 균형 있게 맞추는 후단 판단이 필요. rerank로 top10, top20 순위를 당기는 효과가 뚜렷함</li>
</ul>
<p>조건부 rerank라는 선택은 비용 대비 효과를 극대화하는 실용적 판단이었다.</p>
<hr />
<h2 id="heading-7zse66gs7zse7yq4ioyepoqzhcdqsrdsoju">프롬프트 설계 결정</h2>
<p>RAG 시스템에서 프롬프트는 결국 파이프라인의 각 단계가 제 역할을 하도록 만드는 인터페이스다. 이번에 확정한 프롬프트 설계에서 가장 중요했던 원칙 두 가지가 있다.</p>
<p>첫째, PreRewriter 프롬프트의 "절대 금지" 규칙이다. 법적 판단이나 결론을 내리지 않고, 질문에 없는 구체적 조문번호나 확정적 결론을 추가하지 않는다. 법률명도 질문에 직접 언급되었거나 키워드로 유일하게 특정되는 경우에만 출력한다. 이 제약이 없으면 PreRewriter가 "추측"을 하기 시작하고, 검색 품질이 오히려 떨어진다.</p>
<p>둘째, Answer 생성에서 retrieval용 topK와 answer용 usedReferenceIds를 분리한 것이다. topK는 넓은 후보군이고, usedReferenceIds는 실제로 답변을 뒷받침하는 최소 근거다. answer 모델이 자기가 실제로 쓴 근거 ID를 명시적으로 돌려주게 함으로써, "검색은 됐지만 답변에 안 쓴 조문"과 "실제로 근거가 된 조문"을 구분할 수 있게 했다.</p>
<p>모델 선택도 역할별로 분리했다. retrieval과 PreRewriter에는 <code>gemini-2.5-flash-lite</code>, answer 생성에는 <code>gemini-2.5-flash</code>를 사용한다. answer 문장 품질과 보고서형 응답 구조의 안정성이 더 높은 모델이 필요했기 때문이다.</p>
<hr />
<h2 id="heading-7yj6rcaioq4soykgcdtmzxsoju">평가 기준 확정</h2>
<p>아키텍처를 확정하려면 "무엇이 더 나은가"를 판단할 기준이 먼저 있어야 한다. 최종 평가 기준은 질문 유형별 topK recall이었다.</p>
<p>확정 시점의 주요 결과를 보면:</p>
<ul>
<li>direct, scenario: K10부터 K50까지 전 문항 정답 회수 (20/20)</li>
<li>tax: K10부터 K50까지 전 문항 회수 (17/17)</li>
<li>multi_issue: K10에서 24/31, K40에서 전 문항 회수 (31/31)</li>
</ul>
<p>multi_issue에서 K10과 K40 사이의 격차가 가장 컸다. 이것이 rerank를 multi_issue에만 적용하기로 한 실증적 근거이기도 하다. single 유형은 top10만으로도 충분하지만, multi_issue는 더 넓은 후보에서 추려야 한다.</p>
<p>한 가지 중요했던 에피소드는 benchmark 정의 자체를 수정한 경우다. multi-2 문항에서 원래 정답으로 잡았던 민법 제766조(소멸시효)를 제756조(사용자책임)로 정정했다. 질문의 직접 쟁점과 정답 정의가 불일치하는 문제를 바로잡은 것이지, 성능 수치를 올리기 위한 조작이 아니었다. 평가 기준을 스스로 검증하고 정정하는 것도 실험 프로세스의 일부다.</p>
<p>holdout 세트에서는 domain 수준 일반화가 확인되었으나(21/21), 조문 단위 full recall 평가는 main benchmark만큼 정밀하지 않았다. 이것은 운영 전환 이후의 과제로 남겨두었다.</p>
<hr />
<h2 id="heading-7jq07jibioygho2zmcdssrttgaztj6zsnbjtirg">운영 전환 체크포인트</h2>
<p>아키텍처가 확정되었다고 해서 바로 운영에 넣을 수 있는 것은 아니다. 실험 코드와 운영 코드 사이에는 drift가 있고, 이를 메우는 작업이 필요하다.</p>
<p>내가 정리한 운영 전환 체크포인트는 다음과 같다.</p>
<p><strong>검색 인프라 정비.</strong> 실험에서는 8개 컬렉션(law_articles, ordinance_articles, precedents, legal_interpretations, ministry_interpretations, administrative_trials, constitutional_decisions, committee_decisions)을 한 덩어리로 다뤘지만, 운영에서는 source별 병렬 lane 파이프라인으로 분리해야 한다. 조문은 직접 근거, 판례와 해석례는 보강 근거로 역할이 다르기 때문이다.</p>
<p><strong>컬렉션 네이밍 분리.</strong> 연구용 <code>eval_*</code> 이름을 운영용으로 정리해야 한다. 연구 코드와 운영 코드가 같은 컬렉션을 바라보면 사고가 난다.</p>
<p><strong>Qdrant 결과를 최종 원문으로 믿지 않기.</strong> 대용량 문서는 chunk로 분할되어 저장되므로, Qdrant payload는 "검색용 대표 텍스트"이지 "최종 표시용 원문"이 아니다. 검색 결과의 sourceCollection과 documentId를 기준으로 원문 저장소에서 다시 읽는 흐름이 필요하다.</p>
<p><strong>chunking의 검색 의미론 이해.</strong> 긴 문서는 여러 point로 쪼개어 저장되고, 검색 시에는 documentId 기준으로 collapse한다. 판례처럼 긴 문서가 많은 source에서는 "전체 문서가 골고루 맞는지"보다 "어떤 chunk 하나가 강하게 맞는지"에 더 민감해진다. 이 특성을 전제로 랭킹을 봐야 한다.</p>
<p><strong>Top-K를 너무 일찍 줄이지 않기.</strong> K20과 K50 사이 차이가 실제로 컸다. 운영 초기에는 1차 회수를 넉넉하게 가져가고, 후단 merge와 selection에서 정리하는 방향이 안전하다.</p>
<p><strong>sourceType 기반 공통 reference 스키마 도입.</strong> 조문, 판례, 해석례, 결정례를 한 answer 파이프라인에서 다루려면, 검색 결과를 referenceId, sourceType, sourceCollection, documentId, title, displayText, score 같은 공통 구조로 통일해야 한다.</p>
<p>구현 우선순위는 source별 fan-out 검색기부터 시작해서, lane별 retrieval unit 정리, 공통 reference 스키마 도입, 원문 재조회 흐름 구축, 조문 anchor + 보조 source merge 규칙 확정, 조건부 rerank 연결, 마지막으로 graph lane 연결 순서로 잡았다.</p>
<hr />
<h2 id="heading-1">1차 마무리 회고</h2>
<p>한 달간의 실험을 마무리하며 느낀 것들이 몇 가지 있다.</p>
<p><strong>구조 선택이 모델 선택보다 중요했다.</strong> 임베딩 모델을 바꾸는 것보다, 벡터와 그래프를 병렬 lane으로 분리하고 hybrid merge 정책을 잡는 것이 성능에 더 큰 영향을 줬다. 업계 연구에서도 chunking 전략이 임베딩 모델 선택보다 retrieval 정확도에 더 큰 제약이 된다는 결과가 있는데, 내 경험도 비슷했다. 개별 컴포넌트의 품질보다 컴포넌트 간 연결 방식이 전체 성능을 결정한다.</p>
<p><strong>"항상 좋은" 전략은 없었다.</strong> PreRewriter가 대표적이다. 구조적으로 의미 있는 시도였지만, 모든 질문에서 항상 이득을 주지는 않았다. rerank도 마찬가지다. 전체에 걸면 latency만 늘고, multi_issue에만 걸면 효과적이었다. 결국 "언제 쓸 것인가"를 결정하는 것이 "무엇을 쓸 것인가"만큼 중요했다.</p>
<p><strong>실험 코드와 운영 코드의 drift는 불가피하다.</strong> 실험 중에는 빠르게 검증하기 위해 코드를 자주 바꾸고, 결과적으로 "최종 선택"과 "현재 코드 상태"가 일치하지 않는 구간이 생긴다. 재실행했을 때 rerank latency가 0으로 찍히는 것을 보고, 코드가 곧 사양이 아니라는 점을 확인했다. 그래서 이 문서가 단순 실험 기록이 아니라 구현 기준 사양 문서로서의 역할을 한다.</p>
<p><strong>남은 것은 연구가 아니라 구현이다.</strong> 아키텍처, 모델, 프롬프트, 평가 기준이 모두 확정되었다. 이제 해야 할 일은 이 사양을 안정적인 운영 코드로 옮기는 것이다. PreRewriter 결과를 서비스 요청 경로에 연결하고, 벡터/그래프 병렬 retrieval을 서비스 로직으로 분리하고, hybrid merge와 조건부 rerank를 연결하고, answer 생성과 reference selection을 붙이는 일이 남았다.</p>
<p>1차 아키텍처 확정은 끝이 아니라 운영이라는 다음 단계의 시작이다. 실험에서 확인한 것들이 실제 서비스에서도 동일하게 작동하는지, 그것을 증명하는 과정이 이제부터 시작된다.</p>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (7) — Graph RAG 도입기: Neo4j로 조문 간 의미 관계 구축]]></title><description><![CDATA[법률 QA 시스템을 만들면서 벡터 검색의 근본적인 한계에 부딪혔다. 사용자가 "부당해고 당했는데 어떻게 하나요?"라고 물으면 근로기준법 28조(구제신청)는 잘 찾는데, 함께 알아야 하는 23조(해고제한)와 26조(해고예고)는 Top-50에도 들어오지 않았다. 벡터 유사도만으로는 풀 수 없는 문제였고, 그래프 기반 확장이 필요했다. 이 글은 Neo4j를 도입해 법률 조문 간 의미 관계 그래프를 구축하고, 실제로 놓친 조문을 회수하기까지의 과정을 ...]]></description><link>https://blog.dongjun.win/legal-ai-search-07-graph-rag-neo4j</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-07-graph-rag-neo4j</guid><category><![CDATA[AI]]></category><category><![CDATA[graph database]]></category><category><![CDATA[Neo4j]]></category><category><![CDATA[RAG ]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Sat, 18 Apr 2026 05:56:41 GMT</pubDate><content:encoded><![CDATA[<p>법률 QA 시스템을 만들면서 벡터 검색의 근본적인 한계에 부딪혔다. 사용자가 "부당해고 당했는데 어떻게 하나요?"라고 물으면 근로기준법 28조(구제신청)는 잘 찾는데, 함께 알아야 하는 23조(해고제한)와 26조(해고예고)는 Top-50에도 들어오지 않았다. 벡터 유사도만으로는 풀 수 없는 문제였고, 그래프 기반 확장이 필요했다. 이 글은 Neo4j를 도입해 법률 조문 간 의미 관계 그래프를 구축하고, 실제로 놓친 조문을 회수하기까지의 과정을 정리한 것이다.</p>
<hr />
<h2 id="heading-1">1. 벡터 검색의 한계를 넘어서</h2>
<p>벡터 검색은 질문과 조문의 텍스트 유사도에 의존한다. 질문 표면에 키워드가 드러나는 조문은 잘 찾지만, 논리적으로 연결되어 있으면서 텍스트상 닮지 않은 조문은 놓친다.</p>
<p>구체적인 miss 패턴을 분석해 보니 5가지 유형으로 수렴했다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>관계 유형</td><td>의미</td><td>예시</td></tr>
</thead>
<tbody>
<tr>
<td>절차 체인</td><td>같은 절차의 단계들</td><td>해고제한 - 해고예고 - 구제신청</td></tr>
<tr>
<td>계산 체인</td><td>같은 계산의 구성요소</td><td>양도소득 범위 - 공제 - 세율</td></tr>
<tr>
<td>전제조건</td><td>A가 성립하려면 B가 필요</td><td>갱신청구권 - 대항력</td></tr>
<tr>
<td>동일 원인의 다른 효과</td><td>같은 원인에서 파생되는 별개 결과</td><td>계약해제 - 원상회복 vs 손해배상</td></tr>
<tr>
<td>기간/제한</td><td>권리의 존속 기간이나 제한 조건</td><td>불법행위 - 소멸시효</td></tr>
</tbody>
</table>
</div><p>복수 정답 11문항을 기준으로 벡터 검색이 놓치는 조문 14개를 추적했는데, 전부 이 5가지 관계 중 하나에 해당했다. 텍스트 유사도가 아니라 의미적 관계를 잡아야 하는 문제였다.</p>
<p>핵심 판단은 이것이었다. 그래프는 1차 검색기가 아니라 2차 확장기다. 벡터 검색으로 anchor 조문을 확보한 뒤, 그 anchor에서 그래프를 따라 숨은 보조 조문을 회수하는 구조가 맞다.</p>
<hr />
<h2 id="heading-2-neo4j">2. 왜 Neo4j인가</h2>
<p>이미 MongoDB를 문서 저장소로 쓰고 있었기 때문에, 처음에는 MongoDB의 <code>$graphLookup</code>을 검토했다. 하지만 금방 한계가 드러났다.</p>
<p><code>$graphLookup</code>은 단일 컬렉션 내 재귀 탐색만 가능하다. 우리가 필요한 건 조문 -&gt; 개념 -&gt; 조문이라는 크로스 노드 타입 탐색이었다. typed edge를 표현하려면 별도 컬렉션과 복잡한 <code>$lookup</code> 파이프라인 체인이 필요하고, 멀티홉 쿼리는 파이프라인 지옥이 된다.</p>
<p>Neo4j를 선택한 이유는 명확했다.</p>
<ul>
<li><strong>typed relationship이 1등 시민이다.</strong> 관계에 타입과 속성을 네이티브로 부여할 수 있다.</li>
<li><strong>Cypher 쿼리 언어의 표현력.</strong> "이 조문이 속한 Concept의 다른 모든 조문을 가져와라"를 한 줄로 쓸 수 있다.</li>
<li><strong>멀티홉 탐색이 빠르다.</strong> 관계 수에 비례하는 O(관계 수) 탐색이라 별도 인덱스 없이도 충분하다.</li>
<li><strong>벡터 인덱스 내장.</strong> Neo4j 5.11 이후로 같은 DB 안에서 vector + graph 검색이 가능하다(당장 쓰지는 않았지만 확장성 확보).</li>
</ul>
<p>역할 분리도 깔끔했다. MongoDB는 원본 문서 저장소, Qdrant는 벡터 검색, Neo4j는 의미 관계 그래프 전용. 각 DB가 잘하는 일을 맡기는 구조다.</p>
<p>참고로 GraphRAG 분야에서 Neo4j는 이미 사실상 표준 위치를 차지하고 있다. Neo4j가 공식으로 제공하는 <a target="_blank" href="https://github.com/neo4j/neo4j-graphrag-python">GraphRAG Python 패키지</a>도 있고, Qdrant와 Neo4j를 결합한 <a target="_blank" href="https://qdrant.tech/documentation/examples/graphrag-qdrant-neo4j/">하이브리드 검색 패턴</a>도 공식 문서에 소개되어 있다. 법률 도메인에서도 <a target="_blank" href="https://neo4j.com/blog/developer/from-legal-documents-to-knowledge-graphs/">법률 문서에서 지식 그래프를 구축하는 접근</a>이나 <a target="_blank" href="https://arxiv.org/html/2505.00039v2/">법률 규범의 계층적/시간적 구조를 Graph RAG로 다루는 연구</a>가 활발하게 진행되고 있어서, 방향성에 대한 확신을 가질 수 있었다.</p>
<p>설치는 Docker로 간단하게 진행했다.</p>
<pre><code class="lang-bash">docker run -d --name neo4j \
  -p 7474:7474 -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/&lt;your-password&gt; \
  -e NEO4J_PLUGINS=<span class="hljs-string">'["apoc"]'</span> \
  -v neo4j-data:/data -v neo4j-logs:/logs \
  neo4j:5-community
</code></pre>
<p>APOC 플러그인을 함께 넣은 이유는 JSON 파싱이나 배치 처리에 유용하기 때문이다.</p>
<hr />
<h2 id="heading-3-article-concept-article">3. 그래프 스키마 설계: Article - Concept - Article</h2>
<p>스키마 설계의 핵심 아이디어는 Concept이라는 중간 노드를 두는 것이다.</p>
<h3 id="heading-64w465ocio2dgoyehq">노드 타입</h3>
<pre><code>(:Article {id, lawId, lawName, articleNumber, title})
(:Concept {id, name, type, description, lawName})
</code></pre><p>Article은 개별 법률 조문이고, Concept은 "해고 절차", "종합소득세 계산", "임대차 보호" 같은 법률 개념이다. Concept의 type은 <code>procedure</code>, <code>calculation</code>, <code>right</code>, <code>obligation</code>, <code>definition</code>, <code>penalty</code> 중 하나를 갖는다.</p>
<h3 id="heading-7jej7keaio2dgoyehq">엣지 타입</h3>
<p>조문과 개념 사이의 관계:</p>
<pre><code>(Article)-[:PART_OF {role}]-&gt;(Concept)
</code></pre><p>role에는 "요건", "기간", "효과", "세율", "공제", "정의", "절차", "예외" 등이 들어간다.</p>
<p>조문 간 직접 관계:</p>
<pre><code>(Article)-[:NEXT_STEP]-&gt;(Article)         <span class="hljs-comment">// 절차 순서</span>
(Article)-[:REQUIRES]-&gt;(Article)          <span class="hljs-comment">// 전제조건</span>
(Article)-[:CALCULATION_INPUT]-&gt;(Article) <span class="hljs-comment">// 계산 흐름</span>
(Article)-[:LIMITS]-&gt;(Article)            <span class="hljs-comment">// 기간/제한</span>
(Article)-[:EXCEPTION_OF]-&gt;(Article)      <span class="hljs-comment">// 예외</span>
</code></pre><h3 id="heading-concept">Concept이 핵심인 이유</h3>
<p>벡터 검색이 28조(구제신청)를 찾으면, 이 구조에서는 다음과 같이 확장된다:</p>
<pre><code><span class="hljs-number">28</span>조 --PART_OF--&gt; <span class="hljs-string">"해고 절차"</span> Concept &lt;--PART_OF-- <span class="hljs-number">23</span>조(해고제한)
                                      &lt;--PART_OF-- <span class="hljs-number">26</span>조(해고예고)
</code></pre><p>Concept 하나를 경유하는 1홉 탐색으로 같은 그룹의 모든 조문이 자동 연결된다. 조문 간 직접 관계만으로는 모든 쌍을 일일이 정의해야 하지만, Concept 노드를 두면 그룹 멤버십 하나로 N:N 연결이 만들어진다.</p>
<hr />
<h2 id="heading-4-llm">4. LLM 기반 관계 추출</h2>
<p>관계를 만드는 방법으로 co-citation(판례에서 함께 인용된 조문 쌍)과 LLM 추출 두 가지를 검토했다.</p>
<p>co-citation은 이미 데이터가 있어서 바로 쓸 수 있다는 장점이 있었지만, 우리가 풀려는 문제와 맞지 않았다. miss 패턴의 본질은 "판례에서 같이 인용되었느냐"가 아니라 "논리적으로 같은 절차나 계산에 속하느냐"였다. 예를 들어 근로기준법 28조와 23조는 판례 공출현 빈도가 높지만, 28조와 26조(해고예고)는 상대적으로 낮다. 그런데 논리적으로 26조도 해고 절차의 핵심 구성요소다. 통계적 상관이 아니라 의미적 관계가 필요했다.</p>
<p>LLM 추출의 핵심 설계는 이렇다. 법률 전체 조문 목록(조번호 + 제목 + 본문)을 한번에 LLM에게 주고, Concept 그룹핑 + 각 조문의 role + 조문 간 직접 관계를 출력하게 한다. 조문을 하나씩 보는 게 아니라 전체 맥락을 주는 것이 정확한 관계 추출의 전제조건이다.</p>
<h3 id="heading-1-2">프롬프트 튜닝: 1차 실패와 2차 성공</h3>
<p>1차 시도에서는 Concept이 36개 나왔다. 조문 42개 대비 거의 1:1이라 그룹핑의 의미가 없었고, miss 회수에 실패했다.</p>
<p>2차 시도에서 다음 규칙을 프롬프트에 추가했다:</p>
<ul>
<li>"일반인의 관점에서 함께 알아야 하는 조문을 묶어라"</li>
<li>Concept 수를 조문수/10 ~ 조문수/5로 제한</li>
<li>"적극적으로 중복 배정하라" (하나의 조문이 여러 Concept에 소속 가능)</li>
<li>조문 수가 150개를 초과하면 요약 모드(제목 + 첫 200자만 전달)</li>
</ul>
<p>"일반인의 관점"이라는 지시가 특히 효과적이었다. 법률 전문가 관점에서는 조문 하나하나가 독립적 의미를 갖지만, 일반인 관점에서는 "해고당하면 알아야 할 것들"처럼 실용적 단위로 묶인다. 이 관점이 miss 회수에 정확히 맞았다.</p>
<h3 id="heading-64ya7ziviouyleulocdsspjrpqw">대형 법률 처리</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>법률</td><td>조문 수</td><td>사용 모델</td><td>비고</td></tr>
</thead>
<tbody>
<tr>
<td>주택임대차보호법</td><td>42</td><td>gemini-2.5-flash-lite</td><td>문제 없음</td></tr>
<tr>
<td>근로기준법</td><td>145</td><td>gemini-2.5-flash-lite</td><td>문제 없음</td></tr>
<tr>
<td>국세기본법</td><td>167</td><td>gemini-2.5-flash</td><td>lite 모델 JSON 깨짐</td></tr>
<tr>
<td>법인세법</td><td>247</td><td>gemini-2.5-flash</td><td>요약 모드 + JSON 복구 필요</td></tr>
<tr>
<td>소득세법</td><td>382</td><td>gemini-2.5-flash</td><td>요약 모드</td></tr>
<tr>
<td>민법</td><td>1,307</td><td>-</td><td>편별 분할 필요</td></tr>
</tbody>
</table>
</div><p>조문 수가 늘어나면서 두 가지 문제가 발생했다. 하나는 LLM의 JSON 출력이 깨지는 것이고, 다른 하나는 컨텍스트 윈도우 한계다. 167조 이상부터는 flash-lite에서 flash로 모델을 올렸고, 247조 이상부터는 조문 본문을 요약 모드로 전달했다. 민법(1,307조)은 편(채권편, 물권편 등)별로 분할해서 별도 처리했다.</p>
<hr />
<h2 id="heading-5-6">5. 6개 법률 적재와 검증</h2>
<h3 id="heading-7kcb7j6sioqysoqzva">적재 결과</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>법률</td><td>조문</td><td>Concept</td><td>Membership</td><td>Relations</td></tr>
</thead>
<tbody>
<tr>
<td>주택임대차보호법</td><td>42</td><td>6</td><td>60</td><td>39</td></tr>
<tr>
<td>근로기준법</td><td>145</td><td>19</td><td>131</td><td>163</td></tr>
<tr>
<td>국세기본법</td><td>167</td><td>24</td><td>175</td><td>98</td></tr>
<tr>
<td>법인세법</td><td>247</td><td>24</td><td>202</td><td>165</td></tr>
<tr>
<td>소득세법</td><td>382</td><td>45</td><td>422</td><td>119</td></tr>
<tr>
<td><strong>합계</strong></td><td><strong>983</strong></td><td><strong>117</strong></td><td><strong>971</strong></td><td><strong>584</strong></td></tr>
</tbody>
</table>
</div><p>983개 조문에서 117개 Concept이 추출되었고, 971개의 멤버십(조문-Concept 연결)과 584개의 직접 관계가 만들어졌다.</p>
<p>이후 민법, 형법, 부가가치세법을 추가 적재하면서 정합성 수정도 함께 진행했다. Concept.id를 법률별 namespace로 변경하고, cross-law PART_OF를 제거하고, orphan 노드를 정리했다. 특히 대형 법률에서 fallback(그래프에 제대로 연결되지 못한 조문) 비율을 줄이는 작업이 중요했는데, 법인세법의 fallback을 27%에서 0%로, 부가가치세법을 17%에서 0%로 개선했다.</p>
<h3 id="heading-miss">Miss 회수 검증</h3>
<p>벡터 검색이 놓친 14개 조문에 대해 1홉 Concept 경유 탐색을 테스트한 결과:</p>
<ul>
<li><strong>1홉 회수: 8/14 (57%)</strong></li>
<li><strong>2홉 포함: 9/14 (64%)</strong></li>
<li><strong>실패: 5/14</strong></li>
</ul>
<p>실패한 5건은 모두 소득세법의 "소득 - 공제 - 세율" 계산 체인이었다. 소득세법의 Concept이 45개로 너무 세분화되어 "이자소득"과 "세율"이 별개 Concept으로 분리된 것이 원인이었다. 이 부분은 프롬프트에 "소득 - 공제 - 세율은 같은 계산 체인"이라는 힌트를 추가하면 개선 가능하다.</p>
<p>성공한 케이스를 보면, 이 접근의 유효성이 분명했다. 예를 들어 "부당해고" 질문에서 벡터가 28조(구제신청)만 찾았을 때, "해고 및 고용 보장" Concept을 경유해 23조(해고제한)와 26조(해고예고)를 모두 회수했다.</p>
<hr />
<h2 id="heading-6">6. 결과와 교훈</h2>
<p>최종적으로 검색 파이프라인은 다음 구조로 확정되었다.</p>
<pre><code>질문
-&gt; 프리라이터 (질문 재작성)
-&gt; 벡터 검색 / Neo4j 그래프 확장 (병렬)
-&gt; 하이브리드 병합
-&gt; multi_issue 질문일 때만 LLM rerank
-&gt; 답변 생성
</code></pre><p>벤치마크 기준으로 K10에서 전체 정답 회수를 달성했고, 더 이상 임베딩 모델이나 provider 비교를 계속할 필요가 없는 수준이 되었다.</p>
<h3 id="heading-64m7jwe67o066mwioygleumro2vmouklcdqtzdtm4g">돌아보며 정리하는 교훈</h3>
<p><strong>그래프는 만능이 아니다.</strong> 모든 질문에 그래프 확장을 태우면 노이즈가 늘어난다. 질문 유형(절차형, 계산형, 권리형, 단순 조회형)에 따라 확장 정책을 달리하는 것이 중요하다.</p>
<p><strong>Concept의 적정 수가 성패를 가른다.</strong> 1차 시도에서 조문과 1:1로 나온 Concept은 의미가 없었다. "일반인 관점의 실용적 그룹"이라는 프롬프트 지시가 적절한 추상화 수준을 만들어냈다.</p>
<p><strong>LLM 추출은 co-citation보다 직접적이다.</strong> 법률은 이미 구조화된 텍스트이기 때문에 LLM이 전체 조문 목록만 보고도 논리적 그룹핑이 가능하다. 통계적 상관보다 의미적 관계가 필요한 도메인에서는 LLM 추출이 더 효과적이다.</p>
<p><strong>대형 법률은 별도 전략이 필요하다.</strong> 조문 수가 150개를 넘으면 모델 등급을 올리고, 250개를 넘으면 요약 모드를 적용하고, 1,000개를 넘으면 편별 분할이 필요하다. 이 경계값을 미리 알았다면 시행착오를 줄일 수 있었을 것이다.</p>
<p><strong>역할 분리가 깔끔한 시스템을 만든다.</strong> MongoDB(원본 저장), Qdrant(벡터 검색), Neo4j(의미 관계 확장)라는 세 DB의 역할이 명확하게 나뉘면서 각 구성 요소를 독립적으로 개선할 수 있게 되었다.</p>
<p>벡터 검색만으로 충분하지 않다는 걸 인정하고, 그래프라는 다른 축을 추가한 것이 이 프로젝트에서 가장 큰 전환점이었다.</p>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (6) — 하이브리드 검색과 쿼리 분해 실험기]]></title><description><![CDATA[벡터 검색만으로 단답형 질문은 거의 100% recall을 달성했다. 그런데 "부당해고 구제절차와 관련 판례"처럼 정답이 여러 개인 복수정답 질문에서는 벡터 검색이 한계를 드러냈다. 정답 조문 중 일부만 Top-10에 들어오고, 나머지는 빠지는 문제가 반복됐다. 자연스럽게 다음 질문이 떠올랐다. 벡터만으로 안 되면 키워드를 섞으면 어떨까?
업계의 하이브리드 검색 흐름
하이브리드 검색은 RAG 파이프라인에서 이미 표준에 가까운 접근법이 됐다. D...]]></description><link>https://blog.dongjun.win/legal-ai-search-06-hybrid-search-query-decomposition</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-06-hybrid-search-query-decomposition</guid><category><![CDATA[AI]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[search]]></category><category><![CDATA[vector database]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Thu, 16 Apr 2026 00:26:07 GMT</pubDate><content:encoded><![CDATA[<p>벡터 검색만으로 단답형 질문은 거의 100% recall을 달성했다. 그런데 "부당해고 구제절차와 관련 판례"처럼 정답이 여러 개인 복수정답 질문에서는 벡터 검색이 한계를 드러냈다. 정답 조문 중 일부만 Top-10에 들어오고, 나머지는 빠지는 문제가 반복됐다. 자연스럽게 다음 질문이 떠올랐다. 벡터만으로 안 되면 키워드를 섞으면 어떨까?</p>
<h2 id="heading-7jef6roe7j2yio2vmoydtou4joumroutncdqsodsg4kg7z2q66ae">업계의 하이브리드 검색 흐름</h2>
<p>하이브리드 검색은 RAG 파이프라인에서 이미 표준에 가까운 접근법이 됐다. Dense retrieval(벡터 검색)은 의미적 유사성을 잘 잡지만, 법령 번호나 조문명 같은 정확한 식별자를 놓치는 경우가 있다. 반대로 BM25 같은 sparse retrieval은 키워드 매칭에 강하지만 의미적 확장이 안 된다. 2025~2026년 시점에서 Pinecone, Weaviate, Qdrant 등 주요 벡터 데이터베이스는 모두 하이브리드 검색을 지원하고 있고, 실무에서도 둘을 병합하는 것이 단독 사용보다 일관적으로 좋은 성능을 보인다는 결과가 축적되어 있다.</p>
<p>다만 나의 경우는 일반적인 BM25 + 벡터 조합이 아니었다. 법률 도메인의 특성상, 문서 간 인용 관계(citation graph)를 활용하는 그래프 검색을 키워드 검색 대신 사용했다.</p>
<h2 id="heading-documentrefs">document_refs: 인용 그래프를 검색에 활용하기</h2>
<h3 id="heading-647309">647,309개의 인용 관계</h3>
<p>법률 문서는 서로를 인용한다. 판례는 근거 조문을 인용하고, 해석례는 관련 판례를 참조한다. 이 인용 관계를 <code>document_refs</code>라는 엣지 컬렉션으로 구축했다. 총 647,309건의 관계가 담겼다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>관계 유형</td><td>건수</td></tr>
</thead>
<tbody>
<tr>
<td>국세판례 -&gt; 법조문</td><td>178,289</td></tr>
<tr>
<td>해석례 -&gt; 법조문</td><td>177,060</td></tr>
<tr>
<td>판례 -&gt; 법조문</td><td>128,696</td></tr>
<tr>
<td>판례 -&gt; 판례</td><td>105,238</td></tr>
<tr>
<td>국세판례 -&gt; 판례</td><td>52,676</td></tr>
<tr>
<td>해석례 -&gt; 판례</td><td>5,350</td></tr>
</tbody>
</table>
</div><h3 id="heading-co-citation">그래프 검색의 원리: Co-citation</h3>
<p>그래프 검색의 핵심 아이디어는 co-citation이다. 사용자 질문에서 특정 조문(예: 근로기준법 제27조)이 언급되면, 그 조문을 인용한 모든 문서를 먼저 찾고, 그 문서들이 함께 인용한 다른 조문을 집계한다. "이 조문과 자주 함께 인용되는 조문"을 찾는 셈이다.</p>
<p>기존에는 판례 컬렉션 하나에서 <code>_parsedLawRefs</code> 필드를 <code>$unwind</code>해서 공출현을 찾았는데, <code>document_refs</code> 도입 후에는 판례, 해석례, 국세판례 전체를 아우르는 2단계 조회로 바뀌었다. 검색 범위가 훨씬 넓어진 것이다.</p>
<h2 id="heading-rrf">하이브리드 파이프라인과 RRF 병합</h2>
<p>파이프라인 구조는 다음과 같다.</p>
<pre><code>사용자 질문 -&gt; 프리라이터(Gemini Flash Lite) -&gt; 벡터검색 + 그래프검색(병렬) -&gt; RRF 병합 -&gt; Top<span class="hljs-number">-10</span>
</code></pre><p>벡터 검색과 그래프 검색을 병렬로 실행한 뒤, RRF(Reciprocal Rank Fusion)로 두 순위 목록을 하나로 합친다.</p>
<pre><code>점수(문서) = <span class="hljs-number">1</span>/(k + 벡터순위) + <span class="hljs-number">1</span>/(k + 그래프순위)    (k=<span class="hljs-number">60</span>)
</code></pre><p>양쪽 모두에 등장한 문서가 높은 점수를 받는 구조다. 업계에서도 RRF는 하이브리드 검색의 표준 병합 전략으로 널리 쓰이고 있다.</p>
<h3 id="heading-7zse66as65287j207yswioyepoqzhdog7is4ioqwgoyngcdsojhqt7zrspu">프리라이터 설계: 세 가지 접근법</h3>
<p>벡터 검색과 그래프 검색은 입력 형식이 다르다. 벡터는 자연어 쿼리가, 그래프는 법령명과 키워드가 필요하다. 이 차이를 프리라이터(prerewriter)로 해결하려 했고, 세 가지 방식을 실험했다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>방식</td><td>하이브리드</td><td>벡터만</td><td>차이</td></tr>
</thead>
<tbody>
<tr>
<td>A. 통합 프롬프트 1개</td><td>17/31 (55%)</td><td>15/31 (48%)</td><td>+2</td></tr>
<tr>
<td>B. 합친 프롬프트 (벡터+그래프 원칙 통합)</td><td>15/31 (48%)</td><td>12/31 (39%)</td><td>+3</td></tr>
<tr>
<td>C. 분리 (벡터용 + 그래프용 각각)</td><td>16/31 (52%)</td><td>14/31 (45%)</td><td>+2</td></tr>
</tbody>
</table>
</div><p>방식 B가 그래프 기여도(+3)는 가장 높았지만, 벡터 성능 자체가 12/31로 크게 떨어졌다. 벡터 검색용 searchQuery 표현을 그래프 쪽 요구사항에 맞추다 보니 벡터 품질이 훼손된 것이다.</p>
<p>가장 중요한 발견은 <strong>벡터 검색이 searchQuery 표현에 극도로 민감하다</strong>는 점이었다. 프롬프트를 조금만 바꿔도 성능이 12~15 사이에서 크게 흔들렸다. 반면 그래프 검색은 일관적으로 +1~3의 안정적인 기여를 보여줬다.</p>
<h2 id="heading-68">전체 68개 질문 평가 결과</h2>
<p>프리라이터 분리 방식(C)으로 전체 68개 질문을 평가했다.</p>
<pre><code>하이브리드: <span class="hljs-number">74</span>/<span class="hljs-number">88</span> (<span class="hljs-number">84</span>%) vs 벡터만: <span class="hljs-number">71</span>/<span class="hljs-number">88</span> (<span class="hljs-number">81</span>%)  -&gt; +<span class="hljs-number">3</span>
Full Recall@<span class="hljs-number">10</span>: <span class="hljs-number">59</span>/<span class="hljs-number">68</span> vs <span class="hljs-number">58</span>/<span class="hljs-number">68</span>
</code></pre><div class="hn-table">
<table>
<thead>
<tr>
<td>그룹</td><td>질문 수</td><td>결과</td><td>비고</td></tr>
</thead>
<tbody>
<tr>
<td>단답 기본 (Q1~Q20)</td><td>20</td><td>전부 정답</td><td>벡터만으로 충분</td></tr>
<tr>
<td>시나리오 (Q101~Q120)</td><td>20</td><td>전부 정답</td><td>벡터만으로 충분</td></tr>
<tr>
<td>세법 (Q201~Q217)</td><td>17</td><td>전부 정답</td><td>벡터만으로 충분</td></tr>
<tr>
<td>복수정답 (Q301~Q311)</td><td>11</td><td>MISS 발생</td><td>그래프 +3 개선</td></tr>
</tbody>
</table>
</div><p><strong>단답형, 시나리오형, 세법 질문은 벡터 검색만으로 100%였다.</strong> 문제는 오직 복수정답 케이스에서만 발생했다. 예를 들어 Q5(부당해고)는 벡터만으로는 정답 3개 중 1개만 찾았는데, 그래프 검색 덕분에 3/3 ALL을 달성했다. Q9(양도소득)에서는 그래프가 소득세법 104조를 매번 찾아줬다.</p>
<p>하지만 RRF 병합의 부작용도 있었다. Q8, Q10 같은 케이스에서는 그래프의 엉뚱한 결과가 벡터가 정확히 찾은 문서를 밀어내는 현상이 관찰됐다.</p>
<h2 id="heading-7l866asiou2ho2vtcdsi6ttl5g6ioq4soumgoyzgcdtmitsi6q">쿼리 분해 실험: 기대와 현실</h2>
<p>복수정답 문제를 더 근본적으로 풀기 위해 쿼리 분해(Query Decomposition)를 시도했다. 아이디어는 단순하다. 복수 주제 질문을 서브질문으로 쪼개고, 각각 독립적으로 검색한 뒤 결과를 병합하는 것이다.</p>
<pre><code><span class="hljs-string">"법인세 얼마? 접대비 비용처리?"</span>
  -&gt; 서브<span class="hljs-number">1</span>: <span class="hljs-string">"법인세 과세표준과 세율"</span> -&gt; Top<span class="hljs-number">-5</span> 검색
  -&gt; 서브<span class="hljs-number">2</span>: <span class="hljs-string">"접대비 비용처리 한도"</span> -&gt; Top<span class="hljs-number">-5</span> 검색
  -&gt; RRF 병합 -&gt; 최종 Top<span class="hljs-number">-10</span>
</code></pre><h3 id="heading-vs">치팅 실험 vs 클린 실험</h3>
<p>먼저 few-shot 예시에 평가 질문과 유사한 예시를 넣어 "치팅 실험"을 했다.</p>
<pre><code>치팅: 하이브리드 <span class="hljs-number">20</span>/<span class="hljs-number">31</span> (<span class="hljs-number">65</span>%) vs 벡터만 <span class="hljs-number">19</span>/<span class="hljs-number">31</span> (<span class="hljs-number">61</span>%)
</code></pre><p>Q11(소득세 계산)이 1/4에서 4/4로 극적으로 개선됐다. 하지만 이건 오버피팅이다. 평가 질문과 무관한 few-shot으로 바꾼 "클린 실험" 결과는 달랐다.</p>
<pre><code>클린: 하이브리드 <span class="hljs-number">15</span>/<span class="hljs-number">31</span> (<span class="hljs-number">48</span>%) vs 벡터만 <span class="hljs-number">16</span>/<span class="hljs-number">31</span> (<span class="hljs-number">52</span>%)
</code></pre><p>기존 단일 쿼리 방식(17/31)보다 오히려 성능이 하락했다.</p>
<h3 id="heading-7jmcioylpo2mqo2wioucma">왜 실패했나</h3>
<p>실패 원인은 세 가지였다. 첫째, LLM이 분해를 안 하는 경우가 있었다. Q11은 4개 주제를 담고 있는데 LLM이 단일 질문으로 판단해버렸다. 둘째, 분해된 쿼리의 표현이 벡터 검색에 맞지 않았다. "담장 파손"이라는 구체적 표현이 "불법행위"라는 추상적 법률 용어로 바뀌면서 검색 품질이 떨어졌다. 셋째, few-shot 예시 의존도가 너무 높아서 일반화가 불가능했다.</p>
<p>근본적인 문제는 명확했다. <strong>프롬프트 튜닝은 테스트셋 오버피팅일 뿐, 실제 서비스에서의 일반화를 보장할 수 없다.</strong></p>
<h2 id="heading-7iuk7zey7jeq7isciouwsoyatcdqsom">실험에서 배운 것</h2>
<p>이 실험들에서 얻은 교훈을 정리하면 다음과 같다.</p>
<ol>
<li><p><strong>벡터 검색은 단일 정답 질문에 이미 충분하다.</strong> 57개 단답형/시나리오형 질문에서 100% recall을 달성했다. 문제 영역을 정확히 식별하는 것이 중요하다.</p>
</li>
<li><p><strong>그래프 검색은 안정적이지만 제한적이다.</strong> 복수정답 케이스에서 일관적으로 +1~3의 기여를 했지만, 그 이상의 극적인 개선은 없었다.</p>
</li>
<li><p><strong>RRF 병합은 양날의 검이다.</strong> 두 검색 결과를 합치는 과정에서 오히려 정답이 밀려나는 부작용이 있었다. 가중치 조정이나 다른 병합 전략이 필요하다.</p>
</li>
<li><p><strong>쿼리 분해는 (적어도 이 시점에서는) 효과가 없었다.</strong> few-shot 의존도가 높고, LLM의 분해 판단이 불안정하며, 분해된 쿼리가 벡터 검색에 최적화되지 않았다.</p>
</li>
<li><p><strong>프리라이터 표현이 벡터 성능을 좌우한다.</strong> 이것이 가장 실용적인 발견이었다. 검색 아키텍처를 복잡하게 만드는 것보다, 벡터 검색에 들어가는 쿼리 표현 자체를 개선하는 것이 더 효과적일 수 있다.</p>
</li>
</ol>
<p>이 실험 이후 방향은 두 가지로 좁혀졌다. 하나는 검색 후 리랭킹(post-retrieval reranking)으로 Top-30 후보를 뽑은 뒤 LLM으로 정밀 순위를 매기는 것, 다른 하나는 프리라이터의 쿼리 표현 품질 자체를 높이는 것이었다.</p>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (5) — Query Rewriting: 프롬프트 진화와 subQuery 실험]]></title><description><![CDATA[법률 QA 시스템의 검색 품질을 끌어올리기 위해 query rewriting 프롬프트를 반복 개선하고, sub-query decomposition까지 도입해 본 실험 기록이다. 결론부터 말하면, 프롬프트 개선은 효과가 있었지만 한계가 명확했고, sub-query 전략은 기대만큼의 돌파구가 되지 못했다.

배경: V2까지의 상황
이전 글에서 다뤘듯이, prerewriter V2는 Gemini 2.5 Flash-Lite 모델 기준으로 raw que...]]></description><link>https://blog.dongjun.win/legal-ai-search-05-query-rewriting-prompt-evolution</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-05-query-rewriting-prompt-evolution</guid><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[Prompt Engineering]]></category><category><![CDATA[RAG ]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Tue, 14 Apr 2026 01:07:27 GMT</pubDate><content:encoded><![CDATA[<p>법률 QA 시스템의 검색 품질을 끌어올리기 위해 query rewriting 프롬프트를 반복 개선하고, sub-query decomposition까지 도입해 본 실험 기록이다. 결론부터 말하면, 프롬프트 개선은 효과가 있었지만 한계가 명확했고, sub-query 전략은 기대만큼의 돌파구가 되지 못했다.</p>
<hr />
<h2 id="heading-v2">배경: V2까지의 상황</h2>
<p>이전 글에서 다뤘듯이, prerewriter V2는 Gemini 2.5 Flash-Lite 모델 기준으로 raw query 대비 오히려 성능이 떨어지는 경우가 많았다. 쿼리를 다시 써주는 것이 항상 좋은 것은 아니라는 교훈을 얻은 셈이다. 그래서 V3에서는 프롬프트 자체를 근본적으로 재설계했다.</p>
<p>한편 당시 최고 baseline은 <code>pplx-embed-v1-4b</code> 임베딩 모델에 raw query를 그대로 넣는 조합이었다. Recall@50 기준 30/31, Full Recall@50 기준 10/11. prerewriter가 이 baseline을 넘지 못하면 존재 의의가 없는 상황이었다.</p>
<blockquote>
<p><strong>참고</strong>: 이 30/31 수치는 초기 평가 기준 기준이다. 이후 multi-2 문항의 정답 정의를 재검토하면서(민법 766조 → 756조) benchmark를 수정했고, graph 검색과 결합하여 최종 31/31을 달성했다. 자세한 내용은 이후 "1차 아키텍처 확정: 실험에서 운영으로" 편에서 다룬다.</p>
</blockquote>
<hr />
<h2 id="heading-v3">V3: 프롬프트 개선의 효과</h2>
<p>V3 프롬프트는 2026년 3월 9일에 <code>prerewriter-unified-v3.ts</code>로 구현해서 평가했다. 모델은 동일하게 Gemini 2.5 Flash-Lite, 임베딩 5종 전체에 대해 복수정답 질문셋으로 측정했다.</p>
<h3 id="heading-recall50-full-recall50">핵심 결과 (Recall@50 / Full Recall@50)</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>임베딩 모델</td><td>raw</td><td>V3 prerewrite</td></tr>
</thead>
<tbody>
<tr>
<td>bge-m3</td><td>28/31, 8/11</td><td>28/31, 8/11</td></tr>
<tr>
<td>pplx-embed-v1-0.6b</td><td>28/31, 8/11</td><td><strong>29/31, 9/11</strong></td></tr>
<tr>
<td>pplx-embed-v1-4b</td><td><strong>30/31, 10/11</strong></td><td>29/31, 9/11</td></tr>
<tr>
<td>pplx-embed-context-v1-0.6b</td><td>26/31, 6/11</td><td><strong>28/31, 8/11</strong></td></tr>
<tr>
<td>pplx-embed-context-v1-4b</td><td>28/31, 8/11</td><td>27/31, 7/11</td></tr>
</tbody>
</table>
</div><p>V2 대비 확실한 개선이 있었다. V2는 raw보다 성능을 깎아먹는 경우가 잦았는데, V3는 최소한 그런 참사는 없었다. 특히 두 가지 임베딩에서 의미 있는 향상을 확인했다.</p>
<ul>
<li><code>pplx-embed-v1-0.6b</code>: 28/31에서 29/31로, Full Recall도 8/11에서 9/11로 상승</li>
<li><code>pplx-embed-context-v1-0.6b</code>: 26/31에서 28/31로, Full Recall은 6/11에서 8/11로 대폭 상승</li>
</ul>
<p>다만 주의할 점이 있었다. 최고 성능 임베딩인 <code>pplx-embed-v1-4b</code>에서는 오히려 V3가 raw보다 약간 낮았다. 그리고 평균 latency가 +1.3~1.4초 증가했다. 검색 한 건당 1.5초가 추가되는 것은 운영 환경에서 무시할 수 없는 비용이다.</p>
<p><strong>V3의 판단</strong>: 프롬프트 방향은 맞다. V2보다 확실히 낫다. 그러나 전체 최고 baseline(pplx-embed-v1-4b raw)을 대체하기에는 아직 부족하다.</p>
<hr />
<h2 id="heading-v4-sub-query-decomposition">V4: sub-query decomposition 도입</h2>
<p>V3에서 단일 쿼리 rewriting의 한계를 체감한 뒤, 다음 시도로 sub-query decomposition을 도입했다. 이것이 V4다.</p>
<h3 id="heading-sub-query-decomposition">sub-query decomposition이란</h3>
<p>RAG 시스템에서 널리 연구되는 기법으로, 복잡한 질문을 더 단순한 하위 질문들로 분해한 뒤 각각에 대해 검색을 수행하고 결과를 병합하는 방식이다. 최근 연구들에 따르면, multi-hop 질문에서 관련 사실이 여러 문서에 분산되어 있을 때 표준 RAG가 충분한 정보를 검색하지 못하는 문제를 해결하기 위해 고안되었다. <a target="_blank" href="https://arxiv.org/html/2507.00355v1">Question Decomposition for Retrieval-Augmented Generation (ACL 2025)</a> 연구에서는 질문 분해와 reranking을 결합했을 때 MRR@10 기준 +36.7%, 답변 F1 기준 +11.6%의 개선을 보고하기도 했다.</p>
<p>법률 QA에서도 이 접근이 유효할 것이라는 가설이 있었다. "임대차 계약 해지 시 보증금 반환 절차와 기한은?"처럼 여러 축(해지 절차, 보증금 반환, 기한)을 동시에 묻는 질문이 많기 때문이다. 각 축별로 검색하면 놓치는 조문이 줄지 않을까.</p>
<h3 id="heading-v4">V4 설계</h3>
<p>V4 프롬프트(<code>prerewriter-unified-v4.ts</code>)는 기존 단일 searchQuery 외에 구조화된 출력을 도입했다.</p>
<ul>
<li><code>queryType</code>: 질문 유형 분류</li>
<li><code>vector.subQueries</code>: 하위 질문 목록</li>
<li><code>vector.keywords</code>: 검색 키워드</li>
<li><code>graph.keywords</code>: 그래프 검색용 키워드</li>
<li><code>graph.lawNames</code>: 관련 법률명</li>
</ul>
<p>검색 시에는 메인 searchQuery와 각 subQuery를 개별 검색한 뒤 RRF(Reciprocal Rank Fusion)로 병합했다. 그래프 검색은 이번 실험에서 켜지 않았고, vector 경로만 평가했다.</p>
<h3 id="heading-recall50-full-recall50-1">결과 (Recall@50 / Full Recall@50)</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>임베딩 모델</td><td>raw</td><td>V3</td><td>V4</td></tr>
</thead>
<tbody>
<tr>
<td>bge-m3</td><td>28/31, 8/11</td><td>28/31, 8/11</td><td>27/31, 7/11</td></tr>
<tr>
<td>pplx-embed-v1-0.6b</td><td>28/31, 8/11</td><td>29/31, 9/11</td><td>29/31, 9/11</td></tr>
<tr>
<td>pplx-embed-v1-4b</td><td><strong>30/31, 10/11</strong></td><td>29/31, 9/11</td><td>29/31, 9/11</td></tr>
<tr>
<td>pplx-embed-context-v1-0.6b</td><td>26/31, 6/11</td><td>28/31, 8/11</td><td>27/31, 7/11</td></tr>
<tr>
<td>pplx-embed-context-v1-4b</td><td>28/31, 8/11</td><td>27/31, 7/11</td><td>28/31, 8/11</td></tr>
</tbody>
</table>
</div><p>sub-query를 실제로 검색 병합에 넣어봤지만, V3 대비 전반적으로 나아지지 않았다. 최고치가 같은 경우도 있었지만 Recall@10 같은 초기 정밀도 지표에서는 오히려 크게 악화됐다.</p>
<p>특히 <code>pplx-embed-v1-4b</code>의 Recall@10이 V3의 21/31에서 V4의 14/31로 떨어졌다. sub-query들이 오히려 노이즈를 끌어들여 상위 랭킹을 흐트러뜨린 것이다. Latency도 ~2.2초까지 올라가 V3의 ~1.5초보다 더 나빠졌다.</p>
<hr />
<h2 id="heading-7is4iouyhoyghcdruytqtza6ioustoyxhydhcdrsldsm6drgpg">세 버전 비교: 무엇을 배웠나</h2>
<p>V2에서 V4까지의 여정을 요약하면 이렇다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>버전</td><td>접근</td><td>최고 Recall@50</td><td>최고 Full@50</td><td>Latency 추가</td><td>판정</td></tr>
</thead>
<tbody>
<tr>
<td>raw (baseline)</td><td>없음</td><td>30/31</td><td>10/11</td><td>0ms</td><td>최고</td></tr>
<tr>
<td>V2</td><td>기본 rewriting</td><td>raw 이하</td><td>raw 이하</td><td>+1.2s</td><td>실패</td></tr>
<tr>
<td>V3</td><td>프롬프트 재설계</td><td>29/31</td><td>9/11</td><td>+1.4s</td><td>부분 개선</td></tr>
<tr>
<td>V4</td><td>sub-query + RRF 병합</td><td>29/31</td><td>9/11</td><td>+2.2s</td><td>개선 없음</td></tr>
</tbody>
</table>
</div><p>핵심 발견은 세 가지다.</p>
<p><strong>첫째, 프롬프트 품질은 중요하다.</strong> V2에서 V3로의 개선은 프롬프트 설계만으로도 retrieval 품질을 의미 있게 올릴 수 있음을 보여줬다. "잘 쓴" rewriting은 약한 임베딩 모델의 성능을 끌어올리는 효과가 있었다.</p>
<p><strong>둘째, sub-query decomposition은 만능이 아니다.</strong> 학술 연구에서 보고된 큰 폭의 개선이 우리 도메인에서는 재현되지 않았다. 분해된 하위 질문들이 오히려 노이즈를 증폭시키는 현상이 관찰됐다. 이전에 clean 실험에서도 few-shot 치팅 조건에서만 좋아 보이고 일반화 조건에서는 하락했던 이력이 있었는데, V4에서도 같은 패턴이 반복됐다.</p>
<p><strong>셋째, prerewriter 단독으로는 retrieval miss를 해결할 수 없다.</strong> 어떤 프롬프트를 써도 <code>pplx-embed-v1-4b raw</code>의 30/31, 10/11을 넘지 못했다. 문제의 본질이 "쿼리를 얼마나 잘 다시 쓰느냐"가 아니라 "Top-50 안에 아예 들어오지 못하는 조문을 어떻게 회수하느냐"에 있다는 결론에 도달했다.</p>
<hr />
<h2 id="heading-7zwc6roeioyduoylneqzvcdri6tsnywg6rca7isk">한계 인식과 다음 가설</h2>
<p>이 실험들을 거치며 팀 내 토론에서 도출한 핵심 인식은 이렇다: <strong>현재 병목은 selection miss가 아니라 retrieval miss다.</strong> selection miss(Top-50 안에 있는데 최종 답변에서 빠지는 것)는 이미 상당 부분 완화됐고, 진짜 문제는 Top-50 안에 아예 들어오지 않는 조문이었다.</p>
<p>이 관점에서 네 가지 다음 가설을 세웠다.</p>
<h3 id="heading-1-1">1. 임베딩 모델 재평가 (우선순위 1위)</h3>
<p>retrieval quality를 직접 건드리는 가장 근본적인 접근이다. 기존 임베딩 평가가 단일정답 위주였기 때문에, 복수정답 Recall@20/50과 holdout domain noise 기준으로 다시 평가하면 더 나은 모델이 있을 수 있다. 만약 K20에서 현재 K50 수준 recall이 나오면 노이즈 감소, selection miss 감소, retrieval miss 완화까지 기대할 수 있다.</p>
<h3 id="heading-2-retrieval-stage-2">2. retrieval-stage 웹검색 보조 (우선순위 2위)</h3>
<p>웹검색을 답변 단계가 아니라 검색 단계의 병렬 레인으로 사용하는 것이다. 과거 실험에서 retrieval-stage web augment가 Recall@50을 29/31에서 30/31로, Full Recall을 9/11에서 10/11로 올린 적이 있었다. 웹에서 법률명, 절차명, 숨은 facet 같은 힌트를 먼저 얻고 이를 내부 DB 문서로 다시 매핑/검증하는 구조다.</p>
<h3 id="heading-3-query-3">3. 원문 유지형 query 분할 (우선순위 3위)</h3>
<p>sub-query decomposition 자체를 버리는 것은 아니다. 다만 원문 query를 대체하는 방식이 아니라, 원문 검색 결과에 추가 후보를 확장하는 보조 수단으로만 한정해야 한다는 교훈을 얻었다. V4의 실패는 "분해 결과로 원문을 대체한" 설계에서 비롯된 측면이 크다.</p>
<h3 id="heading-4-graph-db-neo4j-4">4. Graph DB (Neo4j) 도입 (우선순위 4위)</h3>
<p>장기적으로는 검토 가치가 있지만, 지금 당장 정확도 개선 카드로 보기는 어렵다. 현재 문제는 저장소가 MongoDB인지 Neo4j인지가 아니라, 그래프가 "조문과 개념", "조문과 절차", "조문과 기간/요건/효과" 같은 의미 관계를 알고 있는지의 문제이기 때문이다.</p>
<hr />
<h2 id="heading-64m7jwe67o066mw">돌아보며</h2>
<p>prerewriter V3, V4 실험을 통해 query rewriting의 가능성과 한계를 동시에 확인했다. 프롬프트 개선은 분명히 효과가 있었고, sub-query decomposition이라는 학술적으로 검증된 기법도 직접 시도해 봤다. 그러나 우리 도메인에서는 기대한 만큼의 돌파구가 되지 못했다.</p>
<p>가장 큰 수확은 "문제의 본질이 어디에 있는지"를 명확히 한 것이다. prerewriter를 아무리 고도화해도 임베딩 모델의 retrieval quality라는 천장을 넘을 수 없다. 다음 단계는 그 천장 자체를 올리는 작업이 되어야 한다.</p>
<hr />
<p><strong>참고 자료:</strong></p>
<ul>
<li><a target="_blank" href="https://arxiv.org/html/2507.00355v1">Question Decomposition for Retrieval-Augmented Generation (ACL 2025)</a></li>
<li><a target="_blank" href="https://arxiv.org/abs/2510.18633">Query Decomposition for RAG: Balancing Exploration-Exploitation</a></li>
<li><a target="_blank" href="https://arxiv.org/html/2404.00610v1">RQ-RAG: Learning to Refine Queries for Retrieval Augmented Generation</a></li>
<li><a target="_blank" href="https://haystack.deepset.ai/blog/query-decomposition">Advanced RAG: Query Decomposition &amp; Reasoning - Haystack</a></li>
<li><a target="_blank" href="https://github.com/NirDiamant/RAG_Techniques">RAG Techniques - GitHub</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (4) — Query Rewriting: Prerewriter 도입과 모델 비교]]></title><description><![CDATA[벡터 검색 성능을 올리는 가장 쉬운 방법
RAG 파이프라인에서 retrieval 성능이 안 나올 때 가장 먼저 떠오르는 선택지는 보통 두 가지다. 임베딩 모델을 바꾸거나, 쿼리를 바꾸거나. 임베딩 모델 비교는 이미 별도로 진행했고, 이번에는 후자를 건드릴 차례였다.
업계에서는 이 접근을 보통 query rewriting이라고 부른다. 사용자의 원문 질문을 검색에 더 유리한 형태로 변환하는 것이다. Microsoft의 RAG 기법 정리 문서에서는...]]></description><link>https://blog.dongjun.win/legal-ai-search-04-query-rewriting-prerewriter</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-04-query-rewriting-prerewriter</guid><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[Prompt Engineering]]></category><category><![CDATA[RAG ]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Sat, 11 Apr 2026 11:18:39 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-67kh7yswioqygoydisdshlhriqxsnyqg7jis66as64quioqwgoyepsdsiazsmrqg67cp67kv">벡터 검색 성능을 올리는 가장 쉬운 방법</h2>
<p>RAG 파이프라인에서 retrieval 성능이 안 나올 때 가장 먼저 떠오르는 선택지는 보통 두 가지다. 임베딩 모델을 바꾸거나, 쿼리를 바꾸거나. 임베딩 모델 비교는 이미 별도로 진행했고, 이번에는 후자를 건드릴 차례였다.</p>
<p>업계에서는 이 접근을 보통 <strong>query rewriting</strong>이라고 부른다. 사용자의 원문 질문을 검색에 더 유리한 형태로 변환하는 것이다. Microsoft의 RAG 기법 정리 문서에서는 query rewriting을 "pre-retrieval" 단계의 핵심 기법으로 분류하고 있고, 최근 연구(arxiv 2501.07391)에서도 query expansion과 rewriting이 retrieval 품질에 미치는 영향을 체계적으로 조사하고 있다. HyDE(Hypothetical Document Embedding)처럼 가상 문서를 생성해서 검색하는 방법도 있고, Step-Back Prompting처럼 질문을 더 일반적인 형태로 바꾸는 접근도 있다.</p>
<p>내가 만든 파이프라인에서는 이 단계를 <strong>prerewriter</strong>라고 부른다. 검색 전에 쿼리를 다시 쓴다는 뜻 그대로다. 이번 글에서는 prerewriter V2의 설계 의도와, 네 가지 LLM 모델로 비교 실험한 결과를 정리한다.</p>
<h2 id="heading-prerewriter-v2">Prerewriter V2 설계 의도</h2>
<p>Prerewriter V1은 단순한 rephrasing이었다. V2에서 바꾼 핵심 설계는 크게 세 가지다.</p>
<p><strong>첫째, 복수 정답을 더 강하게 고려한다.</strong> 법률 QA에서는 하나의 질문이 여러 조문, 여러 법률에 걸쳐 답을 가지는 경우가 많다. "상속 포기 절차와 그 효과는?"이라는 질문 하나에도 민법의 여러 조문이 관련된다. Prerewriter V2는 하나의 질문 안에서 여러 축--사람, 기관, 관계, 권리, 책임, 절차, 금액, 기간, 숫자--을 보존하도록 프롬프트를 설계했다.</p>
<p><strong>둘째, 출력을 용도별로 분리한다.</strong> 단일 프롬프트 안에서 벡터 검색용과 그래프 검색용 출력을 함께 생성한다.</p>
<ul>
<li><code>vector.searchQuery</code>: 벡터 검색에 넣을 재작성된 쿼리</li>
<li><code>vector.keywords</code>: 벡터 검색 보조 키워드</li>
<li><code>graph.keywords</code>: 그래프 탐색용 키워드</li>
<li><code>graph.lawNames</code>: 그래프 탐색에 쓸 법률명</li>
</ul>
<p>다만, 이번 실험에서는 <strong>vector 출력만 사용</strong>했다. 실제 검색은 벡터 DB의 hybrid(dense + sparse) 검색만 돌렸고, 그래프 검색은 사용하지 않았다. 그래프 검색 쪽은 별도 실험으로 분리할 예정이었다.</p>
<p><strong>셋째, 기존 코드와 실험을 건드리지 않는다.</strong> 새 prerewriter만 별도로 추가하고, baseline(raw, 즉 원문 그대로 검색)과의 비교를 항상 유지했다.</p>
<h2 id="heading-7iuk7zeyioyepoqzha">실험 설계</h2>
<p>질문셋은 전체 84개를 사용했다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>카테고리</td><td>문항 수</td></tr>
</thead>
<tbody>
<tr>
<td>direct</td><td>20</td></tr>
<tr>
<td>scenario</td><td>20</td></tr>
<tr>
<td>tax</td><td>17</td></tr>
<tr>
<td>multi</td><td>11</td></tr>
<tr>
<td>holdout</td><td>16</td></tr>
</tbody>
</table>
</div><p>임베딩 모델은 5종 전체를 돌렸다: <code>bge-m3</code>, <code>pplx-embed-v1-0.6b</code>, <code>pplx-embed-v1-4b</code>, <code>pplx-embed-context-v1-0.6b</code>, <code>pplx-embed-context-v1-4b</code>.</p>
<p>Prerewriter 모델은 두 차례에 걸쳐 총 4종을 비교했다.</p>
<ul>
<li><strong>1차 실험</strong>: <code>gemini-2.5-flash-lite</code>, <code>gpt-5-mini</code></li>
<li><strong>2차 실험</strong>: <code>gemini-2.5-flash</code>, <code>gpt-4.1-mini</code></li>
</ul>
<p>평가 지표는 복수 정답 recall을 중심으로 봤다. <code>@K</code>는 상위 K개 청크 안에 정답 조문이 몇 개 포함되었는지, <code>F@K</code>는 복수 정답이 모두 포함된 질문("full match")이 몇 개인지를 뜻한다. 예를 들어 multi 카테고리에서 <code>@50 = 30/31</code>이라면, 상위 50개 청크 안에 전체 31개 정답 중 30개가 포함되었다는 의미다. <code>F@50 = 10/11</code>이면 11개 multi 질문 중 10개가 모든 정답을 상위 50개 안에서 찾았다는 뜻이다.</p>
<h2 id="heading-1-gemini-flash-lite-vs-gpt-5-mini">1차 실험: Gemini Flash Lite vs GPT-5 mini</h2>
<h3 id="heading-multi">결과 요약 (multi 카테고리, 복수 정답 기준)</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>임베딩</td><td>모드</td><td>@10</td><td>@30</td><td>@50</td><td>F@50</td><td>평균 지연</td></tr>
</thead>
<tbody>
<tr>
<td>pplx-v1-4b</td><td>raw</td><td>19/31</td><td>26/31</td><td><strong>30/31</strong></td><td><strong>10/11</strong></td><td>324ms</td></tr>
<tr>
<td>pplx-v1-4b</td><td>gemini-flash-lite</td><td>19/31</td><td>25/31</td><td>28/31</td><td>8/11</td><td>1,755ms</td></tr>
<tr>
<td>pplx-v1-4b</td><td>gpt-5-mini</td><td>21/31</td><td>29/31</td><td><strong>30/31</strong></td><td><strong>10/11</strong></td><td>5,387ms</td></tr>
<tr>
<td>pplx-v1-0.6b</td><td>raw</td><td>17/31</td><td>26/31</td><td>28/31</td><td>8/11</td><td>323ms</td></tr>
<tr>
<td>pplx-v1-0.6b</td><td>gpt-5-mini</td><td>16/31</td><td>29/31</td><td>29/31</td><td>9/11</td><td>5,383ms</td></tr>
<tr>
<td>pplx-context-v1-0.6b</td><td>raw</td><td>16/31</td><td>24/31</td><td>26/31</td><td>6/11</td><td>326ms</td></tr>
<tr>
<td>pplx-context-v1-0.6b</td><td>gpt-5-mini</td><td>17/31</td><td>27/31</td><td>29/31</td><td>9/11</td><td>5,394ms</td></tr>
</tbody>
</table>
</div><h3 id="heading-7zw07isd">해석</h3>
<p><strong>Gemini 2.5 Flash Lite는 전반적으로 실패였다.</strong> Raw보다 분명히 떨어지는 경우가 많았고, 특히 <code>bge-m3</code>와 <code>pplx-v1-4b</code>에서 손해가 컸다. 쿼리를 재작성했는데 오히려 원문보다 검색 품질이 나빠진 것이다.</p>
<p><strong>GPT-5 mini는 일부 임베딩에서 실제로 개선을 보였다.</strong> 주목할 만한 변화를 정리하면:</p>
<ul>
<li><code>pplx-embed-v1-0.6b</code>: @50이 28/31에서 29/31로, F@50이 8/11에서 9/11로 상승</li>
<li><code>pplx-embed-context-v1-0.6b</code>: @50이 26/31에서 29/31로, F@50이 6/11에서 9/11로 상승. 이건 꽤 큰 점프다.</li>
<li><code>pplx-embed-context-v1-4b</code>: @50이 28/31에서 29/31로, F@50이 8/11에서 9/11로 상승</li>
</ul>
<p>다만 최고 성능 조합인 <code>pplx-embed-v1-4b</code>에서는 @50 기준 30/31로 raw와 동일했다. @30, @40에서는 개선이 있었지만 천장을 뚫지는 못했다.</p>
<p><strong>가장 큰 문제는 속도였다.</strong> GPT-5 mini prerewriter를 붙이면 multi 기준 5.2초에서 5.5초가 걸렸다. Raw는 0.16초에서 0.33초다. Retrieval 단계 하나에 5초를 더 쓰는 건 운영 환경에서 받아들이기 어렵다.</p>
<h2 id="heading-2-gemini-flash-vs-gpt-41-mini">2차 실험: Gemini Flash vs GPT-4.1 mini</h2>
<p>1차에서 flash-lite가 너무 약했기 때문에 상위 모델인 <code>gemini-2.5-flash</code>를 추가했고, GPT 쪽에서는 비용/속도 절충을 위해 <code>gpt-4.1-mini</code>를 넣었다.</p>
<h3 id="heading-multi-1">결과 요약 (multi 카테고리, 복수 정답 기준)</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>임베딩</td><td>모드</td><td>@10</td><td>@30</td><td>@50</td><td>F@50</td><td>평균 지연</td></tr>
</thead>
<tbody>
<tr>
<td>pplx-v1-4b</td><td>raw</td><td>19/31</td><td>26/31</td><td><strong>30/31</strong></td><td><strong>10/11</strong></td><td>376ms</td></tr>
<tr>
<td>pplx-v1-4b</td><td>gemini-flash</td><td>18/31</td><td>26/31</td><td>29/31</td><td>9/11</td><td>3,613ms</td></tr>
<tr>
<td>pplx-v1-4b</td><td>gpt-4.1-mini</td><td>20/31</td><td>26/31</td><td>28/31</td><td>8/11</td><td>2,894ms</td></tr>
<tr>
<td>pplx-v1-0.6b</td><td>gemini-flash</td><td>19/31</td><td>28/31</td><td>28/31</td><td>8/11</td><td>3,601ms</td></tr>
<tr>
<td>pplx-context-v1-4b</td><td>gpt-4.1-mini</td><td>17/31</td><td>24/31</td><td>29/31</td><td>9/11</td><td>2,835ms</td></tr>
</tbody>
</table>
</div><h3 id="heading-7zw07isd-1">해석</h3>
<p><strong>Gemini 2.5 Flash는 Flash Lite보다 확실히 나았다.</strong> 특히 <code>pplx-v1-0.6b</code>에서 @20, @30이 꽤 올라갔다. 하지만 raw를 확실히 뒤집는 수준은 아니었다. 지연도 3.5초에서 3.6초대로 여전히 높았다.</p>
<p><strong>GPT-4.1 mini는 GPT-5 mini보다 빨랐지만, 성능은 약했다.</strong> 지연이 2.7초에서 2.9초로 GPT-5 mini 대비 절반 가까이 줄었는데, 그만큼 rewriting 품질도 떨어진 것으로 보인다. 일부 임베딩에서 @40, @50은 괜찮았지만, 전체 최고 조합을 만들지는 못했다.</p>
<h2 id="heading-64skiouqqounuoydhcdsooxtlantlzjrqbq">네 모델을 종합하면</h2>
<p>모든 실험을 관통하는 결론은 명확했다.</p>
<p><strong>순수 retrieval 최고 성능은 여전히 <code>pplx-embed-v1-4b</code> + raw(원문 그대로)</strong>였다. Multi @50 = 30/31, F@50 = 10/11. 어떤 prerewriter를 붙여도 이 조합을 일관되게 넘지 못했다.</p>
<p>네 모델의 포지션을 정리하면 이렇다:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>모델</td><td>성능 개선</td><td>지연</td><td>종합 판단</td></tr>
</thead>
<tbody>
<tr>
<td>gemini-2.5-flash-lite</td><td>raw보다 하락</td><td>~1.7s</td><td>탈락</td></tr>
<tr>
<td>gemini-2.5-flash</td><td>flash-lite보다 개선, raw 미달</td><td>~3.5s</td><td>보류</td></tr>
<tr>
<td>gpt-4.1-mini</td><td>부분 개선, gpt-5-mini 미달</td><td>~2.8s</td><td>보류</td></tr>
<tr>
<td>gpt-5-mini</td><td>일부 임베딩에서 유의미 개선</td><td>~5.3s</td><td>연구 후보</td></tr>
</tbody>
</table>
</div><p><strong>속도 대비 품질이 가장 좋았던 모델은 gpt-5-mini</strong>다. 특히 중소형 임베딩(<code>pplx-embed-v1-0.6b</code>, <code>pplx-embed-context-v1-0.6b</code>)과 조합했을 때 F@50 기준 6/11에서 9/11로 올라가는 등 눈에 띄는 개선이 있었다. 하지만 가장 강한 임베딩(<code>pplx-embed-v1-4b</code>)과 조합하면 이미 raw가 충분히 좋아서 prerewriter의 추가 가치가 제한적이었다.</p>
<p><strong>Gemini 계열은 이 프롬프트 설계와 궁합이 안 맞았다.</strong> Flash Lite는 아예 역효과였고, Flash도 기대만큼은 아니었다. 모델 자체의 문제인지 프롬프트 최적화 부족인지는 이 실험만으로는 단정할 수 없다.</p>
<h2 id="heading-7j20ioylpo2xmoyxkoyencdtmzxsnbjtlzwg6rkd">이 실험에서 확인한 것</h2>
<p>Query rewriting이 RAG에서 유효한 기법이라는 건 업계 전반의 합의다. 하지만 "항상 좋아진다"는 보장은 없다. 이번 실험에서 확인한 결론은 세 가지다.</p>
<p><strong>1. 강한 임베딩 + 원문이 약한 임베딩 + rewriting을 이긴다.</strong> <code>pplx-embed-v1-4b</code> raw가 어떤 prerewriter 조합보다 좋거나 동등했다. 임베딩 모델의 기본 역량이 충분하면 쿼리를 손대지 않는 것이 더 나을 수 있다.</p>
<p><strong>2. Rewriting은 약한 임베딩을 보완하는 데 더 효과적이다.</strong> GPT-5 mini prerewriter가 가장 큰 효과를 보인 건 0.6b급 소형 임베딩과의 조합이었다. 임베딩 모델의 표현력이 부족한 부분을 쿼리 쪽에서 보완한 결과다.</p>
<p><strong>3. 속도 비용을 무시할 수 없다.</strong> Prerewriter를 붙이면 retrieval 지연이 10배에서 16배로 뛴다. 후속 단계(answer generation, verification)의 지연까지 합치면 전체 파이프라인 응답 시간에 미치는 영향이 상당하다. 운영 환경에서는 이 trade-off를 무시할 수 없다.</p>
<p>결론적으로, prerewriter는 운영 기본값으로 채택하기에는 아직 설득력이 부족했다. 하지만 "쿼리를 바꾸면 검색이 달라진다"는 가능성 자체는 확인했고, 특히 소형 임베딩 환경이나 복수 정답 recall이 중요한 시나리오에서는 여전히 연구할 가치가 있다. 다음 단계에서는 source routing과 결합하거나, prerewriter의 프롬프트 자체를 더 정교하게 다듬는 방향을 검토할 예정이다.</p>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (3) — 복수 정답 문제와 LLM Selector 모델 비교]]></title><description><![CDATA[검색 결과에서 정답을 "선택"하는 것도 문제다
법률 QA 시스템에서 검색(retrieval) 품질은 기본 전제다. 검색이 어느 정도 궤도에 오르자, 다음 병목이 드러났다. Top-50 검색 결과 안에 정답 근거가 들어 있는데도 최종 답변에서 빠지는 경우가 생긴 것이다.
예를 들어 "택배 배송 중 물건이 파손되었을 때 누구에게 책임을 물을 수 있는가?"라는 질문에 대해, 검색 결과에는 민법 제756조(사용자책임)가 포함되어 있었다. 그런데 LLM...]]></description><link>https://blog.dongjun.win/legal-ai-search-03-llm-selector-benchmark</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-03-llm-selector-benchmark</guid><category><![CDATA[AI]]></category><category><![CDATA[Benchmark]]></category><category><![CDATA[llm]]></category><category><![CDATA[RAG ]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Tue, 07 Apr 2026 23:31:17 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-6rka7iojioqysoqzvoyxkoyencdsojxri7xsnyqgiuyeoo2dnsltlzjripqg6rkd64eiousuoygnoulpa">검색 결과에서 정답을 "선택"하는 것도 문제다</h2>
<p>법률 QA 시스템에서 검색(retrieval) 품질은 기본 전제다. 검색이 어느 정도 궤도에 오르자, 다음 병목이 드러났다. Top-50 검색 결과 안에 정답 근거가 들어 있는데도 최종 답변에서 빠지는 경우가 생긴 것이다.</p>
<p>예를 들어 "택배 배송 중 물건이 파손되었을 때 누구에게 책임을 물을 수 있는가?"라는 질문에 대해, 검색 결과에는 민법 제756조(사용자책임)가 포함되어 있었다. 그런데 LLM이 답변을 생성하면서 이 조문을 근거로 선택하지 않았다. 50개 후보 중에서 어떤 것이 진짜 근거인지 "골라내는" 단계, 즉 selector가 별도로 필요했다.</p>
<p>이 글은 selector 구조를 설계하고, 여러 LLM 모델을 비교 실험한 과정을 정리한 기록이다.</p>
<h2 id="heading-single-selector-vs-citation-selector">두 가지 접근법: Single Selector vs Citation Selector</h2>
<p>처음에는 selection planner라는 구조를 시도했다. 검색 결과 전체를 보고 어떤 조문이 왜 필요한지를 장문으로 설명하게 하는 방식이었다. 방향은 맞았지만, 출력이 길고 파싱이 불안정했다. 그래서 출력을 직접 근거, 보조 근거, 누락 포인트 세 필드로 줄인 citation selector로 전환했다.</p>
<p>citation selector는 안정적이었다. 전체 11문항을 파싱 오류 없이 완주했고, Selection Recall 27/31(87.1%)을 기록했다. 하지만 여전히 selection miss가 남았다. 이를 해결하기 위해 두 가지 구조를 실험했다.</p>
<ul>
<li><strong>2-pass selector</strong>: coverage pass와 completion pass를 나눠서 2회 호출. 1차에서 넓게 훑고, 2차에서 빠진 것을 보완한다.</li>
<li><strong>single selector</strong>: 1회 호출로 선택과 보완을 동시에 처리. 속도를 줄이는 대신 품질 손실이 있을 수 있다.</li>
</ul>
<p>2-pass selector는 Gemini 3 Flash Preview 기준으로 Selection Recall을 29/31(93.5%)까지 끌어올렸다. Q2(사용자책임)와 Q11(이자소득)의 selection miss가 모두 해결되었다. 다만 질문당 2회 호출이라 latency가 19~39초 수준으로 늘어났다.</p>
<h2 id="heading-66qo642467oeiou5hoq1kdog7ian64e7jmaio2sioynioydmcdtirjroijsnbtrk5zsmkttliq">모델별 비교: 속도와 품질의 트레이드오프</h2>
<p>selector 구조가 잡히자 다음 질문은 자연스럽게 "어떤 모델이 가장 나은가"였다. OpenAI의 GPT-5 mini, GPT-5 nano와 Google의 Gemini 3 Flash Preview, Gemini 2.5 Flash Lite를 비교했다.</p>
<h3 id="heading-2-pass-selector-q2-q11">2-pass selector 비교 (Q2, Q11 기준)</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>모델</td><td>Selection Recall</td><td>pass1 평균 latency</td><td>pass2 평균 latency</td></tr>
</thead>
<tbody>
<tr>
<td>GPT-5 mini</td><td>6/6</td><td>4,399ms</td><td>4,956ms</td></tr>
<tr>
<td>GPT-5 nano</td><td>4/6</td><td>2,568ms</td><td>3,207ms</td></tr>
<tr>
<td>Gemini 2.5 Flash Lite</td><td>5/6</td><td>-</td><td>-</td></tr>
<tr>
<td>Gemini 3 Flash Preview</td><td>6/6 (전체 11문항 29/31)</td><td>-</td><td>-</td></tr>
</tbody>
</table>
</div><p>GPT-5 mini는 Q2와 Q11을 모두 해결했지만, GPT-5 nano는 Q11에서 무너졌다. 속도는 빠르지만 품질 손실이 컸다.</p>
<h3 id="heading-single-selector-q2-q11">single selector 비교 (Q2, Q11 기준)</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>모델</td><td>Selection Recall</td><td>평균 selector latency</td></tr>
</thead>
<tbody>
<tr>
<td>Gemini 3 Flash Preview</td><td>5/6</td><td>12,011ms</td></tr>
<tr>
<td>Gemini 2.5 Flash Lite</td><td>5/6</td><td>1,659ms</td></tr>
<tr>
<td>GPT-5 mini</td><td>4/6</td><td>3,173ms</td></tr>
<tr>
<td>GPT-5 nano</td><td>3/6</td><td>2,297ms</td></tr>
</tbody>
</table>
</div><p>GPT-5 mini는 2-pass에서 6/6이었지만 single selector에서는 4/6으로 떨어졌다. 반면 Gemini 2.5 Flash Lite는 single selector에서도 5/6을 유지하면서 latency가 1.6초로 가장 빨랐다.</p>
<h3 id="heading-holdout-16-single-selector-answer">holdout 16문항 일반화 검증 (single selector + answer)</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>모델</td><td>도메인 커버</td><td>평균 총 시간</td></tr>
</thead>
<tbody>
<tr>
<td>Gemini 2.5 Flash Lite</td><td>3/3</td><td>4.5~5.0초</td></tr>
<tr>
<td>GPT-5 mini</td><td>3/3</td><td>8.8~11.6초</td></tr>
<tr>
<td>GPT-5 nano</td><td>2/3</td><td>5.5~9.6초</td></tr>
<tr>
<td>Gemini 3 Flash Preview</td><td>3/3</td><td>16.8~25.3초</td></tr>
</tbody>
</table>
</div><p>Gemini 2.5 Flash Lite가 속도와 품질의 균형에서 가장 현실적인 결과를 보였다. 5초 이내에 답변이 나오면서 holdout 도메인도 모두 커버했다.</p>
<h2 id="heading-all-in-one-selector-answer">All-in-One: selector와 answer를 합치면?</h2>
<p>selector와 answer를 분리하면 호출이 최소 2회다. 이걸 1회로 합칠 수 있다면? "근거 선택 + 최종 답변 생성"을 한 번에 처리하는 all-in-one 방식을 실험했다.</p>
<h3 id="heading-core-11">Core 11문항 결과</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>조건</td><td>Selection Recall</td><td>Full Recall</td><td>평균 생성 시간</td></tr>
</thead>
<tbody>
<tr>
<td>Gemini 3 Flash Preview (no-web)</td><td>28/31 (90.3%)</td><td>8/11</td><td>14.7초</td></tr>
<tr>
<td>GPT-5.4 (no-web)</td><td>29/31 (93.5%)</td><td>9/11</td><td>11.7초</td></tr>
<tr>
<td>GPT-5.4 (web)</td><td>27/31 (87.1%)</td><td>7/11</td><td>129.1초</td></tr>
</tbody>
</table>
</div><p>GPT-5.4 no-web이 가장 좋았다. 2-pass selector의 최고 성적(29/31, 9/11)과 동일한 recall을 1회 호출로 달성했고, 평균 11.7초였다. 남은 miss는 Q6, Q7뿐이었는데, 이 둘은 애초에 Top-50 검색 결과에 정답이 없는 retrieval miss였다.</p>
<h3 id="heading-holdout-16">Holdout 16문항 결과</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>조건</td><td>도메인 커버</td><td>평균 생성 시간</td></tr>
</thead>
<tbody>
<tr>
<td>GPT-5.4 (no-web)</td><td>14/16</td><td>9.1초</td></tr>
<tr>
<td>Gemini 3 Flash Preview (no-web)</td><td>14/16</td><td>12.7초</td></tr>
<tr>
<td>Gemini 3 Flash Preview (web)</td><td>14/16</td><td>13.8초</td></tr>
<tr>
<td>GPT-5.4 (web)</td><td>14/16</td><td>114.9초</td></tr>
</tbody>
</table>
</div><p>Holdout에서도 GPT-5.4 no-web이 속도와 품질 모두에서 가장 나은 균형을 보였다.</p>
<h2 id="heading-7ju5ioqygoydisdrs7tsobdsnzgg7zqo6ro8oidquldrjidsmyag64uk66w4ioqysoqzva">웹 검색 보조의 효과: 기대와 다른 결과</h2>
<p>웹 검색을 붙이면 retrieval miss를 보완할 수 있을 거라 기대했다. 결과는 정반대였다.</p>
<p>GPT-5.4에 웹 검색을 붙이자 Core 성능이 오히려 떨어졌다. Selection Recall이 93.5%에서 87.1%로 하락했고, latency는 11.7초에서 129.1초로 11배 증가했다. Q2와 Q11에서 새로운 miss가 발생했다. Gemini 3 Flash Preview의 웹 검색 버전은 Q5에서 반복적으로 timeout이 발생해 전체 결과를 안정적으로 수집하지도 못했다.</p>
<p>Holdout에서도 웹 검색 유무에 관계없이 도메인 커버는 14/16으로 동일했다. 웹 검색이 retrieval miss를 자동으로 메우지 않았다.</p>
<h2 id="heading-gpt-54-thinking-perplexity-sonar">GPT-5.4 Thinking과 Perplexity Sonar 추가 실험</h2>
<p>추가로 두 가지 변형을 더 시도했다.</p>
<p><strong>GPT-5.4 reasoning high</strong>: 품질은 유지되었다(Core Q2, Q11 모두 해결). 하지만 평균 생성 시간이 116.1초로 치솟았다. 기존 no-web(11.7초) 대비 품질 이득은 거의 없으면서 latency만 10배 증가했다.</p>
<p><strong>Perplexity Sonar (web)</strong>: Core 평균 9.4초, Holdout 평균 6.2초로 속도는 빨랐다. 하지만 Q2에서 selection miss가 남았다. 빠른 web-assist 후보로는 가치가 있지만, hard case 품질은 GPT-5.4 no-web에 못 미쳤다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>조건</td><td>Core (Q2, Q11)</td><td>Holdout (H2, H8, H16)</td><td>평균 생성 시간</td></tr>
</thead>
<tbody>
<tr>
<td>GPT-5.4 no-web</td><td>6/6</td><td>3/3</td><td>11.7초</td></tr>
<tr>
<td>GPT-5.4 reasoning high</td><td>6/6</td><td>3/3</td><td>116.1초</td></tr>
<tr>
<td>Perplexity Sonar web</td><td>5/6</td><td>3/3</td><td>6.2~9.4초</td></tr>
</tbody>
</table>
</div><h2 id="heading-selector">결론: Selector 실험에서 확인한 것</h2>
<p>이 실험의 결론을 정리한다.</p>
<p><strong>구조가 모델보다 먼저다.</strong> 2-pass selector는 약한 모델(Gemini 2.5 Flash Lite)에서도 selection miss를 줄여줬다. 반면 single selector는 강한 모델(GPT-5 mini)에서도 품질이 떨어졌다. 호출 구조를 어떻게 설계하느냐가 모델 선택보다 영향이 컸다.</p>
<p><strong>강한 모델은 구조를 단순화할 수 있다.</strong> GPT-5.4는 all-in-one 1회 호출로 2-pass selector의 최고 성적을 재현했다. 모델이 충분히 강하면 복잡한 다단계 구조 없이도 같은 품질을 낼 수 있다.</p>
<p><strong>웹 검색은 만능이 아니다.</strong> 검색 보조를 붙인다고 retrieval miss가 해결되지 않았다. 오히려 latency만 크게 늘고 기존에 잘 되던 것까지 흔들렸다. 웹 검색은 별도 경로로 분리해서, 정말 필요한 경우에만 선택적으로 태워야 한다.</p>
<p><strong>실전 기본값은 단순하게.</strong> 최종적으로 실전 기본값 후보는 GPT-5.4 no-web all-in-one이 되었다. Core 29/31(93.5%), Holdout 14/16, 평균 11초 내외. 남은 miss는 selector가 아니라 retrieval 축에서 풀어야 할 문제였다.</p>
<p>selector는 RAG 파이프라인에서 흔히 간과되는 단계다. 검색만 잘 되면 된다고 생각하기 쉽지만, 50개 후보에서 진짜 근거를 골라내는 일은 그 자체로 독립적인 문제다. 그리고 그 문제를 푸는 방법은 모델을 바꾸는 것만이 아니라, 호출 구조를 설계하는 것이다.</p>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (2) — 임베딩 모델 5종 벤치마크: 법률 도메인 실전 비교]]></title><description><![CDATA[법률 RAG 시스템에서 가장 먼저 결정해야 하는 것은 "어떤 임베딩 모델을 쓸 것인가"다. MTEB 리더보드 점수가 높다고 해서 우리 도메인에서도 잘 동작하리라는 보장은 없다. 한국 법률 조문이라는 특수한 코퍼스 위에서, 실제 질문셋으로 직접 비교하는 것이 유일한 방법이다.
이 글에서는 임베딩 모델 5종을 동일 조건에서 평가한 과정과 결과를 공유한다. 모델 선택 하나가 retrieval 성능의 천장을 결정한다.

평가 대상: 임베딩 모델 5종
...]]></description><link>https://blog.dongjun.win/legal-ai-search-02-embedding-model-benchmark</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-02-embedding-model-benchmark</guid><category><![CDATA[AI]]></category><category><![CDATA[Benchmark]]></category><category><![CDATA[#Embeddings]]></category><category><![CDATA[Machine Learning]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Mon, 06 Apr 2026 06:29:58 GMT</pubDate><content:encoded><![CDATA[<p>법률 RAG 시스템에서 가장 먼저 결정해야 하는 것은 "어떤 임베딩 모델을 쓸 것인가"다. MTEB 리더보드 점수가 높다고 해서 우리 도메인에서도 잘 동작하리라는 보장은 없다. 한국 법률 조문이라는 특수한 코퍼스 위에서, 실제 질문셋으로 직접 비교하는 것이 유일한 방법이다.</p>
<p>이 글에서는 임베딩 모델 5종을 동일 조건에서 평가한 과정과 결과를 공유한다. 모델 선택 하나가 retrieval 성능의 천장을 결정한다.</p>
<hr />
<h2 id="heading-5">평가 대상: 임베딩 모델 5종</h2>
<p>평가에 사용한 모델은 다음 5종이다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>모델</td><td>파라미터</td><td>벡터 차원</td><td>개발사</td><td>특징</td></tr>
</thead>
<tbody>
<tr>
<td>BGE-M3</td><td>568M</td><td>1024</td><td>BAAI</td><td>다국어, dense/sparse/multi-vector 지원</td></tr>
<tr>
<td>pplx-embed-v1-0.6b</td><td>0.6B</td><td>1024</td><td>Perplexity</td><td>경량 임베딩, INT8 네이티브</td></tr>
<tr>
<td>pplx-embed-v1-4b</td><td>4B</td><td>2560</td><td>Perplexity</td><td>대형 임베딩, MTEB 최상위권</td></tr>
<tr>
<td>pplx-embed-context-v1-0.6b</td><td>0.6B</td><td>1024</td><td>Perplexity</td><td>문서 문맥 인식 경량 모델</td></tr>
<tr>
<td>pplx-embed-context-v1-4b</td><td>4B</td><td>2560</td><td>Perplexity</td><td>문서 문맥 인식 대형 모델</td></tr>
</tbody>
</table>
</div><p>BGE-M3는 BAAI에서 공개한 다국어 임베딩 모델로, XLM-RoBERTa 기반이며 8192 토큰까지 처리할 수 있다. dense, sparse, multi-vector 세 가지 retrieval 방식을 동시에 지원하는 것이 특징이다.</p>
<p>Perplexity의 pplx-embed 시리즈는 2종 x 2사이즈, 총 4개 모델로 구성된다. <code>pplx-embed-v1</code>은 표준 dense retrieval용이고, <code>pplx-embed-context-v1</code>은 문서 수준 문맥을 반영하여 청크를 임베딩하는 contextual embedding 모델이다. context 모델은 인덱싱 시에만 사용하고, 쿼리 임베딩에는 표준 v1을 사용하는 비대칭 구조를 갖는다. Qwen3 기반으로 학습되었으며, quantization-aware training을 통해 INT8 임베딩을 네이티브로 생성한다.</p>
<hr />
<h2 id="heading-7yj6rcaio2zmoqyvq">평가 환경</h2>
<h3 id="heading-7l2u7y287iqk">코퍼스</h3>
<p>한국 주요 기본법(민법, 형법, 상법, 민사소송법, 형사소송법, 헌법, 행정소송법 등)에서 추출한 <strong>약 2,300여 개 조문</strong>을 코퍼스로 사용했다. 5개 모델 모두 정확히 동일한 문서 세트로 벡터 DB 컬렉션을 구성했다. 데이터 정합화 작업을 거쳐, 모든 컬렉션의 문서 수와 구조가 일치하는 것을 사전 검증했다.</p>
<h3 id="heading-7kei66y47iwl">질문셋</h3>
<p>총 84개 질문을 다섯 유형으로 나누어 평가했다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>질문 유형</td><td>문항 수</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td>단일정답 직접 질문</td><td>20</td><td>특정 조문을 직접 묻는 질문</td></tr>
<tr>
<td>단일정답 시나리오 질문</td><td>20</td><td>실생활 시나리오로 우회하여 묻는 질문</td></tr>
<tr>
<td>단일정답 세법 질문</td><td>17</td><td>세법 도메인 특화 질문</td></tr>
<tr>
<td>복수정답 질문</td><td>11</td><td>여러 조문이 정답인 질문 (정답 조문 31개)</td></tr>
<tr>
<td>Holdout 질문</td><td>16</td><td>도메인 커버리지 확인용</td></tr>
</tbody>
</table>
</div><p>복수정답 질문이 가장 중요한 평가 축이다. 단일정답은 대부분의 모델이 쉽게 맞추지만, 여러 조문을 빠짐없이 찾아와야 하는 복수정답에서 모델 간 차이가 극명하게 드러났다.</p>
<h3 id="heading-67me6rwqioyhsoqxta">비교 조건</h3>
<p>두 가지 모드로 비교했다.</p>
<ul>
<li><strong>raw</strong>: 사용자 질문 원문 그대로 검색. vector hybrid (dense + BM42 sparse) 사용.</li>
<li><strong>prerewrite</strong>: LLM 프리라이터로 질문을 변환한 뒤 검색. vector + graph 검색을 RRF로 병합.</li>
</ul>
<p>추가로, graph 검색을 완전히 제외한 <strong>vector-only</strong> 조건도 별도 실험했다.</p>
<h3 id="heading-66mu7yq466at">메트릭</h3>
<ul>
<li><strong>@K recall</strong>: K개 결과 안에 정답 조문이 몇 개 포함되었는가</li>
<li><strong>Full match</strong>: 복수정답 질문에서, 한 질문의 정답 조문을 모두 찾았는가 (11문항 중 몇 문항 완전 적중)</li>
<li>K = 10, 20, 50으로 측정</li>
</ul>
<hr />
<h2 id="heading-1-hybrid-dense-bm42-graph">결과 1: Hybrid 검색 (dense + BM42 + graph)</h2>
<p>복수정답 @50 기준이 모델 선택의 핵심 지표였다.</p>
<h3 id="heading-50">복수정답 @50 비교</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>모델</td><td>Raw Recall</td><td>Raw Full</td><td>Raw 속도</td><td>Pre Recall</td><td>Pre Full</td><td>Pre 속도</td></tr>
</thead>
<tbody>
<tr>
<td>BGE-M3</td><td>28/31</td><td>8/11</td><td>103ms</td><td>29/31</td><td>9/11</td><td>1,536ms</td></tr>
<tr>
<td>pplx-embed-v1-0.6b</td><td>28/31</td><td>8/11</td><td>322ms</td><td>29/31</td><td>9/11</td><td>1,700ms</td></tr>
<tr>
<td><strong>pplx-embed-v1-4b</strong></td><td><strong>30/31</strong></td><td><strong>10/11</strong></td><td>319ms</td><td><strong>30/31</strong></td><td><strong>10/11</strong></td><td>1,708ms</td></tr>
<tr>
<td>pplx-embed-context-v1-0.6b</td><td>26/31</td><td>6/11</td><td>323ms</td><td>29/31</td><td>9/11</td><td>1,712ms</td></tr>
<tr>
<td>pplx-embed-context-v1-4b</td><td>28/31</td><td>8/11</td><td>332ms</td><td><strong>30/31</strong></td><td><strong>10/11</strong></td><td>1,714ms</td></tr>
</tbody>
</table>
</div><p><code>pplx-embed-v1-4b</code>가 raw 모드에서 이미 30/31 recall, 10/11 full match를 달성했다. 프리라이터를 붙여도 결과가 동일했다. 즉 이 모델은 질문 원문만으로도 충분히 강력한 retrieval을 보여준 것이다.</p>
<p>반면 BGE-M3와 0.6b 모델들은 @50에서 28/31에 머물렀고, 프리라이터를 적용해야 29/31까지 올라갔다.</p>
<h3 id="heading-64uo7j287kcv64u1ioyaloyvvq">단일정답 요약</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>질문 유형</td><td>핵심 결과</td></tr>
</thead>
<tbody>
<tr>
<td>직접 질문 20</td><td>전 모델 raw @10 = 20/20. 프리라이터 적용 시 오히려 @10이 19/20으로 하락하는 경우 발생</td></tr>
<tr>
<td>시나리오 질문 20</td><td>4b 모델 2종은 raw @10 = 20/20. BGE-M3 raw @10 = 18/20</td></tr>
<tr>
<td>세법 질문 17</td><td>BGE-M3만 raw @10 = 16/17, 나머지 4종은 17/17</td></tr>
</tbody>
</table>
</div><p>단일정답에서는 대부분의 모델이 높은 성능을 보였지만, 시나리오 질문과 세법 질문에서 4b 모델의 우위가 확인되었다.</p>
<hr />
<h2 id="heading-2-vector-only-graph">결과 2: Vector-Only 검색 (graph 제외)</h2>
<p>graph 검색 없이 순수 벡터 검색만으로 평가한 결과도 동일한 결론을 가리켰다.</p>
<h3 id="heading-vector-only-k">복수정답 Vector-Only 비교 (주요 K 값)</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>모델</td><td>Mode</td><td>@20</td><td>F@20</td><td>@30</td><td>F@30</td><td>@50</td><td>F@50</td><td>속도</td></tr>
</thead>
<tbody>
<tr>
<td>BGE-M3</td><td>raw</td><td>21/31</td><td>4/11</td><td>23/31</td><td>5/11</td><td>28/31</td><td>8/11</td><td>113ms</td></tr>
<tr>
<td>BGE-M3</td><td>pre</td><td>21/31</td><td>5/11</td><td>22/31</td><td>6/11</td><td>27/31</td><td>8/11</td><td>1,291ms</td></tr>
<tr>
<td>pplx-v1-0.6b</td><td>raw</td><td>24/31</td><td>5/11</td><td>26/31</td><td>7/11</td><td>28/31</td><td>8/11</td><td>336ms</td></tr>
<tr>
<td><strong>pplx-v1-4b</strong></td><td><strong>raw</strong></td><td><strong>25/31</strong></td><td><strong>7/11</strong></td><td><strong>26/31</strong></td><td><strong>7/11</strong></td><td><strong>30/31</strong></td><td><strong>10/11</strong></td><td><strong>323ms</strong></td></tr>
<tr>
<td>pplx-v1-4b</td><td>pre</td><td>24/31</td><td>5/11</td><td>27/31</td><td>7/11</td><td>29/31</td><td>9/11</td><td>1,501ms</td></tr>
<tr>
<td>pplx-ctx-v1-0.6b</td><td>raw</td><td>23/31</td><td>6/11</td><td>24/31</td><td>6/11</td><td>26/31</td><td>6/11</td><td>319ms</td></tr>
<tr>
<td>pplx-ctx-v1-4b</td><td>raw</td><td>26/31</td><td>7/11</td><td>27/31</td><td>7/11</td><td>28/31</td><td>8/11</td><td>326ms</td></tr>
</tbody>
</table>
</div><p>여기서 주목할 점이 두 가지 있다.</p>
<p>첫째, <strong>vector-only에서도 <code>pplx-embed-v1-4b raw</code>가 @50 = 30/31로 최고 성능</strong>이었다. graph 검색 없이도 이 모델의 벡터 품질 자체가 우수하다는 뜻이다.</p>
<p>둘째, <strong>프리라이터가 vector-only에서는 오히려 성능을 깎는 경우가 있었다</strong>. <code>pplx-embed-v1-4b</code>는 프리라이터 적용 시 30/31에서 29/31로 하락했고, BGE-M3도 28/31에서 27/31로 떨어졌다. 프리라이터가 graph 검색과 결합될 때는 도움이 되지만, 벡터 단독 검색에서는 오히려 원래 질문의 의미를 왜곡할 수 있다는 신호였다.</p>
<h3 id="heading-k">K값에 따른 성능 변화</h3>
<p><code>pplx-embed-v1-4b raw</code>의 복수정답 recall 변화를 K값별로 보면:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>K</td><td>Recall</td><td>Full Match</td></tr>
</thead>
<tbody>
<tr>
<td>10</td><td>19/31</td><td>2/11</td></tr>
<tr>
<td>20</td><td>25/31</td><td>7/11</td></tr>
<tr>
<td>30</td><td>26/31</td><td>7/11</td></tr>
<tr>
<td>40</td><td>28/31</td><td>8/11</td></tr>
<tr>
<td>50</td><td>30/31</td><td>10/11</td></tr>
</tbody>
</table>
</div><p>K20과 K50 사이에 정보 손실이 상당히 크다. K20에서 25/31이던 recall이 K50에서 30/31까지 올라간다. "프리라이터로 K를 줄일 수 있다"는 가설은 이번 실험에서 지지되지 않았고, 복수정답 시나리오에서는 충분한 K가 확보되어야 한다는 결론에 이르렀다.</p>
<hr />
<h2 id="heading-7ian64eiou5hoq1ka">속도 비교</h2>
<div class="hn-table">
<table>
<thead>
<tr>
<td>모델</td><td>Raw 평균</td><td>Prerewrite 평균</td></tr>
</thead>
<tbody>
<tr>
<td>BGE-M3</td><td>~103ms</td><td>~1,536ms</td></tr>
<tr>
<td>Perplexity 계열 4종</td><td>~309-342ms</td><td>~1,400-1,714ms</td></tr>
</tbody>
</table>
</div><p>BGE-M3가 raw 기준 약 3배 빠르다. 다만 prerewrite를 적용하면 LLM 호출 비용이 지배적이 되면서 모델 간 속도 차이가 줄어든다. raw 모드에서 Perplexity 계열은 약 320ms 수준으로, 실서비스에서 충분히 사용 가능한 범위다.</p>
<hr />
<h2 id="heading-pplx-embed-v1-4b">최종 선택: pplx-embed-v1-4b</h2>
<p>종합적으로 <code>pplx-embed-v1-4b</code>를 retrieval 기본 모델로 선택했다. 이유는 다음과 같다.</p>
<p><strong>1. 복수정답 retrieval 최고 성능</strong></p>
<p>가장 까다로운 복수정답 @50에서 30/31 recall, 10/11 full match. hybrid든 vector-only든 동일하게 최상위였다.</p>
<p><strong>2. 프리라이터 없이도 강력한 성능</strong></p>
<p>다른 모델들은 프리라이터를 붙여야 성능이 올라갔지만, 이 모델은 raw 질문만으로 이미 최고 수준에 도달했다. 이는 파이프라인을 단순하게 유지할 수 있다는 실질적인 장점이다.</p>
<p><strong>3. 단일정답에서도 회귀 없음</strong></p>
<p>직접 질문, 시나리오 질문, 세법 질문 모두에서 최상위 또는 동률. 복수정답에서 강하다고 해서 단일정답이 약해지는 트레이드오프가 없었다.</p>
<p><strong>4. 허용 가능한 지연 시간</strong></p>
<p>raw 기준 약 320ms. BGE-M3보다는 느리지만, 법률 QA 서비스의 응답 시간 예산 안에 충분히 들어온다.</p>
<hr />
<h2 id="heading-7j20ioylpo2xmoyxkoyencdtmzxsnbjtlzwg6rkd">이 실험에서 확인한 것</h2>
<ul>
<li><strong>벤치마크 점수와 도메인 성능은 다르다.</strong> MTEB 리더보드에서의 순위가 한국 법률 도메인에서의 순위를 보장하지 않는다. 직접 평가 외에 지름길은 없다.</li>
<li><strong>데이터 정합화가 공정한 비교의 전제 조건이다.</strong> 5개 컬렉션의 문서 수가 불일치하는 상태에서는 비교 자체가 무의미하다. 2,336개로 맞추는 작업이 평가보다 먼저 수행되어야 한다.</li>
<li><strong>프리라이터는 만능이 아니다.</strong> "질문을 다듬으면 무조건 좋아진다"는 직관과 달리, 특정 모델/조건에서는 오히려 성능이 하락했다. 특히 vector-only 검색에서 이 현상이 두드러졌다.</li>
<li><strong>복수정답이 진짜 변별력이다.</strong> 단일정답은 대부분의 모델이 쉽게 맞추므로 모델을 구분하기 어렵다. 여러 조문을 동시에 찾아야 하는 복수정답 시나리오가 실질적인 벤치마크다.</li>
<li><strong>K값은 넉넉하게.</strong> 복수정답에서 K20과 K50 사이의 정보 손실이 크다. K50을 유지한다.</li>
</ul>
<p>법률 RAG의 핵심 파이프라인에서, 임베딩 모델 선택은 모든 후속 단계의 성능 상한을 결정하는 첫 번째 관문이다. 이 단계에서의 비교 실험은 생략할 수 없다.</p>
]]></content:encoded></item><item><title><![CDATA[법률 AI 검색 실험기 (1) — 벡터 검색이 실패하는 이유]]></title><description><![CDATA[도입: 법률 QA를 만들면서 마주한 첫 번째 벽
법률 질의응답 시스템을 만드는 일은, 처음에는 RAG(Retrieval-Augmented Generation)의 교과서적 응용처럼 보였습니다. 법 조문을 임베딩해서 벡터 DB에 넣고, 사용자 질문과 유사한 조문을 검색한 뒤, LLM이 답변을 생성하면 되니까요.
실제로 단일 정답 질문 -- "주택임대차보호법상 대항력은 언제 취득하나요?" 같은 -- 에는 이 방식이 잘 작동했습니다. 해당 조문과 질문...]]></description><link>https://blog.dongjun.win/legal-ai-search-01-why-vector-search-fails</link><guid isPermaLink="true">https://blog.dongjun.win/legal-ai-search-01-why-vector-search-fails</guid><category><![CDATA[AI]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[search]]></category><category><![CDATA[Vector Search]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Mon, 06 Apr 2026 06:25:56 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-qa">도입: 법률 QA를 만들면서 마주한 첫 번째 벽</h2>
<p>법률 질의응답 시스템을 만드는 일은, 처음에는 RAG(Retrieval-Augmented Generation)의 교과서적 응용처럼 보였습니다. 법 조문을 임베딩해서 벡터 DB에 넣고, 사용자 질문과 유사한 조문을 검색한 뒤, LLM이 답변을 생성하면 되니까요.</p>
<p>실제로 단일 정답 질문 -- "주택임대차보호법상 대항력은 언제 취득하나요?" 같은 -- 에는 이 방식이 잘 작동했습니다. 해당 조문과 질문의 텍스트 유사도가 높기 때문입니다.</p>
<p>문제는 현실의 법률 질문이 그렇게 단순하지 않다는 데서 시작됩니다. "부당해고 당했는데 어떻게 하나요?"라는 질문에 정확히 답하려면 근로기준법 23조(해고제한), 26조(해고예고), 28조(구제신청) 세 조문이 모두 필요합니다. 그런데 벡터 검색은 보통 28조(구제신청)만 찾고, 나머지 두 조문은 Top-50에도 들어오지 않았습니다.</p>
<p>이 글은 저희가 법률 QA 시스템을 만들면서 벡터 검색의 구조적 한계를 발견하고, 그 원인을 분석한 과정을 다룹니다.</p>
<hr />
<h2 id="heading-66y47kccioygleydmdog67o17iiyioygleultsdsp4jrrljsnbtrnoa">문제 정의: 복수 정답 질문이란</h2>
<p>법률 상담에서 사용자가 던지는 질문은 대부분 하나의 조문으로 답할 수 없습니다. 하나의 상황에 여러 법률 조항이 얽혀 있기 때문입니다.</p>
<p>저희는 이런 질문을 "복수 정답 질문"이라고 정의하고, 정답이 2~4개 조문인 11개 평가 문항을 설계했습니다. 예를 들면 이런 것들입니다.</p>
<ul>
<li>"부당해고 당했는데 어떻게 하나요?" -- 정답: 해고제한(23조), 해고예고(26조), 구제신청(28조)</li>
<li>"세금을 과다하게 부과한 것 같아요" -- 정답: 경정청구(45조의2), 불복(55조), 청구기간(61조)</li>
<li>"양도차익이 4억인데 세금이 얼마나?" -- 정답: 양도소득 범위(94조), 장기보유공제(95조), 세율(104조)</li>
</ul>
<p>이 질문들의 공통점은, 사용자가 명시적으로 언급하지 않은 조문이 정답에 포함되어 있다는 점입니다. "부당해고 어떻게 하나요"라는 질문에는 "해고예고"라는 단어가 없고, "세금이 과다하다"는 말에는 "심판청구"라는 단어가 없습니다.</p>
<hr />
<h2 id="heading-11">실제 데이터: 복수 정답 11문항 평가 결과</h2>
<p>저희는 여러 임베딩 모델을 비교 평가했습니다. 핵심 지표는 두 가지입니다.</p>
<ul>
<li><strong>Article Recall@K</strong>: 전체 정답 조문 31개 중 Top-K 안에 들어온 수</li>
<li><strong>Question Full Recall@K</strong>: 11개 질문 중 모든 정답 조문이 Top-K 안에 들어온 질문 수</li>
</ul>
<p>초기 평가에서 가장 성능이 좋았던 bge-m3 모델의 결과입니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>지표</td><td>@5</td><td>@10</td></tr>
</thead>
<tbody>
<tr>
<td>Article Recall</td><td>-</td><td>14/31 (45%)</td></tr>
<tr>
<td>Question Full Recall</td><td>0/11</td><td>1/11 (9%)</td></tr>
</tbody>
</table>
</div><p>Top-10 기준으로 정답 조문의 절반도 찾지 못했고, 11개 질문 중 모든 정답을 다 찾은 문항은 단 1개뿐이었습니다.</p>
<p>이후 임베딩 모델을 교체하고 Top-K를 50까지 확대한 최고 baseline(pplx-embed-v1-4b)에서는 상황이 많이 개선되었습니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>지표</td><td>@50</td></tr>
</thead>
<tbody>
<tr>
<td>Article Recall</td><td>30/31 (97%)</td></tr>
<tr>
<td>Question Full Recall</td><td>10/11 (91%)</td></tr>
</tbody>
</table>
</div><blockquote>
<p><strong>참고</strong>: 이 수치는 초기 평가 기준 기준이다. 이후 multi-2 문항의 정답 정의를 재검토하면서(민법 766조 → 756조) benchmark를 수정했고, 최종적으로는 graph 검색과 결합하여 31/31, 11/11을 달성했다. 이 과정은 이후 "1차 아키텍처 확정: 실험에서 운영으로" 편에서 자세히 다룬다.</p>
</blockquote>
<p>Top-50까지 확대하니 대부분의 조문을 회수할 수 있었습니다. 하지만 여전히 놓치는 조문이 있었고, 더 중요한 것은 Top-50이라는 범위 자체가 실용적이지 않다는 점입니다. 50개 조문을 LLM에 넘기는 것은 비용과 레이턴시 면에서 부담이 크고, 노이즈가 많아지면 LLM의 선별 정확도도 떨어집니다.</p>
<p>End-to-end 기준 최고 성능은 <code>gpt-5.4 no-web all-in-one</code> 조합으로, Selection Recall 29/31, Full Recall 9/11을 기록했습니다. 하지만 이 수치도 결국 retrieval 단계에서 후보군에 포함되지 않은 조문은 아무리 좋은 LLM을 써도 찾을 수 없다는 한계를 보여줍니다.</p>
<p><strong>핵심 병목은 LLM 선별(selection)이 아니라 검색(retrieval) 자체</strong>였습니다.</p>
<hr />
<h2 id="heading-67kh7yswioqygoydieydgcdsmzwg7iuk7yyo7zwy64qu6rca">벡터 검색은 왜 실패하는가</h2>
<p>벡터 검색의 원리는 "텍스트가 의미적으로 비슷하면 벡터 공간에서 가까이 위치한다"는 전제에 기반합니다. 이 전제는 많은 경우에 유효하지만, 법률 도메인에서는 구조적으로 맞지 않는 상황이 자주 발생합니다.</p>
<h3 id="heading-7j2y6647kcbioycooycrouphoyzgcdrhbzrpqzsoieg6rsa6roe7j2yioq0toumra">의미적 유사도와 논리적 관계의 괴리</h3>
<p>질문별로 벡터 검색이 놓친 조문을 분석하면 명확한 패턴이 보입니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>질문</td><td>찾은 조문</td><td>놓친 조문</td><td>놓친 이유</td></tr>
</thead>
<tbody>
<tr>
<td>계약해제 원상회복+손배</td><td>548조(해제효과)</td><td>551조(해제와 손배)</td><td>같은 제도의 다른 효과</td></tr>
<tr>
<td>불법행위+사용자배상</td><td>750조(불법행위)</td><td>756조(사용자책임)</td><td>같은 불법행위의 책임 귀속</td></tr>
<tr>
<td>대항력+소액임차인</td><td>8조(소액임차인)</td><td>3조(대항력)</td><td>전제조건</td></tr>
<tr>
<td>해고사유+예고+구제</td><td>28조(구제신청)</td><td>23조, 26조</td><td>절차 체인</td></tr>
<tr>
<td>경정청구+이의+심판</td><td>45의2(경정청구)</td><td>55조, 61조</td><td>불복 절차 체인</td></tr>
<tr>
<td>양도소득+공제+세율</td><td>95조(장기보유공제)</td><td>94조, 104조</td><td>계산 체인</td></tr>
</tbody>
</table>
</div><p>벡터 검색이 놓치는 조문들은 질문과 <strong>텍스트가 안 닮았지만 논리적으로는 반드시 필요한</strong> 조문들입니다. "부당해고 당했는데 어떻게 하나요"라는 질문과 "해고예고" 조문 사이에는 텍스트 유사도가 낮습니다. 하지만 해고 절차를 이해하는 사람이라면 이 둘이 연결되어 있다는 걸 압니다.</p>
<p>임베딩 모델은 아무리 좋아도 텍스트를 고차원 벡터로 압축하는 과정에서 이런 논리적 관계 정보를 잃어버립니다. 이것은 모델의 성능 문제가 아니라 bi-encoder 아키텍처의 구조적 한계입니다.</p>
<h3 id="heading-miss">다섯 가지 miss 패턴</h3>
<p>놓친 조문들을 관통하는 관계 유형을 정리하면 다섯 가지로 분류됩니다.</p>
<ol>
<li><strong>절차 체인(PROCEDURE_CHAIN)</strong>: 같은 법적 절차의 단계들. 해고 제한 -&gt; 해고 예고 -&gt; 구제 신청처럼, 하나의 절차를 구성하는 조문들이 흩어져 있는 경우.</li>
<li><strong>계산 체인(CALCULATION_CHAIN)</strong>: 같은 세금이나 금액 계산의 구성요소. 소득 정의 -&gt; 공제 -&gt; 세율처럼, 하나의 계산 흐름에 속하지만 각각 별도의 조문인 경우.</li>
<li><strong>전제조건(PREREQUISITE)</strong>: A 권리가 성립하려면 B 조건이 필요한 경우. 갱신청구권을 행사하려면 먼저 대항력을 갖추어야 하는 것처럼.</li>
<li><strong>같은 원인의 다른 효과(EFFECT_OF)</strong>: 계약 해제라는 같은 원인에서 원상회복과 손해배상이라는 다른 효과가 나오는 경우.</li>
<li><strong>기간/제한(LIMITATION)</strong>: 권리에 딸린 기간 제한. 임차권의 존속기간이나 갱신청구 기한처럼.</li>
</ol>
<p>이 다섯 패턴은 모두 <strong>"텍스트로는 안 닮았지만 논리적으로 연결된"</strong> 관계입니다. 벡터 검색이 원리적으로 포착하기 어려운 종류의 관계이며, 이것이 법률 도메인에서 벡터 검색만으로는 충분하지 않은 근본적인 이유입니다.</p>
<hr />
<h2 id="heading-7jef6roe7jeq7isc64eioqwmeydgcdrrljsojzrpbwg6rkq6rogioyeioulpa">업계에서도 같은 문제를 겪고 있다</h2>
<p>이 문제는 저희만 겪는 것이 아닙니다. 최근 RAG 연구에서 벡터 검색의 멀티홉 추론 한계는 핵심 연구 주제로 부상하고 있습니다.</p>
<p>2025년 ACM TKDD에 게재된 멀티홉 QA 연구에서는 기존의 반복적 검색(iterative retrieval) 방법이 검색 횟수가 늘어날수록 원래 추론 경로에서 벗어나는 "쿼리 드리프트" 문제를 보고했습니다. 저희가 쿼리 분해(query decomposition)를 시도했을 때 경험한 것과 정확히 같은 현상입니다 -- 분해된 서브쿼리가 원래 의미에서 드리프트하면서 단일 정답 문항의 성능이 20/20에서 16/20으로 급락했습니다.</p>
<p>2025년 발표된 HopRAG 논문(arXiv:2502.12442)은 벡터 유사도 기반 검색이 논리적 관계를 포착하지 못하는 한계를 지적하며, 그래프 기반 검색으로 멀티홉 추론을 지원하는 방법을 제안했습니다. 또한 인도의 법률 AI를 다룬 Domain-Partitioned Hybrid RAG 연구(arXiv:2602.23371)는 법률 코퍼스를 판례, 법령, 헌법으로 분리한 뒤 Neo4j 기반 법률 지식 그래프로 관계형 쿼리와 멀티홉 추론을 지원하는 아키텍처를 제안했는데, 이는 저희가 독립적으로 도달한 결론과 놀라울 정도로 유사합니다.</p>
<p>결국 업계 전반에서 "벡터 검색만으로는 부족하다"는 공감대가 형성되고 있으며, 특히 법률처럼 조문 간 논리적 관계가 핵심인 도메인에서는 그래프 기반 확장이 사실상 필수적인 보완재로 논의되고 있습니다.</p>
<hr />
<h2 id="heading-7iuc64e7zai7kea66emiou2goyhse2wiounmcdqsoprk6q">시도했지만 부족했던 것들</h2>
<p>벡터 검색의 한계를 우회하기 위해 몇 가지 방법을 시도했습니다.</p>
<h3 id="heading-query-decomposition">쿼리 분해 (Query Decomposition)</h3>
<p>질문을 여러 서브쿼리로 분해한 뒤 각각 검색하고 RRF(Reciprocal Rank Fusion)로 병합하는 방식입니다. 결과는 복수 정답에서 소폭 개선(+2~3 Q.Full)이 있었지만, <strong>단일 정답 문항에서 심각한 성능 저하</strong>가 발생했습니다. pplx-embed-v1-4b 모델은 세법 문항에서 17/17이 0/17으로 완전히 무너졌습니다. 분해된 서브쿼리가 원래 맥락에서 벗어나는 쿼리 드리프트가 원인이었습니다.</p>
<h3 id="heading-agentic-rag">Agentic RAG</h3>
<p>LLM이 검색 결과를 보고, 부족하다고 판단하면 재검색하는 방식입니다. 이론적으로는 매력적이지만 근본적인 순환 논리가 있습니다. "결과가 충분한지" 판단하려면 이미 법률 지식이 있어야 합니다. 게다가 비용과 레이턴시가 3~10배 증가합니다.</p>
<h3 id="heading-co-citation">Co-citation 기반 확장</h3>
<p>판례에서 함께 인용된 조문을 확장하는 방식입니다. 저희 MongoDB에는 판례 73,032건에서 추출한 158,152건의 법조문 참조 데이터가 이미 있었습니다. 실제로 근로기준법 28조(구제신청)에서 출발하면, 관련 판례 177건을 거쳐 23조(해고제한)가 148회 공출현하는 것을 확인할 수 있었습니다.</p>
<p>하지만 데이터 폭발 문제가 심각했습니다. 민법 750조(불법행위)처럼 범용 조항 하나만 걸리면 관련 판례가 2,173건, 공출현 조문이 1,307개로 폭발합니다. 또한 co-citation은 통계적 상관관계이지 논리적 관계가 아닙니다. 28조와 26조(해고예고)는 판례에서 함께 인용되는 빈도가 낮지만, 논리적으로는 해고 절차의 핵심 구성요소입니다.</p>
<hr />
<h2 id="heading-64uk7j2mioq4gcdsmijqs6a">다음 글 예고</h2>
<p>이 글에서 확인한 것은 명확합니다. 법률 QA에서 벡터 검색은 필요하지만 충분하지 않습니다. 텍스트 유사도로는 포착할 수 없는 논리적 관계 -- 절차 체인, 계산 체인, 전제조건, 기간 제한 -- 가 법률 질의응답의 정확도를 결정합니다.</p>
<p>이 <strong>법률 AI 검색 실험기</strong> 시리즈에서는 이 문제를 실제로 어떻게 풀어갔는지를 다룹니다. 임베딩 모델 벤치마크, LLM selector 비교, query rewriting, Graph RAG 도입, 멀티 컬렉션 라우팅까지 — 한국 법률 도메인에서 RAG를 운영 가능한 수준까지 끌어올리는 과정을 한 편씩 기록할 예정입니다.</p>
<p>다음 편에서는 법률 도메인에 맞는 임베딩 모델을 어떻게 골랐는지, 5종 모델을 직접 비교한 실험 결과를 다룹니다.</p>
<p>법률 QA를 만들면서 배운 것은, 검색 시스템의 정확도는 임베딩 모델의 성능이 아니라 "어떤 종류의 관계를 포착할 수 있느냐"에 달려 있다는 것입니다. 벡터 검색이 잘하는 것(텍스트 유사도)과 법률 도메인이 요구하는 것(논리적 관계) 사이의 간극을 메우는 것이 이 시리즈의 주제입니다.</p>
<hr />
<p><strong>참고 자료:</strong></p>
<ul>
<li><a target="_blank" href="https://arxiv.org/abs/2502.12442">HopRAG: Multi-Hop Reasoning for Logic-Aware Retrieval-Augmented Generation</a></li>
<li><a target="_blank" href="https://arxiv.org/html/2602.23371v1">Domain-Partitioned Hybrid RAG for Legal Reasoning</a></li>
<li><a target="_blank" href="https://dl.acm.org/doi/10.1145/3789506">Retrieval-Augmented Generation for Multi-Hop Question Answering Based on Structured Planning (ACM TKDD)</a></li>
<li><a target="_blank" href="https://www.freecodecamp.org/news/how-to-solve-5-common-rag-failures-with-knowledge-graphs/">How to Solve 5 Common RAG Failures with Knowledge Graphs</a></li>
<li><a target="_blank" href="https://towardsdatascience.com/vector-search-is-not-all-you-need-ecd0f16ad65e/">Vector Search Is Not All You Need (Towards Data Science)</a></li>
<li><a target="_blank" href="https://neo4j.com/blog/genai/advanced-rag-techniques/">Advanced RAG Techniques (Neo4j)</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[CTO로 한해를 보내며.. (2019 회고)]]></title><description><![CDATA[2020년 설이 지나서야 2019년 회고의 글을 씁니다. 목차를 만들어서 하나씩 회고를 하면서 써나갈까 합니다. 2019년은 정말 다사다난했습니다. 큰일들을 위주로 회고를 시작할까 합니다.
CTO가 되다..
 ITAM GAMES는 2018년에 시니어 개발자로 입사하게 되었습니다. 개발자로서 만족하면서 개발 일을 프런트, 백앤드를 가리지 않고 개발을 했습니다. 2018년 말 전 CTO님께서 회사를 퇴사하면서 저희 대표님은 저에게 CTO 직을 제시...]]></description><link>https://blog.dongjun.win/cto-2019</link><guid isPermaLink="true">https://blog.dongjun.win/cto-2019</guid><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Tue, 28 Jan 2020 05:19:00 GMT</pubDate><content:encoded><![CDATA[<p> 2020년 설이 지나서야 2019년 회고의 글을 씁니다. 목차를 만들어서 하나씩 회고를 하면서 써나갈까 합니다. 2019년은 정말 다사다난했습니다. 큰일들을 위주로 회고를 시작할까 합니다.</p>
<h1 id="heading-cto">CTO가 되다..</h1>
<p> ITAM GAMES는 2018년에 시니어 개발자로 입사하게 되었습니다. 개발자로서 만족하면서 개발 일을 프런트, 백앤드를 가리지 않고 개발을 했습니다. 2018년 말 전 CTO님께서 회사를 퇴사하면서 저희 대표님은 저에게 CTO 직을 제시하였고, 저는 거절했습니다. 하지만 결국은 2019년 1월.. CTO를 맡게 되었습니다. 총인원 약 60명 되는 회사의 CTO는 저에게 있어서 아직 부담으로 다가왔었습니다. 물론 개발팀만 50~60명은 아닙니다.
 CTO를 하면서 많은 일이 있었고, <code>이불킥</code>을 할 정도로 부족했던 나의 모습, 하나하나의 결정에 따른 책임, 회사를 퇴사하겠다는 직원, 항상 무언가 새로운 것만 추구했던 사업부.. CTO는 정말 쉬운 직책이 아니고 편한 직책이 아님을 경험하고 있습니다. (현재도 CTO로 재직 중에 있습니다.)</p>
<h2 id="heading-64ky7j2yioucuumgouhncdrkjjsp4ag7jwk64quiouqqoutocdqsomu">나의 뜻대로 되지 않는 모든 것.</h2>
<p> 2019년 초에는 모든 것이 나의 뜻대로 되지 않았습니다. 팀원들은 나를 신뢰하지 않은 듯한 느낌을 받았고, 저의 진심을 알아주지 않은 팀원들에게 서운한 감정도 많이 들었습니다. 또한 개발일 정도 충분히 가능하리라 봤던 것들도 안되었습니다. 팀원들은 무언가 불만이 많았던 것처럼 보였으며, 나를 무시하는 느낌까지 받았었습니다. 사업부나 기획팀도 일정에 따른 압박을 주기 시작했고, 저는 CTO로서 첫 번째 고비가 다가왔었습니다.</p>
<h4 id="heading-66qo65ogioyemouquydgcdsoidrozzrtodthlaulg">모든 잘못은 저로부터..</h4>
<p> 2019년 초기에는 위에 써놓은 것처럼 모든 것이 마음대로 되지 않았고 그로 인한 쌈닭이 되어 가는 모습을 보게 되었습니다. 저를 바라보고 함께 일하고 싶어서 왔던 동료들인데 제가 변해 가는 모습을 몇 개월 후에나 알게 되었을 때 정말 고개를 들지 못할 정도로 미안함을 느끼게 되었습니다. 모든 것이 제가 미숙했던 거였습니다.</p>
<ol>
<li>왜 나는 동료들에게 다가가려 하지 않았을까?</li>
<li>먼저 신뢰를 얻으려고 하지 않을까?</li>
<li>일에 대한 욕심을 왜 이렇게까지 냈을까..</li>
<li>개발팀의 동료를 생각하지 않고 왜 회사의 입장만 고수하고 강요했을까?</li>
<li>모든 기준을 나로 두고 생각했을까..</li>
<li>팀원 각자가 특출난 부분이 있는데 왜 그걸 알아내지 못했으며, 알면서도 그에 맞게 활용을 못 했을까?</li>
<li>나 스스로 노력하지 않으면서, 나를 따라와 주고 믿어주며, 함께 할 거라 생각을 했을까?</li>
<li>동료의 말을 들으려고 하지 않을까?</li>
<li>한쪽 말만 듣고 판단하려고 했을까?</li>
<li>왜 한 달에 한 번씩 회고를 하지 못했을까..?</li>
<li>감정적으로 행동을 했으며, 말을 함부로 했을까..?</li>
</ol>
<p>위의 11가지 말고도 더 많은 잘못이 있었음을 너무 늦게 깨달았습니다. '그때 그렇게가 아니고 이렇게 했으면 조금 더 좋은 결과가 있었을 텐데..'라는 생각을 지금도 합니다.
그래도 늦게라도 회고하고 반성하며 아래와 같은 생각을 가지고 회사 생활을 하게 되었습니다.</p>
<ol>
<li>팀원들과 함께 하는 시간을 늘려보자. (함께 산책하기, 농담 따먹기 하기 등등)</li>
<li>내가 말하는 시간보다 듣는 시간을 늘려보자.</li>
<li>무언가 일을 진행할 때 욕심을 부리지 말자.</li>
<li>팀원 각자의 특출난 부분에 맞게 업무를 분배하고 나아가자.</li>
<li>감정적으로 행동하지 말고, 말을 함부로 하지 말자.</li>
<li>한쪽의 의견만 듣지 말고 양쪽의 의견을 듣고 판단하자.</li>
<li>팀원들을 신뢰하고 일을 맡기자.</li>
<li>업무에 대한 부족함이 있을 시에는 부족한 부분을 채워주도록 하는 게 나의 역활 중 하나라 생각하자.</li>
<li>새롭게 알게 된 지식은 공유하자.</li>
<li>한 달에 한 번쯤은 스스로 돌아보고 회고하자.</li>
<li>짧은 인생, 안 좋은 소리보다 좋은 소리를 하자.</li>
</ol>
<p>위의 11가지를 생각하면서 변화를 하게 되었고 지금 이 순간은 저희 개발팀은 서로 함께 늙어 가면서 평생 같이 보는 사이가 되었습니다. 더 크게 깨달은 것 중의 하나는 저는 정말 멘탈이 강한 줄 알았는데 맨탈이 강한 게 아니었다는 것을 느끼게 되는 2019년이었습니다.</p>
<blockquote>
<p>지금은 서로 술도 한잔하고 함께 진솔한 애기를 많이 합니다. 팀원들은 이렇게 얘기합니다. 2019년 초기만 해도 머 저런 미친X.. 쌍또라... 등으로 저를 얘기했는데 어떻게 이렇게 웃고 서로 힘이 되어 주고 도와주는 사이가 됐는지 신기해합니다. (자슥들아 너희와 내가 함께 노력한 거다 ㅋㅋㅋ) 그중 한 명은 진짜로 코드에 심각한 오류를 심어 놓고 퇴사할까 라는 생각도 했다고 합니다.</p>
</blockquote>
<h2 id="heading-64si66y0iounjuydgcdsl4xrrltrn4kulg">너무 많은 업무량..</h2>
<p> CTO가 되고 나서 정말 사업부는 너무 많은 업무와 짧고 짧은 기간을 주었습니다. 저는 이 부분을 어떻게 풀어 나가야 할지에 대해서 고민을 했습니다.</p>
<blockquote>
<p>저는 정말 야근이 싫었습니다. 그래서 팀원들에게 야근하게 만들고 싶지 않았습니다.</p>
</blockquote>
<h4 id="heading-1">1. 서버 관리는 하지 말자.</h4>
<p> 미친 듯한 업무량을 제한된 시간에 팀원들과 함께 풀어나가기에는 개발에만 집중하기에도 부족했습니다. 여기에 있어서 서버 관리까지 한다고 생각하니 답이 나오지 않았습니다. 그래서 선택을 아래와 같이 했습니다.</p>
<ol>
<li>Lambda를 사용해서 서버를 관리하지 말자.</li>
<li>S3, Route53, Cloudfront를 사용해서 SPA를 서비스하자</li>
<li>Codepipeline을 이용해서 자동 배포 시스템을 구축하자.</li>
<li>Mongodb를 사용하면서 직접 관리하지 말고 Mongodb Atlas를 사용하자.</li>
</ol>
<p>위와 같은 선택은 정말 신의 한 수였습니다. 선택에 따른 결과는 아래와 같았습니다.</p>
<ol>
<li>서버 관리가 더 이상 필요하지 않았습니다.</li>
<li>Mongodb의 모든 설정 및 관리가 필요하지 않았습니다.</li>
<li>자동 배포로 인해 배포에 대한 부담감이 적어졌습니다.</li>
<li>모니터링 툴이 필요하지 않았습니다. (Cloud Watch를 사용했으며, 더 필요한 부분은 log를 남겼습니다)
결론적으로 개발에 집중 할 수 있는 환경을 만들었습니다. 지금도 그때의 선택은 정말 잘했다고 생각합니다.</li>
</ol>
<h4 id="heading-2">2. 급하게 처리할 수밖에 없던 업무들..</h4>
<p> 짧은 일정은 충분히 잘 만들 수 있던 서비스를 일정에 치여서 아쉬운 상태로 완료된 경우가 너무 많았습니다. 대표님과도 해당 이슈에 대한 부분에 대해 많은 얘기를 했지만, 항상 결과는 회사가 나아가기 위해서는 어쩔 수 없이 그 기간 안에 서비스가 나와야 된다는 애기로 끝났습니다.
 지금 생각해보면 정말 어리석은 결정이었습니다. 제가 조금 더 강하게 의견을 말했으면 어땠을까 라는 생각을 많이 합니다.</p>
<p> 위와 같은 결정에 따른 결과는 아래와 같은 부정적으로 나타나기 시작했습니다.</p>
<ol>
<li>완성도의 하락.</li>
<li>그때 그 순간만을 위한 개발.</li>
<li>개발자의 의욕 상실.</li>
<li>기술의 부채.</li>
<li>엄청난 기회비용의 낭비.</li>
<li>효율적이지 못한 업무.</li>
</ol>
<p>위와 같은 상황은 개발팀뿐만 아니라 회사 전체에 악영향을 주었다고 생각합니다. 지금은 이와 같은 상황을 만들지 않기 위해서 노력하고 있습니다. (부채를 열심히 갚고 있고요..)</p>
<h2 id="heading-7zqo7jyo7kcb7j24ioyxheustcdrsknsi50">효율적인 업무 방식</h2>
<p> CTO가 되면서 욕심을 부리고 싶었던 부분은 효율적인 업무 방식이었습니다. 그에 따라서 정말 다양한 툴을 사용했었습니다.</p>
<ol>
<li>트렐로 + 슬랙</li>
<li>테스크월드 + 슬랙</li>
<li>github(저장소마다 있는 project) + 슬랙</li>
<li>먼데이 + 슬랙</li>
<li>노션 + 텔레그램(현재)</li>
</ol>
<p>위의 5가지를 써보았고, 효율적인 업무를 위해서 애자일 방법론들을 짬뽕하고 조합해서 다양하게 적용을 해봤습니다. 하지만 결과적으로는 실패하게 되었습니다. 그에 따른 경험을 공유할까 합니다.</p>
<h3 id="heading-1-1">1. 모두가 함께 노력해야 된다.</h3>
<p> 모든 업무를 Task 화 하고 그에 맞게 스프린트를 단위로 개발을 하자라는 목표로 시작을 했습니다. 먼저 바로 모든 것을 바꾸기에는 무리가 있었다고 판단하였고 작은 거부터 시작하자는 목표로 진행했습니다. 하지만 쉽지 않았습니다. 누구에게는 이 방식이 편했고 누구에게는 이 방식이 불편했습니다.
업무수행 방식에 대한 약속 또한 지키지 못하는 경우가 자주 발생하였습니다. 업무수행 방식의 변화는 어느 순간부터 팀원들에게 스트레스가 되었고, 개발팀을 제외한 다른 팀, 대표님한테도 불필요한 일처럼 느끼게 만들어졌습니다. 또한 많은 업무량에 이와 같은 업무수행 방식의 변화는 더욱더 안 좋은 인식을 가져다주었습니다. 결론적으로 업무수행 방식의 변화는 회사 전체가 서로 도와줘야 된다는 것을 느끼게 되었습니다. 무엇보다도 대표님이 이해해주고 도와줘야 된다는 것을 절실하게 느꼈습니다. 그리고 엄청난 업무량과 짧은 기간에는 그냥 폭포수처럼 할 수밖에 없나? 정말 방법이 없을까? 라는 생각을 많이 하게 되었습니다. 이와 같은 경험은 2019년에는 아직 업무수행 방식을 변경하기에는 시기상조라는 결론을 내리게 되었고, 2020년에는 꼭 여유를 가지고 지금보다 나은 효율적인 업무수행 방식을 도입하겠다는 다짐을 했습니다.</p>
<h3 id="heading-2-1">2. 인식의 변화가 필요</h3>
<p> 효율적인 업무수행 방식은 회사에서 일하는 모두가 인식의 변화가 필요합니다. 그리고 이것을 적용하고 효율을 보기 위해서는 많은 시간과 노력이 필요하다는 것도 알아야 됩니다. 기존에 일했던 방식과 매우 다를 수도 있기 때문에 주변에서도 많이 도와줘야 되고 여유를 가지고 적용해야 된다는 것을 느꼈습니다.</p>
<h3 id="heading-3">3. 효율적인 업무수행 방식은 편하지 않다.</h3>
<p> 편하다는 것과 효율적이라는 말은 같지 않습니다. 효율적으로 업무를 하기 위해서는 불편함을 느낄 수밖에 없습니다. 가끔 효율적으로 업무 수행하는 게 편하게 일하는 거 아니에요? 라고 하지만 절대 아님을 알고 있어야 됩니다.</p>
<h3 id="heading-4">4. 업무수행 방식의 시스템화</h3>
<p> 확고한 체계를 가지고 시스템화 해야 됩니다. 아마 처음부터 확실한 시스템화는 하기 힘듭니다. 계속 보안하고 발전해 나가서 하나의 시스템으로 자리 잡게 해야 된다고 생각을 합니다. 그 누군가 새로 오든 혹은 누군가 회사를 퇴사하든가 문제없어야 되니깐요..</p>
<h3 id="heading-5">5. 대표님의 도움(?)</h3>
<p> 실패의 가장 큰 요인은 대표님이 불편해했고 불필요하다고 느꼈다는 것입니다. 그 누구보다 앞장서서 도와주셨으면 반은 성공하지 않을까 생각도 합니다.</p>
<p>효율적인 업무수행 방식을 도입하기 위해서는 많은 어려움이 있음을 경험하고 알게 되었습니다. 하지만 절대 포기할 수 없기도 하고요. 효율적으로 업무를 하는 회사가 있으면 경험도 하고 싶습니다. 그리고 구글이나 아마존 등등에서는 어떻게 업무를 하고 있는지도 궁금하기도 하고요. 2020년에는 조금 더 효율적인 업무수행 방식에 대해서 경험도 하고 싶고 도입을 해야 한다는 다짐을 합니다.</p>
<h2 id="heading-7ye07iks66w8ioybko2vmouklcdsp4hsm5a">퇴사를 원하는 직원</h2>
<p> 직원들 모두가 한 회사에서 평생을 함께하지 않습니다. 특히나 스타트업에서는 있을 수 없는 일입니다. 우리 회사도 퇴사를 한 직원이 있었으며, 퇴사를 생각하다가 지금까지 함께 일하는 직원도 있습니다.
 제가 CTO가 아닌 개발자로 있었을 때는 크게 생각을 하지 않았습니다. 하지만 CTO가 되고 나서부터는 누군가가 퇴사한다고 할 때마다 가슴이 철렁합니다. 많지 않은 인원으로 개발팀을 꾸려가고 있는 상황에서 누군가 퇴사하면 모든 부분에 대해서 타격이 입기 때문입니다. 이와 같은 경험을 통해서 느낀 점은 아래와 같습니다.</p>
<ol>
<li>그 누구도 그만둘 수 있다. 준비하자.</li>
<li>퇴사를 막기 위해서 희망 고문 하지 말자.</li>
<li>퇴사라는 결정을 하기 전에 미리 방지할 수 있어야된다.</li>
<li>좋은 곳으로 이직을 하게 되면 진심으로 축하하자.</li>
<li>퇴사하는 직원이 있으면 다른 직원들도 동요하게 된다. 주의하자.</li>
<li>모든 계정 및 비밀번호를 쉽게 변경 할 수 있게 준비하자</li>
<li>항상 문서화를 해서 퇴사를 해도 영향력이 적게 처리하자.</li>
<li><p>누구보다 나 자신의 멘탈 관리를 하자.</p>
<p>함께 하다가 퇴사를 한다는 얘기를 들으면 정말 서운한 감정이 먼저 앞서게 됩니다. 그다음이 업무에 대해서 생각을 하게 되고요. 솔직히 처음에는 맨탈도 많이 흔들리기도 했습니다. 맨탈 관리도 중요합니다. 하지만 언제든 함께할 수 없다는 것을 알고 준비해야 되고 좋은 곳으로 이직을 했을 시에는 진심으로 축하해야 된다고 생각합니다. 나아가서 누군가 퇴사한다는 것은 회사 전체 분위기에도 좋지 않은 영향을 미칠 수 있습니다. 그렇기 때문에 꼭 주의를 해야 됩니다.</p>
<blockquote>
<p>그 무엇이 됐든 미리미리 준비하는 것이 최고의 방법이라 생각합니다.</p>
</blockquote>
</li>
</ol>
<h2 id="heading-64z6riwiou2goyxra">동기 부여</h2>
<p> 동기부여는 정말 어렵습니다. 지금도 어렵고 앞으로도 어렵습니다. 솔직히 어떻게 해야 될지도 모르겠다고 표현하는 게 맞는 거 같습니다. CTO로서 개발팀의 동기부여를 하고 번아웃 되지 않게 잘 해야 되다는 얘기를 들었습니다. 동기부여 어떻게 해야 될까요?? 우선, 제가 직접 해본 방식은 아래와 같습니다.</p>
<ol>
<li>너는 혼자가 아니야 함께 하고 있어.</li>
<li>많은 얘기를 듣고 함께 하는 시간이 필요합니다. (가끔은 형으로써 동생으로서 조언도 하고요)</li>
<li>공감대 형성도 아주 중요하다고 생각합니다.</li>
<li>회사는 일만 하는 곳이 아니야.</li>
<li>하루에 회사에 있는 시간이 많습니다. 일만 하는 곳으로 생각이 들게 하는 게 아니라 가끔은 게임도 하고 떠들고 놀 수도 있게 만들었습니다.</li>
<li>흥미로운 기술 적용</li>
<li>정해진 기술만 가지고 개발을 한다면 본인 스스로가 정체된다는 느낌을 받을 수 있습니다. 팀원이 적용하고 싶은 기술이 있으면 언제든 오픈되어있고 적용도 할 수 있게 했습니다.</li>
<li>충분한 휴식을 주기</li>
<li>많은 업무량을 소화했으면 그것에 맞게 휴식도 주었습니다.</li>
<li>희망 고문은 하지 말자</li>
<li>처음에는 희망고문적인 말을 해봤습니다. 우리가 이렇게 하면 우리는 정말 잘될 거다 같은 느낌으로요. 하지만 순간순간은 동기부여가 될 수 있지만, 장기적으로 봤을 때는 좋지 않은 결과로 다가왔습니다.</li>
</ol>
<p>위와 같은 노력에도 불구하고 동기부여는 아직도 어렵습니다. 그리고 제가 열심히 노력한다고 해도 회사에서 그것에 맞게 따라주지 않으면 동기부여가 무산되기도 합니다. 누군가 동기부여에 대해서 강의를 하면 꼭 돈을 줘서라도 듣고 싶습니다.</p>
<h2 id="heading-67aa7kcv7kcb7j24iou2hoychoq4soyxkcdso7zsnzjtlzjsnpa">부정적인 분위기에 주의하자</h2>
<p> 재미있는 건 긍정적인 분위기보다는 부정적인 분위기가 쉽게 퍼집니다. 부정적인 분위기는 다들 아시겠지만 업무 효율부터 모든 것에 마이너스가 됩니다. 아래와 같은 상황을 주의해야 됩니다.</p>
<ol>
<li>누굴 탓하는 분위기</li>
<li>일하기 싫다는 분위기</li>
<li>퇴사하는 사람한테 나오는 무언가 퇴사해서 좋다는 분위기</li>
</ol>
<p>글 쓰는 이 순간에는 저 위의 3가지만 생각이 납니다. 부정적인 분위기는 막는다고 막히는 게 아니라고 생각은 합니다. 하지만 최대한 주의를 하고 미리 예방한다면 좋지 않을까 합니다.</p>
<h2 id="heading-66ei7kea66ej7jy866gcli4">마지막으로..</h2>
<p> 너무 두서없이 주저리주저리 쓴 거 같습니다. 마지막으로 위의 글 말고도 생각나는 걸 그냥 막 나열해볼까 합니다.</p>
<ol>
<li>맨탈 관리 잘하자.</li>
<li>왜 CTO를 뽑을 때 경험이 있는 사람을 우대하는지 알겠다.</li>
<li>건강 관리하자.</li>
<li>사람은 누구나 특출나게 잘하는 곳이 있다. 그것에 맞게 업무 분배가 중요하다.</li>
<li>나 혼자 아무리 해보려고 해도 팀원이나 회사가 도와주지 않으면 아무것도 못 한다. 함께하자.</li>
<li>효율적인 업무수행 방식은 못하더라도 문서화는 꼭 하자.</li>
<li>욕심을 버리고 내려놓자.</li>
<li>누군가 나에 대해서 얘기를 하는 거에 예민하게 반응하지 말자.</li>
<li>말하기보다는 듣기를 조금 더 하자.</li>
<li>부족한 부분을 탓하기보다는 채워주기 위해서 노력하자.</li>
<li>말을 할 때 10번은 생각하고 말하자.</li>
<li>생각 할 수 있는 여유를 가지고 개발을 할 수 있는 환경을 만들자. (급하게 해서는 절대 안 된다)</li>
<li>진솔한 사람이 되자.</li>
</ol>
<p>아직도 많이 부족하기만 하다는 것을 글을 쓰면서 다시 한번 느끼게 됩니다. 2020년 제가 어떻게 될지는 모르겠지만 지금보다 조금 더 발전하고 나아가야겠습니다. 저와 함께 일한 동료들이 다시 또 함께 할 수 있게 그리고 개발자로서도 발전해 나가야겠다고 다짐을 합니다.</p>
]]></content:encoded></item><item><title><![CDATA[Serverless를 선택한 이유(Lambda, Altas)]]></title><description><![CDATA[CTO를 맡으면서 제가 선택하고 실무에 적용하면서 경험한 Serverless에 대해서 글을 남기려고 합니다.
Serverless?
 여기에 와서 글을 읽으시는 분들은 Serverless가 무엇인지 충분히 알고 있을 거라고 생각합니다. 그래도 간단하게만 얘기한다면 진짜 Server가 없는 것은 아니고 Server를 신경 쓰지 않아도 서비스를 할 수 있게 하는 기술이라고 보면 됩니다.
 조금 더 있어 보이게 얘기한다면 애플리케이션 개발자가 서버를 ...]]></description><link>https://blog.dongjun.win/lambda</link><guid isPermaLink="true">https://blog.dongjun.win/lambda</guid><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Fri, 17 Jan 2020 03:25:00 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://raw.githubusercontent.com/mayajuni/hexo-blog/master/source/images/tyle-bfw-02.png" alt />
 CTO를 맡으면서 제가 선택하고 실무에 적용하면서 경험한 Serverless에 대해서 글을 남기려고 합니다.</p>
<h2 id="heading-serverless">Serverless?</h2>
<p> 여기에 와서 글을 읽으시는 분들은 Serverless가 무엇인지 충분히 알고 있을 거라고 생각합니다. 그래도 간단하게만 얘기한다면 진짜 Server가 없는 것은 아니고 Server를 신경 쓰지 않아도 서비스를 할 수 있게 하는 기술이라고 보면 됩니다.
 조금 더 있어 보이게 얘기한다면 <strong><em>애플리케이션 개발자가 서버를 프로비저닝하거나 애플리케이션의 확장을 관리할 필요가 없는 클라우드 컴퓨팅 모델을 가리킵니다.</em></strong>
 여기에서 저는 실무에 적용하고 경험한 Lambda와 Mongodb Atlas 등 Serverless를 선택한 이유와 그에 대한 글을 남길까 합니다.</p>
<h2 id="heading-lambda">Lambda</h2>
<p> 람다는 AWS에서 만든 <strong><em>서버를 프로비저닝하거나 관리하지 않고도 코드를 실행할 수 있게 해주는 컴퓨팅 서비스</em></strong>입니다. 솔직히 말이 조금 어렵지만 단순하게 서버 관리가 필요 없이 함수를 실행해 주는 놈? 이라고 생각하는 게 편합니다.</p>
<p><strong>람다를 사용했을 시 장점은 무엇이 있을까?</strong></p>
<ol>
<li>개발에만 집중 할 수 있는 환경이 됩니다.</li>
<li>서버에 대해서 고민을 할 필요가 없습니다. (오토스캐일링부터 다양한 관점에서)</li>
<li>비용이 저렴합니다.</li>
<li>서버뿐만 아니라 AWS의 기능에 대한 트리거 및 스케줄로 등등으로 사용이 가능합니다. (예로 code pipeline에서 s3 배포를 완료 후 Cloud front 캐시 초기화할 때도 사용됩니다)</li>
</ol>
<p><strong>장점만 있으면 좋겠지만 단점도 존재합니다.</strong></p>
<ol>
<li>콜드 스타트 부분입니다. 최근에 동시성이라는 기능이 추가되어서 많이 좋아지긴 했습니다. 하지만 디비 커넥션과 같은 부분은 콜드 스타트 부분에서 초기화되기 때문에 생각을 해줘야 됩니다.</li>
<li>로그 보는 부분이 매우 불편합니다. (AWS의 CloudWatch를 통해서 볼 수 있지만 불편합니다)</li>
<li>동시 실행에 대한 제한이 있습니다.</li>
</ol>
<p><strong>단점을 극복 한 저의 경험은?</strong></p>
<ol>
<li>콜드 스타트<ul>
<li>해당 부분은 현재 15분마다 해당 function을 호출해 주는 방법이 가장 좋다는 생각은 듭니다. 하지만 MSA로 구현이 되어 있다면 수많은 function을 호출하는 게 비용적인 측면에서 문제가 있을 수 있습니다. 저는 Lambda의 사용을 꼭 MSA로 구현하지 않아도 된다고 생각하기 때문에 해당 부분은 어떻게 설계하냐에 따라 달라질 거라 생각합니다.</li>
</ul>
</li>
<li>로그 문제<ul>
<li>lambda를 호출하는 부분에 공통으로 로그를 남기는 부분을 만들어 놓았습니다. 물론 x-ray와 같은 서비스를 사용해도 좋겠지만 그거보다는 직접 호출이 되었을 시 그리고 오류가 났을 시 등등을 전부 에러 로그 처리해 놓고 확인하게 하였습니다. 다음에는 ELK를 이용하여 로그 분석 관련해서 작업을 진행할까 생각도 하고 있습니다.</li>
</ul>
</li>
<li>동시 실행에 대한 제한<ul>
<li>이 부분은 방법이 없습니다. 미리미리 AWS에 동시 실행에 대한 제한 부분을 풀어 놓으면 됩니다.</li>
</ul>
</li>
<li>제한된 모니터링 툴<ul>
<li>Lambda에 대한 모니터링 툴은 많이 부족하고 찾기 힘듭니다. 물론 CloudWatch가 있긴하지만 뭔가 부족함이 있다는 것을 느낄 수 있습니다. 아직 이 부분은 정도 CloudWatch를 확인하네요.</li>
</ul>
</li>
</ol>
<p><strong>Lambda를 직접 운영/개발을 하면서 알게 된 팁.</strong></p>
<ol>
<li>Lambda layer를 꼭 사용해야 합니다.<ul>
<li>layer 같은 경우는 외부 코드나 라이브러리, 모듈 등을 사전에 압축하여 하나의 큰 모듈처럼 사용 할 수 있게 해줍니다.</li>
<li>layer를 사용하면 deploy 부분에 대한 속도 부분이 극명하게 차이 날 정도로 빨라집니다.</li>
</ul>
</li>
<li>Serverless Framework 사용 추천<ul>
<li>이 부분을 팁으로 놓아야 하나 고민은 많이 되었습니다. 하지만 다른 프레임워크보다는 다양한 플러그인 지원, 그리고 방대한 커뮤니티 등등이 Lambda 혹은 Serverless를 사용하면서 큰 도움이 되었다고 생각합니다.</li>
</ul>
</li>
<li>Codepipeline 사용한 자동배포<ul>
<li>AWS를 사용해서 배포를 자동화하면 좋습니다. 보안에 민감할 수 있으니 해당 배포에 대한 권한을 개발자에게 주기보다는 AWS IAM을 이용해서 자동 배포 할시에만 주는걸 추천해 드립니다.</li>
</ul>
</li>
<li>MSA?? 굳이... 상황에 맞게 처리.<ul>
<li>Lambda를 사용하면 MSA를 해야 될 거 같은 느낌을 받을 수 있지만, 굳이 그렇게 할 필요가 없습니다. Monolithic과 비슷하게 1개의 endpoint에 1개의 function으로 해도 됩니다. 제가 추천하는 건 적당한 선에서 상황에 맞게 처리하는 것을 추천합니다.</li>
<li>MSA를 했을 때에는 DB 커젝션 수부터 로그 부분 그리고 function이 많아질 경우 그거에 대한 관리 등등을 고민해야 됩니다. 또한 콜드 스타트 때문에 매번 lambda 호출 시 드는 비용에 대해서도 고민할 필요가 있습니다.</li>
<li>Monolithic으로 했을 때에는 너무 방대해진 코드의 용량에 대해 고민을 할 필요가 있습니다. 또한 동시 실행의 제한도 미리미리 신청해서 늘려 놓아야 됩니다.</li>
</ul>
</li>
</ol>
<p><strong> Lambda를 선택한 이유</strong></p>
<p>위의 다양한 의견을 내긴 했지만 제가 Lambda를 선택한 가장 큰 이유는 서버 관리가 필요하지 않고 개발에 집중 할 수 있는 환경을 구성 할 수 있기 때문입니다. Lambda에서 나오는 단점은 충분히 커버도 가능할 거라 생각하고요. 비용 부분도 저렴합니다. 우선 프리티어와 관계없이 월 1백만 호출까지는 무료로 알고 있습니다. 호출된 횟수에 맞게 돈을 지불하기 때문에도 저렴하다고 말 할 수 있습니다. 트레픽이 많아지면 많이 비싸진다는 애기도 있긴 합니다. 이 부분도 말씀드리고 싶은것은 우선 Lambda가 아닌 다른 것을 사용해도 트레픽이 많아지면 가격이 비싸집니다. 물론 딱 물리적인 가격만 비교하면 Lambda가 비싸게 느껴질 수 있지만 트레픽 대응에 대한 서버 관리, 인력 고용 등등을 생각도 해야 됩니다.(대용량 트레픽에 따른 서버 관리하는 사람을 고용하는 것도 어렵고 급여도 많이 높을 거라 생각합니다) 이 모든 비용을 바라본다면 Lambda가 저렴하다고 생각합니다.</p>
<h2 id="heading-mongodb-atlas">Mongodb Atlas</h2>
<p> Mongodb Atlas는 Mongodb management service(MMS)입니다. 말이 어렵게 느껴질 수 있지만 결국 serverless로 mongodb에 대한 모든 관리는 Atlas에서 해준다고 생각하면 편합니다. Lambda랑 비슷합니다. 한때는 Mlab이 가장 유명했지만 mongodb에서 인수를 하면서 서비스가 종료되었습니다.</p>
<p><strong> Mongodb Atals의 장점 </strong></p>
<ol>
<li>가장 좋은 건 Mongodb에 대한 관리가 필요 없습니다. Server부터 모든 기능 전부(리플리카셋, 샤딩 등등)</li>
<li>모니터링 툴을 따로 쓸 이유가 없습니다.</li>
<li>알람 또한 너무 잘되어 있어서 빠르게 확인 할 수 있습니다.</li>
<li>Performance Advisor라는 기능을 제공하여 쿼리 속도부터 index가 필요한 부분까지 체크해줍니다.</li>
<li>멀티 리전을 사용할 수 있습니다.</li>
<li>스토리지에 대한 Auto Scaling을 제공합니다.</li>
<li>백업에 대해서도 지원합니다. (실시간 백업도 가능, 4.2버전 이상은 아직 미지원)</li>
</ol>
<p><strong> 단점은? </strong></p>
<ol>
<li>비용이 저렴하진 않습니다.</li>
</ol>
<p>단점 부분은 솔직히 비용을 적긴 했지만 저는 합리적이라고 생각합니다. 단점을 찾기가 쉽지 않네요..</p>
<p><strong> Mongodb Atals를 사용하면서 알게 된 팁 </strong></p>
<ol>
<li>같은 리전, 같은 클라우드<ul>
<li>당연한 소리이지만 같은 리전 그리고 같은 클라우드 서비스로 만드는 것을 추천합니다. (이유는 굳이 설명 안 하겠습니다.)</li>
</ul>
</li>
<li>Database Access 권한<ul>
<li>Database Access 권한인 경우는 모든 클러스트 공통으로 적용이 됩니다. 저희 같은 경우 개발 클러스트와 실 클러스트를 운영하는데 Database Access 권한을 주면 둘 다 동일하게 적용되었습니다. (다른 방법이 있는데 제가 모르는 거 일 수도 있습니다) 그렇기 때문에 해당 부분을 확인하는 게 좋습니다.</li>
</ul>
</li>
<li>Network Access 권한<ul>
<li>Network Access 같은 경우 보통 0.0.0.0/0으로 세팅 하는 경우가 있는데 보안상 추천하지 않습니다. 꼭 화이트 리스트로 하는 것을 추천합니다.</li>
</ul>
</li>
<li>vpc peering<ul>
<li>VPC peering을 한다고 해서 체감상 속도가 빨라지진 않습니다. 다만 이걸 하는 것을 추천하는 이유는 보안상의 이유가 크다고 볼 수 있습니다.</li>
</ul>
</li>
<li>Performance Advisor 기능<ul>
<li>Performance Advisor 기능을 적극적으로 활용해야 됩니다. 정말 신기할 정도로 잘 추천해주고 그것에 맞게 처리하면 성능 개선을 확실히 볼 수 있습니다. 다만 초기에 데이터가 없을 시에는 확인이 되지 않을 수 있습니다.</li>
</ul>
</li>
<li>스케일 업<ul>
<li>mongodb 스케일 업 시에는 간단하게 버튼만으로 추가 할 수 있습니다. 다만 스케일업 하는 동안은 서비스가 되지 않기 때문에 충분한 공지를 통해서 하시는걸 추천해 드립니다.</li>
</ul>
</li>
</ol>
<p><strong> Mongodb Atlas를 선택한 이유</strong></p>
<p> 똑같은 소리를 반복하는 거일 수 있지만 서버 관리, 몽고디비의 다양한 기능, 설정 등등을 직접 관리할 필요가 없고 개발에 집중 할 수 있는 환경을 조성 할 수 있기 때문입니다. 초기에는 Atlas가 아닌 직접 mongodb를 운영도 해보았습니다. EC2 3대와 글로벌 서비스를 위한 버지니아에 read 전용 EC2 2대까지 총 5대를 세팅하고 운영을 하면서 다양한 버그, 에러 그리고 모니터링 툴의 필요성, 알람의 필요성, 백업 계획 등등을 느끼고 있었지만, 개발을 할 수 있는 인력 및 여건이 되지 않아 Atlas로 옮기는 결정을 하고 작업을 했습니다. 옮기고 나서 만족도는 1,000,000%입니다. 다만 단점이라고 하면 비용에 있습니다. 하지만 위의 부가적인 작업 및  Mongodb에 문제가 있을 시 등등의 기회비용 인력 비용 모든 것을 다 합한다고 하면 합리적이라고 생각합니다.</p>
<h3 id="heading-serverless-1">Serverless 왜 선택했냐?</h3>
<p> Serverless를 선택한 이유는 아래와 같습니다.</p>
<ol>
<li>개발에 집중하는 환경<ul>
<li>많은 스타트업이 그렇겠지만 개발자는 뽑기 힘들고 많이 있지 않습니다. (어디 있나요..?) 많지 않은 인력으로 서비스 개발만 해도 시간이 부족합니다. 그렇기 때문에 개발에 집중할 수 있는 환경을 조성하기 위한 하나의 선택지라 생각합니다.</li>
</ul>
</li>
<li>Serverless에서 제공하는 기능들<ul>
<li>Lambda, Atlas에서 제공하는 기능들은 정말 꿀과 같습니다. (특히 Atlas) 이런 기능들은 직접 만들고 유지하고 운영하기에는 저희는 인력도 부족하고 시간도 부족합니다.</li>
</ul>
</li>
<li>합리적인 비용<ul>
<li>물리적인 하드웨어를 따지고 보면 비싸게 느껴질 수 있지만 모든 기회비용까지 생각하면 오히려 저렴하다고 생각합니다. (개발자 몸값만 생각해도...)</li>
</ul>
</li>
<li>전문 지식<ul>
<li>Serverless를 이용하지 않고 직접 운영을 한다면 해당 기술에 대한 전문지식이 필요합니다. 다양한 버그, 상황에 맞는 설정 등 경험을 해보지 못하는 경우가 많고 아무리 많이 안다고 해도 전문적으로 해당 기술에 대한 Serverless 하는 회사에 보다는 많이 부족함도 사실입니다.</li>
</ul>
</li>
</ol>
<h3 id="heading-66ei66y066as7zwy66mwlg">마무리하며.</h3>
<p> 다양한 서비리스 중 2가지만 애기하기 했지만 다른 좋은 서버리스도 많습니다. 상황에 맞게 그리고 비즈니스에 맞게 잘 활용한다면 아주 큰 도움이 될 수 있다고 생각합니다.</p>
]]></content:encoded></item><item><title><![CDATA[Frontend 개발 후 AWS에 서비스 배포하기]]></title><description><![CDATA[Frontend(vue, react, angular 등등)를 개발을 하고 서비스를 하기 위한 AWS 환경 설정 및 배포에 대해서 글을 남깁니다.
아키텍처
단순하게 SSR이 아니기 때문에 따로 서버를 두고 관리하기 보다는 S3에 파일을 올리고 그에 맞게 CDN인 Coundfront와 연결 후 Route53을 이용해서 도메인까지 연결하는 구조로 생각을 했습니다. 그리고 배포 시스템으로는 Codepipeline을 이용해서 배포 하는 방법을 채택했습니...]]></description><link>https://blog.dongjun.win/fronend-aws-s3-cloudfront-route-53-vue-angular-react</link><guid isPermaLink="true">https://blog.dongjun.win/fronend-aws-s3-cloudfront-route-53-vue-angular-react</guid><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Mon, 13 Jan 2020 02:47:00 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://raw.githubusercontent.com/mayajuni/hexo-blog/master/source/images/tyle-blo-02.png" alt />
Frontend(vue, react, angular 등등)를 개발을 하고 서비스를 하기 위한 AWS 환경 설정 및 배포에 대해서 글을 남깁니다.</p>
<h2 id="heading-7jwe7ykk7ywn7lky">아키텍처</h2>
<p>단순하게 SSR이 아니기 때문에 따로 서버를 두고 관리하기 보다는 S3에 파일을 올리고 그에 맞게 CDN인 Coundfront와 연결 후 Route53을 이용해서 도메인까지 연결하는 구조로 생각을 했습니다. 그리고 배포 시스템으로는 Codepipeline을 이용해서 배포 하는 방법을 채택했습니다.</p>
<p><strong>개발자</strong></p>
<ol>
<li>개발 소스 GitHub 배포</li>
<li>CodePipeline에서 Webhook을 이용 Github 확인</li>
<li>CodeBuild를 이용하여 빌드(ex. npm run build와 같은 것 등등)</li>
<li>CodeDeploy를 이용해서 S3 배포</li>
<li>S3를 배포가 완료 되면 Lambda를 실행 시켜 CloudFront 캐시 초기화</li>
</ol>
<p><strong>고객</strong></p>
<ol>
<li><p>www.example.com 접속시 DNS 접속(Route53)</p>
</li>
<li><p>Route53에 연결된 CNS(CloudFront) 호출</p>
</li>
<li><p>캐싱이 되어 있으면 캐싱된 부분으로 리턴 안되어 있으면 S3 접근 후 리턴</p>
</li>
</ol>
<h2 id="heading-7jwe7ykk7ywn7lky7jeqiouusoulucdtmzjqsr0g7isk7kcv">아키텍처에 따른 환경 설정</h2>
<p>CloudFormation을 이용해서 배포 하는 방법이 있지만 아직 그 부분은 익숙하지 않아서 아래와 같이 직접 배포를 하게 되었습니다.</p>
<h3 id="heading-1-s3">1. S3</h3>
<h4 id="heading-1">1) 저장소 생성</h4>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/%EB%B2%84%ED%82%B7%EC%83%9D%EC%84%B1.png?raw=true" alt /></p>
<p>위의 이미지의 버킷 만들기를 클릭 후 배킷을 생성 합니다.</p>
<blockquote>
<p>생성시 꼭 퍼블릭 액세스 차단은 비활성화를 하셔야됩니다.</p>
</blockquote>
<h4 id="heading-2">2) 정책 설정</h4>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/%EB%B2%84%ED%82%B7%EC%A0%95%EC%B1%85.png?raw=true" alt /></p>
<p>만든 버킷에 접속 후에 해당 권한 탭 -&gt; 버킷 정책을 클릭후 아래와 같은 정책을 입력 합니다. 그냥 해당 정책은 읽기 권한을 퍼블릭하게 오픈 한다는 의미 입니다.</p>
<pre><code class="lang-JSON">{
 <span class="hljs-attr">"Version"</span>: <span class="hljs-string">"2008-10-17"</span>,
 <span class="hljs-attr">"Statement"</span>: [
 {
 <span class="hljs-attr">"Sid"</span>: <span class="hljs-string">"Stmt1484315864175"</span>,
 <span class="hljs-attr">"Effect"</span>: <span class="hljs-string">"Allow"</span>,
 <span class="hljs-attr">"Principal"</span>: <span class="hljs-string">"*"</span>,
 <span class="hljs-attr">"Action"</span>: <span class="hljs-string">"s3:GetObject"</span>,
 <span class="hljs-attr">"Resource"</span>: <span class="hljs-string">"arn:aws:s3:::버킷명/*"</span>
 }
 ]
}
</code></pre>
<h4 id="heading-3">3) 정책 설정</h4>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/%EC%86%8D%EC%84%B1%EC%84%A4%EC%A0%95.png?raw=true" alt /></p>
<p>속성 탭에 있는 정적 웹사이트 호스팅을 위와 같이 설정합니다.</p>
<h3 id="heading-2-cloudfront">2. CloudFront 설정</h3>
<h4 id="heading-1-1">1) 생성</h4>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/CF-1.png?raw=true" alt /></p>
<p>위의 Create Distribution을 클릭합니다.</p>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/CF-2.png?raw=true" alt /></p>
<p>Web 부분의 Get Started를 클립 합니다. (저희는 Web이니깐요)</p>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/CF-3.png?raw=true" alt /></p>
<ol>
<li><strong>Origin Domain Name</strong>
여기에 위의 S3 속성 설정의 앤드포인트(URL주소)를 가지고 와서 붙여넣기 합니다. (url 주소를 넣는 거지 버킷 아이디를 넣으면 안 됩니다.)
Ex) http://dev-front-itam.store-admin.s3-website.ap-northeast-2.amazonaws.com/</li>
<li>다른 부분은 굳이 입력하지 않아도 됩니다.</li>
</ol>
<blockquote>
<p>여기에서 버킷을 안 넣고 주소를 넣는 웹서비스이기 때문입니다. 특히 권한 문제.</p>
</blockquote>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/CF-4.png?raw=true" alt /></p>
<ol>
<li><strong>Viewer Protocol Policy</strong>
<strong>Redirect HTTP to HTTPS</strong> 을 선택해주세요.</li>
<li><strong>Object Caching</strong>
caching에 대한 설정을 다르게 하고 싶으시면 이 부분을 Customize로 설정후 아래의 활성화된 값을 넣으면 됩니다. 굳이 안 하려면 위의 사진과 같이 확인하시면 되요.</li>
</ol>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/CF-5.png?raw=true" alt /></p>
<ol>
<li><strong>Price Class</strong>
기본값인 Use All Edge Locations를 선택합니다. 물론 나의 타깃은 확고하고 정해져 있다고 하면 다른 값으로 설정하셔도 됩니다.</li>
<li><strong>Alternate Domain Names (CNAMEs)</strong>
서비스할 도메인을 넣으면 됩니다. 복수도 가능합니다. 여러 개의 도메인을 사용 시 한 줄 씩 쓰면 됩니다.
Ex) dev.example.com</li>
<li>서비스할 도메인이 있을시 <strong>SSL Certificate</strong>의 Custom SSL Certificate (example.com) 을 선택 후 인증서를 넣으면 됩니다. (인증서는 바로 아래의 버튼을 클릭 후 설정이 가능합니다.)</li>
</ol>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/CF-6.png?raw=true" alt /></p>
<p>여기까지 왔으면 다 온 거라고 보시면 돼요. 여기서는 간단하게 Comment만 적어 두고 끝내면 됩니다. (관리하기 편하게 해당 서비스명을 넣는걸 추천해 드립니다.)</p>
<h4 id="heading-2-spa">2) SPA 관련 세팅</h4>
<p> SPA는 말 그대로 싱글 페이지 애플리케이션이기 때문에 모든 부분을 index.html 가게 해야 됩니다. 지금 이 설정을 하지 많으면 404 에러가 뜨게 됩니다.</p>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/CF-error.png?raw=true" alt /></p>
<p>위의 Create Custom Error Response를 클릭 해주세요.</p>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/CF-error-2.png?raw=true" alt /></p>
<p>위의 사진과 같이 404 관련된 에러는 전부 /index·html로 가게 해주면 됩니다. 여기에서 주의 깊게 볼 부분은 꼭 TTL은 0으로 세팅해주셔야 됩니다. 안 그러면 기다림이 발생합니다.</p>
<h3 id="heading-route53">Route53</h3>
<p> 여기에서는 DNS 설정이 대한 부분은 제외하고 설명하겠습니다. 되게 간단합니다.</p>
<p><img src="https://github.com/mayajuni/images/blob/master/blog/aws/route53.png?raw=true" alt /></p>
<ol>
<li>이름에 서브 도메인을 입력합니다(CloudFront에서 넣은 도메인명).</li>
<li>별칭을 클릭하면 Cloudfront영역에 선택 할 수 있는 주소가 생성되어 있습니다. 해당 부분을 선택합니다.</li>
</ol>
<p>위와 같이 하면 Route 53은 설정이 끝납니다.</p>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p>처음 하는 입장이면 생각보다 어렵다고 느껴지실 수 있습니다.</p>
<p>직접 해보고 익숙해지면 쉽게 하실 수 있다는 생각이 듭니다. 위의 CloudFront 부분에서 도메인 인증서 하는 부분이 빠지긴 했지만, 그 부분은 그냥 버튼을 클릭 한 후에 하라는 대로 따라 하면 됩니다.</p>
]]></content:encoded></item><item><title><![CDATA[EosJS API 사용]]></title><description><![CDATA[EosJS API 사용
안녕하세요. 권동준 입니다.
 이전에 EOSJS 시작하기에서 간단하게 EOSJS를 사용하는 방법을 해봤습니다. 이번에는 EosJs에서 제공하는 api 중에 자주 쓰는 api를 소개하고 테스트 할 수 있게 진행을 하려고 합니다. 

api 목록을 보기를 원하시면 여기를 확인해 보시면 됩니다. 

시작하기에 앞서 준비하기
모든 코드를 직접 사용 해볼 수 있게 할 예정입니다. 그렇게 하기 위해서는 준비가 필요합니다. 
준비 사...]]></description><link>https://blog.dongjun.win/eosjs-api-1</link><guid isPermaLink="true">https://blog.dongjun.win/eosjs-api-1</guid><category><![CDATA[eosjs]]></category><category><![CDATA[Eos]]></category><dc:creator><![CDATA[권동준]]></dc:creator><pubDate>Wed, 01 Aug 2018 01:32:00 GMT</pubDate><content:encoded><![CDATA[<h1 id="heading-eosjs-api">EosJS API 사용</h1>
<p>안녕하세요. 권동준 입니다.
 이전에 EOSJS 시작하기에서 간단하게 EOSJS를 사용하는 방법을 해봤습니다. 이번에는 EosJs에서 제공하는 api 중에 자주 쓰는 api를 소개하고 테스트 할 수 있게 진행을 하려고 합니다. </p>
<blockquote>
<p>api 목록을 보기를 원하시면 <a target="_blank" href="https://github.com/EOSIO/eosjs-api/blob/master/docs/api.md#eos--object">여기</a>를 확인해 보시면 됩니다. </p>
</blockquote>
<h4 id="heading-7iuc7j6r7zwy6riw7jeqioyvnuyencdspidruyttlzjqula">시작하기에 앞서 준비하기</h4>
<p>모든 코드를 직접 사용 해볼 수 있게 할 예정입니다. 그렇게 하기 위해서는 준비가 필요합니다. </p>
<p>준비 사항은 아래와 같습니다.</p>
<ol>
<li>nodeJs</li>
<li>eosJs</li>
</ol>
<p>위의 2개를 설치하고 javascript 파일 가장 위에 아래와 같이 넣어주세요.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> Eos = <span class="hljs-built_in">require</span>(<span class="hljs-string">'eosjs'</span>);

<span class="hljs-keyword">const</span> config = {
    <span class="hljs-attr">expireInSeconds</span>: <span class="hljs-number">60</span>,
    <span class="hljs-attr">broadcast</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">debug</span>: <span class="hljs-literal">false</span>,
    <span class="hljs-attr">sign</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-comment">// mainNet bp endpoint</span>
    <span class="hljs-attr">httpEndpoint</span>: <span class="hljs-string">'https://api.eosnewyork.io'</span>,
    <span class="hljs-comment">// mainNet chainId</span>
    <span class="hljs-attr">chainId</span>: <span class="hljs-string">'aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906'</span>,
};

<span class="hljs-keyword">const</span> eos = Eos(config);
</code></pre>
<p>이렇게 넣고 나서 아래의 api 예제를 직접 코딩하고 nodeJs로 javascript를 실행하면 값이 나옵니다.</p>
<blockquote>
<p>Bp Endpoint마다 응답속도 혹은 신뢰도가 각각 다르게 때문에 본인에 가장 맞는 bp를 사용하기를 권장합니다.</p>
</blockquote>
<h4 id="heading-getblockblocknumorid">getBlock(blockNumOrId)</h4>
<p>해당 블록의 정보를 가지고 올 수 있습니다.</p>
<p>params:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>param</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td>block_num_or_id</td><td>블록의 아이디나 number</td></tr>
</tbody>
</table>
</div><p>Code:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Promise</span>
eos.getBlock(<span class="hljs-number">1</span>).then(<span class="hljs-function"><span class="hljs-params">result</span> =&gt;</span> <span class="hljs-built_in">console</span>.log(result)).catch(<span class="hljs-function"><span class="hljs-params">error</span> =&gt;</span> <span class="hljs-built_in">console</span>.error(error));

<span class="hljs-comment">// callback</span>
eos.getBlock(<span class="hljs-number">1</span>, <span class="hljs-function">(<span class="hljs-params">error, result</span>) =&gt;</span> <span class="hljs-built_in">console</span>.log(error, result));

<span class="hljs-comment">// Parameters object</span>
eos.getBlock({<span class="hljs-attr">block_num_or_id</span>: <span class="hljs-number">1</span>}).then(<span class="hljs-built_in">console</span>.log);
</code></pre>
<p>결과 값: </p>
<pre><code class="lang-json">{ timestamp: '<span class="hljs-number">2018</span><span class="hljs-number">-06</span><span class="hljs-number">-08</span>T08:<span class="hljs-number">08</span>:<span class="hljs-number">08.500</span>',
  producer: '',
  confirmed: <span class="hljs-number">1</span>,
  previous:
   '<span class="hljs-number">0000000000000000000000000000000000000000000000000000000000000000</span>',
  transaction_mroot:
   '<span class="hljs-number">0000000000000000000000000000000000000000000000000000000000000000</span>',
  action_mroot:
   'aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906',
  schedule_version: <span class="hljs-number">0</span>,
  new_producers: <span class="hljs-literal">null</span>,
  header_extensions: [],
  producer_signature:
   'SIG_K1_111111111111111111111111111111111111111111111111111111111111111116uk5ne',
  transactions: [],
  block_extensions: [],
  id:
   '<span class="hljs-number">00000001405147477</span>ab2f5f51cda427b638191c66d2c59aa392d5c2c98076cb0',
  block_num: <span class="hljs-number">1</span>,
  ref_block_prefix: <span class="hljs-number">4126519930</span> }
</code></pre>
<p>해당 블록에서 어떠한 일을 했는지 보기 위해서는 transactions를 보면 됩니다. </p>
<p>transactions를 보기 위해 아래와 같이 한번 같이 해보시죠.</p>
<pre><code class="lang-javascript">[ { <span class="hljs-attr">status</span>: <span class="hljs-string">'executed'</span>,
    <span class="hljs-attr">cpu_usage_us</span>: <span class="hljs-number">1170</span>,
    <span class="hljs-attr">net_usage_words</span>: <span class="hljs-number">40</span>,
    <span class="hljs-attr">trx</span>:
     { <span class="hljs-attr">id</span>:
        <span class="hljs-string">'8a29bfa66850b7d4a2b0b62173a24c5dfe4dbd7b39c211df6309d02a85374960'</span>,
       <span class="hljs-attr">signatures</span>: [<span class="hljs-built_in">Array</span>],
       <span class="hljs-attr">compression</span>: <span class="hljs-string">'none'</span>,
       <span class="hljs-attr">packed_context_free_data</span>: <span class="hljs-string">''</span>,
       <span class="hljs-attr">context_free_data</span>: [],
       <span class="hljs-attr">packed_trx</span>:
        <span class="hljs-string">'9051595bad38f016a289000000000100a6823403ea3055000000572d3ccdcd0110e0a53cab294d7600000000a8ed3232dd0110e0a53cab294d76a0986af64b96bc65010000000000000004454f5300000000bb01496e74726f647563696e67204954414d204e6574776f726b2c20616e20454f532d426173656420444150502050726f6a656374206f6e20426c6f636b636861696e2047616d696e6720506c6174666f726d20666f722061205472616e73706172656e742047616d696e672045636f73797374656d2e202d2d576562736974653a2068747470733a2f2f6974616d2e67616d65732f656e202d2d54656c656772616d3a2068747470733a2f2f742e6d652f6974616d6e6574776f726b00'</span>,
       <span class="hljs-attr">transaction</span>: [<span class="hljs-built_in">Object</span>] } }
 ]
</code></pre>
<p>위와 같은 값으로 주며 저기에서도 transaction를 보면 actions가 있으며 그걸 보면 이 블록에서 어떤일들을 했는지 더욱 깊게 볼 수 있습니다.</p>
<h3 id="heading-getaccountaccountname">getAccount(accountName)</h3>
<p>Eos계정의 정보를 가지고 올때 사용합니다.</p>
<p>Params:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Param</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td>account_name</td><td>eos 계정의 이름</td></tr>
</tbody>
</table>
</div><p>Code:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Promise</span>
eos.getAccount(<span class="hljs-string">'itamnetwork1'</span>)
    .then(<span class="hljs-function"><span class="hljs-params">result</span> =&gt;</span> <span class="hljs-built_in">console</span>.log(result))
    .catch(<span class="hljs-function"><span class="hljs-params">error</span> =&gt;</span> <span class="hljs-built_in">console</span>.error(error));

<span class="hljs-comment">// callback</span>
eos.getAccount(<span class="hljs-string">'itamnetwork1'</span>, <span class="hljs-function">(<span class="hljs-params">error, result</span>) =&gt;</span> <span class="hljs-built_in">console</span>.log(error, result));

<span class="hljs-comment">// Parameters object</span>
eos.getAccount({<span class="hljs-attr">account_name</span>: <span class="hljs-string">'itamnetwork1'</span>})
    .then(<span class="hljs-function"><span class="hljs-params">result</span> =&gt;</span> <span class="hljs-built_in">console</span>.log(result))
    .catch(<span class="hljs-function"><span class="hljs-params">error</span> =&gt;</span> <span class="hljs-built_in">console</span>.error(error));
</code></pre>
<p>결과 값</p>
<pre><code class="lang-json">{ account_name: 'itamnetwork1',
  head_block_num: <span class="hljs-number">8516805</span>,
  head_block_time: '<span class="hljs-number">2018</span><span class="hljs-number">-07</span><span class="hljs-number">-30</span>T07:<span class="hljs-number">34</span>:<span class="hljs-number">52.500</span>',
  privileged: <span class="hljs-literal">false</span>,
  last_code_update: '<span class="hljs-number">1970</span><span class="hljs-number">-01</span><span class="hljs-number">-01</span>T00:<span class="hljs-number">00</span>:<span class="hljs-number">00.000</span>',
  created: '<span class="hljs-number">2018</span><span class="hljs-number">-07</span><span class="hljs-number">-09</span>T02:<span class="hljs-number">24</span>:<span class="hljs-number">58.500</span>',
  core_liquid_balance: '<span class="hljs-number">12.6131</span> EOS',
  ram_quota: <span class="hljs-number">14976</span>,
  net_weight: <span class="hljs-number">201000</span>,
  cpu_weight: <span class="hljs-number">10401000</span>,
  net_limit: { used: <span class="hljs-number">1679786</span>, available: <span class="hljs-number">11108657</span>, max: <span class="hljs-number">12788443</span> },
  cpu_limit: { used: <span class="hljs-number">7950353</span>, available: <span class="hljs-number">6356380</span>, max: <span class="hljs-number">14306733</span> },
  ram_usage: <span class="hljs-number">10934</span>,
  permissions:
   [ { perm_name: 'active', parent: 'owner', required_auth: [Object] },
     { perm_name: 'owner', parent: '', required_auth: [Object] } ],
  total_resources:
   { owner: 'itamnetwork1',
     net_weight: '<span class="hljs-number">20.1000</span> EOS',
     cpu_weight: '<span class="hljs-number">1040.1000</span> EOS',
     ram_bytes: <span class="hljs-number">14976</span> },
  self_delegated_bandwidth:
   { from: 'itamnetwork1',
     to: 'itamnetwork1',
     net_weight: '<span class="hljs-number">0.1000</span> EOS',
     cpu_weight: '<span class="hljs-number">0.1000</span> EOS' },
  refund_request: <span class="hljs-literal">null</span>,
  voter_info:
   { owner: 'itamnetwork1',
     proxy: '',
     producers: [],
     staked: <span class="hljs-number">4000</span>,
     last_vote_weight: '<span class="hljs-number">0.00000000000000000</span>',
     proxied_vote_weight: '<span class="hljs-number">0.00000000000000000</span>',
     is_proxy: <span class="hljs-number">0</span> } }
</code></pre>
<p>위의 결과값중에 다 중요하지만 몇개만 설명을 하려 합니다.</p>
<ol>
<li><p>account_name
누구나 다 알다 싶이 eos account name 입니다.</p>
</li>
<li><p>ram_quota</p>
<p>내가 보유한 RAM 입니다. 단위는 byte입니다.</p>
</li>
<li><p>net_limit</p>
<p>해당 계정이 가지고 있는 총 net, 사용 가능한 net, 사용한 net을 나타냅니다. 단위는 byte입니다.</p>
</li>
<li><p>cpu_limit</p>
<p>해당 계정이 가지고 있는 총 cpu, 사용 가능한 cpu, 사용한 cpu을 나타냅니다. 단위는 us 입니다.</p>
</li>
<li><p>ram_usage</p>
<p>해당 계정이 사용한 RAM 입니다 단위는 byte입니다.</p>
</li>
<li><p>total_resources
나에게 할당된 리소스의 eos를 보여줍니다. (누군가가 나에게 delegated한 것도 포함됩니다.)</p>
</li>
<li><p>self_delegated_bandwidth
내가 내 자신에게 delegated한 정보 입니다.</p>
</li>
<li><p>voter_info</p>
<p>투표에 대한 정보입니다. 여기에서 눈여겨 봐야될 부분은 staked입니다. 이부분은 현재 내가 staked 한 부분인데요. 좀더 자세히 설명한다면 내가 스스로 내 자신에게 delegated한 부분과 누군가에서 delegated한 부분을 포함한 값입니다.</p>
</li>
</ol>
<h3 id="heading-getkeyaccountspublickey">getKeyAccounts(publicKey)</h3>
<p>public key에 해당하는 account들을 가지고 옵니다.</p>
<p>Params:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Param</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td>public_key</td><td>EOS의 public key</td></tr>
</tbody>
</table>
</div><p>Code:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Promise</span>
eos.getKeyAccounts(<span class="hljs-string">'EOS6S6C5ExCM7VHGdmG5h6VREVJEC33bpMJtLucwhyByPmzB58KW5'</span>)
    .then(<span class="hljs-function"><span class="hljs-params">result</span> =&gt;</span> <span class="hljs-built_in">console</span>.log(result))
    .catch(<span class="hljs-function"><span class="hljs-params">error</span> =&gt;</span> <span class="hljs-built_in">console</span>.error(error));

<span class="hljs-comment">// callback</span>
eos.getKeyAccounts(<span class="hljs-string">'EOS6S6C5ExCM7VHGdmG5h6VREVJEC33bpMJtLucwhyByPmzB58KW5'</span>,
    <span class="hljs-function">(<span class="hljs-params">error, result</span>) =&gt;</span> <span class="hljs-built_in">console</span>.log(error, result));

<span class="hljs-comment">// Parameters object</span>
eos.getKeyAccounts({<span class="hljs-attr">public_key</span>: <span class="hljs-string">'EOS6S6C5ExCM7VHGdmG5h6VREVJEC33bpMJtLucwhyByPmzB58KW5'</span>})
    .then(<span class="hljs-built_in">console</span>.log);
</code></pre>
<p>결과값:</p>
<pre><code class="lang-json">{ account_names: [ 'itamnetwork1' ] }
</code></pre>
<p>EOS의 public key 한개로 여러 account를 만들수 있습니다. 그렇게 때문에 account_name의 값이 string으로 이루어진 array 입니다.</p>
<h3 id="heading-getcurrencybalancecode-account-symbol">getCurrencyBalance(code, account, symbol)</h3>
<p>code의 symbol에 해당하는 Token을 가지고 옵니다. </p>
<p>Params:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Param</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td>code</td><td>컨트렉트 명 혹은 해당 컨트렉트가 있는 account명을 말합니다.<br />ex) eosio.token, therealkarma 등등</td></tr>
<tr>
<td>account</td><td>조회할 EOS의 계정명 입니다.</td></tr>
<tr>
<td>symbol</td><td>Token의 symbol 입니다. 이부분은 필수값이 아닌 옵션 값입니다.</td></tr>
</tbody>
</table>
</div><p>Code:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Promise</span>
eos.getCurrencyBalance(<span class="hljs-string">'eosio.token'</span>, <span class="hljs-string">'itamnetwork1'</span>, <span class="hljs-string">'EOS'</span>)
    .then(<span class="hljs-function"><span class="hljs-params">result</span> =&gt;</span> <span class="hljs-built_in">console</span>.log(result))
    .catch(<span class="hljs-function"><span class="hljs-params">error</span> =&gt;</span> <span class="hljs-built_in">console</span>.error(error));

<span class="hljs-comment">// callback</span>
eos.getCurrencyBalance(<span class="hljs-string">'eosio.token'</span>, <span class="hljs-string">'itamnetwork1'</span>, <span class="hljs-string">'EOS'</span>,
    <span class="hljs-function">(<span class="hljs-params">error, result</span>) =&gt;</span> <span class="hljs-built_in">console</span>.log(error, result));

<span class="hljs-comment">// Parameters object</span>
eos.getCurrencyBalance({<span class="hljs-attr">account</span>: <span class="hljs-string">'itamnetwork1'</span>, <span class="hljs-attr">code</span>: <span class="hljs-string">'eosio.token'</span>, <span class="hljs-attr">symbol</span>: <span class="hljs-string">'EOS'</span>})
    .then(<span class="hljs-built_in">console</span>.log);
</code></pre>
<p>결과값:</p>
<pre><code class="lang-json">[ '<span class="hljs-number">12.6131</span> EOS' ]
</code></pre>
<p>결과 값을 보면 string형식의 array가 나옵니다. 이유는 해당 컨트렉트안에 여러 symbol을 가진 token들이 있을수 있기 때문입니다. EOS 테스트넷인 정글넷을 보면 symbol을 제외하고 eosio.token을 조회하면 2개의 token들을 볼수 있습니다.</p>
<h3 id="heading-getcurrencystatscode-symbol">getCurrencyStats(code, symbol)</h3>
<p>symbol에 해당하는  Token의 정보를 가지고 옵니다.</p>
<p>Params:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Param</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td>code</td><td>컨트렉트 명 혹은 해당 컨트렉트가 있는 account명을 말합니다.<br />ex) eosio.token, therealkarma 등등</td></tr>
<tr>
<td>symbol</td><td>Token의 symbol 입니다.</td></tr>
</tbody>
</table>
</div><p>Code:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Promise</span>
eos.getCurrencyStats(<span class="hljs-string">'eosio.token'</span>, <span class="hljs-string">'EOS'</span>)
    .then(<span class="hljs-function"><span class="hljs-params">result</span> =&gt;</span> <span class="hljs-built_in">console</span>.log(result))
    .catch(<span class="hljs-function"><span class="hljs-params">error</span> =&gt;</span> <span class="hljs-built_in">console</span>.error(error));

<span class="hljs-comment">// callback</span>
eos.getCurrencyStats(<span class="hljs-string">'eosio.token'</span>, <span class="hljs-string">'EOS'</span>,
    <span class="hljs-function">(<span class="hljs-params">error, result</span>) =&gt;</span> <span class="hljs-built_in">console</span>.log(error, result));

<span class="hljs-comment">// Parameters object</span>
eos.getCurrencyStats({<span class="hljs-attr">code</span>: <span class="hljs-string">'eosio.token'</span>, <span class="hljs-attr">symbol</span>: <span class="hljs-string">'EOS'</span>})
    .then(<span class="hljs-built_in">console</span>.log);
</code></pre>
<p>결과값:</p>
<pre><code class="lang-json">{ EOS:
   { supply: '<span class="hljs-number">1006148640.3388</span> EOS',
     max_supply: '<span class="hljs-number">10000000000.0000</span> EOS',
     issuer: 'eosio' } }
</code></pre>
<p>결과값에 대한 설명은 아래와 같습니다.</p>
<ol>
<li><p>supply
현재 공급된 토큰의 갯수 입니다.</p>
</li>
<li><p>max_supply</p>
<p>총 토큰의 갯수 입니다.</p>
</li>
<li><p>issuer
발행자 입니다.</p>
</li>
</ol>
<h2 id="heading-66ei66y066as7zwy66mw">마무리하며</h2>
<p>자주 쓰는 api들중 5개를 소개하는 시간을 가지게 되었습니다. 아직 더 많은 api들이 있고 다음 블로그에 이어서 많이 쓰는 api들에 대해서 연재할 계획입니다. 감사합니다.</p>
<blockquote>
<p>해당 예제는 <a target="_blank" href="https://github.com/ITAMNETWORK/eosjs-api-example">github</a>에서 확인 하실 수 있습니다.</p>
</blockquote>
<p>해당 게시글은 저의 블로그 혹은 itamnetwork 블로그에서 동일하게 확인 하실수 있습니다.</p>
]]></content:encoded></item></channel></rss>