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

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

프로젝트 3 — 공공 민원 FAQ 도우미

가상의 "가온구청"에는 부서마다 정리한 자주 묻는 질문(FAQ) 40개가 있습니다. 주민은 "헌 소파 버리려면 어떻게 해요?"처럼 FAQ 와 다른 말로 묻고, FAQ 에 없는 것("구청장님 면담")도 묻습니다. 주민의 말을 가장 가까운 FAQ 와 짝지어 답하되, 점수가 낮으면 지어내지 말고 민원 상담 창구로 넘기는 도우미를 만들어 보세요. 공공기관의 안내는 틀리면 피해가 크므로 "잘못 답하기"를 가장 줄이는 것이 목표입니다. 구청과 부서, 제도, 금액은 모두 지어낸 것입니다.

파일생김새
civil_faq.csv번호, 부서, 질문, 답변. 40행
civil_queries.csv말(주민이 한 말), 정답번호(짝이 되는 FAQ 번호, FAQ 에 없으면 0). 32행(있는 26개, 없는 6개)

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

  1. 주민의 말과 FAQ 를 임베딩해 가장 가까운 FAQ 를 찾고, 1등이 정답 FAQ 인 비율을 잽니다.
  2. FAQ 의 질문만 임베딩할 때와 질문과 답변을 함께 임베딩할 때를 견줍니다.
  3. 기준값을 바꿔 가며 "맞게 답함", "잘못 답함", "넘김"의 수를 표로 만들고, 기준값 하나를 고릅니다.
  4. 기준값을 넘으면 짝지은 FAQ 의 답변과 부서를 바탕으로 LLM 이 친절한 답을 쓰게 하고, 못 넘으면 넘기는 안내를 냅니다.
  5. 고른 기준값에서 잘못 답한 말을 읽고, 무엇을 고치면 될지 적습니다.

단계별 힌트입니다.

  1. 주민의 말에는 query: , FAQ 에는 passage: 를 붙입니다. 질문끼리 견줄 때는 둘 다 query: 를 붙이는 방법도 있습니다.
  2. "잘못 답함"은 기준값을 넘었는데 짝지은 FAQ 가 틀린 경우(FAQ 에 없는 말을 억지로 짝지은 경우 포함)입니다.
  3. 1장의 기준값 표와 같은 꼴입니다. 기준값을 올리면 잘못 답함이 줄고 넘김이 늡니다.
  4. LLM 에는 짝지은 FAQ 하나만 보여 주고, 그 안의 내용만 쓰게 합니다. 금액과 날짜를 바꾸지 말라고 적습니다.
  5. 넘길 때는 "민원 상담 창구(지어낸 번호 02-0000-0000)로 연결해 드리겠습니다"처럼 다음에 할 일을 알려 줍니다.

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

실제 데이터로 해 보려면

공공기관의 FAQ 와 민원 데이터는 공개된 것이 많습니다. 자료마다 이용 조건(공공누리 유형)을 확인하세요.

자료구하는 곳내용
지자체 자주 묻는 질문시청·구청 누리집의 "자주 묻는 질문"부서와 질문, 답이 정리되어 있다. 공공누리 표시를 확인한다
민원 질의응답 데이터공공데이터포털(data.go.kr)에서 "민원", "FAQ"로 검색기관이 올린 질의응답 파일. 내려받기나 공식 API 로 구한다
민원 업무 말뭉치AI Hub 에서 "민원"으로 검색민원 상담 대화와 질의응답. 이용 신청이 필요하다

실제 민원 글에는 이름, 주소, 전화번호가 섞여 있을 수 있습니다. 개인정보를 지운 공개 데이터만 쓰고, 지운 뒤에도 남은 것이 없는지 확인합니다.

코드의 흐름

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

읽기, 매칭, 기준값 표, 답·넘기기, 오답 읽기의 다섯 상자를 화살표로 이은 공공 민원 FAQ 도우미 프로젝트의 코드 흐름도

주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 civil_faq.csv 의 FAQ 40개와 주민의 말을 읽고, 2단계(마젠타)에서 U @ F.T 로 주민의 말과 FAQ 를 매칭하며 질문만과 질문+답변의 두 임베딩을 견줍니다. 3단계(다홍)는 기준값을 0.83~0.88 로 바꿔 가며 맞게 답함, 잘못 답함, 넘김을 세고, 4단계(빨강)는 helper 가 기준값을 넘으면 FAQ 로 답하고 못 넘으면 HANDOFF 문구로 넘깁니다. 5단계(짙은 청록)는 wrong 에 모은 잘못 답한 말과 짝지은 FAQ 를 읽습니다.

