LLM 엔지니어링6장 · RAG 의 원리와 로컬 모델로 옮기기21 / 23← 이전목차다음 →MetaContents

6장. RAG 의 원리와 로컬 모델로 옮기기

프로젝트 1 — 아파트 관리규약 Q&A

가상의 "가람숲 아파트" 관리규약으로 주민 질문에 답하는 RAG 를 만들어 보세요. apt_rules.txt 에 장 8개, 조항 27개가 있고, apt_rules_qa.jsonl 에 질문 25개(규약에 있는 20개, 없는 5개)와 정답 열쇠, 근거 조항 제목이 있습니다. 단지와 규약은 모두 연습용으로 지어낸 것이고 실제 단지나 법령과 관계가 없습니다.

파일생김새
apt_rules.txt"제4장 주차" 같은 장 제목 줄 아래 "제9조(세대별 주차) …" 같은 조항 줄이 이어진다
apt_rules_qa.jsonl질문, 정답열쇠("2만원", 여럿이면 | 로 나눔), 근거조항(조항 제목, 규약에 없으면 빈 글자)

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

  1. 조항 한 줄을 조각 하나로 나누고, 조각마다 장 제목, 조항 번호, 제목을 함께 둡니다.
  2. TF-IDF 검색기를 만들고, 조각에 장 제목을 붙이기 전과 뒤의 hit@3 을 견줍니다.
  3. 검색한 조항 3개를 붙여 Ollama 의 모델(실습 6에서 가장 나았던 것)로 답하고, 답 끝에 근거 조항을 "(제N조)" 꼴로 적게 합니다.
  4. 질문 25개로 정답, 출처(적은 조항 번호가 맞는가), 모름 맞음, 지나친 거절을 셉니다.
  5. 시스템 지시의 "모를 때의 말"을 두 가지로 써 보고 결과를 견줍니다.

단계별 힌트입니다.

  1. 장 제목 줄은 re.match(r"^제\d+장 ", 줄), 조항 줄은 줄.startswith("제") and "조(" in 줄 로 가릅니다. 앞에 나온 장 제목을 변수에 들고 있다가 조항에 붙입니다.
  2. 근거조항이 조항 제목으로 적혀 있으니, {제목: 번호} 사전을 만들어 번호로 바꿉니다.
  3. 답에서 조항 번호는 re.findall(r"제\s*(\d+)\s*조", 답) 으로 뽑습니다.
  4. 첫째 문구는 실습 4처럼 "답이 없으면 … 라고만 답하세요", 둘째 문구는 "관련된 조항이 있으면 그 내용으로 답하고, 관련된 조항이 하나도 없을 때만 …"처럼 씁니다.
  5. 맞혔다고 센 답도 몇 개 직접 읽어 봅니다. 열쇠 낱말은 맞았는데 뜻이 틀린 답이 섞일 수 있습니다.

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

실제 데이터로 해 보려면

관리규약은 단지마다 다르지만, 시·도가 정한 "공동주택관리규약 준칙"이 본보기로 공개되어 있습니다. 자료마다 이용 조건을 확인하세요.

자료구하는 곳내용
공동주택관리규약 준칙국가법령정보센터(law.go.kr)의 자치법규에서 "공동주택관리규약 준칙"으로 검색시·도마다 정한 관리규약의 본보기. 장과 조로 나뉘어 있어 조항 단위로 나누기 좋다
우리 단지의 관리규약관리사무소나 단지 홈페이지단지에서 실제로 쓰는 규약. 공개 범위를 관리사무소에 확인한다
공동주택 관리 정보공동주택관리정보시스템(K-apt)단지별 관리비 같은 공개 정보. 질문 묶음을 만들 때 참고한다

준칙은 조항이 길고 "다음 각 호" 같은 목록이 많습니다. 조항을 조각으로 삼되, 너무 긴 조항은 호 단위로 다시 나눕니다. 7장의 청킹과 의미 검색을 배운 뒤 다시 해 보면 검색이 나아집니다.

코드의 흐름

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

조항 조각, 검색 견주기, 근거 RAG, 문구 바꾸기의 네 상자를 화살표로 이은 아파트 관리규약 Q&A 프로젝트의 코드 흐름도

