100개 테이블에서 정확한 5개를 찾는 법 — RAG + Reranking [3]
테이블 선별이 가장 어렵다#
2편에서 7+1개 에이전트 구조를 다뤘는데, 막상 구현하면서 제일 오래 붙잡고 있었던 건 SQL 생성보다도 테이블 선별 쪽이었다.
실제로 "최근 평가의 세부 항목을 보여줘" 같은 질문을 넣었을 때, 쿼리가 아예 에러도 안 나고 0건이 반환된 적이 있었다. 처음엔 SQL 생성이 잘못된 줄 알았는데, 뜯어보니 더 앞단에서 필요한 테이블 자체를 못 고르고 있었다.
이걸 겪고 나서 생각이 바뀌었다. SQL을 잘 만드는 것보다, 어떤 테이블을 써야 하는지 정확하게 찾는 게 더 어렵다. 잘못된 테이블을 고르면 그 뒤를 아무리 잘 만들어도 결국 틀린 SQL이 나온다. 1편에서 본 14개 기업도 여기에 가장 많은 엔지니어링을 쓰고 있었다.
수백 개 테이블 중에서 질문에 필요한 5~7개를 정확하게 골라내는 것. 이 글은 그 문제를 어떻게 풀었는지에 대한 기록이다.
왜 벡터 검색만으로는 부족한가#
처음에는 벡터 유사도 검색만 썼습니다. 테이블 메타데이터를 임베딩해서 Qdrant에 넣고, 질문을 임베딩해서 가장 가까운 테이블을 가져오는 방식입니다.
의미적으로 비슷한 테이블을 찾는 데는 잘 동작합니다. "부서별 인원 현황"이라고 물으면 부서 관련 테이블이 올라옵니다.
하지만 정확한 키워드 매칭에서 실패합니다.
질문: "TB_LEAVE_MGMT 테이블의 컬럼 보여줘"
Dense 검색 결과:
1. TB_EMPLOYEE (연차 관리와 관련?)
2. TB_ATTENDANCE (출퇴근 관리?)
3. TB_LEAVE_MGMT ← 3위 (정확한 이름인데 밀림)
벡터 검색은 의미적 유사성을 봅니다. 테이블 이름이라는 정확한 토큰 매칭은 잘 못합니다. 한국어 도메인 용어도 마찬가지입니다. "연차관리"라는 단어가 질문에 있어도 벡터 공간에서 "연차관리" 테이블이 최상위가 아닐 수 있습니다.
Multi-stage Retrieval 파이프라인#
이 문제를 해결하기 위해 6단계 파이프라인을 구성했습니다.
사용자 질문
↓
[1. Vector Search] Qdrant — 후보 30개 추출 (Bi-Encoder)
↓
[2. Rerank] Cohere rerank-v3.5 — 상위 15개 재순위화 (Cross-Encoder)
↓
[3. Keyword Boost] korean_name 역방향 매칭 — 누락 테이블 복구
↓
[4. Dictionary Hints] 비즈니스 용어 매핑 — 강제 주입
↓
[5. Graph Expansion] FK 1-hop 이웃 — 최대 5개 추가
↓
[6. LLM Selection] Claude — 최종 5개 선택
막상 구조를 그려보면 길어 보이는데, 하나씩 보면 다 이유가 있습니다.
1단계: Vector Search — 넓게 긁어오기#
OpenAI text-embedding-3-small(1536차원)로 질문과 테이블 메타데이터를 임베딩하고, Qdrant에서 코사인 유사도 기준 상위 30개를 가져옵니다.
임베딩 사양:
모델: text-embedding-3-small
차원: 1536
최대 토큰: 8000 (모델 한도 8191, 여유분 확보)
토크나이저: tiktoken cl100k_base
배치 크기: 50
이 단계는 재현율(Recall) 극대화가 목적입니다. 관련 있을 수 있는 테이블을 최대한 많이 가져옵니다. 정밀도는 낮아도 됩니다 — 어차피 다음 단계에서 걸러냅니다.
테넌트별 컬렉션 분리#
Qdrant에 테넌트별로 3개 컬렉션을 유지합니다:
tables_{tenant_id} — 테이블 메타데이터
columns_{tenant_id} — 컬럼 정보
sample_queries_{tenant_id} — 검증된 (질문, SQL) 쌍
멀티테넌트 환경에서 다른 테넌트의 스키마가 검색 결과에 섞이면 안 되니까, 컬렉션 레벨에서 격리합니다.
테이블 임베딩 — 무엇을 벡터화하나#
테이블 이름만 임베딩하면 안 됩니다. 의미 있는 검색을 위해 summary_text를 구성해서 임베딩합니다:
TB_LEAVE_MGMT | 연차관리 | 직원의 연차/휴가 사용 현황을 관리하는 테이블
컬럼: ANNUAL_LEAVE_CNT, USED_LEAVE_CNT, EMPLOYEE_ID, ...
FK: EMPLOYEE_ID → TB_EMPLOYEE.ID
테이블명, 한국어 이름, 설명, 주요 컬럼, FK 관계를 하나의 텍스트로 합쳐서 임베딩합니다. 이렇게 해야 "연차 몇 개 남았어?"라는 질문이 TB_LEAVE_MGMT에 매칭됩니다.
2단계: Rerank — 정밀하게 걸러내기#
30개 후보를 Cohere rerank-v3.5로 재순위화합니다.
벡터 검색(Bi-Encoder)과 Reranker(Cross-Encoder)의 차이:
Bi-Encoder (벡터 검색):
질문 → [임베딩] → 벡터A
문서 → [임베딩] → 벡터B
→ 각각 독립적으로 벡터화 후 거리 비교
→ 빠르지만, 질문과 문서의 상호작용을 못 봄
Cross-Encoder (Reranker):
[질문 + 문서] → 함께 모델에 입력 → 관련도 점수
→ 질문-문서 사이의 미묘한 관계를 직접 파악
→ 정확하지만, 후보가 많으면 느림
그래서 2단계 구조가 효과적입니다. 1단계에서 빠르게 30개를 추리고, 2단계에서 30개만 정밀하게 평가해서 15개로 줄입니다. 전체 수백 개에 Cross-Encoder를 돌리면 시간이 너무 오래 걸립니다.
CHESS 논문(ICML 2025)에서도 동일한 2단계(IR + Rerank) 구조를 사용하고 있습니다.
3단계: Korean Name Boost — 키워드 누락 복구#
벡터 + Rerank로도 놓치는 케이스가 있습니다. 사용자가 한국어 테이블명을 직접 언급하는 경우입니다.
질문: "연차관리 테이블에서 남은 연차 보여줘"
벡터 검색: 의미적으로 "연차"를 잡지만 "연차관리"라는 정확한 이름은 놓칠 수 있음
Korean Name 역방향 매칭으로 이걸 보완합니다. 테이블의 korean_name에서 접두사/접미사(2자 이상)를 추출해서 질문에 포함되어 있는지 체크합니다. 매칭되면 해당 테이블을 부스트합니다.
오탐 방지를 위해 STOPWORDS를 둡니다: "정보", "관리", "이력", "상세", "기본", "마스터", "설정" — 이런 단어는 너무 많은 테이블에 들어있어서 매칭 기준에서 제외합니다.
4단계: Dictionary Hints — 비즈니스 용어 매핑#
RAG만으로 잡히지 않는 도메인 지식이 있습니다. "활성 사용자"가 어떤 테이블의 어떤 조건을 의미하는지는 용어 사전에서 가져와야 합니다.
용어 사전은 3계층 구조입니다:
User 레벨 > Tenant 레벨 > Base 레벨
(사용자 정의) (조직 공통) (시스템 기본)
질문에 용어 사전에 등록된 단어가 포함되면, 해당 용어가 매핑하는 테이블을 강제로 후보에 주입합니다. 벡터 검색에서 놓쳤더라도 사전에 등록된 매핑은 반드시 포함됩니다.
3편(피드백 루프)에서 다루겠지만, 이 용어 사전은 사용자 피드백으로 자동 교정됩니다. 처음에는 비어있어도 운영하면서 채워집니다.
운영 사례: 다중 테이블 조인과 브릿지 테이블 누락#
Dictionary Hints가 왜 중요한지를 보여주는 운영 사례입니다. 사용자가 "최근 평가의 세부 항목을 보여줘"라고 질문했을 때, 쿼리 에러 없이 0건이 반환됐습니다. 수동 SQL로는 정상적으로 4건이 나옵니다.
원인: LLM이 메인 테이블의 VARCHAR 메모 컬럼을 "세부 항목"으로 판단한 것이었습니다. 실제 "세부 항목"은 별도의 마스터 테이블에 있었습니다. 이름이 비슷한 컬럼에 LLM이 잘못 매핑한 건데, 단순 버그라기보다는 고도로 정규화된 도메인 DB의 구조적 특성에서 비롯된 문제입니다.
4단 조인이 필요한 스타 스키마
tb_evaluation (평가 메인)
→ tb_eval_apply_item (평가 적용 브릿지)
→ tb_eval_kind (평가 종류)
→ tb_eval_item (세부 항목 마스터)
팩트 테이블과 디멘전 테이블이 분리된 스타 스키마입니다. 데이터 무결성에는 좋은 설계인데, LLM이 DDL만 보고 이 경로를 파악하기는 쉽지 않습니다.
브릿지 테이블의 3중 함정
문제의 핵심은 브릿지 테이블 tb_eval_apply_item입니다. N:M 관계를 풀어주는 테이블인데, 세 가지 특성이 겹칩니다:
- 사용자 질문에 등장하지 않습니다 — 자연어로 이 테이블명을 언급할 일이 없습니다.
- SQL에는 반드시 필요합니다 — 이 테이블 없이는 메인 테이블과 마스터 테이블 간 조인이 불가능합니다.
- Vector Search에 걸리지 않습니다 — 테이블명이나 컬럼 설명에 사용자가 쓸 법한 키워드가 없습니다.
결과적으로 테이블 선택 단계에서 빠지고, SQL 생성 단계에는 DDL이 전달되지 않습니다. LLM이 참조할 수 있는 정보에 이 테이블이 아예 포함되지 않는 겁니다.
해결: related_tables
Vector Search가 못 찾는 테이블을 도메인 전문가가 직접 등록하는 방식입니다. 용어 사전 항목에 related_tables 필드를 추가해서, 조인 체인에 필요한 테이블을 명시적으로 선언합니다.
평가항목:
aliases: ["세부 항목", "평가 세부", "항목별 점수"]
description: "평가에 설정된 세부 평가항목 목록"
sql_hint: >
평가항목 조회 시 반드시 아래 조인 패턴을 사용할 것.
tb_evaluation.eval_item 컬럼(VARCHAR 메모 필드)과 혼동하지 말 것.
주의: group별 중복이 발생할 수 있으므로 반드시 SELECT DISTINCT 사용.
mapping:
table: "tb_eval_item"
column: "item_name"
related_tables:
- "tb_eval_apply_item"
- "tb_eval_kind"mapping.table은 의미적 매핑의 주 테이블, related_tables는 조인 체인에 필요한 보조 테이블, sql_hint는 LLM에 전달할 조인 패턴과 주의사항입니다. 이 세 가지가 동시에 동작합니다.
기존에는 mapping이 있으면 sql_hint를 무시하는 elif 로직이 있었는데, if로 변경해서 둘 다 전달되게 했습니다. 코드 변경은 4개 파일, 변경량은 크지 않지만, 핵심은 **"Vector Search에만 의존하지 않겠다"**는 설계 전환에 있습니다.
수정 후 동작 흐름:
사용자: "최근 평가의 세부 항목 보여줘"
→ Dictionary Resolver: "평가항목" 매칭
- table = tb_eval_item
- related_tables = [tb_eval_apply_item, tb_eval_kind]
- sql_hint = 조인 패턴 + DISTINCT 주의사항
→ Table Selection: 3개 테이블 강제 포함 (Vector Search 결과와 무관)
- tb_evaluation은 Vector Search로 자연 선택
→ SQL Generation: 4개 테이블 DDL 전체 참조
→ 결과: 4건 (정확한 세부 항목)
Uber QueryGPT, LinkedIn SQL Bot 같은 사례를 봐도 결국 같은 결론으로 돌아갑니다: "Metadata quality > Model quality". 모델이 더 똑똑해지길 기다리기보다, 검증된 조인 패턴을 시스템 쪽에 먼저 등록해두는 게 엔터프라이즈 Text-to-SQL에서는 훨씬 확실했습니다.
5단계: Graph Expansion — JOIN 브릿지 테이블#
사용자가 직접 언급하지 않았지만 JOIN에 필요한 테이블이 있습니다.
질문: "부서별 직원 수"
필요한 테이블: TB_DEPARTMENT, TB_EMPLOYEE
하지만 중간에 TB_DEPT_MEMBER 같은 매핑 테이블이 필요할 수 있음
FK 관계 그래프에서 선택된 테이블의 1-hop 이웃(직접 FK로 연결된 테이블)을 최대 5개까지 추가합니다. 이렇게 하면 사용자가 언급하지 않은 브릿지 테이블도 포함됩니다.
FK 관계는 DB 스키마에서 자동 추출하고, 명시적 FK가 없는 경우 PK 이름 매칭으로 추정(inferred FK)도 합니다.
6단계: LLM Selection — 최종 판단#
여기까지 오면 후보가 15~25개 정도입니다. 이걸 Claude에게 넘겨서 최종 5개를 선택하게 합니다.
LLM은 앞 단계에서 잡지 못하는 미묘한 판단을 합니다. 예를 들어 "부서별 주문 현황"이라는 질문에서 "주문"이 TB_ORDER인지 TB_ORDER_DETAIL인지는 컬럼 설명과 비즈니스 맥락을 같이 봐야 알 수 있습니다.
Hybrid Search — Dense + Sparse#
벡터 검색의 키워드 매칭 약점을 근본적으로 해결하기 위해 Hybrid Search도 구현했습니다.
| 방식 | 강점 | 약점 |
|---|---|---|
| Dense (OpenAI embedding) | 의미적 유사성, 동의어 처리 | 정확한 키워드 매칭 약함 |
| Sparse (BM25) | 정확한 키워드/토큰 매칭 | 의미적 유사성 못 잡음 |
| Hybrid (RRF 퓨전) | 둘 다 잡음 | 약간의 추가 연산 |
Qdrant가 Named Vectors를 지원하기 때문에, 하나의 포인트에 Dense 벡터와 Sparse 벡터를 같이 저장할 수 있습니다.
PointStruct:
id: "table_uuid"
vector:
dense: [0.12, -0.34, ...] ← OpenAI embedding
sparse: SparseVector(...) ← BM25 (fastembed)
payload:
table_name, korean_name, description, ...
검색 시에는 Reciprocal Rank Fusion(RRF)으로 두 결과를 합칩니다:
Dense 검색: TB_A(1위), TB_B(2위), TB_C(3위), ...
Sparse 검색: TB_C(1위), TB_A(2위), TB_D(3위), ...
↓ RRF
최종 결과: TB_A(Dense 1위 + Sparse 2위 = 높은 점수)
TB_C(Dense 3위 + Sparse 1위 = 높은 점수)
테이블명을 정확히 언급하는 질문에서 Sparse가 잡아주고, 의미적으로 관련된 테이블은 Dense가 잡아줍니다.
메타데이터 자동 생성 — Auto Descriptor#
RAG의 품질은 결국 인덱싱되는 메타데이터의 품질에 달려있습니다. 1편에서 우아한형제들이 "성능의 핵심은 어떤 문서를 수집하느냐"라고 한 것과 같은 맥락입니다.
DB에서 스키마를 추출하면 테이블명, 컬럼명, 타입 정도만 나옵니다. 이걸로는 벡터 검색이 잘 안 됩니다. TB_LEAVE_MGMT라는 이름만으로는 이게 연차 관리 테이블인지 알 수 없으니까요.
Auto Descriptor가 LLM(Claude)에게 DDL을 보여주고 자동으로 설명을 생성합니다:
{
"table_name": "TB_LEAVE_MGMT",
"korean_name": "연차관리",
"description": "직원의 연차/휴가 사용 현황을 관리하는 테이블",
"column_descriptions": {
"ANNUAL_LEAVE_CNT": "연간 부여된 연차 일수",
"USED_LEAVE_CNT": "사용한 연차 일수"
},
"business_context": "관리팀에서 연차 잔여 현황 조회 시 사용"
}이 접근은 Anthropic의 Contextual Retrieval과 유사합니다. 문서(메타데이터)에 LLM이 맥락을 미리 심어놓아서 검색 정확도를 높이는 방식입니다.
자동 생성된 설명은 완벽하지 않기 때문에 관리자가 검토하고 보완하는 과정이 필요합니다. 하지만 처음부터 수작업으로 수백 개 테이블의 설명을 쓰는 것보다 훨씬 빠릅니다.
스키마 버전 관리#
DB 스키마는 변경됩니다. 테이블이 추가되거나, 컬럼이 바뀌거나. 이때마다 메타데이터도 업데이트해야 합니다.
스키마를 SHA256으로 해싱해서 변경 감지를 합니다. 스키마가 바뀌면 새 버전을 생성하고, 이전 버전과의 diff도 저장합니다. 인덱싱은 스키마가 변경된 경우에만 실행되어서 불필요한 재인덱싱을 방지합니다.
CREATE TABLE schema_metadata (
tenant_id VARCHAR(64) NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
schema_hash VARCHAR(64) NOT NULL,
tables_json JSONB NOT NULL,
diff_json JSONB DEFAULT NULL,
PRIMARY KEY (tenant_id, version)
);Sample Query — 가장 강력한 RAG 소스#
검증된 "질문-SQL" 쌍을 벡터화해서 sample_queries_{tenant_id} 컬렉션에 저장합니다. 사용자가 비슷한 질문을 하면 이 샘플이 few-shot 예시로 SQL Agent에 전달됩니다.
스키마와 설명만으로는 LLM이 복잡한 비즈니스 로직을 파악하기 어렵습니다. 하지만 "이전에 비슷한 질문에 이렇게 SQL을 만들었더니 맞았다"는 예시가 있으면 패턴을 그대로 참고할 수 있습니다.
이 샘플은 두 경로로 축적됩니다:
- 온보딩 시: 기존 쿼리 로그에서 자주 쓰이는 패턴을 마이닝
- 운영 중: 사용자가 👍 피드백을 주면 자동 등록 (피드백 루프에서 상세 설명)
설정 값 정리#
| 단계 | 파라미터 | 값 |
|---|---|---|
| Vector Search | 후보 수 | 30개 |
| Rerank | 모델 | Cohere rerank-v3.5 |
| Rerank | 상위 N | 15개 |
| Korean Name Boost | 최소 매칭 길이 | 2자 |
| Korean Name Boost | 최대 복구 수 | 10개 |
| Graph Expansion | 최대 추가 수 | 5개 (FK 1-hop) |
| LLM Selection | 최종 선택 수 | 5개 |
검색 파이프라인 테스트 — mock만으로 충분했나#
6단계 파이프라인에 외부 서비스가 3개 (Qdrant, Cohere, Claude) 연결되어 있습니다. 실제 서비스를 띄우고 테스트하면 느리고 불안정합니다. mock 기반으로 핵심 로직만 검증하는 전략을 택했습니다.
Hybrid Search — Dense + Sparse 경로 분기#
Hybrid Search가 제대로 동작하는지 확인하려면 sparse가 활성화됐을 때와 비활성화됐을 때의 동작이 완전히 달라야 합니다.
@pytest.fixture
def mock_embedder_hybrid():
"""sparse 활성화된 embedder mock"""
embedder = MagicMock()
embedder.dimensions = 3072
embedder.sparse_enabled = True
embedder.embed_sparse.return_value = SparseEmbeddingResult(
indices=[1, 5, 10], values=[0.5, 0.3, 0.8]
)
return embeddersparse가 활성화되면 Qdrant에 Prefetch + FusionQuery로 검색합니다. 비활성화되면 using='dense'로 단순 벡터 검색만 합니다.
def test_hybrid_search_uses_prefetch_and_fusion(self, mock_embedder_hybrid, mock_qdrant):
retriever.search_tables("직원 목록", top_k=5)
query_call = mock_qdrant.query_points.call_args
assert "prefetch" in query_call.kwargs
prefetch_list = query_call.kwargs["prefetch"]
assert len(prefetch_list) == 2 # dense + sparse
query_arg = query_call.kwargs["query"]
assert hasattr(query_arg, "fusion")
def test_dense_only_uses_named_vector(self, mock_embedder_dense_only, mock_qdrant):
retriever.search_tables("직원 목록", top_k=5)
query_call = mock_qdrant.query_points.call_args
assert query_call.kwargs.get("using") == "dense"
assert "prefetch" not in query_call.kwargs같은 search_tables() 호출인데 embedder의 sparse_enabled 플래그에 따라 Qdrant 호출 방식이 완전히 달라집니다. 이걸 테스트로 보장해둬야 sparse 관련 코드를 수정할 때 다른 쪽이 깨지는 걸 막을 수 있습니다.
컬렉션 마이그레이션 자동 감지#
Qdrant 컬렉션이 이전 버전(flat VectorParams, 1536차원)에서 새 버전(Named Vectors, 3072차원 + Sparse)으로 바뀌어야 할 때, 자동으로 감지해서 재생성합니다.
def test_needs_recreation_flat_vector_params(self):
"""flat VectorParams → 재생성 필요"""
collection_info.config.params.vectors = VectorParams(size=1536, distance="Cosine")
assert indexer._needs_recreation("test_collection") is True
def test_needs_recreation_dimension_mismatch(self):
"""Named Vectors이지만 차원 불일치 → 재생성"""
old_dense.size = 1536 # embedder는 3072
assert indexer._needs_recreation("test_collection") is True
def test_needs_recreation_missing_sparse(self):
"""sparse 필요한데 없음 → 재생성"""
collection_info.config.params.sparse_vectors = None
assert indexer._needs_recreation("test_collection") is True
def test_no_recreation_when_compatible(self):
"""모든 조건 충족 시 재생성 불필요"""
assert indexer._needs_recreation("test_collection") is False임베딩 모델을 text-embedding-3-small(1536)에서 text-embedding-3-large(3072)로 교체했을 때 이 테스트가 빛을 발했습니다. 기존 컬렉션을 자동으로 삭제하고 재생성하는 로직이 정확히 동작하는지 확인할 수 있었습니다.
FK 관계 그래프 — 추론의 정확도#
Graph Expansion 단계에서 FK 관계를 추론하는 로직은 오탐(false positive)과 누락(false negative) 사이의 균형이 핵심입니다.
범용 PK 이름 필터링: id, sn, seq, no 같은 이름은 너무 많은 테이블에 있어서 추론하면 안 됩니다. 하지만 order_appraisal_sn 같은 구체적 이름은 추론해야 합니다.
@pytest.mark.parametrize("generic_name", ["id", "sn", "seq", "no", "idx", "key"])
def test_generic_pk_names_excluded(self, generic_name):
"""id, sn 등 범용 PK 이름은 추론하지 않음"""
# a 테이블의 PK가 "id"이고, b 테이블에도 "id" 컬럼이 있다고 해서
# a → b FK를 추론하면 안 됩니다
assert graph.stats["pk_name_match"] == 0
def test_specific_name_not_excluded(self):
"""order_appraisal_sn 같은 구체적 이름은 필터링하지 않음"""
# a 테이블의 PK가 "order_appraisal_sn"이고,
# b 테이블에 "order_appraisal_sn" 컬럼이 있으면 FK 추론
assert graph.stats["pk_name_match"] == 1자기참조 방지: 테이블의 PK와 같은 이름의 컬럼이 자기 테이블에 있을 때 자기참조 FK를 만들면 안 됩니다.
def test_no_self_reference(self):
"""자기 테이블의 PK와 같은 이름의 컬럼은 자기참조하지 않음"""
table = _make_table("test", [{"name": "test_id", "type": "INT"}],
primary_keys=["test_id"])
assert graph.stats["total"] == 0Explicit FK와 Inferred FK 분리: DB에 선언된 FK와 이름 기반 추론 FK를 구분합니다. 같은 테이블 쌍에 explicit FK가 있으면 inferred FK를 중복 생성하지 않습니다.
def test_no_duplicate_when_explicit_exists(self):
"""explicit FK가 있으면 같은 쌍의 inferred FK를 생성하지 않음"""
assert graph.stats["total"] == 1
assert graph.stats["explicit_fk"] == 1
assert graph.stats["pk_name_match"] == 0실제 프로덕션 DB에서 테이블 40개 이상을 연결했을 때, 범용 이름 필터링이 없으면 추론된 FK가 수백 개로 폭발합니다. 이 테스트들은 그 폭발을 막으면서도 필요한 관계는 놓치지 않는 균형점을 검증합니다.
다음 글에서#
테이블을 정확하게 찾았다고 끝이 아닙니다. 생성된 SQL이 맞는지 검증하고, 틀리면 자동으로 수정하는 Self-Correction 루프를 4편에서 다루겠습니다.