사내 매뉴얼 RAG 에이전트 실전 구현 — 청킹부터 리랭킹까지

사내 매뉴얼 RAG 에이전트 실전 구현 — 청킹부터 리랭킹까지
요약 — 컨플루언스에 쌓아둔 업무 매뉴얼을 질문하면 근거를 찾아 답하는 에이전트로 바꾸는 파이프라인을, 코드 레벨까지 정리합니다. 인제스천(문서 수집·청킹·색인)과 질의(하이브리드 검색·재정렬·생성) 두 흐름으로 나눠서, 각 단계에 실제로 쓰이는 라이브러리·API·권장 파라미터를 다룹니다.

→ AI 에이전트 구현 시리즈 전체 보기

왜 이런 구조가 필요한가

문서가 쌓일수록 “필요한 내용이 어느 페이지, 어느 문단에 있는지” 찾는 비용이 커집니다. 전체 문서를 LLM 컨텍스트에 통째로 넣는 방식은 문서량이 조금만 늘어도 컨텍스트 한도에 걸리고, 관련 없는 내용이 섞여 답변 품질도 떨어집니다. RAG는 질문마다 전체 문서를 다 읽히는 대신, 검색으로 관련 조각만 추려서 그것만 근거로 건네주는 방식입니다.

전체 아키텍처 — 두 개의 분리된 흐름

흐름 실행 시점 단계
인제스천 문서 → 검색 가능한 색인 문서 추가·수정 시 (배치/증분) 수집 → 청킹 → 임베딩(dense+sparse) → 색인
질의 질문 → 답변 사용자 질문마다 (실시간) 쿼리 처리 → 하이브리드 검색 → 재정렬 → 컨텍스트 조립 → 생성

이 둘을 분리해서 관리하는 게 중요합니다 — 색인 파이프라인이 느려도 사용자 응답 속도에는 영향이 없어야 합니다. 아래에서 각 단계를 실제 코드 패턴과 함께 봅니다.

1. 인제스천 — 컨플루언스 문서 가져오기

LangChain의 ConfluenceLoader를 쓰면 스페이스 단위로 페이지를 가져올 수 있습니다.

from langchain_community.document_loaders import ConfluenceLoader

loader = ConfluenceLoader(
    url="https://your-site.atlassian.net/wiki",
    token=API_TOKEN,
)
docs = loader.load(
    space_key="SPACE",
    include_attachments=True,
    limit=50,
    max_pages=1000,
)

매번 전체 스페이스를 다시 읽으면 비효율적이므로, 실무에서는 증분 동기화를 씁니다 — 컨플루언스 API가 페이지마다 제공하는 version.when(마지막 수정 시각) 값을 이전 동기화 시점과 비교해서, 바뀐 페이지만 다시 청킹·색인하는 방식입니다.[1] 이렇게 하면 매뉴얼이 수백~수천 페이지로 늘어나도 매번 전체를 재처리할 필요가 없습니다.

2. 청킹 — 구조를 살려서 자르기

가장 기본이 되는 건 RecursiveCharacterTextSplitter입니다. 줄바꿈 두 번 → 줄바꿈 한 번 → 공백 순으로 우선순위를 두고, 최대한 자연스러운 경계를 지키면서 정해진 길이로 자릅니다.

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1024,     # 토큰 기준, 임베딩 모델의 length_function과 맞춰야 함
    chunk_overlap=128,   # 약 12% — 청크 경계에서 문맥이 끊기는 걸 완화
)
chunks = splitter.split_documents(docs)

chunk_size 1024토큰·chunk_overlap 128토큰(약 12%) 정도가 실무에서 흔히 쓰는 시작값입니다.[2] 다만 컨플루언스처럼 제목·섹션 구조가 이미 있는 위키 문서라면, 고정 길이로 바로 자르기 전에 MarkdownHeaderTextSplitter로 헤더 단위로 먼저 나누고, 그 각각을 다시 RecursiveCharacterTextSplitter로 2차 분할하는 조합이 표준적인 패턴입니다.[3] 이렇게 하면 “표 헤더는 있는데 표 내용은 다음 청크에 있는” 식의 문맥 단절이 줄어듭니다.

3. Dense+Sparse 벡터를 함께 색인하기

Qdrant 같은 벡터DB는 하나의 포인트(레코드)에 dense 벡터와 sparse 벡터를 동시에 저장할 수 있습니다. Sparse 벡터는 FastEmbed 라이브러리의 BM25 구현으로 만듭니다.

from fastembed import SparseTextEmbedding, TextEmbedding

dense_model = TextEmbedding("BAAI/bge-base-en-v1.5")
sparse_model = SparseTextEmbedding("Qdrant/bm25")

