← Start here · Module 4A · 20 slides

Module 4A · Context Management — Indexing / RAG

거대한 문서 더미를 모델에게 통째로 주는 대신, 질문에 관련 있는 조각만 찾아 붙여 답하게 합니다. 두 축: ① Indexing(미리 쪼개 벡터로 저장) ② RAG(검색→붙이기→생성).

📊 원본 슬라이드: ksetp.netlify.app/context · 칩(슬라이드 N)이 덱의 번호와 같습니다.

4A.1 · First, the obvious question: why not a giant context window? Slide 4

"그냥 문서 전부를 프롬프트에 넣으면 안 되나?" — 4가지 이유로 안 됩니다.

Live demo: Solutions Manager — indexed Drive + cited chat (Slide 2)

Why retrieval (4 reasons)
  1. Scale — 실제 코퍼스는 수천만~수억 토큰. 다 못 넣음
  2. Cost — 큰 버퍼를 매번 재전송하면 타겟 검색보다 비쌈
  3. Latency — 입력이 클수록 느려짐
  4. Accuracy — 아주 긴 컨텍스트에서 성능 저하("lost in the middle")
📚 비유: 시험 볼 때 도서관 전체를 책상에 쌓는 대신, 필요한 몇 페이지만 복사해 옆에 두는 것. 빠르고 싸고, 헷갈리지도 않아요.

Two paths — stuff everything vs retrieve then add

