LLM 엔지니어링7장 · 자체 임베딩과 벡터DB, 검색 품질20 / 22← 이전목차다음 →MetaContents

7장. 자체 임베딩과 벡터DB, 검색 품질

프로젝트 1 — 사내 규정 검색기와 모델 비교표

가상 회사 "솔내기술"의 사내 규정집(company_rules.txt, 장 7개, 조항 30개)으로 검색기를 만들고, Ollama 의 오픈소스 LLM 네 개를 같은 질문으로 견주는 비교표를 만들어 보세요. 질문은 company_rules_qa.jsonl 의 25개(규정에 있는 20개, 없는 5개)입니다. 회사와 규정은 모두 연습용으로 지어낸 것입니다.

파일생김새
company_rules.txt"제2장 휴가" 같은 장 제목 줄 아래 "제5조(연차) …" 같은 조항 줄이 이어진다
company_rules_qa.jsonl질문, 정답열쇠, 근거조항(조항 제목, 규정에 없으면 빈 글자)

요구사항은 다섯 가지입니다.

  1. 조항 한 줄을 조각으로 삼고, 조각 앞에 장 제목을 붙입니다.
  2. 의미 검색과 키워드 검색을 가중 합(α = 0.7)으로 섞고, 위 10개를 크로스 인코더로 재정렬하는 검색기를 만듭니다. 재정렬 전후의 hit@1, hit@3 을 잽니다.
  3. 검색한 조항 3개를 붙여 qwen2.5:1.5b, qwen2.5:3b, gemma3:1b, llama3.2:1b 에 같은 프롬프트로 묻습니다.
  4. 모델마다 정답, 출처 맞음, 지나친 거절, 모름 맞음, 걸린 시간을 세어 비교표를 만들고 라이선스 열을 붙입니다.
  5. 표를 보고 이 서비스에 쓸 모델 하나를 고르고 까닭을 적습니다.

단계별 힌트입니다.

  1. 조항 나누기와 출처 세기는 6장 프로젝트의 코드를 그대로 씁니다.
  2. 프롬프트는 6장 프로젝트의 "둘째 문구"(관련된 조항이 하나도 없을 때만 모른다고 답하기)를 씁니다.
  3. 모델마다 시간을 재기 전에 앞 모델을 GPU 에서 내립니다. Ollama 의 /api/generate 에 {"model": 이름, "keep_alive": 0} 을 보내면 바로 내립니다.
  4. 교안을 만든 PC 에서는 모델을 여러 번 올리고 내리다 보면, 어떤 모델이 올라온 뒤 모든 요청에 prediction aborted 오류를 내는 일이 가끔 있었습니다. 내렸다가 다시 올리면 풀렸으므로, 오류가 나면 모델을 내리고 같은 요청을 다시 보냅니다. 세 번 해도 안 되면 "[오류]"로 적어 둡니다.
  5. 숫자만 보지 말고 모델마다 답을 몇 개 읽어 봅니다. 한국어가 어색하거나 근거를 지어내는 모델이 있습니다.

확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.

실제 데이터로 해 보려면

사내 규정은 회사마다 다르지만, 공개된 규정과 질문 묶음으로 같은 실험을 해 볼 수 있습니다. 자료마다 이용 조건을 확인하세요.

자료구하는 곳내용
공공기관 내부 규정각 공공기관 홈페이지의 "내부규정" 또는 ALIO(공공기관 경영정보 공개시스템)복무, 여비, 보수 규정 같은 실제 규정. 조와 항으로 나뉘어 있다
한국어 검색 평가 데이터AI Hub(aihub.or.kr)에서 "기계독해", "질의응답"으로 검색문서와 질문, 정답 위치가 함께 있어 hit@k 를 잴 수 있다. 회원 가입과 이용 신청이 필요하다
공개 임베딩 순위표Hugging Face 의 MTEB 순위표언어별 임베딩 모델 성적. 한국어 과제를 골라 후보 모델을 추린다

코드의 흐름

예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.