주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 apt_rules.txt 를 읽으며 장 제목을 기억해 두었다가 조항 조각에 붙이고, 2단계(마젠타)에서 make_search 로 장 제목을 붙이기 전과 뒤의 hit@3 을 견줍니다. 3단계(다홍)는 answer 와 evaluate 로 근거 조항을 적게 하는 RAG 를 첫째 문구로 채점하고, 4단계(빨강)는 SOFT 문구로 바꿔 지나친 거절이 줄었는지 봅니다.

결과 보기 (예시 풀이)

1단계. 규약을 조항 조각으로

Python
import json, re, time
from collections import Counter
from openai import OpenAI, APIError
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

pieces, 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:    # 조항 줄
        m = re.match(r"제(\d+)조\((.+?)\)", line)
        pieces.append({"장": chapter, "조": int(m.group(1)), "제목": m.group(2), "글": line})
qa = [json.loads(line) for line in open("apt_rules_qa.jsonl", encoding="utf-8")]
title_to_no = {p["제목"]: p["조"] for p in pieces}     # 근거조항(제목) → 조항 번호
print("조각", len(pieces), "개 · 질문", len(qa), "개")
print(pieces[8])
출력
조각 27 개 · 질문 25 개
{'장': '제4장 주차', '조': 9, '제목': '세대별 주차', '글': '제9조(세대별 주차) 세대마다 첫 차는 무료로 등록합니다. 두 번째 차는 한 달 2만원, 세 번째 차부터는 한 대에 한 달 5만원을 냅니다.'}

2단계. 장 제목을 붙이기 전과 뒤의 검색

Python
def make_search(docs):
    """문서 글 목록으로 TF-IDF 검색 함수를 만들어 돌려준다"""
    vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
    X = vec.fit_transform(docs)
    def search(question, k=3):
        scores = cosine_similarity(vec.transform([question]), X)[0]
        return scores.argsort()[::-1][:k].tolist()
    return search

def hit_at_3(search):
    """규약에 있는 질문 20개 가운데 정답 조항이 위 3개에 든 수"""
    return sum(title_to_no[r["근거조항"]] in [pieces[i]["조"] for i in search(r["질문"])]
               for r in qa if r["근거조항"])

plain = make_search([p["글"] for p in pieces])
with_chapter = make_search([p["장"] + " " + p["글"] for p in pieces])   # "제4장 주차 제9조(…) …"
print("장 제목 없이 hit@3:", hit_at_3(plain), "/ 20")
print("장 제목 붙여 hit@3:", hit_at_3(with_chapter), "/ 20")
출력
장 제목 없이 hit@3: 17 / 20
장 제목 붙여 hit@3: 19 / 20

3단계. 근거 조항을 적게 하는 RAG

Python
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen2.5:3b"          # 실습 6에서 가장 나았던 모델
REFUSE = ["찾지 못", "찾을 수 없", "나와 있지 않", "제공되지 않"]

def answer(question, system):
    idx = with_chapter(question)
    context = "\n".join(pieces[i]["글"] for i in idx)
    msgs = [{"role": "system", "content": system},
            {"role": "user", "content": f"[규약]\n{context}\n\n[질문]\n{question}"}]
    try:
        resp = client.chat.completions.create(model=MODEL, messages=msgs, temperature=0, seed=42, max_tokens=150)
        return resp.choices[0].message.content.strip()
    except APIError as e:
        return f"[오류] {e.message[:40]}"

def evaluate(system, show=0):
    """질문 25개를 묻고 정답, 출처, 모름 맞음, 지나친 거절을 센다. show 개만큼 답을 보여 준다"""
    count = Counter()
    for n, r in enumerate(qa):
        a = answer(r["질문"], system)
        refused = any(w in a for w in REFUSE)
        if r["근거조항"]:
            count["정답"] += any(k in a for k in r["정답열쇠"].split("|"))
            count["출처"] += str(title_to_no[r["근거조항"]]) in re.findall(r"제\s*(\d+)\s*조", a)
            count["지나친 거절"] += refused
        else:
            count["모름 맞음"] += refused
        if n < show:
            print(" ", r["질문"], "→", a.replace("\n", " ")[:90])
    return dict(count)

STRICT = ("당신은 가람숲 아파트 관리사무소의 안내 직원입니다. [규약]에 적힌 내용만 근거로 한두 문장으로 답하세요. "
          "답 끝에 근거 조항 번호를 (제N조) 꼴로 적으세요. 규약에 답이 없으면 '규약에서 찾지 못했습니다.'라고만 답하세요.")
