AI Engineering/RAG

RAG 성능 고도화1 — 청킹 전략 깊게 파기

코드와트 2026. 6. 21. 16:51

RAG 성능 고도화1 — 청킹 전략 깊게 파기

이 글은 RAG 기반 시스템을 실제로 구축하면서 마주쳤던 청킹 전략의 선택 문제를 정리한 내용이다. 책과 Gemini의 도움을 빌려 궁금증을 해소해가며 쌓은 기록이다. 

청킹이 왜 필요한가?

RAG 시스템을 처음 설계할 때 가장 단순한 접근은 "그냥 문서를 통째로 LLM에 넣어서 처리하면 되지 않나?"라는 생각이 든다. 하지만 실제로 해보면 두 가지 벽에 부딪힌다.

첫째는 토큰 한계다. LLM은 한 번에 처리할 수 있는 텍스트 양이 정해져 있어서, 문서가 길면 아예 입력 자체가 불가능하다. 둘째는 정확도 문제다. 설령 들어간다 해도, 방대한 문서 속에서 관련 없는 내용까지 한꺼번에 읽어야 하는 LLM은 핵심을 놓치거나 엉뚱한 부분에 집중하기 쉽다.

그래서 문서를 적절한 크기로 잘라 저장하고, 필요한 조각만 골라 LLM에 넘기는 방식이 등장했다. 이 잘라내는 행위를 청킹(Chunking)이라 부른다. 어떻게 자르느냐에 따라 검색 정확도와 답변 품질이 크게 달라지기 때문에, 단순해 보이는 이 과정이 사실 RAG 고도화의 핵심 중 하나다.

청킹 전략은 크게 두 갈래로 나뉜다.
문자수 기반 분할 부모-자식 기반 분할 등이 있다.


문자수 기반 분할 — 가장 단순하지만 한계가 있다

문자수 기반 분할은 말 그대로 정해진 글자 수마다 텍스트를 잘라내는 방식이다. 구현이 간단하고 예측 가능하다는 장점이 있다. 하지만 문장 한가운데를 무 자르듯 끊어버리면, 앞뒤 문맥을 잃어버려 임베딩 모델이 의미를 제대로 포착하지 못하는 문제가 생긴다.

이 문제를 완화하기 위해 오버랩(Overlap) 개념이 쓰인다. 앞 청크의 끝부분과 뒷 청크의 앞부분이 일정 글자 수만큼 겹치도록 설정해, 문맥의 연결 고리를 유지하는 방식이다. 예를 들어 chunk_size=500, chunk_overlap=50으로 설정하면 청크들이 50자씩 겹치며 이어진다.

[원본 문서] ─────────────────────────────────────────────────>
[Chunk 1: 0~500자] ──(Overlap: 50자)── [Chunk 2: 450~950자] ──> ...

LangChain의 CharacterTextSplitter가 이 방식의 대표 구현체다.

# 텍스트 청킹 예제: 도서관 책 소개 텍스트를 chunk_size와 chunk_overlap으로 분할

def chunk_text(text: str, chunk_size: int, chunk_overlap: int) -> list[str]:
    """텍스트를 일정 크기(chunk_size)로 나누되, 앞 청크와 겹치는 구간(chunk_overlap)을 유지한다."""
    if chunk_size <= 0:
        raise ValueError("chunk_size는 1 이상이어야 합니다.")
    if chunk_overlap >= chunk_size:
        raise ValueError("chunk_overlap은 chunk_size보다 작아야 합니다.")

    chunks = []
    start = 0

    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])

        # 다음 청크 시작점: chunk_size만큼 전진하되 overlap만큼 뒤로 당긴다
        start += chunk_size - chunk_overlap

    return chunks


# 도서관 책 소개 예시 텍스트
book_description = (
    "도서관 사서가 추천하는 이번 달의 책은 '내일의 도서관'입니다. "
    "이 책은 작은 마을 도서관을 배경으로, 책 속 이야기가 현실로 스며드는 신비로운 경험을 그립니다. "
    "주인공 소율은 오래된 열람실 한 켠에서 낡은 일기장을 발견하고, "
    "그 일기장의 주인을 찾아가는 여정을 통해 독서의 의미를 깨달아 갑니다. "
    "저자는 도서관이 단순한 책 보관소가 아니라 사람과 이야기가 만나는 공간임을 섬세하게 묘사합니다. "
    "책장을 넘길수록 독자는 자신만의 도서관 기억을 떠올리게 될 것입니다."
)