검색 준비, 검색기, 네 모델, 비교표의 네 상자를 화살표로 이은 사내 규정 검색기 프로젝트의 코드 흐름도

주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 company_rules.txt 를 조항 조각 30개로 나눠 검색을 준비하고, 2단계(마젠타)에서 minmax 와 search 로 하이브리드와 재정렬 검색기를 만들어 hit@1 을 잽니다. 3단계(다홍)는 검색을 한 번만 하고 MODELS 의 네 모델에 ask 로 똑같이 물으며 모델마다 unload 로 GPU 를 비웁니다. 4단계(빨강)는 정답, 출처, 속도에 라이선스를 함께 붙인 비교표를 pd.DataFrame 으로 만듭니다.

결과 보기 (예시 풀이)

1단계. 조항 조각과 검색 준비

Python
import json, re, time, urllib.request
import numpy as np
import pandas as pd
from sentence_transformers import SentenceTransformer, CrossEncoder
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

pieces, chapter = [], ""
for line in open("company_rules.txt", encoding="utf-8").read().split("\n"):
    if re.match(r"^제\d+장 ", line):
        chapter = line
    elif line.startswith("제") and "조(" in line:
        m = re.match(r"제(\d+)조\((.+?)\)", line)
        pieces.append({"조": int(m.group(1)), "제목": m.group(2), "글": chapter + " " + line})   # 장 제목을 앞에 붙인다
qa = [json.loads(line) for line in open("company_rules_qa.jsonl", encoding="utf-8")]
title_to_no = {p["제목"]: p["조"] for p in pieces}
docs = [p["글"] for p in pieces]

# 임베딩과 재정렬 모델은 CPU 에 둔다. GPU 는 3단계의 Ollama 모델에 비워 둔다
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
ce = CrossEncoder("cross-encoder/mmarco-mMiniLMv2-L12-H384-v1", device="cpu")
E = emb.encode(["passage: " + d for d in docs], normalize_embeddings=True)
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
X = vec.fit_transform(docs)
print("조각", len(docs), "개 ·", docs[4])
출력
조각 30 개 · 제2장 휴가 제5조(연차) 입사 첫해에는 한 달 개근할 때마다 연차 하루가 생깁니다. 1년을 채우면 15일이 생기고, 2년마다 하루씩 늘어 최대 25일까지 생깁니다.

2단계. 하이브리드와 재정렬 검색기

Python
def minmax(a):
    return (a - a.min()) / (a.max() - a.min())

def search(question, k=3, rerank=True):
    """가중 합(α=0.7) 하이브리드로 위 10개를 찾고, rerank 면 크로스 인코더로 다시 줄 세운다"""
    sem = E @ emb.encode("query: " + question, normalize_embeddings=True)
    kw = cosine_similarity(vec.transform([question]), X)[0]
    cand = np.argsort(-(0.7 * minmax(sem) + 0.3 * minmax(kw)))[:10].tolist()
    if rerank:
        s = ce.predict([(question, docs[i]) for i in cand])
        cand = [cand[i] for i in np.argsort(-s)]
    return cand[:k]

inside = [r for r in qa if r["근거조항"]]
for rr in [False, True]:
    found = [[pieces[i]["조"] for i in search(r["질문"], rerank=rr)] for r in inside]
    gold = [title_to_no[r["근거조항"]] for r in inside]
    h1 = sum(f[0] == g for f, g in zip(found, gold))
    h3 = sum(g in f for f, g in zip(found, gold))
    print("재정렬" if rr else "하이브리드", f"hit@1 {h1}/20 · hit@3 {h3}/20")
출력
하이브리드 hit@1 17/20 · hit@3 20/20
재정렬 hit@1 19/20 · hit@3 19/20

3단계. 네 모델에 묻기

Python
from openai import OpenAI, APIError

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
SYSTEM = ("당신은 솔내기술 인사팀의 안내 직원입니다. 아래 [규정] 조항을 읽고 질문에 한두 문장으로 답하세요. "
          "질문과 관련된 조항이 있으면 그 조항의 내용으로 답하고, 답 끝에 (제N조) 꼴로 근거를 적으세요. "
          "관련된 조항이 하나도 없을 때만 '규정에서 찾지 못했습니다.'라고 답하세요.")