결과 보기 (예시 풀이)

1단계. FAQ 와 주민의 말 읽기

Python
import json, time, urllib.request
import numpy as np
import pandas as pd
from sentence_transformers import SentenceTransformer
from openai import OpenAI, APIError

faq = pd.read_csv("civil_faq.csv")
queries = pd.read_csv("civil_queries.csv")
print("FAQ:", len(faq), "개 · 부서:", faq["부서"].nunique(), "곳")
print(faq.head(3).to_string(index=False))
print("주민의 말:", len(queries), "개 · FAQ 에 없는 말:", int((queries["정답번호"] == 0).sum()), "개")
출력
FAQ: 40 개 · 부서: 10 곳
 번호    부서                     질문                                                                        답변
  1 민원여권과 여권을 새로 만들려면 무엇이 필요한가요? 신분증과 6개월 안에 찍은 여권 사진 1장을 가지고 구청 1층 여권 창구에 오시면 됩니다. 10년짜리 여권 수수료는 5만 원입니다.
  2 민원여권과    여권은 신청하고 며칠 뒤에 받나요?              보통 신청한 날부터 8일 안팎(근무일 기준)이 걸립니다. 받으실 때도 본인이 신분증을 가지고 오셔야 합니다.
  3 민원여권과   주민등록등본은 어디서 뗄 수 있나요?        구청과 동 행정복지센터 창구, 무인민원발급기, 정부24 누리집에서 뗄 수 있습니다. 정부24 에서는 수수료가 없습니다.
주민의 말: 32 개 · FAQ 에 없는 말: 6 개

2단계. FAQ 매칭과 두 가지 임베딩

Python
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
U = emb.encode(["query: " + x for x in queries["말"]], normalize_embeddings=True)
inside = (queries["정답번호"] > 0).to_numpy()
gold = queries["정답번호"].to_numpy()

ways = {"질문만": ["query: " + q for q in faq["질문"]],
        "질문+답변": ["passage: " + q + " " + a for q, a in zip(faq["질문"], faq["답변"])]}
match = {}
for name, texts in ways.items():
    F = emb.encode(texts, normalize_embeddings=True)
    S = U @ F.T                                      # (주민의 말 32, FAQ 40)
    best, score = S.argmax(axis=1) + 1, S.max(axis=1)  # 1등 FAQ 번호(1부터)와 그 점수
    match[name] = (best, score)
    print(f"{name:6s} 1등이 정답: {int((best[inside] == gold[inside]).sum())}/26 · "
          f"있는 말의 점수 최저 {score[inside].min():.3f} · 없는 말의 점수 최고 {score[~inside].max():.3f}")
출력
질문만    1등이 정답: 21/26 · 있는 말의 점수 최저 0.834 · 없는 말의 점수 최고 0.898
질문+답변  1등이 정답: 21/26 · 있는 말의 점수 최저 0.812 · 없는 말의 점수 최고 0.867

3단계. 기준값 표

Python
best, score = match["질문+답변"]
rows = []
for t in [0.0, 0.83, 0.84, 0.85, 0.86, 0.87, 0.88]:
    answer = score >= t                                  # 기준값을 넘으면 답한다
    rows.append({"기준값": t,
                 "맞게 답함": int((answer & (best == gold)).sum()),
                 "잘못 답함": int((answer & (best != gold)).sum()),
                 "넘김": int((~answer).sum()),
                 "넘김 중 FAQ 에 없는 말": int((~answer & ~inside).sum())})
print(pd.DataFrame(rows).to_string(index=False))
출력
 기준값  맞게 답함  잘못 답함  넘김  넘김 중 FAQ 에 없는 말
0.00     21     11   0                0
0.83     21      6   5                2
0.84     20      4   8                4
0.85     19      2  11                5
0.86     17      1  14                5
0.87     14      0  18                6
0.88     10      0  22                6

4단계. 답하기와 넘기기

Python
THRESHOLD = 0.86
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen2.5:3b"
SYSTEM = ("당신은 가온구청의 민원 안내 도우미입니다. 아래 [FAQ] 하나의 내용만 써서 주민에게 두세 문장으로 친절하게 답하세요. "
          "금액, 날짜, 장소를 바꾸지 말고, 마지막에 담당 부서를 알려 주세요.")