flowchart TB Q(["one user question"]) Q --> A["❌ Stuff everything in
hundreds of millions of tokens"] Q --> B["✅ Retrieve, then add (RAG)
only the top-K relevant chunks"] A --> A2["slow · expensive
lost in the middle"] B --> B2["fast · cheap
focused evidence + citations"] A2 -.-> Bad(["answer quality down"]) B2 -.-> Good(["answer quality up"])
🧭 "lost in the middle": 컨텍스트가 아주 길면 모델은 맨 앞·맨 뒤는 잘 보지만 가운데에 묻힌 사실을 놓치는 경향이 있어요. 그래서 "다 넣으면 안전"이 아니라, 관련 조각만 골라 짧게 주는 편이 오히려 정확합니다.

문서 전체를 그냥 프롬프트에 다 넣는 게 왜 나쁜가요? (가장 정확한 설명)

네 가지가 동시에 걸려요: 실제 코퍼스는 다 안 들어가고(Scale), 큰 버퍼를 매번 재전송하면 비싸고(Cost), 입력이 클수록 느리고(Latency), 너무 길면 가운데를 놓칩니다(Accuracy, lost in the middle). 그래서 검색으로 좁혀 줍니다.

4A.2 · What is an embedding model? Slide 6–7

텍스트를 고정 길이 숫자 벡터로 바꿉니다. 뜻이 비슷하면 공간적으로 가까워져요.

embedder.embed([
  "the cat sat on the mat",
  "a feline rested on a rug",      # 위 문장과 매우 가까움
  "Python is a programming language",  # 멀리 떨어짐
])
# → [[0.012, -0.034, ...],   # 768 floats
#    [0.014, -0.030, ...],   # row 1과 근접
#    [0.412,  0.107, ...]]   # row 1·2와 멀다

Distance in meaning space — closer meaning, closer vectors

graph LR cat(["🐱 the cat sat on the mat"]) feline(["🐱 a feline rested on a rug"]) py(["🐍 Python is a language"]) cat <-->|"cosine ≈ 0.92
close"| feline cat <-->|"cosine ≈ 0.15
far"| py
📏 cosine similarity: 두 벡터가 이루는 각도로 닮음을 잽니다. 같은 방향이면 1, 직각이면 0, 반대면 −1. 길이가 아니라 방향을 보기 때문에 문장 길이가 달라도 "뜻"만 비교할 수 있어요. 검색은 결국 "쿼리 벡터와 cosine이 가장 큰 K개 청크 찾기"입니다.
🧪 Try it yourself — Projects/Module_04_embedding-similarity/similarity.py

두 문장을 입력하면 cosine similarity를 찍어주는 작은 콘솔 앱이에요. 로컬 multilingual 임베딩 모델을 써서 API 키가 필요 없고, 서로 다른 언어끼리도 뜻이 같으면 가깝게 나옵니다.

cd embedding-similarity
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt        # 첫 실행 때 모델 ~470MB 1회 다운로드

python similarity.py                    # 대화형: 두 문장 입력
python similarity.py "cat" "고양이"      # 한 방 모드

출력엔 벡터 앞 5개 숫자 미리보기 · 두 벡터의 각도 · cosine 점수가 같이 나와요.

이런 쌍을 넣어 직접 비교해 보세요 — 점수를 먼저 예측하고 실행:

  • 강아지가 공원에서 뛴다a puppy sprints across the grass — 교차언어인데도 높음
  • kingqueen — 관련 깊지만 같진 않음 (중간)
  • cat고양이 — 거의 같은 뜻 (높음)
  • I love thisI hate this — 감정은 정반대인데 −1이 아니라 오히려 높게 (아래 퀴즈)
⚙️ 왜 cosine이 그냥 내적(dot product)으로 계산되나: 이 앱은 벡터를 normalize_embeddings=True길이 1로 정규화해 저장해요. 단위 벡터끼리는 cos(각도) = 내적이라, cosine = va · vb = Σ vaᵢ·vbᵢ 한 줄로 끝납니다.

"I love this movie"와 "I hate this movie"의 cosine similarity는 대략?

임베딩은 주제·구조까지 통째로 담아요. 두 문장은 "이 영화에 대한 내 감정"이라는 큰 방향을 공유해서 cosine이 높게 나오고, love↔hate 차이는 그 안의 작은 성분으로 들어갑니다. 그래서 cosine을 감정 분석기로 오해하면 안 돼요 — 그건 "의미적 유사"이지 "같은 입장"이 아닙니다.

The dimension-count trade-off

flowchart LR d1["384 dims
light & fast"] --> d2["768 / 1536"] --> d3["3072 dims
more precise
higher storage & search cost"]

놀거리: TensorFlow Embedding Projector로 임베딩을 3D로 탐색.

인덱싱할 때와 검색할 때 서로 다른 임베딩 모델을 쓰면 어떻게 될까요?

임베딩 좌표는 모델마다 다른 공간이에요. A 모델로 넣고 B 모델로 찾으면 cosine 거리가 무의미해지는데, 에러 없이 그럴듯한 엉뚱한 결과를 내서 잡기 어렵습니다. 그래서 인덱싱·쿼리에 반드시 같은 임베딩 모델을 고정하세요.

4A.3 · Indexing: documents → chunks → vectors → store Slide 8

질문이 오기 전에 문서를 쪼개고 의미 벡터로 바꿔 저장해 둡니다.

flowchart LR S["Source
PDF · docs · sheets"] --> E["Extract
(text per MIME type)"] E --> C["Chunk
(N chars + overlap)"] C --> V["Embed
(chunk to vector)"] V --> St["Store
(vec + meta + ANN)"]

각 청크는 벡터만 저장하지 않아요. 메타데이터(원본 문서 id, 파일명, 위치/페이지, 그리고 뒤의 4.8 Memory에서 쓰는 user_id 같은 격리 키)를 함께 저장해야 나중에 인용을 달고 필터링을 할 수 있습니다.

flowchart TB chunk["one chunk"] --> rec["stored record"] rec --> v["vector: [0.01, -0.03, ...]"] rec --> m["metadata:
doc_id · source · position · user_id"]

Why it must be idempotent — the re-indexing trap

flowchart LR D["doc#42 content changes"] --> Q{"how to handle old chunks?"} Q -->|"❌ just INSERT"| Dup["old + new chunks coexist
→ duplicates · ghost results"] Q -->|"✅ delete-then-insert"| Clean["DELETE all doc#42 chunks
→ INSERT new chunks"] Clean --> Fresh["always clean & up to date"]
♻️ Idempotency rule (슬라이드 8): 한 문서를 다시 인덱싱했을 때 옛 청크가 중복·잔존하면 안 됩니다. delete-then-insert(먼저 지우고 다시 넣기)가 가장 단순·정확. "같은 입력을 두 번 넣어도 결과가 같다" — 그게 idempotent입니다.

4A.4 · Chunking is a design decision Slide 9

문서를 어떻게 쪼개느냐가 검색 품질을 좌우합니다. 검색을 튜닝하기 전에 청킹부터.

Strategies (Slide 9)
  • Fixed-size(N자/토큰) — 빠른 시작, 균일 문서. 단점: 문장 중간 절단
  • Sentence/paragraph 경계 — 산문·기사. 단점: 크기 가변
  • Semantic(모델이 결정) — 긴 기술 문서. 단점: $$ 추가 패스
  • Document-as-chunk — 짧은 문서. 단점: 검색 페이로드↑
📐 권고 시작점: 1500자 / 200자 overlap. 나쁜 청크면 아무리 영리한 랭킹도 못 구합니다. overlap은 경계에서 잘린 문맥을 이웃 청크가 일부 겹쳐 보완하는 장치예요.
def chunk_text(text, target=1500, overlap=200):
    # 길면 target 창으로 자르되, 뒤쪽 절반에서 문단 경계(\n\n, \n, ". ")를
    # 만나면 거기서 끊어 문장 중간 절단을 피한다. 이웃 청크는 overlap만큼 겹침.
    ...

Which strategy? Decide by document type

flowchart TD Start{"What kind of document?"} Start -->|"short (fits in one chunk)"| Doc["Document-as-chunk
e.g. one FAQ entry, short note"] Start -->|"uniform · fast to start"| Fixed["Fixed-size
e.g. 1500 chars + 200 overlap"] Start -->|"prose · articles"| Sent["Sentence/paragraph boundaries
e.g. blog, news"] Start -->|"long · structured · precise"| Sem["Semantic (model decides)
e.g. long technical manual"]

What overlap does — stitch context cut at the boundary

flowchart LR subgraph doc["original text (char positions)"] direction LR c1["chunk 1
0 – 1500"] c2["chunk 2
1300 – 2800"] c3["chunk 3
2600 – 4100"] end c1 -.->|overlap 1300–1500| c2 c2 -.->|overlap 2600–2800| c3
🔗 겹치는 200자 덕분에, 경계에 걸친 문장("…매출은 전년 대비 | 20% 늘었다")이 어느 한 청크에는 온전히 들어갑니다. overlap이 0이면 그 문장은 양쪽에서 반쪽씩 잘려 검색에 안 걸려요.

청크 사이에 overlap(겹침)을 두는 가장 큰 이유는?

overlap은 경계 손실 방어예요. 핵심 문장이 딱 청크 경계에 걸리면 양쪽 모두에서 반쪽이 되어 검색에 안 잡히는데, 조금 겹쳐 두면 최소 한 청크엔 통째로 들어갑니다. 용량·속도는 오히려 overlap 때문에 약간 늘어나니 정답이 아니에요.

4A.5 · Vector stores & batch economics Slide 10–13

벡터를 저장하고 "가장 가까운 K개"를 빠르게 찾는 곳. 규모에 맞춰 고릅니다.

Options
  • numpy/in-memory — 프로토타입(<100K). 재시작 시 소실
  • Postgres + pgvector — 기존 Postgres에 메타 SQL join. ~1M 편함
  • Managed(Pinecone·Weaviate·Qdrant) — 스케일·하이브리드. 비용·락인
  • SQLite + sqlite-vec — 단일 사용자·로컬·임베디드

Which store? Decide by scale & environment (with examples)

flowchart TD Q{"How many vectors? Where does it run?"} Q -->|"under 100K · prototype"| NP["numpy / in-memory
e.g. rag-starter default
⚠️ lost on restart"] Q -->|"already on Postgres · ~1M"| PG["Postgres + pgvector
e.g. join metadata in SQL
👍 simple ops"] Q -->|"large scale · hybrid"| MG["Managed
e.g. Pinecone · Weaviate · Qdrant
⚠️ cost · vendor lock-in"] Q -->|"single user · local app"| SQ["SQLite + sqlite-vec
e.g. desktop embedded app
👍 ships as one file"]

Search speed — prototype O(n) vs production ANN

flowchart LR subgraph naive["Prototype: linear scan — O(n)"] direction TB q1(["query vector"]) --> s1["compare with EVERY record
one by one (cosine)"] s1 --> r1(["top-K (exact)"]) end subgraph fast["Production: ANN (HNSW) — O(log n)"] direction TB q2(["query vector"]) --> s2["hop across a graph,
visit only nearby neighbors"] s2 --> r2(["top-K (approximate)"]) end

프로토타입은 모든 레코드를 훑는 O(n) 검색으로 충분하지만, 프로덕션은 HNSW 같은 ANN(근사 최근접)으로 O(log n)에 찾습니다. ANN은 "정확히 가장 가까운" 대신 "거의 가장 가까운"을 주는 대신 훨씬 빨라요 — 검색에선 이 근사로 충분한 경우가 대부분입니다.

Slide 11 — ANN & HNSW, unpacking the acronyms

ANN = Approximate Nearest Neighbor

목표는 "쿼리와 가장 가까운 벡터 K개" 찾기예요. 이걸 정확히(exact) 하려면 모든 벡터와 일일이 비교해야 하는데(O(n)), 수백만 개면 너무 느립니다. ANN은 "정확히 1등"을 고집하지 않고 거의 가장 가까운 것을 받아들이는 대신, 비교 횟수를 확 줄여 훨씬 빠르게 찾아요. 검색·추천에선 이 작은 근사가 거의 티가 안 납니다.

HNSW = Hierarchical Navigable Small World

가장 널리 쓰는 ANN 인덱스예요. 이름 세 조각이 그대로 작동 원리입니다:

  • Small World — "6단계 법칙"처럼, 점(벡터)들을 잘 이어 두면 어느 점에서 출발해도 몇 번만 건너뛰면 목표 근처에 닿는 그래프.
  • Navigable — 매 단계에서 "목표에 더 가까워지는 이웃"으로만 이동하며 길을 찾음(greedy 탐색).
  • Hierarchical — 그래프를 여러 층으로 쌓음. 위층은 점이 적고 점프가 커서 빠르게 근처로, 아래층은 점이 많고 점프가 작아 정밀하게 최근접을 찾음.
🚇 비유: 지하철과 같아요. 먼저 급행(위층, 큰 점프)을 타고 목적지 근처 역까지 빠르게 간 뒤, 완행·도보(아래층, 작은 걸음)로 정확한 출구를 찾습니다. 매번 모든 역에 내리지 않으니 빠르죠 — 이게 O(n)이 아니라 O(log n)인 이유예요.
flowchart TB Q(["query vector"]) subgraph top["Layer 2 — sparse, big jumps"] T["enter at the top
few nodes, long hops"] end subgraph mid["Layer 1 — medium density"] M["move closer
medium hops"] end subgraph bot["Layer 0 — all nodes, dense"] Bm["fine-tune to the nearest
neighbors (short hops)"] end Res(["approximate top-K"]) Q --> T --> M --> Bm --> Res
⚖️ 트레이드오프: ANN/HNSW는 정확도를 아주 조금 양보하고 속도(O(log n))를 얻어요. 파라미터로 "더 정확 vs 더 빠름"을 조절합니다 — 예를 들어 탐색 폭 efSearch를 키우면 더 많은 이웃을 살펴 정확도↑·속도↓. 프로토타입(수만 개)은 그냥 O(n) 선형 스캔으로 충분하고, 수백만 개부터 HNSW가 빛납니다.

HNSW가 굳이 "계층(hierarchical)" 구조를 쓰는 이유는?

위층은 점이 적어 한 번에 멀리 점프해 목표 근처로 빠르게 이동하고, 아래층은 촘촘해서 정밀하게 최근접을 찾아요. 덕분에 모든 점을 보지 않고도 O(log n)에 좋은 답을 냅니다. 100% 정확 보장은 ANN의 목적이 아니에요 — 그건 exact 검색이고, 속도를 위해 일부러 근사합니다.

Batch economics — halve on cap (adaptive fallback)

flowchart TD B["embed a batch of N chunks"] --> C{"per-request cap exceeded?"} C -->|"no"| Done(["embeddings OK"]) C -->|"yes (too large)"| Half["halve the batch size"] Half --> B

수백만 벡터를 다루는 프로덕션에서 선형 스캔(O(n)) 대신 ANN을 쓰는 이유는?

ANN(예: HNSW)은 약간의 근사를 받아들이는 대가로 속도를 크게 얻어요. 수백만 개를 매번 전부 비교하면 너무 느리니, 그래프를 점프하며 가까운 이웃만 탐색합니다. "정확히 최근접"을 보장하진 않지만 검색 품질엔 보통 충분합니다.

4A.6 · RAG in 30 lines: retrieve · augment · generate Slide 15–17

검색(Retrieve) → 번호 붙여 붙이기(Augment) → Claude 한 번 호출(Generate). 끝.

# 1. RETRIEVE — 질문을 임베딩해 가장 가까운 K개 청크
query_vec = embedder.embed([question])[0]
hits = vector_store.search(query_vec, k=10)

# 2. AUGMENT — 청크를 번호 매긴 컨텍스트 블록으로
context = "\n\n".join(f"[{i+1}] {h['text']}" for i, h in enumerate(hits))
system_prompt = '''You answer using only the provided context.
Cite each factual claim with a bracket number, e.g. "Sales grew 20% [3]".
If the context doesn't contain the answer, say so.'''
user_prompt = f"CONTEXT:\n{context}\n\nQUESTION:\n{question}"

# 3. GENERATE — Claude 한 번
answer = client.messages.create(
    model="claude-sonnet-4-6", max_tokens=2048,
    system=system_prompt,
    messages=[{"role": "user", "content": user_prompt}],
).content[0].text

The path one question flows through

sequenceDiagram participant U as User participant E as Embedder participant V as Vector store participant C as Claude U->>E: embed the question E->>V: search top-K by query vector V-->>U: K relevant chunks (+meta) Note over U: AUGMENT — numbered [1][2][3] blocks + citation rule U->>C: CONTEXT + QUESTION C-->>U: answer with [3] citations Note over U: after: validate citation numbers are in range

The retrieval failures (Slide 16)

flowchart LR f1["low diversity
one doc dominates top-K"] --> x1["per-group budget · MMR"] f2["relevant but useless
only surface-similar chunks"] --> x2["reranker · hybrid (vector+keyword)"] f3["non-specific query
'what are their sales?'"] --> x3["rewrite into a self-contained
query using chat history"] f4["too much context
K too large"] --> x4["trim with a chunk budget"]

4A.7 · Citations — why and how Slide 18

모든 사실 주장에 [n] 번호로 출처를 강제 → 사용자 검증·디버깅·hallucination 방어.

# 모델이 쓴 인용 번호 파싱; 범위 밖 번호는 제거
import re
used  = [int(n) for n in re.findall(r"\[(\d+)\]", answer)]
valid = [n for n in used if 1 <= n <= len(retrieved_chunks)]

Citation validation — don't trust the answer, check the numbers

flowchart LR A["Claude answer"] --> P["parse [n] numbers with regex"] P --> R{"1 ≤ n ≤ #chunks?"} R -->|"yes"| Keep["keep as valid citation"] R -->|"no"| Drop["out of range → drop
(invented number = hallucination)"]

흔한 실패: 없는 번호 발명, 순서 뒤섞임, 필요한 곳 인용 누락, 인용–주장 불일치. 그래서 받은 뒤 검증합니다(범위 밖 번호 제거).

⚖️ Single-turn vs Agentic RAG (슬라이드 18): 기본은 single-turn (1 검색 + 1 LLM, 예측 가능). 개방형 리서치·multi-hop처럼 single-turn이 증명적으로 불가능할 때만 agentic(여러 턴 검색)으로 올립니다.
flowchart TD Q{"What type of question?"} Q -->|"single fact lookup (most)"| ST["✅ Single-turn
1 search + 1 LLM
predictable · cheap · fast"] Q -->|"multi-hop · open-ended research
(provably impossible single-turn)"| AG["Agentic
multi-turn search + reasoning
powerful but slow & costly"]

RAG에서 인용 [3]을 강제하는 가장 큰 이유는?

인용은 귀속 강제 = hallucination 방어예요. 컨텍스트에 없으면 "모른다"고 하게 만들고, 받은 답의 번호가 실제 청크 범위 안인지 코드로 한 번 더 검증합니다.

4A.8 · Memory: the model starts blank every time supplement · not in the deck(20)

LLM은 stateless — 지난 대화를 스스로 기억하지 못합니다. "기억"은 우리가 저장하고 다시 찾아 붙여주는 것. 그리고 그 방법은 이미 배운 RAG와 똑같아요.

🧠 비유: 모델은 매 요청마다 기억상실증에 걸린 천재예요. 똑똑하지만 어제 일을 못 떠올립니다. 그래서 우리가 "이전에 이런 얘기 했었죠" 하고 메모를 쥐여주는 거예요. chat-app(2B편)에서 대화 히스토리를 매 턴 다시 보낸 것 — 그게 가장 단순한 메모리였습니다.
Two layers
  • 단기(short-term) — 이번 대화의 최근 턴들을 그대로 다시 보냄. 단순하지만 길어지면 토큰이 폭증 → 오래된 턴을 요약(summarization)해 압축.
  • 장기(long-term) — 대화에서 기억할 사실만 뽑아 vector store에 저장 → 나중 대화에서 관련 기억을 검색해 붙임. 즉 RAG를 "대화 기록"에 적용한 것. 같은 chunk→embed→store→retrieve 파이프라인을 그대로 재사용합니다.

Both layers at a glance

flowchart TB subgraph ST["Short-term memory (this conversation)"] direction TB s1["resend the last N turns as-is"] --> s2["when it grows, summarize
old turns to compress"] end subgraph LT["Long-term memory (beyond one chat)"] direction TB l1["extract facts → embed → store"] --> l2["retrieve relevant memories
for a new message"] l2 --> l3["= RAG applied to chat history"] end

The long-term memory cycle — retrieve, answer, store again

flowchart LR U["new message"] --> R["search relevant memories
(vector store)"] R --> A["augment the prompt
summary + retrieved facts"] A --> G["call Claude"] G --> X["extract facts to remember"] X --> St["store
(isolated by user_id)"] St -.next turn.-> R
⚠️ 설계 함정:전부 저장하면 노이즈 — 무엇을 기억할지 골라야 검색 품질이 삽니다. ② 관련성 ≠ 최신성 — "지난주엔 채식"이 "오늘은 고기"를 덮어쓰면 곤란 (타임스탬프·갱신 규칙 필요). ③ 사용자 격리 — 메타데이터 user_id 필터로 남의 기억이 새어들지 않게.

장기 대화 메모리를 구현하는 가장 자연스러운 방법은?

장기 메모리는 새 개념이 아니라 RAG의 재사용이에요. 사실을 chunk·embed해 저장하고, 새 발화가 오면 관련 기억만 top-K로 찾아 붙입니다. context를 무한정 키우거나 전체 로그를 매번 재전송하는 건 이 모듈 첫 섹션의 4가지 이유(Scale·Cost·Latency·Accuracy)에 정면으로 부딪혀요.

4A.9 · Project: index a corpus, then add RAG to your chat app Slide 19–20

작은 코퍼스를 인덱싱하고, 2편 chat-app에 RAG + 인용을 얹습니다.

In order
  1. Index — 청크/임베드/저장
  2. Retrieve — top-K
  3. Augment[n] 블록 + 인용 규칙
  4. Generate — Claude 단일 호출 유지
  5. Report — 질문 5개로 답 품질·인용 정확도 점검(강점 2/약점 2)
flowchart LR I["① Index
chunk · embed · store"] --> R["② Retrieve
top-K"] R --> A["③ Augment
[n] blocks + citation rule"] A --> G["④ Generate
single Claude call"] G --> Rep["⑤ Report
check with 5 questions"] Rep -.improve prompt & chunking.-> I

스타터: rag-starter.zip다운로드 (documents/, indexer.py, RAG 스텁 backend, 인용 표시 UI 포함)