Skip to main content
Summary

검색은 답변 생성보다 앞선다

RAG 애플리케이션에서 모델은 마지막에 답변을 작성한다. 그 전에 먼저 해야 할 일은 "질문과 관련 있는 문서 조각을 찾는 것"이다. 이 검색 단계가 부정확하면 아무리 좋은 모델을 써도 답변은 흔들린다. 모델은 주어진 문맥을 바탕으로 그럴듯한 문장을 만들 수 있지만, 잘못된 문맥을 받으면 잘못된 근거로 답할 수밖에 없다.

이번 장에서는 이전 장에서 만든 문서 청크를 실제 검색 가능한 형태로 바꾸는 방법을 다룬다. 흐름은 단순하다.

문서 청크 → 임베딩 → 벡터스토어 저장 → 사용자 질문 임베딩 → 유사한 청크 검색 → 답변 생성에 사용

여기서 핵심 부품은 세 가지다.

  • 임베딩: 텍스트를 숫자 벡터로 바꾸는 과정

  • 벡터스토어: 임베딩 벡터와 문서 조각을 저장하고 검색하는 저장소

  • 리트리버: 질문을 받아 관련 문서를 반환하는 검색 인터페이스

LangChain은 임베딩 모델과 벡터스토어를 공통 인터페이스로 다룬다. 그래서 작은 실습에서는 FAISS를 쓰고, 로컬 영속 저장이 필요하면 Chroma를 쓰고, 운영 규모가 커지면 클라우드나 서버형 벡터 데이터베이스로 바꾸는 식의 전환이 비교적 쉽다.1

임베딩, 벡터스토어, 리트리버 이해하기AI 생성 이미지

임베딩은 의미를 좌표로 바꾸는 일이다

컴퓨터는 문장의 의미를 그대로 이해하지 못한다. 대신 텍스트를 숫자 목록으로 바꿔 비교한다. 이 숫자 목록을 벡터라고 한다.

예를 들어 다음 세 문장을 생각해 보자.

A: 환불 정책을 알려 주세요.
B: 결제 취소는 어떻게 하나요?
C: 사무실 와이파이 비밀번호가 무엇인가요?

A와 B는 단어가 완전히 같지는 않지만 의미는 가깝다. 둘 다 결제나 환불과 관련된 질문이다. C는 전혀 다른 주제다. 임베딩 모델은 이런 문장을 고정 길이의 숫자 벡터로 변환하고, 의미가 가까운 문장끼리 벡터 공간에서 가깝게 놓이도록 학습되어 있다.2

검색할 때는 사용자의 질문도 같은 임베딩 모델로 벡터화한다. 그런 다음 문서 청크 벡터들과 비교해 가장 가까운 조각을 찾는다. 이때 자주 쓰는 비교 방식은 코사인 유사도, 내적, 유클리드 거리 등이다. 실무에서는 수식 자체보다 다음 원칙이 더 중요하다.

문서를 저장할 때 쓴 임베딩 모델과 질문을 검색할 때 쓴 임베딩 모델은 같아야 한다.

문서 청크는 OpenAI 임베딩으로 저장했는데 질문은 다른 로컬 임베딩 모델로 변환하면 벡터 공간이 달라진다. 같은 지도 위에서 위치를 비교해야 하는데, 서로 다른 좌표계를 섞는 셈이다.

LangChain의 임베딩 객체는 보통 두 메서드를 제공한다.

embeddings.embed_documents(["문서 1", "문서 2"])
embeddings.embed_query("사용자 질문")

문서는 여러 개를 한 번에 임베딩하고, 질문은 보통 하나씩 임베딩한다. LangChain 벡터스토어를 사용하면 이 과정을 직접 호출하지 않아도 내부에서 처리해 준다.

벡터스토어 선택 기준

벡터스토어는 임베딩 벡터와 원본 문서 조각, 메타데이터를 함께 저장한다. 선택지는 많지만 처음에는 세 부류로 나누면 이해하기 쉽다.

구분예적합한 상황주의할 점
로컬 인덱스FAISS빠른 실험, 노트북, 작은 데모별도 서버 기능이 약하고 운영 기능은 직접 보완해야 함
로컬/서버형 저장소Chroma개인 프로젝트, 팀 내부 프로토타입, 로컬 영속 저장대규모 운영에서는 백업, 동시성, 권한 모델을 따로 검토해야 함
클라우드/서버형 벡터 DBPinecone, Weaviate, Qdrant, Milvus, Elasticsearch 등운영 서비스, 대용량 데이터, 확장성·관리 기능 필요비용, 네트워크 지연, 벤더 종속성, 보안 설정을 고려해야 함

