Skip to main content

검색은 "단어 찾기"에서 "의미 찾기"로 바뀝니다

지난 장에서 우리는 문서를 불러오고, 깨끗하게 다듬고, 적당한 크기의 청크로 나누었습니다. 이제 책상 위에는 잘라 둔 문서 조각들이 가지런히 놓여 있습니다. 하지만 아직 이 조각들은 LLM 애플리케이션 입장에서는 그냥 문자열일 뿐입니다. 사용자가 "환불 정책이 어떻게 돼?"라고 물었을 때, 문서 안에 "반품", "취소", "결제 취소", "구매 철회" 같은 표현이 흩어져 있다면 단순 키워드 검색만으로는 충분하지 않습니다.

여기서 임베딩이 등장합니다. 임베딩은 텍스트를 숫자 벡터로 바꾸는 과정입니다. 벡터라고 하면 어렵게 느껴질 수 있지만, 쉽게 말하면 문장의 의미를 좌표로 옮기는 일입니다. 비슷한 의미의 문장은 벡터 공간에서 가까운 곳에 놓이고, 전혀 다른 의미의 문장은 멀리 떨어집니다. 그래서 "강아지 사료 추천"과 "반려견에게 먹일 음식"은 단어가 많이 달라도 의미적으로 가까운 결과로 검색될 수 있습니다.

LangChain의 검색 파이프라인은 보통 다음 흐름으로 움직입니다. 문서를 로드하고, 청크로 나눈 뒤, 각 청크를 임베딩 모델로 벡터화합니다. 그 벡터와 원문, 메타데이터를 벡터스토어에 저장합니다. 사용자가 질문하면 질문도 같은 임베딩 모델로 벡터화하고, 벡터스토어에서 가장 가까운 청크를 찾아옵니다. 이 검색 결과가 다음 장에서 만들 RAG 체인의 재료가 됩니다. LangChain 문서에서도 검색 파이프라인은 로더, 분할기, 임베딩, 벡터스토어, 리트리버가 교체 가능한 구성요소로 이어지는 구조로 설명됩니다.1

임베딩과 벡터스토어로 검색 기반 만들기

임베딩 모델 고르기: 품질, 비용, 속도, 실행 위치

임베딩 모델을 선택할 때 "가장 유명한 것"만 보면 안 됩니다. 실제 서비스에서는 검색 품질만큼이나 비용, 지연 시간, 벡터 차원 수, 데이터 반출 가능 여부가 중요합니다. 같은 문서를 매번 임베딩해야 한다면 호출 비용이 누적되고, 사용자의 질문을 실시간으로 임베딩해야 한다면 지연 시간이 곧 사용자 경험이 됩니다.

먼저 OpenAI 임베딩을 사용해 보겠습니다. text-embedding-3-small은 프로토타입과 일반적인 RAG에 자주 쓰기 좋은 모델입니다. 품질과 비용의 균형이 좋고, LangChain 통합도 간단합니다. 이 모델은 기본적으로 1536차원 벡터를 생성하는 예가 널리 사용됩니다.2

from langchain_openai import OpenAIEmbeddings

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

vector = embeddings.embed_query("환불 정책은 어떻게 되나요?")

print(type(vector))
print(len(vector))
print(vector[:5])

출력되는 값은 사람이 읽을 수 있는 문장이 아니라 실수 배열입니다. 예를 들어 [-0.01, 0.03, ...]처럼 보입니다. 중요한 것은 이 숫자 하나하나를 직접 해석하는 것이 아니라, 같은 모델로 만든 벡터끼리 거리를 비교할 수 있다는 점입니다.

문서 여러 개를 한 번에 임베딩할 때는 embed_documents를 사용하고, 질문 하나를 임베딩할 때는 embed_query를 사용합니다. LangChain의 임베딩 인터페이스가 이 둘을 나누는 이유는 일부 임베딩 제공자가 문서용 임베딩과 질의용 임베딩을 다르게 최적화하기 때문입니다.3

