Skip to main content

2부 7장. RAG가 필요한 순간: 의미 검색 붙이기

메모가 열 개쯤 있을 때는 search_notes 같은 키워드 검색만으로도 꽤 잘 버틸 수 있다. 그런데 메모가 백 개, 천 개가 되면 분위기가 달라진다. 사람은 대충 기억한다. "분명 예전에 적어둔 것 같은데, 제목은 기억 안 나고… OAuth 토큰 갱신 얘기였나? 아니면 세션 만료 처리였나?" 이런 식이다. 이때 키워드 검색은 정직하지만 답답하고, 의미 검색은 영리하지만 때때로 너무 추상적이다.

이번 장의 목표는 간단하다. PKM-MCP에 semantic_search_notes를 추가해, 우리의 읽기 전용 메모 서버를 RAG 친화적인 서버로 한 단계 끌어올리는 것이다. 여기서 중요한 건 "벡터 DB를 붙였다"가 아니다. 정말 중요한 건 메모가 많아질수록 검색 품질이 곧 서버의 체감 성능이 된다는 사실이다.

2부 7장. RAG가 필요한 순간: 의미 검색 붙이기

검색이 곧 성능인 이유

MCP 서버는 도구를 많이 붙인다고 무조건 좋아지지 않는다. 도구가 아무리 많아도, 필요한 메모를 잘 찾아주지 못하면 AI는 엉뚱한 문서를 읽고 엉뚱한 답을 만든다. 반대로 검색이 좋으면 같은 모델, 같은 메모, 같은 프롬프트여도 답변 품질이 훨씬 좋아진다.

특히 PKM-MCP 같은 개인 지식 서버에서는 이 차이가 크게 드러난다. 개인 메모에는 같은 주제를 서로 다른 말로 적어두는 일이 흔하다. "토큰 갱신", "refresh flow", "재인증", "OAuth 만료 처리"는 사실 비슷한 이야기인데, 키워드 검색은 표면의 단어만 보고 움직인다. 의미 검색은 이런 표현 차이를 어느 정도 흡수해준다.12

여기서 RAG의 역할이 나온다. RAG는 거창한 마법이 아니라, 답을 만들기 전에 관련 문서를 먼저 찾아서 모델에게 읽혀주는 방식이다. 즉, LLM의 기억력에만 기대지 않고, 검색된 메모를 근거로 답하게 만드는 구조다.3

7.1 임베딩과 벡터 DB 짧은 복습

임베딩은 텍스트를 숫자 배열, 즉 벡터로 바꾸는 기술이다. 문장을 숫자로 바꾸면 컴퓨터는 "이 둘이 얼마나 비슷한 의미인가?"를 거리 계산으로 다룰 수 있다. 예를 들어 "연차 정책"과 "휴가 규정"은 글자는 다르지만, 좋은 임베딩 모델에서는 서로 가까운 위치에 놓일 가능성이 높다.14

이 과정을 아주 단순하게 말하면 이렇다.

메모를 잘게 나눈다. 이 조각을 보통 청크라고 부른다. 그런 다음 각 청크를 임베딩 모델로 벡터화한다. 그리고 벡터 DB나 벡터 검색이 가능한 저장소에 넣어둔다. 사용자가 질문하면 질문도 같은 모델로 벡터화하고, 가장 가까운 청크를 찾아 반환한다. 이것이 의미 검색의 기본 흐름이다.34

키워드 검색이 "글자가 같은가?"를 묻는다면, 의미 검색은 "뜻이 가까운가?"를 묻는다. 그래서 "비 오는 날 신기 좋은 러닝화" 같은 자연어 질의가 통한다. 실제로 의미 검색 튜토리얼들은 이런 예를 자주 든다. 정확히 같은 단어가 없어도 관련 결과를 찾는 것이 장점이다.4

