<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발과 일상</title>
    <link>https://akku-dev.tistory.com/</link>
    <description>개발자의 일기장</description>
    <language>ko</language>
    <pubDate>Tue, 28 Jul 2026 13:08:18 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>애쿠</managingEditor>
    <item>
      <title>RAG 검색 개선기: 크로스 인코더 리랭커를 사용해보자</title>
      <link>https://akku-dev.tistory.com/321</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yz9AH/dJMcahyuoTT/13tQlXUcUBNkRRlh6M9h4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yz9AH/dJMcahyuoTT/13tQlXUcUBNkRRlh6M9h4k/img.png&quot; data-alt=&quot;썸네일이 썩 맘에 들지 않는다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yz9AH/dJMcahyuoTT/13tQlXUcUBNkRRlh6M9h4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fyz9AH%2FdJMcahyuoTT%2F13tQlXUcUBNkRRlh6M9h4k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;썸네일이 썩 맘에 들지 않는다&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://akku-dev.tistory.com/316&quot;&gt;질의처리기 글&lt;/a&gt;에서 질의를 슬롯과 키워드로 나눴고, &lt;a href=&quot;https://akku-dev.tistory.com/317&quot;&gt;하이브리드 검색 글&lt;/a&gt;에서 키워드(BM25)와 벡터를 합쳐 1차 검색을 완성하는 데까지 왔다. 그런데 그 1차 결과를 들여다보다 자꾸 같은 장면에 걸렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;정&lt;/b&gt;&lt;b&gt;답 문서가 분명히 후보 안에는 있는데, 1등이 아니다.&lt;/b&gt; 넓게 건지는 건 잘 되는데 맨 위에 정답을 올려놓는 건 약했다. 그 마지막 한 끗을 손보는 게 이번 글의 주제, 리랭커(re-ranker)다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;a href=&quot;https://akku-dev.tistory.com/304&quot;&gt;RAG 구축 고려사항&lt;/a&gt; 중 여섯 번째, 리랭커 매듭이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 왜 리랭커인가 &amp;mdash; 넓게 건지기와 1등 맞히기는 다른 일&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차 검색의 일은 &quot;넓게, 빠르게 건지기&quot;다. 후보 안에 정답이 들어 있게만 하면 절반은 성공이다(이걸 recall이라 부른다). 그런데 그 후보들 중 무엇이 &lt;i&gt;진짜 1등&lt;/i&gt;인지 가리는 정밀함은 또 다른 능력이다. 1차 검색은 전자엔 강하고 후자엔 약했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 뒤 단계가 순위에 민감하다는 점이다. 검색 결과를 생성 모델(LLM)에 넘길 땐 보통 상위 몇 개만 넘긴다. 그러면 2~10위에 묻힌 정답은 후보 안에 있어도 사실상 버려진다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;질의:  &quot;지난달 회의 자료&quot;
1차 검색 결과(상위 10):
  1. 다른달 회의록
  2. 회의 안건 메모
  3. 작년 워크숍 자료
  4. ★ 지난달 회의 자료   &amp;larr; 정답인데 4위
  ...
