00 — 개념: Mistral Search Toolkit 시각적 투어

00 — 개념: Mistral Search Toolkit 시각적 투어 (Concepts: A Visual Tour of the Mistral Search Toolkit)

Mistral 검색 툴킷의 핵심 개념을 시각적으로 배우는 온램프(on-ramp) 노트북이에요. RAG가 해결하는 문제, 문서·청크·임베딩·벡터 스토어, 하이브리드 검색과 RRF, Pipeline과 QueryEngine의 역할을 이해하게 됩니다.

출처: 문서

본문

이 노트북은 다른 모든 쿡북으로 가는 온램프예요. 여기서는 어떤 배포도 하지 않고, 이후 모든 쿡북이 전제하는 정신적 모델(mental model)을 구축하게 됩니다. 다른 프레임워크에서 RAG에 이미 익숙하다면 건너뛰어도 좋아요.

배우고 갈 것들 (What you'll walk away with)

이 노트북이 끝나면 다음 질문에 스스로 답할 수 있어야 해요:

  • RAG는 어떤 문제를 해결하고, retrieval(검색)은 그림에서 어디에 위치하나요?
  • 문서(Document), 청크(Chunk), 임베딩(Embedding), 벡터 스토어(Vector Store)란 무엇인가요?
  • 하이브리드 검색(Hybrid Search)이란 무엇이고, RRF가 무엇을 결합하나요?
  • 툴킷이 왜 ingestion을 교체 가능한 부품들의 조합 가능한 Pipeline으로 노출하나요?
  • QueryEngine은 쿼리 시점의 검색을 어떻게 조율하나요?

설정 (Setup)

이 노트북의 두 코드 셀(임베딩 데모 + RRF 데모)은 이 쿡북 워크스페이스 설치만 필요해요:

cd search/cookbooks
uv sync

임베딩 데모는 환경에 MISTRAL_API_KEY도 필요해요 (쿡북 루트의 .env 파일이 자동으로 로드됩니다).

1. 왜 RAG인가? 검색이 해결하는 문제

LLM은 텍스트 추론에 뛰어나지만 두 가지 지속적인 약점이 있어요:

  • 사용자의 문서(내부 문서, 계약서, 과학 PDF, 고객 티켓 등)를 알지 못해요.
  • 학습 데이터에 컷오프 날짜가 있어서, 실시간 데이터를 유지하려면 실시간 데이터를 넣어줘야 해요.

RAG(Retrieval-Augmented Generation, 검색 증강 생성)은 두 가지 AI 구성요소를 가져요:

  • Retriever(검색기): 사용자 질문에 가장 관련성 높은 문단 몇 개를 코퍼스에서 찾아요.
  • LLM: 해당 문단을 컨텍스트로 받아 그것을 사용해 답하도록 요청받아요.

LLM은 이제 근거(grounded evidence)가 있으므로 허공에서 환각하지 않아요. 그리고 문서를 다시 ingestion하거나 라이브 소스에서 가져오기만 하면 최신 지식을 얻을 수 있어요.

2. 두 개의 파이프라인, 하나의 공유 스토어

문서를 검색하려면 먼저 벡터 스토어에 인덱싱해야 해요. 두 개의 파이프라인이 있고, 벡터 스토어에서 만나요.

Pipeline 실행 시점 입력 출력
Ingestion 오프라인/배치, 문서가 변경될 때 원시 파일 (PDF 등) Vespa의 인덱싱된 청크
Search 온라인, 매 사용자 쿼리마다 쿼리 문자열 순위가 매겨진 청크 목록

Ingestion은 느리고 비싸며 쓰기가 많은 절반(OCR, 임베딩, 인덱싱)이에요. Search는 빠르고 읽기가 많은 절반(쿼리 임베딩, 하이브리드 검색, top-k 반환)이죠. 둘은 런타임에 벡터 스토어 외에는 아무것도 공유하지 않아요.

툴킷은 이 구분을 반영해요:

  • Ingestion은 Pipeline 클래스를 중심으로 구성돼요 (loader → extractor → splitter → embedder → store).
  • Search는 QueryEngine 클래스를 중심으로 구성돼요 (retriever(s) → 선택적 rewriter/reranker).

3. Ingestion 한눈에 보기 — Pipeline

이 저장소의 모든 ingestion 스크립트는 똑같이 생겼어요. 01-quickstart/ingest.py에서 직접 가져온 코드예요:

pipeline = Pipeline(
    loader=FilesystemFileLoader(),
    extractor=MistralOCRExtractor(client=mistral_client),
    text_splitter=MarkdownTextSplitter(MarkdownTextSplitterConfig(chunk_size=5048, chunk_overlap=50)),
    embedder=MistralEmbedder(client=mistral_client),
    stores=vector_store,
)

await pipeline.run(documents=[Path("my.pdf")], use_checkpoint=False)

Pipeline은 다섯 개의 교체 가능한 슬롯을 연결하는 오케스트레이터일 뿐이에요. 각 슬롯은 하나의 일만 해요:

슬롯 역할 01-quickstart 예시
loader 파일이 어디에 살까? 원시 파일을 가져온다. FilesystemFileLoader
extractor 파일을 깨끗한 텍스트로 바꾼다. MistralOCRExtractor (OCR 인식, 마크다운 페이지들의 Document 반환)
text_splitter 임베딩할 수 있을 만큼 작고 검색할 수 있을 만큼 정밀한 청크로 텍스트를 자른다. MarkdownTextSplitter (마크다운 구조 존중, 청크 간 50자 겹침)
embedder 각 청크를 고정 길이 벡터로 바꾼다. MistralEmbedder (Mistral 임베딩 API)
stores 인덱싱된 청크를 어디에 쓸지. VespaSearchIndex

이후의 모든 쿡북은 이 모양의 작은 변형이에요:

  • 02-advanced-indexing/ingest.py는 체크포인트 디렉터리와 진행 콜백을 추가해요.
  • ingest_from_s3.py는 FilesystemFileLoader를 FileLoader(S3BlobStorage(...))로 바꿔요.
  • ingest_with_metadata.py는 추가 chunk_enrichers=[CustomMetadataEnricher()] 인자를 넘겨요.
  • ingest_with_summaries.py는 chunk_enrichers=[SummaryEnricher(...)]를 넘겨요.

4. 임베딩 — 텍스트를 기하학으로 바꾸기

임베딩은 청크의 의미를 고차원 공간의 한 점으로 나타내는 고정 길이의 float 리스트(보통 1024개 숫자)예요. 벡터 검색의 모든 마법은 한 가지 속성에 달려 있어요:

의미가 비슷한 텍스트는 이 공간에서 서로 가까이 위치하며, 이는 단어 자체가 아니라 단어가 등장하는 문맥을 기반으로 해요.

이 단일 속성은 한 번에 두 가지를 해요:

  1. 단어가 다르지만 의미가 같은 두 문단은 가까워진다 — 예: "a ball python coiled around a tree branch"와 "the constrictor snake wrapped itself around the perch"는 어휘가 거의 겹치지 않지만 임베딩 공간에서 이웃이에요. 이 덕분에 사용자 표현이 인덱싱된 텍스트와 일치하지 않아도 관련 청크를 찾을 수 있어요.
  2. 단어가 같지만 의미가 다른 두 문단은 멀어진다 — "a ball python coiled around a branch"와 "a Python script that scrapes an API"는 겉보기 토큰 python을 공유하지만, 주변 문맥이 이들이 무관한 개념임을 알려주므로 완전히 다른 영역에 위치해요.

같은 임베딩 모델이 ingestion 시점(모든 청크 저장 전 임베딩)과 쿼리 시점(사용자 쿼리 임베딩)에 모두 사용돼요. 이 대칭성이 양쪽의 기하학을 비교 가능하게 만듭니다.

다음 코드 셀은 "python"이라는 단어의 두 가지 의미가 섞인 작은 코퍼스에 Mistral 임베딩 API를 직접 호출하고, 같은 코퍼스에서 서로 다른 두 부분집합을 끌어내는 두 개의 쿼리를 실행해요.

"""Same word, two meanings. what 'context' really means for embeddings.

We call the Mistral embedding API directly (no toolkit wrapper) on a small
corpus where the token ``python`` appears in every entry, but with two
completely different meanings: half the sentences are about the snake, the
other half about the programming language. We then issue two queries — one
biological, one software-engineering — and rank the corpus by cosine
similarity to each. The two queries pull out *different* subsets of the
corpus, even though the literal word overlap is identical.
"""

import os

import numpy as np
from dotenv import load_dotenv
from mistralai.client import Mistral

load_dotenv()

client = Mistral(
    api_key=os.environ["MISTRAL_API_KEY"],
    server_url=os.getenv("MISTRAL_API_URL"),
)

def get_text_embedding(text: str) -> list[float]:
    response = client.embeddings.create(model="mistral-embed", inputs=text)
    return response.data[0].embedding

corpus = [
    # python the snake
    "Pythons are large, non-venomous constrictor snakes native to tropical Africa and Southeast Asia.",
    "A ball python coiled itself around a low branch deep in the rainforest.",
    "Reticulated pythons can grow over six metres long and are among the largest reptiles on Earth.",
    # python the programming language
    "Python is one of the most popular programming languages for data science and machine learning.",
    "We wrote a small Python script that scrapes the API and dumps the results to a CSV file.",
    "Django and FastAPI are mature Python web frameworks used in production by major companies.",
]
corpus_labels = ["snake"] * 3 + ["language"] * 3

queries = ["snakes in the tropical rainforest", "scripting language for data analysis"]

corpus_vectors = np.array([get_text_embedding(text) for text in corpus])
query_vectors = np.array([get_text_embedding(q) for q in queries])

def cosine(a: np.ndarray, b: np.ndarray) -> float:
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

for query, query_vec in zip(queries, query_vectors):
    print(f"Query: {query!r}")
    ranked = sorted(zip(corpus, corpus_vectors), key=lambda kv: -cosine(query_vec, kv[1]))
    for text, vec in ranked:
        print(f" {cosine(query_vec, vec):+.3f} {text}")
    print()

무슨 일이 일어났나요? 코퍼스의 모든 줄은 python이라는 단어를 포함하므로, 순진한 키워드 검색기(BM25)는 어떤 쿼리에 대해서도 모두 동일하게 순위를 매길 거예요. 하지만 임베딩 모델은 주변 문맥(branch를 감싼 뱀, 열대 우림, Django, script, CSV file)을 인코딩하며, 두 쿼리는 벡터 공간의 서로 다른 이웃에 위치합니다:

  • "snakes in the tropical rainforest"는 파충류 문장 세 개를 위로 끌어올려요.
  • "scripting language for data analysis"는 프로그래밍 문장 세 개를 위로 끌어올려요.

이것이 툴킷의 나머지가 의존하는 속성이에요: 검색 시점에 임베딩은 "X에 관한 문단 찾기"를 "X의 임베딩에 가장 가까운 벡터 찾기"로 바꾸는데, 여기서 "X에 관한"은 문자 그대로의 단어가 포착하지 못하는 문맥을 포함해요.

5. 벡터 스토어와 하이브리드 검색

벡터 스토어는 모든 청크 + 그 임베딩을 보관하고 검색 쿼리에 답하는 데이터베이스예요. 이 쿡북들은 [Vespa]를 사용하는데, Vespa는 단순한 벡터 데이터베이스가 아니라는 점에서 흥미로워요. 각 청크에 대해 다음을 저장해요:

  • 청크의 텍스트 — BM25 키워드 검색(고전적 tf-idf 관련성 점수)용
  • 청크의 임베딩 벡터 — ANN(근사 최근접 이웃) 검색용
  • 청크의 메타데이터 — 쿼리 시점 필터링용 (filename, page_number, ChunkEnricher로 붙이는 커스텀 필드)

이것이 단일 쿼리에서 하이브리드 검색을 가능하게 해요: 각 청크가 키워드와 의미 유사성 둘 다로 매칭되고, 두 순위 목록이 하나로 융합됩니다.

왜 둘 다 필요할까?

각 신호는 다르게 실패해요:

  • BM25는 사용자가 소수 문서에서만 의미 있는 정확한 용어(제품 코드, 오류 메시지, 명명된 개체, 약어)를 입력할 때 이겨요. 임베딩은 이런 것들을 일반적 이웃 근처로 매핑해 평활화하는 경향이 있어요.
  • 벡터 검색은 사용자가 의역하거나 더 높은 추상화 수준으로 질문할 때 이겨요 — "treatments for cancer"가 "chemotherapy"와 "radiotherapy"만 포함하고 "cancer"라는 단어는 없는 청크와 매칭되는 식이죠.

정말 둘 다 원해요. 문제는 완전히 다른 점수 척도를 가진 두 순위 목록을 어떻게 결합하느냐예요(BM25는 무한대의 양수, 코사인 유사도는 [-1, 1]).

답은 **RRF(Reciprocal Rank Fusion)**예요: 원점수는 잊고, 각 목록에서 각 청크의 순위만 보고 간단한 공식을 사용해요.

$$\text{RRF}(d) = \sum_{r \in \text{retrievers}} \frac{1}{k + \text{rank}_r(d)}$$

여기서 k는 보통 60이에요. 청크는 적어도 한 목록의 상단 근처에 있으면 높은 점수를 받아요. 양쪽 모두 상단 근처면 더 좋죠.

6. 검색 측면 — QueryEngine과 검색기

문서가 인덱싱되면 검색 측면은 훨씬 작아요. 툴킷은 하나의 오케스트레이터인 QueryEngine을 노출하는데, 이는 검색기 목록과 (선택적으로) rewriter·reranker 같은 후처리 컴포넌트를 받아요.

01-quickstart/search.py에 있는 가장 단순한 설정 — 검색기 하나, rewriter 없음, reranker 없음:

query_engine = QueryEngine(
    retriever=[VectorRetriever(client=vector_store, embedder=embedder)],
)
result = await query_engine.search(query="What is the main topic?", top_k=5)
  • VectorRetriever는 순수 벡터 쿼리가 아니라 하이브리드 쿼리(BM25 + ANN + RRF)를 발행해요. 이름은 역사적이에요. "표준 검색기"로 생각하면 됩니다.
  • 03-advanced-search는 쿼리 시점 파이프라인을 세 가지 클래스로 풍부하게 해요:
    • LLMQueryRewriter — 검색 전에 사용자 질문을 다시 표현해요.
    • LLMQueryExtension — 여러 하위 쿼리를 생성하고 결과를 병합해요.
    • LLMReRanker — LLM에 점수를 매기게 해 top-k 후보를 재정렬해요.

7. 용어집 — 여기저기서 볼 단어들

용어 한 줄 정의 처음 사용하는 쿡북
Document 추출 후 툴킷의 최상위 객체; 메타데이터와 pages 목록을 가짐 01-quickstart
DocumentChunk 스플리터가 만든 Document의 작은 조각; 검색의 단위 01-quickstart
Embedding 청크 또는 쿼리의 의미를 나타내는 고정 길이 float 벡터 01-quickstart
BM25 고전적 키워드 기반 관련성 점수; 정확한 용어에 탁월 01-quickstart (하이브리드 아래)
ANN 임베딩에 대한 근사 최근접 이웃 검색; 하이브리드의 "벡터" 절반 01-quickstart (하이브리드 아래)
Hybrid search RRF를 통해 BM25 + ANN을 결합하는 단일 쿼리 01-quickstart
RRF Reciprocal Rank Fusion; 두 순위 목록을 하나로 융합하는 공식 01-quickstart (하이브리드 아래)
Pipeline ingestion 오케스트레이터: loader → extractor → splitter → embedder → store 01-quickstart
QueryEngine 검색 오케스트레이터: retrievers (+ 선택적 rewriter/reranker) 01-quickstart
ChunkEnricher 임베딩 전에 각 청크에 커스텀 메타데이터를 붙이는 훅 02-advanced-indexing
Checkpointing 이미 인덱싱된 문서를 기록해 충돌 시 재-ingestion하지 않게 함 02-advanced-indexing
LLMQueryRewriter 검색 전에 LLM으로 사용자 질문을 다시 표현함 03-advanced-search
LLMQueryExtension 하나의 쿼리를 N개의 하위 쿼리로 분해하고 결과 병합 03-advanced-search
LLMReRanker LLM에 점수를 매기게 해 top-k 결과 재정렬 03-advanced-search
Match phase / Ranking phase Vespa의 2단계 검색: 후보 선택 vs. 정렬 04-evaluation
Precision@k / Recall@k / F1@k 표준 검색 품질 지표; ground-truth 데이터셋 기준 계산 04-evaluation

8. 다음으로 어디로 갈까

이제 정신적 모델이 생겼어요. 다음 쿡북들을 순서대로 실행해 보세요.

더 알아보기 (Learn more)