다만 의미 검색이 만능은 아니다. 제품 코드, 에러 메시지, 함수 이름, 클래스 이름처럼 한 글자도 틀리면 안 되는 정보는 키워드 검색이 더 강하다. ERR_CONNECTION_REFUSED, useEffect, invoice_id 같은 단어는 의미보다 정확한 철자 일치가 중요하다. 그래서 실무에서는 의미 검색과 키워드 검색을 함께 쓰는 하이브리드 접근이 점점 표준이 되고 있다.25

키워드 검색과 의미 검색을 머릿속에서 구분하는 법

둘의 차이를 너무 어렵게 생각할 필요는 없다.

키워드 검색은 도서관의 색인 카드 같다. 정확한 제목이나 단어를 알면 무섭게 빠르다. 대신 표현이 조금만 바뀌어도 못 찾는다. 의미 검색은 사서에게 "이런 느낌의 자료요"라고 말하는 것에 가깝다. 표현이 달라도 의도를 따라가지만, 때로는 너무 넓게 이해할 수 있다.2

PKM-MCP에서는 이 차이가 특히 중요하다. 메모는 보통 완성된 문서가 아니라, 내가 그날그날 적어둔 흔적이기 때문이다. 같은 내용을 어떤 날은 "세션 분리", 어떤 날은 "user isolation", 어떤 날은 "멀티테넌시 메모"라고 적어둘 수 있다. 이런 환경에서는 키워드 검색 하나만으로는 누락이 생기기 쉽다.

그렇다고 키워드 검색을 버리면 안 된다. 에러 코드, 태그, 파일명, 고유한 약어는 키워드 검색이 훨씬 안정적이다. 그러니 이 장의 방향은 "기존 search_notes를 대체한다"가 아니라 "semantic_search_notes를 추가해 검색 계층을 두껍게 만든다"이다.

7.2 키워드 검색 vs 의미 검색을 도구로 분리하는 이유

초보자일수록 이런 유혹을 받는다. "그냥 search_notes 하나만 두고 내부에서 알아서 섞어버리면 안 되나요?" 물론 가능하다. 하지만 학습 단계의 PKM-MCP에서는 분리하는 편이 훨씬 좋다.

첫째, AI가 무엇을 호출했는지 명확해진다. search_notes는 정확한 단어, 제목, 태그, 파일명 중심의 검색이라는 성격이 분명하다. semantic_search_notes는 자연어 의미 매칭 중심이다. 이렇게 도구의 성격이 분리되어 있으면, 모델도 선택하기 쉽고 사람도 디버깅하기 쉽다.

둘째, 실패 양상이 다르다. 키워드 검색의 실패는 보통 "아예 못 찾음"이다. 반면 의미 검색의 실패는 "그럴듯하지만 엇나감"이다. 두 도구가 섞여 있으면 왜 엉뚱한 결과가 나왔는지 추적하기 어렵다. 분리해두면 임계값, 상위 k개 개수, 청크 크기, 키워드 부스팅 같은 설정을 각각 조절할 수 있다.5

셋째, 나중에 하이브리드 검색으로 발전시키기 쉽다. 처음에는 search_notes와 semantic_search_notes를 따로 두고, 나중에 정말 필요할 때 hybrid_search_notes를 추가하면 된다. 순서를 거꾸로 하면 서버가 처음부터 너무 똑똑한 척하다가, 실제로는 설명하기 어려운 서버가 되기 쉽다.

여기서 재미있는 점이 하나 있다. 최근에는 문서 검색뿐 아니라 "도구 선택"에도 RAG를 붙이려는 시도가 나왔다. 도구가 많아질수록 모든 도구 설명을 프롬프트에 넣으면 토큰이 불어나고 선택 정확도도 떨어지기 때문이다. 그래서 사용자 요청과 가장 관련 있는 도구 설명만 먼저 검색해 LLM에게 보여주는 방식이 연구되고 있다.6 이 이야기가 주는 교훈은 단순하다. 검색은 문서를 찾을 때만 중요한 게 아니라, 시스템 전체를 덜 멍청하게 만드는 핵심 레이어라는 것이다.

언제 어떤 검색을 먼저 쓰게 할까

PKM-MCP에서의 기본 규칙은 이렇게 잡으면 좋다.