texts = [
    "구매 후 7일 이내에는 환불을 요청할 수 있습니다.",
    "배송은 영업일 기준 2~3일 정도 걸립니다.",
    "멤버십 등급에 따라 쿠폰 혜택이 달라집니다.",
]

doc_vectors = embeddings.embed_documents(texts)
query_vector = embeddings.embed_query("결제 취소가 가능한 기간은?")

print(len(doc_vectors))
print(len(doc_vectors[0]))
print(len(query_vector))

오픈소스 모델이나 로컬 실행이 필요하다면 Hugging Face 계열 임베딩을 사용할 수 있습니다. 개인정보나 내부 문서가 외부 API로 나가면 안 되는 조직에서는 로컬 임베딩 모델이 큰 장점이 됩니다. 대신 처음 모델을 내려받아야 하고, CPU만 사용할 경우 속도가 느릴 수 있습니다.

from langchain_huggingface import HuggingFaceEmbeddings

hf_embeddings = HuggingFaceEmbeddings(
    model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)

vector = hf_embeddings.embed_query("환불 정책은 어떻게 되나요?")
print(len(vector))

한국어 문서가 많다면 다국어 성능을 반드시 확인해야 합니다. 모델 이름에 multilingual이 들어 있다고 해서 항상 내 업무 데이터에서 잘 동작하는 것은 아닙니다. 제품 매뉴얼, 법률 문서, 고객 상담 로그처럼 문체가 다른 데이터에서는 작은 샘플셋을 만들어 직접 검색 품질을 비교하는 편이 안전합니다.

Ollama를 사용하면 로컬에서 실행 중인 임베딩 모델을 LangChain과 연결할 수도 있습니다.

from langchain_ollama import OllamaEmbeddings

ollama_embeddings = OllamaEmbeddings(model="nomic-embed-text")

vector = ollama_embeddings.embed_query("배송 지연 보상 기준을 알려줘")
print(len(vector))

임베딩 모델을 고를 때는 다음 기준을 함께 봐야 합니다.

기준확인할 질문실무 영향
임베딩 품질내 데이터에서 관련 문서를 잘 찾는가?답변 정확도와 직접 연결됩니다.
차원 수벡터가 몇 차원인가?저장 공간, 인덱스 크기, 검색 비용에 영향을 줍니다.
호출 비용문서와 질의를 임베딩할 때 비용이 얼마나 드는가?대량 인덱싱과 트래픽 증가 시 비용이 커집니다.
지연 시간질의 임베딩이 얼마나 빨리 끝나는가?사용자 응답 속도에 영향을 줍니다.
로컬 실행외부 API 없이 실행 가능한가?보안, 개인정보, 네트워크 의존성과 관련됩니다.
언어 지원한국어, 영어, 혼합 문서에 강한가?다국어 서비스의 검색 품질을 좌우합니다.

한 가지 규칙은 반드시 기억해야 합니다. 문서를 인덱싱할 때 사용한 임베딩 모델과 검색 질의를 임베딩할 때 사용하는 모델은 같아야 합니다. 임베딩 모델이 다르면 벡터가 놓이는 공간 자체가 달라집니다. 차원 수가 우연히 같더라도 의미 비교가 깨질 수 있습니다. 같은 지도 위의 좌표끼리 비교해야지, 서울 지하철 노선도 좌표와 세계지도 좌표를 섞어 비교할 수는 없습니다.

VectorStore는 검색 가능한 책장입니다

임베딩 벡터를 만들었다고 해서 검색 시스템이 완성되는 것은 아닙니다. 벡터를 어딘가에 저장하고, 사용자의 질의 벡터와 가까운 문서 벡터를 빠르게 찾아야 합니다. 이 역할을 하는 계층이 벡터스토어입니다.

LangChain의 VectorStore는 다양한 벡터DB를 같은 방식으로 다루기 위한 공통 인터페이스입니다. 구현체가 Chroma든 FAISS든 Pinecone이든, 애플리케이션 코드는 대체로 add_documents, delete, similarity_search, as_retriever 같은 메서드를 중심으로 작성할 수 있습니다.4

