하네스 워크북 Lab 09 교재 0/11

Lab 09 · 약 35분

RAG는 원래 하네스 패턴이었다

1 개념

원문: 15장 RAG Was a Harness Pattern All Along

무엇이 깨지나

모델은 우리 회사 규정 문서를 모른다. 전부 프롬프트에 넣기엔 너무 많다(고 가정하자).

패치

문서를 청크로 쪼개 색인하고, 질문과 비슷한 top-k 만 배열에 넣는다. 넣는 방식은 둘:

여기선 의존성 없이 BM25 + 한글 글자 바이그램으로 구현합니다. 임베딩으로 바꿔도 하네스 쪽 구조는 같습니다.

비용 / 실패 지점

청크로 쪼개는 순간 문서 사이의 관계가 흩어진다. top-k 가 연결 고리를 놓치면 모델은 반쪽 근거로 답한다. python labs/09_rag/solution.py "대금지급 전에 검수가 끝나야 하나요?" → 삼자대사, 입고 문서가 빠진다. (→ Lab 10)

할 일

생각해볼 것

2 따라 해보기

비유 → 짧은 코드 → 실제 출력. 아래 출력은 미리 실행해 둔 결과이고, ▶ 실행을 누르면 브라우저 안 파이썬으로 다시 돌립니다(첫 실행 때 약 10MB 내려받음).

예제 1 오픈북 시험: RAG 한 장 요약

비유 — RAG 는 오픈북 시험입니다. 책 전체를 외우게 하는 대신(학습), 시험 문제와 관련된 페이지 몇 장만 펴서 옆에 놓아 줍니다(검색 → 주입).

핵심은 "어떤 페이지를 펴 줄 것인가"이고, 그건 모델이 아니라 하네스 코드가 정합니다.

import solution as rag

index = rag.Index(rag.load_docs())
question = "어음으로 지급해도 되나요?"
print(f"책(청크) {len(index.chunks)}장 중에서 관련 페이지를 고릅니다.\n")
q_tokens = set(rag.tokenize(question))
for score, chunk in index.search(question, k=2):
    # 청크에서 질문과 가장 많이 겹치는 문장을 보여 준다
    best = max(chunk["text"].splitlines(), key=lambda line: len(set(rag.tokenize(line)) & q_tokens))
    print(f"📄 {chunk['id']}  (점수 {score:.2f})")
    print("   " + best[:60] + "\n")
실행 결과
책(청크) 8장 중에서 관련 페이지를 고릅니다.

📄 06_대금지급_규정.md#0  (점수 5.72)
   지급은 계좌 송금을 원칙으로 하며, 어음 지급은 하지 않습니다.

📄 01_발주_가이드.md#0  (점수 0.24)
   발주(PO)는 공급사에 품목, 수량, 단가, 납기를 확정해서 보내는 문서입니다. 발주번호는 시스템이 자동으로

💡 바꿔 보기: 질문을 "식당은 몇 시까지 해?"로 바꿔 보세요. 구매 문서 대신 식당 공지가 뽑힙니다.

예제 2 한국어 검색의 요령: 글자 바이그램

"대금지급은"과 "지급"은 형태소 분석기 없이는 일치하지 않습니다. 단어를 두 글자씩 겹쳐 자르면(바이그램) "지급"이 공통 조각으로 잡힙니다.

import solution as rag

for w in ["대금지급은", "지급", "3-Way Match 통제"]:
    print(f"{w!r:20} → {rag.tokenize(w)}")
common = set(rag.tokenize("대금지급은")) & set(rag.tokenize("지급"))
print("\n공통 조각:", common)
실행 결과
'대금지급은'              → ['대금', '금지', '지급', '급은']
'지급'                 → ['지급']
'3-Way Match 통제'     → ['3', 'way', 'match', '통제']

공통 조각: {'지급'}

💡 바꿔 보기: 자기 이름이나 회사 용어를 넣어 어떻게 쪼개지는지 보세요.

예제 3 BM25 점수표: 드문 단어가 점수를 만든다

BM25 는 "질문의 단어가 이 청크에 얼마나 나오나" × "그 단어가 얼마나 드문가(idf)"를 더합니다. 모든 문서에 나오는 어미 "니다"나 여러 문서에 나오는 "송장"보다, 한 문서에만 나오는 "어음"이 훨씬 강한 단서입니다.

import solution as rag

index = rag.Index(rag.load_docs())
for term in ["어음", "송장", "발주", "니다"]:
    print(f"'{term}'  문서빈도 {index.df.get(term, 0)}/{len(index.chunks)}   idf {index.idf(term):.2f}")
