LLM 엔지니어링8장 · Project 01. 내 데이터로 답하는 LLM 서비스8 / 11← 이전목차다음 →MetaContents

8장. Project 01. 내 데이터로 답하는 LLM 서비스

프로젝트 2 — 가전제품 설명서 Q&A

가상의 드럼세탁기 "포근 WD-210"의 설명서로 고객 질문에 답하는 Q&A 를 만들어 보세요. 설명서에는 글로 된 절과 함께, 세탁 코스와 오류 코드가 표로 들어 있습니다. 고객은 "화면에 E2 가 떠요"처럼 코드로 묻기도 하고, "물 빠지는 곳 거름망"처럼 설명서와 다른 말로 묻기도 합니다. 답에는 근거가 된 절 제목을 적어, 고객이 설명서의 어디를 보면 되는지 알려 줍니다. 제품과 설명서, 서비스센터 번호는 모두 지어낸 것입니다.

파일생김새
washer_manual.md## 제목 아래 글이 이어지는 절 16개. "세탁 코스", "오류 코드" 두 절은 | 칸 | 칸 | 꼴의 표다
washer_qa.jsonl질문 25개(설명서에 있는 22개, 없는 3개). 질문, 정답열쇠, 근거(절 제목)

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

  1. 설명서를 절 단위로 나누고, 표가 든 절은 표의 행마다 "머리 칸 이름 + 값"으로 된 문장 하나로 바꿉니다.
  2. 표를 통째로 둔 조각과 행으로 바꾼 조각에서 키워드·의미·하이브리드 검색의 hit@3 을 견줍니다.
  3. 검색한 조각 3개로 답하게 하고, 답 끝에 [절: 제목] 꼴로 근거 절을 적게 합니다.
  4. 조각 맨 앞에 ## 제목 을 둔 판과 [절: 제목] 을 둔 판으로 출처가 맞은 수를 견줍니다.
  5. 정답, 출처, 모름 맞음을 세어 표로 냅니다.

단계별 힌트입니다.

  1. 표 줄은 | 로 시작합니다. 첫 줄은 머리 칸, 나머지가 행입니다. 줄.strip("|").split("|") 로 칸을 나눕니다.
  2. 행 문장은 "오류 코드: 표시 E2, 뜻 물이 빠지지 않는다, 할 일 배수 필터를 청소하고 …."처럼 머리 칸 이름을 함께 적어야 혼자서도 뜻이 통합니다.
  3. 근거 절이 맞았는지는 답에 [절: 근거 제목] 이 그대로 들어 있는지로 셉니다.
  4. 모델이 출처 이름을 지어내는 일이 많으면, 그 이름을 조각 안에 그대로 넣어 두어 베껴 쓰게 합니다.
  5. 오류 코드처럼 짧은 낱말 질문은 키워드 검색이, 바꿔 말한 질문은 의미 검색이 강합니다(7장).

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

실제 데이터로 해 보려면

제품 설명서는 제조사가 공개하는 경우가 많습니다. 저작권이 제조사에 있으니 개인 학습에만 쓰고, 이용 조건을 확인하세요.

자료구하는 곳내용
제품 사용 설명서 PDF제조사 누리집의 고객지원 → 설명서 내려받기실제 설명서. PDF 에서 글과 표를 뽑아(12장) 조각으로 나눈다
자주 묻는 질문제조사 누리집의 FAQ질문 묶음을 만들 때 참고한다
한국어 기계독해 데이터AI Hub(aihub.or.kr)문서와 질문, 정답이 함께 있는 공개 데이터. 이용 신청이 필요하다

PDF 의 표는 글을 뽑으면 칸이 뒤섞이기 쉽습니다. 표만 따로 뽑는 방법은 12장의 "이미지와 표가 섞인 문서"에서 다룹니다.

코드의 흐름

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

절과 표, 검색 견주기, 판 1 답, 판 2 답의 네 상자를 화살표로 이은 가전제품 설명서 Q&A 프로젝트의 코드 흐름도