FAISS는 빠르게 벡터 검색을 실험하기 좋다. Python 코드 안에서 인덱스를 만들고 로컬 파일로 저장할 수 있다. 다만 사용자 권한 관리, 백업, 모니터링 같은 운영 기능은 직접 설계해야 한다.

Chroma는 로컬에서 시작하기 쉽고, 저장 디렉터리를 지정해 데이터를 유지할 수 있다. 간단한 RAG 앱이나 팀 내부 도구를 만들 때 편하다. 필요하면 별도 서버나 클라우드 방식으로 확장할 수도 있지만, 운영 요구사항이 커질수록 백업, 동시성, 접근 제어를 함께 검토해야 한다.

클라우드/서버형 벡터 데이터베이스는 운영 환경에 적합하다. 대량의 벡터를 저장하고, 필터링, 복제, 확장, 관리 기능을 제공한다. 하지만 검색 한 번마다 네트워크 호출이 발생할 수 있고, 데이터가 외부 서비스에 저장될 수 있으므로 보안 정책을 반드시 확인해야 한다.

처음부터 가장 비싼 선택지를 고를 필요는 없다. 학습과 검증 단계에서는 FAISS나 Chroma로 시작하고, 데이터 크기와 운영 요구사항이 명확해진 뒤 클라우드나 서버형 벡터 DB를 검토하는 방식이 안전하다.

문서를 임베딩해 FAISS에 저장하기

먼저 가장 단순한 예제로 시작하자. 이전 장에서 문서를 로드하고 청크로 나누는 방법을 배웠다. 여기서는 Document 목록이 이미 있다고 가정한다.

필요한 패키지를 설치한다.

pip install langchain langchain-openai langchain-community faiss-cpu python-dotenv

다음 코드는 작은 문서 목록을 만들고 FAISS 벡터스토어에 저장한 뒤 검색한다.

from dotenv import load_dotenv
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS

load_dotenv()

# 1. 검색할 문서 조각 준비
chunks = [
    Document(
        page_content="환불은 결제일로부터 7일 이내에 요청할 수 있습니다.",
        metadata={"source": "policy.md", "category": "billing"},
    ),
    Document(
        page_content="비밀번호는 설정 화면의 보안 메뉴에서 변경할 수 있습니다.",
        metadata={"source": "account.md", "category": "account"},
    ),
    Document(
        page_content="기업 요금제는 월 단위 청구서를 이메일로 발송합니다.",
        metadata={"source": "pricing.md", "category": "billing"},
    ),
]

# 2. 임베딩 모델 준비
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# 3. 문서 조각을 임베딩해 벡터스토어 생성
vectorstore = FAISS.from_documents(chunks, embeddings)

# 4. 사용자 질문과 유사한 문서 검색
query = "결제를 취소하고 돈을 돌려받고 싶어요."
results = vectorstore.similarity_search(query, k=2)

for doc in results:
    print(doc.page_content)
    print(doc.metadata)
    print("---")

similarity_search(query, k=2)는 질문과 가장 가까운 문서 조각 2개를 반환한다. 여기서는 "환불"이라는 단어가 질문에 직접 들어 있지 않아도, 의미가 가까운 환불 정책 문서가 검색될 가능성이 높다. 이것이 키워드 검색과 벡터 검색의 큰 차이다.

FAISS 인덱스를 파일로 저장할 수도 있다.

vectorstore.save_local("./faiss_index")

loaded_store = FAISS.load_local(
    "./faiss_index",
    embeddings,
    allow_dangerous_deserialization=True,
)

allow_dangerous_deserialization=True는 로컬에서 신뢰할 수 있는 파일을 불러올 때만 사용해야 한다. 출처를 알 수 없는 인덱스 파일을 역직렬화하는 것은 보안상 위험할 수 있다.

Chroma로 영속 저장소 만들기

Chroma는 로컬 디렉터리에 데이터를 저장하면서 시작하기 좋다. 설치는 다음과 같다.

pip install langchain-chroma chromadb

사용 방식은 FAISS와 비슷하다.

from dotenv import load_dotenv
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

load_dotenv()