REFUSE = ["찾지 못", "찾을 수 없", "나와 있지 않", "제공되지 않"]
# 검색은 한 번만 하고, 네 모델에 똑같은 프롬프트를 넣는다
prompts = [[{"role": "system", "content": SYSTEM},
            {"role": "user", "content": "[규정]\n" + "\n".join(docs[i] for i in search(r["질문"])) + "\n\n[질문]\n" + r["질문"]}]
           for r in qa]

def unload(model):
    """Ollama 에 keep_alive=0 을 보내 모델을 GPU 에서 바로 내린다"""
    body = json.dumps({"model": model, "keep_alive": 0}).encode()
    urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", data=body,
                                                  headers={"Content-Type": "application/json"})).read()

def ask(model, messages):
    for _ in range(3):
        try:
            resp = client.chat.completions.create(model=model, messages=messages, temperature=0, seed=42, max_tokens=150)
            return resp.choices[0].message.content.strip()
        except APIError as e:
            error = e.message[:40]
            unload(model)              # 오류가 나면 모델을 내렸다가(다음 요청 때 새로 올라온다) 다시 보낸다
            time.sleep(2)
    return f"[오류] {error}"

MODELS = ["qwen2.5:1.5b", "qwen2.5:3b", "gemma3:1b", "llama3.2:1b"]
answers, seconds = {}, {}
for m in MODELS:
    ask(m, [{"role": "user", "content": "안녕하세요"}])   # 모델을 먼저 올려 둔다
    start = time.time()
    answers[m] = [ask(m, p) for p in prompts]
    seconds[m] = round(time.time() - start, 1)
    unload(m)                                            # 다음 모델을 위해 GPU 를 비운다
    print(m, seconds[m], "초 · 오류", sum(a.startswith("[오류]") for a in answers[m]), "개")
출력
qwen2.5:1.5b 35.9 초 · 오류 0 개
qwen2.5:3b 57.6 초 · 오류 0 개
gemma3:1b 31.9 초 · 오류 0 개
llama3.2:1b 29.3 초 · 오류 0 개

4단계. 비교표 만들기

Python
LICENSE = {"qwen2.5:1.5b": "Apache 2.0", "qwen2.5:3b": "Qwen Research",
           "gemma3:1b": "Gemma 이용 약관", "llama3.2:1b": "Llama 3.2 커뮤니티"}
rows = []
for m in MODELS:
    c = {"정답(/20)": 0, "출처 맞음(/20)": 0, "지나친 거절": 0, "모름 맞음(/5)": 0}
    for r, a in zip(qa, answers[m]):
        refused = any(w in a for w in REFUSE)
        if r["근거조항"]:
            c["정답(/20)"] += any(k in a for k in r["정답열쇠"].split("|"))
            c["출처 맞음(/20)"] += str(title_to_no[r["근거조항"]]) in re.findall(r"제\s*(\d+)\s*조", a)
            c["지나친 거절"] += refused
        else:
            c["모름 맞음(/5)"] += refused
    rows.append({"모델": m, **c, "25문항 초": seconds[m], "라이선스": LICENSE[m]})
print(pd.DataFrame(rows).to_string(index=False))

# 규정에 없는 질문 하나와 있는 질문 하나의 네 답을 읽어 본다
for q in ["명절 선물은 무엇을 주나요?", "부산 출장 가면 호텔비는 얼마까지 나와요?"]:
    n = next(i for i, r in enumerate(qa) if r["질문"] == q)
    print("질문:", q)
    for m in MODELS:
        print(f"  {m:13s}", answers[m][n].replace("\n", " ")[:80])
출력
          모델  정답(/20)  출처 맞음(/20)  지나친 거절  모름 맞음(/5)  25문항 초           라이선스