정확한 제목, 태그, 식별자, 코드 조각을 찾는 질문에는 search_notes가 먼저다. 반대로 사용자가 길게 설명하거나, 단어는 다르지만 비슷한 개념을 찾는 질문에는 semantic_search_notes가 먼저다. 예를 들어 "지난번에 적은 OAuth 갱신 흐름 메모 찾아줘"는 의미 검색이 유리하고, "notes 중에 refresh_token 들어간 것 찾아줘"는 키워드 검색이 유리하다.

이 구분이 중요한 이유는 도구를 잘 설계하면 LLM의 실수도 줄어들기 때문이다. 도구 설명이 명확할수록 모델이 헛짚을 일이 줄어든다. 즉, 검색 품질은 알고리즘 문제이기도 하지만 동시에 도구 설계와 글쓰기 문제이기도 하다.

7.3 검색 결과를 LLM이 잘 쓰게 만드는 응답 포맷

의미 검색을 붙였는데도 답이 시원찮은 경우가 있다. 이때 많은 사람이 임베딩 모델부터 바꾸려 한다. 하지만 종종 진짜 문제는 검색 결과 포맷이다.

LLM은 검색 결과를 받자마자 사람이 보듯 이해하지 않는다. 결과가 너무 길면 중요한 부분이 묻히고, 너무 짧으면 근거가 부족하다. 파일 경로만 주면 읽기 싫어하고, 본문 전체를 주면 토큰을 낭비한다. 결국 좋은 검색 결과는 "바로 읽고 판단할 수 있는 적당한 조각"이어야 한다.

PKM-MCP의 semantic_search_notes는 최소한 다음 정보를 반환하는 편이 좋다.

  • note_id 또는 파일 경로

  • 제목

  • 관련 청크 본문

  • 유사도 점수 또는 거리

  • 왜 이 결과가 나왔는지 짧은 힌트가 되는 메타데이터

이때 핵심은 점수를 과신하지 않는 것이다. 0.91이 0.83보다 항상 더 좋은 결과라는 뜻은 아니다. 점수는 정렬용 힌트로 보고, 본문 조각과 메타데이터를 함께 제공해 모델이 스스로 교차 검증하게 만들어야 한다.

또 하나 중요한 점은 청크를 반환하되, 원문 출처를 잃지 않는 것이다. 실제 RAG 서버 구현 사례들도 파일 경로, 문서 제목, 주변 문맥, 세션 캐시, 보고서 생성 같은 구조를 중요하게 다룬다.15 검색 결과가 "어디에서 왔는지" 보여주지 않으면, 나중에 답변을 검증하기 어렵다.

PKM-MCP에 semantic_search_notes 추가하기

이제 직접 붙여보자. 이번 예제는 로컬에서 가볍게 시작하기 좋은 Chroma 기준으로 설명한다. Pinecone을 써도 구조는 거의 같다. 차이는 저장소가 로컬 파일 기반이냐, 외부 관리형 서비스냐 정도다.

우리가 만들 기능은 네 단계로 요약된다. 메모를 청크로 나누고, 청크 임베딩을 생성하고, 벡터 저장소에 넣고, 질의 시 가장 가까운 청크를 찾아주는 것이다.

설계: 노트 단위가 아니라 청크 단위로 검색하기

메모 한 파일 전체를 한 벡터로 저장하면 구현은 쉽다. 하지만 검색 품질은 금방 나빠진다. 한 노트 안에 여러 주제가 섞여 있으면 벡터가 평균값처럼 뭉개지기 때문이다. 그래서 보통은 문단이나 의미 단위로 잘라 청크 단위로 저장한다.35

PKM 메모는 길이가 들쭉날쭉하므로, 처음에는 너무 똑똑한 청킹보다 단순한 규칙이 낫다. 예를 들어 제목과 본문을 합친 뒤, 400~800자 정도의 덩어리로 자르고, 80~120자 정도 겹치게 만드는 방식이면 충분하다. 나중에 품질이 아쉬우면 문단 기반 청킹이나 의미 기반 청킹으로 바꾸면 된다. 실제 로컬 RAG 구현들 역시 고정 길이보다는 의미 경계를 살리는 청킹이 검색 품질에 유리하다고 강조한다.5