chunks = [
    Document(
        page_content="휴가 신청은 사내 포털의 근태 메뉴에서 등록합니다.",
        metadata={"source": "hr.md", "department": "hr"},
    ),
    Document(
        page_content="법인카드 영수증은 매월 말까지 회계팀에 제출해야 합니다.",
        metadata={"source": "finance.md", "department": "finance"},
    ),
    Document(
        page_content="연차는 반차 단위로도 사용할 수 있습니다.",
        metadata={"source": "hr.md", "department": "hr"},
    ),
]

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    collection_name="company_docs",
    persist_directory="./chroma_db",
)

results = vectorstore.similarity_search("연차 신청 방법", k=2)

for doc in results:
    print(doc.page_content, doc.metadata)

persist_directory를 지정하면 프로그램을 다시 실행해도 같은 디렉터리에서 컬렉션을 불러올 수 있다.

vectorstore = Chroma(
    collection_name="company_docs",
    embedding_function=embeddings,
    persist_directory="./chroma_db",
)

다만 같은 persist_directory와 collection_name에 대해 from_documents()를 반복 실행하면, 설정에 따라 같은 문서가 중복으로 추가될 수 있다. 실습에서는 저장 디렉터리를 지우고 다시 만들면 되지만, 실제 애플리케이션에서는 문서별 고유 ID를 관리하거나 이미 저장된 문서를 업데이트하는 흐름을 따로 설계하는 편이 좋다.

실습 단계에서는 FAISS와 Chroma 중 어느 쪽을 써도 좋다. 중요한 것은 벡터스토어 자체가 아니라 "문서 조각을 임베딩해 저장하고, 질문과 가까운 조각을 찾아온다"는 흐름이다.

리트리버는 검색을 체인에 연결하는 인터페이스다

벡터스토어의 similarity_search()를 직접 호출해도 검색은 가능하다. 하지만 RAG 체인에서는 보통 벡터스토어를 리트리버로 바꿔 사용한다.

retriever = vectorstore.as_retriever(
    search_kwargs={"k": 3}
)

docs = retriever.invoke("법인카드 영수증은 언제 제출하나요?")

for doc in docs:
    print(doc.page_content)

리트리버는 질문을 입력받아 Document 목록을 반환한다. 이 형태가 중요한 이유는 LCEL 체인에 자연스럽게 연결할 수 있기 때문이다.

from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_messages([
    ("system", "다음 문맥만 사용해 질문에 답하세요. 모르면 모른다고 말하세요."),
    ("human", "문맥:n{context}nn질문: {question}"),
])

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)


def format_docs(docs):
    return "nn".join(doc.page_content for doc in docs)

chain = (
    {
        "context": retriever | format_docs,
        "question": RunnablePassthrough(),
    }
    | prompt
    | model
    | StrOutputParser()
)

answer = chain.invoke("법인카드 영수증 제출 기한은 언제인가요?")
print(answer)

이제 질문은 두 갈래로 흐른다. 하나는 리트리버로 가서 관련 문서를 찾고, 다른 하나는 그대로 프롬프트의 질문으로 들어간다. 이 구조가 다음 장에서 만들 RAG 애플리케이션의 기본 뼈대다.

top_k, 점수 기준, 메타데이터 필터 조정하기

검색 품질은 설정에 따라 크게 달라진다. 가장 먼저 조정할 값은 k다. k는 몇 개의 문서 조각을 가져올지 정한다.

retriever = vectorstore.as_retriever(
    search_kwargs={"k": 5}
)

k가 너무 작으면 필요한 근거를 놓칠 수 있다. 반대로 너무 크면 관련 없는 문서가 프롬프트에 섞이고 토큰 비용도 늘어난다. 일반적인 문서 QA에서는 3~6개 정도로 시작해 질문 유형에 따라 조정한다.

검색 점수를 함께 보고 싶다면 다음처럼 확인할 수 있다.

results = vectorstore.similarity_search_with_score(
    "환불 가능 기간은?",
    k=3,
)

for doc, score in results:
    print(score, doc.page_content)

다만 점수의 의미는 벡터스토어마다 다를 수 있다. 어떤 구현에서는 낮을수록 가깝고, 어떤 구현에서는 높을수록 관련성이 높다. 같은 벡터스토어라도 similarity_search_with_score()가 거리 값을 반환하는지, 정규화된 관련도 점수를 반환하는지 확인해야 한다. 따라서 점수 기준을 운영에 쓰기 전에는 실제 질문 세트로 분포를 살펴봐야 한다.

일부 벡터스토어는 점수 임계값 검색도 지원한다.

retriever = vectorstore.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={
        "k": 10,
        "score_threshold": 0.5,
    },
)

