RAG 원리 — AI 문서 검색을 TF-IDF 검색기로 직접 만들어 보기, 내 블로그 68편에서 질문 15개 실측

사내 문서나 매뉴얼을 넣어 두고 “우리 자료 기준으로 답해 줘”라고 하는 AI 서비스는 대부분 RAG(검색 증강 생성) 방식입니다. 이름은 어렵지만 RAG 원리는 단순합니다. 질문과 관련 있는 문서 조각을 먼저 찾고, 그 조각만 질문과 함께 AI에 넘깁니다. 문서 전체를 넣을 수 없으니 “무엇을 골라 넣느냐”가 답의 품질을 좌우합니다. 그래서 그 검색 부분을 파이썬 표준 라이브러리만으로 직접 만들어, 이 블로그의 원고 68편(본문 49만 6천여 자)에 질문 15개를 던져 봤습니다. 가장 나은 방식이 15개 중 10개에서 정답 글을 1위로 찾았고, 한 번의 질문에 AI로 넘길 자료는 약 1,100자, 전체의 0.23%였습니다. 반대로 틀린 5개를 뜯어보니 RAG가 왜 엉뚱한 답을 하는지가 그대로 드러났습니다. 환경은 윈도우 11, Python 3.12.10, Node 24이고 설치한 패키지는 없습니다.

RAG에서 검색이 하는 일

RAG는 세 단계로 나눠 볼 수 있습니다.

  1. 검색: 질문과 관련 있는 문서(또는 문서 조각)를 고릅니다.
  2. 프롬프트 조립: 고른 조각을 “아래 자료만 근거로 답하라”는 지시와 함께 질문 앞에 붙입니다.
  3. 생성: AI가 그 자료를 읽고 답합니다.

이번 글은 1·2단계를 직접 만듭니다. 3단계 AI 호출은 하지 않았고, 대신 AI에 보낼 프롬프트를 파일로 저장해 무엇이 들어가는지 확인했습니다. 실제 서비스들은 검색에 “임베딩”이라는 뜻 기반 방식을 많이 쓰지만, 원리를 눈으로 보기에는 단어가 얼마나 겹치는지로 점수를 매기는 TF-IDF가 좋습니다. 점수가 어디서 나왔는지 단어 단위로 쪼개 볼 수 있기 때문입니다.

  • TF(단어 빈도): 한 문서에 그 단어가 많이 나올수록 점수가 올라갑니다. 10번 나왔다고 10배가 되지 않도록 로그를 씌웁니다.
  • IDF(역문서 빈도): “하는”, “있습니다”처럼 모든 문서에 나오는 단어는 가중치를 낮추고, 일부 문서에만 나오는 단어는 높입니다.
  • 코사인 유사도: 질문과 문서를 단어별 점수 목록으로 만든 뒤 두 목록이 얼마나 같은 방향인지 계산합니다. 긴 문서가 무조건 유리해지지 않습니다.

검색기 전체 코드 (tfidf.py)

측정에 쓴 모듈 그대로입니다. 외부 패키지 없이 66줄입니다.

"""TF-IDF 문서 검색기 — 표준 라이브러리만 사용"""
import math
import re
from collections import Counter

WORD = re.compile(r"[0-9A-Za-z가-힣_]+")


def tokens_word(text):
    """띄어쓰기 기준 단어 (영문은 소문자로)"""
    return [w.lower() for w in WORD.findall(text)]


def tokens_bigram(text):
    """단어마다 글자 2개씩 잘라 붙인 조각 (한 글자 단어는 그대로)"""
    out = []
    for w in tokens_word(text):
        if len(w) == 1:
            out.append(w)
        else:
            out.extend(w[i:i + 2] for i in range(len(w) - 1))
    return out