먼저 메모리 기반 벡터스토어로 감각을 잡아 보겠습니다. InMemoryVectorStore는 서버도, 별도 DB도 필요 없습니다. 실험과 단위 테스트에 좋습니다. 다만 프로세스가 종료되면 데이터가 사라지므로 운영 저장소로 쓰면 안 됩니다.

from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import OpenAIEmbeddings

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

sample_docs = [
    Document(
        page_content="구매 후 7일 이내에는 환불을 요청할 수 있습니다.",
        metadata={"source": "policy.md", "section": "refund", "page": 1},
    ),
    Document(
        page_content="배송은 영업일 기준 2~3일 정도 소요됩니다.",
        metadata={"source": "shipping.md", "section": "delivery", "page": 1},
    ),
    Document(
        page_content="프리미엄 멤버는 매월 무료 배송 쿠폰을 받을 수 있습니다.",
        metadata={"source": "membership.md", "section": "benefit", "page": 2},
    ),
]

vectorstore = InMemoryVectorStore(embeddings)
ids = vectorstore.add_documents(sample_docs)

results = vectorstore.similarity_search("결제 취소는 언제까지 가능한가요?", k=2)

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

이 코드는 내부적으로 문서를 임베딩하고, 벡터스토어에 저장한 뒤, 질문과 의미적으로 가까운 문서 두 개를 가져옵니다. 사용자는 "결제 취소"라고 물었지만, 문서에는 "환불"이라고 쓰여 있어도 검색될 수 있습니다. 이것이 의미 기반 검색의 매력입니다.

처음에는 similarity_search만으로도 충분합니다. 그러나 LangChain에서 RAG 체인을 만들 때는 보통 벡터스토어를 리트리버로 바꿔 사용합니다. 리트리버는 "질문을 넣으면 관련 문서 목록을 돌려주는 객체"라고 보면 됩니다.

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

docs = retriever.invoke("온보딩 절차에서 계정 발급은 언제 하나요?")

for doc in docs:
    print(doc.page_content[:200])

다음 장에서 우리는 이 retriever를 LCEL 체인에 연결해 "검색한 뒤 답변하는" 기본 RAG 파이프라인을 만들 것입니다.

Chroma, FAISS, InMemoryVectorStore로 로컬 검색 만들기

로컬 실험 단계에서는 설치가 쉽고 빠르게 결과를 확인할 수 있는 벡터스토어가 좋습니다. 대표적으로 Chroma, FAISS, InMemoryVectorStore가 있습니다.

Chroma는 로컬 개발과 작은 규모의 프로토타입에 편합니다. 디스크에 저장할 수 있고, 컬렉션 단위로 문서를 관리할 수 있습니다.

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

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

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

vectorstore.add_documents(chunks)

results = vectorstore.similarity_search("연차 사용 규칙을 알려줘", k=4)

for doc in results:
    print(doc.metadata.get("source"), doc.page_content[:150])

FAISS는 Meta에서 만든 벡터 검색 라이브러리로, 로컬에서 빠른 유사도 검색을 실험하기 좋습니다. 별도 서버 없이 동작하고, 대량 벡터 검색 알고리즘을 공부하거나 빠른 프로토타입을 만들 때 자주 쓰입니다. 다만 메타데이터 필터링이나 운영 관리 기능은 전문 벡터DB보다 제한적일 수 있습니다.

from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

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

vectorstore = FAISS.from_documents(
    documents=chunks,
    embedding=embeddings,
)

vectorstore.save_local("./faiss_index")

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

results = loaded_vectorstore.similarity_search("퇴사 절차는 어떻게 되나요?", k=3)

for doc in results:
    print(doc.page_content[:200])

allow_dangerous_deserialization=True는 이름 그대로 주의가 필요합니다. 신뢰할 수 없는 사람이 만든 인덱스 파일을 로드하면 보안 문제가 생길 수 있습니다. 로컬에서 직접 만든 파일을 다시 읽는 용도로만 사용하세요.