for chunk in chunks:
    dense_vec = list(dense_model.embed(chunk.page_content))[0]
    sparse_vec = list(sparse_model.embed(chunk.page_content))[0]
    client.upsert(collection_name, points=[{
        "id": chunk.id,
        "vector": {"dense": dense_vec, "sparse": sparse_vec},
        "payload": {"text": chunk.page_content, "source_url": chunk.metadata["source"], "title": chunk.metadata["title"]},
    }])

BM25 sparse 벡터는 코퍼스 전체의 용어 빈도(IDF) 통계를 반영해야 정확해지므로, 문서가 늘어날 때마다 IDF 통계를 갱신하는 옵션을 켜두는 게 좋습니다.[4] payload에 원본 URL·제목 같은 메타데이터를 함께 저장해두는 게 핵심인데, 이게 나중에 답변에 출처를 붙이는 근거가 됩니다.

4. 질의 처리 — “질문을 쪼갠다”는 게 정확히 뭘 뜻하나

질문이 들어왔을 때 “청킹”한다는 표현을 쓰는 경우가 있는데, 엄밀히는 청킹은 문서를 분할하는 용어이고 질문에 대해 실무에서 실제로 쓰는 기법은 둘 중 하나입니다.

  • 멀티쿼리 확장(multi-query expansion): LLM이 원래 질문을 표현이 다른 여러 버전으로 다시 써서, 각각 따로 검색한 뒤 결과를 합치고 중복을 제거합니다. 사용자가 쓴 단어와 문서에 실제로 쓰인 단어가 다를 때(동의어·줄임말 차이) 놓치는 걸 줄여줍니다.
  • 쿼리 분해(query decomposition): “A와 B를 비교했을 때 C는 어떻게 되나” 같은 복합 질문을 “A는 무엇인가”, “B는 무엇인가”처럼 하위 질문으로 쪼개 각각 검색합니다. 여러 문서를 넘나들며 답해야 하는 멀티홉 질문에 강합니다.
from langchain.retrievers.multi_query import MultiQueryRetriever

mq_retriever = MultiQueryRetriever.from_llm(
    retriever=base_hybrid_retriever, llm=llm,
)
docs = mq_retriever.invoke("연차를 이월할 수 있는 조건이 뭐야?")
# 내부적으로 LLM이 2~3개 변형 질문을 만들어 각각 검색 후 합쳐서 반환

“100개를 뽑는다”는 이 확장된 질문들로 하이브리드 검색을 돌린 결과를 합친 후보군의 크기라고 보면 됩니다 — 정답을 1등으로 맞히는 게 목표가 아니라, 정답이 후보군 안에 반드시 들어있게 하는 게 이 단계의 목표이기 때문에 넉넉하게 뽑습니다.

5. 하이브리드 검색 — Dense와 Sparse를 RRF로 합치기

Qdrant는 Query API에서 dense·sparse 검색을 각각 prefetch로 돌린 뒤, Fusion.RRF로 결합하는 기능을 기본 제공합니다.

from qdrant_client import models