# 청킹 파라미터 설정
CHUNK_SIZE = 80      # 한 청크에 담을 최대 문자 수
CHUNK_OVERLAP = 20   # 앞 청크와 겹칠 문자 수

chunks = chunk_text(book_description, CHUNK_SIZE, CHUNK_OVERLAP)

print(f"전체 텍스트 길이: {len(book_description)}자")
print(f"chunk_size={CHUNK_SIZE}, chunk_overlap={CHUNK_OVERLAP}")
print(f"생성된 청크 수: {len(chunks)}\n")

for i, chunk in enumerate(chunks):
    print(f"[청크 {i+1}] (길이: {len(chunk)}자)")
    print(chunk)
    print("-" * 60)

단순하고 빠르다는 건 장점이지만, 문장 구조나 단락 경계를 전혀 고려하지 않는다는 게 치명적인 약점이다. 이 단점을 개선한 것이 뒤에서 다룰 RecursiveCharacterTextSplitter다.


부모-자식 분할 — 검색과 답변을 분리한다

문자수 기반이 "어떻게 자르냐"의 문제였다면, 부모-자식 분할은 "무엇을 검색에 쓰고, 무엇을 LLM에 넘기냐"를 분리하는 전략이다.

핵심 아이디어는 이렇다. 검색할 때는 잘게 쪼갠 자식 청크로 정밀하게 찾고, LLM에 답변을 생성시킬 때는 충분한 문맥을 담은 부모 청크를 넘긴다. 두 역할을 서로 다른 크기의 텍스트에 맡기는 것이다.

구체적인 흐름은 다음과 같다.

  1. 사용자의 질문이 들어오면 벡터 DB에서 유사한 자식 청크를 검색한다.
  2. 찾아낸 자식 청크에 붙어있는 메타데이터(parent_id)를 따라 부모 청크를 추적한다.
  3. 부모 청크의 풍부한 원문을 LLM에 전달해 답변을 생성한다.

자식 청크는 임베딩되어 벡터 DB에 저장하고, 부모 청크는 RDB 형태로 원본을 보관한다. 단순 의미 기반 검색을 대체하는 게 아니라, 상호보완적으로 활용하는 구조다.


청크 사이즈는 어떻게 정하는가

부모-자식 구조를 도입하기로 했다면, 각각의 크기를 어떻게 설정할지가 다음 고민이다. 기준은 단순하다. 자식은 "검색 정확도"를, 부모는 "답변의 풍부함"을 기준으로 크기를 잡는다.

구분추천 크기역할

자식(Child) 200 ~ 400자 검색 전용. 임베딩 모델이 의미를 가장 잘 포착하는 단위
부모(Parent) 1,000 ~ 2,000자 답변 생성용. LLM이 충분한 문맥을 파악할 수 있는 단락 단위

자식이 너무 작으면 의미가 파편화되고, 너무 크면 검색 정밀도가 떨어진다. 부모가 너무 크면 LLM에 전달되는 토큰이 급증해 비용이 오르고 속도가 느려진다. 보통 자식 크기의 4~5배 수준이 실무에서 쓰는 경험치다.


임베딩 모델의 토큰 한계를 놓치면 안 된다

청크 사이즈를 정할 때 한 가지 더 고려해야 하는 게 있다. 바로 임베딩 모델이 한 번에 처리할 수 있는 최대 토큰 수다.기존의 오픈소스 임베딩 모델은 BERT 구조를 기반으로 만들어졌는데, 이 모델들은 설계 단계에서 "최대 512개 토큰까지만 처리한다"고 약속하고 학습됐다. 한글 기준으로 대략 700~900자 분량이다.

"전통적으로 많은 경량 오픈소스 임베딩 모델은 BERT 계열 인코더 구조라, 설계상 최대 512 토큰까지만 처리하도록 학습된 경우가 많다(한글 약 700~900자). 다만 최근에는 nomic-embed·jina·BGE-M3처럼 8,192 토큰을 지원하는 오픈소스 임베딩도 늘었고, 상위권은 디코더 LLM 기반으로 옮겨가는 추세다."