예제: 인덱싱 코드

아래 코드는 PKM-MCP의 메모 디렉터리를 읽어 Chroma에 임베딩을 저장하는 아주 단순한 인덱서다. 설명을 위해 최대한 짧게 적었고, 실전에서는 변경 감지와 재색인을 추가하면 된다.

from pathlib import Path
from uuid import uuid4
import chromadb
from openai import OpenAI

client = OpenAI()
chroma = chromadb.PersistentClient(path="./data/chroma")
collection = chroma.get_or_create_collection(name="pkm_notes")

NOTES_DIR = Path("./notes")


def chunk_text(text: str, chunk_size: int = 600, overlap: int = 100) -> list[str]:
    text = text.strip()
    if not text:
        return []

    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        if end >= len(text):
            break
        start = end - overlap
    return chunks


def embed_text(text: str) -> list[float]:
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=text,
    )
    return response.data[0].embedding


def rebuild_index() -> dict:
    collection.delete(where={})

    for path in NOTES_DIR.rglob("*.md"):
        raw = path.read_text(encoding="utf-8")
        title = path.stem
        chunks = chunk_text(raw)

        for i, chunk in enumerate(chunks):
            chunk_id = str(uuid4())
            embedding = embed_text(chunk)
            collection.add(
                ids=[chunk_id],
                embeddings=[embedding],
                documents=[chunk],
                metadatas=[{
                    "note_id": path.stem,
                    "title": title,
                    "path": str(path),
                    "chunk_index": i,
                }],
            )

    return {"ok": True}

여기서 눈여겨볼 점은 세 가지다. 첫째, documents에는 청크 본문이 들어간다. 둘째, metadatas에는 경로와 제목 같은 출처 정보가 들어간다. 셋째, 질의와 문서는 반드시 같은 임베딩 모델로 벡터화해야 한다. 다른 모델을 섞으면 검색 품질이 조용히 망가진다.5

예제: semantic_search_notes 도구 추가

이제 MCP Tool을 추가하자. FastMCP 스타일로 쓰면 다음처럼 만들 수 있다.

from fastmcp import FastMCP
from pydantic import Field

mcp = FastMCP("PKM-MCP")


@mcp.tool()
async def semantic_search_notes(
    query: str = Field(description="찾고 싶은 메모의 의미를 자연어로 설명하세요."),
    limit: int = Field(default=5, ge=1, le=10, description="반환할 최대 결과 수"),
) -> list[dict]:
    """
    메모를 의미 기반으로 검색합니다.

    정확한 단어가 기억나지 않아도, 주제나 의도를 자연어로 설명하면
    관련 메모 조각을 찾아줍니다.

    다음과 같은 상황에 적합합니다:
    - 예전에 적어둔 개념 설명이나 회의 메모를 찾고 싶을 때
    - 제목이나 태그가 기억나지 않을 때
    - 비슷한 표현으로 적힌 메모를 폭넓게 찾고 싶을 때

    정확한 파일명, 태그, 에러 코드, 식별자 검색은 search_notes를 우선 사용하세요.
    """
    if not query.strip():
        raise ValueError("query는 비어 있을 수 없습니다.")

    query_embedding = embed_text(query)
    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=limit,
        include=["documents", "metadatas", "distances"],
    )

    items = []
    docs = results.get("documents", [[]])[0]
    metas = results.get("metadatas", [[]])[0]
    distances = results.get("distances", [[]])[0]

    for doc, meta, distance in zip(docs, metas, distances):
        items.append({
            "note_id": meta["note_id"],
            "title": meta["title"],
            "path": meta["path"],
            "chunk_index": meta["chunk_index"],
            "snippet": doc[:300],
            "distance": round(float(distance), 4),
        })

    return items