생성엔 top 3만 넘긴다면 &amp;rarr; 정답이 안 들어간다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답이 후보엔 있는데 순위가 안 맞는 것. 이걸 푸는 게 리랭커다. 1차가 건져온 소수의 후보를 &lt;b&gt;다시 한 번 정밀하게 채점해 순서를 바꾸는&lt;/b&gt; 2차 단계. 위 예에서 4위를 1위로 끌어올리면 정답이 살아난다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 크로스인코더가 뭔가 &amp;mdash; 바이인코더와 뭐가 다른가&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리랭커를 만드는 길은 크게 둘이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나는 &lt;b&gt;LLM한테 직접 맡기는 것&lt;/b&gt; &amp;mdash; &quot;이 문서들을 질의에 맞게 순위 매겨줘&quot;라고 프롬프트로 시키는 방식이다. 솔직히 이쪽이 더 세다. 순위 정확도 자체가 크로스인코더보다 나을 수 있고, 단순히 순서만 뱉는 게 아니라&lt;b&gt; &quot;왜 이게 1등인지&quot;, &quot;이 문서가 질의에 어떻게 답이 되는지&quot; 같은 부가적인 판단까지 함께 받을 수 있다.&lt;/b&gt; 검색 결과를 그냥 정렬하는 걸 넘어, 결과에 대한 설명이나 다른 형태의 응답으로 확장할 여지가 열린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 딱 하나, &lt;b&gt;느리다&lt;/b&gt;는 것이다. 그런데 이 하나가 치명적이었다. 매 검색마다 LLM을 한 번씩 부르는 셈이라, 밀리초로 끝나야 할 검색에 초 단위가 얹힌다. 서버에 큰 GPU가 있으면 감수해볼 만하지만, 온디바이스에선 감당이 안 됐다.&amp;nbsp; 그래서 성능을 조금 내주더라도, 순위 매기기만 전문으로 하는 작고 빠른 모델 &amp;mdash; &lt;b&gt;크로스인코더(cross-encoder)&lt;/b&gt; &amp;mdash; 를 &lt;i&gt;어쩔 수 없이&lt;/i&gt; 골랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 이해하려면 지금까지의 벡터 검색이 쓰던 &lt;b&gt;바이인코더(bi-encoder)&lt;/b&gt;와 비교하는 게 빠르다. 바이인코더는 질의는 질의대로, 문서는 문서대로 &lt;i&gt;따로&lt;/i&gt; 임베딩해서 각각 벡터로 만든 뒤, 둘의 코사인 유사도로 가까움을 잰다. SBERT 문서 표현으로는 &quot;질의와 문단의 임베딩을 각각 독립적으로 만든다&quot;(&lt;a href=&quot;https://www.sbert.net/examples/sentence_transformer/applications/retrieve_rerank/README.html&quot;&gt;SBERT Retrieve &amp;amp; Re-Rank&lt;/a&gt;). 핵심은 문서 임베딩을 &lt;i&gt;미리 계산해 인덱싱&lt;/i&gt;해둘 수 있다는 것 &amp;mdash; 그래서 빠르다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;바이인코더:  질의 &amp;rarr; [벡터]
             문서 &amp;rarr; [벡터]   (미리 계산해 둠)
             두 벡터의 거리로 유사도

크로스인코더: (질의 + 문서) &amp;rarr; 트랜스포머 한 번에 &amp;rarr; 관련도 점수 1개&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유하자면 바이인코더는 두 사람한테 각자 자기소개서를 받아 얼마나 겹치는지 비교하는 것이다. 빠르지만, 둘을 직접 만나게 한 적은 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크로스인코더는 다르다. 질의와 문서를 &lt;b&gt;한 번에 같이&lt;/b&gt; 트랜스포머에 넣어, 두 텍스트의 토큰이 서로 어텐션을 주고받게 한 뒤 &quot;이 문서가 이 질의에 얼마나 맞나&quot;를 점수 하나로 뱉는다. SBERT 표현으로 &quot;질의와 후보 문서를 동시에 트랜스포머에 통과시켜, 관련도를 나타내는 0~1 사이 점수 하나를 출력한다.&quot; bge 리랭커 모델 카드도 같은 말을 한다 &amp;mdash; &quot;입력 쌍에 대해 full-attention을 수행하므로 임베딩 모델(바이인코더)보다 정확하지만 더 느리다&quot;(&lt;a href=&quot;https://huggingface.co/BAAI/bge-reranker-large&quot;&gt;BAAI/bge-reranker&lt;/a&gt;).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유를 이으면, 크로스인코더는 둘을 한 방에 앉혀놓고 실제로 대화를 시켜 보는 것이다. 훨씬 정확하다. 대신 그 만남을 &lt;i&gt;매번 새로 주선&lt;/i&gt;해야 한다. 미리 계산해둘 수가 없다 &amp;mdash; 질의가 들어와야 비로소 (질의, 문서)를 같이 통과시키니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 크로스인코더는 전체 문서를 다 채점하는 데는 못 쓴다. 1차에서 바이인코더(또는 BM25)가 추려준 &lt;b&gt;top-k 후보 소수만&lt;/b&gt; 다시 정밀 채점하는 2차 재정렬에 쓴다. SBERT가 정리한 그림 그대로다 &amp;mdash; 1차에서 100개쯤 빠르게 회수하고, 2차에서 크로스인코더가 그 후보들의 관련도를 다시 매긴다. &lt;b&gt;1차는 넓게 회수, 2차는 좁게 정밀&lt;/b&gt;, 이 분업이 핵심이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1635&quot; data-origin-height=&quot;885&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QksXC/dJMcadW8Zqw/TuftFXGHH4kg6eFTKST7b0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QksXC/dJMcadW8Zqw/TuftFXGHH4kg6eFTKST7b0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QksXC/dJMcadW8Zqw/TuftFXGHH4kg6eFTKST7b0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQksXC%2FdJMcadW8Zqw%2FTuftFXGHH4kg6eFTKST7b0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1635&quot; height=&quot;885&quot; data-origin-width=&quot;1635&quot; data-origin-height=&quot;885&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 얼마나 오르나 &amp;mdash; 일반적 기대값과 내 경우&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문헌과 벤더 벤치마크는 방향이 한결같다. 리랭커는 임베딩 검색보다 &lt;i&gt;더 정확하다.&lt;/i&gt; Pinecone은 &quot;리랭커가 임베딩 모델보다 훨씬 정확하다&quot;고 못 박는데, 이유가 와닿는다 &amp;mdash; 바이인코더는 문서의 온갖 의미를 벡터 하나에 압축해 욱여넣지만, 크로스인코더는 원문을 그대로 트랜스포머에 넣어 정보 손실이 적다(&lt;a href=&quot;https://www.pinecone.io/learn/series/rag/rerankers/&quot;&gt;Pinecone Rerankers&lt;/a&gt;).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 &quot;몇 점 오른다&quot;는 숫자는 조심해야 한다. 같은 리랭커라도 비교 기준(베이스라인)이 &quot;리랭커 없음&quot;이냐 &quot;다른 리랭커&quot;냐에 따라 향상폭이 천차만별이라, 특정 퍼센트를 일반값처럼 박는 건 과장이 되기 쉽다. 그래서 여기선 단정 수치 대신 방향만 &amp;mdash; &quot;유의미하게 오른다&quot;까지만 일반화로 두는 게 정직하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 내 경우를 가볍게 적어둔다. 1차 검색이 약했던 구간 &amp;mdash; 특히 오타가 섞이거나 표현이 모호한 질의 &amp;mdash; 에서 top1 정답률이 대략 &lt;b&gt;10%포인트 안팎&lt;/b&gt; 올랐다. 반대로 1차가 이미 잘 맞히던 강한 구간에선 거의 움직이지 않았다. 이 &quot;약한 데서 효과, 강한 데선 미미&quot;라는 패턴이 &amp;mdash; 미리 말하자면 &amp;mdash; 이 글 마지막의 결론으로 그대로 이어진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 공짜가 아니다 &amp;mdash; 온디바이스에서 더 따져야 하는 것&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지면 &quot;그럼 항상 켜면 되잖아&quot;인데, 그렇지 않았다. 특히 온디바이스에선.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;메모리.&lt;/b&gt; 서버라면 리랭커 모델 하나쯤 더 띄우는 게 큰일이 아니다. 그런데 온디바이스에선 1차 검색기&amp;middot;임베딩 모델&amp;middot;앱이 같은 RAM을 나눠 쓴다. 리랭커 모델을 통째로 올리는 것 자체가 다른 몫을 깎아먹는다. 그래서 모델을 양자화로 작게 줄여 겨우 예산에 맞추는 식의 타협이 처음부터 깔린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;지연.&lt;/b&gt; 크로스인코더는 후보 쌍마다 매번 트랜스포머 추론을 돌린다. 이 비용을 GPU 서버로 떠넘길 수 없으니 기기가 그대로 떠안는다. 1차 검색이 밀리초로 끝나는데 리랭커가 거기에 초 단위를 얹으면 체감이 확 나빠진다. 이게 왜 전수에 못 쓰는지를 극적으로 보여주는 예가 있다 &amp;mdash; Pinecone는 &quot;4천만 건을 작은 리랭킹 모델로 V100 GPU에서 전부 돌리면 쿼리 하나에 50시간 넘게 기다려야 한다&quot;고 적는다. 서버 GPU로도 그런데 기기에선 말할 것도 없다. 그래서 재정렬 대상 top-k를 작게 잡는 건 선택이 아니라 필수다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 설계가 죄다 '아껴 쓰는' 쪽으로 갔다. 재정렬은 소수의 후보로만 제한하고, 항상 켜지 않고 1차가 약한 구간에만 선택적으로 켜고, 순위를 바꿀 때도 확실할 때만 바꾼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 질문 자체가 바뀐다. 서버에선 &quot;정확도가 좀 오르면 켤까?&quot;였다면, 온디바이스에선 &lt;b&gt;&quot;이 메모리와 이 지연을 내주고도 그만큼 값어치가 있나?&quot;&lt;/b&gt;가 된다. 리랭커를 쓸지 말지가 정확도만의 문제가 아니라, 한정된 자원 예산을 어디에 쓸지의 문제가 되는 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 개악을 막는 안전장치 &amp;mdash; 확실할 때만 재정렬&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리랭커를 켜고 처음 본 건, 좋아지기만 하는 게 아니라는 거였다. &lt;b&gt;멀쩡하게 1등이던 정답을 리랭커가 끌어내리는 &quot;개악&quot;도 같이 생겼다.&lt;/b&gt; 평균 점수는 올라도 어떤 질의는 오히려 더 나빠지는 것. 그래서 둔 게 일종의 문턱이었다. 리랭커가 매긴 점수 차가 충분히 확실할 때만 재정렬을 반영하고, &lt;b&gt;애매하면 1차 결과의 순서를 그대로 존중&lt;/b&gt;한다. 어설프게 끼어들어 평균을 갉아먹느니, 확신이 있을 때만 손대는 쪽을 택한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 &quot;확실하지 않으면 손대지 않는다&quot;는 태도는 &lt;a href=&quot;https://akku-dev.tistory.com/316&quot;&gt;질의처리기 글&lt;/a&gt;에서 &quot;확신 없으면 하드 필터로 안 걸고 검색어로 남긴다&quot;던 그 보수성과 정확히 같다. 자동화가 전체 평균을 올리되 누군가를 망치지 않게 하는, 같은 철학의 다른 적용이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리랭커는 검색에 무조건 꽂으면 좋아지는 만능 부스터가 아니었다. 1차 검색이 약한 자리를 메우는 보정 장치에 가까웠다. 1차(키워드+벡터)가 이미 강한 구간에선 지연과 메모리를 내준 만큼의 이득이 안 났고, 약한 구간에선 확실히 제 값을 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 리랭커의 진짜 질문은 &quot;성능이 오르나?&quot;가 아니라 &lt;b&gt;&quot;어디에, 어떤 비용으로 쓰이나?&quot;&lt;/b&gt;였다. 특히 온디바이스처럼 자원이 빠듯한 자리에선 더 그랬다. 화려한 한 방이 아니라 쓸 곳을 고르는 판단 &amp;mdash; 돌아보면 이 시리즈 내내 반복된 결론이, 리랭커에서도 똑같이 나왔다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RAG 개선기의 마지막 글이다. 다음 글에는 총 정리를 해보면서 느낀점들을 정리해 볼 것 같다.&lt;/p&gt;</description>
      <category>개발/AI</category>
      <category>bi-encoder</category>
      <category>Cross-Encoder</category>
      <category>NDCG</category>
      <category>rag</category>
      <category>reranker</category>
      <category>검색 정확도</category>
      <category>리랭커</category>
      <category>온디바이스 AI</category>
      <category>크로스인코더</category>
      <author>애쿠</author>
      <guid isPermaLink="true">https://akku-dev.tistory.com/321</guid>
      <comments>https://akku-dev.tistory.com/321#entry321comment</comments>
      <pubDate>Thu, 23 Jul 2026 16:09:15 +0900</pubDate>
    </item>
    <item>
      <title>Hermes-Agent 설치 및 사용 후기</title>
      <link>https://akku-dev.tistory.com/320</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;923&quot; data-origin-height=&quot;564&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfD2Xb/dJMcahrELaD/tqYrdmeK3A7x60isvil690/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfD2Xb/dJMcahrELaD/tqYrdmeK3A7x60isvil690/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfD2Xb/dJMcahrELaD/tqYrdmeK3A7x60isvil690/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfD2Xb%2FdJMcahrELaD%2FtqYrdmeK3A7x60isvil690%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;923&quot; height=&quot;564&quot; data-origin-width=&quot;923&quot; data-origin-height=&quot;564&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;헤르메스(Hermes) 에이전트를 요즘 하도 좋다좋다 하길래 설치해봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평소 Claude Code를 잘 쓰고 있으면서도,&lt;b&gt; &quot;무료 모델로 도는 자율 에이전트&quot;&lt;/b&gt;라는 말에 궁금해서 손이 갔다. .&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 적어두면, 뭔가 미묘하게 나쁜 도구는 아닌 것 같은데 나한테는 안 맞았다.(무료 오픈라우터 에이전트를 쓴 이유도 있을 것 같다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확히는 &quot;안 좋다&quot;가 아니라, 헤르메스의 동작 방식이 현재 작업 중인 환경에서는 큰 장점이 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 그렇게 느꼈는지 설치부터 순서대로 적어둔다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 왜 깔았나&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유랄 게 딱히 거창하진 않다. 여기저기서 좋다는 얘기가 자꾸 들렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자율 에이전트인데 무료 모델로도 돌릴 수 있다길래, 가벼운 마음으로 설치했다. 이미 Claude Code를 매일 쓰고 있어서, 아쉬운 게 있어 갈아탄 것도 아니다. 순수하게 궁금했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무료로 도는 자율 에이전트가 얼마나 하나, 내가 쓰는 것과 뭐가 다르나. 소문의 실체를 내 손으로 확인해보고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 미리 밝혀두면, 나는 이쪽 도구를 폭넓게 써본 사람이 아니다. 에이전트라고는 Claude Code 하나만 붙잡고 살았다. 이 사실이 뒤에서 후기의 발목을 잡는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 설치, 선택지에서 갈린 것들&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치는 마법사가 네 가지를 차례로 물어본다. 기본값을 그냥 Enter로 넘겨도 되지만, 각 선택지가 뭘 뜻하는지 알고 고르는 게 낫다. 하나씩, 무슨 옵션이고 내가 뭘 골랐는지 적어둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설치&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1784010870178&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;iex (irm https://hermes-agent.nousresearch.com/install.ps1)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(1) 모델 고르기.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 에이전트를 굴릴 기본 모델을 정한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Select default model:

(●)  1. tencent/hy3:free              &amp;larr; 무료. 기본 선택이라 이걸로 Enter
(○)  2. stepfun/step-3.7-flash:free   &amp;larr; 또 다른 무료 모델
(○)  3. Enter custom model name       &amp;larr; 유료 포함 아무 모델명 직접 입력
(○)  4. Skip (keep current)           &amp;larr; 안 고르고 현재값 유지 (비면 크레딧 소모 위험)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1&amp;middot;2번 무료 모델은 &lt;b&gt;OpenRouter가 한시로 푼 것&lt;/b&gt;들이다(1번 hy3 무료판엔 만료일이 붙어 있는데, 이 얘긴 뒤에서). 좋은 유료 모델을 쓰고 싶으면 3번으로 직접 입력하면 된다. 나는 기본값 1번으로 Enter했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정 파일엔 &lt;code&gt;provider=nous&lt;/code&gt;로 잡혔는데, 무료 모델을 Nous가 대주는 구조라 실질은 같은 공짜다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(2) 도구 풀 고르기.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트가 쓸 외부 도구를 켜고 끈다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;Your free Nous tool pool &amp;mdash; pick the tools to enable:

[✓]  1. Web search &amp;amp; extract (Firecrawl)    &amp;larr; 웹 검색&amp;middot;본문 추출
[✓]  2. Image generation (FAL)              &amp;larr; 이미지 생성
[✓]  3. Text-to-speech (OpenAI TTS)         &amp;larr; 텍스트를 음성으로
[✓]  4. Speech-to-text (OpenAI Whisper)     &amp;larr; 음성을 텍스트로(받아쓰기)
[✓]  5. Browser automation (Browser Use)    &amp;larr; 브라우저 자동 조작&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 도구 뒤에 실제로 일하는 외부 서비스가 붙어 있다(웹 검색은 Firecrawl, 이미지는 FAL 하는 식).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다섯 개가 다 켜진 채 시작하는데, 다 켜둬도 실제 부를 때만 소모되니 당장 돈이 새진 않는다. 다만 블로그&amp;middot;코딩엔 웹 검색&amp;middot;브라우저(1&amp;middot;5)만 쓸 거라 이미지&amp;middot;음성 세 개(2&amp;middot;3&amp;middot;4)는 껐다. 실수로 이미지 생성 같은 데 크레딧 새는 것도 막을 겸.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(3) 터미널 백엔드 고르기.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트가 쉘 명령과 코드를 어디서 실행할지 정한다. 여기가 진짜 갈림길이다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;◆ Terminal Backend  (에이전트가 쉘&amp;middot;코드를 어디서 실행할지)

(○)  1. Local - run directly on this machine   &amp;larr; 내 PC에서 직접 (편하지만 파일 직접 만짐)
(○)  2. Docker - isolated container            &amp;larr; 격리 컨테이너 (안전, 설치&amp;middot;마운트 붙음)
(○)  3. Modal - serverless cloud sandbox       &amp;larr; 클라우드 샌드박스
(○)  4. SSH - run on a remote machine          &amp;larr; 원격 머신에서
(○)  5. Daytona - cloud dev environment        &amp;larr; 상주형 클라우드 개발환경
(●)  6. Keep current (local)                   &amp;larr; 현재값(로컬) 유지&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라우드&amp;middot;원격 계열(Modal&amp;middot;SSH&amp;middot;Daytona)은 내 로컬 파일을 다루려면 따로 연결해줘야 해서 첫 맛보기엔 번거롭다. 결국 Local이냐 Docker냐의 싸움이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 Local을 골랐다. 이게 무슨 뜻이냐면, 이 자율 에이전트가 내 PC에서 직접 쉘 명령을 실행한다는 얘기다. 파일을 만들고 지우고, 명령을 돌린다. 그것도 무료 모델이 알아서 판단해서. 상상해보면 좀 아찔하다. 똑똑하지도 않은 공짜 모델이 내 폴더에서 &lt;code&gt;rm&lt;/code&gt; 비슷한 걸 지 맘대로 판단해 실행할 수도 있다는 거니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 겁이 좀 나서 blog repo에만 붙여 시켰다. git으로 관리되니 뭔가 잘못돼도 되돌릴 수 있다는 안전판이 있어서다. 회사 폴더나 민감한 데 근처에선 아예 안 돌렸다. 보안이 정말 걱정되면 Docker 격리로 가는 게 맞지만, 그건 설치&amp;middot;마운트가 따라붙어서 &quot;가볍게 맛보기&quot;엔 과했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(4) 메신저 연결.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텔레그램&amp;middot;디스코드 같은 메신저를 붙여 원격으로 부릴지 묻는다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;Connect a messaging platform? (Telegram, Discord, etc.)

(●)  1. Set up messaging now (recommended)   &amp;larr; 지금 메신저 연동 (원격 조종 창구)
(○)  2. Skip, set up later                   &amp;larr; 건너뛰고 나중에 'hermes setup gateway'로&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;recommended&quot;가 떡하니 붙어 있었지만 2번으로 건너뛰었다. PC 앞에서 가볍게 볼 거라 필요 없다고 봤고, 봇 만들고 토큰 넣는 세팅만 늘어날 것 같았다. 그런데 나중에 보니, 이 &quot;추천&quot;을 무시하고 skip한 게 이 후기의 결론을 통째로 바꿔놓았다. (뒤에서 다시 나온다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 내가 고른 건 이렇다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;설치 마법사에서 고른 것
├ 모델       tencent/hy3:free   (OpenRouter 무료, 7/21 만료)
├ 도구       웹검색&amp;middot;브라우저만, 이미지&amp;middot;TTS&amp;middot;STT는 끔
├ 실행 위치  로컬 (내 PC에서 직접 쉘 실행)
└ 메신저     연결 안 함 (skip)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 써보니, 되긴 되는데&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋업을 마치고 실제로 이것저것 시켜봤다. 솔직히 첫인상은 미지근했다. 걸린 게 네 가지였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째, 비교할 잣대가 없었다.&lt;/b&gt; 현재 사용하는 에이전트 도구가 Claude Code 하나만 남아 있다(이것 저것 써보다가 가장 잘 맞는 하나만 사용 중). 그래서 Hermes가 &quot;좋다&quot;는 걸 느끼려 해도, 상황에 맞게 사용할 에이전트가 코드 에이전트가 딱히 없어서 뭐랑 비교해서 좋은 건지 감이 안 잡혔다. 좋다는 소문만 듣고 왔지, 정작 좋음을 판별할 도구가 없었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째, 실시간으로 붙어 일 시키는 건 그냥 Claude가 나았다.&lt;/b&gt; 터미널에 앉아 &quot;이거 고쳐, 저거 확인해&quot; 하는 식으로 써봤는데, 이 대결에선 손에 익은 쪽이 더 매끄러웠다. 반응도, 결과물도. 굳이 낯선 걸로 갈아탈 이유를 못 찾았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;셋째, 무료 모델이 한국어를 잘 못했다.&lt;/b&gt; 내 작업은 한국어로 진행된다. 블로그 글을 다듬거나, 한국어로 지시하거나. 그런데 기본으로 깔린 무료 모델이 한국어에서 영 힘을 못 썼다. 아무리 도구(손발)가 좋아도 정작 머리가 한국어를 버벅이면 결과가 안 나온다(나오더라도 무슨 말인지 잘 이해가 안됨). 영어권 데모에선 근사해 보였을 그림이, 한국어 앞에선 급격히 흐려졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;넷째, UI가 깜빡였다.&lt;/b&gt; 화면이 자꾸 깜빡거리는 이슈가 있었다. 사소해 보이는데, 오래 들여다보고 있으면 이게 은근히 사람 신경을 긁는다. 작업 자체보다 깜빡임에 눈이 가는 순간이 늘었다. ㅠㅠ&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 어? Claude Code랑 뭐가 다르지&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무료 모델이 약한 걸 걷어내고 봐도, 하는 일이 Claude Code랑 크게 다르지 않게 느껴졌다. &quot;좋다길래 깔았는데 왜 똑같지?&quot; 하는 의문이 계속 남았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파보니 이유가 있었다. 내가 사용법을 착각하고 있었다. 이 바닥 도구는 대충 두 끝 사이의 스펙트럼에 놓인다. 한쪽 끝은 Claude Code나 opencode, aider 같은 &quot;실시간 터미널 짝꿍&quot;이다. 사람이 옆에 붙어 대화하며 코딩하는 도구. 다른 쪽 끝은 OpenClaw 같은 &quot;원격 상주 비서&quot;다. 텔레그램으로 문자 하나 던지면 알아서 일하고, 예약해두면 밤새 혼자 도는 도구.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1275&quot; data-origin-height=&quot;494&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTEteE/dJMcafUTKLA/mphaKLjaHcx8VC3Rv36uE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTEteE/dJMcafUTKLA/mphaKLjaHcx8VC3Rv36uE1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTEteE/dJMcafUTKLA/mphaKLjaHcx8VC3Rv36uE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTEteE%2FdJMcafUTKLA%2FmphaKLjaHcx8VC3Rv36uE1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1275&quot; height=&quot;494&quot; data-origin-width=&quot;1275&quot; data-origin-height=&quot;494&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hermes는 이 스펙트럼에서 Claude Code 쪽에 가깝긴 한데, 옵션을 켜면 원격&amp;middot;자율 쪽으로 쭉 뻗을 수 있는 물건이다. 즉 걔의 진짜 무기는 &quot;원격에 던져두고 알아서 시키기&quot;인데, 문제는 내가 그걸 정확히 Claude Code처럼, 실시간 터미널 짝꿍으로만 굴렸다는 거다. 게다가 원격 창구(메신저)는 설치 때 꺼버렸고.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유하면, 오프로드 트럭을 사놓고 시내 주차 실력만 시험한 꼴이다. Hermes를 판단하려면 진창길로 데려갔어야 했는데, 적당한 비교 실험을 하지 못한 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 장점표는 대부분 Claude Code도 된다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;궁금해서 Hermes가 홈페이지에 내세운 장점들을 하나하나 훑어봤다. 지속 메모리, 스킬 자동 생성, 예약 실행, 병렬 서브에이전트, 브라우저 제어. 읽으면서 계속 &quot;어, 이거 내가 쓰는 것도 되는데?&quot; 소리가 나왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1106&quot; data-origin-height=&quot;765&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bf5cy3/dJMcacjtEhJ/Kwob5aXg3FdkuN9YoS5vh1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bf5cy3/dJMcacjtEhJ/Kwob5aXg3FdkuN9YoS5vh1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bf5cy3/dJMcacjtEhJ/Kwob5aXg3FdkuN9YoS5vh1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbf5cy3%2FdJMcacjtEhJ%2FKwob5aXg3FdkuN9YoS5vh1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1106&quot; height=&quot;765&quot; data-origin-width=&quot;1106&quot; data-origin-height=&quot;765&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 그랬다. 자랑으로 적힌 것 대부분이 요즘 에이전트면 다 하는 공통 기능이었다. 지속 메모리도, cron 예약도, 병렬 서브에이전트도, 웹 검색도 Claude Code가 다 한다. 어떤 건 오히려 더 성숙하게.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니 기능표만 나란히 놓으면 두 도구는 팔 할이 겹친다. 내가 차이를 못 느낀 게 착각이 아니라, 실제로 안 다른 부분을 보고 있었던 거다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 Hermes만의 것은 딱 세 개였고, 그건 &quot;기능&quot;이 아니라 &quot;구조&quot;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 모델을 아무거나 꽂는다.&lt;/b&gt; OpenRouter로 수백 개 모델, 심지어 내 컴퓨터에서 도는 로컬 모델까지 갖다 붙인다. 작업마다 다른 모델을 골라 태울 수도 있다. Claude Code는 Claude 전용이라 이 자유가 없다. 셋 중 제일 큰 차이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 메신저로 원격 조종한다.&lt;/b&gt; 텔레그램&amp;middot;디스코드&amp;middot;슬랙&amp;middot;WhatsApp&amp;middot;시그널로 밖에서 부린다. &quot;폰으로 문자 보내면 집에 있는 에이전트가 일한다&quot;는 결인데, Claude Code엔 아예 없는 축이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 오픈소스에 완전 로컬이고, 데이터를 안 보낸다.&lt;/b&gt; 내 기기에만 메모리가 쌓이고 텔레메트리가 없다(MIT 라이선스). Claude Code는 비공개 소프트웨어에 Anthropic 클라우드를 쓴다. 이건 &quot;능력&quot;이 아니라 &quot;누가 내 데이터와 모델을 쥐느냐&quot;의 문제다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 그럼 언제 써야 하나&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 Hermes는 &quot;뇌&quot;가 없는 몸통이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판단하고 도구를 부리는 손발은 갖췄는데, 정작 무슨 생각을 하느냐는 바깥 모델에 맡긴다. 그 모델을 대주는 흔한 창구가 OpenRouter다. 한 곳에 키 하나 꽂으면 수백 개 모델을 골라 쓰는 중개소. 그러니까 Hermes가 몸이면, OpenRouter는 아무 뇌나 꽂을 수 있는 콘센트인 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 알고 나면 걔를 언제 써야 하는지도 보인다. 두 가지가 갖춰져야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째, 오픈 라우터에 쓸 만한 모델을 꽂아야 한다.&lt;/b&gt; 내가 시원찮았던 건 하필 약한 무료 모델을 꽂아서다. 손발이 멀쩡해도 무료 뇌가 한국어를 못하면 그림이 안 나온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그런데 여기 함정이 하나 더 있다.&lt;/b&gt; 내가 고른 &lt;code&gt;tencent/hy3:free&lt;/code&gt;는 목록을 열어보니 만료일이 붙어 있었다. 7월 21일까지만 무료인 한시 프로모션이었던 거다. 내 설치일 기준 카운트다운이 아니라, 그냥 그 날짜에 무료판이 내려가는 고정 일정. 이런 무료 모델은 원래 홍보용으로 잠깐 열렸다 닫히기를 반복하고, 호출 횟수 제한도 걸려 있어 자율 에이전트처럼 도구를 연달아 부르는 용도엔 중간에 막히기도 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 유료 모델을 꽂으면 물론 체감이 확 달라지겠지만, 그럴 거면 &quot;그냥 익숙한 Claude 쓰지&quot;라는 생각이 드는 게 딜레마다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째, 터미널 짝꿍이 아니라 비대면&amp;middot;비동기로 굴려야 한다.&lt;/b&gt; 메신저로 던져두거나, 예약으로 혼자 돌리거나. 걔가 Claude Code보다 나은 자리는 &quot;옆에 앉아 짝코딩&quot;이 아니라 &quot;밖에서 문자로 부리는 상주 심부름꾼&quot;이다. 야간 백업, 주간 리포트, 자리를 비운 사이 도는 잡무. 그런데 나는 그 자리를 아예 안 써봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치 때 메신저를 끈 순간, 정작 이 도구의 존재 이유를 스스로 잘라내고 시작한 셈이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. 마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌아보면 내 후기는 &quot;Hermes가 나쁘다&quot;가 아니라 &quot;나한테는 필요가 없다&quot;에 가깝다. 실시간 터미널에서 비교를 하면 Claude Code가 이기는 게 당연했다.(내 로컬엔 로컬 LLM이 없다)&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 도구는 &quot;내 모델과 데이터와 채널을 내가 쥐고 싶다&quot;는 통제권이 중요한 사람일 거다. 로컬 모델로 데이터를 밖에 안 내보내거나, 작업마다 모델을 갈아 끼워 비용을 아끼거나, 밖에서 메신저로 에이전트를 부리고 싶은 사람. 그런 니즈가 뚜렷하다면, 오픈소스에 완전 로컬인 이 물건이 제법 답이 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나한테는 그 니즈가 아직 없었다. 그래서 지금은 단순하고 똑똑한 Claude Code로 돌아왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하네스나 에이전트나 중요한 건 내 작업에 어울리는 툴을 골라야 하는 것 같다..&lt;/p&gt;</description>
      <category>개발/AI</category>
      <category>ai 에이전트</category>
      <category>claude code</category>
      <category>hermes</category>
      <category>hermes agent</category>
      <category>Nous Research</category>
      <category>OpenClaw</category>
      <category>OpenRouter</category>
      <category>로컬 llm</category>
      <category>오픈소스 ai</category>
      <category>자율 에이전트</category>
      <author>애쿠</author>
      <guid isPermaLink="true">https://akku-dev.tistory.com/320</guid>
      <comments>https://akku-dev.tistory.com/320#entry320comment</comments>
      <pubDate>Tue, 14 Jul 2026 15:33:56 +0900</pubDate>
    </item>
    <item>
      <title>RAG 검색 개선기: 한국어 검색엔 한국어 임베딩 모델</title>
      <link>https://akku-dev.tistory.com/319</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bN4ftk/dJMcaic2hCW/Vs99KC33I5OlKO9LpXO7A0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bN4ftk/dJMcaic2hCW/Vs99KC33I5OlKO9LpXO7A0/img.png&quot; data-alt=&quot;썸네일이 좀 맘에 안들긴한데..&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bN4ftk/dJMcaic2hCW/Vs99KC33I5OlKO9LpXO7A0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbN4ftk%2FdJMcaic2hCW%2FVs99KC33I5OlKO9LpXO7A0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;썸네일이 좀 맘에 안들긴한데..&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한동안 검색을 들여다보다 구멍을 하나 발견했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국어 검색인데, &lt;b&gt;외래어가 섞인 질의가 자꾸 헛돌았다.&lt;/b&gt; 예를 들어 &quot;스마트 팩토리&quot;로 찾으면, 정작 스마트 팩토리를 다루는 문서가 위로 안 올라오고 엉뚱한 게 섞였다. &quot;작년 회의 자료&quot; 같은 평범한 한국어 질의는 멀쩡한데, 이렇게 영어에서 온 말이 한글로 적힌 구간에서만 새는 현상이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 원인을 찾는건 그렇게 어렵지 않았는데, 검색의 초기 버전이 임베딩 모델에 전적으로 의존하고 있었기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때 쓰던 건 &lt;b&gt;범용 다국어 임베딩&lt;/b&gt;이었다. 여러 언어를 두루 하는 모델. 검색 성능을 조금 더 올리고 싶던 참이라, 이번엔 임베딩 모델 자체를 손대보기로 했다. &lt;b&gt;한국어에 특화된 임베딩&lt;/b&gt;으로.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;a href=&quot;https://akku-dev.tistory.com/304&quot;&gt;RAG 구축 고려사항&lt;/a&gt;의 임베딩 모델 항목에 해당하는, 성능을 한 끗 올린 이야기다. (임베딩이 뭔지는 &lt;a href=&quot;https://akku-dev.tistory.com/294&quot;&gt;지난 글&lt;/a&gt;에서 다뤘다. 임베딩 차원&amp;middot;데이터셋 같은 얘기는 임베딩을 본격적으로 다룰 다음 글로 미룬다.)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 왜 외래어에서 샜나 &amp;mdash; 그리고 왜 처음엔 티가 안 났나&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 왜 외래어가 약했는지부터. 범용 다국어 임베딩은 &quot;smart factory&quot;(영어)도 알고 &quot;스마트 팩토리&quot;(한글 표기)도 대충 안다. 문제는 그 한글 표기 외래어를 원래 개념에 &lt;i&gt;딱&lt;/i&gt; 붙이는 힘이 무뎠다는 것이다. 조금 더 파고들면 이렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 모델은 정해진 표현력(파라미터)을 수십 개 언어에 나눠 담는다. 그러다 보니 한글로 적힌 외래어는 원어인 &quot;smart factory&quot;와도, 순우리말로 된 한국어 문맥과도 어중간하게 떨어진 자리에 놓인다. 어느 쪽에도 확실히 소속되지 못한 애매한 위치인 셈이다. 그래서 &quot;스마트 팩토리&quot; 질의가 살짝 엉뚱한 쪽으로 흘렀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 한국어 특화 모델로 바꾸면 되겠네 싶어, 두 모델을 붙여봤다 &amp;mdash; 범용 쪽은 작고 빠른 다국어 임베딩, 특화 쪽은 한국어에 튜닝된 &lt;code&gt;bge-m3-ko-q8&lt;/code&gt;이다. 다국어 임베딩으로 널리 쓰이는 bge-m3를 한국어에 맞춰 손보고, 온디바이스로도 돌릴 수 있게 8비트로 양자화한(q8) 판이라 골랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 첫 결과가 김이 빠졌다. &lt;b&gt;거의 똑같이 나왔다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;쉬운 평가셋 (100문항)      MRR
범용 임베딩               0.876
한국어 특화               0.874&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0.876 대 0.874. 오차 수준이다. &quot;어, 모델 차이가 없나?&quot; 싶었는데, 파보니 문제는 모델이 아니라 &lt;b&gt;평가셋&lt;/b&gt;이었다. 그 세트가 너무 쉬웠던 것이다. 질의의 단어가 문서에 그대로 들어 있는 경우(exact match)가 많아서, 어떤 임베딩을 쓰든 다 맞혔다. 정답 단어가 문서에 그냥 박혀 있으면, 임베딩의 미세한 품질 차이가 드러날 틈이 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 &lt;a href=&quot;https://akku-dev.tistory.com/309&quot;&gt;평가셋 글&lt;/a&gt;에서 배운 걸 다시 확인한 셈이었다 &amp;mdash; &lt;b&gt;쉬운 평가셋은 문제도, 모델 차이도 가려버린다.&lt;/b&gt; 정작 내가 겪던 외래어 구멍은 어려운 질의에서 났는데 세트는 쉬운 질의로 차 있었으니, 차이가 안 보일 수밖에 없었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 언어 특화로 바꾸니 '분별력'이 달랐다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점수가 같아 보인다고 두 모델이 같은 건 아니었다. 그걸 확인하려고, 점수 대신 &lt;b&gt;임베딩 공간 자체&lt;/b&gt;를 들여다봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국어 단어들을 의미가 통하는 것끼리 여러 의미 그룹으로 묶어놓고(이를테면 '공장&amp;middot;설비&amp;middot;생산'을 한 묶음, '회의&amp;middot;일정&amp;middot;보고'를 다른 묶음 식으로), &lt;i&gt;같은 그룹 안의 단어끼리&lt;/i&gt; 얼마나 가까운지(코사인 유사도)와 &lt;i&gt;다른 그룹의 단어와는&lt;/i&gt; 얼마나 먼지를 재봤다. 좋은 임베딩이라면 같은 그룹은 바짝 붙이고 다른 그룹은 밀어내야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 벌어진 폭이 곧 &quot;분별력&quot;이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;                 그룹 안    그룹 밖    벌어진 폭(gap)
범용 임베딩       0.79       0.73       0.06     &amp;larr; 다 비슷하게 붙어 있음
한국어 특화       0.42       0.31       0.12     &amp;larr; 거리를 더 벌림&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자를 보면 성격이 확 갈린다. 범용 모델은 모든 게 0.7대로 &lt;b&gt;조밀&lt;/b&gt;하게 붙어 있다 &amp;mdash; 비슷한 말이든 상관없는 말이든 죄다 0.7 언저리라, 둘의 차이가 &lt;b&gt;0.06밖에&lt;/b&gt; 안 난다. &quot;비슷한 것&quot;과 &quot;다른 것&quot;을 가를 여백이 좁은 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 한국어 특화 모델은 같은 그룹은 붙이고(0.42) 다른 그룹은 더 밀어내서(0.31), 그 폭이 &lt;b&gt;0.12&lt;/b&gt;로 두 배쯤 벌어진다. 절대값 자체는 범용보다 낮지만, 중요한 건 절대값이 아니라 _벌어진 폭_이다. 특화 모델이 &quot;비슷한 것과 다른 것&quot;을 더 자신 있게 갈랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1245&quot; data-origin-height=&quot;764&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2H0Iv/dJMcad3JpRd/RkjiljdN45Vzhl6Pmqjfk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2H0Iv/dJMcad3JpRd/RkjiljdN45Vzhl6Pmqjfk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2H0Iv/dJMcad3JpRd/RkjiljdN45Vzhl6Pmqjfk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2H0Iv%2FdJMcad3JpRd%2FRkjiljdN45Vzhl6Pmqjfk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1245&quot; height=&quot;764&quot; data-origin-width=&quot;1245&quot; data-origin-height=&quot;764&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유하면 이렇다. 둘 다 시험 답은 맞혔는데, 한쪽은 &quot;어&amp;hellip; 아마 이거?&quot; 하고 애매하게 맞히고, 한쪽은 &quot;이거랑 저건 확실히 다르지&quot; 하고 딱 갈라 맞힌 것이다. 쉬운 문제에선 둘 다 맞으니 점수가 같지만, 헷갈리는 문제에선 이 자신감의 차이가 정답률로 나타난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번엔 평가셋을 일부러 &lt;b&gt;어렵게&lt;/b&gt; 다시 짰다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답 단어가 문서에 그대로 나오는 경우(exact match)를 빼고, 다른 표현으로 바꿔 쓴 것(의역)만 정답으로 두고, 외래어가 섞인 헷갈리는 질의를 넣었다. 그제서야 차이가 드러났다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;어려운 셋 (exact 제거&amp;middot;의역&amp;middot;외래어 포함)   Recall@1  Recall@3  Recall@5    MRR
범용 임베딩                              0.17      0.53      0.67      0.378
한국어 특화                              0.18      0.61      0.75      0.420&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Recall@3&amp;middot;Recall@5&amp;middot;MRR, 전 지표에서 한국어 특화가 앞섰다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 실제로, 그토록 새던 외래어 검색이 눈에 띄게 나아졌다. &quot;스마트 팩토리&quot;가 스마트 팩토리 문서를 데려오기 시작한 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;트레이드오프?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트레이드오프라고 할 만한 건 오히려 이걸 &lt;b&gt;일반화할 때&lt;/b&gt; 드러난다. 성능을 끝까지 짜내려면, 결국 언어마다 그 언어에 특화된 모델을 따로 써야 할 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;범용 임베딩 하나면 여러 언어를 한 방에 처리하는 &lt;b&gt;단순함&lt;/b&gt;이 있는데, 특화로 가는 순간 그 단순함을 내주고 '언어별로 최고를 골라 관리하는' 몫을 떠안는다. 한국어에 몰린 검색이라면 고민할 것도 없이 특화가 맞지만, 여러 언어를 두루 받아야 한다면 &lt;i&gt;범용 하나의 편함*과 *언어별 특화의 성능&lt;/i&gt; 사이에서 저울질이 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 언어 특화 모델도 만능은 아니다. 외래어를 개념에 붙이는 힘이 좋아졌다곤 해도 100%는 아니어서, 신조어나 도메인 전문용어, 같은 개념을 제각각으로 적는 표기 흔들림 앞에선 여전히 헛도는 구간이 남는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 임베딩 하나로 전부 메우려 하기보다, 임베딩이 놓치는 자리를 다른 장치로 받쳐주는 편이 낫다 &amp;mdash; 외래어&amp;middot;동의어를 하나의 개념으로 묶어주는 용어 사전을 두거나, 용어와 개념의 관계를 노드 그래프로 엮어 검색을 보강하는 식으로.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 용어 사전 &amp;mdash; 표기가 흔들려도 한 개념으로 묶어준다
스마트 팩토리  &amp;equiv;  스마트팩토리 &amp;middot; smart factory &amp;middot; 스마트공장
RAG           &amp;equiv;  검색증강생성 &amp;middot; retrieval-augmented generation&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서 손대는 순서도 대개 값이 싼 쪽부터다 &amp;mdash; 용어 사전과 하이브리드(키워드+임베딩) 검색으로 큰 구멍을 먼저 싸게 메우고, 관계 추론이 진짜 필요한 도메인에서만 노드 그래프까지 간다. 임베딩 교체는 그중 손이 가장 덜 가는 한 수였을 뿐이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌아보면 이 교체는 드문 경우였다 &amp;mdash; &lt;b&gt;딱히 값을 치를 게 없었다.&lt;/b&gt; 검색을 개선하다 보면 보통 뭔가를 내줘야 하는데(&lt;a href=&quot;https://akku-dev.tistory.com/304&quot;&gt;리랭커&lt;/a&gt;는 지연을, 하이브리드는 임베딩 비용을 치렀듯이), 범용에서 한국어 특화로 옮기는 건 그 한국어+외래어 용도에선 거의 공짜에 가까운 개선이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;굳이 이 케이스에서 값을 꼽자면, 이득이 외래어&amp;middot;의역 같은 어려운 구간에만 몰린다는 것과 벡터 차원이 커진다는 것 정도다(차원 얘기는 다음 글로 미룬다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 범용 임베딩으로 바닥을 깔고 그 위에서 한국어 특화 모델로 외래어 구멍을 메운 셈이다. 화려한 교체는 아니었지만, &quot;한국어 검색이면 한국어를 더 잘 아는 모델&quot;이라는 당연한 선택이 성능의 한 끗을 조용히 올려줬다.&lt;/p&gt;</description>
      <category>개발/AI</category>
      <category>BGE-M3</category>
      <category>embedding</category>
      <category>rag</category>
      <category>검색 성능</category>
      <category>언어 특화 모델</category>
      <category>온디바이스 AI</category>
      <category>외래어 검색</category>
      <category>임베딩</category>
      <category>평가셋</category>
      <category>한국어 임베딩</category>
      <author>애쿠</author>
      <guid isPermaLink="true">https://akku-dev.tistory.com/319</guid>
      <comments>https://akku-dev.tistory.com/319#entry319comment</comments>
      <pubDate>Mon, 13 Jul 2026 15:58:51 +0900</pubDate>
    </item>
    <item>
      <title>쉽게 로컬 PC에 LLM 띄우기 &amp;mdash; LM Studio &amp;middot; Ollama &amp;middot; llama.cpp</title>
      <link>https://akku-dev.tistory.com/318</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvjVDy/dJMcabLI0Vg/adFpERKZZY35kkgPqmLFWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvjVDy/dJMcabLI0Vg/adFpERKZZY35kkgPqmLFWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvjVDy/dJMcabLI0Vg/adFpERKZZY35kkgPqmLFWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdvjVDy%2FdJMcabLI0Vg%2FadFpERKZZY35kkgPqmLFWK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;내 PC에서 LLM을 직접 굴려보고 싶다.&quot; 여기까지는 쉬운데, 막상 찾아보면 도구가 셋이나 나온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;LM Studio, Ollama, llama.cpp.&lt;/b&gt; 뭘 골라야 하나 싶어 한참 헤맸는데, 알고 보니 셋은 &lt;i&gt;경쟁하는 대체재가 아니라 층이 다른 것&lt;/i&gt;이었다. 맨 밑에 엔진(llama.cpp)이 있고, 그 위에 편의 도구(Ollama&amp;middot;LM Studio)가 얹힌 구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 셋을 &lt;b&gt;쉬운 순서로&lt;/b&gt; 하나씩 띄워보고, 언제 뭘 쓰면 좋은지까지 정리한다. (&lt;a href=&quot;https://akku-dev.tistory.com/314&quot;&gt;양자화 글&lt;/a&gt;에서 모델을 GGUF로 만드는 얘기를 했는데, 이 글은 그 GGUF를 &lt;i&gt;실제로 굴리는&lt;/i&gt; 법이다. &lt;a href=&quot;https://akku-dev.tistory.com/304&quot;&gt;RAG 구축 고려사항&lt;/a&gt;의 서빙 곁가지쯤.)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 제일 쉬운 길 : LM Studio (터미널 없이 클릭 몇 번)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널이 부담스러우면 여기서 시작하는 게 제일 편하다. 모델 검색&amp;middot;다운로드&amp;middot;대화&amp;middot;서버까지 전부 GUI로 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설치:&lt;/b&gt;&amp;nbsp;&lt;a href=&quot;https://lmstudio.ai/download&quot;&gt;lmstudio.ai/download&lt;/a&gt;에서 Windows 설치본을 받아 실행하면 끝이다. 명령줄이 편하면 winget으로도 된다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;winget install --id ElementLabs.LMStudio&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;모델 받기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좌측 &lt;b&gt;Discover 탭&lt;/b&gt;(돋보기, &lt;code&gt;Ctrl+2&lt;/code&gt;)에서 모델을 검색한다. 이름이나 Hugging Face 주소를 넣고, 양자화는 Q4 이상을 고르면 무난하다(양자화 얘기는 &lt;a href=&quot;https://akku-dev.tistory.com/314&quot;&gt;지난 글&lt;/a&gt; 참고). 받고 나면 &lt;b&gt;Chat 탭&lt;/b&gt;에서 그 모델을 로드해 바로 대화할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1394&quot; data-origin-height=&quot;751&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0LQeC/dJMcahE2LWO/PeefTWYxxN1haAHj9BWMa0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0LQeC/dJMcahE2LWO/PeefTWYxxN1haAHj9BWMa0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0LQeC/dJMcahE2LWO/PeefTWYxxN1haAHj9BWMa0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0LQeC%2FdJMcahE2LWO%2FPeefTWYxxN1haAHj9BWMa0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1394&quot; height=&quot;751&quot; data-origin-width=&quot;1394&quot; data-origin-height=&quot;751&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LM Studio가 편한 또 한 가지는 &lt;b&gt;모델을 로드할 때 하드웨어 설정을 슬라이더로 만질 수 있다&lt;/b&gt;는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GPU에 레이어를 몇 개 올릴지(GPU offload), 컨텍스트 길이를 얼마로 할지 &amp;mdash; 뒤에 볼 llama.cpp에선 &lt;code&gt;-ngl&lt;/code&gt;&amp;middot;&lt;code&gt;-c&lt;/code&gt; 플래그로 넘겨야 하는 것들을 여기선 화면에서 눈금으로 조절한다. 채팅 쪽에서도 시스템 프롬프트나 temperature 같은 값을 GUI로 바꿀 수 있어서, 코드 한 줄 없이 이것저것 실험해보기 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1391&quot; data-origin-height=&quot;751&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cyvlpV/dJMcafN68Nl/Ji0xktJFS0e2hAXBukIpUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cyvlpV/dJMcafN68Nl/Ji0xktJFS0e2hAXBukIpUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cyvlpV/dJMcafN68Nl/Ji0xktJFS0e2hAXBukIpUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcyvlpV%2FdJMcafN68Nl%2FJi0xktJFS0e2hAXBukIpUK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1391&quot; height=&quot;751&quot; data-origin-width=&quot;1391&quot; data-origin-height=&quot;751&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;API 서버로도 쓰기.&lt;/b&gt; 그냥 채팅만 하는 게 아니라, &lt;b&gt;Developer 탭&lt;/b&gt;에서 로컬 서버를 켜면 OpenAI 호환 API가 열린다. 주소는 &lt;code&gt;http://localhost:1234/v1&lt;/code&gt;. 그러면 OpenAI SDK에서 &lt;code&gt;base_url&lt;/code&gt;만 이걸로 바꿔 그대로 갖다 쓸 수 있다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;채팅 API   http://localhost:1234/v1/chat/completions
임베딩     http://localhost:1234/v1/embeddings&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 나는 검색 실험에서 &lt;b&gt;bge-m3 임베딩을 이 OpenAI 호환 서버로 호출&lt;/b&gt;해서 썼다. 임베딩 모델을 GUI로 받아 클릭 한 번으로 서버를 열 수 있으니, 이것저것 갈아 끼우며 실험하기엔 이만한 게 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LM Studio는 GUI라 &lt;b&gt;처음 발 담그거나 모델을 이리저리 실험할 땐 최고&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 그만큼 무겁고 GUI에 의존한다. 화면 없는 서버(헤드리스)에 얹거나 &lt;b&gt;프로덕션에 그대로 배포하기엔 맞지 않는다.&lt;/b&gt; (CLI가 아예 없진 않다 &amp;mdash; &lt;code&gt;lms server start&lt;/code&gt; 같은 &lt;code&gt;lms&lt;/code&gt; 명령이 번들돼 있다. 다만 이 도구의 본령은 GUI다.)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 적당히 쉽고 깔끔한 서빙 : Ollama (명령 한 줄)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널이 익숙하다면 Ollama가 더 빠르다. 명령 한 줄이면 모델을 받아 바로 돌리고, 깔끔한 로컬 API까지 딸려 온다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;설치&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ollama.com/download/windows&quot;&gt;ollama.com/download/windows&lt;/a&gt;에서 설치본을 받거나, PowerShell 한 줄, 또는 winget으로 깐다.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;irm https://ollama.com/install.ps1 | iex     # 또는
winget install --id Ollama.Ollama&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치 프로그램이 PATH를 자동으로 잡아준다. 단 &lt;b&gt;이미 열려 있던 터미널엔 반영이 안 되고, 새 터미널부터&lt;/b&gt; &lt;code&gt;ollama&lt;/code&gt; 명령이 먹는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;모델 받고 돌리기&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;ollama pull gemma3      # 모델 다운로드
ollama run gemma3       # 실행 &amp;rarr; &amp;gt;&amp;gt;&amp;gt; 프롬프트에서 바로 대화
ollama list             # 받아둔 모델 목록&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ollama run&lt;/code&gt;은 모델이 로컬에 없으면 알아서 받은 뒤 대화 프롬프트로 넘어간다. 성공하면 콘솔이 대략 이렇게 흐른다(진행 바 문구는 버전마다 조금씩 다르고, 마지막 &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; 프롬프트가 &quot;이제 됐다&quot;는 신호다).&lt;/p&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;pulling manifest
pulling a1b2c3...  100% ▕████████████████▏ 4.7 GB
verifying sha256 digest
writing manifest
success
&amp;gt;&amp;gt;&amp;gt; Send a message (/? for help)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;API로 쓰기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 기본으로 &lt;code&gt;http://localhost:11434&lt;/code&gt;에 뜬다. 네이티브 엔드포인트(&lt;code&gt;/api/chat&lt;/code&gt;)도 있고, &lt;b&gt;OpenAI 호환 엔드포인트&lt;/b&gt;(&lt;code&gt;http://localhost:11434/v1/&lt;/code&gt;)도 열려 있다. API 키는 형식상 필요하지만 값은 무시되니 아무거나 넣으면 된다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from openai import OpenAI
client = OpenAI(base_url=&quot;http://localhost:11434/v1/&quot;, api_key=&quot;ollama&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 &lt;a href=&quot;https://akku-dev.tistory.com/316&quot;&gt;파인튜닝한 질의처리기&lt;/a&gt;를 GGUF로 만들어 Ollama에 등록해서(Modelfile로) 서빙했다. &lt;code&gt;run&lt;/code&gt; 한 줄로 굴러가고 API가 깔끔하니, 스크립트나 앱에 붙이기 좋았다. 참고로 2025년부터는 Ollama도 Windows용 &lt;b&gt;네이티브 데스크톱 앱&lt;/b&gt;(트레이 아이콘 + 채팅 창)이 생겨서, 꼭 터미널만 써야 하는 건 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, pull 한 줄&amp;middot;API 깔끔함이 최대 장점이다. 대신 그 편의를 위해 내부가 추상화돼 있어서 세부 플래그나 메모리를 손톱만큼 더 짜내는 제어는 다음에 볼 llama.cpp만큼 되진 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 밑바닥 엔진 직접 : llama.cpp server (제어와 경량)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 위 둘 다 이걸 감싼 것이다. &lt;b&gt;llama.cpp가 맨 아래에서 도는 추론 엔진&lt;/b&gt;이고, GGUF 포맷의 본가다. 최대한 가볍게, 플래그를 직접 만지고 싶으면 이걸 직접 띄운다. 정본 저장소는 &lt;a href=&quot;https://github.com/ggml-org/llama.cpp&quot;&gt;github.com/ggml-org/llama.cpp&lt;/a&gt;다(예전 &lt;code&gt;ggerganov&lt;/code&gt; 계정에서 이전됐고, 옛 주소는 리다이렉트된다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;설치&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/ggml-org/llama.cpp/releases&quot;&gt;Releases&lt;/a&gt;에서 Windows용 zip을 받는다. 파일명이 &lt;code&gt;llama-&amp;lt;버전&amp;gt;-bin-win-&amp;lt;종류&amp;gt;-x64.zip&lt;/code&gt; 꼴이고, 내 하드웨어에 맞는 종류를 고른다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;...-cpu-x64.zip            CPU만
...-cuda-12.4-x64.zip      NVIDIA (CUDA 버전 갈래 있음, 드라이버 맞춰서)
...-vulkan-x64.zip         Vulkan (범용 GPU)
...-hip-radeon-x64.zip     AMD&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;zip은 &lt;b&gt;인스톨러가 아니라 풀어서 바로 실행&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;압축을 풀면 &lt;code&gt;llama-server.exe&lt;/code&gt;, &lt;code&gt;llama-cli.exe&lt;/code&gt;가 들어 있고, 그 폴더에서 실행하거나 PATH를 수동으로 추가해야 한다(zip은 PATH 자동 등록을 안 해준다). 손이 가는 게 싫으면 winget으로 깔면 PATH까지 잡아준다.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;winget install llama.cpp&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;실행&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GGUF 모델 파일을 하나 준비해서(보통 Hugging Face에서 &lt;code&gt;.gguf&lt;/code&gt;) &lt;code&gt;llama-server&lt;/code&gt;에 물린다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;.\llama-server.exe -m .\models\model.gguf -c 4096 -ngl 99 --host 127.0.0.1 --port 8080&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;diff&quot;&gt;&lt;code&gt;-m     모델 경로 (필수)
-c     컨텍스트 길이 (여기선 4096)
-ngl   GPU에 올릴 레이어 수 (99 = 되는 만큼 다)
--port 포트 (기본 8080)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성공했는지는 마지막 리스닝 라인으로 안다(모델 로딩 로그는 버전마다 문구가 바뀌지만, 이 줄은 늘 뜬다).&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;main: model loaded
main: server is listening on http://127.0.0.1:8080
main: starting the main loop...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 브라우저로 &lt;code&gt;http://localhost:8080&lt;/code&gt;에 들어가면 &lt;b&gt;llama.cpp에 내장된 웹 UI&lt;/b&gt;로 바로 대화할 수 있다. API도 OpenAI 호환(&lt;code&gt;/v1/chat/completions&lt;/code&gt;)이라 붙이는 방식은 앞의 둘과 똑같다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;  http://localhost:8080                    llama.cpp 내장 웹 UI
────────────────────────────────────────────────────────────
   나  ▸  안녕, 넌 어떤 모델이야?

   봇  ▸  로컬에서 llama.cpp로 도는 GGUF 모델이에요.
          무엇을 도와드릴까요?
────────────────────────────────────────────────────────────
   [ 메시지를 입력&amp;hellip;                            ]    ▸ 보내기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;온디바이스 개발하는 상황에서는 사실상 선택권이 없다.&lt;/b&gt;&amp;nbsp;성능과 플래그를 완전히 손에 쥘 수 있고, 셋 중 가장 가볍다. 대신 앞의 편의 도구들이 대신 해주던 일을 여기선 전부 사람이 떠안는다. 구체적으로 세 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 빌드 고르기.&lt;/b&gt; 릴리스에는 하드웨어별로 다른 zip이 올라온다 &amp;mdash; CPU 전용, CUDA(엔비디아, 그것도 드라이버의 CUDA 버전까지 맞춰야 한다), Vulkan(범용 GPU), ROCm(AMD). 내 장비에 맞는 걸 직접 골라야 하고, 엉뚱한 걸 받으면 GPU를 못 쓰거나 아예 안 뜬다(CUDA 빌드는 런타임 DLL 묶음도 따로 챙겨야 한다). Ollama&amp;middot;LM Studio는 이걸 알아서 감지해 맞는 걸 깔아준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. PATH.&lt;/b&gt; zip이 인스톨러가 아니라 그냥 압축을 푸는 것이라 생기는 문제다. 압축을 풀면 &lt;code&gt;llama-server.exe&lt;/code&gt;가 그 폴더 안에 들어 있는데, 실행도 그 폴더 안에서만 된다. 아무 위치의 터미널에서나 &lt;code&gt;llama-server&lt;/code&gt;라고 불러 쓰려면, 그 폴더 경로를 Windows 환경변수 &lt;b&gt;PATH&lt;/b&gt;에 손수 등록해야 한다(시스템 속성 &amp;rarr; 환경 변수). winget으로 깔면 이건 알아서 잡아준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 모델 관리.&lt;/b&gt; Ollama의 &lt;code&gt;ollama pull&lt;/code&gt;처럼 모델을 받아서 정리하고 버전까지 챙겨주는 창구가 없다는 뜻이다. 쓸 GGUF 파일을 보통 Hugging Face에서 직접 찾아 받아 어딘가 저장해두고, 실행할 때마다 &lt;code&gt;-m&lt;/code&gt;에 그 경로를 정확히 적어 물려줘야 한다. 어떤 모델을 어디에 뒀는지도 스스로 관리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 셋 중 가장 강력하지만, 진입장벽도 그만큼 제일 높다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 그래서 뭘 쓰나 &amp;mdash; '레이어'로 이해하면 쉽다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋을 나란히 놓고 &quot;뭐가 제일 좋냐&quot;를 따지면 답이 안 나온다. 애초에 같은 줄에 선 물건이 아니기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;   LM Studio (GUI)          Ollama (CLI + API)      &amp;larr; 이 엔진을 감싼 편의층
        ▲                        ▲
        └───────────┬────────────┘
                    │
            llama.cpp  &amp;middot;  엔진 (GGUF)               &amp;larr; 밑바닥 (이걸 감싼 게 위의 둘)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ollama도 LM Studio도 &lt;b&gt;내부적으로 llama.cpp를 엔진으로 쓴다&lt;/b&gt;(양쪽 공식 문서가 그렇게 밝힌다). 위에 무엇을 얹었느냐가 다를 뿐이다 &amp;mdash; Ollama는 CLI와 간단한 API를, LM Studio는 풀 GUI를 얹었고, llama.cpp는 그 엔진을 날것으로 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니 선택은 &quot;무엇이 우월한가&quot;가 아니라 &lt;b&gt;&quot;어디에 쓰느냐&quot;&lt;/b&gt;의 문제다.&lt;/p&gt;
&lt;pre class=&quot;avrasm&quot;&gt;&lt;code&gt;처음 발 담그기 &amp;middot; 모델 실험 &amp;middot; 임베딩 굴려보기     &amp;rarr;  LM Studio
CLI로 편하게 서빙 &amp;middot; 스크립트/앱에 API 연동         &amp;rarr;  Ollama
성능 튜닝 &amp;middot; 최대 경량 &amp;middot; 플래그 완전 제어           &amp;rarr;  llama.cpp 직접&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니 셋 중 뭐가 낫냐를 따질 게 아니라, 지금 내가 뭘 하려는지를 먼저 보면 된다. &lt;b&gt;&lt;i&gt;편의를 얻으면 그만큼 제어를 내주고, 제어를 쥐면 그만큼 손이 간다&lt;/i&gt;&lt;/b&gt; &amp;mdash; 셋은 그 맞바꿈의 눈금이 어디쯤이냐가 다를 뿐이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에 LLM을 띄우는 건 생각보다 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LM Studio면 클릭 몇 번, Ollama면 명령 한 줄이면 내 PC에서 모델이 돈다. 다만 그 쉬움의 뒤에는 다 llama.cpp라는 같은 엔진이 있고 편의 도구들은 그 위에 각자 다른 껍데기를 씌운 것뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 온디바이스처럼 자원이 빠듯한 자리에선 이 선택이 곧 자원 예산의 문제가 된다. &lt;a href=&quot;https://akku-dev.tistory.com/314&quot;&gt;양자화 글&lt;/a&gt;에서 GGUF를 &lt;i&gt;만드는&lt;/i&gt; 법을 봤다면, 이 글은 그걸 &lt;i&gt;굴리는&lt;/i&gt; 법이었다. 만들고 굴리는 두 조각이 맞물려야, 비로소 &quot;내 PC(또는 기기) 위에서 도는 LLM&quot;이 완성된다.&lt;/p&gt;</description>
      <category>개발/AI</category>
      <category>GGUF</category>
      <category>llama.cpp</category>
      <category>lm studio</category>
      <category>ollama</category>
      <category>OpenAI 호환 API</category>
      <category>Windows</category>
      <category>로컬 서버</category>
      <category>로컬LLM</category>
      <category>소형 LLM</category>
      <category>온디바이스 AI</category>
      <author>애쿠</author>
      <guid isPermaLink="true">https://akku-dev.tistory.com/318</guid>
      <comments>https://akku-dev.tistory.com/318#entry318comment</comments>
      <pubDate>Fri, 10 Jul 2026 11:10:58 +0900</pubDate>
    </item>
    <item>
      <title>RAG 검색 개선기: 하이브리드 검색</title>
      <link>https://akku-dev.tistory.com/317</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c5DLWi/dJMcaaF0ZSW/TqwxMkmtLnG8RPMcKzURVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c5DLWi/dJMcaaF0ZSW/TqwxMkmtLnG8RPMcKzURVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c5DLWi/dJMcaaF0ZSW/TqwxMkmtLnG8RPMcKzURVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc5DLWi%2FdJMcaaF0ZSW%2FTqwxMkmtLnG8RPMcKzURVK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://akku-dev.tistory.com/307&quot;&gt;BM25 글&lt;/a&gt;에서 키워드 검색을 다뤘다. 정확히 같은 단어로 물어보면 강하다. 그런데 &quot;회의 자료&quot;로 색인된 문서를 누가 &quot;미팅 정리본&quot;이라고 검색하면? 단어가 안 겹쳐서 못 찾는다. 키워드 검색의 천장이 여기다. 이걸 벡터(임베딩) 검색이 메워준다 &amp;mdash; 단어가 달라도 &lt;i&gt;의미&lt;/i&gt;가 가까우면 잡는다. 그럼 둘을 합치면 되지 않나? 그게 이번 글, 하이브리드 검색이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;a href=&quot;https://akku-dev.tistory.com/304&quot;&gt;RAG 구축 고려사항&lt;/a&gt;에서 1차 검색을 완성하는 자리다. 다음 글에선 이 1차 위에 리랭커(2차)를 얹는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 두 검색은 강점이 다르다 &amp;mdash; 키워드 vs 벡터&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;키워드 검색(BM25)은 어휘 일치&lt;/b&gt;다. 질의의 단어가 문서에 그대로 있으면 점수가 붙는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 파일명이나 고유한 용어처럼 &quot;딱 그 단어&quot;를 찾을 땐 거의 안 틀린다. 대신 같은 뜻 다른 표현엔 약하다. &quot;기획서&quot;와 &quot;제안서&quot;, &quot;미팅&quot;과 &quot;회의&quot;를 서로 다른 단어로 본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;벡터 검색은 반대다.&lt;/b&gt; 문장을 임베딩 모델로 &lt;b&gt;벡터(의미 좌표)&lt;/b&gt;로 바꿔, 질의 벡터와 가까운 문서를 찾는다. 단어가 달라도 의미가 가까우면 잡아낸다 &amp;mdash; &quot;미팅 정리본&quot;으로 &quot;회의 자료&quot;를 찾아준다. 대신 정확한 매칭이나 희귀한 고유어에선 흐릿해지고, 의미가 비슷한 엉뚱한 문서를 끌고 오기도 한다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;질의: &quot;미팅 정리본&quot;      정답 문서: &quot;회의 자료&quot;
─────────────────────────────────────────────
키워드(BM25)   단어 안 겹침 &amp;rarr; 못 찾음          (정확엔 강, 표현 변형엔 약)
벡터(임베딩)   의미가 가까움 &amp;rarr; 찾아냄          (의미엔 강, 정확 매칭엔 약)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;i&gt;한쪽이 약한 자리에서 다른 쪽이 강하다&lt;/i&gt;는 점이다. 그래서 둘 중 하나를 고르는 게 아니라, 합치자는 발상이 나온다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 어떻게 합치나 &amp;mdash; 점수 정규화의 함정, 그리고 RRF&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;합치려니 문제가 하나 있다. 두 검색의 점수가 서로 다른 자로 잰 값이라는 것. BM25 점수는 위로 열려 있어(이론상 상한이 없다) 질의&amp;middot;코퍼스마다 범위가 들쭉날쭉한데, 벡터 코사인 유사도는 -1에서 1 사이에 갇혀 있다. 스케일도 분포도 다르니, 그냥 더하면 큰 숫자를 내는 쪽(보통 BM25)이 다른 쪽을 통째로 잡아먹는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 먼저 떠오르는 해법은 점수 정규화다.&lt;/b&gt; 각 검색의 점수를 0~1 같은 공통 범위로 눌러 맞춘 뒤 더하자는 것(min-max, z-score 같은 방식). 그럴듯한데, 막상 해보면 정규화가 새 문제를 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 정규화 기준이 &lt;i&gt;그 질의의 결과 분포에 통째로 휘둘린다&lt;/i&gt;는 점이다. min-max는 그 결과 집합의 최댓값&amp;middot;최솟값에 맞춰 누르는데, 점수 하나만 유난히 높아도(이상치) 나머지가 전부 바닥으로 깔린다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;질의 A의 BM25 점수:  [30.0,  2.1,  2.0,  1.9]   &amp;larr; 1등만 유난히 튐
   min-max 정규화 &amp;rarr;  [1.00, 0.007, 0.003, 0.0]
   2~4등은 멀쩡한 후보인데 0에 수렴 &amp;rarr; 사실상 버려짐&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 이 기준이 질의마다 달라서, 똑같은 정규화 점수 0.5라도 어떤 질의에선 좋은 점수고 어떤 질의에선 형편없는 점수가 된다. 질의를 가로질러 비교가 안 되는 값이 되는 것이다. 정규화 방식을 고르고 맞추는 일도 또 다른 손이고.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RRF(Reciprocal Rank Fusion)는 이걸 통째로 우회한다.&lt;/b&gt; 점수를 아예 안 본다. 각 검색에서 몇 등을 했는지, 그 &lt;i&gt;순위&lt;/i&gt;만 가져다 &lt;code&gt;1/(k+순위)&lt;/code&gt;로 점수를 매겨 합산한다. 순위는 스케일도 분포도 없는 값이라(1등은 어느 검색에서나 그냥 1등이다), 정규화가 풀려던 문제 자체가 사라진다. k는 상위 순위가 과도하게 지배하지 않게 눌러주는 완충값이다(흔히 60을 쓴다).&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;질의 &amp;rarr; 키워드 검색 &amp;rarr; [랭킹 A]  ┐
                              ├─&amp;rarr; 순위로 합산: &amp;Sigma; 1/(k+순위) &amp;rarr; 최종 순위
질의 &amp;rarr; 벡터 검색   &amp;rarr; [랭킹 B]  ┘&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 RRF도 공짜는 아니다. 점수를 버리는 만큼 &lt;i&gt;얼마나 더 좋은지&lt;/i&gt;(1등이 2등을 압도하는지, 간발의 차인지)라는 크기 정보를 잃는다. 그래도 정규화의 취약함을 떠안느니, 단순하고 분포에 안 휘둘리는 순위 융합을 기본으로 두는 편이 실전에선 더 잘 버텼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한쪽에 &lt;b&gt;가중치&lt;/b&gt;를 둬서 &quot;벡터를 얼마나 반영할지&quot;를 조절한다. 그런데 이 가중치가, 다음 장의 진짜 골칫거리다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1485&quot; data-origin-height=&quot;765&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ehWMCm/dJMcaa0eDdE/pk1JYg8OKtHlRZdCqxyc71/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ehWMCm/dJMcaa0eDdE/pk1JYg8OKtHlRZdCqxyc71/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ehWMCm/dJMcaa0eDdE/pk1JYg8OKtHlRZdCqxyc71/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FehWMCm%2FdJMcaa0eDdE%2Fpk1JYg8OKtHlRZdCqxyc71%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1485&quot; height=&quot;765&quot; data-origin-width=&quot;1485&quot; data-origin-height=&quot;765&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 얼마나 섞을까 &amp;mdash; 정답은 도메인마다 다르다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;벡터를 많이 섞을수록 똑똑해지겠지&quot; 했다. 아니었다. 벡터 가중을 올릴수록 top1(맨 위 정답률)이 오히려 떨어졌다. 의미는 넓게 잡지만, 키워드가 딱 맞히던 1등을 &lt;i&gt;의미적으로 비슷한 다른 문서&lt;/i&gt;가 밀어내는 것이다. 반대로 벡터를 너무 줄이면 recall(정답이 후보 안에 드는 비율)에서 손해를 봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니까 하이브리드는 &quot;더하면 좋아지는&quot; 게 아니라 &lt;i&gt;균형을 맞추는&lt;/i&gt; 일이었다. 벡터를 적당히 낮게 섞은 지점에서 top1과 recall이 같이 사는 정점이 나왔고, 과하게 섞으면 오히려 해로웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 중요한 건, &lt;b&gt;그 정점이 도메인마다 다르다&lt;/b&gt;는 것이다. 파일명&amp;middot;키워드로 찾는 질의가 많은 데이터면 벡터 비중을 낮게, 자연어로 풀어 묻는 질의가 많으면 좀 더 높게 &amp;mdash; 최적 가중치가 데이터 성격을 탄다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 가중치는 미리 정해 박는 값이 아니라, &lt;i&gt;그 도메인의 실제 질의로 테스트하며 찾아가는&lt;/i&gt; 값이었다. 같은 가중치를 다른 데이터에 그대로 옮기면 안 맞는다. 평가셋을 들고 가중치를 조금씩 바꿔가며 top1&amp;middot;recall이 어디서 제일 좋은지 재보는, 손이 가는 튜닝이 필요했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1335&quot; data-origin-height=&quot;765&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Uu42A/dJMcaazdnkK/eD6zkyTmt92lChkjR1pnUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Uu42A/dJMcaazdnkK/eD6zkyTmt92lChkjR1pnUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Uu42A/dJMcaazdnkK/eD6zkyTmt92lChkjR1pnUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUu42A%2FdJMcaazdnkK%2FeD6zkyTmt92lChkjR1pnUk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1335&quot; height=&quot;765&quot; data-origin-width=&quot;1335&quot; data-origin-height=&quot;765&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 공짜가 아니다 &amp;mdash; 의미를 얻는 대신, 모든 파일을 임베딩해야 한다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지면 &quot;그럼 무조건 하이브리드네&quot; 싶은데, 벡터를 더하는 데는 분명한 값이 따른다. 이게 이 글에서 제일 정직하게 적고 싶은 부분이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;벡터 검색이 의미를 잡으려면, &lt;b&gt;검색 대상이 되는 모든 파일을 미리 임베딩해 둬야 한다.&lt;/b&gt; 키워드 검색은 단어만 색인하면 되지만, 벡터는 문서 하나하나를 임베딩 모델에 통과시켜 벡터로 만들어 저장해야 한다. 코퍼스가 크면 이 선행 작업이 그만큼 길어지고, 새 문서가 들어올 때마다 또 임베딩해야 한다. &quot;한 번 켜면 끝&quot;이 아니라 계속 따라붙는 비용이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 그 임베딩 모델이 &lt;b&gt;검색하는 내내 메모리에 떠 있어야 한다.&lt;/b&gt; 질의가 들어올 때마다 질의를 즉석에서 임베딩해야 하니까. 서버면 모를까, 온디바이스에선 이게 부담이다 &amp;mdash; 키워드 검색기&amp;middot;앱과 같은 RAM을 나눠 쓰는데 임베딩 모델 하나가 수백 MB에서 GB 단위를 차지한다. 자원이 빠듯하면 모델이 메모리에서 밀려 내려가는 일까지 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 트레이드오프가 분명하다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;얻는 것    의미 검색 &amp;mdash; 단어가 달라도 뜻으로 찾는다
치르는 값  &amp;middot; 전체 코퍼스 선행 임베딩 (+ 신규 문서마다 추가)
           &amp;middot; 임베딩 모델 상시 메모리 상주
           &amp;middot; 질의마다 임베딩하는 지연&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결정적으로, &lt;b&gt;이 값을 치른다고 정확도가 반드시 오르는 것도 아니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키워드만으로 이미 잘 찾히는 질의&amp;middot;도메인이라면, 벡터를 더해 얻는 이득이 그 비용을 못 넘는다. &quot;의미 검색이 되니까 좋다&quot;가 아니라, &quot;이 메모리와 이 임베딩 비용을 치를 만큼 의미 검색이 값을 하나&quot;를 따져야 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 그래서 언제 켜나 &amp;mdash; 조건부 이득&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 하이브리드는 만능이 아니라 조건부였다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;자연어로 풀어 묻고, 같은 뜻을 여러 표현으로 찾는 일이 잦다
   &amp;rarr; 벡터가 값을 한다. 하이브리드 ㅇㅋ

대부분 파일명&amp;middot;정확한 키워드로 찾고, 표현 변형이 적다
   &amp;rarr; 키워드 검색만으로 충분. 임베딩 비용을 안 치르는 BM25 단독도 합리적&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 어떤 환경에선 &quot;굳이 임베딩 모델까지 올릴 이유가 없다&quot;는 판단으로 키워드 단독을 택하기도 했다. 하이브리드가 더 고급이라서 항상 옳은 게 아니라, &lt;i&gt;데이터와 질의 성격에 맞는가&lt;/i&gt;의 문제였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하이브리드는 키워드의 정확함과 벡터의 의미를 한 검색에 합쳐, 1차 검색을 완성하는 단계였다. 다만 그 &quot;의미&quot;는 공짜가 아니라 전체 임베딩과 상시 메모리라는 값을 요구했고, 그 값이 늘 회수되는 것도 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가중치도 도메인마다 다시 맞춰야 했고. 결국 또 같은 결론이다 &amp;mdash; &lt;i&gt;더 좋은 기법이냐*가 아니라 *이 비용으로 어디에 쓰느냐.&lt;/i&gt; 이렇게 다듬은 1차 검색 위에, 다음 글에선 상위 후보만 다시 정밀 채점하는 리랭커(2차)를 얹는다.&lt;/p&gt;</description>
      <category>개발/AI</category>
      <category>bm25</category>
      <category>hybrid search</category>
      <category>rag</category>
      <category>RRF</category>
      <category>검색 가중치</category>
      <category>벡터 검색</category>
      <category>온디바이스 AI</category>
      <category>임베딩</category>
      <category>하이브리드 검색</category>
      <author>애쿠</author>
      <guid isPermaLink="true">https://akku-dev.tistory.com/317</guid>
      <comments>https://akku-dev.tistory.com/317#entry317comment</comments>
      <pubDate>Thu, 9 Jul 2026 10:58:09 +0900</pubDate>
    </item>
    <item>
      <title>RAG 검색 개선기: LLM을 걷어내고 규칙 기반 질의처리기로</title>
      <link>https://akku-dev.tistory.com/316</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cLnuNK/dJMcahSoBns/ahOYpnfkYKyW7a7xUpYeR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cLnuNK/dJMcahSoBns/ahOYpnfkYKyW7a7xUpYeR0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cLnuNK/dJMcahSoBns/ahOYpnfkYKyW7a7xUpYeR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcLnuNK%2FdJMcahSoBns%2FahOYpnfkYKyW7a7xUpYeR0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://akku-dev.tistory.com/312&quot;&gt;파인튜닝 글&lt;/a&gt;에서 작은 LLM으로 질의처리기를 만들어봤지만, 표준 어순에선 잘 되다가 변형에서 무너졌고 결국 곁가지로 남았다고 적었다. 그러면 본선 질의처리기는 어떻게 됐을까. &lt;b&gt;결국 LLM을 걷어내고 규칙 기반으로 다시 만들었다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 &lt;a href=&quot;https://akku-dev.tistory.com/310&quot;&gt;질의처리 글&lt;/a&gt;에서 이미 답이 나와 있었다 &amp;mdash; 질의처리의 핵심은 LLM을 쓰느냐가 아니라, 검색어&amp;middot;필터&amp;middot;제거를 나누는 기준이라는 것. 이 글은 &lt;a href=&quot;https://akku-dev.tistory.com/304&quot;&gt;RAG 구축 고려사항&lt;/a&gt; 중 질의 강화&amp;middot;키워드 추출의 마지막 매듭이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 왜 다시 규칙이었나&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM으로 질의를 처리하면 깔끔해 보인다. 그런데 검색 본경로에 두기엔 세 가지가 걸렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 호출이 느렸고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 같은 질의에 매번 결과가 조금씩 달라졌으며&lt;b&gt;(비결정성)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 파인튜닝으로 정확도를 올려도 학습에 없던 표현에서 흔들렸다&lt;b&gt;(오버피팅).&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌아보면 질의처리는 애초에 &lt;i&gt;생성&lt;/i&gt; 문제가 아니라 &lt;i&gt;분류&lt;/i&gt; 문제였다. 한 문장 안에는 날짜 같은 조건과 진짜 찾고 싶은 키워드가 섞여 있는데, 이걸 정해진 칸(슬롯)에 나눠 담는 일은 기준만 또렷하면 모델 없이도 결정론적으로 할 수 있다. 그래서 LLM 질의처리기를 들어내고, 그 자리에 규칙 기반 처리기를 끼웠다. 입력과 출력 계약은 그대로 둬서 &lt;b&gt;드롭인 교체&lt;/b&gt;가 되게 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 &quot;규칙으로 가자&quot;가 곧 설계도는 아니었다. 어떤 규칙으로 어떻게 나눌지는 한 번에 안 나왔다. 이것저것 만들어보고 깨지고를 반복하다, 결국 &lt;b&gt;무엇을 어느 칸에 담을지 정하는 일&lt;/b&gt;과 &lt;b&gt;그 판단이 맞는지 확인하는 일&lt;/b&gt; 두 축으로 수렴했다. 둘 다 처음부터 그려둔 그림이 아니라 검색을 만지며 부딪힌 문제에서 하나씩 끌려나온 방법이다. 아래에서 그 둘이 왜 그렇게 자리 잡았는지를 차례로 풀어본다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 큰 그림 &amp;mdash; 질의를 슬롯으로 쪼갠다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙 기반 처리기가 하는 일은 한 문장이다. &lt;b&gt;질의를 슬롯과 키워드로 분해한다.&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1485&quot; data-origin-height=&quot;824&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b5hPP3/dJMcaay2339/DQ0rFiJI15CxWEuWchfuI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b5hPP3/dJMcaay2339/DQ0rFiJI15CxWEuWchfuI0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b5hPP3/dJMcaay2339/DQ0rFiJI15CxWEuWchfuI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb5hPP3%2FdJMcaay2339%2FDQ0rFiJI15CxWEuWchfuI0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1485&quot; height=&quot;824&quot; data-origin-width=&quot;1485&quot; data-origin-height=&quot;824&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬롯은 메타데이터로 거를 수 있는 조건들이다. 시간 범위나 파일 형식, 작성자처럼 문서가 들고 있는 속성들인데, 정확히 어떤 슬롯을 둘지는 검색 대상 문서가 가진 메타데이터에 따라 달라진다. 이 슬롯에 잡히지 않고 남은 내용어가 검색 키워드가 된다. 예를 들어 &quot;김민수가 작성한 작년 회의 자료 pdf&quot;는 이렇게 갈린다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;질의:  김민수가 작성한 작년 회의 자료 pdf
─────────────────────────────────────────────
작성자   김민수
시간     작년 &amp;rarr; 날짜 범위
확장자   pdf
키워드   회의, 자료
제거     가, 작성한   (조사 &amp;middot; 활용형)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. Kiwi로 슬롯을 잡는다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국어 경로의 핵심은 형태소 분석기 &lt;b&gt;Kiwi&lt;/b&gt;다. 질의를 형태소로 쪼개 품사를 붙이면(형태소 분석이 뭔지는 별도 글에서 따로 다룬다), 어떤 토큰이 날짜&amp;middot;경로&amp;middot;확장자인지 규칙으로 잡아낼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방식은 단순하다. 날짜나 형식 같은 슬롯이 먼저 자기 토큰을 가져가고, 그렇게 &lt;b&gt;이미 슬롯이 가져간 토큰을 빼고 남은 명사&lt;/b&gt;가 검색 키워드가 된다. 형태소 단위로 다루니, 조사나 활용형이 붙어 있어도 품사를 보고 자연스럽게 걸러진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모르는 표현이 엉뚱하게 쪼개지는 경우조차 모델의 직감이 아니라 품사 규칙으로 직접 손볼 수 있다는 게, 규칙 기반의 힘이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 애매하면 되묻는다 &amp;mdash; 멀티홉 확인&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙 기반이라고 모든 걸 단정하지는 않는다. 오히려 애매한 토큰을 억지로 슬롯에 욱여넣지 않는 게 핵심이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전형적인 골칫거리가 연도였다. &quot;2025년 보고서&quot;의 &quot;2025년&quot;은 날짜 범위일 수도, 그냥 제목의 일부(&quot;2025년 사업계획&quot;처럼)일 수도 있다. 여기서 날짜로 강제하면 검색이 그 해로 좁혀지면서, 정작 제목에 &quot;2025&quot;가 든 다른 해 문서를 다 떨군다. 반대로 무조건 키워드로만 두면 &quot;작년에 나온 보고서&quot;를 찾는 사람의 시간 의도를 놓친다. 어느 쪽으로 찍어도 절반은 틀리는 자리다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 한 일은 &lt;i&gt;확정할 수 있는 건 확정하는&lt;/i&gt; 것이었다. 연도에 월이 붙으면(&quot;2025년 5월 보고서&quot;) 기간이 명확하니 그냥 그 달로 필터를 건다. 끝까지 애매한 건 딱 하나, &lt;b&gt;연도 단독 + 내용 명사&lt;/b&gt; 조합이다. 이 좁은 케이스만 따로 _애매 슬롯_으로 표시해 뒀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 이 애매 슬롯을 그냥 키워드로 흘려보냈다. &quot;확신 없으면 거르지 말고 검색어로 둔다&quot;는 보수적인 규칙이다. 안전하긴 한데, 시간 의도가 분명한 질의까지 매번 키워드로 떨어뜨리니 아쉬웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 한 겹을 더 댔다. &lt;b&gt;멀티홉 &amp;mdash; 애매하면 사용자에게 직접 되묻는다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;사용자:  2025년 보고서
시스템:  &quot;2025년&quot;을 날짜로 좁힐까요, 검색어로 둘까요?
  ├─ 날짜로  &amp;rarr;  그 해 기간으로 필터 + 키워드 &quot;보고서&quot;
  └─ 검색어로 &amp;rarr;  필터 없이 키워드로 검색&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;749&quot; data-origin-height=&quot;145&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/coSynm/dJMcaiRlWpZ/VCBSQZCvX7jCJFLwTJBb2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/coSynm/dJMcaiRlWpZ/VCBSQZCvX7jCJFLwTJBb2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/coSynm/dJMcaiRlWpZ/VCBSQZCvX7jCJFLwTJBb2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcoSynm%2FdJMcaiRlWpZ%2FVCBSQZCvX7jCJFLwTJBb2K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;749&quot; height=&quot;145&quot; data-origin-width=&quot;749&quot; data-origin-height=&quot;145&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 이 되묻기가 &lt;b&gt;LLM 추론이 아니라는 점&lt;/b&gt;이다. 질문 문구는 정해진 템플릿이고, 분기는 규칙이다. 모델에게 &quot;이 질의를 여러 단계로 추론해줘&quot;라고 맡기는 비싼 멀티홉이 아니라, 애매한 경우에만 뜨는 한두 단계짜리 확인이다. 그래서 같은 답에는 늘 같은 결과가 나오고(결정론), 모델 호출 비용도 들지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 애매함을 다루는 층이 세 겹이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(1)&lt;/b&gt; 확정 가능하면 바로 &lt;b&gt;확정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(2)&lt;/b&gt; 진짜 애매한 좁은 케이스만 &lt;b&gt;되묻고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(3)&lt;/b&gt; 그래도 답이 없으면 &lt;b&gt;키워드로 폴백&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM의 비결정적 추론을 한 줄도 끼우지 않고, 애매함을 사용자의 한 번의 선택으로 결정론적으로 닫은 셈이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 규칙으로 돌아와서 좋았던 것, 감수한 것&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋았던 건 분명했다. LLM 호출이 0이라 빠르고, 같은 질의는 항상 같은 결과를 내며(결정론), 결과가 이상하면 &quot;어느 규칙이 이렇게 잘랐나&quot;를 그대로 추적할 수 있다. 파인튜닝에서 오버피팅으로 흔들리던 표현들도, 모델의 직감이 아니라 슬롯 규칙으로 결정론적으로 다뤘다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 공짜는 아니다. 가장 큰 비용은 &lt;b&gt;사전을 계속 손봐야 한다는 것&lt;/b&gt;이다. 표현을 알아보는 여러 사전과 규칙이 결국 사람이 채우고 갱신해야 하는 것들이라, 새 표현이나 신조어가 들어오면 분석기가 알아서 일반화해주지 않는다. 빠진 걸 발견할 때마다 한 줄씩 메워야 한다. 모델이라면 데이터로 흡수했을 변화를, 규칙 기반에선 운영자가 떠안는 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티홉 규칙도 계속 손 봐야한다. 일단은 사용성 테스트에서 나온 예시들을 기반으로 만들었지만, 당연히 모든 케이스를 커버하지는 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델이 알아서 일반화해주길 기대할 수 없는 대신, 동작을 내가 완전히 통제하고 설명할 수 있는 쪽을 택한 것이다. 검색 본경로처럼 빠르고 예측 가능해야 하는 자리(온디바이스 환경)에는 이 맞교환이 맞다고 생각한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌아보면 길게 돌아온 셈이다. 검색을 개선하려고 평가셋을 다시 짰고, 질의처리가 진짜 문제임을 확인했고, 그걸 작은 LLM으로 풀어보려다 한계를 만났고, 결국 LLM을 걷어내고 규칙 기반으로 정착했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 이 모든 게 &lt;b&gt;온디바이스에서 돌아가야 했다&lt;/b&gt;는 점이 컸다. 서버의 큰 모델을 매 질의마다 부를 수 없는 환경이라, 빠르고 가볍고 예측 가능한 규칙 기반이 결국 더 잘 맞았다. 화려한 방법을 끝까지 밀어본 끝에 고른 게 가장 단순한 쪽이었다는 게, 이 시리즈에서 내가 가장 오래 기억할 부분일 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질의처리기는 결국 똑똑한 모델이 아니라, &lt;b&gt;무엇을 어느 칸에 담을지에 대한 또렷한 기준&lt;/b&gt;으로 완성됐다. &lt;a href=&quot;https://akku-dev.tistory.com/310&quot;&gt;질의처리 글&lt;/a&gt;에서 적어둔 그 한 줄이, 시리즈의 끝에서 그대로 답이 됐다.&lt;/p&gt;</description>
      <category>개발/AI</category>
      <category>kiwi</category>
      <category>query processing</category>
      <category>rag</category>
      <category>가제티어</category>
      <category>규칙 기반</category>
      <category>슬롯</category>
      <category>온디바이스 AI</category>
      <category>질의처리</category>
      <category>형태소분석</category>
      <author>애쿠</author>
      <guid isPermaLink="true">https://akku-dev.tistory.com/316</guid>
      <comments>https://akku-dev.tistory.com/316#entry316comment</comments>
      <pubDate>Fri, 26 Jun 2026 08:35:45 +0900</pubDate>
    </item>
    <item>
      <title>한국어 검색의 바탕, Kiwi 형태소 분석</title>
      <link>https://akku-dev.tistory.com/315</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/D2Lzw/dJMcahdPWki/qcFt2vFY6s0ukmeqYBHadK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/D2Lzw/dJMcahdPWki/qcFt2vFY6s0ukmeqYBHadK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/D2Lzw/dJMcahdPWki/qcFt2vFY6s0ukmeqYBHadK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FD2Lzw%2FdJMcahdPWki%2FqcFt2vFY6s0ukmeqYBHadK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색이든 질의처리든, 한국어를 다루는 일은 결국 &quot;문장을 어떻게 쪼개느냐&quot;에서 시작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://akku-dev.tistory.com/310&quot;&gt;질의처리 글&lt;/a&gt;에서 검색어&amp;middot;필터&amp;middot;제거를 나눈다고 했고, &lt;a href=&quot;https://akku-dev.tistory.com/307&quot;&gt;BM25 글&lt;/a&gt;에서는 질의와 문서를 같은 단어로 맞춰야 점수가 붙는다고 했다. 두 글 모두 바닥에 같은 도구를 깔고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;형태소 분석기 &lt;b&gt;Kiwi&lt;/b&gt;(&lt;a href=&quot;https://github.com/bab2min/Kiwi&quot;&gt;github.com/bab2min/Kiwi&lt;/a&gt;, 파이썬 래퍼는 kiwipiepy)다. 이번 글은 그 바탕을 따로 짚는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 한국어는 띄어쓰기로 안 갈린다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영어는 띄어쓰기가 곧 단어 경계다. &quot;marketing report&quot;는 공백으로 자르면 끝이다. 그런데 한국어는 단어에 조사&amp;middot;어미가 찰싹 붙어 한 덩어리로 다닌다. &quot;마케팅 보고서를 찾아줘&quot;를 그냥 공백으로 자르면 이렇게 된다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;&quot;마케팅 보고서를 찾아줘&quot;
   띄어쓰기로만 자르면 &amp;rarr;  [마케팅]  [보고서를]  [찾아줘]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &quot;보고서를&quot;이다. 이건 &quot;보고서&quot; + &quot;를&quot;인데 통으로 남았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 문서 쪽은 보통 &quot;보고서&quot;로 색인돼 있다. 그러면 검색할 때 &quot;보고서를&quot; &amp;ne; &quot;보고서&quot;라 매칭이 0이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;마케팅&quot;도 질의에선 &quot;마케팅을&quot;, 문서에선 &quot;마케팅&quot;으로 갈리면 같은 단어인데 서로 못 알아본다. 조사 하나 때문에 같은 단어가 다른 토큰으로 보이는 것, 이게 한국어 검색의 1번 함정이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 한국어는 단어를 더 작은 단위로 쪼개서 &quot;보고서&quot;&amp;middot;&quot;마케팅&quot;이라는 &lt;i&gt;같은 형태&lt;/i&gt;로 맞춰줘야 한다. 그 쪼개는 일이 형태소 분석이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 형태소 분석이 하는 일 &amp;mdash; 그리고 왜 어려운가&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;형태소는 &quot;의미를 가진 최소 단위&quot;다. Kiwi에 문장을 넣으면 형태소로 쪼개고 각 조각에 품사를 붙여준다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;&quot;김민수가 작성한 작년 마케팅 보고서 pdf&quot;
   &amp;darr; Kiwi 형태소 분석
김민수[고유명사]  가[조사]  작성[명사]  하[동사파생]  ㄴ[어미]
작년[명사]  마케팅[명사]  보고서[명사]  pdf[외국어]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;i&gt;품사가 붙는다&lt;/i&gt;는 점이다. 어떤 조각이 명사이고 어떤 게 조사&amp;middot;어미인지 알면, &quot;검색어로 쓸 것&quot;과 &quot;버릴 것&quot;을 규칙으로 가를 수 있다. 모델의 직감이 아니라 품사라는 명확한 근거로.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이게 단순한 사전 찾기가 아니다. 한국어는 중의성이 심하다. 같은 글자가 문맥에 따라 전혀 다르게 쪼개진다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;&quot;나는 학교에 간다&quot;   &amp;rarr;  나[대명사]  는[조사]
&quot;하늘을 나는 새&quot;      &amp;rarr;  날[동사]    는[어미]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;나는&quot;이라는 똑같은 글자가, 한쪽은 대명사+조사이고 다른 쪽은 동사다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 형태소 분석기는 앞뒤 문맥을 보고 품사를 정하는 일종의 시퀀스 라벨러에 가깝다. 단어만 보고 기계적으로 자르는 게 아니라, 문장 전체를 보고 가장 그럴듯한 분해를 고른다. Kiwi는 이걸 통계 모델과 사전으로 꽤 정확하게 풀어준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kiwi가 붙이는 품사 태그는 세종 태그셋을 따른다. 종류가 많지만, 검색에서 자주 만나는 것만 추리면 이 정도다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;품사 태그   설명          예시
NNG        일반명사       보고서, 마케팅
NNP        고유명사       김민수, 서울
NP         대명사         나, 우리
NNB        의존명사       것, 수, 개
VV         동사           찾다, 만들다
VA         형용사         빠르다, 작다
MM         관형사         새, 모든
JKS&amp;middot;JKB    격 조사        이/가, 에서, 에게
JX         보조사         은/는, 도, 만
ETM&amp;middot;EF     어미           -ㄴ/-는, -다/-요
SL         외국어         pdf, AI
SN         숫자           5, 2025&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색어로 쓰고 싶은 건 보통 명사 쪽(NNG&amp;middot;NNP)과 외국어(SL)다. 조사(J로 시작)&amp;middot;어미(E로 시작)는 버릴 것이고, 동사&amp;middot;형용사&amp;middot;관형사는 검색어로 두기 애매한 회색지대다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 명사만 골라낸다 &amp;mdash; 검색어 추출&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색어가 될 만한 건 대부분 명사다. 그래서 형태소 중에서 일반명사&amp;middot;고유명사&amp;middot;외국어만 남기고 나머지는 버린다. 코드로 옮기면 의외로 단순하다. 형태소로 쪼갠 뒤, 원하는 품사 태그만 거르면 된다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;from kiwipiepy import Kiwi
kiwi = Kiwi()

tokens = kiwi.tokenize(&quot;김민수가 작성한 작년 마케팅 보고서 pdf&quot;)
# 각 토큰은 .form(표면형)과 .tag(품사)를 가진다

WANT = {&quot;NNG&quot;, &quot;NNP&quot;, &quot;SL&quot;}          # 일반명사&amp;middot;고유명사&amp;middot;외국어
keywords = [t.form for t in tokens if t.tag in WANT]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 한 줄짜리 필터가 형태소 분석으로 검색어를 뽑는 기본 골격이다. 조사&amp;middot;어미는 태그가 J&amp;middot;E로 시작하니 자연히 빠지고, 명사만 남는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이대로면 빈틈이 있다. 예컨대 &quot;작성한&quot;의 &quot;작성&quot;도 일반명사(NNG)로 잡혀 딸려온다. 그래서 실제로는 보정을 몇 겹 더 얹었다. &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;동사로 파생되는 명사(작성&amp;rarr;작성하다처럼 뒤에 동사화 접미사가 붙는 경우)는 빼고, 검색에 의미 없는 흔한 말(불용어)은 거르고, &quot;5G&quot;처럼 숫자와 단위가 붙은 표현은 쪼개지지 않게 묶는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파인튜닝 모델이 &quot;5G&quot;를 &quot;G&quot;로 흘리던 사고도, 여기서는 품사 조합 규칙으로 직접 다룰 수 있었다. &quot;필요한 품사만 고르고, 예외를 규칙으로 메운다&quot; &amp;mdash; 이게 형태소 기반 추출의 전부다. 물론 트레이드오프는 있다. 명사만 남기면 &quot;빠른&quot;, &quot;최신&quot; 같은 수식어나 동사가 담은 정보는 날아간다. &quot;급하게 찾는 보고서&quot;에서 &quot;급하게&quot;는 버려진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 키워드 검색에서는 명사가 의미의 대부분을 지고 있어서, 잃는 것보다 또렷한 검색어를 얻는 쪽이 이득이었다. 무엇을 남기고 버릴지가 분명하니, 결과가 이상할 때 &quot;왜 이 단어가 빠졌나&quot;를 품사로 바로 설명할 수 있는 것도 컸다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 조사를 떼어낸다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름이나 지명에 붙은 조사도 형태소 분석으로 깔끔히 떼어낸다. &quot;서울에서&quot;는 &quot;서울&quot;로, &quot;행정안전부가&quot;는 &quot;행정안전부&quot;로 정리된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1425&quot; data-origin-height=&quot;735&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCWhsq/dJMb99UmTZp/YfsoYW5VFvkk3vyPQNNwJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCWhsq/dJMb99UmTZp/YfsoYW5VFvkk3vyPQNNwJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCWhsq/dJMb99UmTZp/YfsoYW5VFvkk3vyPQNNwJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCWhsq%2FdJMb99UmTZp%2FYfsoYW5VFvkk3vyPQNNwJk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1425&quot; height=&quot;735&quot; data-origin-width=&quot;1425&quot; data-origin-height=&quot;735&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 중요한 이유는, 같은 지명&amp;middot;기관명이 조사 때문에 매번 다른 토큰으로 보이면 필터가 어긋나기 때문이다. &quot;서울에서&quot;&amp;middot;&quot;서울의&quot;&amp;middot;&quot;서울&quot;이 다 같은 &quot;서울&quot;로 모여야 지역 필터가 제대로 걸린다. 작성자도 마찬가지다. &quot;김민수가&quot;&amp;middot;&quot;김민수는&quot;&amp;middot;&quot;김민수&quot;가 따로 놀면 같은 사람을 못 묶는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 신경 쓴 건, 조사만 떼되 원본의 띄어쓰기는 건드리지 않는 것이었다. 무작정 형태소를 이어 붙이면 &quot;미국 라스베이거스에&quot;가 &quot;미국라스베이거스&quot;처럼 뭉쳐버린다. 그래서 조사로 판정된 조각만 들어내고 나머지 공백은 그대로 둬서, &quot;미국 라스베이거스&quot;로 자연스럽게 남게 했다. 사소해 보여도 이런 데서 어긋나면 지명&amp;middot;기관명이 통째로 깨진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 형태소 분석도 만능은 아니다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙 기반의 바탕이라고 해서 완벽하진 않았다. 형태소 분석에도 분명한 한계가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째는 신조어&amp;middot;고유어다.&lt;/b&gt; 사전에 없는 새 단어나 도메인 용어는 엉뚱하게 쪼개진다. 앞서 &quot;5G&quot;가 깨지던 것처럼, 분석기가 모르는 말은 익숙한 조각으로 잘못 분해해버린다. 이건 결국 사용자사전으로 보강해줘야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째는 중의성 오분석이다.&lt;/b&gt; 문맥으로 품사를 고르다 보니 가끔 틀리게 잡는다. 위의 &quot;나는&quot; 같은 경우가 짧은 질의에서 종종 헷갈렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 형태소 분석은 모델처럼 &lt;b&gt;&quot;알아서 일반화&quot;해주지는 않는다.&lt;/b&gt; 모르는 말이 나오면 사람이 사전을 채워줘야 한다. 대신 동작이 투명하고, 틀렸을 때 어디서 틀렸는지 바로 짚어 고칠 수 있다. 검색 본경로처럼 빠르고 예측 가능해야 하는 자리에선 이 통제 가능함이 일반화 능력보다 더 값졌다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 왜 Kiwi였나&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국어 형태소 분석기는 여럿 있다. KoNLPy로 묶이는 Mecab&amp;middot;Okt 같은 것들도 많이 쓰인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그중 Kiwi를 고른 이유는 몇 가지였다. 먼저 설치가 깔끔하다. Mecab은 성능은 좋지만 사전 설치가 환경마다 골치 아픈 걸로 악명 높은데(특히 윈도우 ㅠㅠ), Kiwi는 그런 설치 지옥이 없었다. 그리고 빠르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C++로 구현돼 있어 가볍고, 온디바이스처럼 자원이 빠듯한 환경에서도 부담이 적었다. 사용자사전으로 도메인 복합명사를 추가하기 쉬운 것도, 신조어 한계를 메우는 데 결정적이었다. 무엇보다 LLM 호출 없이 매 질의를 즉석에서 분석할 수 있어서, &quot;빠르고 예측 가능해야 하는&quot; 검색 본경로에 두기 알맞았다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. Kiwi로 더 할 수 있는 것&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번엔 형태소 분석과 명사 추출에만 썼지만, Kiwi의 쓰임은 거기서 끝이 아니다. 한국어 전처리에 두루 쓸 만한 기능이 더 있다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;문장 분리         긴 글을 문장 단위로 자른다 &amp;mdash; 색인 청크 나눌 때 유용
사용자 사전       도메인 복합명사&amp;middot;신조어 등록 &amp;rarr; 오분리 방지 (앞서 5G&amp;middot;복합명사 보정이 이것)
띄어쓰기 교정     띄어쓰기가 엉망인 입력도 정리해준다
오타에 강한 분석   약간의 오타가 섞여도 형태소를 복원하는 모드가 있다
원형 복원         &quot;만든&quot;의 원형 &quot;만들다&quot;처럼 활용형을 기본형으로 되돌린다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Kiwi는 질의 분석뿐 아니라 색인 단계의 토큰화, 문서 텍스트 정제, 문장&amp;middot;청크 분할까지 한국어 파이프라인 곳곳에 한 번 깔아두면 두루 재활용된다. 검색 한 군데 쓰자고 들였는데, 결과적으로 전처리 전반의 공용 도구가 됐다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;형태소 분석은 화려한 기술이 아니다. 논문에 나올 최신 기법도 아니고, 데모에서 박수받을 만한 것도 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 한국어 검색에서는 이게 바닥이다. 검색어 추출도, 작성자 인식도, 슬롯 분해도 결국 &quot;문장을 형태소로 제대로 쪼갰는가&quot; 위에 선다. 바탕이 흔들리면 그 위에 아무리 좋은 검색기&amp;middot;리랭커를 얹어도 흔들린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 모델을 얹기 전에 이 바탕부터 단단히 다지는 게, 돌아보면 가장 효율 좋은 투자였다. 이 위에서 질의를 슬롯과 키워드로 나눈 이야기는 [규칙 기반 질의처리기 글]에서 이어진다.&lt;/p&gt;</description>
      <category>개발/AI</category>
      <category>kiwi</category>
      <category>kiwipiepy</category>
      <category>rag</category>
      <category>검색</category>
      <category>조사 제거</category>
      <category>중의성</category>
      <category>키워드 추출</category>
      <category>한국어 NLP</category>
      <category>형태소분석</category>
      <author>애쿠</author>
      <guid isPermaLink="true">https://akku-dev.tistory.com/315</guid>
      <comments>https://akku-dev.tistory.com/315#entry315comment</comments>
      <pubDate>Tue, 23 Jun 2026 15:59:25 +0900</pubDate>
    </item>
    <item>
      <title>LLM 모델 양자화해보기 Q4와 Q8 사이?</title>
      <link>https://akku-dev.tistory.com/314</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/w2L6G/dJMcab5HOdk/fckNzzPkpBcMuvYw2IrSok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/w2L6G/dJMcab5HOdk/fckNzzPkpBcMuvYw2IrSok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/w2L6G/dJMcab5HOdk/fckNzzPkpBcMuvYw2IrSok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fw2L6G%2FdJMcab5HOdk%2FfckNzzPkpBcMuvYw2IrSok%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://akku-dev.tistory.com/313&quot;&gt;지난 글&lt;/a&gt;에서는 학습한 모델을 GGUF로 바꿔 Ollama에 올렸다. 그때 &quot;GGUF Q8_0으로 변환했다&quot;고 한 줄 적고 넘어갔는데, 사실 그 한 줄에는 숨은 결정이 하나 있었다. &lt;b&gt;어느 정밀도로 양자화할 것인가.&lt;/b&gt; 이번 글은 &lt;a href=&quot;https://akku-dev.tistory.com/304&quot;&gt;RAG 구축 고려사항&lt;/a&gt; 중 ⑦모델 양자화에 해당하는, 그 결정에 대한 이야기다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 양자화는 결국 &quot;비트를 줄이는 일&quot;이다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델은 수십억 개의 가중치, 그러니까 숫자 덩어리다. 이 숫자를 원본은 보통 16-bit 부동소수점(fp16)으로 저장한다. 양자화는 이 숫자를 &lt;b&gt;더 적은 비트로 근사해서 저장&lt;/b&gt;하는 일이다. 8-bit, 4-bit로 누르는 식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이게 크기에 직접 영향을 주냐면, 모델 용량은 거의 &quot;가중치 개수 &amp;times; 가중치 하나당 바이트&quot;로 정해지기 때문이다. 정밀도를 낮추면 가중치 하나가 차지하는 바이트가 그대로 줄어든다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;정밀도        가중치 1개   1.7B 모델 크기(대략)
16-bit (fp16)   2 byte       ~3.4 GB
8-bit  (Q8)     1 byte       ~1.7 GB
4-bit  (Q4)     0.5 byte     ~0.9 GB&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 공짜는 아니다. 비트 수가 줄면 표현할 수 있는 값의 해상도가 떨어진다. 원래 16비트로 촘촘히 표현하던 숫자를 8비트, 4비트 격자에 욱여넣으니 미세한 오차가 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;양자화는 이 &quot;크기 &amp;darr; vs 정밀도 &amp;darr;&quot;의 맞교환이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라우드에서 개발할 때는 모델 크기를 크게 신경 쓰지 않았다. GPU 메모리가 넉넉했으니까. 그런데 온디바이스로 넘어오니 위 표의 숫자가 곧바로 벽이 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.7B를 원본 정밀도로 올리면 그것만 3GB가 넘는다. 여기에 추론 런타임이 올라가고, 사용자가 실제로 쓰는 다른 프로그램도 같이 돌아가야 한다. 일반 PC에 이대로 얹기는 부담스럽다. 학습 때도 마찬가지였다. 무료 Colab T4의 14GB 안에 1.7B를 통째로 올리는 것 자체가 빠듯했다. 그래서 양자화는 &quot;하면 좋은 것&quot;이 아니라 &quot;안 하면 안 올라가는 것&quot;이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 학습할 때 &amp;mdash; 4-bit로 올리고 어댑터만 (QLoRA)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재미있는 건, 이 실험에서 양자화를 두 단계에서 &lt;i&gt;다른 목적으로&lt;/i&gt; 썼다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 학습 단계. T4에 1.7B를 올리려고 &lt;b&gt;베이스 모델을 4-bit로 눌러 동결&lt;/b&gt;한 뒤, 그 위에 작은 LoRA 어댑터만 학습했다. 이게 QLoRA다. 핵심은 무거운 베이스는 4-bit로 메모리만 차지하게 두고, 실제로 학습되는 건 고정밀도의 작은 어댑터(전체의 1% 남짓)뿐이라는 것. 덕분에 원본을 통째로 올리면 안 들어갈 모델도 무료 GPU 한 장에서 파인튜닝할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방법은 의외로 간단하다. 모델을 불러올 때 4-bit 로드 한 줄이면 된다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 학습: 베이스를 4-bit로 올리고 LoRA만 학습 (QLoRA)
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name=&quot;unsloth/Qwen3-1.7B&quot;,
    load_in_4bit=True,        # &amp;larr; 여기서 4-bit 양자화로 로드
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 여기서 양자화의 목적은 &quot;품질&quot;이 아니라 &quot;빠듯한 GPU에 학습을 욱여넣기&quot;였다. 학습이 끝나면 4-bit 베이스는 버리고, 남기는 건 어댑터다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 서빙할 때 &amp;mdash; GGUF로 배포 (Q8_0, Q4_K_M)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습이 끝난 모델은 배포&amp;middot;추론용으로 GGUF 포맷으로 바꿨다. GGUF는 llama.cpp&amp;middot;Ollama 계열에서 쓰는 포맷인데, 내보낼 때 원하는 정밀도를 지정하면 그 정밀도로 양자화해서 저장해준다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 서빙: GGUF로 내보내며 원하는 정밀도로 양자화
model.save_pretrained_gguf(
    &quot;qwen3_filter_gguf&quot;, tokenizer,
    quantization_method=&quot;q8_0&quot;,   # 또는 &quot;q4_k_m&quot;
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일 이름에는 그 정밀도가 그대로 박힌다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Q8_0     8-bit, 단순 양자화. 품질 손실이 거의 없음
Q4_K_M   4-bit K-quant. Q=비트수, K=블록별 스케일로 오차를 줄인 방식, M=중간 크기 변형&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;b&gt;K-quant&lt;/b&gt;가 핵심이다. 그냥 4-bit로 일괄해서 누르면 손실이 크지만, 가중치를 블록으로 나눠 블록마다 스케일을 따로 두면(K-quant) 같은 4-bit라도 오차를 꽤 줄일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 4-bit를 쓸 때도 보통 &lt;code&gt;Q4_K_M&lt;/code&gt; 같은 K-quant 변형을 쓴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습용 4-bit(QLoRA)와 서빙용 GGUF는 도구도 목적도 다르다는 걸 기억해두면 헷갈리지 않는다. 전자는 학습을 GPU에 올리려는 것이고, 후자는 추론을 가볍게 배포하려는 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. Q4냐 Q8이냐 &amp;mdash; 무엇을 깎게 되나&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서빙에서&amp;nbsp;실제로&amp;nbsp;고민한&amp;nbsp;건&amp;nbsp;정밀도였다.&amp;nbsp;더&amp;nbsp;낮게&amp;nbsp;누를수록&amp;nbsp;작아지지만&amp;nbsp;그만큼&amp;nbsp;정보가&amp;nbsp;깎인다.&amp;nbsp;그런데&amp;nbsp;&quot;정보가&amp;nbsp;깎인다&quot;가&amp;nbsp;실제로&amp;nbsp;뭘&amp;nbsp;뜻하는지를&amp;nbsp;봐야,&amp;nbsp;어디까지&amp;nbsp;눌러도&amp;nbsp;되는지가&amp;nbsp;보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kR1hm/dJMcad3sIm8/PSBBbIj9pJmocwVQTllTa0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kR1hm/dJMcad3sIm8/PSBBbIj9pJmocwVQTllTa0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kR1hm/dJMcad3sIm8/PSBBbIj9pJmocwVQTllTa0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkR1hm%2FdJMcad3sIm8%2FPSBBbIj9pJmocwVQTllTa0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1200&quot; height=&quot;720&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가중치를 낮은 비트 격자에 반올림해 넣으면 값 하나하나엔 아주 작은 오차만 생긴다. 문제는 이 미세한 오차가 수십억 개 가중치에 쌓이면서 출력으로 번진다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;b&gt;먼저 무너지는 건 '확실한 답'이 아니라 '아슬아슬한 판단'이다.&lt;/b&gt;누가 봐도 명백한 케이스는 양자화해도 잘 맞히는데, 미묘하게 갈리는 경계 케이스&amp;middot;드문 표현&amp;middot;긴 꼬리 지식부터 흔들린다. 정밀도가 하던 일이 바로 그 미세한 구분이었으니까. &lt;br /&gt;&lt;br /&gt;이 실험처럼 정해진 형식(JSON)을 뱉어야 하는 모델에선 증상이 더 눈에 띄었다. 지시를 끝까지 따르는 힘이 약해져서 포맷이 살짝 깨지거나, 여분 토큰을 흘리거나, 제때 멈추지 못하는 식이다. 뒤(7장)에서 볼 0.6B Q4가 딱 그랬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;깎이는 정도는 &lt;b&gt;어디서 어디로 내리느냐&lt;/b&gt;에 크게 달렸다. fp16에서 Q8로 내리는 구간은 손실이 거의 없다 &amp;mdash; 8비트만 돼도 원본을 충분히 촘촘하게 흉내 낸다. 진짜 손실이 나기 시작하는 건 Q8에서 Q4로 한 번 더 누를 때다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;특히 작은 모델일수록 Q4가 더 아프다.&lt;/b&gt; 파라미터가 적어 애초에 여유가 없는데 정밀도까지 깎으면, 같은 4-bit라도 큰 모델보다 출력이 더 쉽게 흔들린다. 큰 모델은 어느 정도 깎여도 버틸 여력이 있지만, 작은 모델은 그렇지 못하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1290&quot; data-origin-height=&quot;750&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bj5fJJ/dJMcafget4s/y3jh1Fra2flWmQiD66ppr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bj5fJJ/dJMcafget4s/y3jh1Fra2flWmQiD66ppr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bj5fJJ/dJMcafget4s/y3jh1Fra2flWmQiD66ppr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbj5fJJ%2FdJMcafget4s%2Fy3jh1Fra2flWmQiD66ppr0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1290&quot; height=&quot;750&quot; data-origin-width=&quot;1290&quot; data-origin-height=&quot;750&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;서빙&amp;nbsp;기본값은&amp;nbsp;Q8로&amp;nbsp;잡았다.&amp;nbsp;크기를&amp;nbsp;절반으로&amp;nbsp;줄이면서&amp;nbsp;정확도는&amp;nbsp;지키는,&amp;nbsp;가장&amp;nbsp;안전한&amp;nbsp;선이었다.&amp;nbsp;Q4는&amp;nbsp;0.6B&amp;nbsp;같은&amp;nbsp;초소형&amp;nbsp;모델에서,&amp;nbsp;크기를&amp;nbsp;최대한&amp;nbsp;줄여야&amp;nbsp;할&amp;nbsp;때만&amp;nbsp;손실을&amp;nbsp;감수하고&amp;nbsp;조심스럽게&amp;nbsp;썼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서빙용으로 변환해 둔 모델들의 크기는 이랬다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;모델     정밀도    크기       메모
0.6B     Q4_K_M    ~380 MB    초소형, 크기 최소화
0.8B     Q8_0      ~775 MB    주력 (정확도&amp;middot;크기 균형)
1.7B     Q8_0      ~1.75 GB   품질은 좋지만 무거움&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 주력은 &lt;b&gt;0.8B Q8&lt;/b&gt;이었다. 표준 평가셋 정확도가 가장 좋으면서 크기는 1GB 아래라, 온디바이스에 올리기에 균형이 가장 잘 맞았다. 1.7B는 품질이 비슷해도 두 배 이상 무거웠고, 0.6B Q4는 크기는 가장 작지만 품질이 떨어졌다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 작다고 빠른 건 아니었다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 함정이 있었다. 가장 작게 누른 0.6B Q4가, 셋 중 &lt;b&gt;가장 느렸다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;양자화는 모델을 &lt;i&gt;작게&lt;/i&gt; 만들지만, 추론 속도는 크기만으로 정해지지 않는다. 실제 응답 시간은 &quot;토큰을 몇 개나 생성하고 언제 멈추는가&quot;에 더 크게 좌우됐다. 학습이 부족한 0.6B는 JSON을 다 뱉고도 멈추지 못하고 토큰을 계속 만들어내서, 크기가 가장 작은데도 응답이 제일 늦었다. (속도&amp;middot;정확도 수치 비교는 &lt;a href=&quot;https://akku-dev.tistory.com/312&quot;&gt;파인튜닝 결과 글&lt;/a&gt;과 &lt;a href=&quot;https://akku-dev.tistory.com/313&quot;&gt;실습 글&lt;/a&gt;에 있다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니 &quot;양자화로 크기를 줄였다&quot;가 곧 &quot;빨라졌다&quot;는 아니었다. 크기를 줄이는 것과, 원하는 형식으로 짧고 안정적으로 끝내는 것은 별개의 문제였다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 그래서 언제, 어디까지 양자화하나&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하고&amp;nbsp;보니&amp;nbsp;양자화를&amp;nbsp;대하는&amp;nbsp;순서가&amp;nbsp;생겼다. &lt;br /&gt;&lt;br /&gt;먼저, &lt;b&gt;양자화는 기본값이 아니라 강제된 타협이다.&lt;/b&gt; 배포 대상이 원본을 그대로 담을 수 있으면 굳이 누르지 않는 게 최선이다 &amp;mdash; 정밀도를 지키는 쪽이 품질엔 언제나 유리하니까. 양자화는 &quot;이 기기에 이대로는 안 올라간다&quot;가 확인됐을 때 꺼내는 카드였다. &lt;br /&gt;&lt;br /&gt;그다음, 누른다면 &lt;b&gt;가장 덜 아픈 것부터&lt;/b&gt; 시도한다.&lt;/p&gt;
&lt;pre id=&quot;code_1783567626749&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;① fp16 &amp;rarr; Q8          거의 무손실로 크기 절반 &amp;mdash; 대부분 여기서 끝난다
② 그래도 안 들어가면    더 작은 모델을 Q8로      (Q4로 깎기 전에)
③ 그래도 안 되면        마지막에 Q4 &amp;mdash; 단, 평가셋으로 품질이 버티는지 확인하고&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;핵심은 &lt;b&gt;멈추는 기준을 '크기'가 아니라 '품질'로 잡는 것&lt;/b&gt;이다. 더 작게 누를수록 좋아 보여도, 평가셋 정확도가 정해둔 선 밑으로 떨어지면 거기서 멈추고 한 단계 물러선다(Q4 &amp;rarr; Q8, 또는 더 작은 Q8 모델). &lt;b&gt;&quot;가장 작은 것&quot;이 아니라 &quot;품질을 지키면서 가장 작은 것&quot;이 목표&lt;/b&gt;였다. &lt;br /&gt;&lt;br /&gt;학습과&amp;nbsp;서빙의&amp;nbsp;시점이&amp;nbsp;다르다는&amp;nbsp;것도&amp;nbsp;다시&amp;nbsp;짚어둘&amp;nbsp;만하다.&amp;nbsp;학습에서&amp;nbsp;4-bit로&amp;nbsp;누르는&amp;nbsp;건(QLoRA)&amp;nbsp;GPU에&amp;nbsp;올리려는&amp;nbsp;임시&amp;nbsp;조치라&amp;nbsp;품질과&amp;nbsp;무관하고,&amp;nbsp;남기는&amp;nbsp;건&amp;nbsp;고정밀&amp;nbsp;어댑터다.&amp;nbsp;배포&amp;nbsp;품질을&amp;nbsp;실제로&amp;nbsp;결정하는&amp;nbsp;양자화는&amp;nbsp;학습이&amp;nbsp;끝난&amp;nbsp;뒤&amp;nbsp;서빙용&amp;nbsp;GGUF로&amp;nbsp;내보낼&amp;nbsp;때&amp;nbsp;한&amp;nbsp;번&amp;nbsp;더&amp;nbsp;정해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;온디바이스에서&amp;nbsp;양자화는&amp;nbsp;&quot;하면&amp;nbsp;좋은&amp;nbsp;것&quot;이&amp;nbsp;아니라&amp;nbsp;&quot;안&amp;nbsp;하면&amp;nbsp;안&amp;nbsp;올라가는&amp;nbsp;것&quot;이었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 무조건 작게가 답인 줄 알았는데, 정리하고 보니 진짜 기준은 크기가 아니라 &lt;b&gt;품질이 버티는 선까지만 누른다&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 크기를 줄였다고 끝도 아니었다 &amp;mdash; 추론이 짧고 안정적으로 끝나는지까지 봐야 비로소 &quot;온디바이스에 올릴 만하다&quot;고 말할 수 있었다.&lt;/p&gt;</description>
      <category>개발/AI</category>
      <category>GGUF</category>
      <category>K-quant</category>
      <category>Q4</category>
      <category>Q8</category>
      <category>QLoRA</category>
      <category>Quantization</category>
      <category>모델 경량화</category>
      <category>소형 LLM</category>
      <category>양자화</category>
      <category>온디바이스 AI</category>
      <author>애쿠</author>
      <guid isPermaLink="true">https://akku-dev.tistory.com/314</guid>
      <comments>https://akku-dev.tistory.com/314#entry314comment</comments>
      <pubDate>Sat, 20 Jun 2026 17:15:31 +0900</pubDate>
    </item>
    <item>
      <title>무료 Colab으로 소형 LLM 파인튜닝 직접 해보기</title>
      <link>https://akku-dev.tistory.com/313</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cSQj7w/dJMcaiDLqtF/Kyqh9oYG1rOtsdrEeLtMrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cSQj7w/dJMcaiDLqtF/Kyqh9oYG1rOtsdrEeLtMrK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cSQj7w/dJMcaiDLqtF/Kyqh9oYG1rOtsdrEeLtMrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcSQj7w%2FdJMcaiDLqtF%2FKyqh9oYG1rOtsdrEeLtMrK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://akku-dev.tistory.com/312&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;지난 글&lt;/a&gt;에서는 질의처리기를 규칙 기반에서 소형 LLM으로 옮겨보기로 한 이유를 적었다. 말은 그렇게 했지만, 막상 모델을 직접 학습시켜 본 적은 없었다. GPU가 달린 장비도 없었고, 비용을 들이기 전에 일단 &quot;이게 되긴 되는가&quot;부터 확인하고 싶었다. 그래서 무료 Colab T4 한 장으로 소형 모델을 파인튜닝해봤고, 이번 글에는 그 과정을 셀 단위로 적어둔다. 한 번에 끝난 게 아니라 데이터를 늘려가며 네 라운드를 돌렸는데, 처음 57%에서 96%까지 올라간 흐름도 같이 담는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목표는 단순하다. 사용자의 파일 검색 질의에서 필터 조건과 키워드를 JSON으로 뽑아내는 일이다. 예를 들어 &quot;지난달 마케팅팀 보고서 pptx&quot; 같은 문장을 받으면 아래처럼 정리해주면 된다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{&quot;file_types&quot;: [&quot;pptx&quot;], &quot;paths&quot;: [&quot;마케팅팀&quot;], &quot;keywords&quot;: [&quot;보고서&quot;], &quot;owners&quot;: [], &quot;date_expression&quot;: &quot;지난달&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 이 작업을 LLM을 여러 번 호출하는 구조로 처리하고 있는데, 호출이 많아 느리고 매번 결과가 조금씩 달라지는 일관성 문제가 있었다. 이걸 작은 모델 한 번 호출로 줄여보는 게 큰 그림이었다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[기존 구조]
질의 ─▶ LLM 여러 번 호출 ─▶ JSON

[목표 구조]
질의 ─▶ 소형 파인튜닝 모델 (1회) ─▶ JSON&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 왜 QLoRA + Unsloth + 무료 Colab이었나&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 고민한 건 환경이었다. 1.7B 모델이라고 해도 그냥 올리면 T4의 14GB 메모리에 부담스럽다. 그래서 두 가지를 택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나는 &lt;b&gt;4bit 양자화(QLoRA)&lt;/b&gt;다. 모델 가중치를 4bit로 눌러 올리면 T4 한 장에도 들어간다. 다른 하나는 &lt;b&gt;LoRA&lt;/b&gt;다. 전체 파라미터를 다 건드리는 full fine-tuning 대신, 작은 어댑터만 학습한다. 실제로 학습된 파라미터는 전체 17억 개 중 약 1,700만 개, &lt;b&gt;1%&lt;/b&gt;였다. 나머지는 그대로 두고 이 1%만 바꿔서 태스크에 맞춘 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;거기에 &lt;b&gt;Unsloth&lt;/b&gt;를 얹었다. 같은 QLoRA 학습을 더 빠르고 메모리도 덜 쓰게 패치해주는 라이브러리인데, 무료 Colab처럼 자원이 빠듯한 환경에서 특히 도움이 됐다. 정리하면 &lt;b&gt;&quot;무료 T4 + 4bit + LoRA + Unsloth&quot;&lt;/b&gt;가 돈 안 들이고 시작하기에 가장 만만한 조합이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdVvkZ/dJMcabknIC7/fWvluBurbIG4KvRA7A54tK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdVvkZ/dJMcabknIC7/fWvluBurbIG4KvRA7A54tK/img.png&quot; data-origin-width=&quot;437&quot; data-origin-height=&quot;422&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.2815%; margin-right: 10px;&quot; data-widthpercent=&quot;49.86&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdVvkZ/dJMcabknIC7/fWvluBurbIG4KvRA7A54tK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcdVvkZ%2FdJMcabknIC7%2FfWvluBurbIG4KvRA7A54tK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;437&quot; height=&quot;422&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/doMYoX/dJMcaff0zXf/nOTZF8PEqh8STCjgjYJfk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/doMYoX/dJMcaff0zXf/nOTZF8PEqh8STCjgjYJfk0/img.png&quot; data-origin-width=&quot;605&quot; data-origin-height=&quot;581&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.5557%;&quot; data-widthpercent=&quot;50.14&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/doMYoX/dJMcaff0zXf/nOTZF8PEqh8STCjgjYJfk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdoMYoX%2FdJMcaff0zXf%2FnOTZF8PEqh8STCjgjYJfk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;605&quot; height=&quot;581&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;사용량을 초과했거나, 사용률이 많을때 기본 옵션이 CPU로 가있을 수 있어서 확인이 필요하다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 데이터 준비&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습 데이터는 Alpaca 포맷으로 맞췄다. 세 부분으로 나뉘는 익숙한 형태다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;Alpaca 포맷 - 세 부분
  Instruction   파일 검색 질의에서 필터 정보를 JSON으로 추출하라 (고정 지시문)
  Input         실제 사용자 질의 - 예: &quot;사업개발팀 폴더의 인수인계 문서&quot;
  Response      원하는 JSON 출력&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jsonl 한 줄은 instruction(고정 지시문)&amp;middot;input(질의)&amp;middot;output(정답 JSON) 세 키로 구성된다. 대략 이런 식이다.&lt;/p&gt;
&lt;pre class=&quot;taggerscript&quot;&gt;&lt;code&gt;{&quot;instruction&quot;: &quot;&amp;lt;고정 지시문&amp;gt;&quot;, &quot;input&quot;: &quot;사업개발팀 폴더의 인수인계 문서&quot;, &quot;output&quot;: &quot;{\&quot;file_types\&quot;: [], \&quot;paths\&quot;: [\&quot;사업개발팀\&quot;], \&quot;keywords\&quot;: [\&quot;인수인계\&quot;, \&quot;문서\&quot;], \&quot;owners\&quot;: [], \&quot;date_expression\&quot;: null}&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 규칙을 두었다. &lt;b&gt;날짜는 모델이 계산하지 않게 했다.&lt;/b&gt; &quot;지난달&quot;은 언제 검색하느냐에 따라 가리키는 날짜가 달라진다. 6월에 찾으면 5월, 7월에 찾으면 6월이다. 그래서 모델은 &quot;지난달&quot;이라는 말만 그대로 뽑게 하고, 실제 날짜 계산은 검색 시점을 아는 코드가 맡도록 나눴다. 같은 이유로 확장자 정규화(ppt &amp;rarr; pptx)도 모델이 아니라 서비스 로직이 처리한다. 모델은 &quot;질의어를 그대로 잘라내는 일&quot;만 하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 라운드는 339개로 시작했다. 많지 않은 양이지만, 일단 파이프라인이 도는지부터 보는 게 목적이라 욕심내지 않았다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 셀 단위로 따라가기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서부터는 Colab 노트북을 셀 순서대로 적는다. 사전 준비는 두 가지뿐이다. 데이터 jsonl을 구글 드라이브에 올려두고, Colab 런타임을 GPU(T4)로 바꾸는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;패키지 설치&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;!pip install unsloth -q
!pip install datasets trl -q&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;드라이브 마운트&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터를 드라이브에서 읽어오기 때문에 마운트부터 한다. 경로가 틀리면 뒤에서 한참 헤매니 &lt;code&gt;assert&lt;/code&gt;로 파일 존재부터 확인했다.&lt;/p&gt;
&lt;pre class=&quot;moonscript&quot;&gt;&lt;code&gt;from google.colab import drive
drive.mount('/content/drive')

import os
DATA_PATH = '/content/drive/MyDrive/query_filter_sft_v1.jsonl'
assert os.path.exists(DATA_PATH), f'데이터 파일 없음: {DATA_PATH}'
print('데이터 파일 확인 완료')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;모델 로드 (4bit)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;load_in_4bit=True&lt;/code&gt; 한 줄이 핵심이다. 이게 있어야 T4에 올라간다. 최대 시퀀스 길이는 질의가 짧으니 512로 잡았다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from unsloth import FastLanguageModel

MAX_SEQ_LENGTH = 512

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name='unsloth/Qwen3-1.7B',
    max_seq_length=MAX_SEQ_LENGTH,
    load_in_4bit=True,
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 출력 로그에 &lt;code&gt;Tesla T4. Num GPUs = 1. Max memory: 14.563 GB&lt;/code&gt; 같은 줄이 보이면 GPU가 제대로 잡힌 거다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;LoRA 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;r=16&lt;/code&gt;, &lt;code&gt;lora_alpha=32&lt;/code&gt;로 어댑터를 붙였다. 어텐션과 MLP의 주요 projection 모듈에 모두 LoRA를 걸었다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;model = FastLanguageModel.get_peft_model(
    model,
    r=16,
    lora_alpha=32,
    target_modules=[
        'q_proj', 'k_proj', 'v_proj', 'o_proj',
        'gate_proj', 'up_proj', 'down_proj'
    ],
    lora_dropout=0,
    bias='none',
    use_gradient_checkpointing='unsloth',
    random_state=42,
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;데이터 포맷 변환&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jsonl을 읽어서 Alpaca 템플릿 문자열로 합친 뒤 &lt;code&gt;Dataset&lt;/code&gt;으로 만든다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;import json
from datasets import Dataset

ALPACA_TEMPLATE = &quot;&quot;&quot;### Instruction:
{instruction}

### Input:
{input}

### Response:
{output}&quot;&quot;&quot;

def load_jsonl(path):
    with open(path, encoding='utf-8') as f:
        return [json.loads(l) for l in f]

raw = load_jsonl(DATA_PATH)
formatted = [{'text': ALPACA_TEMPLATE.format(**item)} for item in raw]
dataset = Dataset.from_list(formatted)
print(f'데이터 로드: {len(dataset)}개')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;학습&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TRL의 &lt;code&gt;SFTTrainer&lt;/code&gt;로 돌렸다. 3 epoch, 배치 4에 gradient accumulation 4를 줘서 실질 배치 16으로 학습했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from trl import SFTTrainer
from transformers import TrainingArguments

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    dataset_text_field='text',
    max_seq_length=MAX_SEQ_LENGTH,
    args=TrainingArguments(
        num_train_epochs=3,
        per_device_train_batch_size=4,
        gradient_accumulation_steps=4,
        learning_rate=2e-4,
        warmup_ratio=0.05,
        lr_scheduler_type='cosine',
        fp16=True,
        logging_steps=10,
        save_strategy='no',
        seed=42,
        output_dir='/content/qwen3_filter_trainer',
    ),
)
trainer.train()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;339개 데이터, 총 66스텝 학습에 걸린 시간은 &lt;b&gt;92초&lt;/b&gt;였다. 무료 T4로 1분 반 만에 한 라운드가 끝나니 생각보다 가벼웠다.&lt;/p&gt;
&lt;pre id=&quot;code_1781922302538&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  [219/219 05:19, Epoch 3/3]

   Step   Training Loss
     10       2.5462
     20       0.7049
     30       0.3525
     50       0.2607
    100       0.1700
    150       0.1542
    200       0.1283
    210       0.1361

  TrainOutput(global_step=219, train_loss=0.3180, train_runtime=332.9s, epoch=3.0)
  (loss 2.55 &amp;rarr; 0.13으로 수렴, 1,164개 &amp;middot; 219스텝 &amp;middot; 약 5분 20초)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;어댑터 저장&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습한 건 LoRA 어댑터뿐이라 저장 용량도 작다. 드라이브에 어댑터와 토크나이저만 떨궈둔다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;ADAPTER_PATH = '/content/drive/MyDrive/qwen3_filter_adapter'
model.save_pretrained(ADAPTER_PATH)
tokenizer.save_pretrained(ADAPTER_PATH)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 첫 라운드 결과&lt;/b&gt;&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 궁금했던 건 결국 &quot;JSON을 제대로 뱉느냐&quot;였다. 학습이 끝난 모델에 테스트 질의 다섯 개를 넣어봤다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;FastLanguageModel.for_inference(model)

TEST_QUERIES = [
    '지난달 마케팅팀 보고서 pptx',
    '홍길동이 만든 계약서 pdf',
    '2025년 3월 15일 작성된 기획안',
]
for query in TEST_QUERIES:
    prompt = f'### Instruction:\n{INSTRUCTION}\n\n### Input:\n{query}\n\n### Response:\n'
    inputs = tokenizer(prompt, return_tensors='pt').to('cuda')
    outputs = model.generate(**inputs, max_new_tokens=128, temperature=0.1, do_sample=True)
    print(tokenizer.decode(outputs[0][inputs['input_ids'].shape[1]:], skip_special_tokens=True))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 이랬다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;입력: 지난달 마케팅팀 보고서 pptx
출력: {&quot;file_types&quot;: [&quot;pptx&quot;], &quot;paths&quot;: [&quot;마케팅팀&quot;], &quot;keywords&quot;: [&quot;보고서&quot;, &quot;pptx&quot;], &quot;owners&quot;: [], &quot;date_expression&quot;: &quot;지난달&quot;}

입력: 홍길동이 만든 계약서 pdf
출력: {&quot;file_types&quot;: [&quot;pdf&quot;], &quot;paths&quot;: [], &quot;keywords&quot;: [&quot;계약서&quot;], &quot;owners&quot;: [&quot;홍길동&quot;], &quot;date_expression&quot;: null}

입력: 2025년 3월 15일 작성된 기획안
출력: {&quot;file_types&quot;: [], &quot;paths&quot;: [], &quot;keywords&quot;: [&quot;기획안&quot;], &quot;owners&quot;: [], &quot;date_expression&quot;: &quot;2025년 3월 15일&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다섯 개 모두 JSON 파싱에 성공했다. 작성자(&quot;홍길동&quot;)를 owners로, 확장자(&quot;pdf&quot;)를 file_types로 정확히 갈라냈고, 날짜는 의도한 대로 표현 그대로 &lt;code&gt;date_expression&lt;/code&gt;에 들어갔다. 처음으로 &quot;어, 진짜 되네&quot; 싶었던 순간이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 여기서 멈추면 안 된다. 이건 손에 잡히는 다섯 개 예시였을 뿐이고, 평가셋 100개를 제대로 돌려보니 이 첫 라운드(Round 1)의 전체 정확도는 &lt;b&gt;57%&lt;/b&gt; 수준이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표준 어순의 쉬운 질의는 잘 맞췄지만 경로나 복합 조건이 섞인 질의에서 많이 흔들렸다. 심지어 미디어 감지는 기존 규칙 기반(50%)보다 퇴보한 25%였다. 파이프라인이 도는 걸 확인했다는 의미는 있었어도 바로 쓸 수 있는 성능은 아니었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 데이터를 늘려가며 &amp;mdash; 57%에서 96%까지&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 결국 데이터를 늘리는 싸움이 됐다. 평가셋에서 틀리는 패턴을 보고, 그 패턴을 학습 데이터에 보강하고, 다시 돌리는 일을 반복했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1350&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ch7M9d/dJMcagMLwZe/DYqwcOPBLb5Hv2m4xjDEU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ch7M9d/dJMcagMLwZe/DYqwcOPBLb5Hv2m4xjDEU1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ch7M9d/dJMcagMLwZe/DYqwcOPBLb5Hv2m4xjDEU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fch7M9d%2FdJMcagMLwZe%2FDYqwcOPBLb5Hv2m4xjDEU1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1350&quot; height=&quot;720&quot; data-origin-width=&quot;1350&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라운드별로 정리하면 이렇다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;  라운드              모델           데이터    v1    핵심 변화
  ---------------------------------------------------------------------------------
  기존 query_parser   -              -         50%   베이스라인
  Round 1             Qwen3-1.7B     339개     57%   날짜&amp;middot;확장자 개선, 미디어 퇴보
  Round 2             Qwen3-1.7B     563개     67%   미디어 회복, 복합 개선 시작
  Round 3             Qwen3-1.7B     763개     92%   경로&amp;middot;복합 대폭 개선, 실용 도달
  Round 4             Qwen3-1.7B     1,164개   93%   미디어 보강
  Round 4 (best)      Qwen3.5-0.8B   1,164개   96%   전 카테고리 안정화&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 크게 뛴 건 Round 2 &amp;rarr; Round 3 구간이었다. 67%에서 92%로 올랐는데, 이때 경로(25% &amp;rarr; 92%)와 복합 조건(23% &amp;rarr; 77%) 데이터를 집중적으로 채웠다. 데이터가 763개 정도 모이니 비로소 &quot;실용 수준&quot;이라 부를 만한 선에 닿았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흥미로운 건 마지막 줄이다. Round 4에서 데이터는 그대로 둔 채 베이스 모델만 Qwen3-1.7B에서 더 최신 세대인 &lt;b&gt;Qwen3.5-0.8B&lt;/b&gt;로 바꿨더니, 파라미터 수는 절반 이하인데 오히려 93% &amp;rarr; 96%로 올랐다. 모델 크기보다 세대가 더 중요할 수 있다는 걸 여기서 체감했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;데이터를 어떻게 늘렸나&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무작정 양만 늘린 건 아니었다. Round 3에서 92%를 찍고 나서, 실사용 질의를 던져보니 평가셋에는 없던 실패가 보였다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;스프링부트 다니엘이 만든 보고서  &amp;rarr;  owners=['스프링부트 다니엘']   (owner 경계 오염)
백엔드 김철수가 만든 보고서       &amp;rarr;  keywords=[]                  (keyword 누락)
마이클이 올린 도커 설정 파일       &amp;rarr;  file_types=['docker']        (필드 오분류)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 좁혀보니 &lt;b&gt;역순 어순&lt;/b&gt;이 핵심이었다. &quot;김철수가 만든 마케팅 기획서&quot;(정상)는 잘 맞추는데, &quot;마케팅 김철수가 만든 기획서&quot;(역순)처럼 키워드가 이름 앞으로 오면 키워드를 통째로 흘리거나 이름 경계를 잘못 잡았다. 여기에 한글 외래어 이름(&quot;다니엘&quot;, &quot;프레디&quot;)이 겹치면 owner에 앞 단어까지 빨려 들어갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Round 4에서는 이 실패 패턴을 겨눠 300개를 추가했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Round 4에서 추가한 300개
  역순 작성자   50개   순한글 25 + 외래어 25
  역순 + 복합   30개   날짜&amp;middot;파일타입&amp;middot;경로 조합
  다양 주제     20개   마케팅&amp;middot;인사&amp;middot;재무&amp;middot;법무 등 비개발 도메인&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 &quot;틀리는 걸 보고 &amp;rarr; 그걸 데이터로 만들고 &amp;rarr; 다시 학습&quot;하는 루프를 돈 게 결국 점수를 끌어올린 동력이었다. 모델을 더 키우는 것보다 데이터의 다양성을 채우는 쪽이 효과가 컸다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1365&quot; data-origin-height=&quot;765&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDnVkc/dJMcah5X9SY/QhXzk5s7PTfnnHbOw8vEu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDnVkc/dJMcah5X9SY/QhXzk5s7PTfnnHbOw8vEu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDnVkc/dJMcah5X9SY/QhXzk5s7PTfnnHbOw8vEu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDnVkc%2FdJMcah5X9SY%2FQhXzk5s7PTfnnHbOw8vEu0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1365&quot; height=&quot;765&quot; data-origin-width=&quot;1365&quot; data-origin-height=&quot;765&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 모델 크기는 어디까지 줄일 수 있나&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능이 잡힌 뒤엔 &quot;얼마나 작게 가도 되나&quot;를 봤다. 같은 Round 4 데이터로 크기가 다른 세 모델을 비교했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;660&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/p1ysq/dJMcaaFMycY/rD00Nls0JZEErvTIF1TWG1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/p1ysq/dJMcaaFMycY/rD00Nls0JZEErvTIF1TWG1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/p1ysq/dJMcaaFMycY/rD00Nls0JZEErvTIF1TWG1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fp1ysq%2FdJMcaaFMycY%2FrD00Nls0JZEErvTIF1TWG1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1500&quot; height=&quot;660&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;660&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;  모델                  v1 정확도   응답시간(CPU)   생성 토큰   크기
  ---------------------------------------------------------------------
  Qwen3-0.6B            46%         1457ms          203개       ~395MB
  Qwen3-1.7B            93%         707ms           40개        ~1.75GB
  Qwen3.5-0.8B (best)   96%         782ms           39개        ~775MB&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 의외였던 건 가장 작은 0.6B가 오히려 가장 느렸다는 점이다. 학습이 덜 돼서 JSON을 다 뱉고도 멈추지 못하고 토큰을 203개까지 계속 생성했다. 반대로 0.8B와 1.7B는 39~40개에서 깔끔하게 끝났고 CPU 추론에서는 메모리 대역폭이 병목이라 둘의 속도 차이가 75ms에 불과했다. 결국 &lt;b&gt;정확도 96% &amp;middot; 크기 775MB &amp;middot; 속도 782ms&lt;/b&gt;의 Qwen3.5-0.8B가 온디바이스에 올리기 가장 균형 잡힌 선택이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서빙은 GGUF Q8_0으로 변환해 Ollama에 올렸다. 등록하고 나면 이렇게 한 줄로 돌려볼 수 있다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;ollama run qwen3-filter-0.8b &quot;작년 팀장님이 작성한 pdf 제안서&quot;

입력: 작년 팀장님이 작성한 pdf 제안서
출력: {&quot;file_types&quot;: [&quot;pdf&quot;], &quot;keywords&quot;: [&quot;제안서&quot;], &quot;owners&quot;: [&quot;팀장님&quot;], &quot;date_expression&quot;: &quot;작년&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. 마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업으로 확인한 건 세 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, &lt;b&gt;무료 Colab으로도 소형 모델 파인튜닝 진입장벽이 생각보다 낮았다.&lt;/b&gt; 4bit + LoRA + Unsloth 조합이면 1.7B 모델도 T4 한 장에서 1분 반이면 한 라운드가 돈다. GPU 장비나 비용 없이 &quot;되긴 되는가&quot;를 확인하기에 충분했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, &lt;b&gt;점수를 올린 건 모델이 아니라 데이터였다.&lt;/b&gt; 57% &amp;rarr; 96%까지의 대부분은 &quot;틀리는 패턴을 데이터로 메우는&quot; 반복에서 나왔다. 특히 역순 어순처럼 실사용에서만 드러나는 실패는, 평가셋을 실제 입력에 가깝게 늘려야 비로소 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, &lt;b&gt;작아도 충분할 수 있다.&lt;/b&gt; 세대가 최신이면 0.8B로도 1.7B를 앞섰고, 온디바이스에 올리기에 크기&amp;middot;속도&amp;middot;정확도가 모두 납득할 만한 선이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 남은 숙제도 있다. owners 필드는 실제 메타데이터(&lt;code&gt;Gildong Hong&lt;/code&gt;)와 사용자 표현(&quot;길동&quot;, &quot;팀장님&quot;)이 달라 계정 매핑 없이는 신뢰하기 어렵고, 역순&amp;middot;외래어 같은 예외 표현(v2) 점수는 끝까지 낮았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이 실험은 여기서 멈췄다. 점수를 끌어올린 방식이 &quot;틀리는 패턴을 데이터로 메우는&quot; 루프였는데, 예외 표현 쪽은 데이터를 아무리 더해도 잘 따라오지 않았다. 어느 순간부터는 모델이 일반화되는 게 아니라 평가셋에 맞춰 오버피팅되는 느낌이 들었다. 데이터를 계속 붓는 게 정말 모델을 좋게 만드는 건지 확신이 서지 않아 거기서 손을 뗐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 &quot;규칙 기반 50%&quot;에서 &quot;소형 모델 96%&quot;까지 온 건, 무료 GPU 한 장에서 시작한 것치고는 충분히 와볼 만한 거리였다.&lt;/p&gt;</description>
      <category>개발/AI</category>
      <category>Google Colab</category>
      <category>llm 파인튜닝</category>
      <category>LoRa</category>
      <category>QLoRA</category>
      <category>qwen</category>
      <category>rag</category>
      <category>SFT</category>
      <category>unsloth</category>
      <category>소형 LLM</category>
      <category>온디바이스 AI</category>
      <author>애쿠</author>
      <guid isPermaLink="true">https://akku-dev.tistory.com/313</guid>
      <comments>https://akku-dev.tistory.com/313#entry313comment</comments>
      <pubDate>Sat, 20 Jun 2026 11:36:58 +0900</pubDate>
    </item>
    <item>
      <title>RAG 검색 개선기: 질의처리기 모델 파인튜닝하기</title>
      <link>https://akku-dev.tistory.com/312</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tpeX8/dJMb990TN0C/NQl0KipgkikWSJWbeTD57k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tpeX8/dJMb990TN0C/NQl0KipgkikWSJWbeTD57k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tpeX8/dJMb990TN0C/NQl0KipgkikWSJWbeTD57k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtpeX8%2FdJMb990TN0C%2FNQl0KipgkikWSJWbeTD57k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://akku-dev.tistory.com/311&quot;&gt;지난 글&lt;/a&gt;에서는 질의처리기를 테스트하기 위한 테스트셋을 어떻게 만들었는지 정리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트셋을 만들고 나니 다음 질문이 생겼다. 이 테스트셋을 기준으로 작은 모델을 학습시키면, 룰 기반 질의처리기를 대체하거나 보완할 수 있을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 본경로에 큰 LLM을 그대로 넣기는 어려웠다. 프롬프팅만으로 질의처리를 맡겼을 때 정확도는 50%대에 머물렀고, 온디바이스 환경에서는 속도 부담도 컸다. 하지만 질의처리 작업 자체는 범위가 비교적 명확했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자의 입력에서 검색 키워드&amp;middot;날짜&amp;middot;파일 타입&amp;middot;작성자&amp;middot;경로 같은 정보를 JSON으로 뽑아내면 된다. 자유로운 답변을 생성하는 것보다 훨씬 좁은 문제였다. 그래서 작은 모델을 이 작업에 맞게 파인튜닝해보면 어떨까 생각했다. 이번 글은 &lt;a href=&quot;https://akku-dev.tistory.com/304&quot;&gt;RAG 구축 고려사항&lt;/a&gt; 중 모델 양자화에도 닿는 실험이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 목표는 답변이 아니라 구조화였다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 모델에게 검색 결과를 설명하거나 답변을 생성하게 하려던 건 아니었다. 목표는 훨씬 좁았다. 사용자의 질의를 받아서 검색에 필요한 구조화된 정보를 JSON으로 뽑는 것. 모델이 할 일은 답을 만드는 게 아니라 검색에 필요한 조건을 분리하는 것이었다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;&amp;gt; 작년 팀장님이 작성한 pdf 제안서 찾아줘
{ &quot;keywords&quot;: [&quot;제안서&quot;], &quot;date&quot;: &quot;작년&quot;, &quot;file_type&quot;: &quot;pdf&quot;, &quot;author&quot;: &quot;팀장님&quot; }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 구조화 결과가 안정적으로 나오면 검색기는 이 정보를 바탕으로 더 명확하게 문서를 찾을 수 있다. 즉 이번 파인튜닝의 목표는 LLM을 검색기로 만드는 게 아니라, 검색기 앞단의 질의처리기를 만드는 것이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 출력 스키마를 먼저 고정했다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파인튜닝을 하려면 모델이 어떤 형식으로 답해야 하는지부터 정해야 했다. 질의처리 결과는 자유 텍스트가 아니라 JSON이어야 했다. 그래야 검색 파이프라인에서 바로 쓸 수 있고 테스트도 자동화된다. 그래서 출력 스키마를 먼저 고정하고, 모델은 입력 문장을 이 필드들로 변환하게 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 중요한 결정이 하나 있었다. &lt;b&gt;날짜 계산을 모델에게 맡기지 않은 것이다.&lt;/b&gt; &quot;지난달&quot;, &quot;작년 3월&quot; 같은 표현을 모델이 바로 절대 날짜로 바꿀 수도 있지만, 그렇게 하면 검색 시점이 달라질 때 결과도 달라지고 테스트도 불안정해진다. 그래서 모델은 날짜 표현만 추출하고, 실제 날짜 범위 계산은 후처리가 담당하게 나눴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델이 모든 걸 해결하게 하기보다, 모델이 잘하는 부분과 코드가 안정적으로 처리할 부분을 가른 셈이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 프롬프팅만으로는 50%대였다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 Qwen3 1.7B에 프롬프팅만 적용해 질의처리를 시켜봤다. 결과는 좋지 않았다. 정확도는 50%대에 머물렀고, 검색 본경로에 넣기엔 불안정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 복합 조건에서 많이 흔들렸다. 날짜&amp;middot;작성자&amp;middot;파일 타입&amp;middot;키워드가 함께 들어오면 일부 필드는 맞히지만 다른 필드를 놓쳤다. 파일 타입을 검색 키워드에 섞거나, 작성자를 일반 키워드처럼 처리하거나, 날짜 표현을 잘못 해석하는 식이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 자체는 작아 보였지만, 실제로는 검색 도메인에 맞는 출력 규칙을 엄격하게 학습해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프팅만으로는 어렵다고 판단했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 실패 유형을 보고 데이터를 보강했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파인튜닝은 한 번에 완성하기보다, 실패 유형을 보고 데이터를 보강하는 방식으로 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1290&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9GOrn/dJMcabknPwD/X1ZaHxfPiroEeiVis1mKRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9GOrn/dJMcabknPwD/X1ZaHxfPiroEeiVis1mKRk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9GOrn/dJMcabknPwD/X1ZaHxfPiroEeiVis1mKRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9GOrn%2FdJMcabknPwD%2FX1ZaHxfPiroEeiVis1mKRk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1290&quot; height=&quot;720&quot; data-origin-width=&quot;1290&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Round1에서는 기본 출력 형식과 주요 필드를 맞추는 데 집중했다. Round2에서는 약했던 유형, 특히 취약 카테고리를 보강했다. Round3에서는 데이터를 더 채워 전체 정확도가 90%대까지 올라갔고, 이때부터 질의처리 전용 모델로 쓸 수 있겠다는 가능성이 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 파인튜닝은 모델이 틀린 유형을 보고, 그 유형을 다시 데이터로 만들고, 다시 테스트하는 반복 과정에 가까웠다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 작은 모델이 더 좋았다, 다만 속도는 단순하지 않았다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흥미로운 건 모델 크기와 세대에 따른 차이였다. 처음엔 당연히 파라미터가 큰 Qwen3 1.7B가 나을 줄 알았다. 그런데 Round4에서 Qwen3.5 0.8B를 테스트해보니 0.8B가 1.7B보다 더 좋은 결과를 냈고, 96%까지 올라갔다. 모델 크기보다 베이스 모델의 세대와 데이터 품질이 더 중요할 수 있다는 걸 확인한 지점이었다. 온디바이스에서는 작은 모델이 메모리&amp;middot;배포 면에서 훨씬 유리하니, 이 결과는 꽤 의미가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 속도는 생각보다 단순하지 않았다. tokens/sec만 보면 작은 모델이 유리해 보이지만, 실제 응답 시간은 생성 토큰 수와 종료 여부에 크게 좌우됐다. 학습이 부족한 모델은 JSON을 다 출력하고도 불필요한 토큰을 계속 만들며 오히려 느려졌다. 작은 모델이 빠르려면 크기만 작아서는 부족하고, 원하는 형식으로 짧고 안정적으로 끝내는 것까지 돼야 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 어려운 테스트셋에서 무너졌다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 결과만 보면 파인튜닝은 꽤 성공적으로 보였다. 그런데 더 어려운 테스트셋을 돌려보니 상황이 달라졌다. 어려운 셋에는 역순 표현, 외래어, 마케팅&amp;middot;인사&amp;middot;재무&amp;middot;법무 같은 다양한 도메인, 복합 조건이 더 많이 들어갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;990&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MnDnP/dJMcaa6Qh80/ObqLn5C1HK6VyAc6JsQlE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MnDnP/dJMcaa6Qh80/ObqLn5C1HK6VyAc6JsQlE1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MnDnP/dJMcaa6Qh80/ObqLn5C1HK6VyAc6JsQlE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMnDnP%2FdJMcaa6Qh80%2FObqLn5C1HK6VyAc6JsQlE1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;990&quot; height=&quot;720&quot; data-origin-width=&quot;990&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 정보가 앞에 안 나오거나, 조건 순서가 바뀌거나, 낯선 도메인 표현이 섞이면 성능이 크게 떨어졌다. Round3 1.7B는 19%, Round4 0.8B도 27% 수준이었다. v1에서 90%를 넘겼던 것과 비교하면 큰 차이였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 결과는 조금 아팠지만 중요한 사실을 보여줬다. 모델이 좋아진 게 아니라 &lt;b&gt;테스트셋에 익숙해진 것일 수도 있다&lt;/b&gt;는 점이다. 학습 데이터와 비슷한 표현에서는 잘 동작하지만 순서나 도메인이 바뀌면 금방 흔들렸다. 그래서 v1 점수만 보고 실서비스 투입이 가능하다고 판단하는 건 위험했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. 그래도 실패는 아니었다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다고 이 실험이 실패였다고 보지는 않았다. 작은 모델이 질의처리 JSON을 안정적으로 뽑을 수 있다는 가능성은 확인했다. 자유로운 답변 생성이 아니라 정해진 스키마에 맞춰 검색 조건을 추출하는, 범위가 좁은 태스크라면 작은 모델도 꽤 잘했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 역할을 명확히 해야 했다. 이 모델이 곧바로 검색 품질을 개선한다고 말할 수는 없다. 질의처리 결과가 좋아졌다고 해서 최종 검색 결과가 좋아졌다는 뜻은 아니기 때문이다. 그건 다시 검색 테스트와 E2E 테스트로 확인해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이 단계의 결론은 이 정도였다. 작은 모델로 질의처리기는 만들 수 있다. 하지만 검색을 완전히 맡기기보다, 룰 기반 질의처리기를 보완하거나 후보를 제안하는 역할이 더 현실적이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 프롬프팅만으로도 어느 정도 될 줄 알았지만 50%대에 머물렀고, 출력 스키마를 고정하고 실패 유형으로 데이터를 보강하며 파인튜닝한 끝에 Qwen3.5 0.8B가 96%까지 올라갔다. 작은 모델도 구조화 추출 태스크에서는 충분히 가능성이 있었다. 하지만 어려운 테스트에서는 27%로 떨어졌고, 그걸 보며 다시 처음 질문으로 돌아왔다. &lt;b&gt;무엇을 기준으로 좋아졌다고 말해야 할까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질의처리 정확도가 올라간 것과 실제 검색 품질이 좋아진 건 같은 말이 아니다. 좋은 검색 품질은 질의처리 결과만으로 되지 않는다. 질의처리가 빨라야 하고, 핵심 키워드가 검색기에 잘 전달돼야 하고, 검색 대상 문서도 검색하기 좋은 형태로 정리돼 있어야 한다. 질의처리&amp;middot;검색기&amp;middot;문서 데이터가 함께 맞물려야 사용자가 체감하는 품질이 좋아진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 이번 시리즈는 검색 시스템을 개선한 기록이라기보다, &lt;b&gt;&quot;개선됐다&quot;고 말하기 위한 기준을 하나씩 만들어간 과정&lt;/b&gt;에 가까웠다.&lt;/p&gt;</description>
      <category>개발/AI</category>
      <category>LLM</category>
      <category>qwen</category>
      <category>rag</category>
      <category>SFT</category>
      <category>질의처리</category>
      <category>파인튜닝</category>
      <author>애쿠</author>
      <guid isPermaLink="true">https://akku-dev.tistory.com/312</guid>
      <comments>https://akku-dev.tistory.com/312#entry312comment</comments>
      <pubDate>Fri, 29 May 2026 15:09:25 +0900</pubDate>
    </item>
  </channel>
</rss>