주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 설명서를 절로 나누고 table_rows 로 표를 행 문장으로 바꾸며, 2단계(마젠타)에서 Search.top 으로 표 통째로와 표를 행으로의 hit@3(/22)을 키워드, 의미, 하이브리드마다 견줍니다. 3단계(다홍)는 evaluate 에 hash_tag(## 제목) 꼴을 넣어 판 1 의 답을 채점하고, 4단계(빨강)는 bracket_tag([절: 제목]) 꼴로 출처 이름을 조각 안에 넣은 판 2 와 견줍니다.

결과 보기 (예시 풀이)

1단계. 절과 표를 조각으로

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

text = open("washer_manual.md", encoding="utf-8").read()
sections = [s.strip() for s in text.split("\n## ")[1:]]       # 절 16개. 첫 줄이 제목
qa = [json.loads(l) for l in open("washer_qa.jsonl", encoding="utf-8")]

def table_whole(tag):
    """절 하나를 조각 하나로. tag(제목) 는 조각 맨 앞에 붙일 글을 만드는 함수"""
    return [(tag(s.split("\n")[0]) + " " + s.split("\n", 1)[1], s.split("\n")[0]) for s in sections]

def table_rows(tag):
    """표가 든 절은 행마다 '머리 칸 이름 + 값' 문장 하나로 바꾼다"""
    out = []
    for s in sections:
        title, body = s.split("\n", 1)
        if body.startswith("|"):
            lines = body.split("\n")
            head = [c.strip() for c in lines[0].strip("|").split("|")]
            for line in lines[1:]:
                cells = [c.strip() for c in line.strip("|").split("|")]
                out.append((tag(title) + " " + ", ".join(f"{h} {c}" for h, c in zip(head, cells)) + ".", title))
        else:
            out.append((tag(title) + " " + body, title))
    return out

hash_tag = lambda title: f"## {title}\n"          # 판 1: 마크다운 제목 꼴
bracket_tag = lambda title: f"[절: {title}]"        # 판 2: 답에 적을 출처 꼴 그대로
print("표 통째로:", len(table_whole(hash_tag)), "조각 · 표를 행으로:", len(table_rows(hash_tag)), "조각")
for c, title in table_rows(bracket_tag)[18:21]:
    print(" ", c[:90])
출력
표 통째로: 16 조각 · 표를 행으로: 26 조각
  [절: 오류 코드] 표시 E2, 뜻 물이 빠지지 않는다, 할 일 배수 필터를 청소하고 배수 호스가 꺾이지 않았는지 본다.
  [절: 오류 코드] 표시 E3, 뜻 문이 닫히지 않았다, 할 일 문틈에 옷이 끼었는지 보고 문을 세게 닫는다.
  [절: 오류 코드] 표시 UE, 뜻 빨래가 한쪽으로 쏠렸다, 할 일 빨래를 고르게 펴고 다시 시작한다.

2단계. 검색 견주기

Python
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")

class Search:
    def __init__(self, docs):
        self.E = emb.encode(["passage: " + d for d in docs], normalize_embeddings=True)
        self.vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
        self.X = self.vec.fit_transform(docs)
    def top(self, question, k=3, alpha=0.7):
        sem = self.E @ emb.encode("query: " + question, normalize_embeddings=True)
        kw = cosine_similarity(self.vec.transform([question]), self.X)[0]
        mm = lambda a: (a - a.min()) / (a.max() - a.min() + 1e-9)
        return np.argsort(-(alpha * mm(sem) + (1 - alpha) * mm(kw)))[:k].tolist()

inside = [r for r in qa if r["근거"]]
rows = []
for way, make in [("표 통째로", table_whole), ("표를 행으로", table_rows)]:
    chunks = make(hash_tag)
    s = Search([c for c, _ in chunks])
    for name, alpha in [("키워드", 0.0), ("의미", 1.0), ("하이브리드", 0.7)]:
        hit = sum(r["근거"] in [chunks[i][1] for i in s.top(r["질문"], 3, alpha)] for r in inside)
        rows.append({"조각": way, "검색": name, "hit@3(/22)": hit})
print(pd.DataFrame(rows).pivot(index="조각", columns="검색", values="hit@3(/22)"))
출력
검색      의미  키워드  하이브리드
조각
표 통째로   22   18     22
표를 행으로  22   20     22

3단계. 근거 절을 적게 하는 답

Python
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen2.5:3b"
REFUSE = ["찾지 못", "찾을 수 없", "나와 있지 않", "제공되지 않"]
SYSTEM = ("당신은 포근 WD-210 세탁기의 고객 상담원입니다. 아래 [설명서] 내용만 근거로 한두 문장으로 답하세요. "
          "답 끝에 근거가 된 절 제목을 [절: 제목] 꼴로 적으세요. "
          "관련된 내용이 하나도 없을 때만 '설명서에서 찾지 못했습니다.'라고 답하세요.")

def unload(model):
    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(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}"

def evaluate(tag, show=0):
    """표를 행으로 바꾼 조각에 tag 꼴의 제목을 붙여 25문항을 묻고 센다"""
    chunks = table_rows(tag)
    s = Search([c for c, _ in chunks])
    count = {"정답(/22)": 0, "출처(/22)": 0, "지나친 거절": 0, "모름 맞음(/3)": 0}
    start = time.time()
    for n, r in enumerate(qa):
        context = "\n".join(chunks[i][0] for i in s.top(r["질문"]))
        a = ask([{"role": "system", "content": SYSTEM},
                 {"role": "user", "content": f"[설명서]\n{context}\n\n[질문]\n{r['질문']}"}])
        refused = any(w in a for w in REFUSE)
        if r["근거"]:
            count["정답(/22)"] += any(k in a for k in r["정답열쇠"].split("|"))
            count["출처(/22)"] += f"[절: {r['근거']}]" in a
            count["지나친 거절"] += refused
        else:
            count["모름 맞음(/3)"] += refused
        if n < show:
            print(" ", r["질문"], "→", a.replace("\n", " ")[:90])
    count["25문항 초"] = round(time.time() - start)
    return count

first = evaluate(hash_tag, show=6)
print("판 1(## 제목):", first)
출력
  화면에 E2 가 떠요. → [절: 오류 코드] 표시 E2, 뜻 물이 빠지지 않는다, 할 일 배수 필터를 청소하고 배수 호스가 꺾이지 않았는지 본다. 이것은 설명서에서 제공된 정보를 바탕으로
  E1 은 무슨 뜻이에요? → [절: 오류 코드] ## 오류 코드 표시 E1, 뜻 물이 들어오지 않는다, 할 일 수도꼭지가 열렸는지, 급수 호스가 꺾이지 않았는지 본다.
  UE 가 뜨면서 멈췄어요. → [절: 문이 안 열릴 때] 당신의 세탁기가 UE 오류를 발생시켰을 경우, 세탁기가 빨래를 고르지 못하고 멈추셨을 수 있습니다. 빨래를 고르고 다시 시작하면 오류가
  LE 표시가 나오면 어떻게 하죠? → [절: LE] 표시 LE가 뜨면 모터에 이상이 있다는 것을 의미하므로, 먼저 전원을 끄고 서비스센터에 연락하는 것이 최선의 방법입니다. [절: LE]
  니트 스웨터는 어떤 코스로 빨아요? → [절: 세탁 코스] 니트 스웨터는 '울 스웨터' 코스로 세탁할 수 있습니다. 걸리는 시간은 45분입니다. [절: 세탁 코스]
  이불 빨래는 얼마나 걸려요? → [절: 세탁 코스] 코스 이불, 알맞은 빨래 솜이불, 극세사 이불 한 채, 물 온도 30도, 걸리는 시간 1시간 50분.  이불 빨래는 1시간 50분 걸립니다.
판 1(## 제목): {'정답(/22)': 18, '출처(/22)': 12, '지나친 거절': 1, '모름 맞음(/3)': 3, '25문항 초': 74}

4단계. 출처 이름을 조각 안에 넣기

Python
second = evaluate(bracket_tag, show=6)
print("판 2([절: 제목]):", second)
print(pd.DataFrame([first, second], index=["판 1(## 제목)", "판 2([절: 제목])"]))
출력
  화면에 E2 가 떠요. → [절: 오류 코드] 표시 E2, 뜻 물이 빠지지 않는다. 이 경우, 배수 필터를 청소하고 배수 호스가 꺾이지 않았는지 확인하세요. [절: 오류 코드] E2 에 대
  E1 은 무슨 뜻이에요? → [절: 오류 코드] 표시 E1, 뜻 물이 들어오지 않는다, 할 일 수도꼭지가 열렸는지, 급수 호스가 꺾이지 않았는지 본다. [절: 겨울철 얼었을 때] 급수 호스나
  UE 가 뜨면서 멈췄어요. → [절: 오류 코드] UE, 뜻 빨래가 고르지 않아서 멈췄습니다. 해결 방법은 할 일 고르는 데 사용한 시간이 길어져서 빨래가 고르지 않아서 멈추는 것일 수 있습니
  LE 표시가 나오면 어떻게 하죠? → [절: 오류 코드] 표시 LE는 모터에 이상이 있다는 뜻으로, 이 경우 전원을 끄고 서비스센터에 연락하세요. [절: 제목]
  니트 스웨터는 어떤 코스로 빨아요? → [절: 세탁 코스] 코스 울, 알맞은 빨래 니트, 울 스웨터, 물 온도 찬물, 걸리는 시간 45분. 이 코스로 빨아야 합니다. [절: 세탁 코스]
  이불 빨래는 얼마나 걸려요? → [절: 세탁 코스] 코스 이불은 걸리는 시간이 1시간 50분입니다. [절: 제목]
판 2([절: 제목]): {'정답(/22)': 20, '출처(/22)': 19, '지나친 거절': 6, '모름 맞음(/3)': 2, '25문항 초': 59}
              정답(/22)  출처(/22)  지나친 거절  모름 맞음(/3)  25문항 초
판 1(## 제목)         18       12       1          3      74
판 2([절: 제목])       20       19       6          2      59

표를 행 문장으로 바꾸자 키워드 검색의 hit@3 이 18 에서 20 으로 올랐고, 의미 검색과 하이브리드는 두 방식 모두 22개를 다 찾았습니다. 이 설명서처럼 작은 문서에서는 표를 통째로 두어도 검색은 되지만, 행 문장 하나에 "표시 E2, 뜻 …, 할 일 …"이 함께 들어 있어 모델이 읽고 옮기기 쉬워집니다. 출처 표기는 차이가 컸습니다. 조각 앞에 ## 제목 을 둔 판 1은 출처가 12개 맞았고, 모델은 [절: LE], [절: 문이 안 열릴 때]처럼 절 이름을 지어내거나 엉뚱하게 골랐습니다. 답에 적을 꼴 그대로 [절: 오류 코드] 를 조각 앞에 둔 판 2는 출처가 19개로 늘고 정답도 18개에서 20개로 늘었습니다. 대신 지나친 거절이 1개에서 6개로 늘고 설명서에 없는 질문 3개 가운데 하나에는 지어내어, 한 가지를 고치면 다른 데가 흔들린다는 것도 보입니다. [절: 제목] 이라는 지시 문구를 그대로 베낀 답도 있어, 프롬프트의 예시 꼴은 실제 값과 헷갈리지 않게 적는 편이 좋습니다. 이 PC 에서 25문항에 1분 안팎이 걸렸습니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 세탁기 상담원에게 설명서를 오려 만든 카드 묶음을 쥐여 주는 일과 같습니다. 설명서를 절마다 오리고, 표는 줄마다 "표시 E2, 뜻 …, 할 일 …" 같은 문장 카드로 바꿔 씁니다(1단계). 고객의 질문에 맞는 카드를 찾아오는 세 방법(낱말 맞추기, 뜻으로 찾기, 둘을 섞기)을 견줍니다(2단계). 상담원에게 찾아온 카드 3장으로 답하고, 끝에 "[절: 제목]"으로 설명서의 어디를 보면 되는지 적게 합니다(3단계). 카드 맨 앞의 머리표를 "## 제목"에서 답에 적을 꼴 그대로인 "[절: 제목]"으로 바꾸어 같은 질문으로 다시 시험합니다(4단계). 여기서 카드가 조각, 카드 찾아오기가 키워드·의미·하이브리드 검색, 머리표가 조각 앞의 제목 꼬리표, 상담원이 qwen2.5:3b 입니다.

단계출력뜻
1. 조각 수표 통째로 16 조각, 표를 행으로 26 조각표 두 개를 줄마다 나누자 조각이 10개 늘었습니다. 줄 카드는 머리 칸 이름이 함께 있어 혼자 읽어도 뜻이 통합니다
2. 키워드 검색18 → 20(/22)hit@3 은 맞는 절이 위 3개 안에 들어온 질문 수입니다. 표를 줄로 나누자 낱말 맞추기 검색이 두 개 더 찾았습니다
2. 의미·하이브리드두 방식 모두 22뜻으로 찾는 검색은 22문항을 다 찾았습니다. 이 작은 설명서에서는 검색이 걸림돌이 아니었습니다
3. 판 1 예시 답[절: LE], [절: 문이 안 열릴 때]없는 절 이름을 지어내거나 엉뚱한 절을 적었습니다
3. 판 1 합계정답 18, 출처 12, 지나친 거절 1, 모름 맞음 3, 74초내용은 22개 가운데 18개를 맞혔지만 출처는 12개만 맞았습니다. 설명서에 없는 3문항은 모두 모른다고 했습니다
4. 판 2 합계정답 20, 출처 19, 지나친 거절 6, 모름 맞음 2, 59초출처가 12개에서 19개로 크게 늘고 정답도 2개 늘었습니다. 대신 있는 내용을 모른다고 한 답이 1개에서 6개로 늘고, 없는 질문 하나에는 답을 지어냈습니다
4. [절: 제목] 이 그대로 나온 답LE, 이불 질문의 답 끝지시문의 예시 꼴을 실제 절 이름 대신 그대로 베꼈습니다. 예시 꼴은 실제 값과 헷갈리지 않게 적어야 합니다

한 줄로 줄이면, 답에 적을 출처의 꼴을 자료 안에 그대로 넣어 두면 출처 표기가 크게 좋아지지만, 한쪽을 고치면 거절이 늘어나듯 다른 쪽이 흔들린다는 것을 보인 프로젝트입니다. 25문항으로 한 번 돌린 결과라 한두 개 차이는 실행마다 바뀔 수 있습니다. 정답과 출처도 낱말이 들어 있는지로 센 것이므로 답을 눈으로 함께 봐야 합니다. 실제 PDF 설명서에서 표를 뽑는 법은 12장에서 다룹니다.

← 이전 절8장 · 프로젝트 1 — 아파트 생활 안내 챗봇다음 절 →8장 · 프로젝트 3 — 공공 민원 FAQ 도우미