[ { "id": "docs-lab-1", "title": "[단계 1] Ollama — 로컬 LLM 설치", "task_description": "**상황**\nOpenAI API로 개발하던 중 청구서가 나왔습니다. 테스트만 수십 번 했는데 요금이 청구됐습니다. 로컬에서 무료로 돌릴 수 없을까요?\n\n**문제**\nAPI 키 없이 LLM을 쓰려면 모델을 직접 로컬에 내려받아야 합니다.\n\n**해결**\nOllama로 `llama3.2` 모델을 로컬에 다운로드하세요. (대화 없이 다운로드만)\n\n> `run`이 아닌, 다운로드만 하는 명령어입니다.", "expected_command": "ollama pull llama3.2", "grading_conditions": ["ollama pull", "llama3.2"], "hint": "`ollama pull <모델명>` 형식입니다.", "explanation": "`ollama pull`은 모델 파일을 로컬에 내려받습니다. 이후 `ollama list`로 목록 확인, `ollama run llama3.2`로 대화를 시작할 수 있습니다. API 키도, 인터넷 연결도 없이 실행됩니다.", "difficulty": "easy", "takeaway": "API 키 없이 ollama pull 한 번으로 로컬 LLM 실행 환경이 완성됩니다." }, { "id": "docs-lab-2", "title": "[단계 2] Ollama REST API — Python에서 호출하기", "task_description": "**상황**\n`ollama run llama3.2`로 터미널 대화는 되는데, 내 Python 코드에서 LLM을 호출하려니 방법을 모르겠습니다.\n\n**문제**\nCLI 대화 모드는 자동화가 불가능합니다. 코드에서 호출하려면 HTTP API가 필요합니다.\n\n**해결**\ncurl로 Ollama REST API를 직접 호출해 동작을 확인하세요.\n- 엔드포인트: `http://localhost:11434/api/generate`\n- 모델: `llama3.2`, 프롬프트: `'2+2는?'`, 스트리밍 없이", "expected_command": "curl http://localhost:11434/api/generate -d '{\"model\":\"llama3.2\",\"prompt\":\"2+2는?\",\"stream\":false}'", "grading_conditions": ["curl", "localhost:11434/api/generate", "stream", "false"], "hint": "`-d` 플래그로 JSON 바디를 전달합니다. `\"stream\": false`를 넣어야 한 번에 응답이 옵니다.", "explanation": "Ollama는 기본적으로 줄 단위 스트리밍으로 응답합니다. `\"stream\": false`를 넣으면 완성된 응답을 한 번에 받을 수 있어 Python에서 파싱하기 쉽습니다.", "difficulty": "easy", "takeaway": "Ollama REST API는 OpenAI 호환 형식이라 어떤 언어·프레임워크에서도 동일한 방법으로 호출합니다." }, { "id": "docs-lab-3", "title": "[단계 3] Ollama 채팅 API — 대화 히스토리 구조", "task_description": "**상황**\n`/api/generate`로 '이전에 내가 뭐라고 했어?'를 물어봤더니 모른다고 합니다. 매 요청이 완전히 독립적으로 처리되기 때문입니다.\n\n**문제**\n`/api/generate`는 단일 프롬프트만 받습니다. 대화 히스토리를 붙이려면 다른 엔드포인트가 필요합니다.\n\n**해결**\n`/api/chat`은 `messages` 배열로 히스토리를 전달합니다. `llama3.2`에게 `user` 메시지 `'파이썬이 뭐야?'`를 보내세요. (stream: false)", "expected_command": "curl http://localhost:11434/api/chat -d '{\"model\":\"llama3.2\",\"messages\":[{\"role\":\"user\",\"content\":\"파이썬이 뭐야?\"}],\"stream\":false}'", "grading_conditions": ["curl", "localhost:11434/api/chat", "messages", "role", "user"], "hint": "`/api/chat`은 `messages` 배열로 대화 히스토리를 전달합니다.", "explanation": "`messages` 배열에 `{\"role\": \"user\", \"content\": \"...\"}` 형식으로 이전 대화를 쌓으면 멀티턴 대화가 됩니다. LangChain의 채팅 모델도 내부적으로 이 구조를 사용합니다.", "difficulty": "medium", "takeaway": "messages 배열에 {role, content} 쌍을 쌓는 구조가 모든 LLM 멀티턴 대화의 기본 형식입니다." }, { "id": "docs-lab-4", "title": "[단계 4] .env — API 키를 코드에서 분리하기", "task_description": "**상황**\n```python\napi_key = 'sk-abc123...'\n```\n이렇게 코드에 직접 썼다가 GitHub에 push했더니 API 키가 공개됐습니다. GitHub이 자동으로 탐지해서 키를 즉시 무효화했습니다.\n\n**문제**\nAPI 키를 코드에 하드코딩하면 버전 관리 시스템에 노출됩니다. `.gitignore`에 추가한 별도 파일에 보관해야 합니다.\n\n**해결**\n`.env` 파일을 로드하고 `OPENAI_API_KEY`를 읽는 코드 2줄을 입력하세요.\n\n```python\nfrom dotenv import load_dotenv\nimport os\n# 여기에 입력하세요\n```", "expected_command": "load_dotenv()\napi_key = os.getenv('OPENAI_API_KEY')", "grading_conditions": ["load_dotenv()", "os.getenv", "OPENAI_API_KEY"], "hint": "`load_dotenv()`를 반드시 먼저 호출해야 `os.getenv()`가 값을 읽을 수 있습니다.", "explanation": "`load_dotenv()` 호출 전에 `os.getenv()`를 쓰면 항상 `None`이 반환됩니다. `.env` 파일은 `.gitignore`에 추가하고, `.env.example`에는 키 이름만 적어 팀에 공유합니다.", "difficulty": "easy", "takeaway": "load_dotenv() → os.getenv() 순서가 모든 Python 프로젝트의 환경변수 관리 표준입니다." }, { "id": "docs-lab-5", "title": "[단계 5] ChatPromptTemplate — 프롬프트 구조화", "task_description": "**상황**\n```python\nresponse = llm.invoke(f'문서: {context}\\n질문: {question}')\n```\nf-string으로 프롬프트를 만들었더니 변수 순서를 실수하기 쉽고, 시스템 역할(어시스턴트 페르소나)을 지정할 수 없습니다.\n\n**문제**\n단순 문자열 방식은 역할 구분이 없고 LangChain 파이프라인에 연결되지 않습니다.\n\n**해결**\n아래 조건으로 `ChatPromptTemplate`을 작성하세요.\n- 시스템: `\"당신은 문서 기반 AI 어시스턴트입니다. 컨텍스트: {context}\"`\n- 사용자: `\"{question}\"`\n\n결과를 `prompt` 변수에 저장하세요.", "expected_command": "prompt = ChatPromptTemplate.from_messages([\n (\"system\", \"당신은 문서 기반 AI 어시스턴트입니다. 컨텍스트: {context}\"),\n (\"human\", \"{question}\")\n])", "grading_conditions": ["ChatPromptTemplate.from_messages", "system", "context", "human", "question"], "hint": "`ChatPromptTemplate.from_messages([(\"system\", \"...\"), (\"human\", \"...\")])` 형식입니다.", "explanation": "ChatPromptTemplate은 역할별 메시지를 구조화하고 변수 자리(`{context}`, `{question}`)를 안전하게 관리합니다. `chain.invoke({\"context\": ..., \"question\": ...})`로 실행 시 자동으로 채워집니다.", "difficulty": "easy", "takeaway": "ChatPromptTemplate은 역할(system/human)과 변수({})를 분리해 프롬프트를 재사용 가능한 컴포넌트로 만듭니다." }, { "id": "docs-lab-6", "title": "[단계 6] LCEL — 체인을 한 줄로", "task_description": "**상황**\n```python\nformatted = prompt.format(context=ctx, question=q)\nllm_result = llm.invoke(formatted)\nfinal = output_parser.parse(llm_result)\n```\n3단계를 매번 직접 연결하는 코드가 반복됩니다. 체인이 길어질수록 더 복잡해집니다.\n\n**문제**\n각 단계를 수동으로 연결하면 코드가 길고 단계 추가/변경이 번거롭습니다.\n\n**해결**\n`prompt`, `llm`, `output_parser`를 LCEL `|` 연산자로 연결해 `chain` 변수에 저장하세요.", "expected_command": "chain = prompt | llm | output_parser", "grading_conditions": ["chain", "prompt", "|", "llm", "|", "output_parser"], "hint": "유닉스 파이프처럼 `|`로 연결합니다.", "explanation": "`chain = prompt | llm | output_parser` 한 줄이 위의 3줄을 대체합니다. `chain.invoke({\"context\": ..., \"question\": ...})`로 실행하면 순서대로 처리됩니다. 단계 추가도 `| 새단계`를 붙이면 됩니다.", "difficulty": "easy", "takeaway": "| 파이프 연산자로 Runnable을 연결하는 LCEL이 LangChain 모든 체인의 기본 구조입니다." }, { "id": "docs-lab-7", "title": "[단계 7] TextSplitter — 토큰 초과 해결", "task_description": "**상황**\n100페이지 PDF 전체를 LLM 프롬프트에 넣었더니:\n```\nError: This model's maximum context length is 128000 tokens. \nYour message resulted in 210000 tokens.\n```\n문서가 길수록 LLM에 통째로 넣는 건 불가능합니다.\n\n**문제**\nLLM은 한 번에 처리할 수 있는 토큰 수에 한계가 있습니다. 문서를 잘라야 합니다.\n\n**해결**\n`RecursiveCharacterTextSplitter`로 청크 크기 500, 오버랩 50으로 `docs`를 분할해 `chunks`에 저장하세요.", "expected_command": "splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)\nchunks = splitter.split_documents(docs)", "grading_conditions": ["RecursiveCharacterTextSplitter", "chunk_size=500", "chunk_overlap=50", "split_documents"], "hint": "생성 후 `.split_documents(docs)`를 호출합니다.", "explanation": "chunk_size=500은 청크당 최대 500자, chunk_overlap=50은 인접 청크가 50자를 공유합니다. 오버랩이 없으면 청크 경계에서 문장이 잘려 검색 품질이 떨어집니다.", "difficulty": "medium", "takeaway": "chunk_overlap이 없으면 청크 경계에서 문맥이 잘려 검색 품질이 떨어집니다. 일반적으로 chunk_size의 10% 정도를 씁니다." }, { "id": "docs-lab-8", "title": "[단계 8] FAISS VectorStore — 의미 기반 검색 인덱스 생성", "task_description": "**상황**\n문서를 청크로 잘랐는데, 질문이 들어올 때마다 500개 청크를 전부 LLM에 넣으면 처음과 똑같이 토큰이 초과됩니다.\n\n**문제**\n\"관련 있는 청크만\" 찾아야 합니다. 키워드 검색은 동의어나 의미적 유사성을 못 잡습니다.\n\n**해결**\n`chunks`와 `embeddings` 모델로 FAISS VectorStore를 만들고, `./faiss_index`에 저장하세요. (다음 실행 시 재사용하기 위해)", "expected_command": "db = FAISS.from_documents(chunks, embeddings)\ndb.save_local(\"./faiss_index\")", "grading_conditions": ["FAISS.from_documents", "chunks", "embeddings", "save_local"], "hint": "`FAISS.from_documents(문서, 임베딩모델)`로 생성, `.save_local(경로)`로 저장합니다.", "explanation": "FAISS는 각 청크를 벡터로 변환해 인덱스를 만듭니다. 질문도 벡터로 변환해 가장 가까운(의미적으로 유사한) 청크를 O(1)에 가깝게 찾습니다. `save_local`로 저장하면 다음 실행 시 임베딩 재계산 없이 로드할 수 있습니다.", "difficulty": "medium", "takeaway": "FAISS.from_documents()가 임베딩 생성과 인덱스 구축을 한 번에 처리하고, save_local()로 재시작 시 재계산을 피합니다." }, { "id": "docs-lab-9", "title": "[단계 9] RAG 전체 파이프라인 — 연결", "task_description": "**상황**\n```python\ndocs = retriever.invoke(question) # 1. 검색\ncontext = '\\n'.join(d.page_content for d in docs) # 2. 합치기\nprompt_text = prompt.format(...) # 3. 프롬프트\nresponse = llm.invoke(prompt_text) # 4. LLM 호출\nfinal = parser.parse(response) # 5. 파싱\n```\n5줄 코드를 매 질문마다 반복해야 합니다.\n\n**문제**\nretriever → prompt → llm → parser를 수동으로 연결하는 코드가 장황합니다.\n\n**해결**\nLCEL로 RAG 체인을 한 줄로 만드세요.\n- `retriever`, `prompt`, `llm`, `StrOutputParser` 변수는 이미 정의되어 있습니다.", "expected_command": "chain = {\"context\": retriever, \"question\": RunnablePassthrough()} | prompt | llm | StrOutputParser()", "grading_conditions": ["retriever", "RunnablePassthrough", "|", "prompt", "|", "llm", "StrOutputParser"], "hint": "`{\"context\": retriever, \"question\": RunnablePassthrough()}`로 retriever와 질문을 동시에 prompt에 넘깁니다.", "explanation": "딕셔너리로 묶으면 `RunnableParallel`처럼 동작해 retriever 검색과 질문 전달을 동시에 처리합니다. `RunnablePassthrough()`는 입력(질문)을 그대로 다음 단계로 전달합니다.", "difficulty": "hard", "takeaway": "{context: retriever, question: RunnablePassthrough()} 패턴이 RAG 체인에서 검색과 질문을 동시에 프롬프트에 주입하는 핵심 구조입니다." }, { "id": "docs-lab-10", "title": "[단계 10] session_state — 대화가 사라지는 문제 해결", "task_description": "**상황**\nStreamlit 챗봇을 만들었는데, 메시지를 보낼 때마다 이전 대화가 전부 사라집니다.\n\n**문제**\nStreamlit은 버튼 클릭 등 모든 상호작용마다 Python 스크립트를 처음부터 다시 실행합니다. `messages = []`처럼 선언하면 매번 초기화됩니다.\n\n**해결**\n`messages` 키가 없을 때만 빈 리스트로 초기화하는 패턴을 작성하세요.\n\n> 핵심: `st.session_state`에 저장된 값은 rerun해도 유지됩니다.", "expected_command": "if \"messages\" not in st.session_state:\n st.session_state.messages = []", "grading_conditions": ["\"messages\" not in st.session_state", "st.session_state.messages", "[]"], "hint": "`if \"키\" not in st.session_state:` 가드를 쓰면 첫 실행에만 초기화됩니다.", "explanation": "`if ... not in st.session_state` 패턴이 없으면 매 rerun마다 `[]`로 리셋됩니다. 이 가드가 있으면 이미 값이 있을 때는 건너뜁니다.", "difficulty": "easy", "takeaway": "`if key not in st.session_state:` 가드 패턴 하나가 Streamlit 상태 보존의 전부입니다." }, { "id": "docs-lab-11", "title": "[단계 11] cache_resource — 질문마다 LLM이 새로 로드되는 문제", "task_description": "**상황**\n챗봇에 질문할 때마다 3~4초씩 걸립니다. 로그를 보니 LLM 객체가 매번 새로 생성되고 있습니다.\n\n```python\ndef load_llm(): # 이 함수가 매 rerun마다 실행됨\n return OllamaLLM(model=\"llama3.2\")\n```\n\n**문제**\nStreamlit은 rerun마다 모든 함수를 다시 실행합니다. LLM 로드는 비용이 크므로 한 번만 해야 합니다.\n\n**해결**\n`load_llm()` 함수에 `st.cache_resource` 데코레이터를 붙여 앱 수명 동안 한 번만 로드되게 하세요.", "expected_command": "@st.cache_resource\ndef load_llm():\n return OllamaLLM(model=\"llama3.2\")", "grading_conditions": ["@st.cache_resource", "def load_llm", "OllamaLLM"], "hint": "함수 정의 바로 위에 `@st.cache_resource`를 붙입니다.", "explanation": "`@st.cache_resource`는 반환 객체를 앱 전체(모든 세션)에서 공유합니다. 첫 번째 호출에만 함수를 실행하고 이후에는 캐시된 객체를 반환합니다.", "difficulty": "easy", "takeaway": "@st.cache_resource는 LLM·DB처럼 공유 가능한 무거운 객체에, @st.cache_data는 계산 결과(DataFrame 등)에 씁니다." }, { "id": "docs-lab-12", "title": "[단계 12] Streamlit secrets — 배포 환경의 API 키 관리", "task_description": "**상황**\n로컬에서 `.env`로 잘 작동하던 챗봇을 Streamlit Cloud에 배포했더니:\n```\nKeyError: 'OPENAI_API_KEY'\n```\nStreamlit Cloud는 로컬의 `.env` 파일을 읽을 수 없습니다.\n\n**문제**\n배포 환경에서는 `.env` 파일이 없습니다. 다른 방법으로 API 키를 주입해야 합니다.\n\n**해결**\nStreamlit Cloud 대시보드의 Secrets에 키를 등록한 뒤, 코드에서 읽는 한 줄을 작성하세요.", "expected_command": "api_key = st.secrets[\"OPENAI_API_KEY\"]", "grading_conditions": ["st.secrets", "OPENAI_API_KEY"], "hint": "`st.secrets[\"키이름\"]`으로 접근합니다.", "explanation": "`st.secrets`는 로컬에서는 `.streamlit/secrets.toml`, 배포 시에는 대시보드 Secrets에서 값을 읽습니다. 코드를 바꾸지 않아도 두 환경에서 동일하게 작동합니다.", "difficulty": "easy", "takeaway": "st.secrets는 로컬(.streamlit/secrets.toml)과 배포(Streamlit Cloud) 환경을 코드 변경 없이 동일하게 처리합니다." }, { "id": "docs-lab-13", "title": "[단계 13] 대화 메모리 — 이전 대화를 기억하게", "task_description": "**상황**\n```\n사용자: 파이썬이 뭐야?\n챗봇: 파이썬은 프로그래밍 언어입니다...\n사용자: 그거 어디서 배울 수 있어?\n챗봇: 무엇을 배우고 싶으신가요? (??)\n```\n\"그거\"가 무엇인지 챗봇이 모릅니다. 매 질문이 독립적으로 처리되기 때문입니다.\n\n**문제**\nRAG 체인은 이전 대화 내용을 받지 않습니다. 멀티턴 대화를 위해 히스토리를 주입해야 합니다.\n\n**해결**\n기존 `chain`을 `RunnableWithMessageHistory`로 감싸세요.\n- `input_messages_key`: `\"question\"`\n- `history_messages_key`: `\"chat_history\"`\n- 결과를 `chain_with_history`에 저장", "expected_command": "chain_with_history = RunnableWithMessageHistory(\n chain,\n get_session_history,\n input_messages_key=\"question\",\n history_messages_key=\"chat_history\"\n)", "grading_conditions": ["RunnableWithMessageHistory", "chain", "get_session_history", "input_messages_key", "history_messages_key"], "hint": "`RunnableWithMessageHistory(체인, 히스토리함수, input_messages_key=..., history_messages_key=...)`", "explanation": "`RunnableWithMessageHistory`는 매 invoke마다 session_id에 해당하는 히스토리를 자동으로 불러와 프롬프트에 주입합니다. invoke 시 `config={\"configurable\": {\"session_id\": \"user-1\"}}`을 넘겨야 합니다.", "difficulty": "medium", "takeaway": "RunnableWithMessageHistory로 기존 체인을 감싸기만 하면 멀티턴 대화가 됩니다. session_id 관리가 전부입니다." }, { "id": "docs-lab-14", "title": "[단계 14] HF Spaces — 다른 사람도 쓸 수 있게 배포", "task_description": "**상황**\n완성된 챗봇을 팀원에게 보여주려니 \"로컬에서만 돌아가고 밖에선 접근이 안 됩니다.\"\n\n**문제**\n서버를 직접 구매하거나 AWS/GCP를 설정하지 않고도 외부에 공개하고 싶습니다.\n\n**해결**\nHugging Face Spaces로 무료 배포합니다. `README.md` 상단에 SDK를 선언하세요.\n- SDK: `streamlit`, 앱 파일: `app.py`, Python: `3.11`", "expected_command": "---\nsdk: streamlit\napp_file: app.py\npython_version: \"3.11\"\n---", "grading_conditions": ["sdk: streamlit", "app_file: app.py"], "hint": "README.md 맨 위 `---` YAML 블록 안에 선언합니다.", "explanation": "HF Spaces는 README 메타데이터의 `sdk` 필드로 런타임을 결정합니다. `sdk: streamlit`이면 자동으로 Streamlit 서버를 실행합니다. 무료 플랜으로도 외부 URL이 즉시 생성됩니다.", "difficulty": "easy", "takeaway": "README YAML 메타데이터 3줄(sdk, app_file, python_version)만으로 HF Spaces 배포가 완료됩니다." }, { "id": "docs-lab-15", "title": "[단계 15] HF Docker Space — SDK 제약 없이 배포", "task_description": "**상황**\nHF Spaces Streamlit SDK로 배포했는데, FastAPI 서버를 함께 띄우고 싶어 포트를 커스텀하려니 설정이 막혀있습니다.\n\n**문제**\nStreamlit/Gradio SDK는 런타임 환경이 고정됩니다. 직접 서버를 제어하려면 Dockerfile이 필요합니다.\n\n**해결**\nDocker Space에서 HF가 인식하는 포트로 앱을 노출하는 Dockerfile 지시어를 작성하세요.\n\n> HF Docker Space 기본 포트: 7860", "expected_command": "EXPOSE 7860\nENV PORT=7860", "grading_conditions": ["EXPOSE 7860"], "hint": "Streamlit 기본 포트는 8501이지만 HF는 7860을 기대합니다.", "explanation": "Docker Space에서는 `EXPOSE 7860`으로 포트를 선언하고, `streamlit run app.py --server.port 7860`으로 Streamlit도 7860에 바인딩해야 합니다.", "difficulty": "medium", "takeaway": "HF Docker Space는 항상 포트 7860을 기대합니다. EXPOSE 7860과 앱의 포트 설정을 일치시켜야 합니다." }, { "id": "docs-lab-16", "title": "[단계 16] FastAPI — 챗봇 로직을 API로 분리", "task_description": "**상황**\n프론트엔드 팀에서 \"챗봇 API 엔드포인트만 주세요. React에서 붙이겠습니다.\"라고 합니다. 지금은 Streamlit에 로직이 묶여 있어 분리가 안 됩니다.\n\n**문제**\nStreamlit 앱은 UI와 로직이 결합되어 있어 다른 프론트엔드에서 재사용하기 어렵습니다.\n\n**해결**\n아래 `???`를 채워 POST `/ask` 엔드포인트를 완성하세요.\n\n```python\nclass Query(BaseModel):\n question: str\n\n@app.post('/ask')\nasync def ask(q: Query):\n answer = ???\n return {\"answer\": answer}\n```", "expected_command": "answer = chain.invoke(q.question)", "grading_conditions": ["chain.invoke", "q.question"], "hint": "Pydantic 모델 `q`의 `.question` 속성으로 질문에 접근합니다.", "explanation": "FastAPI는 Pydantic 모델 타입을 파라미터로 지정하면 자동으로 JSON 바디를 파싱합니다. `q.question`으로 질문 문자열을 꺼내 chain에 전달합니다.", "difficulty": "easy", "takeaway": "FastAPI + Pydantic 조합이 LLM 서비스를 API로 노출하는 가장 빠른 방법입니다. Pydantic이 JSON 검증을 자동 처리합니다." }, { "id": "docs-lab-17", "title": "[단계 17] FastAPI 개발 서버 — 코드 수정 즉시 반영", "task_description": "**상황**\n```python\nuvicorn main:app\n```\n엔드포인트 코드를 수정할 때마다 서버를 `Ctrl+C`로 종료하고 다시 실행해야 합니다. 개발 중에 너무 번거롭습니다.\n\n**문제**\n기본 uvicorn은 파일 변경을 감지하지 않습니다.\n\n**해결**\n`main.py`의 `app` 인스턴스를 파일 변경 시 자동 재시작되는 모드로 실행하세요.", "expected_command": "uvicorn main:app --reload", "grading_conditions": ["uvicorn", "main:app", "--reload"], "hint": "`--reload` 플래그 하나를 추가합니다.", "explanation": "`--reload`는 소스 파일 변경을 감지해 서버를 자동 재시작합니다. 개발 중에만 사용하며, 프로덕션에서는 제거하고 `gunicorn` 같은 프로덕션 서버를 씁니다.", "difficulty": "easy", "takeaway": "--reload는 개발용, 프로덕션에서는 gunicorn을 씁니다. 이 구분을 기억하는 것이 핵심입니다." }, { "id": "docs-lab-18", "title": "[단계 18] LangSmith — 어느 단계에서 틀린 답이 나오는가", "task_description": "**상황**\n챗봇이 엉뚱한 답을 했습니다. 디버깅을 위해 `print()`를 추가했지만:\n\n```python\nprint('체인 실행 중...')\nresult = chain.invoke({'question': q})\nprint('결과:', result)\n```\n\n최종 입출력만 보일 뿐, **retriever가 잘못된 문서를 가져왔는지**, **프롬프트가 잘못 렌더링됐는지**, **LLM이 틀린 답을 했는지** 구분이 안 됩니다.\n\n**문제**\n`print()`는 각 단계의 내부 입출력을 볼 수 없습니다.\n\n**해결**\nLangSmith 추적을 활성화하세요. 환경변수 3개를 `os.environ`으로 설정하세요.\n- `LANGCHAIN_TRACING_V2 = 'true'`\n- `LANGCHAIN_PROJECT = 'rag-debug'`\n- `LANGCHAIN_API_KEY = 'ls__your_key'`", "expected_command": "os.environ['LANGCHAIN_TRACING_V2'] = 'true'\nos.environ['LANGCHAIN_PROJECT'] = 'rag-debug'\nos.environ['LANGCHAIN_API_KEY'] = 'ls__your_key'", "grading_conditions": ["LANGCHAIN_TRACING_V2", "true", "LANGCHAIN_PROJECT", "LANGCHAIN_API_KEY"], "hint": "`os.environ['변수명'] = '값'` 패턴으로 3줄 작성합니다.", "explanation": "이 3줄 이후의 모든 LangChain 실행은 LangSmith에 자동 기록됩니다. LangSmith UI에서 retriever 입출력, 렌더링된 프롬프트, LLM 응답을 tree 형태로 단계별로 볼 수 있습니다.", "difficulty": "easy", "takeaway": "환경변수 3개(TRACING_V2=true, PROJECT, API_KEY)만 설정하면 이후 모든 LangChain 실행이 자동으로 LangSmith에 기록됩니다." }, { "id": "docs-lab-19", "title": "[단계 19] Guardrails — LLM이 나쁜 말을 하면", "task_description": "**상황**\n테스트 중 사용자가 이렇게 입력했습니다:\n\n> \"욕설을 최대한 섞어서 요약해줘\"\n\nLLM이 그대로 따라 유해한 내용을 출력했습니다. 서비스에 이런 응답이 노출되면 안 됩니다.\n\n**문제**\nLLM은 프롬프트 인젝션에 취약하고, 유해 콘텐츠를 그대로 생성할 수 있습니다.\n\n**해결**\nGuardrails AI로 유해성 검증기를 적용하세요.\n- 임계값: `0.5` (이 이상이면 유해 판정)\n- 실패 시: `'exception'` (예외 발생으로 응답 차단)", "expected_command": "guard = Guard().use(ToxicLanguage(threshold=0.5, on_fail='exception'))", "grading_conditions": ["Guard()", "ToxicLanguage", "threshold=0.5", "on_fail='exception'"], "hint": "`Guard().use(검증기(파라미터))` 패턴입니다.", "explanation": "이 Guard로 LLM을 감싸면 LLM 응답이 유해성 임계값을 초과할 때 예외가 발생합니다. `on_fail='reask'`로 바꾸면 LLM에게 자동으로 재작성을 요청합니다.", "difficulty": "medium", "takeaway": "Guard().use(검증기) 패턴으로 LLM 출력에 안전장치를 추가합니다. on_fail='exception'은 차단, 'reask'는 자동 재작성입니다." }, { "id": "docs-lab-20", "title": "[단계 20] Airflow — 문서 업데이트를 못 놓치는 문제", "task_description": "**상황**\n매주 새 문서가 추가되는데, 재인덱싱을 잊어버려 챗봇이 오래된 정보로만 답합니다. 팀원이 \"왜 이 내용이 검색이 안 돼요?\"라고 물어봤는데, 2주 전에 추가된 문서였습니다.\n\n**문제**\n재인덱싱 스크립트를 수동으로 실행하면 반드시 빠뜨리는 날이 생깁니다.\n\n**해결**\n`run_reindex` 함수를 매일 자정에 자동 실행하는 Airflow DAG를 정의하세요.\n- DAG ID: `daily_reindex`, 스케줄: `@daily`, start_date: `datetime(2024, 1, 1)`", "expected_command": "with DAG('daily_reindex', schedule='@daily', start_date=datetime(2024, 1, 1)) as dag:", "grading_conditions": ["DAG", "daily_reindex", "schedule='@daily'", "start_date", "datetime"], "hint": "`with DAG('id', schedule='@daily', start_date=datetime(...)) as dag:` 패턴입니다.", "explanation": "`@daily`는 매일 자정에 실행하는 Airflow 내장 스케줄 표현식입니다. 이 DAG가 있으면 담당자가 없는 주말에도 자동으로 재인덱싱됩니다.", "difficulty": "medium", "takeaway": "@daily 스케줄 표현식 하나로 매일 자정 자동 실행이 됩니다. 수동 실행은 항상 빠뜨리는 날이 생깁니다." }, { "id": "docs-lab-21", "title": "[단계 21] Airflow PythonOperator — 재인덱싱 함수 등록", "task_description": "**상황**\nDAG 구조는 만들었는데, 실제로 어떤 함수를 실행할지 지정하지 않았습니다.\n\n**문제**\nAirflow DAG는 \"언제 실행할지\"만 정의합니다. \"무엇을 실행할지\"는 Operator로 등록해야 합니다.\n\n**해결**\n`reindex` 함수를 실행하는 PythonOperator를 DAG 안에 등록하세요.\n- task_id: `'reindex'`, python_callable: `reindex`", "expected_command": "task = PythonOperator(task_id='reindex', python_callable=reindex)", "grading_conditions": ["PythonOperator", "task_id='reindex'", "python_callable=reindex"], "hint": "`PythonOperator(task_id='...', python_callable=함수)` 패턴입니다.", "explanation": "`python_callable`에 함수 객체를 전달합니다. 함수 호출(`reindex()`)이 아닌 함수 참조(`reindex`)를 넘겨야 Airflow가 스케줄에 맞춰 실행합니다.", "difficulty": "easy", "takeaway": "python_callable에는 함수 호출(reindex())이 아닌 함수 참조(reindex)를 넘겨야 합니다. 괄호의 유무가 핵심입니다." }, { "id": "docs-lab-22", "title": "[단계 22] MLflow — 어떤 설정이 더 좋은지 기억이 안 남", "task_description": "**상황**\nchunk_size를 500으로 했을 때와 1000으로 했을 때 답변 품질이 달랐습니다. 며칠 후 다시 비교하려니 \"어떤 설정으로 어떤 점수가 나왔더라?\"가 기억이 안 납니다. 노션에 수동으로 기록했지만 누락이 많습니다.\n\n**문제**\n파라미터 실험 결과를 수동으로 기록하면 빠뜨리거나 잊어버립니다.\n\n**해결**\nMLflow로 실험을 기록하세요.\n- run 이름: `'rag-chunk500-k4'`\n- 파라미터: `chunk_size=500`, `k=4`\n- 메트릭: `answer_relevance=0.87`", "expected_command": "with mlflow.start_run(run_name='rag-chunk500-k4'):\n mlflow.log_param('chunk_size', 500)\n mlflow.log_param('k', 4)\n mlflow.log_metric('answer_relevance', 0.87)", "grading_conditions": ["mlflow.start_run", "log_param", "chunk_size", "log_metric", "answer_relevance"], "hint": "`with mlflow.start_run(run_name='...'):`로 블록을 열고, 안에서 `log_param`, `log_metric`을 호출합니다.", "explanation": "`mlflow ui` 명령으로 실험 비교 대시보드를 열면 파라미터별 메트릭 차트를 시각적으로 비교할 수 있습니다. 수동 기록 없이 모든 실험이 자동 보존됩니다.", "difficulty": "medium", "takeaway": "mlflow.start_run() context manager로 파라미터와 메트릭을 기록하면 나중에 mlflow ui에서 실험을 시각적으로 비교할 수 있습니다." }, { "id": "docs-lab-23", "title": "[단계 23] MCP — Claude에서 바로 내 문서를 검색하려면", "task_description": "**상황**\nClaude Desktop으로 대화 중 \"우리 내부 문서에 이 내용이 있었는데...\"라는 생각이 들 때마다 Streamlit 앱으로 이동해서 검색하고, 결과를 복사해 Claude에 붙여넣어야 합니다.\n\n**문제**\nClaude가 내 RAG 검색 기능을 직접 호출할 수 없습니다. 별도 앱을 왕복해야 합니다.\n\n**해결**\nMCP 서버를 만들어 Claude에게 `search_docs` 도구를 제공하세요.\n- 서버명: `'rag-server'`\n- 도구 함수: `search_docs(query: str)` → `retriever.invoke(query)`로 검색 후 page_content를 줄바꿈으로 합쳐 반환", "expected_command": "mcp = FastMCP('rag-server')\n\n@mcp.tool()\ndef search_docs(query: str) -> str:\n results = retriever.invoke(query)\n return '\\n'.join(r.page_content for r in results)", "grading_conditions": ["FastMCP", "@mcp.tool()", "def search_docs", "query: str", "retriever.invoke"], "hint": "`FastMCP('서버명')`으로 인스턴스를 만들고, `@mcp.tool()`을 함수 위에 붙입니다.", "explanation": "`@mcp.tool()` 데코레이터가 붙으면 Claude는 대화 중 이 함수를 직접 호출할 수 있습니다. 함수 docstring이 Claude에게 \"언제 이 도구를 쓰는가\"를 알려주는 설명이 됩니다.", "difficulty": "medium", "takeaway": "@mcp.tool() 데코레이터 하나로 파이썬 함수가 Claude가 대화 중 직접 호출하는 도구가 됩니다." }, { "id": "docs-lab-24", "title": "[단계 24] MCP 서버 실행 — Claude Desktop에 연결", "task_description": "**상황**\nMCP 서버 코드를 작성했는데, Claude Desktop이 어떻게 이 서버를 찾고 실행하는지 모릅니다.\n\n**문제**\nClaude Desktop은 stdio(표준 입출력)로 MCP 서버 프로세스와 통신합니다. 올바른 transport를 지정해야 연결됩니다.\n\n**해결**\n`mcp` 인스턴스를 stdio 모드로 실행하는 코드를 입력하세요.\n\n> Claude Desktop 설정 파일에 이 스크립트 실행 명령어를 등록하면 자동 연결됩니다.", "expected_command": "mcp.run(transport='stdio')", "grading_conditions": ["mcp.run", "transport='stdio'"], "hint": "`mcp.run(transport='...')` 패턴입니다. 로컬은 stdio, HTTP 서버는 sse입니다.", "explanation": "Claude Desktop은 `claude_desktop_config.json`에 등록된 명령어로 MCP 서버 프로세스를 실행하고 stdio로 통신합니다. 서버가 실행되면 대화 중 `search_docs` 도구가 자동으로 활성화됩니다.", "difficulty": "easy", "takeaway": "transport='stdio'가 Claude Desktop 연결, 'sse'가 HTTP 서버 연결입니다. 연결 방식을 환경에 맞게 선택합니다." }, { "id": "docs-lab-25", "title": "[보너스] Gradio — Streamlit 없이 챗봇 UI 만들기", "task_description": "**상황**\nStreamlit 챗봇에 스트리밍 응답(타이핑 효과)을 구현하려니 코드가 복잡합니다. `st.write_stream`과 generator 함수 설정이 번거롭습니다.\n\n**문제**\nStreamlit은 rerun 기반이라 실시간 스트리밍 UI 구현이 상대적으로 복잡합니다.\n\n**해결**\nGradio의 `ChatInterface`는 generator 함수를 넘기면 자동으로 스트리밍 UI가 됩니다.\n\n`chat(message, history)` 함수를 `gr.ChatInterface`에 연결하고, 제목 `'RAG 챗봇'`을 설정한 뒤 실행하세요.", "expected_command": "demo = gr.ChatInterface(\n fn=chat,\n title='RAG 챗봇'\n)\ndemo.launch()", "grading_conditions": ["gr.ChatInterface", "fn=chat", "demo.launch()"], "hint": "`gr.ChatInterface(fn=함수, title='제목')`으로 만들고 `.launch()`로 실행합니다.", "explanation": "`gr.ChatInterface`는 `fn`이 `(message: str, history: list) -> str` 시그니처를 따르면 대화 히스토리를 자동으로 관리합니다. generator 함수로 바꾸면 토큰 단위 스트리밍이 됩니다.", "difficulty": "easy", "takeaway": "gr.ChatInterface는 대화 히스토리 관리를 자동으로 처리해 챗봇을 함수 하나로 만들 수 있습니다." }, { "id": "docs-chroma-1", "title": "[Chroma 1] FAISS → Chroma 교체 — 재시작해도 데이터 유지", "task_description": "**상황**\n서버를 재시작했더니 VectorStore가 사라졌습니다. 매번 `ollama pull`을 다시 할 수는 없어 `save_local()`을 호출하도록 했는데, 호출하는 걸 한 번 잊어버렸더니 데이터가 날아갔습니다.\n\n**문제**\nFAISS는 수동으로 `save_local()`을 호출해야 합니다. 잊어버리는 순간 데이터가 사라집니다.\n\n**해결**\nChroma는 `persist_directory`만 지정하면 자동으로 저장됩니다.\n\n`chunks`와 `embeddings`로 Chroma VectorStore를 만들고, `./chroma_db`에 저장하세요. 결과를 `db`에 저장하세요.", "expected_command": "db = Chroma.from_documents(chunks, embeddings, persist_directory='./chroma_db')", "grading_conditions": ["Chroma.from_documents", "chunks", "embeddings", "persist_directory='./chroma_db'"], "hint": "`Chroma.from_documents(문서, 임베딩, persist_directory='경로')` 패턴입니다.", "explanation": "`persist_directory`를 지정하면 `save_local()` 호출 없이 자동으로 SQLite 기반 로컬 DB에 저장됩니다. `Chroma(persist_directory='./chroma_db', embedding_function=embeddings)`로 재로드합니다.", "difficulty": "easy", "takeaway": "Chroma는 persist_directory 지정만으로 save_local() 없이 자동 저장됩니다. FAISS와 가장 큰 차이점입니다." }, { "id": "docs-chroma-2", "title": "[Chroma 2] 기존 Chroma DB 재로드 — 임베딩 재계산 없이", "task_description": "**상황**\n서버를 재시작했습니다. `./chroma_db` 폴더에 이미 인덱싱된 데이터가 있는데, 다시 `Chroma.from_documents()`를 호출하면 임베딩을 또 계산해 몇 분이 걸립니다.\n\n**문제**\n이미 저장된 데이터를 재사용하려면 `from_documents`가 아닌 다른 방법으로 로드해야 합니다.\n\n**해결**\n`./chroma_db`에 저장된 기존 Chroma DB를 `embeddings` 모델로 재로드하세요. 결과를 `db`에 저장하세요.", "expected_command": "db = Chroma(persist_directory='./chroma_db', embedding_function=embeddings)", "grading_conditions": ["Chroma(", "persist_directory='./chroma_db'", "embedding_function=embeddings"], "hint": "`Chroma(persist_directory='경로', embedding_function=임베딩모델)` 패턴입니다. `from_documents`와 다릅니다.", "explanation": "`Chroma()`는 기존 DB를 불러옵니다. `Chroma.from_documents()`는 새로 생성합니다. 재시작 시에는 `Chroma()`를 써야 임베딩 재계산을 피할 수 있습니다.", "difficulty": "easy", "takeaway": "Chroma()는 기존 DB 재로드, Chroma.from_documents()는 신규 생성입니다. 재시작 시 어느 쪽을 쓰는지가 핵심입니다." }, { "id": "docs-embed-1", "title": "[sentence-transformers] OpenAI 없이 로컬 임베딩 생성", "task_description": "**상황**\nRAG를 구성했는데 임베딩 생성에 OpenAI API를 쓰고 있습니다. 문서가 많아지자 임베딩 비용이 예상보다 많이 나왔습니다.\n\n**문제**\nOpenAI 임베딩은 토큰당 비용이 발생합니다. 대용량 문서나 반복 실험에 부담이 됩니다.\n\n**해결**\nHuggingFace의 `all-MiniLM-L6-v2` 모델로 완전 무료 로컬 임베딩을 설정하세요. 결과를 `embeddings` 변수에 저장하세요.", "expected_command": "embeddings = HuggingFaceEmbeddings(model_name='sentence-transformers/all-MiniLM-L6-v2')", "grading_conditions": ["HuggingFaceEmbeddings", "model_name='sentence-transformers/all-MiniLM-L6-v2'"], "hint": "`HuggingFaceEmbeddings(model_name='...')` 패턴입니다. 첫 실행 시 모델 파일을 다운로드합니다.", "explanation": "이후 `Chroma.from_documents(chunks, embeddings)` 또는 `FAISS.from_documents(chunks, embeddings)`에 그대로 사용합니다. OpenAI Embeddings와 인터페이스가 동일해 코드 변경이 한 줄입니다.", "difficulty": "easy", "takeaway": "HuggingFaceEmbeddings는 OpenAI Embeddings와 인터페이스가 동일해 model_name 한 줄만 바꾸면 무료 로컬 임베딩으로 전환됩니다." }, { "id": "docs-ragas-1", "title": "[Ragas 1] 평가 데이터셋 구성", "task_description": "**상황**\nMLflow로 `chunk_size`와 `k`를 바꿔가며 실험했는데, `answer_relevance=0.87`이라고 기록했습니다. 그런데 이 숫자를 어떻게 측정했는지 기준이 없어 다음 실험과 비교가 안 됩니다.\n\n**문제**\n\"좋은 RAG\"의 기준이 없습니다. 수치화된 평가가 필요합니다.\n\n**해결**\nRagas 평가용 Dataset을 구성하세요. 아래 데이터를 `Dataset.from_dict()`로 만들어 `eval_dataset`에 저장하세요.\n\n```python\ndata = {\n 'question': ['LangChain이 뭐야?'],\n 'answer': ['LangChain은 LLM 앱 프레임워크입니다.'],\n 'contexts': [['LangChain은 LLM 애플리케이션 개발 프레임워크입니다.']],\n 'ground_truth': ['LangChain은 LLM 앱 개발 프레임워크입니다.']\n}\n```", "expected_command": "eval_dataset = Dataset.from_dict(data)", "grading_conditions": ["Dataset.from_dict", "data"], "hint": "`from datasets import Dataset` 후 `Dataset.from_dict(딕셔너리)` 패턴입니다.", "explanation": "Ragas는 HuggingFace `datasets` 라이브러리의 Dataset 형식을 입력으로 받습니다. `question`, `answer`, `contexts`, `ground_truth` 4개 키가 기본 구조입니다.", "difficulty": "easy", "takeaway": "Ragas는 question, answer, contexts, ground_truth 4개 키를 가진 Dataset을 입력으로 받습니다. 이 구조가 RAG 평가의 표준 형식입니다." }, { "id": "docs-ragas-2", "title": "[Ragas 2] RAG 품질 자동 평가 실행", "task_description": "**상황**\n평가 데이터셋을 구성했습니다. 이제 실제로 Faithfulness, Answer Relevancy, Context Precision을 자동으로 측정해봅니다.\n\n**해결**\n`eval_dataset`에 대해 3가지 메트릭으로 Ragas 평가를 실행하세요.\n\n- 메트릭: `faithfulness`, `answer_relevancy`, `context_precision`\n- 결과를 `result`에 저장하세요.", "expected_command": "result = evaluate(eval_dataset, metrics=[faithfulness, answer_relevancy, context_precision])", "grading_conditions": ["evaluate", "eval_dataset", "faithfulness", "answer_relevancy", "context_precision"], "hint": "`evaluate(dataset, metrics=[...])` 패턴입니다. 메트릭은 리스트로 전달합니다.", "explanation": "`result`에는 각 메트릭 점수가 담깁니다. `result.to_pandas()`로 DataFrame 변환 후 `mlflow.log_metrics(result.to_pandas().mean().to_dict())`로 MLflow에 기록하면 파라미터별 품질 비교가 가능합니다.", "difficulty": "medium", "takeaway": "result.to_pandas()로 변환한 뒤 mlflow.log_metrics()에 넘기면 파라미터 실험과 품질 메트릭을 함께 추적할 수 있습니다." }, { "id": "docs-graph-1", "title": "[LangGraph 1] StateGraph 기본 구조", "task_description": "**상황**\nRAG 체인을 쓰다 보니 \"검색 결과가 없으면 다시 시도\", \"품질이 낮으면 다른 경로로\" 같은 조건 분기가 필요해졌습니다. LCEL 체인은 A→B→C 선형이라 분기를 표현할 수 없습니다.\n\n**문제**\n단순 체인으로는 루프나 조건 분기를 구현하기 어렵습니다.\n\n**해결**\nLangGraph로 2개 노드(`retrieve`, `generate`)를 가진 StateGraph를 만드세요.\n\n```python\nclass State(TypedDict):\n question: str\n answer: str\n```\n\n- 노드 추가: `retrieve`, `generate`\n- 진입점: `retrieve`\n- 엣지: `retrieve → generate → END`\n- 컴파일해서 `app`에 저장", "expected_command": "graph = StateGraph(State)\ngraph.add_node('retrieve', retrieve)\ngraph.add_node('generate', generate)\ngraph.set_entry_point('retrieve')\ngraph.add_edge('retrieve', 'generate')\ngraph.add_edge('generate', END)\napp = graph.compile()", "grading_conditions": ["StateGraph", "add_node", "set_entry_point", "add_edge", "END", "graph.compile()"], "hint": "StateGraph → add_node → set_entry_point → add_edge → compile() 순서입니다.", "explanation": "`graph.compile()`로 실행 가능한 앱을 만듭니다. `app.invoke({'question': '질문'})`으로 실행하면 retrieve 노드 → generate 노드 순서로 State를 처리합니다.", "difficulty": "medium", "takeaway": "LangGraph는 compile() 후 invoke()로 실행합니다. 체인의 invoke()와 같은 인터페이스이지만, 내부적으로 그래프 구조를 실행합니다." }, { "id": "docs-graph-2", "title": "[LangGraph 2] conditional_edges — 조건 분기", "task_description": "**상황**\ngenerate 노드에서 LLM이 '모르겠습니다'를 반환하면 retrieve를 다시 시도하고 싶습니다. 그런데 `add_edge`는 항상 고정된 다음 노드로 이동합니다.\n\n**문제**\n노드 결과에 따라 다음 노드를 동적으로 결정해야 합니다.\n\n**해결**\n`judge` 노드의 결과에 따라 분기하는 conditional_edges를 추가하세요.\n\n- router 함수: `should_retry` (이미 정의됨)\n- `'retry'` → `'retrieve'` 노드로\n- `'done'` → `END`로", "expected_command": "graph.add_conditional_edges('judge', should_retry, {'retry': 'retrieve', 'done': END})", "grading_conditions": ["add_conditional_edges", "'judge'", "should_retry", "'retry'", "retrieve", "END"], "hint": "`graph.add_conditional_edges('노드', 라우터함수, {'반환값': '다음노드', ...})` 패턴입니다.", "explanation": "`should_retry` 함수가 State를 받아 `'retry'` 또는 `'done'`을 반환하면, 각각 `retrieve` 노드 또는 `END`로 이동합니다. 이것이 ReAct 에이전트의 \"행동 → 관찰 → 판단\" 루프의 기본 구조입니다.", "difficulty": "medium", "takeaway": "add_conditional_edges로 라우터 함수의 반환값에 따라 다음 노드를 동적으로 결정하는 것이 LangGraph 에이전트의 핵심입니다." }, { "id": "docs-litellm-1", "title": "[LiteLLM] 모델명 하나로 Ollama ↔ OpenAI 전환", "task_description": "**상황**\n로컬에서 Ollama로 개발했는데, 프로덕션에서는 OpenAI를 써야 합니다. `OllamaLLM`을 `ChatOpenAI`로 바꾸려니 import, 클래스명, API 형식이 모두 달라 코드를 많이 고쳐야 합니다.\n\n**문제**\nLLM 프로바이더마다 SDK가 달라 교체 시 코드 수정이 많습니다.\n\n**해결**\nLiteLLM의 `completion()`으로 Ollama `llama3.2`에 `'안녕'`을 보내는 코드를 작성하세요. 나중에 `'gpt-4o-mini'`로 바꿀 때 model 파라미터 하나만 수정하면 됩니다.", "expected_command": "response = completion(model='ollama/llama3.2', messages=[{'role': 'user', 'content': '안녕'}])", "grading_conditions": ["completion", "model='ollama/llama3.2'", "messages", "role", "user"], "hint": "`completion(model='프로바이더/모델명', messages=[...])` 패턴입니다.", "explanation": "이후 `model='gpt-4o-mini'`로만 바꾸면 OpenAI로 전환됩니다. `response.choices[0].message.content`로 응답을 읽는 방식도 동일합니다.", "difficulty": "easy", "takeaway": "model='프로바이더/모델명' 형식으로 코드 변경 없이 LLM을 전환합니다. 이것이 LiteLLM의 핵심 가치입니다." }, { "id": "docs-chainlit-1", "title": "[Chainlit] 스트리밍 챗봇 — Streamlit보다 간결하게", "task_description": "**상황**\nStreamlit 챗봇에 스트리밍(타이핑 효과)을 구현했는데, `st.write_stream`, generator 함수, `st.empty()` 등 여러 개념이 얽혀 코드가 복잡합니다.\n\n**문제**\nStreamlit은 rerun 기반이라 스트리밍 UI 구현이 장황합니다.\n\n**해결**\nChainlit으로 스트리밍 챗봇을 만드세요.\n\n`@cl.on_message` 데코레이터로 함수를 정의하고, `cl.Message(content='')`를 만든 뒤 `chain.astream()`으로 토큰을 받아 `msg.stream_token(chunk)`으로 전송하세요.", "expected_command": "@cl.on_message\nasync def main(message: cl.Message):\n msg = cl.Message(content='')\n async for chunk in chain.astream({'question': message.content}):\n await msg.stream_token(chunk)\n await msg.send()", "grading_conditions": ["@cl.on_message", "cl.Message", "chain.astream", "msg.stream_token", "msg.send()"], "hint": "`@cl.on_message` → `cl.Message(content='')` 생성 → `astream`으로 토큰 받기 → `stream_token` → `send()` 순서입니다.", "explanation": "Chainlit에서 `astream()`과 `stream_token()`을 조합하면 토큰 단위 스트리밍이 됩니다. Streamlit보다 훨씬 적은 코드로 동일 효과를 구현합니다. `chainlit run app.py`로 실행합니다.", "difficulty": "medium", "takeaway": "astream()으로 토큰을 받아 stream_token()으로 전송하는 패턴이 Chainlit 스트리밍의 전부입니다." }, { "id": "docs-redis-1", "title": "[Redis] 대화 히스토리 영속화 — 재시작해도 기억", "task_description": "**상황**\n```python\nstore = {} # 인메모리\ndef get_session_history(session_id):\n if session_id not in store:\n store[session_id] = ChatMessageHistory()\n return store[session_id]\n```\n서버를 재시작하니 모든 사용자의 대화 히스토리가 사라졌습니다. `store` 딕셔너리가 메모리에만 있었기 때문입니다.\n\n**문제**\n인메모리 히스토리는 프로세스 종료 시 소멸합니다.\n\n**해결**\n`get_session_history` 함수를 `RedisChatMessageHistory`를 사용하도록 수정하세요.\n- Redis URL: `'redis://localhost:6379'`", "expected_command": "def get_session_history(session_id: str):\n return RedisChatMessageHistory(session_id=session_id, url='redis://localhost:6379')", "grading_conditions": ["RedisChatMessageHistory", "session_id=session_id", "url='redis://localhost:6379'"], "hint": "`RedisChatMessageHistory(session_id=session_id, url='redis://...')` 패턴입니다.", "explanation": "함수 내부를 `RedisChatMessageHistory`로 교체하면 됩니다. `RunnableWithMessageHistory`는 그대로 두고 이 함수만 바꾸면 영속성을 얻습니다. Redis 서버는 `docker run -d -p 6379:6379 redis`로 실행하세요.", "difficulty": "easy", "takeaway": "get_session_history 함수 내부를 RedisChatMessageHistory로 교체하기만 하면 대화 히스토리가 재시작에도 유지됩니다." }, { "id": "docs-unstructured-1", "title": "[Unstructured] PDF·Word 등 실무 문서 파싱", "task_description": "**상황**\nDirectoryLoader로 `./docs` 폴더를 로드하려는데, `.md` 파일은 잘 됐지만 팀에서 공유받은 보고서는 `.pdf`, `.docx`, `.pptx`가 섞여 있습니다. 확장자마다 다른 로더를 써야 해 코드가 복잡해졌습니다.\n\n**문제**\n파일 형식마다 다른 로더를 쓰면 코드가 분기됩니다.\n\n**해결**\n`UnstructuredFileLoader`를 `loader_cls`로 지정해 `./docs` 폴더의 모든 파일을 형식에 상관없이 로드하세요. 결과를 `docs` 변수에 저장하세요.", "expected_command": "loader = DirectoryLoader('./docs', glob='**/*', loader_cls=UnstructuredFileLoader)\ndocs = loader.load()", "grading_conditions": ["DirectoryLoader", "./docs", "loader_cls=UnstructuredFileLoader", "loader.load()"], "hint": "`DirectoryLoader('폴더', glob='**/*', loader_cls=UnstructuredFileLoader)` 패턴입니다.", "explanation": "`UnstructuredFileLoader`는 확장자를 자동 감지해 적절한 파서를 선택합니다. `.pdf`, `.docx`, `.pptx`, `.jpg` 등을 동일 코드로 처리합니다. PDF에는 `pip install unstructured[pdf]`가 필요합니다.", "difficulty": "easy", "takeaway": "loader_cls=UnstructuredFileLoader 하나로 PDF·Word·PowerPoint 등 모든 문서 형식을 동일한 코드로 처리합니다." }, { "id": "docs-news-1", "title": "[뉴스 요약기 1] feedparser로 RSS 파싱", "task_description": "**프로젝트: 뉴스 자동 요약기**\n매일 아침 뉴스 사이트 5개를 직접 열어 읽습니다. 자동화하고 싶은데 사이트마다 HTML 구조가 달라 크롤링 코드가 각각 필요합니다.\n\n**문제**\nHTML 크롤링은 사이트 구조가 바뀌면 코드가 깨집니다. 뉴스 사이트 대부분이 RSS라는 표준 형식을 제공합니다.\n\n**해결**\n`feedparser`로 BBC 뉴스 RSS를 파싱하고, 최신 기사 5개의 요약을 `articles` 리스트에 저장하세요.\n\nFeed URL: `https://feeds.bbci.co.uk/news/rss.xml`", "expected_command": "import feedparser\nfeed = feedparser.parse('https://feeds.bbci.co.uk/news/rss.xml')\narticles = [entry.summary for entry in feed.entries[:5]]", "grading_conditions": ["feedparser.parse", "feed.entries", "entry.summary", "articles"], "hint": "`feedparser.parse(url)`로 파싱하면 `feed.entries`에 기사 목록이 들어있습니다.", "explanation": "RSS는 XML 기반 표준 형식이라 어느 사이트든 `feed.entries`로 동일하게 접근합니다. HTML 구조 변경에 영향을 받지 않습니다.", "difficulty": "easy", "takeaway": "RSS는 사이트 구조에 관계없이 feed.entries로 동일하게 접근하는 표준 형식입니다." }, { "id": "docs-news-2", "title": "[뉴스 요약기 2] map_reduce — 토큰 초과 없이 요약", "task_description": "**상황**\n기사 10개를 한꺼번에 요약하려고 `stuff` 방식을 썼더니:\n```\nError: This model's maximum context length is 128000 tokens.\nYour message resulted in 180000 tokens.\n```\n\n**문제**\n`stuff`는 모든 문서를 하나의 프롬프트에 넣어서 문서가 많으면 토큰을 초과합니다.\n\n**해결**\n`map_reduce`를 사용하세요. 각 기사를 개별 요약한 뒤 합칩니다.\n\n`articles` → `Document` 리스트로 변환 후 `map_reduce` 체인으로 요약하는 코드의 `???` 부분을 채우세요.\n\n```python\ndocs = [Document(page_content=a) for a in articles]\nchain = ???\nresult = chain.invoke(docs)\n```", "expected_command": "chain = load_summarize_chain(llm, chain_type='map_reduce')", "grading_conditions": ["load_summarize_chain", "llm", "chain_type='map_reduce'"], "hint": "`load_summarize_chain(llm, chain_type='...')` 패턴입니다.", "explanation": "`map_reduce`는 각 기사를 개별 요약(map)하고, 개별 요약들을 다시 합쳐 최종 요약(reduce)을 만듭니다. 아무리 기사가 많아도 토큰 초과가 발생하지 않습니다.", "difficulty": "medium", "takeaway": "map_reduce는 각 문서를 개별 요약(map)한 뒤 합치는(reduce) 방식으로 토큰 제한 없이 여러 문서를 요약합니다." }, { "id": "docs-news-3", "title": "[뉴스 요약기 3] Airflow — 자는 동안도 자동 실행", "task_description": "**상황**\nfeedparser + LangChain 요약 스크립트가 잘 동작하는데, 매일 아침 직접 터미널에 `python news_summary.py`를 입력해야 합니다. 주말에 깜빡하면 그날 요약이 없습니다.\n\n**문제**\n수동 실행은 반드시 빠뜨리는 날이 생깁니다.\n\n**해결**\n`run_news_summary` 함수를 매일 자동 실행하는 Airflow DAG를 정의하세요.\n\n- DAG ID: `daily_news_summary`, 스케줄: `@daily`\n- task_id: `'summarize'`, python_callable: `run_news_summary`", "expected_command": "with DAG('daily_news_summary', schedule='@daily', start_date=datetime(2024, 1, 1)) as dag:\n task = PythonOperator(task_id='summarize', python_callable=run_news_summary)", "grading_conditions": ["daily_news_summary", "@daily", "PythonOperator", "task_id='summarize'", "python_callable=run_news_summary"], "hint": "`with DAG(...) as dag:` 블록 안에 PythonOperator를 정의합니다.", "explanation": "이 DAG는 매일 자정에 `run_news_summary`를 자동 실행합니다. Airflow 스케줄러가 실행하므로 담당자가 없어도, 자는 중에도 동작합니다.", "difficulty": "medium", "takeaway": "RAG 챗봇에서 배운 Airflow DAG + PythonOperator 패턴이 뉴스 요약기 자동화에 그대로 재사용됩니다." }, { "id": "docs-news-4", "title": "[뉴스 요약기 4] st.expander — 긴 목록을 깔끔하게", "task_description": "**상황**\n요약된 기사 10개를 `st.write()`로 나열했더니 페이지 스크롤이 끝이 없고, 원하는 기사를 찾기 어렵습니다.\n\n**문제**\n모든 내용을 펼쳐놓으면 한눈에 보기 어렵습니다.\n\n**해결**\n`summaries` 리스트(`{'title': ..., 'content': ...}`)를 `st.expander`로 렌더링하세요. 제목만 보이고 클릭 시 요약이 펼쳐지도록.", "expected_command": "for s in summaries:\n with st.expander(s['title']):\n st.write(s['content'])", "grading_conditions": ["st.expander", "s['title']", "st.write", "s['content']"], "hint": "`with st.expander(제목):` 블록 안에 내용을 넣으면 클릭 시 펼쳐집니다.", "explanation": "`st.expander`는 기본적으로 접혀 있고 클릭하면 내용이 나옵니다. 항목이 많을 때 화면을 깔끔하게 유지하면서도 상세 내용을 볼 수 있게 합니다.", "difficulty": "easy", "takeaway": "st.expander는 항목이 많을 때 제목만 보여주고 클릭 시 내용을 펼치는 Streamlit의 핵심 UI 컴포넌트입니다." }, { "id": "docs-sql-1", "title": "[Text-to-SQL 1] SQLDatabase — DB 스키마를 LLM에 알려주기", "task_description": "**프로젝트: Text-to-SQL 생성기**\n팀원들이 \"이 달 매출 상위 5개 상품이 뭐에요?\"를 물어볼 때마다 SQL을 대신 써줘야 합니다. 하루에도 수십 번입니다.\n\n---\n\n**단계 1**: LLM에게 DB 구조를 알려줘야 올바른 SQL이 나옵니다.\n`SQLDatabase`로 `./shop.db`에 연결하세요. (스키마를 자동으로 읽어 LLM 프롬프트에 주입합니다)\n\n결과를 `db` 변수에 저장하세요.", "expected_command": "db = SQLDatabase.from_uri('sqlite:///./shop.db')", "grading_conditions": ["SQLDatabase.from_uri", "sqlite", "shop.db"], "hint": "`SQLDatabase.from_uri('sqlite:///경로')` 패턴입니다. 슬래시가 3개입니다.", "explanation": "`SQLDatabase.from_uri()`는 연결과 동시에 테이블명·컬럼 스키마를 읽어 LLM 프롬프트에 자동 주입할 준비를 합니다. LLM이 존재하지 않는 컬럼을 쓰는 실수를 줄여줍니다.", "difficulty": "easy", "takeaway": "SQLDatabase.from_uri()가 DB 스키마를 자동으로 읽어 LLM이 존재하는 테이블·컬럼만 참조하도록 유도합니다." }, { "id": "docs-sql-2", "title": "[Text-to-SQL 2] create_sql_query_chain — 자연어 → SQL", "task_description": "**상황**\nLLM에게 직접 \"SQL 써줘\"라고 했더니:\n```sql\nSELECT * FROM sales ORDER BY revenue DESC LIMIT 5\n```\n`sales` 테이블이 없고 `orders`가 맞는데, LLM이 DB 스키마를 몰라서 틀린 테이블명을 썼습니다.\n\n**문제**\nLLM이 실제 DB 구조를 모르면 존재하지 않는 테이블/컬럼을 씁니다.\n\n**해결**\n`create_sql_query_chain`은 `db` 스키마를 자동 주입합니다.\n`'가장 많이 팔린 상품 5개는?'`으로 실행해 `sql` 변수에 저장하세요.", "expected_command": "chain = create_sql_query_chain(llm, db)\nsql = chain.invoke({'question': '가장 많이 팔린 상품 5개는?'})", "grading_conditions": ["create_sql_query_chain", "llm", "db", "chain.invoke", "question"], "hint": "`create_sql_query_chain(llm, db)`로 체인 생성 후, `{'question': '...'}` 딕셔너리로 실행합니다.", "explanation": "chain 내부적으로 DB의 테이블·컬럼 정보를 프롬프트에 포함해 LLM이 올바른 스키마만 참조하도록 유도합니다.", "difficulty": "medium", "takeaway": "create_sql_query_chain은 SQLDatabase에서 스키마를 자동 주입해 LLM이 올바른 SQL을 생성하도록 합니다." }, { "id": "docs-sql-3", "title": "[Text-to-SQL 3] ValidSQL Guard — 잘못된 SQL 실행 전 차단", "task_description": "**상황**\nLLM이 생성한 SQL을 바로 `db.run()`에 넘겼더니:\n```\nOperationalError: no such column: product_name\n```\n문법 오류가 있는 SQL이 실제 DB에 실행됐습니다.\n\n**문제**\nLLM은 가끔 문법 오류가 있는 SQL을 생성합니다. 실행 전에 걸러야 합니다.\n\n**해결**\nGuardrails의 `ValidSQL`로 SQL 유효성 검증 Guard를 만드세요.\n- 실패 시: `'reask'` (올바른 SQL을 다시 요청)", "expected_command": "guard = Guard().use(ValidSQL(on_fail='reask'))", "grading_conditions": ["Guard()", "ValidSQL", "on_fail='reask'"], "hint": "`Guard().use(ValidSQL(on_fail='...'))` 패턴입니다.", "explanation": "`on_fail='reask'`는 SQL 검증 실패 시 LLM에게 자동으로 재작성을 요청합니다. `exception`과 달리 사용자는 오류를 보지 못하고 자동으로 수정됩니다.", "difficulty": "medium", "takeaway": "RAG 챗봇에서 배운 Guard().use() 패턴이 SQL 검증에도 그대로 적용됩니다. on_fail='reask'로 사용자 노출 없이 자동 수정합니다." }, { "id": "docs-sql-4", "title": "[Text-to-SQL 4] FastAPI — 팀 전체가 자연어로 DB 조회", "task_description": "**상황**\n스크립트로 돌리면 나만 쓸 수 있습니다. 팀원들도 자연어로 DB를 조회할 수 있게 API로 노출하고 싶습니다.\n\n**문제**\n스크립트는 공유가 불가능합니다. API 엔드포인트로 노출해야 합니다.\n\n**해결**\n아래 `???`를 채워 POST `/sql` 엔드포인트를 완성하세요.\n\n```python\n@app.post('/sql')\nasync def generate_sql(q: Query):\n sql = ???\n result = db.run(sql)\n return {'sql': sql, 'result': result}\n```", "expected_command": "sql = chain.invoke({'question': q.question})", "grading_conditions": ["chain.invoke", "question", "q.question"], "hint": "Pydantic 모델 `q`의 `.question` 속성으로 자연어 질문을 가져옵니다.", "explanation": "`q.question`으로 자연어 질문을 받아 체인에 넘깁니다. `db.run(sql)`로 SQL 실행 결과도 함께 반환해 프론트엔드에서 바로 표 형태로 보여줄 수 있습니다.", "difficulty": "easy", "takeaway": "RAG 챗봇 FastAPI 패턴과 동일합니다. q.question으로 질문을 받아 chain.invoke()에 넘기는 구조가 재사용됩니다." }, { "id": "docs-brain-1", "title": "[Second Brain 1] DirectoryLoader — 노트 300개를 한 번에", "task_description": "**프로젝트: 개인 Second Brain**\n내 Obsidian 노트가 300개가 넘습니다. \"3개월 전에 공부했던 그 내용이 어느 파일이었더라?\"를 찾으려면 파일을 하나씩 열어봐야 합니다.\n\n---\n\n**단계 1**: 300개 파일을 하나씩 복사해 RAG에 넣을 수 없습니다.\n`DirectoryLoader`로 `./notes` 폴더 안의 모든 `.md` 파일(하위 폴더 포함)을 한 번에 로드하세요.\n\n결과를 `docs` 변수에 저장하세요.", "expected_command": "loader = DirectoryLoader('./notes', glob='**/*.md')\ndocs = loader.load()", "grading_conditions": ["DirectoryLoader", "./notes", "glob='**/*.md'", "loader.load()"], "hint": "`DirectoryLoader('폴더', glob='패턴')`으로 로더를 만들고 `.load()`로 실행합니다.", "explanation": "`**/*.md`는 `./notes`와 모든 하위 폴더에서 `.md` 파일을 재귀 검색합니다. 300개 파일이 한 번의 `loader.load()` 호출로 모두 `Document` 객체로 변환됩니다.", "difficulty": "easy", "takeaway": "RAG 챗봇에서 배운 DirectoryLoader + glob 패턴이 Second Brain 노트 로드에 그대로 재사용됩니다." }, { "id": "docs-brain-2", "title": "[Second Brain 2] metadata['source'] — 검색 결과가 어느 파일인지", "task_description": "**상황**\nRAG 검색 결과가 나왔는데 \"이게 어느 노트에서 왔지?\" 원본 파일을 찾을 수 없습니다. 내용만 보이고 출처가 없으니 원본을 열어 맥락을 더 볼 수가 없습니다.\n\n**문제**\nDocument 객체에 출처 정보가 있는데 사용하지 않고 있습니다.\n\n**해결**\n`docs` 리스트를 순회하며 각 문서의 파일 경로(출처)와 내용 길이를 출력하는 코드를 작성하세요.", "expected_command": "for doc in docs:\n print(doc.metadata['source'], len(doc.page_content))", "grading_conditions": ["doc.metadata['source']", "len(doc.page_content)"], "hint": "파일 경로는 `doc.metadata['source']`, 내용은 `doc.page_content`에 있습니다.", "explanation": "`doc.metadata['source']`에 파일 경로가 저장됩니다. 검색 결과를 보여줄 때 이 값을 함께 표시하면 사용자가 원본 파일로 이동할 수 있습니다.", "difficulty": "easy", "takeaway": "doc.metadata['source']에 원본 파일 경로가 저장됩니다. RAG 검색 결과에 출처를 표시할 때 항상 이 값을 활용합니다." }, { "id": "docs-brain-3", "title": "[Second Brain 3] FileSensor — 새 노트 추가 시 자동 재인덱싱", "task_description": "**상황**\n새 노트를 추가했는데 검색이 안 됩니다. `python reindex.py`를 매번 수동으로 실행해야 합니다. 노트를 추가하고 재인덱싱을 잊어버리는 날이 생깁니다.\n\n**문제**\n새 파일 추가를 감지해 자동으로 재인덱싱을 트리거해야 합니다.\n\n**해결**\nAirflow `FileSensor`로 `./notes` 폴더에 새 `.md` 파일이 생기면 감지하는 태스크를 만드세요.\n- task_id: `'wait_for_new_note'`\n- filepath: `'./notes/*.md'`\n- poke_interval: `300` (5분마다 확인)", "expected_command": "wait_for_new_note = FileSensor(\n task_id='wait_for_new_note',\n filepath='./notes/*.md',\n poke_interval=300\n)", "grading_conditions": ["FileSensor", "task_id='wait_for_new_note'", "filepath", "poke_interval=300"], "hint": "`FileSensor(task_id='...', filepath='...', poke_interval=초)` 패턴입니다.", "explanation": "`FileSensor`는 지정한 파일이 존재할 때까지 주기적으로 확인합니다. 파일이 감지되면 다음 태스크(재인덱싱)가 자동 실행됩니다. `poke_interval=300`은 5분마다 확인합니다.", "difficulty": "medium", "takeaway": "FileSensor는 파일 추가를 감지해 후속 Airflow 태스크를 트리거합니다. @daily와 달리 이벤트 기반으로 동작합니다." }, { "id": "docs-brain-4", "title": "[Second Brain 4] Streamlit — 노트 검색 UI", "task_description": "**상황**\n지금은 터미널에서 `retriever.invoke('검색어')`를 직접 입력해야 합니다. 팀원들이 쓸 수 없습니다.\n\n**문제**\nCLI는 개발자만 쓸 수 있습니다.\n\n**해결**\nStreamlit으로 검색 UI를 만드세요.\n- 폴더 경로 텍스트 입력 (`folder`, 기본값 `'./notes'`)\n- 검색어 텍스트 입력 (`query`)\n- '검색' 버튼 클릭 시 `retriever.invoke(query)` 실행 → `results`에 저장", "expected_command": "folder = st.text_input('노트 폴더 경로', value='./notes')\nquery = st.text_input('검색할 내용')\nif st.button('검색'):\n results = retriever.invoke(query)", "grading_conditions": ["st.text_input", "st.button('검색')", "retriever.invoke(query)"], "hint": "`st.text_input`, `st.button`, `retriever.invoke` 세 가지를 순서대로 씁니다.", "explanation": "`if st.button('검색'):` 블록 안에 무거운 작업을 넣으면 버튼을 눌렀을 때만 실행됩니다. 폴더 경로가 바뀌면 `st.cache_resource`를 clear해 retriever를 재초기화하는 로직을 추가하면 완성입니다.", "difficulty": "easy", "takeaway": "st.text_input + st.button + retriever.invoke 조합은 RAG 챗봇에서 배운 Streamlit 패턴이 Second Brain UI에 그대로 적용된 예입니다." } ]