result = client.query_points(
    collection_name,
    prefetch=[
        models.Prefetch(query=sparse_vec, using="sparse", limit=20),
        models.Prefetch(query=dense_vec, using="dense", limit=20),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=100,
)

RRF(Reciprocal Rank Fusion)는 두 검색 결과의 점수 스케일이 달라서 단순 평균을 낼 수 없다는 문제를 해결하는 표준적인 방법입니다 — 각 결과에서 문서의 순위(rank)만 가지고 1/(k+rank) 점수를 매겨 합산하며, k는 보통 60을 씁니다.[5] LangChain을 쓴다면 dense retriever와 BM25Retriever를 가중치와 함께 묶는 EnsembleRetriever로도 같은 효과를 낼 수 있습니다.

6. 재정렬 — 100개를 10개로 압축하기

하이브리드 검색에 쓰는 임베딩 모델(bi-encoder)은 질문과 문서를 각각 따로 벡터화해서 비교하는 반면, 재정렬에 쓰는 크로스 인코더(cross-encoder)는 질문과 문서를 한 쌍으로 묶어 동시에 넣고 관련도를 직접 계산합니다. 훨씬 정확하지만 느려서, 넓게 뽑은 후보(100개)에만 적용합니다.

# 상용 API (Cohere Rerank)
resp = co.rerank(model="rerank-v3.5", query=query, documents=docs, top_n=10)

# 오픈소스 대안 — 로컬 실행
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")  # 다국어 지원, CPU로도 구동 가능
scores = reranker.predict([(query, doc) for doc in docs])

사내 매뉴얼처럼 한국어·영어가 섞인 환경이라면 다국어를 지원하는 bge-reranker-v2-m3가 현실적인 선택지입니다. 이 재정렬 단계만으로 검색 품질 지표(NDCG@10)가 5~15점 개선되는 것으로 보고됩니다.[6]

7. 컨텍스트 조립과 답변 생성 — 출처를 남기기

재정렬로 압축한 top-N 청크를 LLM 프롬프트에 넣을 때, 3단계에서 저장해둔 메타데이터(원본 URL·섹션 제목)를 함께 넣어두면 답변에 근거를 표시할 수 있습니다.

context = "\n\n".join(
    f"[출처: {c.title}]({c.source_url})\n{c.text}" for c in top_chunks
)
prompt = f"""아래 문서 내용만 근거로 답변하세요. 근거가 없으면 모른다고 답하세요.

{context}

질문: {question}"""

“문서에 없으면 모른다고 답하라”는 지시를 프롬프트에 명시하는 것과, 답변에 출처 링크를 함께 반환하도록 요구하는 것 — 이 두 가지가 실무에서 할루시네이션을 줄이고 검증 가능성을 높이는 가장 기본적인 장치입니다.

운영 — 품질을 어떻게 측정하나

이 파이프라인을 한 번 만들고 끝내는 게 아니라 지속적으로 품질을 확인하려면, RAGAS 같은 평가 프레임워크를 씁니다.[7] 검색 단계는 Recall@k(정답 근거가 상위 k개 안에 들어오는 비율)·MRR·Context Precision으로, 생성 단계는 Faithfulness(답변의 주장이 실제로 검색된 컨텍스트로 뒷받침되는 비율)로 측정합니다. 청크 크기나 재정렬 모델을 바꿀 때마다 이 지표로 전/후를 비교하는 게 감으로 튜닝하는 것보다 훨씬 안전합니다.

이 구조를 “랭체인(LangChain) 기반”이라고 불러도 될까?

아키텍처 패턴 자체는 특정 프레임워크에 종속되지 않습니다. 청킹 → 임베딩 → 하이브리드 검색 → 재정렬 → 생성 파이프라인은 LangChain 없이도, 또는 LlamaIndex·Haystack 같은 다른 프레임워크로도 동일하게 만들 수 있습니다.[8] “LangChain 기반”이라는 표현이 정확하려면, 위 코드에서처럼 실제로 LangChain의 컴포넌트(ConfluenceLoader, TextSplitter, MultiQueryRetriever, EnsembleRetriever, Chain)를 오케스트레이션에 썼는지가 기준입니다. 컴포넌트 대부분을 직접 구현했거나 다른 프레임워크를 썼다면 “RAG 파이프라인(자체 구현)”이 더 정확한 표현입니다.

다음 편에서 다루는 Langflow는 LangChain 위에 만들어진 비주얼 빌더입니다 — 화면의 각 노드가 LangChain의 LLM·Chain·Retriever 객체에 그대로 매핑되는 구조라, Langflow로 파이프라인을 구성했다면 그건 LangChain 기반이라고 부르는 게 맞습니다.[9] 이 파이프라인을 실제로 Langflow 컴포넌트로 재구성하면서 어디까지 로우코드로 되고 어디서부터 코드가 필요한지는 다음 편에서 이어집니다.

정리

  • 인제스천(수집→청킹→dense/sparse 색인)과 질의(쿼리 확장→하이브리드 검색→재정렬→생성)를 분리해서 관리한다.
  • 청킹은 chunk_size 1024토큰·overlap 128토큰이 출발점, 위키형 문서는 헤더 기반 분할을 먼저 적용한다.
  • 하이브리드 검색은 RRF로 결합하고, 재정렬은 크로스 인코더로 100개를 10개로 압축한다.
  • “질문을 청킹한다”는 표현은 실무에서는 멀티쿼리 확장이나 쿼리 분해를 가리키는 경우가 많다.
  • “LangChain 기반”이라 부르려면 실제로 LangChain의 로더·스플리터·리트리버·체인을 오케스트레이션에 썼는지를 기준으로 판단한다.
  • 다음 편에서는 이 구조를 LangFlow로 시각적으로 구성하는 방법을 다룬다 — 시리즈 전체 목록에서 확인할 수 있다.

참고자료

질문이나 지적할 부분이 있으면 문의로 알려주세요.

Questions or corrections? Let us know via Contact.

AI

AI map Ontology

기업 IT·데이터 조직에서 20년 넘게 실무를 해온 사람이 씁니다. 모든 사례는 익명화·일반화합니다. 소개 보기 →

AI

AI map Ontology

Written by someone with 20+ years in enterprise IT and data. All cases are anonymized and generalized. About us →

다음으로 읽어볼 글

개념을 이해했다면, 실제 설계와 활용 방법을 이어서 살펴보세요.

온톨로지 Foundry AIP 기업 AI 전략

Keep reading

Once you understand the concept, continue on to real design and usage patterns.

Ontology Foundry AIP Enterprise AI Strategy