qwen2.5:1.5b       17          13       0          2    35.9     Apache 2.0
  qwen2.5:3b       17          17       2          4    57.6  Qwen Research
   gemma3:1b       14           8       4          5    31.9    Gemma 이용 약관
 llama3.2:1b       14          11       0          0    29.3 Llama 3.2 커뮤니티
질문: 명절 선물은 무엇을 주나요?
  qwen2.5:1.5b  [규정] 제4장 경비 제17조(경비 정산) 경비는 쓴 달의 다음 달 5일까지 영수증과 함께 정산합니다. 늦으면 개인 비용으로 처리합니다.
  qwen2.5:3b    규정에서 찾지 못했습니다.
  gemma3:1b     규정에서 찾지 못했습니다.
  llama3.2:1b   명절 선물은 본인과 배우자에게 선물하는 것을 의미합니다.
질문: 부산 출장 가면 호텔비는 얼마까지 나와요?
  qwen2.5:1.5b  부산 출장의 숙박비는 8만원까지 나와 있습니다. (제3장 출장 제11조)
  qwen2.5:3b    부산은 서울과 광역시가 아닌 다른 지역이므로 제8조(출장비)에 따라 하룻밤 최대 8만원까지 줍니다. (제3조 제8조)
  gemma3:1b     부산 출장 시 호텔비는 숙박비에 따라 달라집니다. 서울과 광역시는 하룻밤 10만원, 그 밖의 지역은 8만원까지 실제 쓴 만큼 줍니다.  (제2)
  llama3.2:1b   제3장 출장 제10조(국내 출장비)에서, 국내 출장의 일비는 하루 3만원입니다. 부산 출장은 지역 등급이 상당히 높은 곳이기 때문에, 숙박비는

검색기는 하이브리드만으로 hit@3 이 20/20 이었고, 재정렬을 더하자 hit@1 은 17 에서 19 로 올랐지만 hit@3 은 19 로 하나 줄었습니다. 첫째 조각만 붙인다면 재정렬이, 세 조각을 붙인다면 하이브리드만으로도 충분합니다. 같은 검색 결과를 붙였을 때 정답은 qwen2.5:1.5b 와 qwen2.5:3b 가 17개, gemma3:1b 와 llama3.2:1b 가 14개였습니다. 차이는 다른 열에서 더 크게 납니다. 출처를 맞게 적은 것은 3B 가 17개로 가장 많고 gemma 는 8개뿐이며, 규정에 없는 질문 5개에 모른다고 한 것은 gemma 5개, 3B 4개, 1.5B 2개, llama 0개입니다. llama 는 "명절 선물은 본인과 배우자에게 선물하는 것"처럼 없는 규정을 지어냈습니다. 답을 읽어 보면 숫자에 안 잡히는 잘못도 있습니다. "부산 출장 호텔비"의 답은 광역시 기준 10만원인데, qwen 두 모델은 부산을 광역시가 아니라고 보고 8만원이라 답했고, gemma 는 두 기준을 다 늘어놓아 열쇠 낱말 덕에 정답으로 셌습니다. 문서에 없는 상식(부산은 광역시)이 필요한 질문은 작은 모델이 자주 틀립니다. 고르는 기준도 표 밖에 있습니다. 정답과 출처, 모를 때의 답이 가장 고른 것은 qwen2.5:3b 지만 Qwen Research 라이선스라 회사 서비스에 바로 쓰기 어렵고, Apache 2.0 인 qwen2.5:1.5b 는 정답은 같지만 없는 질문에 지어내는 일이 많아 "모를 때"를 막는 장치(검색 점수 기준, 프롬프트 다듬기)를 더해야 합니다. 시간은 이 PC 에서 25문항에 29~58초였고, 오류가 나서 모델을 다시 올린 시간이 들어 있을 수 있어 실행마다 달라집니다. 교안을 만들며 여러 번 돌렸을 때도 정답 수가 모델마다 하나둘씩 달랐으니, 한두 개 차이로 순위를 매기지 않습니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 회사 인사팀 안내 창구에 둘 상담원을 뽑는 면접과 같습니다. 규정집을 조항 카드로 만들어 장 이름을 적고(1단계), 카드를 찾아 주는 사서를 두 단계(하이브리드 검색, 재정렬)로 꾸려 찾기 성적을 재고(2단계), 같은 카드 세 장과 같은 질문지를 지원자 넷에게 똑같이 주어 답을 받고(3단계), 정답, 근거 표시, 모를 때의 대답, 시간, 고용 조건을 한 장의 비교표로 정리합니다(4단계). 여기서 카드가 장 제목을 붙인 조항 조각, 지원자 넷이 qwen2.5:1.5b, qwen2.5:3b, gemma3:1b, llama3.2:1b 모델, 고용 조건이 모델의 라이선스(이용 허락 조건)입니다.