InMemoryVectorStore는 가장 가볍습니다. 테스트 코드, 예제, 검색 로직 검증에 좋습니다. 그러나 영속 저장이 필요하거나 여러 서버 인스턴스가 같은 인덱스를 공유해야 한다면 Chroma, PGVector, Qdrant, Pinecone 같은 저장소로 넘어가야 합니다.

운영용 벡터스토어 선택하기

프로토타입이 잘 동작하면 곧 다음 질문이 옵니다. "이걸 실제 서비스에 올리면 어디에 저장하지?" 이때는 벡터스토어를 데이터베이스로 보아야 합니다. 검색 품질뿐 아니라 백업, 확장성, 장애 대응, 권한 관리, 비용, 필터링 성능까지 함께 고려해야 합니다.

벡터스토어적합한 상황장점주의할 점
InMemoryVectorStore단위 테스트, 초소형 예제설치가 거의 필요 없고 빠르게 실험 가능프로세스 종료 시 데이터가 사라짐
Chroma로컬 프로토타입, 소규모 앱사용이 쉽고 디스크 저장 가능대규모 분산 운영은 별도 검토 필요
FAISS로컬 고속 검색, 연구·실험빠른 벡터 검색, 서버 불필요DB 운영 기능과 메타데이터 처리 기능은 제한적
Pinecone관리형 벡터DB, 빠른 운영 시작인프라 관리 부담이 낮고 확장성 확보가 쉬움외부 서비스 비용과 데이터 반출 정책 확인 필요
Qdrant오픈소스/관리형 선택, 필터링 중요 서비스메타데이터 필터링과 운영 기능이 좋음클러스터 운영 시 인프라 지식 필요
PGVectorPostgreSQL 기반 서비스기존 Postgres와 통합, 트랜잭션·권한·백업 활용인덱스와 커넥션 튜닝을 소홀히 하면 지연 시간 증가

이미 PostgreSQL을 중심으로 운영되는 서비스라면 PGVector가 매력적입니다. 사용자, 결제, 권한, 문서 메타데이터가 모두 Postgres에 있는데 벡터만 완전히 다른 시스템에 두면 운영 복잡도가 커질 수 있기 때문입니다.

import os
from langchain_openai import OpenAIEmbeddings
from langchain_postgres import PGVector

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

connection = os.environ["POSTGRES_CONNECTION_STRING"]
# 예: postgresql+psycopg://user:password@localhost:5432/app

vectorstore = PGVector(
    embeddings=embeddings,
    collection_name="company_policy",
    connection=connection,
    use_jsonb=True,
)

vectorstore.add_documents(chunks)

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

PGVector는 기존 PostgreSQL 운영 경험을 활용할 수 있다는 점이 강력합니다. 하지만 운영에서는 "그냥 Postgres니까 괜찮겠지"라고 생각하면 위험합니다. 벡터 컬럼이 커지고 동시 검색 요청이 늘어나면 일반적인 CRUD 서비스와 다른 병목이 생깁니다. 인덱스 생성, 쿼리 플랜, 커넥션 풀, p95와 p99 지연 시간을 반드시 관찰해야 합니다.

메타데이터는 검색의 안전벨트입니다

청크를 벡터스토어에 저장할 때 page_content만 넣으면 처음에는 간단해 보입니다. 하지만 곧 문제가 생깁니다. 검색 결과가 어느 문서에서 왔는지 알 수 없고, 특정 사용자에게 보여 주면 안 되는 문서가 섞일 수 있으며, 오래된 문서와 최신 문서를 구분하기 어렵습니다.

from langchain_core.documents import Document

chunk = Document(
    page_content="구매 후 7일 이내에는 환불을 요청할 수 있습니다.",
    metadata={
        "source": "policy/refund.md",
        "document_id": "refund-policy-2026",
        "section": "refund",
        "page": 1,
        "created_at": "2026-04-01",
        "tenant_id": "acme",
        "access_level": "internal",
    },
)