이 docstring에는 일부러 중요한 힌트를 많이 넣었다. "언제 쓰는 도구인지", "언제는 쓰지 말아야 하는지"를 함께 적어두면 모델의 도구 선택이 훨씬 안정적이 된다. 앞 장에서 배운 것처럼, 도구는 코드로만 설계하는 것이 아니라 설명문으로도 설계한다.

응답 포맷 다듬기: RAG 친화적으로 만들기

방금 구현한 도구는 작동은 한다. 하지만 그대로는 RAG에 조금 아쉽다. 이유는 두 가지다.

하나는 snippet이 너무 짧을 수 있다는 점이다. 모델이 답을 만들 만큼의 근거가 부족할 수 있다. 다른 하나는 주변 문맥이 없다는 점이다. 청크 하나만 덜렁 주면 문장이 중간에서 잘려 어색할 수 있다.

그래서 실전에서는 결과를 이렇게 조금 더 다듬는 편이 좋다.

[
  {
    "note_id": "oauth-refresh-flow",
    "title": "OAuth 토큰 갱신 흐름 정리",
    "path": "notes/auth/oauth-refresh-flow.md",
    "chunk_index": 2,
    "score": 0.8421,
    "snippet": "액세스 토큰이 만료되면 클라이언트는 refresh token으로 새 토큰을 요청한다...",
    "highlights": ["refresh token", "만료", "재발급"],
    "next_action_hint": "더 정확한 답변이 필요하면 원문 메모 전체를 읽으세요."
  }
]

여기서 next_action_hint는 사소해 보이지만 꽤 유용하다. LLM에게 "이 결과를 어떻게 이어서 써야 하는지" 방향을 주기 때문이다. 예를 들어 검색 뒤에 read_note 같은 도구가 있다면, 먼저 후보를 찾고 나서 원문을 읽는 2단계 패턴이 안정적이다. 검색 도구는 발견(discovery), 읽기 도구는 확인(verification)을 맡는 셈이다.

키워드 검색과 의미 검색을 함께 쓰는 가장 현실적인 방법

처음부터 복잡한 랭킹 공식을 만들 필요는 없다. PKM-MCP에서는 다음 순서만 지켜도 체감이 확 달라진다.

먼저 search_notes를 유지한다. 그 다음 semantic_search_notes를 추가한다. 그리고 시스템 프롬프트나 도구 설명에 "정확한 문자열이면 search_notes, 개념 설명이면 semantic_search_notes"라는 기준을 분명히 적는다. 이 단순한 분리만으로도 검색 품질과 도구 선택 정확도가 함께 오른다.

여유가 생기면 하이브리드 검색으로 갈 수 있다. 실제 로컬 RAG 서버 중에는 의미 검색 결과에 키워드 부스트를 얹어, 자연어 이해와 정확한 식별자 매칭을 동시에 챙기는 구현도 있다.5 예를 들어 useEffect나 특정 에러 코드처럼 정확한 단어가 중요한 질의에서는 키워드 점수를 더해주는 식이다.

즉, 순서는 이렇다. 먼저 분리하고, 그다음 비교하고, 마지막에 결합한다. 처음부터 다 섞으려 하지 말자. 검색 엔진도 요리는 순서가 중요하다.

Pinecone을 쓸까, Chroma를 쓸까

이 책의 메인 프로젝트는 학습용이면서도 점점 실전으로 커진다. 그래서 선택 기준이 중요하다.

Chroma는 로컬에서 시작하기 좋다. 설치와 실험이 가볍고, 메모 수가 많지 않은 개인 프로젝트에서는 충분히 편하다. 반면 Pinecone이나 pgvector 같은 외부 저장소는 규모가 커질수록 관리, 성능, 다중 사용자 대응에서 유리하다. 실제로 로컬 지식 RAG 서버 사례들은 PostgreSQL + pgvector, HNSW 인덱스, 다중 워크스페이스 격리 같은 구성을 통해 더 큰 데이터셋과 운영 시나리오를 다룬다.1