실행 결과
'어음'  문서빈도 1/8   idf 1.79
'송장'  문서빈도 3/8   idf 0.94
'발주'  문서빈도 4/8   idf 0.69
'니다'  문서빈도 8/8   idf 0.06

💡 바꿔 보기: "입고", "공급" 같은 단어를 넣어 문서빈도와 idf 를 비교해 보세요.

예제 4 같은 정보, 두 가지 주입 방식

사전 주입 — 하네스가 먼저 검색해 질문 앞에 붙입니다. 모델은 검색이 있었는지도 모릅니다.

검색 도구 — search_docs 도구를 주면 모델이 검색어를 직접 골라 여러 번 찾을 수 있습니다(agentic RAG). 아래는 사전 주입된 user 메시지의 실제 모습입니다.

import solution as rag

index = rag.Index(rag.load_docs())
print(rag.augment(index, "송장에 발주번호가 없으면?", k=1))
실행 결과
<retrieved>
<doc id="04_송장_접수.md#0">
# 송장 접수 안내

공급사는 대금 청구를 위해 세금계산서(송장)를 발행합니다. 송장에는 반드시 발주번호를 기재해야 합니다.

발주번호가 없는 송장은 접수하지 않고 공급사에 반려합니다.

송장의 공급가액과 세액은 시스템에 그대로 입력하며, 임의로 수정하지 않습니다.
</doc>
</retrieved>

질문: 송장에 발주번호가 없으면?

예제 5 연결 고리를 놓치는 RAG

"대금지급 전에 검수가 끝나야 하나요?" — 답하려면 대금지급 → 삼자대사 → 입고 → 검수 를 이어야 합니다. 그런데 질문에 없는 중간 단어(삼자대사·입고) 문서는 top-3 에 잘 안 뽑힙니다. 청크 검색의 구조적 한계이고, Lab 10 의 온톨로지가 이 틈을 메웁니다.

import solution as rag

index = rag.Index(rag.load_docs())
need = {"06_대금지급_규정.md", "05_대사_통제.md", "02_입고_처리.md", "03_수입검사_기준.md"}
got = {c["source"] for _, c in index.search("대금지급 전에 검수가 끝나야 하나요?", k=3)}
print("필요한 문서:", sorted(need))
print("top-3 결과 :", sorted(got))
print("놓친 고리  :", sorted(need - got))
실행 결과
필요한 문서: ['02_입고_처리.md', '03_수입검사_기준.md', '05_대사_통제.md', '06_대금지급_규정.md']
top-3 결과 : ['03_수입검사_기준.md', '04_송장_접수.md', '06_대금지급_규정.md']
놓친 고리  : ['02_입고_처리.md', '05_대사_통제.md']

3 확인 퀴즈

Q1 '대금지급 전에 검수가 끝나야 하나요?' 에 RAG top-3 가 놓친 것은?

Q2 BM25 에서 idf 가 크다는 뜻은?

4 실습: exercise.py 채우기

TODO 표시 부분을 채우고 채점하세요. 입력 내용은 이 기기에 자동 저장됩니다.

5 정답과 테스트

정답 코드 보기 (solution.py)
#!/usr/bin/env python3
"""Lab 9 — RAG는 원래 하네스 패턴이었다.

RAG(검색 증강 생성)도 결국 "배열에 무엇을 넣을지" 고르는 하네스 코드입니다.
  색인  : 문서를 청크로 쪼개고 검색할 수 있게 만든다
  검색  : 질문과 비슷한 청크 top-k 를 고른다
  주입  : 두 가지 방식
          (a) 사전 주입 — 하네스가 먼저 검색해 user 메시지에 붙인다 (모델은 검색을 모름)
          (b) 도구 방식 — search_docs 도구를 주고 모델이 필요할 때 검색한다 (agentic RAG)

외부 라이브러리 없이 BM25 + 한글 글자 바이그램으로 구현합니다.
(임베딩 벡터 검색으로 바꿔도 하네스 쪽 구조는 똑같습니다)

실패 지점: 청크로 쪼개는 순간 문서 사이의 '관계'가 흩어지고, top-k 가 연결 고리를
놓칠 수 있습니다. 이 한계를 Lab 10 온톨로지와 비교합니다.

실행:  python labs/09_rag/solution.py "대금지급 조건이 뭐야?"
"""
import glob
import math
import os
import re
import sys
from collections import Counter

ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, ROOT)
DOCS_DIR = os.path.join(ROOT, "data", "procurement", "docs")