메타데이터는 검색 결과를 설명하는 데도 쓰이고, 검색 범위를 제한하는 데도 쓰입니다. 예를 들어 고객에게 공개된 문서만 검색하거나, 특정 제품군 문서만 검색하거나, 특정 날짜 이후의 정책 문서만 검색할 수 있습니다.

results = vectorstore.similarity_search(
    "환불은 언제까지 가능한가요?",
    k=3,
    filter={"section": "refund"},
)

벡터스토어 구현체마다 필터 문법은 조금씩 다릅니다. 운영 환경에서는 필터가 단순 편의 기능이 아니라 보안 기능이 될 수 있습니다. 멀티테넌트 서비스라면 tenant_id 필터를 빼먹는 순간 A 회사 문서가 B 회사 사용자에게 노출될 수 있습니다. RAG의 환각만큼이나 위험한 것이 "검색 권한 오류"입니다.

리트리버 튜닝: k, 점수 임계값, 필터, MMR

벡터스토어를 만들었다면 이제 검색 결과를 조정해야 합니다. 이때 가장 먼저 만지는 값이 k입니다. k는 검색 결과를 몇 개 가져올지 정합니다.

retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
retriever.invoke("재택근무 신청 절차를 알려줘")

k를 작게 잡으면 응답이 빠르고 LLM에 넣을 컨텍스트가 적어집니다. 대신 필요한 문서를 놓칠 수 있습니다. 반대로 k를 크게 잡으면 관련 문서를 놓칠 가능성은 줄지만, 불필요한 문서도 함께 들어와 답변이 흐려질 수 있습니다.

검색 결과에 점수를 함께 보고 싶다면 similarity_search_with_score 또는 벡터스토어가 제공하는 관련 메서드를 사용할 수 있습니다. 점수의 의미는 벡터스토어와 거리 metric에 따라 다를 수 있으므로 "높을수록 좋은지", "낮을수록 가까운지"를 확인해야 합니다.

results = vectorstore.similarity_search_with_score(
    "재택근무 신청 절차를 알려줘",
    k=5,
)

for doc, score in results:
    print(score, doc.metadata.get("source"), doc.page_content[:120])

검색 결과가 너무 엉뚱하게 섞인다면 점수 임계값을 사용할 수 있습니다. 지원하는 벡터스토어라면 다음처럼 설정할 수 있습니다.

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

마지막으로 MMR을 봅시다. MMR은 Maximal Marginal Relevance의 약자로, 관련성은 유지하면서 서로 너무 비슷한 결과가 반복되지 않도록 다양성을 높이는 검색 방식입니다.

retriever = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={
        "k": 5,
        "fetch_k": 20,
        "lambda_mult": 0.5,
    },
)

docs = retriever.invoke("복지 제도에는 어떤 것들이 있나요?")

fetch_k는 먼저 후보를 몇 개 가져올지 정합니다. k는 최종 반환 개수입니다. lambda_mult는 관련성과 다양성 사이의 균형을 조절합니다. 값이 1에 가까울수록 관련성을 더 중시하고, 0에 가까울수록 다양성을 더 중시합니다.

작은 검색 기반 파이프라인 완성하기

이제 문서 청크를 벡터화하고, 벡터스토어에 저장하고, 리트리버로 검색하는 기본 파이프라인을 하나로 묶어 보겠습니다. 다음 코드는 지난 장의 chunks를 입력으로 받아 Chroma에 저장하고 검색기를 만듭니다.

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings


def build_vectorstore(chunks):
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

    vectorstore = Chroma(
        collection_name="knowledge_base",
        embedding_function=embeddings,
        persist_directory="./chroma_knowledge_base",
    )

    vectorstore.add_documents(chunks)
    return vectorstore


def build_retriever(vectorstore):
    return vectorstore.as_retriever(
        search_type="mmr",
        search_kwargs={
            "k": 5,
            "fetch_k": 20,
            "lambda_mult": 0.5,
        },
    )


vectorstore = build_vectorstore(chunks)
retriever = build_retriever(vectorstore)