start = time.time()
print("첫째 문구:", evaluate(STRICT, show=4), f"({time.time() - start:.0f}초)")
출력
  관리비는 매달 언제까지 내야 하나요? → 제6조(관리비 납부) 조항에 따르면 관리비는 매달 25일까지 납부해야 합니다. (제6조)
  관리비를 늦게 내면 얼마를 더 내나요? → (제7조) 규약에서 답이 있습니다. 관리비 납부를 늦추면 밀린 금액에 한 달에 2%의 연체료가 붙습니다.
  차를 두 대 등록하면 돈을 내야 하나요? → 규약에서 찾지 못했습니다.
  친구가 차를 가지고 놀러 오면 몇 시간 공짜예요? → 규약에서 찾지 못했습니다.
첫째 문구: {'정답': 13, '출처': 14, '지나친 거절': 7, '모름 맞음': 5} (110초)

4단계. 모를 때의 말을 바꿔 보기

Python
SOFT = ("당신은 가람숲 아파트 관리사무소의 안내 직원입니다. 아래 [규약] 조항을 읽고 질문에 한두 문장으로 답하세요. "
        "질문과 관련된 조항이 있으면 그 조항의 내용으로 답하고, 답 끝에 (제N조) 꼴로 근거를 적으세요. "
        "관련된 조항이 하나도 없을 때만 '규약에서 찾지 못했습니다.'라고 답하세요.")
print("둘째 문구:", evaluate(SOFT))

# 둘째 문구로 몇 가지 답을 직접 읽어 본다
for q in ["지하주차장에서 담배 피워도 되나요?", "헌 소파는 어떻게 버리나요?", "인테리어 공사는 토요일에도 할 수 있나요?", "단지에 수영장이 있나요?"]:
    print(" ", q, "→", answer(q, SOFT).replace("\n", " ")[:110])
출력
둘째 문구: {'정답': 17, '출처': 17, '지나친 거절': 3, '모름 맞음': 5}
  지하주차장에서 담배 피워도 되나요? → 제15조(흡연)에 따르면 복도, 계단, 승강기, 지하주차장, 놀이터는 금연 구역입니다. 따라서 지하주차장에서 담배를 피울 수는 없습니다. (제15조)
  헌 소파는 어떻게 버리나요? → 규약에서 찾지 못했습니다.
  인테리어 공사는 토요일에도 할 수 있나요? → 규약에서 찾지 못했습니다.
  단지에 수영장이 있나요? → 규약에서 찾지 못했습니다.

조항마다 장 제목을 붙이자 검색의 hit@3 이 17/20 에서 19/20 으로 올랐습니다. "제4장 주차" 같은 장 제목의 낱말이 질문과 겹쳤기 때문입니다. 첫째 문구("답이 없으면 … 라고만 답하세요")로는 정답 13개, 출처 14개였고, 규약에 있는 질문 7개에서 "찾지 못했습니다"로 피했습니다(지나친 거절). 둘째 문구("관련된 조항이 하나도 없을 때만")로 바꾸자 정답 17개, 출처 17개로 늘고 지나친 거절은 3개로 줄었으며, 규약에 없는 5개에는 두 문구 모두 모른다고 답했습니다. 남은 실패를 읽어 보면 성격이 다릅니다. "헌 소파는 어떻게 버리나요?"는 규약이 "대형 폐기물", "가구"라고만 적어 검색이 놓친 검색 실패이고, "인테리어 공사는 토요일에도 할 수 있나요?"는 "평일 오전 9시부터"라는 조항을 받고도 토요일을 잇지 못한 지나친 거절로, 실습 6의 토요일 질문과 같은 꼴입니다. 앞의 것은 검색(7장의 의미 검색)을, 뒤의 것은 모델이나 문서의 표현을 고칠 일입니다. 또 정답으로 센 답 가운데에도 처음 돌려 볼 때 "담배를 피울 수는 있지만"처럼 열쇠 낱말("금연")만 맞고 뜻이 틀린 답이 나온 적이 있어, 숫자만 보지 말고 답을 읽어야 합니다. 이 PC 에서 3B 모델은 GPU 에 다 들어가지 못하고 CPU 로 일부가 넘어가 25문항에 110초쯤 걸렸습니다. 다른 모델을 ollama stop 으로 내리고 돌리면 더 빠릅니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 아파트 관리사무소에 안내 직원을 새로 두고, 주민 질문에 규약집을 찾아 답하게 하는 것과 같습니다. 규약집을 조항마다 카드 한 장으로 만들되 카드마다 어느 장(예: 제4장 주차)인지 적어 두고(1단계), 장 이름을 적은 카드와 안 적은 카드로 찾기 시험을 견주고(2단계), 직원에게 찾은 카드 세 장을 보며 답하고 끝에 "제N조"로 근거를 밝히게 한 뒤 25문제를 채점하고(3단계), "모를 때는 이렇게 말하라"는 당부를 부드럽게 바꿔 다시 채점합니다(4단계). 여기서 카드가 조항 조각, 장 이름이 장 제목, 직원이 qwen2.5:3b 모델, 당부가 시스템 지시의 "모를 때의 말"(첫째 문구 STRICT, 둘째 문구 SOFT)입니다.