# --------------------------------------------------------------- 토큰화 (제공)
def tokenize(text):
    """영숫자 단어는 그대로, 한글 단어는 글자 바이그램으로 쪼갠다.
    '대금지급은' → ['대금', '금지', '지급', '급은']  (형태소 분석기 없이도 부분 일치 가능)"""
    tokens = []
    for w in re.findall(r"[가-힣]+|[a-zA-Z0-9]+", text.lower()):
        if re.match(r"[가-힣]", w) and len(w) >= 2:
            tokens += [w[i:i + 2] for i in range(len(w) - 1)]
        else:
            tokens.append(w)
    return tokens


# ------------------------------------------------------------------ 청크 나누기
def chunk_text(text, source, max_chars=200):
    """빈 줄로 문단을 나누고, 문단을 순서대로 이어 붙이다가 max_chars 를 넘으면 새 청크를 시작한다.
    (문단 하나가 max_chars 보다 길면 그 문단 혼자 한 청크)
    반환: [{"id": f"{source}#{i}", "source": source, "text": ...}, ...]  (i 는 0부터)
    문단 사이는 "\\n\\n" 으로 잇는다."""
    # >>> TODO: 문단 단위로 묶어 청크 리스트를 만드세요
    paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
    chunks, cur = [], ""
    for p in paras:
        if cur and len(cur) + 2 + len(p) > max_chars:
            chunks.append(cur)
            cur = p
        else:
            cur = f"{cur}\n\n{p}" if cur else p
    if cur:
        chunks.append(cur)
    return [{"id": f"{source}#{i}", "source": source, "text": c} for i, c in enumerate(chunks)]
    # <<< TODO


def load_docs(docs_dir=DOCS_DIR, max_chars=200):
    chunks = []
    for path in sorted(glob.glob(os.path.join(docs_dir, "*.md"))):
        with open(path, encoding="utf-8") as f:
            chunks += chunk_text(f.read(), os.path.basename(path), max_chars)
    return chunks


# ----------------------------------------------------------------------- 색인
class Index:
    K1, B = 1.5, 0.75

    def __init__(self, chunks):
        self.chunks = chunks
        self.tfs = [Counter(tokenize(c["text"])) for c in chunks]
        self.lens = [sum(tf.values()) for tf in self.tfs]
        self.avglen = sum(self.lens) / max(len(self.lens), 1)
        self.df = Counter(t for tf in self.tfs for t in tf)

    def idf(self, term):
        n, df = len(self.chunks), self.df.get(term, 0)
        return math.log(1 + (n - df + 0.5) / (df + 0.5))

    def score(self, query_tokens, i):
        """BM25 점수:  Σ_t idf(t) · tf·(K1+1) / (tf + K1·(1 − B + B·len/avglen))
        t 는 질문 토큰(중복 제거), tf 는 청크 i 에서 t 의 빈도. tf=0 이면 기여 0."""
        # >>> TODO: 위 공식을 구현하세요
        tf, length, s = self.tfs[i], self.lens[i], 0.0
        for t in set(query_tokens):
            f = tf.get(t, 0)
            if f:
                s += self.idf(t) * f * (self.K1 + 1) / (
                    f + self.K1 * (1 - self.B + self.B * length / self.avglen))
        return s
        # <<< TODO

    def search(self, query, k=3):
        """점수가 0 보다 큰 청크를 점수 내림차순으로 k 개. [(score, chunk), ...]"""
        # >>> TODO: 모든 청크를 채점하고 정렬해 상위 k 개를 반환하세요
        q = tokenize(query)
        scored = [(self.score(q, i), c) for i, c in enumerate(self.chunks)]
        scored = [x for x in scored if x[0] > 0]
        scored.sort(key=lambda x: -x[0])
        return scored[:k]
        # <<< TODO


def format_hits(hits):
    """검색 결과를 모델이 출처를 인용할 수 있는 형태로."""
    if not hits:
        return "(검색 결과 없음)"
    return "\n\n".join(f'<doc id="{c["id"]}">\n{c["text"]}\n</doc>' for _, c in hits)


# ------------------------------------------------------- 하네스에 끼우는 두 방식
def augment(index, question, k=3):
    """(a) 사전 주입: 검색 결과를 <retrieved> 로 감싸 질문 '앞'에 붙인 user 메시지 문자열.
    형식:
        <retrieved>
        {format_hits(...)}
        </retrieved>

        질문: {question}
    """
    # >>> TODO: 위 형식대로 문자열을 만드세요
    return f"<retrieved>\n{format_hits(index.search(question, k))}\n</retrieved>\n\n질문: {question}"
    # <<< TODO