question = "신규 입사자의 장비 신청 절차는 어떻게 되나요?"
retrieved_docs = retriever.invoke(question)

for i, doc in enumerate(retrieved_docs, start=1):
    print(f"n[{i}] {doc.metadata.get('source')}")
    print(doc.page_content[:300])

여기까지가 RAG의 검색 기반입니다. 아직 LLM에게 답변을 생성시키지는 않았습니다. 그러나 이미 중요한 일을 끝냈습니다. 사용자의 질문을 의미 벡터로 바꾸고, 문서 청크 중 의미적으로 가까운 것들을 찾아오는 구조를 만들었습니다.

이 파이프라인을 실전적으로 만들려면 문서 ID를 명시적으로 관리하는 편이 좋습니다. 나중에 문서가 수정되거나 삭제될 때 기존 벡터를 지워야 하기 때문입니다.

ids = [
    f"{doc.metadata.get('document_id', 'doc')}:{doc.metadata.get('chunk_index', i)}"
    for i, doc in enumerate(chunks)
]

vectorstore.add_documents(chunks, ids=ids)

vectorstore.delete(ids=["employee-handbook:12"])

문서가 바뀌었는데 예전 벡터가 남아 있으면 사용자는 이미 폐기된 정책을 답변으로 받을 수 있습니다. 검색 기반 시스템에서 "업데이트와 삭제"는 부가 기능이 아니라 정확도의 일부입니다.

검색 품질을 실험하는 방법

임베딩과 벡터스토어를 연결하면 바로 RAG 답변 생성으로 넘어가고 싶어집니다. 하지만 좋은 RAG는 좋은 검색에서 시작합니다. 답변이 이상할 때 원인이 프롬프트인지, LLM인지, 검색인지 구분하려면 먼저 검색 결과 자체를 평가해야 합니다.

test_queries = [
    "환불은 며칠 안에 가능한가요?",
    "재택근무 신청은 어디에서 하나요?",
    "신규 입사자는 노트북을 어떻게 받나요?",
    "보안 교육을 듣지 않으면 어떻게 되나요?",
]

for query in test_queries:
    docs = retriever.invoke(query)
    print(f"n## Query: {query}")
    for doc in docs[:3]:
        print("-", doc.metadata.get("source"), doc.page_content[:120].replace("n", " "))

처음에는 자동 평가보다 사람이 눈으로 보는 것이 빠릅니다. 질문별로 "정답 문서가 top 3 안에 들어오는가?", "중복 청크가 너무 많지 않은가?", "권한이나 카테고리가 다른 문서가 섞이지 않는가?"를 확인합니다.

그다음 설정을 바꿔 가며 비교합니다.

실험바꿀 값관찰할 것
top-k 조정k=3, k=5, k=10정답 문서 포함 비율과 불필요한 문서 증가 여부
MMR 적용search_type="similarity"와 "mmr" 비교중복 청크 감소와 주제 다양성
필터 적용category, tenant_id, date 등검색 범위 축소와 누락 여부
임계값 적용score_threshold엉뚱한 문서 제거와 무응답 증가 여부
임베딩 모델 교체OpenAI, Hugging Face, Ollama한국어 검색 품질, 비용, 지연 시간

검색 품질을 볼 때는 재현율과 정확도를 함께 생각해야 합니다. 재현율은 관련 문서를 놓치지 않는 능력이고, 정확도는 가져온 문서가 실제로 관련 있는 비율입니다. 정답 문서를 못 가져오면 LLM은 아무리 똑똑해도 근거 없는 답을 만들 가능성이 높습니다. 반대로 너무 많은 잡음 문서를 가져오면 LLM이 엉뚱한 근거에 끌려갈 수 있습니다.

운영 환경에서 달라지는 것들

노트북에서 100개 문서를 검색할 때와 실제 서비스에서 수십만 개 청크를 검색할 때는 완전히 다른 세계입니다. 프로토타입에서는 "잘 되네"였던 코드가 운영에서는 "왜 p99가 4초까지 튀지?"라는 장애가 될 수 있습니다.