def split_chunks(text, size=400):
    """문장 단위로 이어 붙여 size자 안팎의 조각으로 나누기"""
    chunks, buf = [], ""
    for sent in re.split(r"(?<=[.!?다요])\s+", text):
        if buf and len(buf) + len(sent) > size:
            chunks.append(buf)
            buf = ""
        buf = (buf + " " + sent).strip()
    if buf:
        chunks.append(buf)
    return chunks


class Index:
    def __init__(self, items, tokenize):
        """items: [(id, text), ...]"""
        self.tokenize = tokenize
        self.ids = [i for i, _ in items]
        self.texts = [t for _, t in items]
        counts = [Counter(tokenize(t)) for t in self.texts]
        n = len(counts)
        df = Counter()
        for c in counts:
            df.update(c.keys())
        # 흔한 단어일수록 낮은 가중치 (smooth idf)
        self.idf = {w: math.log((1 + n) / (1 + d)) + 1 for w, d in df.items()}
        self.vecs = [self._vector(c) for c in counts]

    def _vector(self, counts):
        v = {w: (1 + math.log(tf)) * self.idf[w] for w, tf in counts.items() if w in self.idf}
        norm = math.sqrt(sum(x * x for x in v.values())) or 1.0
        return {w: x / norm for w, x in v.items()}

    def search(self, query, k=3):
        q = self._vector(Counter(self.tokenize(query)))
        scores = []
        for i, v in enumerate(self.vecs):
            s = sum(x * v.get(w, 0.0) for w, x in q.items())
            if s > 0:
                scores.append((s, i))
        scores.sort(reverse=True)
        return [(self.ids[i], round(s, 3), self.texts[i]) for s, i in scores[:k]]

토큰을 두 가지로 만든 게 핵심입니다. tokens_word는 띄어쓰기 단위 단어, tokens_bigram은 단어를 두 글자씩 잘라 낸 조각입니다. 한국어는 “파일을”, “파일이”, “파일로”가 조사 때문에 전부 다른 단어가 되는데, 두 글자씩 자르면 셋 다 “파일” 조각을 공유합니다. split_chunks는 문장을 이어 붙여 400자 안팎의 조각으로 나눕니다.

검색할 문서 준비 — 이 블로그의 원고 68편

예제 문서를 따로 만들지 않고 이 블로그 원고 파일을 그대로 썼습니다. 원고는 자바스크립트 파일(제목·카테고리·본문 HTML)로 저장돼 있어서 node로 읽어 태그를 지우고 docs.json에 담았습니다.

$ node dump.js
문서 68개, 본문 합계 496,473자
건너뜀: _reschedule.js (사용법: node drafts/_reschedule.js <postId> <ISO날짜>)

원고 폴더의 파일 69개 중 하나는 예약 시각을 바꾸는 도구 스크립트라 읽는 순간 사용법 오류를 냈고, 자동으로 건너뛰었습니다. 문서 준비에서 처음 걸린 건 오히려 출력이었습니다. 제목이 제대로 들어갔는지 파이썬으로 찍어 보다가 바로 죽었습니다.

UnicodeEncodeError: 'cp949' codec can't encode character '\u2014' in position 15: illegal multibyte sequence

제목에 들어 있는 줄표(—)를 윈도우 콘솔 기본 인코딩(CP949)으로 출력하지 못한 것입니다. python -X utf8로 실행하면 통과했고, 이후 스크립트에는 sys.stdout.reconfigure(encoding="utf-8")를 넣었습니다. 한국어 문서를 다루는 검색기라면 첫 줄에 넣어 두는 편이 낫습니다.

질문 15개로 재 본 정확도

제목을 그대로 베끼지 않고 사람이 실제로 물을 법한 말로 질문 15개를 만들었습니다. 예를 들어 “모듈을 찾을 수 없다는 에러가 나요”의 정답은 ModuleNotFoundError 글, “8080 포트가 이미 사용 중”의 정답은 포트 충돌 글입니다. 토큰 방식 두 가지와 검색 단위 두 가지(글 전체 / 400자 조각 1,334개)를 조합해 비교했습니다.

