본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 02. 08:19

Markdown 문서 기반의 RAG 유사 봇 구축하기 — 스크래핑, PostgreSQL, 그리고 BM25 검색을 통한 프롬프트 강화

요약

Markdown 문서를 기반으로 환각 현상을 최소화하는 RAG 유사 봇 구축 방법을 설명합니다. GitHub API 스크래핑부터 PostgreSQL 저장, 그리고 벡터 인프라 없이 BM25 검색을 활용하는 아키텍처를 다룹니다.

핵심 포인트

  • Markdown 데이터를 활용한 RAG 기반 Q&A 봇 구축
  • API 속도 제한(Rate limits)을 준수하는 스크래핑 전략
  • 벡터 인프라 없이 BM25를 활용한 효율적인 검색 방식
  • PostgreSQL을 이용한 데이터 저장 및 관리

에이전트(Agents)가 사용할 수 있도록 Markdown 파일의 데이터를 재사용하는 방법은 무엇일까요? 오늘은 해당 문서에 근거하여 질문에 답변하는 Q&A 봇을 구축하는 저의 접근 방식을 공유하고자 합니다. API 시그니처(API signatures)를 환각(Hallucinate)하거나 설정 옵션을 잘못 기억하지 않는 시스템을 만드는 법입니다.

가장 명백한 해결책은 RAG(Retrieval-Augmented Generation)입니다. 모든 페이지를 임베딩(Embed)하고, 벡터(Vectors)를 저장하며, 쿼리 시점에 가장 유사한 청크(Chunks)를 검색하는 방식이죠. 이 방법은 효과적이지만, 아무것도 없는 상태에서 RAG로 바로 뛰어드는 것은 너무 큰 도약입니다. 이 글을 쓰면서 저 또한 RAG를 어디서부터 시작해야 할지 몰랐던 기억이 나는데, 더 나아가 실제 운영 환경의 대부분의 AI 시스템은 RAG만 단독으로 사용하는 것이 아니라 전통적인 검색(Traditional search)과 결합하여 사용하는 경우가 많다는 사실을 몰랐습니다.

이 포스트는 arxiv-paper-curator에서 영감을 받은 바로 그 접근 방식을 설명합니다. 이 프로젝트는 벡터 인프라(Vector infrastructure) 없이 BM25를 사용하여 학술 논문을 검색합니다.

개요 (Overview)

우리는 아래에 설명된 아키텍처를 설정할 것이며, 여기에는 Markdown 스크래핑(Scraping), 저장(Storing), 인덱싱(Indexing), 검색(Retrieving) 및 프롬프트 생성(Prompt generation)이 포함됩니다.

GitHub API로부터 가져오기 (Fetching from the GitHub API)

저의 사례에서는 GitHub API로부터 파일을 가져옵니다. 하지만 여러분이 사용하는 API가 무엇이든, 가장 중요한 점은 속도 제한(Rate limits)을 준수해야 한다는 것입니다.

def list_md_files(client, owner, repo, ref, path, headers) -> list[dict]:
    url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{ref}?recursive=1"
    tree = github_get(client, url, headers).json()["tree"]
...

속도 제한 시스템을 갖춘 대부분의 백엔드 시스템은 클라이언트에게 남은 요청 횟수와 각 요청 사이의 시간에 대한 정보를 제공합니다. GitHub의 경우, 모든 GitHub 응답에는 X-RateLimit-RemainingX-RateLimit-Reset이 포함되어 있습니다.

스크래퍼는 매 호출 후에 이를 읽으며, 카운트가 낮아지면 선제적으로 휴식(sleep)합니다. 만약 403 또는 429 응답이 반환되면 Retry-After 헤더를 기다린 후 최대 3번까지 재시도합니다.

PostgreSQL에 저장하기

처음에는 로컬의 JSON 파일로 시작하세요. 나중에는 데이터베이스 저장 방식으로 전환합니다. 저의 사용 사례에서 블로그 포스트는 메타데이터와 방대한 본문 텍스트를 가지고 있습니다. 파일의 원시 표현(raw representation)을 이미 가지고 있으므로, 저장 최적화를 위해 데이터베이스에는 GitHub URL 참조를 저장할 수 있습니다. 규모에 따라 이것이 적절한 엔지니어링 방식이 될 수 있습니다. 엔지니어링에서 항상 그렇듯 여러 트레이드오프(trade-offs)가 존재하지만, 저장 공간이 문제가 되지 않는다면 데이터베이스에 전체 본문을 저장하는 것도 유효한 방법입니다.

스키마는 간단합니다:

CREATE TABLE doc_pages (
    slug       TEXT PRIMARY KEY,
    title      TEXT NOT NULL,
...

모든 스크래핑 실행은 ON CONFLICT (slug) DO UPDATE를 사용하므로, 재실행이 항상 안전합니다. 이는 추가(append)가 아닌 업서트(upsert) 방식입니다. 콘텐츠가 변경되지 않은 페이지의 경우 updated_at만 변경됩니다.

async def upsert_page(self, page: dict) -> None:
    await self._conn.execute(
        """
...

스크래퍼를 로컬, GitHub Actions 작업, 또는 스케줄에 따라 어디에서든 실행하면 결과가 데이터베이스에 영구적으로 저장되어, API가 다음 시작 시점에 읽을 수 있도록 준비됩니다.

요청 시점의 BM25 검색

그렇다면 BM25란 무엇일까요? BM25는 키워드 순위 지정 알고리즘(keyword ranking algorithm)입니다. 쿼리가 주어지면, 말뭉치(corpus) 전체에서 각 용어가 얼마나 희귀한지에 따라 가중치를 둔 용어 빈도(term frequency)를 바탕으로 각 문서의 점수를 매깁니다. Python 라이브러리인 rank-bm25가 이를 구현합니다.

일관된 어휘를 사용하는 기술 문서 세트의 경우, BM25는 정확한 일치(exact matches)에 대해 시맨틱 검색(semantic search)보다 성능이 뛰어난 경우가 많습니다. 만약 사용자가 "what is the rate limit for the search endpoint?"라고 질문한다면, BM25는 해당 단어들이 문자 그대로 포함된 페이지를 최상단에 배치할 것입니다.

BM25는 비용 면에서도 훨씬 저렴합니다. 임베딩 (Embeddings)은 새로운 문서를 인덱싱하거나 기존 문서를 업데이트할 때마다 API 호출(및 API 비용)이 필요합니다. 반면 BM25는 원문 텍스트로부터 메모리 내에서 재인덱싱을 수행하므로, 외부 호출도 없고 비용도 들지 않습니다.

세 번째 장점은 디버깅 가능성 (debuggability)입니다. BM25가 잘못된 결과를 반환할 때, 왜 그런 결과가 나왔는지 정확히 조사할 수 있습니다. 쿼리 토큰 (query tokens)이 문서 본문에 나타나는지 확인하고, 단어 빈도 (term frequencies)를 살펴보거나 코퍼스 (corpus)를 조정할 수 있습니다. 하지만 벡터 검색 (vector search)이 잘못된 결과를 반환할 때는 직접 조사할 수 없는 1,536차원의 공간을 추론해야 합니다. "왜 이 청크 (chunk)가 저것보다 점수가 더 높게 나왔을까?"라는 질문에 답하려면, 쿼리를 임베딩 모델 (embedding model)에 통과시키고 코사인 유사도 (cosine similarity) 계산을 수동으로 수행하지 않는 한 답하기 어렵습니다.

그렇다고 해서 BM25와 의미론적 검색 (semantic search)이 상호 배타적인 것은 아닙니다. 대부분의 프로덕션 검색 시스템은 두 가지를 모두 사용합니다. BM25는 정확한 일치 (exact matches)와 알려진 용어를 잘 처리하고, 임베딩은 의역 (paraphrase) 및 유의어 (synonym) 쿼리를 잘 처리합니다. 하이브리드 시스템 (hybrid system)은 두 방식을 병렬로 실행하고 순위가 매겨진 결과들을 병합합니다. 초기 단계의 프로젝트라면 BM25만으로 시작하는 것이 올바른 선택입니다. 정밀한 기술적 쿼리에 효과적이고, 운영 비용이 무료이며, BM25의 한계가 어디인지 파악한 후에 임베딩을 추가하는 것은 간단한 확장 작업이기 때문입니다.

전체 RAG 스택은 두 개의 외부 서비스, API 키, 운영을 위한 데이터베이스, 그리고 매 요청마다 발생하는 검색 지연 시간 (retrieval latency)을 필요로 합니다. BM25는 이 중 어느 것도 필요로 하지 않습니다.

시작 시점에 DocsIndexer는 모든 행을 쿼리하여 인덱스를 구축합니다:

class DocsIndexer:
    async def index(self) -> DocSearch:
        async with self._engine.connect() as conn:
...

인덱스는 시작 시 한 번 구축되어 메모리에 유지됩니다. 그 이후의 모든 search() 호출은 순수하게 메모리 내에서 이루어지는 BM25 작업입니다. 수백 페이지 분량의 경우 1밀리초 미만이 소요되며, I/O나 네트워크 작업도 발생하지 않습니다.

코퍼스(Corpus)는 title + section + tags + body를 결합합니다. 태그(Tags)는 "authentication", "rate-limits", "webhooks"와 같이 저자가 페이지에 직접 부여한 어휘이며, 이는 사용자가 해당 주제에 대해 질문할 때 입력할 가능성이 높은 단어들과 일치합니다.

LLM 프롬프트에 주입하기

에이전트는 검색 결과를 가져와 일치하는 페이지들을 시스템 프롬프트(System Prompt)에 추가합니다:

async def run(self, user_message: str) -> str:
    results = self.doc_search.search(user_message, top_k=3)
    messages = [
...

만약 쿼리(Query)가 어떤 문서 페이지와도 키워드 중복이 없다면, search()는 빈 리스트를 반환하며 시스템 프롬프트는 변경되지 않습니다. 모델은 문서 내용을 허구로 만들어내지 않고 자신의 배경 지식(Background Knowledge)을 바탕으로 답변합니다.

한계점 및 수행하지 않는 작업

BM25는 유의어(Synonyms)를 놓칩니다. "How do I authenticate?"라는 질문은 본문에 "authenticate"라는 단어가 나타나지 않는 한 "API Keys and Tokens"라는 제목의 페이지와 일치하지 않습니다. 일관된 기술 어휘를 사용하는 문서의 경우 이는 대개 문제가 되지 않습니다. 하지만 다양한 자연어(Natural Language)로 질문하는 사용자층을 대상으로 한다면, 임베딩(Embeddings)을 이용한 의미론적 검색(Semantic Search)이 더 나은 성능을 보일 것입니다. 장기적인 관점에서의 정답은 하이브리드 검색(Hybrid Retrieval)입니다. 두 방식을 모두 실행하고 결과를 병합하는 것이지만, BM25만으로 시작하는 것이 올바른 출발점입니다.

직접 구현하며 많은 것을 배웠고, 다른 분들도 배울 수 있도록 이를 공유합니다. 이에 대해 어떻게 생각하시는지 알려주세요.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0