운영에서는 먼저 인덱스를 확인해야 합니다. 벡터 검색은 모든 벡터를 하나하나 비교하면 데이터가 커질수록 느려집니다. 그래서 많은 벡터DB는 HNSW 같은 근사 최근접 탐색 인덱스를 사용합니다. HNSW는 높은 차원의 벡터 공간에서 가까운 후보를 빠르게 찾기 위한 그래프 기반 방법입니다.5

운영 체크포인트를 정리하면 다음과 같습니다.

영역확인할 것
인덱스벡터 컬럼에 적절한 인덱스가 있는가? 거리 metric이 임베딩 모델과 맞는가?
동시 요청검색 요청이 동시에 몰릴 때 큐가 쌓이지 않는가?
커넥션 풀DB 커넥션이 고갈되지 않는가? 웹 서버와 워커 수에 맞는가?
지연 시간평균뿐 아니라 p95, p99 검색 지연 시간을 관찰하는가?
재인덱싱문서 변경 시 기존 벡터 삭제와 새 벡터 추가가 안전하게 처리되는가?
필터 성능자주 쓰는 메타데이터 필터가 인덱싱되어 있는가?
비용임베딩 재계산, 벡터 저장 공간, 검색 요청 비용을 추적하는가?
보안tenant, 권한, 공개 범위 필터가 누락되지 않는가?

p95는 전체 요청 중 95%가 이 시간 안에 끝난다는 뜻이고, p99는 99%가 이 시간 안에 끝난다는 뜻입니다. 평균 응답 시간이 200ms여도 p99가 5초라면 일부 사용자는 매우 느린 서비스를 경험합니다.

또 하나 중요한 것은 임베딩 캐시입니다. 동일한 문서를 매번 다시 임베딩하면 비용과 시간이 낭비됩니다. 문서의 해시값을 저장해 두고 내용이 바뀌었을 때만 새로 임베딩하는 방식이 좋습니다. 질문 임베딩도 완전히 같은 질문이 반복되는 서비스라면 캐시할 수 있습니다. 다만 사용자별 권한이 다른 경우 검색 결과 자체를 캐시할 때는 매우 조심해야 합니다.

다음 장으로 가기 전에

이번 장에서는 문서 청크를 벡터로 바꾸고, 벡터스토어에 저장한 뒤, 리트리버로 의미 기반 검색을 수행하는 기반을 만들었습니다. 이제 LangChain 애플리케이션은 단순히 프롬프트에 들어 있는 정보만 다루는 것이 아니라, 외부 문서 저장소에서 필요한 맥락을 찾아올 수 있게 되었습니다.

핵심은 세 가지입니다. 첫째, 임베딩 모델은 텍스트의 의미를 숫자 벡터로 바꾸며, 문서와 질문은 같은 모델로 임베딩해야 합니다. 둘째, VectorStore는 벡터와 원문, 메타데이터를 저장하고 유사도 검색을 수행하는 계층이며, LangChain의 공통 인터페이스 덕분에 구현체를 비교적 쉽게 교체할 수 있습니다. 셋째, 검색 품질은 k, 점수 임계값, 메타데이터 필터, MMR, 인덱스 설정 같은 요소를 함께 조정해야 좋아집니다.

다음 장에서는 이 검색기를 LLM 체인에 연결합니다. 사용자의 질문이 들어오면 리트리버가 관련 청크를 찾고, 프롬프트가 그 청크를 컨텍스트로 정리하고, 모델이 근거 기반 답변을 생성하는 기본 RAG 파이프라인을 만들 것입니다. 지금 만든 retriever가 바로 그 출발점입니다.

참고

1Retrieval - Docs by LangChain

2OpenAI Embeddings 문서

3LangChain Embedding models

4LangChain Vector stores

5Qdrant: What is HNSW?

책 목록

LangChain 실전 활용법: LLM 앱부터 RAG·에이전트·운영까지

전체 목차 보기

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

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

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

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

참여하기