토큰 / 단위1위가 정답3위 안에 정답질문당 검색
단어 / 글 전체7/1512/150.1ms
단어 / 조각6/1511/151.3ms
2글자 / 글 전체10/1512/150.2ms
2글자 / 조각7/1510/152.1ms

두 가지가 눈에 띄었습니다.

  • 두 글자로 자르니 1위 정답이 7개에서 10개로 늘었습니다. 서로 다른 단어 수(어휘)도 19,081개에서 10,723개로 줄었습니다. 조사가 붙은 변형들이 하나로 모인 결과입니다.
  • 조각 단위가 오히려 나빴습니다. RAG 설명에서는 보통 “문서를 잘게 나눠 검색한다”고 하는데, 이번 데이터에서는 400자 조각으로 찾으면 1위 정답이 10개에서 7개로 떨어졌습니다. 짧은 조각은 질문 단어 한두 개만 우연히 겹쳐도 점수가 높게 나와서, 엉뚱한 글의 한 문단이 1위로 올라오는 경우가 생겼습니다.

그래서 아래 RAG 예제는 두 단계로 찾습니다. 먼저 글 전체 기준으로 관련 글 2개를 고르고, 그 두 글의 조각들 안에서만 다시 순위를 매겨 3개를 고릅니다. 색인 준비는 68편 전체에 0.15초, 질문 하나 검색은 4~8ms였습니다.

검색 결과로 AI에 보낼 프롬프트 만들기

from tfidf import Index, split_chunks, tokens_bigram

# docs = [{"file": ..., "title": ..., "text": ...}, ...]
doc_index = Index([(d["file"], d["title"] + " " + d["text"]) for d in docs], tokens_bigram)
titles = {d["file"]: d["title"] for d in docs}
chunks = {d["file"]: split_chunks(d["text"]) for d in docs}


def retrieve(question, n_docs=2, n_chunks=3):
    # 1단계: 글 전체 기준으로 관련 글 고르기
    hits = doc_index.search(question, k=n_docs)
    # 2단계: 고른 글의 조각들만 모아 다시 순위 매기기
    items = [(f, c) for f, _, _ in hits for c in chunks[f]]
    chunk_index = Index(items, tokens_bigram)
    return hits, chunk_index.search(question, k=n_chunks)


def build_prompt(question, picked):
    ctx = "\n\n".join(f"[출처: {titles[f]}]\n{text}" for f, _, text in picked)
    return ("아래 자료만 근거로 질문에 답하세요. 자료에 없으면 모른다고 답하세요.\n\n"
            f"{ctx}\n\n질문: {question}")

“예약한 글이 9시간 늦게 올라감”이라고 물었을 때의 결과입니다.

$ python rag.py   # 기본 질문: 예약한 글이 9시간 늦게 올라감
색인 준비: 0.15초
질문: 예약한 글이 9시간 늦게 올라감  (검색 7.6ms)
  관련 글 0.108  예약 발행이 엉뚱한 시각에 나갈 때 — 타임존 점검 순서 5단계
  관련 글 0.071  RSS로 내 글이 실제로 올라갔는지 자동 확인하기 — 파이썬 표준 라이브
  조각 0.154  1분이면 끝나고, 차이가 9시간이 아니라면 그 자리에서 원인이 드러납니다. 그다음으로 발행 스크립트에서 da...
  조각 0.114  어긋난 정도 유력한 원인 확인할 곳 정확히 9시간 늦게/빠르게 한쪽이 UTC, 한쪽이 한국 시각 사이트 시간...
  조각 0.111  윈도우 작업 스케줄러는 PC 로컬 시각을 따르므로 보통 문제가 없지만, 트리거 설정의 "표준 시간대 간 동기...
프롬프트 1,143자 (전체 글 496,473자의 0.23%)