SEARCH_TOOL = {
    "name": "search_docs",
    "description": "사내 구매 규정 문서를 키워드로 검색해 관련 단락을 출처 id와 함께 돌려준다.",
    "input_schema": {"type": "object",
                     "properties": {"query": {"type": "string"},
                                    "k": {"type": "integer"}},
                     "required": ["query"]},
}


def make_search_executor(index, base_execute):
    """(b) 도구 방식: search_docs 는 index 로, 나머지는 base_execute 로."""
    def execute(name, args):
        if name == "search_docs":
            return format_hits(index.search(args["query"], int(args.get("k", 3))))
        return base_execute(name, args)
    return execute


if __name__ == "__main__":
    q = " ".join(sys.argv[1:]) or "대금지급 전에 검수가 끝나야 하나요?"
    idx = Index(load_docs())
    print(f"청크 {len(idx.chunks)}개 색인 / 질문: {q}\n")
    for s, c in idx.search(q, k=3):
        print(f"{s:6.2f}  {c['id']}  {c['text'][:60].replace(chr(10), ' ')}…")
채점 테스트 보기 (test_lab.py)
import os
import sys
import unittest

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from common.labkit import load_target  # noqa: E402
from common.brain import ScriptedBrain, reply, text, tool_use  # noqa: E402
from common.loop import run_loop  # noqa: E402

lab = load_target(__file__)


class Lab9(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.index = lab.Index(lab.load_docs())

    def test_chunking_packs_paragraphs(self):
        doc = "가" * 50 + "\n\n" + "나" * 50 + "\n\n" + "다" * 150
        cs = lab.chunk_text(doc, "d.md", max_chars=120)
        self.assertEqual([c["id"] for c in cs], ["d.md#0", "d.md#1"])
        self.assertEqual(cs[0]["text"], "가" * 50 + "\n\n" + "나" * 50)
        self.assertEqual(cs[1]["text"], "다" * 150, "긴 문단은 혼자 한 청크")
        self.assertEqual(cs[0]["source"], "d.md")

    def test_bm25_prefers_matching_chunk(self):
        hits = self.index.search("중복 송장 차단", k=2)
        self.assertEqual(hits[0][1]["source"], "05_대사_통제.md")
        self.assertGreaterEqual(hits[0][0], hits[1][0])

    def test_search_excludes_zero_scores(self):
        self.assertEqual(self.index.search("zzqqxx", k=3), [])

    def test_irrelevant_doc_not_retrieved(self):
        sources = [c["source"] for _, c in self.index.search("대금지급 규정", k=3)]
        self.assertNotIn("07_사내공지_식당.md", sources)

    def test_augment_format(self):
        msg = lab.augment(self.index, "지급은 어떻게 하나요?", k=2)
        self.assertTrue(msg.startswith("<retrieved>"))
        self.assertIn('<doc id="06_대금지급_규정.md#0">', msg)
        self.assertTrue(msg.rstrip().endswith("질문: 지급은 어떻게 하나요?"))

    def test_agentic_rag_tool(self):
        brain = ScriptedBrain([
            reply(tool_use("search_docs", {"query": "어음 지급"}, id="S")),
            reply(text("어음 지급은 하지 않습니다 [06_대금지급_규정.md#0]")),
        ])
        msgs = [{"role": "user", "content": "어음으로 줘도 돼?"}]
        ex = lab.make_search_executor(self.index, lambda n, a: "base")
        run_loop(brain, msgs, [lab.SEARCH_TOOL], "sys", execute=ex)
        self.assertIn("어음 지급은 하지 않습니다", msgs[2]["content"][0]["content"])

    def test_multi_hop_gap(self):
        """관찰용: '대금지급 ↔ 검수'를 잇는 고리(삼자대사·입고) 문서가 top-3 에 없다.
        RAG 의 구조적 한계 — Lab 10 에서 온톨로지로 메웁니다."""
        sources = {c["source"] for _, c in
                   self.index.search("대금지급 전에 검수가 끝나야 하나요?", k=3)}
        self.assertFalse({"05_대사_통제.md", "02_입고_처리.md"} <= sources)


if __name__ == "__main__":
    unittest.main()

📚 더 읽을거리

교재 전체 목록 →