만약 자식 청크를 900자로 설정했는데 임베딩 모델의 한계가 512토큰이라면, 모델은 앞의 512토큰만 읽고 나머지는 그냥 잘라낸다. 결과적으로 청크 뒷부분의 내용은 벡터에 반영되지 않는다. 사용자의 질문에 대한 정답이 그 잘린 부분에 있었다면, 검색 자체에서 누락된다.

반면 OpenAI의 text-embedding-3 같은 상용 임베딩 모델은 최대 8,191토큰까지 처리할 수 있어 이 한계에서 훨씬 자유롭다. 다만 검색 정밀도를 위해서는 상용 모델이라도 자식 청크를 작게 유지하는 게 유리하다는 건 마찬가지다.


BERT와 GPT는 같은 LLM인데 왜 역할이 다른가

청킹 공부를 하다 보면 BERT가 자주 등장한다. GPT도 LLM이고 BERT도 LLM의 범주에 들어가는데, 왜 임베딩 이야기를 할 때마다 BERT가 튀어나오는지 처음엔 헷갈렸다. 이유는 두 모델이 트랜스포머(Transformer)라는 공통 뿌리에서 출발했지만, 서로 완전히 다른 방향으로 특화됐기 때문이다.

GPT는 Decoder 구조로, "다음 단어 예측하기"에 올인했다. 앞의 문장을 보고 뒤에 올 내용을 생성하는 데 특화된 '작가'다. 우리가 흔히 쓰는 생성형 AI의 주인공이다.

BERT는 Encoder 구조로, "문맥 파악하기"에 올인했다. 문장 전체를 한꺼번에 읽어서 각 단어의 의미를 숫자로 뽑아내는 데 특화된 '독해 전문가'다.

  • GPT에게 "사과"를 주면 → "사과는 맛있는 과일입니다..."라고 글을 이어 쓴다.
  • BERT에게 "사과"를 주면 → [0.12, -0.05, 0.88, ...] 같은 임베딩 벡터를 뱉는다.

RAG에서 정보를 '찾는' 단계에는 글을 잘 쓰는 능력보다 의미를 정확한 숫자로 바꿔 유사도를 계산하는 능력이 필요하다. 그래서 검색 역할은 BERT 계열 모델들이 담당하는 것이다.

실무에서는 이 두 유형을 구분해서 부른다.

유형대표 모델역할

임베딩 모델 (BERT 계열) BGE, KoSBERT 등 문장을 벡터로 변환. 검색 담당
생성 LLM (GPT, Claude 계열) GPT-4o, Claude, Llama 찾은 정보를 읽고 답변 생성

임베딩 모델 선택: BERT 계열 vs text-embedding-3

오픈소스 BERT 계열 모델과 OpenAI의 상용 임베딩 모델 중 무엇을 쓸지는 상황에 따라 갈린다.

구분BERT 계열 (BGE, RoBERTa 등)text-embedding-3 (OpenAI)

인프라 내 서버(GPU)에 직접 올려야 함 API 키로 호출
입력 한계 보통 512 토큰 최대 8,191 토큰
비용 하드웨어 유지비 API 호출당 과금
보안 데이터가 외부로 나가지 않음 OpenAI 서버로 전송

text-embedding-3는 긴 문서도 한 번에 처리할 수 있고, 벡터 차원을 줄여도 성능 하락이 적은 Matryoshka Embedding 기능이 있어 저장 비용을 아낄 수 있다. MTEB 리더보드 상위권에 꾸준히 이름을 올리며 한국어 성능도 준수하다.

반면 BERT 계열을 고집하는 이유는 명확하다. 금융권이나 공공기관처럼 데이터를 외부로 보낼 수 없는 환경이라면 선택의 여지가 없다. 또 네트워크 통신 없이 내 GPU 안에서 바로 계산하므로, 초당 수천 건의 검색이 필요한 서비스에서는 로컬 모델이 유리하다. 의료, 법률처럼 전문 용어가 많은 도메인에서는 해당 분야 데이터로 직접 파인튜닝한 BERT 계열 모델이 범용 모델보다 훨씬 정확하다.