타임존 점검 글이 1위로 나왔고, 고른 조각은 “정확히 9시간 늦게/빠르게 → 한쪽이 UTC, 한쪽이 한국 시각” 같은 원인 표와 시간대 설정 확인 문단이었습니다. 49만 자 가운데 1,143자만 AI에 넘기면 되니, AI 입장에서는 짧고 질문에 맞는 참고자료를 받은 셈입니다.

검색이 틀리면 AI도 틀린다 — 놓친 질문 뜯어보기

정확도가 가장 높았던 방식도 “커밋에 비밀번호를 올려버렸어요”는 놓쳤습니다. 이때 만들어진 프롬프트를 보면 문제가 바로 보입니다.

$ python rag.py "커밋에 비밀번호를 올려버렸어요"
질문: 커밋에 비밀번호를 올려버렸어요  (검색 4.4ms)
  관련 글 0.082  환경변수(.env) 파일로 API 키 안전하게 관리하는 법
  관련 글 0.065  블로그 자동 포스팅 시스템 구축 경험담 (직접 만든 프로젝트 후기)
  조각 0.149  이유는 세 가지였습니다. 깨지는 지점이 너무 많다. 에디터가 업데이트되면서 버튼 위치가 바뀌면 그날 발행이 ...
  조각 0.148  워드프레스는 API 전용 자격증명인 애플리케이션 비밀번호 를 따로 제공합니다. 관리자 > 사용자 > 프로필 ...
  조각 0.099  만들고 나서 달라진 것 현재 이 시스템은 매일 오전 9시에 돌아가고, 저는 예약 목록을 훑어보는 데 하루 3...

정답인 “깃에 실수로 올린 비밀키 지우기” 글은 2위 안에도 없고, AI에 넘어갈 자료는 블로그 자동화 경험담과 워드프레스 애플리케이션 비밀번호 이야기입니다. AI가 “자료만 근거로 답하라”는 지시를 잘 지킬수록 오히려 엉뚱한 답이 나올 수 있는 구조입니다. RAG 서비스가 그럴듯하게 틀린 답을 할 때, 원인이 AI가 아니라 검색 단계인 경우가 이런 모습입니다.

왜 놓쳤는지 점수를 단어 조각별로 쪼개 보고, 정답 글에 질문 단어가 몇 번 나오는지 세어 봤습니다.

Q: 한글이 이상한 문자로 깨져서 나와요
  1위 article-0920-filepath.js: 0.047 = 깨져 0.016, 자로 0.013, 문자 0.010, 한글 0.007
  정답 2026-09-12-korean-encoding-error.js: 0.042 = 한글 0.012, 자로 0.009, 글이 0.009, 이상 0.006
Q: 디스크 공간을 많이 차지하는 폴더 찾기
  1위 2026-10-09-automation-healthcheck.js: 0.121 = 공간 0.039, 디스 0.024, 차지 0.020, 지하 0.014
  정답 2026-10-11-folder-size-script.js: 0.028 = 디스 0.013, 폴더 0.009, 스크 0.004, 하는 0.002

2026-09-12-korean-encoding-error.js {'깨져': 0, '깨짐': 2, '깨지': 6}
2026-10-15-git-remove-big-file-secret.js {'비밀번호': 0, '비밀키': 12, '토큰': 0}
2026-10-11-folder-size-script.js {'디스크': 2, '용량': 8, '공간': 0}

세 질문 모두 원인이 같았습니다. 같은 뜻인데 다른 단어였습니다. 정답 글은 “깨져” 대신 “깨짐·깨지”, “비밀번호” 대신 “비밀키”, “공간” 대신 “용량”을 썼습니다. 한글 깨짐 질문은 0.047 대 0.042로 아깝게 졌는데, 1위를 한 파일 경로 글에 “깨져”라는 표현이 있었기 때문입니다. 디스크 질문은 헬스체크 글에 “디스크 여유” 점검 항목이 있어서 “공간”이 다섯 번 나왔고, 정답 글은 “공간”이 한 번도 없어 크게 밀렸습니다.

