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). 그래서 검색으로 좁혀 줍니다.
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와 멀다
⚠️ 가장 흔한 침묵의 RAG 버그: 인덱싱과 쿼리에 같은 임베딩 모델을 안 쓰는 것. 제공자마다 좌표계가 달라 섞으면 비교 불가.
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 — 교차언어인데도 높음
king ↔ queen — 관련 깊지만 같진 않음 (중간)
cat ↔ 고양이 — 거의 같은 뜻 (높음)
I love this ↔ I 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을 감정 분석기로 오해하면 안 돼요 — 그건 "의미적 유사"이지 "같은 입장"이 아닙니다.
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 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입니다.
📐 권고 시작점: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 때문에 약간 늘어나니 정답이 아니에요.
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)은 약간의 근사를 받아들이는 대가로 속도를 크게 얻어요. 수백만 개를
매번 전부 비교하면 너무 느리니, 그래프를 점프하며 가까운 이웃만 탐색합니다. "정확히 최근접"을
보장하진 않지만 검색 품질엔 보통 충분합니다.
검색(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)
다양성 부족 — verbose 문서 하나가 top-K 독점 → 그룹별 예산·MMR 다양화
관련은 있으나 무의미 — 텍스트만 비슷한 청크 → reranker·하이브리드(벡터+키워드)
비특정 검색 — "그들 매출은?"이 광범위 → 대화 히스토리로 self-contained 쿼리 재작성
과도한 컨텍스트 — K=50이 100K+ 토큰 → 청크 예산으로 가차없이 트림
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"]
모든 사실 주장에 [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(여러 턴 검색)으로 올립니다.
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
Index — 청크/임베드/저장
Retrieve — top-K
Augment — [n] 블록 + 인용 규칙
Generate — Claude 단일 호출 유지
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 포함)