이번 장에서는 Chroma로 시작하는 것이 좋다. 이유는 단순하다. 지금 우리는 운영보다 개념을 배우는 단계이기 때문이다. 3부에 가면 HTTP 전송, OAuth, 사용자별 격리, 배포 이야기가 나오고, 그때 저장소 선택도 더 현실적으로 바뀔 수 있다.

read-only 서버가 RAG 친화적으로 바뀌는 순간

여기서 중요한 전환이 하나 있다. 지금 PKM-MCP는 아직 메모를 수정하지 않는 읽기 전용 서버다. 그런데 semantic_search_notes가 들어오면 이 서버는 단순 조회기를 넘어, LLM이 메모를 근거로 사고할 수 있게 돕는 검색 계층이 된다.

이 차이는 생각보다 크다. 전에는 AI가 "메모 목록을 본다" 수준이었다면, 이제는 "질문에 맞는 메모 조각을 골라 문맥으로 가져온다" 수준이 된다. 다시 말해 PKM-MCP는 이제 단순한 메모 브라우저가 아니라, RAG의 검색기 역할을 맡기 시작한다.

실제로 의미 검색을 붙인 MCP 예제들은 보통 여기서 끝나지 않는다. 검색 결과를 캐시하고, 보고서를 만들고, 원문 주변 청크를 더 읽고, 인덱스를 다시 빌드하는 흐름까지 이어진다.15 우리는 아직 거기까지 가지 않았지만, 방향은 이미 정해졌다. 이 장은 그 첫걸음이다.

자주 겪는 실수

의미 검색을 처음 붙일 때 흔한 실수도 미리 짚고 가자.

첫 번째는 노트 전체를 한 덩어리로 임베딩하는 것이다. 구현은 쉬운데 검색 품질이 빨리 무너진다. 한 메모 안에 주제가 여러 개 섞여 있으면 질문과 직접 관련 없는 내용이 벡터를 흐리게 만든다.

두 번째는 검색 결과를 너무 많이 반환하는 것이다. 상위 20개를 한꺼번에 모델에 넣으면 풍성해 보이지만, 실제로는 잡음이 늘고 토큰만 먹는다. 대개 3~5개 정도의 좋은 청크가 15개의 평범한 청크보다 낫다.

세 번째는 출처 메타데이터를 빼먹는 것이다. 제목, 경로, 청크 번호가 없으면 검색 결과가 예쁘게 보일 수는 있어도, 나중에 다시 읽거나 검증하기 어렵다.

네 번째는 임베딩 모델을 바꿨는데 기존 인덱스를 그대로 쓰는 것이다. 이 경우 검색은 되지만 품질이 이상하게 흔들린다. 조용히 망가지는 유형이라 더 무섭다.5

이번 장에서 만든 것, 다음 장으로 이어질 것

이번 장에서 우리는 search_notes 옆에 semantic_search_notes를 세웠다. 이것만으로도 PKM-MCP는 훨씬 덜 답답한 서버가 된다. 제목이 정확히 기억나지 않아도, 비슷한 개념으로 적어둔 메모를 찾을 수 있기 때문이다.

하지만 여기서 끝은 아니다. 검색이 좋아졌다고 해서 서버가 곧바로 실전용이 되는 것은 아니다. 같은 기능을 TypeScript로도 만들 수 있어야 하고, 외부 시스템과도 붙어야 하며, 나중에는 사용자별 데이터 분리와 운영까지 고려해야 한다.

그래도 분명한 사실이 하나 있다. 메모가 많아질수록 검색 품질은 사치가 아니라 기본기다. MCP 서버에서 좋은 검색은 그 자체로 사용자 경험이고, 좋은 RAG는 결국 좋은 검색에서 시작한다.

참고

2Semantic Search(의미론적 검색) vs Keyword Search의 차이

3RAG, MCP, A2A

4Semantic Search Integration - mcp-for-beginners

6RAG-MCP: Mitigating Prompt Bloat in LLM Tool Selection via Retrieval-Augmented Generation

책 목록

만들면서 배우는 MCP: PKM-MCP로 끝내는 설계·구현·운영·배포

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기