HANDOFF = "정확한 안내를 위해 민원 상담 창구(02-0000-0000, 지어낸 번호)로 연결해 드리겠습니다."

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 helper(n):
    """n 번째 주민의 말에 답한다. 점수가 기준값보다 낮으면 넘긴다"""
    b, s = int(best[n]), float(score[n])
    if s < THRESHOLD:
        return f"(점수 {s:.3f}, 넘김) {HANDOFF}"
    row = faq[faq["번호"] == b].iloc[0]
    msgs = [{"role": "system", "content": SYSTEM},
            {"role": "user", "content": f"[FAQ]\n부서: {row['부서']}\n질문: {row['질문']}\n답변: {row['답변']}\n\n[주민의 말]\n{queries['말'][n]}"}]
    return f"(점수 {s:.3f}, FAQ {b}) " + ask(msgs)

for n in [6, 18, 13, 26, 30]:
    print("주민:", queries["말"][n])
    print("도우미:", helper(n).replace("\n", " "))
출력
주민: 헌 소파 버리려면 어떻게 해요?
도우미: (점수 0.869, FAQ 7) 대형 폐기물을 버리는 방법은 가온구 누리집이나 동 행정복지센터에서 신고 후, 수수료를 납부하고 번호를 받아 정한 날 집 앞에 내놓으시면 됩니다. 한 개의 소파는 8천 원입니다. 자원순환과 부서에서 도와드립니다.
주민: 아이를 낳으면 구에서 돈을 주나요?
도우미: (점수 0.872, FAQ 21) 출산 지원금은 첫째 아이가 태어나면 50만 원, 둘째 아이가 태어나면 100만 원, 셋째 이상 아이가 태어나면 200만 원을 드립니다. 가온구 가족정책과에서 처리합니다.
주민: 집 앞 거주자 주차 자리 받고 싶어요.
도우미: (점수 0.885, FAQ 14) 거주자 우선 주차를 신청하려면 가온구 시설관리공단 누리집을 통해 직접 신청하면 됩니다. 한 달 요금은 3만 원에서 5만 원으로 구역에 따라 다릅니다. 교통행정과 담당 부서에서 도와드리겠습니다.
주민: 구청장님 면담을 신청하고 싶어요.
도우미: (점수 0.867, FAQ 38) 답변: 면담을 원하시는 경우, 구청 2층 일자리센터에서 오전 9시부터 오후 6시까지 방문해 주시면 됩니다. 상담 및 이력서 작성 도와드릴 수 있습니다. 담당 부서는 일자리정책과입니다.
주민: 전기 요금이 너무 많이 나왔어요.
도우미: (점수 0.840, 넘김) 정확한 안내를 위해 민원 상담 창구(02-0000-0000, 지어낸 번호)로 연결해 드리겠습니다.

5단계. 잘못 답한 말 읽기

Python
wrong = [n for n in range(len(queries)) if score[n] >= THRESHOLD and best[n] != gold[n]]
for n in wrong:
    b = int(best[n])
    print(f"말: {queries['말'][n]} | 정답 FAQ {gold[n]} | 짝지은 FAQ {b}: {faq['질문'][b - 1]} ({score[n]:.3f})")
출력
말: 구청장님 면담을 신청하고 싶어요. | 정답 FAQ 0 | 짝지은 FAQ 38: 구청 일자리 상담은 어디서 받나요? (0.867)