BERT에서 파생된 주요 모델들도 알아두면 좋다. SBERT(Sentence-BERT)는 문장 간 유사도 검색에 최적화된 버전으로, 우리가 쓰는 대부분의 오픈소스 임베딩이 이 구조를 따른다. BioBERT, LegalBERT, KoSBERT 같은 도메인 특화 모델도 있고, DistilBERT, ALBERT처럼 성능은 유지하면서 크기를 줄인 경량화 버전도 있다.

현대 AI 개발의 표준은 모델을 직접 만드는 게 아니라, 이미 거대한 데이터로 학습된 Pre-trained 임베딩 모델을 가져다 쓰는 것이다. Hugging Face의 MTEB 리더보드에서 내 도메인(한국어, 금융, 의료 등)에 점수가 높은 모델을 선택하면 된다.


RecursiveCharacterTextSplitter — 문맥을 지키며 자르는 법

문자수 기반 분할의 단점, 즉 문장 중간을 무작정 끊어버리는 문제를 개선한 것이 RecursiveCharacterTextSplitter다. LangChain에서 가장 기본적으로 쓰이는 분할기로, AI 기술이 들어간 게 아니라 순수한 Python 로직이다. CharacterTextSplitter보다 한 단계 진화된 형태라고 보면 된다.

핵심 원칙은 "글자 수는 맞추되, 문맥은 깨지 마라"다. 단순히 500자마다 칼로 자르는 대신, 문단과 문장의 경계를 최대한 존중하면서 지정한 크기에 맞게 양보해가며 잘라낸다.

내부적으로는 구분자 배열 ["\n\n", "\n", " ", ""]을 순서대로 시도한다. 먼저 문단 단위(\n\n)로 잘라보고 크기가 맞으면 확정하고, 문단이 너무 크면 문장 단위(\n)로 다시 쪼개고, 그래도 크면 단어 단위, 마지막에는 글자 단위까지 내려간다. 덕분에 가능한 한 의미 있는 단위를 유지하면서 청킹된다.


ParentDocumentRetriever — 부모-자식 구조의 실전 구현

LangChain은 부모-자식 청킹 전략을 쉽게 쓸 수 있도록 ParentDocumentRetriever를 제공한다. 두 개의 저장소와 두 개의 분할기를 유기적으로 연결한 데이터 매핑 라우터다. 반드시 이걸 써야 하는 건 아니지만, 빠르게 프로토타입을 만들 때는 편리하다.

구성 요소는 네 가지다.

  • vectorstore: 자식 청크와 그 임베딩 벡터를 저장하는 벡터 DB. 검색 엔진 역할.
  • docstore: 부모 문서 원본을 보관하는 Key-Value 저장소(InMemoryStore, Redis 등). 검색이 아닌 원문 보관 목적.
  • child_splitter: 자식 청크를 자르는 기준. 유사도 검색 정밀도를 높이기 위해 작게(100~200자) 설정.
  • parent_splitter: 부모 청크를 자르는 기준. LLM이 읽기 좋은 크기(1,000~2,000자)로 설정. 지정하지 않으면 원본 문서 전체가 부모가 된다.
import math
import uuid
from collections import defaultdict

# ── 간단한 TF 기반 벡터라이저 (실제 임베딩 대신 사용) ──────────────────────
def tokenize(text: str) -> list[str]:
    return text.lower().split()

def build_vocab(documents: list[str]) -> list[str]:
    vocab = set()
    for doc in documents:
        vocab.update(tokenize(doc))
    return sorted(vocab)

def vectorize(text: str, vocab: list[str]) -> list[float]:
    tokens = tokenize(text)
    freq = defaultdict(int)
    for t in tokens:
        freq[t] += 1
    return [freq.get(word, 0) for word in vocab]

def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float:
    dot = sum(a * b for a, b in zip(vec_a, vec_b))
    norm_a = math.sqrt(sum(a ** 2 for a in vec_a))
    norm_b = math.sqrt(sum(b ** 2 for b in vec_b))
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)


# ── 부모 저장소(RDB 역할): 뉴스 기사 전체 원문 ─────────────────────────────
parent_store: dict[str, dict] = {}

# ── 자식 저장소(벡터 DB 역할): 문단 단위 청크 ─────────────────────────────
child_store: dict[str, dict] = {}
vocab: list[str] = []


def split_into_paragraphs(article_text: str) -> list[str]:
    return [p.strip() for p in article_text.split("\n\n") if p.strip()]