단계출력뜻
1. 조각조각 27 개 · 질문 25 개규약의 조항 27개가 조각 27개가 되었고, 시험 질문은 25개(규약에 있는 20개, 없는 5개)입니다
1. 조각 하나{'장': '제4장 주차', '조': 9, '제목': '세대별 주차', …}조항 글과 함께 장 제목, 번호, 제목을 들고 있습니다
2. 장 제목 없이hit@3: 17 / 20답이 있는 20문제 중 17개에서 정답 조항이 위 3개 안에 들었습니다
2. 장 제목 붙여hit@3: 19 / 20장 제목을 붙이자 19개로 늘었습니다. "주차" 같은 장 이름이 질문의 낱말과 겹쳤기 때문입니다
3. 답 네 개관리비 "25일까지 (제6조)", 연체료 "2% (제7조)", 나머지 둘은 "규약에서 찾지 못했습니다."근거를 적으라는 지시는 따랐지만, 차 두 대 등록과 손님 차 질문은 답이 규약에 있는데도 피했습니다
3. 첫째 문구{'정답': 13, '출처': 14, '지나친 거절': 7, '모름 맞음': 5} (110초)20문제 중 13개가 정답이고 14개에서 맞는 조항 번호를 적었습니다. 7개는 답이 있는데 피했고, 없는 5문제에는 모두 모른다고 했습니다. 한 문제에 4초 남짓 걸렸습니다
4. 둘째 문구{'정답': 17, '출처': 17, '지나친 거절': 3, '모름 맞음': 5}"관련 조항이 하나도 없을 때만 모른다고 하라"로 바꾸자 정답과 출처가 17개로 늘고 지나친 거절은 3개로 줄었습니다. 없는 5문제에는 그대로 모두 모른다고 했습니다
4. 흡연"…지하주차장에서 담배를 피울 수는 없습니다. (제15조)"조항 내용과 번호를 함께 바르게 답했습니다
4. 헌 소파규약에서 찾지 못했습니다.규약은 "대형 폐기물", "가구"라고만 써서 검색이 놓친 질문입니다(검색 실패)
4. 인테리어 토요일규약에서 찾지 못했습니다.조항을 받고도 "평일"과 "토요일"을 잇지 못한 지나친 거절입니다
4. 수영장규약에서 찾지 못했습니다.규약에 없는 질문이라 바르게 모른다고 했습니다

한 줄로 줄이면, 조각에 장 제목을 붙이면 찾기가 나아지고(17개에서 19개), "모를 때의 말"을 부드럽게 바꾸면 답이 있는 질문을 덜 피하면서(지나친 거절 7개에서 3개) 없는 질문에는 여전히 모른다고 한다(5개 모두)는 것을 보인 프로젝트입니다. 남은 실패는 검색이 놓친 것(헌 소파)과 모델이 잇지 못한 것(토요일)으로 나뉘고, 앞의 것은 7장의 의미 검색이 다룹니다. 25문제를 한 번 돌린 결과이고 Ollama 는 실행마다 숫자가 하나둘 달라질 수 있으며, 열쇠 낱말 채점이라 정답으로 센 답도 직접 읽어 봐야 합니다.

← 이전 절6장 · 실습 6 — 모델 바꿔 끼우기다음 절 →6장 · 정리