FAQ 의 질문만 임베딩할 때와 질문과 답변을 함께 임베딩할 때 모두 1등이 정답인 말은 26개 가운데 21개였습니다. 차이는 점수의 모양에 있습니다. 질문만 쓰면 FAQ 에 없는 말이 0.898 까지 높게 나와 가려내기 어렵고, 질문과 답변을 함께 쓰면 없는 말의 최고 점수가 0.867 로 내려갑니다. 그래도 있는 말의 최저 점수(0.812)보다 높아서, 어떤 기준값으로도 완벽하게 가를 수는 없습니다. 기준값 표에서 0 이면 32개 모두 답해 11개를 잘못 답하고, 0.86 이면 17개를 맞게 답하고 1개만 잘못 답하며 14개를 넘깁니다. 넘긴 14개 가운데 5개는 FAQ 에 없는 말이고, 9개는 답할 수 있었는데 놓친 말입니다. 0.86 에서 남은 잘못 하나는 "구청장님 면담"을 일자리 상담 FAQ(0.867)와 짝지은 것으로, LLM 은 그 FAQ 대로 "구청 2층 일자리센터"를 안내했습니다. 기준값 안의 답은 FAQ 의 금액과 부서(8천 원, 자원순환과)를 그대로 옮겨 친절하게 풀었습니다. 공공 안내처럼 틀린 답이 더 위험한 서비스라면 0.87 로 올려 잘못 답함을 0 으로 만들고 넘김(18개)을 상담 창구가 받는 편이 낫습니다. 넘김을 줄이려면 FAQ 마다 주민이 쓸 법한 다른 말투의 질문을 몇 개씩 더해 두는 것이 다음 할 일입니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 구청 안내 창구의 직원이 일하는 방식과 같습니다. 부서마다 만든 FAQ(Frequently Asked Questions, 자주 묻는 질문) 카드 40장과 주민이 실제로 한 말 32개를 펼쳐 놓습니다(1단계). 주민의 말을 듣고 가장 닮은 카드를 고르는데, 카드의 질문만 보고 고를 때와 질문과 답을 함께 보고 고를 때를 견줍니다(2단계). "얼마나 닮아야 그 카드로 답할지"의 기준을 여러 값으로 바꿔 보며 맞게 답한 수, 잘못 답한 수, 넘긴 수를 셉니다(3단계). 기준을 넘으면 그 카드 내용으로 친절하게 말하고, 못 넘으면 상담 창구로 넘깁니다(4단계). 끝으로 잘못 답한 말을 읽습니다(5단계). 여기서 FAQ 카드가 civil_faq.csv 의 행, 닮은 정도가 임베딩 점수, 기준이 기준값 THRESHOLD, 친절하게 말을 다듬는 사람이 LLM 인 qwen2.5:3b 입니다. 비유와 다른 점은, 사람은 "구청장님 면담"이 일자리 상담과 다른 일임을 바로 알지만, 임베딩 점수는 그런 말에도 높게 나올 수 있다는 것입니다.

단계출력뜻
1. 읽기FAQ 40 개, 부서 10 곳, 주민의 말 32 개(FAQ 에 없는 말 6 개)답할 수 있는 말 26개와 답하면 안 되는 말 6개가 섞인 시험입니다
2. 질문만1등이 정답 21/26, 있는 말 최저 0.834, 없는 말 최고 0.898없는 말이 있는 말보다 더 높은 점수를 받기도 해서, 점수만으로 가르기 어렵습니다
2. 질문+답변1등이 정답 21/26, 있는 말 최저 0.812, 없는 말 최고 0.867맞힌 수는 같지만 없는 말의 최고 점수가 내려갔습니다. 그래도 0.867 이 0.812 보다 높아, 완벽하게 가르는 기준값은 없습니다
3. 기준값 0.00맞게 21, 잘못 11, 넘김 0무조건 답하면 32개 가운데 11개를 틀리게 안내합니다
3. 기준값 0.86맞게 17, 잘못 1, 넘김 14잘못 답함이 1개로 줄었습니다. 넘긴 14개 가운데 5개는 FAQ 에 없는 말, 9개는 답할 수 있었는데 놓친 말입니다
3. 기준값 0.87맞게 14, 잘못 0, 넘김 18잘못 답함은 0 이 되지만 넘김이 18개로 늘어 상담 창구의 일이 많아집니다
4. 헌 소파점수 0.869, FAQ 7, "8천 원", "자원순환과"기준값을 넘어, FAQ 의 금액과 부서를 그대로 옮겨 친절하게 답했습니다
4. 전기 요금점수 0.840, 넘김기준값보다 낮아 지어내지 않고 상담 창구로 넘겼습니다
5. 잘못 답한 말"구청장님 면담" → FAQ 38 일자리 상담(0.867)0.86 에서 남은 잘못 하나입니다. FAQ 에 없는 말이 기준값을 넘어 엉뚱한 카드와 짝지어졌고, LLM 은 그 카드대로 일자리센터를 안내했습니다

한 줄로 줄이면, "얼마나 많이 맞히나"보다 "틀린 안내를 얼마나 참을 수 있나"로 기준값을 고르는 프로젝트입니다. 풀이는 0.86 을 썼지만, 공공 안내처럼 틀린 답이 더 위험하면 0.87 로 올려 잘못 답함을 0 으로 만드는 편이 낫다고 결론 냈습니다. 이 기준값은 주민의 말 32개로 고른 것이라, 새로운 말이 들어오면 맞는 값이 달라질 수 있습니다. 넘김을 줄이려면 FAQ 마다 주민이 쓸 법한 다른 말투의 질문을 더해 두는 것이 다음 할 일입니다.

← 이전 절8장 · 프로젝트 2 — 가전제품 설명서 Q&A다음 절 →8장 · 정리