이 방식은 "상위 5개를 무조건 가져오기"보다 안전할 때가 있다. 관련성이 낮은 문서를 억지로 넣지 않기 때문이다. 단, 임계값을 너무 높게 잡으면 아무 문서도 검색되지 않을 수 있다. 또한 지원 여부와 점수 범위는 벡터스토어 구현마다 다를 수 있으므로, 사용 중인 저장소에서 동작을 확인해야 한다. 문서가 검색되지 않은 경우 답변 체인은 "근거 문서를 찾지 못했다"고 말하도록 설계해야 한다.

메타데이터 필터링도 중요하다. 예를 들어 인사 문서만 검색하려면 다음처럼 필터를 줄 수 있다.

results = vectorstore.similarity_search(
    "연차는 반차로 사용할 수 있나요?",
    k=3,
    filter={"department": "hr"},
)

리트리버에 적용할 수도 있다.

hr_retriever = vectorstore.as_retriever(
    search_kwargs={
        "k": 3,
        "filter": {"department": "hr"},
    }
)

필터 문법은 벡터스토어마다 조금씩 다르다. LangChain 인터페이스에서는 보통 filter를 통해 메타데이터 조건을 전달하지만, 내부 데이터베이스에서는 where 같은 이름을 쓰거나 지원하는 연산자가 다를 수 있다. 복잡한 조건을 쓸 때는 사용 중인 벡터스토어의 문서를 확인해야 한다.3

검색 품질을 개선할 때 보는 순서

RAG 품질이 낮을 때 모델부터 바꾸고 싶어지지만, 먼저 검색 결과를 확인해야 한다. 다음 순서로 점검하면 원인을 빨리 찾을 수 있다.

  1. 질문에 맞는 문서가 실제로 검색되는가?
    similarity_search_with_score()로 검색 결과를 직접 출력한다.

  2. 청크가 너무 크거나 작지 않은가?
    너무 크면 관련 없는 내용이 섞이고, 너무 작으면 답변에 필요한 맥락이 빠진다.

  3. 메타데이터가 검색에 도움이 되는가?
    부서, 문서 유형, 날짜, 권한, 제품명 같은 필드를 잘 설계하면 검색 범위를 줄일 수 있다.

  4. top_k가 적절한가?
    필요한 근거가 4번째, 5번째에 있는데 k=2로 검색하고 있을 수 있다.

  5. 임베딩 모델이 데이터 언어와 도메인에 맞는가?
    한국어 문서, 코드 문서, 법률 문서처럼 특성이 강한 데이터는 임베딩 모델에 따라 검색 품질 차이가 날 수 있다.

  6. 키워드 검색이 필요한가?
    제품 코드, 계약 번호, 고유 명사처럼 정확한 문자열이 중요한 경우 벡터 검색만으로 부족할 수 있다. 이때는 BM25 같은 키워드 검색과 벡터 검색을 함께 쓰는 하이브리드 검색을 검토한다.

좋은 RAG는 "좋은 모델 하나"로 완성되지 않는다. 문서를 잘 나누고, 적절한 임베딩 모델을 고르고, 벡터스토어를 선택하고, 검색 설정을 계속 평가해야 한다.

이번 장의 정리

임베딩은 텍스트의 의미를 숫자 벡터로 표현한다. 벡터스토어는 이 벡터와 문서 조각을 저장하고, 사용자의 질문과 가까운 조각을 찾아준다. 리트리버는 이 검색 과정을 RAG 체인에 연결하기 위한 표준 인터페이스다.

실무에서는 다음 원칙을 기억하자.

  • 문서와 질문은 같은 임베딩 모델로 변환한다.

  • 처음에는 FAISS나 Chroma로 빠르게 검증한다.

  • 운영 환경에서는 확장성, 비용, 보안, 필터링 기능을 기준으로 벡터 DB를 선택한다.

  • top_k, 점수 임계값, 메타데이터 필터를 조정하며 검색 결과를 직접 확인한다.

  • 답변 품질이 낮으면 먼저 검색된 문서부터 본다.

다음 장에서는 이 검색 흐름을 프롬프트와 모델에 연결해, 출처가 있는 RAG 애플리케이션을 완성한다.

참고

1LangChain Vector store integrations

2LangChain Embedding model integrations

3LangChain Chroma integration

책 목록

랭체인 활용법: LLM 애플리케이션 개발부터 RAG·에이전트·운영까지

전체 목차 보기

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

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

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

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

참여하기