def index_article(title: str, content: str, date: str) -> str:
    parent_id = str(uuid.uuid4())

    # 부모 저장: 기사 원문 전체 보관
    parent_store[parent_id] = {"title": title, "content": content, "date": date, "paragraph_ids": []}

    # 자식 생성: 문단 단위로 분리
    for para in split_into_paragraphs(content):
        child_id = str(uuid.uuid4())
        parent_store[parent_id]["paragraph_ids"].append(child_id)
        child_store[child_id] = {"text": para, "parent_id": parent_id, "vector": None}

    return parent_id


def rebuild_index():
    global vocab
    vocab = build_vocab([c["text"] for c in child_store.values()])
    for child in child_store.values():
        child["vector"] = vectorize(child["text"], vocab)


def search(query: str, top_k: int = 3) -> list[dict]:
    query_vec = vectorize(query, vocab)
    scores = [(cid, cosine_similarity(query_vec, c["vector"])) for cid, c in child_store.items()]
    scores.sort(key=lambda x: x[1], reverse=True)

    seen_parents: set[str] = set()
    results: list[dict] = []
    for child_id, sim in scores:
        if len(results) >= top_k:
            break
        parent_id = child_store[child_id]["parent_id"]
        if parent_id in seen_parents:
            continue
        seen_parents.add(parent_id)
        article = parent_store[parent_id]
        results.append({
            "score": round(sim, 4),
            "matched_paragraph": child_store[child_id]["text"],
            "title": article["title"],
            "date": article["date"],
            "full_content": article["content"],
        })
    return results

LangChain을 걷어내고 직접 구현하는 이유

실제로 상용 수준의 서비스를 만들다 보면, 시니어 엔지니어들이 결국 ParentDocumentRetriever를 버리고 직접 구현하는 경우를 자주 보게 된다. 이유는 세 가지다.

첫 번째는 벡터 DB 고유 기능의 완전한 활용이다. LangChain 래퍼를 통하면 Qdrant 같은 벡터 DB가 지원하는 고성능 기능을 쓰기 까다로워진다. 직접 구현하면 테넌트 격리(user_id 기반 필터링 등)를 자유롭게 쿼리에 녹여낼 수 있다.

두 번째는 인프라 단순화다. LangChain은 내부에 InMemoryStore 같은 불안정한 자원을 쓰거나, 자체 규격 객체로 데이터를 변환하는 오버헤드가 있다. 내가 이미 쓰는 메인 DB(Postgres, Redis 등)에 부모 문서를 올려버리면 구조가 훨씬 단순해지고 백업과 관리도 쉬워진다.

세 번째는 LangChain 버전 업데이트 지옥 탈출이다. LangChain은 API 변경이 잦아 버전이 조금만 바뀌어도 기존 코드가 깨지는 것으로 악명이 높다. 비즈니스의 핵심인 RAG 파이프라인을 순수 Python과 공식 DB 드라이버(qdrant-client, SQLAlchemy 등)로만 짜두면, 외부 라이브러리 변화에 흔들리지 않는 견고한 구조가 된다.

직접 구현하면 쿼리 흐름은 단 두 번의 DB 호출로 정리된다. 벡터 DB에서 자식 청크로 위치를 저격하고, 그 parent_id로 메인 DB에서 부모 원문을 꺼내 LLM에 넘긴다.

import math
import random

# ── 가짜 임베딩 함수 (실제 모델 대신 해시 기반 벡터) ──────────────────────
def fake_embed(text: str, dim: int = 8) -> list[float]:
    random.seed(hash(text) % (2**32))
    vec = [random.gauss(0, 1) for _ in range(dim)]
    norm = math.sqrt(sum(v**2 for v in vec))
    return [v / norm for v in vec]

def cosine_similarity(a: list[float], b: list[float]) -> float:
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x**2 for x in a))
    norm_b = math.sqrt(sum(x**2 for x in b))
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)


# ── 메인 DB (SQLAlchemy 역할): 영화 리뷰 원문 저장 ─────────────────────────
parent_store: dict[str, dict] = {}

def db_insert_parent(parent_id: str, title: str, full_review: str, rating: float):
    parent_store[parent_id] = {"parent_id": parent_id, "title": title, "full_review": full_review, "rating": rating}

