fromanthropicimportAnthropicfromopenaiimportOpenAIimportchromadbanthropic=Anthropic()openai=OpenAI()# ---------- INDEXING ----------# 1) 문서 로드 (여기선 문자열로 직접)docs=[{"id":"refund_policy","text":"""[환불 정책]구매 후 7일 이내, 팀장 승인 필요.5일 이상 연속 사용 제품은 임원 승인 추가.긴급 사유는 이메일 사전 통보 후 사후 신청 가능.""","source":"policy.md#refund",},{"id":"shipping_policy","text":"""[배송 정책]주문일 기준 영업일 2~3일 소요.도서산간은 +2일 추가.5만원 이상 구매 시 무료 배송.""","source":"policy.md#shipping",},]# 2) Chunking — 여기선 짧아서 그대로 통째 chunkchunks=docs# 실전은 §5.2 분할defembed(texts):res=openai.embeddings.create(model="text-embedding-3-small",input=texts)return[d.embeddingfordinres.data]# 3) 임베딩 + 4) 저장chroma=chromadb.PersistentClient(path="./mini_rag_db")col=chroma.get_or_create_collection(name="policies")col.upsert(ids=[c["id"]forcinchunks],documents=[c["text"]forcinchunks],embeddings=embed([c["text"]forcinchunks]),metadatas=[{"source":c["source"]}forcinchunks],)# ---------- QUERY ----------defrag_answer(question:str,k:int=2)->str:# 5) 쿼리 임베딩 + 6) 검색q_emb=embed([question])[0]res=col.query(query_embeddings=[q_emb],n_results=k)retrieved=[(doc,meta["source"])fordoc,metainzip(res["documents"][0],res["metadatas"][0])]# 7) Augment — 검색 결과를 프롬프트에 붙임context="\n\n".join(f"[{src}]\n{doc}"fordoc,srcinretrieved)system=f"""아래 회사 문서를 근거로만 답하세요.문서에 없는 내용은 "문서에 없습니다" 라고 답하세요.답변 끝에 반드시 참고한 [source] 를 나열하세요.{context}"""# 8) 생성r=anthropic.messages.create(model="claude-haiku-4-5",max_tokens=512,system=system,messages=[{"role":"user","content":question}],)returnr.content[0].textprint(rag_answer("구매한 물건을 환불하고 싶어요"))
기대 출력:
환불은 구매 후 7일 이내 신청 가능하며 팀장 승인이 필요합니다.
5일 이상 연속 사용하신 제품은 임원 승인이 추가로 필요합니다.
참고: [policy.md#refund]
frompypdfimportPdfReaderfrompathlibimportPathdefload_pdf(path:str)->list[dict]:"""페이지 단위로 분리 · 메타데이터에 페이지 번호 포함."""reader=PdfReader(path)return[{"text":p.extract_text()or"","source":f"{path}#page={i+1}"}fori,pinenumerate(reader.pages)]defload_markdown(path:str)->list[dict]:text=Path(path).read_text()# 헤딩으로 분리sections=[]current={"title":"intro","text":""}forlineintext.split("\n"):ifline.startswith("## "):ifcurrent["text"].strip():sections.append(current)current={"title":line[3:].strip(),"text":""}else:current["text"]+=line+"\n"ifcurrent["text"].strip():sections.append(current)return[{"text":s["text"],"source":f"{path}#{s['title']}"}forsinsections]
PDF 함정
pypdf 는 텍스트만 잘 뽑음. 표·이미지·수식은 망가짐. 실전에선 unstructured · docling · PyMuPDF (fitz) 같은 라이브러리 비교.
# 권장 메타 스키마{"source":"policy.md#refund",# 원 문서 + 앵커"chunk_id":3,# 문서 내 chunk 순서"updated_at":"2026-04-15",# 갱신일 (stale 체크)"owner":"legal-team",# 소유 팀 (권한 체크)"doc_type":"policy",# 필터용 (policy | faq | wiki)"lang":"ko",# 언어 (다국어 필터)}
defbuild_prompt(question:str,retrieved:list[dict])->str:# 토큰 초과 방어 — 상위 k 만retrieved=retrieved[:5]context="\n\n".join(f"<doc source=\"{c['source']}\" updated=\"{c.get('updated_at','N/A')}\">\n"f"{c['text']}\n"f"</doc>"forcinretrieved)returnf"""<context>{context}</context>위 문서만 근거로 답하세요. 문서에 없으면 "문서에 없습니다" 라고 답하세요.모든 주장은 [source] 인용을 붙이세요."""
XML 태그로 감싸 경계 명확히 — LLM이 문서 내용과 사용자 질문을 헷갈리지 않음 (프롬프트 인젝션 대비).
고정 길이로 자르면 한 문장 중간 에서 끊기기 십상. 임베딩 품질 · 답변 정확도 하락. 대응: RecursiveCharacterTextSplitter 처럼 문단 → 줄 → 문장 경계 존중. overlap 50~100자 넣어 경계 문맥 보존.
실수 2. Citation 환각
프롬프트에 "[source] 를 인용하라" 했는데 모델이 존재하지 않는 source 를 만들어냄. 대응: (a) 프롬프트에서 허용 source 목록 명시, (b) 응답 후 source 유효성 검증 (실제 검색된 것 중 하나인지), (c) LangChain citations 기능 활용.
실수 3. 컨텍스트 초과
top-10 을 그대로 다 넣어 프롬프트가 모델 컨텍스트 초과 → 에러. 대응: top-k 제한 (5~10) + chunk 당 토큰 상한 + 예산 계산. 초과 시 요약하거나 drop.
실수 4. 문서 업데이트가 반영 안 됨
PDF/MD 만 바꿨다고 안 됨. 재임베딩 해서 벡터 DB 에 upsert 필요. 대응: 파일 해시 비교 → 변경된 것만 재임베딩하는 증분 인덱싱 파이프라인. cron 또는 Git hook.
실수 5. 민감 문서가 인덱스에 섞임
급여 테이블·개인정보 문서가 RAG 코퍼스에 들어가면 누구나 검색 가능. 대응: 문서 분류 → 민감 등급에 따라 별도 컬렉션 + 권한 기반 메타 필터링. 가장 안전한 건 아예 인덱싱하지 않기.