확인 삼아 질문의 한 단어만 글에 쓰인 말로 바꿔 다시 돌렸습니다.

$ python rag.py "커밋에 비밀키를 올려버렸어요"
질문: 커밋에 비밀키를 올려버렸어요  (검색 8.2ms)
  관련 글 0.11  git 커밋 파일 삭제 —실수로 올린 큰 파일·비밀키를 히스토리에서 완전
  관련 글 0.092  환경변수(.env) 파일로 API 키 안전하게 관리하는 법
  조각 0.14  상황별 정리와 작업 순서 상황 방법 실측에서 확인한 점 방금 커밋, push 전 git rm --cached...
  조각 0.126  실험용 저장소를 TEMP 폴더에 만들고 네 번 커밋했습니다. 두 번째 커밋에서 50MB짜리 backup.zi...
  조각 0.114  백업 압축 파일이나 API 키가 든 설정 파일을 깃에 같이 올렸다는 걸 뒤늦게 알면, 보통 git rm 으로...

“비밀번호”를 “비밀키”로 바꾸자 정답 글이 1위로 올라왔고 조각 세 개가 모두 그 글에서 나왔습니다. “공간”을 “용량”으로 바꾼 질문은 정답 글이 2위까지 올라왔지만 1위는 여전히 헬스체크 글(0.077 대 0.059)이었습니다. 단어 하나에 결과가 갈리는 것, 이게 글자 겹침으로 찾는 방식의 한계이고 실제 RAG 서비스들이 뜻으로 찾는 임베딩 검색이나 두 방식을 섞어 쓰는 이유입니다.

직접 만들어 보고 정리한 주의할 점

  • 한국어는 띄어쓰기 단어로 자르지 마세요. 두 글자 조각만으로 1위 정답이 7개에서 10개로 늘었습니다.
  • 조각 크기는 재 보고 정하세요. 잘게 나눌수록 좋은 게 아니었습니다. 이번 데이터에서는 글 전체로 먼저 거르고 그 안에서 조각을 고르는 두 단계가 나았습니다.
  • AI 답이 이상하면 프롬프트에 들어간 자료부터 보세요. 놓친 질문의 프롬프트에는 정답과 무관한 자료만 들어 있었습니다. 검색 결과를 파일로 남겨 두면 원인을 바로 찾을 수 있습니다.
  • 문서에 쓰는 말과 사람이 묻는 말이 다릅니다. 비밀번호/비밀키, 공간/용량처럼 자주 엇갈리는 말은 문서 쪽에 함께 적어 두거나 질문을 바꿔 다시 찾는 단계를 두는 방법이 있습니다. 이번에도 한 단어를 바꾸자 정답이 1위로 올라온 경우가 있었습니다.
  • 윈도우에서는 출력 인코딩을 먼저 맞추세요. 문서에 줄표 하나만 있어도 CP949 콘솔에서 출력이 죽었습니다.

마무리

RAG에서 AI가 볼 수 있는 건 검색이 골라 준 1,100자뿐입니다. 그 1,100자가 틀리면 AI가 아무리 좋아도 답이 틀립니다.

가장 작은 첫걸음은 이렇습니다.

  1. 위 tfidf.py를 저장하고, 자기 문서 폴더(메모, 매뉴얼, 회의록)를 (파일 이름, 본문) 목록으로 읽어 Index에 넣어 봅니다.
  2. 실제로 물어볼 법한 질문 10개와 정답 파일을 적어 두고, 1위·3위 안 정답 개수를 셉니다.
  3. 놓친 질문은 점수를 단어별로 쪼개 보고, 문서와 질문의 단어가 어떻게 엇갈리는지 확인합니다.

다음 글에서는 파이썬 RecursionError가 왜 나는지, 재귀 함수를 반복문으로 바꾸면 무엇이 달라지는지 직접 돌려 보며 정리하겠습니다.

댓글 남기기