def db_get_parent(parent_id: str) -> dict | None:
    """두 번째 DB 호출: parent_id로 원문 조회"""
    return parent_store.get(parent_id)


# ── 벡터 DB (Qdrant 역할): 자식 청크 + parent_id 저장 ─────────────────────
child_store: list[dict] = []

def vector_db_insert_child(chunk_id: str, parent_id: str, chunk_text: str):
    child_store.append({"chunk_id": chunk_id, "parent_id": parent_id,
                        "chunk_text": chunk_text, "embedding": fake_embed(chunk_text)})

def vector_db_search(query: str, top_k: int = 3) -> list[dict]:
    """첫 번째 DB 호출: 쿼리와 유사한 자식 청크 검색"""
    query_vec = fake_embed(query)
    scored = [{**c, "score": cosine_similarity(query_vec, c["embedding"])} for c in child_store]
    scored.sort(key=lambda x: x["score"], reverse=True)
    return scored[:top_k]


def parent_child_rag_search(query: str, top_k: int = 2) -> list[dict]:
    """
    [1차 DB 호출] 벡터 DB(Qdrant): 자식 청크로 위치 저격
    [2차 DB 호출] 메인 DB(SQLAlchemy): parent_id로 부모 원문 조회
    """
    # 첫 번째 DB 호출
    matched_chunks = vector_db_search(query, top_k=top_k)

    # parent_id 중복 제거
    unique_parent_ids = list(dict.fromkeys(c["parent_id"] for c in matched_chunks))

    # 두 번째 DB 호출
    results = []
    for parent_id in unique_parent_ids:
        parent_doc = db_get_parent(parent_id)
        if parent_doc:
            results.append(parent_doc)

    return results

"결국 원문 전체를 저장하는 꼴 아닌가?"

부모-자식 전략을 처음 접하면 이런 의문이 생긴다. 어차피 부모에 원문 다 넣으면 문서 전체를 저장하는 거 아닌가? 맞다. 데이터의 절대적인 총량만 보면 결국 텍스트 전체를 어딘가에 보관하는 건 맞다.

하지만 이 전략의 진짜 가치는 저장하느냐 마느냐가 아니라, 검색하는 뇌(벡터 DB)와 답변하는 뇌(LLM)가 바라보는 크기를 영리하게 분리하는 데 있다.

한 가지 오해를 먼저 짚어두자. 부모가 "책 한 권 전체"일 필요는 없다. 100페이지짜리 문서를 통째로 부모로 지정하면 LLM이 읽다가 토큰이 폭발하거나 엉뚱한 답을 낸다. 현업에서는 보통 이렇게 계층을 설계한다.

  • 원본 문서: 100페이지 전체 PDF (약 50,000자)
  • 부모 청크: 하나의 개념을 다루는 '절' 단위 (약1,000~2,000자)
  • 자식 청크: 몇 문장짜리 초소형 조각 (약 200자)

부모도 결국 "LLM이 한눈에 읽고 완전히 이해할 수 있는 맥락의 최소 단위"로 잘려진 원문 조각이다.

이 구조가 만들어내는 실질적인 효과는 비용이다. 벡터 DB에는 가볍고 뾰족한 자식 벡터만 쌓아 유지비와 검색 속도를 극단적으로 아끼고, 메인 DB에는 압축률이 좋은 텍스트 형태의 부모 원문을 보관한다. 사용자가 질문하면 자식 벡터로 빠르고 저렴하게 "정확한 위치"만 찾아낸 뒤, LLM에 프롬프트를 넘길 때만 부모 원문을 꺼내 포장한다.

결과적으로 문서 전체를 매번 LLM에 읽게 만드는 구조에 비해, 서버 비용은 크게 줄이면서도 LLM이 엉뚱한 대답을 하지 못하도록 완벽한 전후 맥락을 쥐여주는 구조가 완성된다. 검색의 해상도를 높이기 위해 껍데기 레이어(자식 벡터)를 한 겹 더 만들고 메인 데이터(부모 원문)와 링크를 걸어둔 것이다.


[참고자료]
RAG 마스터 레시피 - 브라이스 유 , 조경아 , 박수진 , 김재웅
https://product.kyobobook.co.kr/detail/S000216240484

테디노트의 랭체인을 활용한 RAG 비법노트
https://product.kyobobook.co.kr/detail/S000216574552