8장. Project 01. 내 데이터로 답하는 LLM 서비스
프로젝트 1 — 아파트 생활 안내 챗봇
6장의 가람숲 아파트에 관리사무소의 공지문이 더해졌습니다. 주민은 한 번에 끝내지 않고 "헬스장은 몇 시에 열어요?", "그럼 이용료는요?"처럼 이어서 묻습니다. 공지는 규약보다 나중에 나와 규약의 내용을 바꾸기도 합니다(재활용 요일, 10월 관리비 납부일). 규약과 공지를 함께 보고, 후속 질문까지 알아듣는 챗봇을 만들어 보세요. 단지와 규약, 공지는 모두 지어낸 것입니다.
| 파일 | 생김새 |
|---|---|
apt_rules.txt | 6장의 관리규약. 장 8개, 조항 27개 |
apt_notices.txt | 공지 8건. 한 줄에 [2026-08-20] 제목: 내용 |
apt_chat_tests.jsonl | 대화 시험 12개. 대화(사용자의 말 목록), 마지막 질문의 정답열쇠, 근거(공지 또는 규약, 없으면 빈 글자) |
요구사항은 다섯 가지입니다.
- 규약 조항과 공지를 한 목록의 조각으로 합치고, 조각마다
[규약],[공지 날짜]꼬리표를 붙입니다. - 프롬프트에 오늘 날짜와 "공지가 규약과 다르면 날짜가 늦은 공지를 따른다"는 규칙을 넣습니다.
- 후속 질문을 검색하는 세 방법(마지막 질문만, 앞 질문과 이어 붙이기, LLM 으로 질문 바꿔 쓰기)을 견줍니다.
- 대화 시험 12개로 방법마다 정답 수와 시간을 셉니다.
- 틀린 답을 읽고, 검색 탓인지 생성 탓인지 적습니다.
단계별 힌트입니다.
- 공지 줄은
line[1:11]이 날짜,line[13:]이 "제목: 내용"입니다. - 앞의 대화는 메시지 목록에 사용자 말과 빈 자리의 답("(앞의 답)")으로 넣습니다. 실제 서비스라면 그때 낸 답을 넣습니다.
- 질문 바꿔 쓰기는 LLM 에 "대화 기록을 보고 마지막 질문을 혼자서도 뜻이 통하는 한 문장으로 바꿔 쓰세요"라고 따로 한 번 묻는 것입니다.
- 대화 시험의 근거가 "공지"인지 "규약"인지 보고, 검색한 조각에 그 종류가 들어 있었는지도 셉니다.
- 작은 모델은 바꿔 쓰기에서 뜻을 바꿔 버리기도 합니다. 바꿔 쓴 질문을 출력해 읽어 봅니다.
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
실제 데이터로 해 보려면
아파트의 규약과 공지는 단지마다 다르고 주민에게만 공개되는 경우가 많습니다. 자료마다 이용 조건과 공개 범위를 확인하세요.
| 자료 | 구하는 곳 | 내용 |
|---|---|---|
| 공동주택관리규약 준칙 | 국가법령정보센터(law.go.kr)의 자치법규 | 시·도의 관리규약 본보기(6장 프로젝트와 같다) |
| 우리 단지의 공지 | 관리사무소 게시판, 단지 앱 | 날짜가 붙은 공지. 주민 이름, 동·호수 같은 개인정보는 지우고 쓴다 |
| 공동주택 관리 정보 | 공동주택관리정보시스템(K-apt) | 단지별 관리비, 공사 이력 같은 공개 정보 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 규약과 공지를 꼬리표를 붙여 docs 한 목록으로 합치고 종류를 kinds 에 적어 두며, 2단계(마젠타)에서 하이브리드 검색 Search, 오류가 나면 다시 보내는 ask, 앞의 대화를 넣어 답하는 answer(turns, query) 를 만듭니다. 3단계(다홍)는 WAYS 의 세 방법(마지막 질문만, 이어 붙이기, rewrite)으로 후속 질문을 검색해 채점하고, 4단계(빨강)는 results 에서 틀린 답을 골라 검색 글과 근거 종류를 찾았는지 읽습니다.
결과 보기 (예시 풀이)
1단계. 규약과 공지를 한 목록으로
import json, re, time, urllib.request
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from openai import OpenAI, APIError
docs, kinds = [], []
chapter = ""
for line in open("apt_rules.txt", encoding="utf-8").read().split("\n"):
if re.match(r"^제\d+장 ", line):
chapter = line
elif line.startswith("제") and "조(" in line:
docs.append(f"[규약] {chapter} {line}") # 꼬리표 + 장 제목 + 조항
kinds.append("규약")
for line in open("apt_notices.txt", encoding="utf-8").read().strip().split("\n"):
docs.append(f"[공지 {line[1:11]}] {line[13:]}") # [공지 2026-08-20] 제목: 내용
kinds.append("공지")
tests = [json.loads(l) for l in open("apt_chat_tests.jsonl", encoding="utf-8")]
print("조각:", len(docs), "개 (규약", kinds.count("규약"), "· 공지", kinds.count("공지"), ")")
print(docs[16])
print(docs[27])
print("대화 시험:", len(tests), "개 · 첫 시험:", tests[2])조각: 35 개 (규약 27 · 공지 8 )
[규약] 제6장 쓰레기와 재활용 제17조(재활용 배출) 재활용품은 수요일과 일요일에 동마다 있는 분리수거장에 냅니다. 내는 시간은 오전 6시부터 밤 10시까지입니다.
[공지 2026-08-20] 재활용 배출 요일 변경: 9월 1일부터 재활용품은 화요일과 토요일에 냅니다. 내는 시간은 그대로 오전 6시부터 밤 10시까지입니다.
대화 시험: 12 개 · 첫 시험: {'대화': ['헬스장은 아침 몇 시에 열어요?', '한 달 이용료는요?'], '정답열쇠': '2만원', '근거': '규약'}2단계. 검색과 답하기 부품
# 임베딩은 CPU 에, GPU 는 Ollama 에(7차시)
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=4, 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()
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen2.5:3b"
def unload(model):
"""keep_alive=0 으로 모델을 내린다. 다음 요청 때 새로 올라온다"""
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, max_tokens=150):
"""7차시 프로젝트의 ask: 오류가 나면 모델을 내렸다 다시 보낸다"""
for _ in range(3):
try:
resp = client.chat.completions.create(model=MODEL, messages=messages, temperature=0, seed=42, max_tokens=max_tokens)
return resp.choices[0].message.content.strip()
except APIError as e:
error = e.message[:40]
unload(MODEL)
time.sleep(2)
return f"[오류] {error}"
search = Search(docs)
REFUSE = ["찾지 못", "찾을 수 없", "나와 있지 않", "제공되지 않"]
SYSTEM = ("당신은 가람숲 아파트 관리사무소의 안내 직원입니다. 오늘은 2026년 9월 30일입니다. "
"아래 [자료]의 규약과 공지를 읽고 한두 문장으로 답하세요. "
"공지가 규약과 다르면 날짜가 늦은 공지를 따르세요. 답 끝에 근거를 (규약 제N조) 또는 (공지 날짜) 꼴로 적으세요. "
"관련된 자료가 하나도 없을 때만 '자료에서 찾지 못했습니다.'라고 답하세요.")
def answer(turns, query):
"""turns: 사용자의 말 목록. query: 검색에 쓸 글. 검색한 조각 4개를 붙여 마지막 질문에 답한다"""
found = search.top(query)
context = "\n".join(docs[i] for i in found)
msgs = [{"role": "system", "content": SYSTEM}]
for t in turns[:-1]: # 앞의 대화를 메시지로 넣는다
msgs += [{"role": "user", "content": t}, {"role": "assistant", "content": "(앞의 답)"}]
msgs.append({"role": "user", "content": f"[자료]\n{context}\n\n[질문]\n{turns[-1]}"})
return ask(msgs), [kinds[i] for i in found]
for q in ["재활용품은 무슨 요일에 버려요?", "10월 관리비는 언제까지 내야 해요?"]:
a, found_kinds = answer([q], q)
print(q, "→", a.replace("\n", " ")[:90], "| 찾은 조각:", found_kinds)재활용품은 무슨 요일에 버려요? → 재활용품은 9월 1일부터 화요일과 토요일에 버려야 합니다. (공지 2026-08-20 제1조) | 찾은 조각: ['공지', '규약', '규약', '규약']
10월 관리비는 언제까지 내야 해요? → 10월 관리비는 납부일이 10월 27일까지로 연장되었습니다. (공지 2026-09-12 제조) | 찾은 조각: ['공지', '규약', '규약', '규약']3단계. 후속 질문을 다루는 세 방법
def rewrite(turns):
"""앞의 대화를 보고 마지막 질문을 혼자서도 통하는 질문으로 바꿔 쓴다"""
if len(turns) == 1:
return turns[0]
history = "\n".join(f"사용자: {t}" for t in turns[:-1])
msgs = [{"role": "system", "content": "대화 기록을 보고, 마지막 질문을 앞의 대화 없이도 뜻이 통하는 질문 한 문장으로 바꿔 쓰세요. 질문만 쓰세요."},
{"role": "user", "content": f"[대화 기록]\n{history}\n\n[마지막 질문]\n{turns[-1]}"}]
return ask(msgs, max_tokens=60)
WAYS = {"마지막 질문만": lambda turns: turns[-1],
"앞 질문과 이어 붙이기": lambda turns: " ".join(turns),
"LLM 으로 바꿔 쓰기": rewrite}
results = {}
for name, make_query in WAYS.items():
start = time.time()
rows = []
for t in tests:
query = make_query(t["대화"])
a, found_kinds = answer(t["대화"], query)
if t["정답열쇠"]:
ok = any(k in a for k in t["정답열쇠"].split("|"))
else:
ok = any(w in a for w in REFUSE) # 어디에도 없는 질문은 모른다고 해야 맞다
found_ok = (t["근거"] in found_kinds) if t["근거"] else None
rows.append({"query": query, "answer": a, "ok": ok, "found": found_ok})
results[name] = rows
print(f"{name:12s} 정답 {sum(r['ok'] for r in rows)}/12 · 근거 종류를 찾음 "
f"{sum(r['found'] is True for r in rows)}/11 · {time.time() - start:.0f}초")마지막 질문만 정답 8/12 · 근거 종류를 찾음 11/11 · 26초
앞 질문과 이어 붙이기 정답 9/12 · 근거 종류를 찾음 11/11 · 26초
LLM 으로 바꿔 쓰기 정답 8/12 · 근거 종류를 찾음 11/11 · 31초4단계. 틀린 답 읽기
for name, rows in results.items():
print("■", name)
for t, r in zip(tests, rows):
if not r["ok"]:
print(" 질문:", " / ".join(t["대화"]))
print(" 검색:", r["query"][:60], "| 근거 종류를 찾음:", r["found"])
print(" 답 :", r["answer"].replace("\n", " ")[:90])■ 마지막 질문만
질문: 9월 20일에 헬스장에 가도 되나요?
검색: 9월 20일에 헬스장에 가도 되나요? | 근거 종류를 찾음: True
답 : 자료에서 찾지 못했습니다. (규약 제8장 공용 시설 제23조)에서 헬스장은 오전 6시부터 밤 10시까지 엽니다. 하지만 공지 2026-09-18에 따르면 9월 2
질문: 강아지를 데리고 놀이터에 가도 되나요?
검색: 강아지를 데리고 놀이터에 가도 되나요? | 근거 종류를 찾음: True
답 : 강아지를 데리고 놀이터에 가는 것은 불가능합니다. (공지 날짜)
질문: 관리사무소는 토요일에도 문을 여나요? / 몇 시까지요?
검색: 몇 시까지요? | 근거 종류를 찾음: True
답 : (공지 날짜)
질문: 택배 보관함에 넣은 물건은 며칠 안에 찾아야 해요? / 그 뒤에는 어디로 가요?
검색: 그 뒤에는 어디로 가요? | 근거 종류를 찾음: True
답 : 자료에서 찾지 못했습니다.
■ 앞 질문과 이어 붙이기
질문: 9월 20일에 헬스장에 가도 되나요?
검색: 9월 20일에 헬스장에 가도 되나요? | 근거 종류를 찾음: True
답 : 자료에서 찾지 못했습니다. (규약 제8장 공용 시설 제23조)에서 헬스장은 오전 6시부터 밤 10시까지 엽니다. 따라서 9월 20일에는 헬스장에 가실 수 있습니다
질문: 방문 차량은 몇 시간 무료예요? / 10월부터 달라지는 게 있나요?
검색: 방문 차량은 몇 시간 무료예요? 10월부터 달라지는 게 있나요? | 근거 종류를 찾음: True
답 : 방문 차량의 무료 주차 시간이 하루 6시간에서 하루 4시간으로 줄어들었습니다. (공지 날짜)
질문: 강아지를 데리고 놀이터에 가도 되나요?
검색: 강아지를 데리고 놀이터에 가도 되나요? | 근거 종류를 찾음: True
답 : 강아지를 데리고 놀이터에 가는 것은 불가능합니다. (공지 날짜)
■ LLM 으로 바꿔 쓰기
질문: 9월 20일에 헬스장에 가도 되나요?
검색: 9월 20일에 헬스장에 가도 되나요? | 근거 종류를 찾음: True
답 : 자료에서 찾지 못했습니다. (규약 제8장 공용 시설 제23조)에서 헬스장은 오전 6시부터 밤 10시까지 엽니다. 따라서 9월 20일에는 헬스장에 가실 수 있습니다
질문: 방문 차량은 몇 시간 무료예요? / 10월부터 달라지는 게 있나요?
검색: 10월부터 방문 차량의 무료 기간이 달라진 게 있나요? | 근거 종류를 찾음: True
답 : 10월부터 방문 차량의 무료 주차 시간이 하루 6시간에서 하루 4시간으로 줄어들었습니다. (공지 날짜)
질문: 강아지를 데리고 놀이터에 가도 되나요?
검색: 강아지를 데리고 놀이터에 가도 되나요? | 근거 종류를 찾음: True
답 : 강아지를 데리고 놀이터에 가는 것은 불가능합니다. (공지 날짜)
질문: 택배 보관함에 넣은 물건은 며칠 안에 찾아야 해요? / 그 뒤에는 어디로 가요?
검색: 그 뒤에는 어디로 가세요? | 근거 종류를 찾음: True
답 : 공지가 나온 날짜인 2026년 9월 10일부터 방문 차량은 단지 앱에서 미리 등록해야 들어올 수 있습니다. 따라서 공지가 나온 날짜인 2026년 9월 10일부터규약과 공지를 꼬리표와 함께 합치고 "날짜가 늦은 공지를 따른다"는 규칙을 넣자, 재활용 요일(화요일과 토요일)과 10월 관리비 납부일(27일)을 규약이 아니라 공지대로 답했습니다. 검색한 조각 4개에 공지와 규약이 함께 들어 있었는데도 공지를 골랐습니다. 후속 질문을 다루는 세 방법은 이번 실행에서 정답 8개, 9개, 8개로 "앞 질문과 이어 붙이기"가 하나 앞섰습니다. 교안을 만들며 따로 돌렸을 때는 12개, 10개, 8개였으니, 방향은 같지만 크기는 실행마다 다릅니다. 틀린 답을 읽으면 원인이 나뉩니다. "몇 시까지요?", "그 뒤에는 어디로 가요?"는 마지막 질문만으로 검색하면 모델이 무엇을 묻는지 몰라 답하지 못했고, 앞 질문과 이어 붙이면 풀렸습니다. LLM 바꿔 쓰기는 "그 뒤에는 어디로 가세요?"처럼 말만 다듬고 앞의 뜻(택배 보관함)을 넣지 못해, 3B 모델에게는 도움이 되지 않았습니다. "9월 20일 헬스장"은 세 방법 모두 공지 조각을 찾았는데도 공사 기간을 날짜와 견주지 못한 생성 쪽 실패이고, "10월부터 무료 시간이 4시간으로 줄었다"는 답은 공지에 없는 숫자를 지어낸 것입니다. 또 "강아지와 놀이터"에 "불가능합니다"라고 바르게 답했는데도, 정답 열쇠(데려갈 수 없, 안 됩니다, 없습니다)에 "불가능"이 없어 틀린 것으로 셌습니다. 채점 열쇠도 답을 읽으며 다듬어야 합니다.
이 프로젝트가 뜻하는 것
이 프로젝트는 아파트 관리사무소에 새로 온 안내 직원을 길러 내는 일과 같습니다. 먼저 두꺼운 규약집과 게시판의 공지문을 한 서류철에 모으고, 장마다 "규약"인지 "몇 월 며칠 공지"인지 꼬리표를 붙입니다(1단계). 직원에게 "오늘 날짜를 기억하고, 공지가 규약과 다르면 늦게 나온 공지를 따르라"고 일러 준 뒤, 서류철에서 알맞은 장을 찾아 답하게 합니다(2단계). 주민이 "그럼 이용료는요?"처럼 앞말을 빼고 물을 때를 대비해, 마지막 말만 듣기, 앞말과 이어 듣기, 질문을 다시 풀어 쓰기의 세 방법을 같은 시험 12개로 견줍니다(3단계). 끝으로 틀린 답을 하나씩 읽어, 서류를 못 찾은 탓인지 찾고도 잘못 말한 탓인지 가립니다(4단계). 여기서 서류철이 조각 목록 docs, 꼬리표가 [규약]·[공지 날짜], 서류철에서 장을 찾는 일이 하이브리드 검색, 안내 직원이 LLM(Large Language Model, 대규모 언어 모델)인 qwen2.5:3b 입니다. 비유와 다른 점은, 사람 직원은 앞의 대화에서 자기가 한 답을 기억하지만 이 풀이에서는 앞의 답 자리에 "(앞의 답)"이라는 빈 글자만 넣었다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 한 목록 | 조각 35 개(규약 27 · 공지 8) | 규약 조항 27개와 공지 8건이 한 서류철에 모였습니다. 꼬리표 덕분에 조각마다 어디서 온 글인지 알 수 있습니다 |
| 1. 두 조각 | 제17조 "수요일과 일요일", 공지 2026-08-20 "화요일과 토요일" | 같은 재활용 요일을 규약과 공지가 다르게 말합니다. 이 차이를 바르게 다루는지가 첫 시험입니다 |
| 2. 재활용 요일 | "9월 1일부터 화요일과 토요일" | 검색한 4조각에 공지 하나와 규약 셋이 섞여 있었지만, 늦게 나온 공지를 따랐습니다 |
| 2. 10월 관리비 | "10월 27일까지" | 이것도 공지대로 답했습니다. 다만 근거를 "(공지 2026-09-12 제조)"처럼 어색하게 적어, 근거 표기는 아직 거칩니다 |
| 3. 마지막 질문만 | 정답 8/12, 26초 | "몇 시까지요?"만 듣고 검색하면 무엇을 묻는지 몰라 틀리는 일이 생깁니다 |
| 3. 앞 질문과 이어 붙이기 | 정답 9/12, 26초 | 앞 질문을 함께 붙여 검색하자 하나 더 맞혔습니다. 이번 실행에서 세 방법 가운데 가장 나았습니다 |
| 3. LLM 으로 바꿔 쓰기 | 정답 8/12, 31초 | 질문을 다시 쓰느라 LLM 을 한 번 더 불러 5초 더 걸렸지만, 더 맞히지는 못했습니다 |
| 3. 근거 종류를 찾음 | 세 방법 모두 11/11 | 근거가 있는 시험 11개에서, 검색한 조각 안에 필요한 종류(공지 또는 규약)가 늘 들어 있었습니다. 종류만 센 것이라 꼭 맞는 조각을 찾았다는 뜻은 아닙니다 |
| 4. 9월 20일 헬스장 | "자료에서 찾지 못했습니다." 뒤에 헬스장 시간 | 세 방법 모두 근거 종류를 찾았는데도 공사 기간과 날짜를 견주지 못했습니다. 답을 쓰는 쪽(생성)의 실패입니다 |
| 4. 택배 보관함 후속 질문 | 바꿔 쓴 질문 "그 뒤에는 어디로 가세요?" | 바꿔 쓰기가 말투만 다듬고 "택배 보관함"이라는 앞의 뜻을 넣지 못해, 엉뚱한 방문 차량 공지로 답했습니다 |
| 4. 방문 차량 | "하루 6시간에서 하루 4시간으로" | 풀이 문단에 적은 대로, 공지에 없는 숫자를 지어낸 답입니다 |
| 4. 강아지와 놀이터 | "불가능합니다" | 뜻으로는 맞는 답인데, 정답 열쇠에 "불가능"이 없어 틀린 것으로 셌습니다. 채점 쪽 문제입니다 |
한 줄로 줄이면, 꼬리표와 "늦은 공지를 따른다"는 규칙만으로 바뀐 규정을 바르게 답하게 할 수 있고, 후속 질문은 앞 질문과 이어 붙이는 단순한 방법이 3B 모델에서 가장 나았다는 것을 보인 프로젝트입니다. 정답 8, 9, 8개는 한 번 돌린 결과이고, 교안을 만들며 따로 돌렸을 때는 12, 10, 8개였습니다. 그러니 한 개 차이로 어느 방법이 확실히 낫다고 단정할 수는 없습니다. 틀린 답에는 채점 열쇠 탓도 섞여 있으므로, 숫자와 함께 답을 눈으로 읽어야 합니다.