단계출력뜻
1. 조각조각 30 개 · 제2장 휴가 제5조(연차) …조항 30개가 장 제목을 앞에 붙인 조각 30개가 되었습니다
2. 하이브리드hit@1 17/20 · hit@3 20/20규정에 답이 있는 20문제 모두 정답 조항이 위 3개 안에 들었습니다
2. 재정렬hit@1 19/20 · hit@3 19/201등으로 맞힌 수는 19개로 늘었지만, 위 3개에 든 수는 하나 줄었습니다
3. 시간과 오류29.3~57.6초 · 오류 0 개네 모델 모두 25문제를 오류 없이 답했습니다. qwen2.5:3b 가 가장 오래 걸렸습니다
4. qwen2.5:1.5b정답 17 · 출처 맞음 13 · 지나친 거절 0 · 모름 맞음 2정답은 많지만, 규정에 없는 5문제 중 3개에는 모른다고 하지 않았습니다
4. qwen2.5:3b17 · 17 · 2 · 4정답과 근거 표시가 가장 고르고, 없는 질문 5개 중 4개에 모른다고 했습니다
4. gemma3:1b14 · 8 · 4 · 5없는 질문에는 모두 모른다고 했지만, 근거 조항 번호를 맞게 적은 것이 8개뿐입니다
4. llama3.2:1b14 · 11 · 0 · 0가장 빨랐지만, 규정에 없는 5문제 모두 모른다고 하지 않았습니다
4. 라이선스Apache 2.0, Qwen Research, Gemma 이용 약관, Llama 3.2 커뮤니티성적과 따로, 회사 서비스에 써도 되는 조건인지 따져야 합니다
4. 명절 선물3b·gemma 는 "규정에서 찾지 못했습니다.", llama 는 "본인과 배우자에게…"규정에 없는 질문에서 모른다고 한 모델과 지어낸 모델이 갈립니다. 1.5b 는 상관없는 경비 정산 조항을 그대로 옮겼습니다
4. 부산 출장1.5b·3b 는 "8만원", 근거는 제11조·제8조로 적음근거 조항은 제10조(국내 출장비)이고 광역시는 10만원입니다. qwen 두 모델은 부산이 광역시라는 상식을 잇지 못했고 조항 번호도 잘못 적었습니다

한 줄로 줄이면, 같은 검색 결과를 붙여도 모델마다 정답 수보다 "근거를 바르게 적는가", "모를 때 모른다고 하는가"에서 차이가 더 크고, 고르는 기준에는 라이선스까지 들어간다는 것을 보인 프로젝트입니다. 성적이 가장 고른 것은 qwen2.5:3b 지만 라이선스 때문에 회사 서비스에 바로 쓰기 어렵습니다. 본문 "오픈소스 LLM 비교와 선택"에서 정확도, 속도, 메모리, 라이선스를 함께 보라고 한 까닭입니다. 25문제를 한 번 돌린 결과이고 Ollama 는 실행마다 정답 수가 하나둘 달라지므로, 한두 개 차이로 순위를 매기지 않습니다. 시간에도 오류 뒤 모델을 다시 올린 시간이 섞일 수 있습니다.

← 이전 절7장 · 실습 6 — 하이브리드와 재정렬다음 절 →7장 · 정리