71일차 자료 추가(답 제외)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-23 06:50:50 +09:00
1 parent 505dcd313f
commit 176e195dc2
62 files changed
+6288

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
runs/
tools/.cache/
my-work/*.json
+197
View File
@@ -0,0 +1,197 @@
# AGENTS.md · n8n RAG(검색·응답) 작업 규칙 (71일차 실습 폴더)
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 70일차 규칙과 같고, 3절이 오늘 만드는 **색인 · 검색 · 규정 Q&A의 계약**, 5절에 **벡터 저장소 · 임베딩 노드**가 더해졌습니다.
## 1. 작업 순서 (항상 이 순서)
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. RAG면 **문서 → 청크 → 검색 → 답** 각 단계의 입력·결과도 함께 보여 준다.
2. 워크플로우 JSON을 `my-work/<이름>.json`에 쓴다. 이미 있으면 그 파일을 고친다 (새 파일을 만들지 않는다).
3. `uv run tools/flow.py check my-work/<이름>.json` → 오류(✘)가 없을 때까지 고친다.
4. 끝나면 한국어로 짧게 보고한다: 바꾼 노드, check 결과, 그리고 사람이 실행할 명령 두 줄.
```bash
uv run tools/flow.py push my-work/<이름>.json
uv run tools/flow.py test cases/<요청에 나온 테스트 파일>.json -v
```
5. **push와 test는 사람이 실행해 결과를 확인한다.** 사람이 "네가 push·test까지 해"라고 하면 직접 실행해도 된다 (push는 약 10초, n8n이 잠깐 재시작됨).
6. 사람이 실패 보고서(`runs/*.json`)를 알려 주면 실패한 케이스의 요청·응답을 읽고 원인을 설명한 뒤 고친다. **같은 곳을 3번 고쳐도 실패하면 멈추고** 다른 방법을 제안한다.
7. 문서(`my-work/*.md`)를 써 달라는 요청이면 워크플로우를 만들지 않고 문서만 쓴다.
## 2. 환경 (바꾸지 말 것)
| 항목 | 값 |
| --- | --- |
| n8n | 2.42.6, 65일차 공식 설치 `~/day65-n8n` (Docker 프로젝트 `day65-n8n`, 컨테이너 보통 `day65-n8n-n8n-1`), 브라우저 http://localhost:5678 |
| n8n 켜고 끄기 | `~/aio-01d4-stu/Last3Mon/65/n8n`에서 `docker compose start/stop` (사람이 함, Ollama 컨테이너는 없음) |
| 게시된 웹훅 주소 | 밖에서 `http://localhost:5678/webhook/<path>`, **n8n 안에서도** `http://localhost:5678/webhook/<path>` (같은 컨테이너) |
| OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`, 모델 `gpt-4.1-mini` |
| 다른 Credential | 쓰지 않음 (Ollama 등 없음, LLM은 모두 위 OpenAI Credential) |
| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**71일차 판** `Last3Mon/71/data-api`: FAQ · 접수 현황 · 규정 문서) |
| 임베딩 | OpenAI `text-embedding-3-large` (같은 Credential) |
| 벡터 저장소 | n8n **메모리** 저장소, 이름(memoryKey) `my_policy_docs`. **n8n이 재시작되면(push 할 때마다) 지워진다** → 색인을 다시 (`call my-index`) |
## 3. 오늘 만드는 것의 계약 (요청에 다른 말이 없으면 이대로)
| 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 |
| --- | --- | --- | --- |
| 색인 | `my-work/index.json` · `POST /webhook/my-index` | `chunking`: `"section"`(기본) 또는 글자 수(숫자) | `{"indexed_chunks", "chunking"}` |
| 검색 | `my-work/search.json` · `POST /webhook/my-search` | `question`(필수), `top_k`(기본 3) | `{"question", "hits": [{"rank", "source", "title", "score", "text"}], "top_score", "indexed"}` |
| 규정 Q&A | `my-work/policy-qa.json` · `POST /webhook/my-policy-qa` | `question`(필수), `min_score`(기본 0.33) | `{"route", "answer", "sources", "cited", "top_score"}` |
- 규정 문서: `GET /docs` → `{"docs": [{"name", "title"}]}` 8개, `GET /docs/<name>` → `{"name", "title", "text"}` (마크다운, `## ` 섹션). `name`이 `source`가 된다 (예: `password-policy`).
- `route`: `자동 답변` 또는 `담당자 연결`. 답 안의 출처는 문서 제목 `[비밀번호·계정 보안 규정]`처럼 보여 준다. `cited`는 답이 실제로 인용한 문서 `name` 목록.
- 관련도 기준: 검색 1등 점수(`top_score`)가 `min_score`보다 낮으면 LLM을 부르지 않고 `담당자 연결` (수업 준비 측정: 규정 질문 0.46~0.62, 규정 밖 0.20~0.26).
## 4. 워크플로우 파일 규칙
```json
{
"name": "71 Policy QA (my)",
"nodes": [ ... ],
"connections": { ... },
"settings": { "executionOrder": "v1" }
}
```
- 파일 하나에 워크플로우 하나. `id`는 쓰지 않아도 된다 (`push`가 파일 이름으로 정한다).
- 노드 `name`은 영어로, 워크플로우 안에서 겹치지 않게. 노드마다 `type`, `typeVersion`, `position: [x, y]`, `parameters`가 필요하다.
- **API 키를 JSON에 쓰지 않는다.** LLM 노드는 위 Credential id만 쓴다.
- 웹훅은 `POST`, `responseMode: "responseNode"`, 결과는 Respond to Webhook 노드로 보낸다.
- 요청 본문은 `$json.body.question`처럼 `body` 아래에 있다.
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
아래 `type`/`typeVersion`을 그대로 쓴다. 다른 노드가 필요하면 써도 되지만 `flow.py check`가 이 n8n에 있는지 확인한다.
### Webhook · If · Respond to Webhook
```json
{"name": "Question webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [0, 300],
"parameters": {"httpMethod": "POST", "path": "my-policy-qa", "responseMode": "responseNode", "options": {}}}
```
```json
{"name": "Has question?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [200, 300],
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2},
"conditions": [{"id": "c1", "leftValue": "={{ $json.body.question }}", "rightValue": "",
"operator": {"type": "string", "operation": "notEmpty", "singleValue": true}}], "combinator": "and"}, "options": {}}}
```
- If 출력 0번이 true, 1번이 false. 조건을 둘 넣으면 `combinator: "and"`. 불리언은 `"leftValue": "={{ $json.passed }}", "operator": {"type": "boolean", "operation": "true", "singleValue": true}`.
```json
{"name": "Send result", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [2600, 300],
"parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}}}
```
- 오류 응답: `"responseBody": "{ \"error\": \"question is required\" }"`, `"options": {"responseCode": 400}`
### HTTP Request · Split Out (문서 가져오기)
```json
{"name": "List docs", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [200, 300], "parameters": {"url": "http://data-api:8800/docs", "options": {}}}
```
```json
{"name": "One item per doc", "type": "n8n-nodes-base.splitOut", "typeVersion": 1, "position": [400, 300], "parameters": {"fieldToSplitOut": "docs", "options": {}}}
```
```json
{"name": "Get doc", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [600, 300], "parameters": {"url": "=http://data-api:8800/docs/{{ $json.name }}", "options": {}}}
```
### Code (청크 만들기 · 결과 모양)
```json
{"name": "Make chunks", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [800, 300],
"parameters": {"jsCode": "const chunks = [];\nfor (const item of $input.all()) {\n const { name, title, text } = item.json;\n text.split(/\\n(?=## )/).slice(1).forEach((piece, i) => chunks.push({ json: { source: name, title, chunk: i + 1, text: `${title}\\n${piece}` } }));\n}\nreturn chunks;"}}
```
- 여러 항목을 돌려주면 다음 노드가 항목마다 처리한다. 청크 글 앞에 문서 제목을 붙이면 검색이 문서를 더 잘 찾는다.
- Code 노드 안에는 `URL` 클래스가 없다.
### Vector Store (메모리) · Embeddings · Loader — 색인
```json
{"name": "Store chunks", "type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory", "typeVersion": 1.3, "position": [1020, 300],
"parameters": {"mode": "insert", "memoryKey": {"__rl": true, "mode": "list", "value": "my_policy_docs", "cachedResultName": "my_policy_docs"}, "clearStore": true}}
```
```json
{"name": "Embeddings", "type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi", "typeVersion": 1.2, "position": [960, 520],
"parameters": {"model": "text-embedding-3-large", "options": {}}, "credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}}
```
```json
{"name": "Chunk loader", "type": "@n8n/n8n-nodes-langchain.documentDefaultDataLoader", "typeVersion": 1.1, "position": [1120, 520],
"parameters": {"jsonMode": "expressionData", "jsonData": "={{ $json.text }}", "textSplittingMode": "simple",
"options": {"metadata": {"metadataValues": [{"name": "source", "value": "={{ $json.source }}"}, {"name": "title", "value": "={{ $json.title }}"}]}}}}
```
- `clearStore: true`: 색인할 때마다 비우고 다시 넣는다 (같은 청크가 두 번 들어가지 않게).
- Loader의 `textSplittingMode: "simple"`은 거의 나누지 않는다. **청크는 앞의 Code가 정한다.**
- 색인 뒤 개수는 Code에서 `$('Make chunks').all().length`로 센다.
### Vector Store (메모리) — 검색
```json
{"name": "Search policies", "type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory", "typeVersion": 1.3, "position": [420, 300],
"parameters": {"mode": "load", "memoryKey": {"__rl": true, "mode": "list", "value": "my_policy_docs", "cachedResultName": "my_policy_docs"},
"prompt": "={{ $json.body.question }}", "topK": "={{ $json.body.top_k ?? 3 }}", "includeDocumentMetadata": true},
"alwaysOutputData": true}
```
- 결과는 항목 여러 개: `$json.document.pageContent`(글), `$json.document.metadata.source`, `$json.score`(0~1, 클수록 가까움). 같은 `Embeddings` 노드를 `ai_embedding`으로 연결한다.
- `alwaysOutputData: true`: 저장소가 비어도 빈 항목 하나가 나와 워크플로우가 멈추지 않는다. 다음 Code에서 `item.json.document`가 있는 항목만 쓴다.
### AI Agent + OpenAI chat model (근거로 답하기, 질문 다시 쓰기)
```json
{"name": "Answer from policies", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 3.1, "position": [1080, 200],
"parameters": {"promptType": "define", "text": "=질문: {{ $json.question }}\n\n규정 발췌:\n{{ $json.context }}",
"options": {"systemMessage": "Answer only from the numbered policy excerpts ... write the excerpt number like [2] ...", "maxIterations": 2}}}
```
```json
{"name": "OpenAI chat model", "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", "typeVersion": 1.2, "position": [1040, 420],
"parameters": {"model": {"__rl": true, "mode": "id", "value": "gpt-4.1-mini"}, "options": {"temperature": 0}},
"credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}}
```
- Agent의 결과는 `$json.output`(글자). 발췌 번호 `[2]`는 다음 Code에서 문서 제목으로 바꾼다.
### Edit Fields (Set) — 담당자 연결
```json
{"name": "Not in policies", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [1080, 520],
"parameters": {"assignments": {"assignments": [
{"id": "a0", "name": "route", "value": "담당자 연결", "type": "string"},
{"id": "a1", "name": "answer", "value": "규정에서 답을 찾지 못했습니다. IT 문의 창구로 연결해 드릴게요.", "type": "string"},
{"id": "a2", "name": "cited", "value": "={{ [] }}", "type": "array"}]}, "options": {}}}
```
## 6. 연결 (`connections`)
```json
{
"Make chunks": {"main": [[{"node": "Store chunks", "type": "main", "index": 0}]]},
"Embeddings": {"ai_embedding": [[{"node": "Store chunks", "type": "ai_embedding", "index": 0}]]},
"Chunk loader": {"ai_document": [[{"node": "Store chunks", "type": "ai_document", "index": 0}]]},
"Relevant?": {"main": [[{"node": "Answer from policies", "type": "main", "index": 0}], [{"node": "Not in policies", "type": "main", "index": 0}]]},
"OpenAI chat model": {"ai_languageModel": [[{"node": "Answer from policies", "type": "ai_languageModel", "index": 0}]]}
}
```
- 키는 **보내는 노드 이름**, 안의 `node`는 받는 노드 이름. If의 `main`은 `[ [true 쪽], [false 쪽] ]`.
- Chat model은 `ai_languageModel`, Embeddings는 `ai_embedding`, Loader는 `ai_document`로 연결한다 (`main` 아님).
## 7. 표현식 주의
- 표현식은 `=`로 시작한다: `"={{ $json.body.question }}"`. `=`가 없으면 그냥 글자로 남는다.
- `{{ }}` 안에서 객체를 만들 때 `}}`가 붙지 않게 띄어 쓴다: `"={{ JSON.stringify({ draft: $json.draft }) }}"`
- 다른 노드 뒤에서 웹훅 입력은 `$('Question webhook').first().json.body`로 읽는다 (`$json`은 바로 앞 노드 결과).
## 8. 테스트 파일 (`cases/*.json`)
```json
{
"webhook": "my-search",
"before": [{"webhook": "my-index", "request": "requests/index-section.json"}],
"cases": [
{"name": "비밀번호 길이", "request": "requests/q-password-length.json",
"expect": {"equals": {"hits.0.source": "password-policy"}, "contains": {"hits.0.text": ["12자"]}}},
{"name": "직접 쓴 질문", "body": {"question": "", "top_k": 3}, "expect": {"status": 400}},
{"name": "다른 웹훅으로", "webhook": "my-policy-qa", "body": {"question": "점심 메뉴"}, "expect": {"equals": {"route": "담당자 연결"}}}
]
}
```
- `before`: 케이스보다 먼저 한 번 부른다 (메모리 저장소를 다시 채우는 색인). `--no-before`면 건너뛴다 (내가 다른 설정으로 색인해 둔 것을 그대로 쓸 때).
- 필드 이름은 점으로 내려간다: `hits.0.source` = `hits` 첫 항목의 `source`.
- `equals`: 값이 같아야 함 · `contains`: 목록 중 **하나라도** 들어 있어야 함 (배열·객체는 JSON 글자로 바꿔 찾음) · `not_contains`: 하나도 없어야 함 · `status`: HTTP 코드(기본 200)
- `uv run tools/flow.py test <파일> -v --repeat 3`: LLM 답은 매번 조금씩 다르므로 3번 이상 돌려 본다. `-v`(`--verbose`)는 케이스마다 보낸 요청 JSON과 받은 응답 JSON을 화면에 보여 준다 (보고서 `runs/*.json`에도 같은 것이 남음). `call ... -v`도 보낸 요청 JSON을 응답 위에 함께 보여 준다.
- **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다.
## 9. 하지 말 것
- `tools/`, `data-api/`, `requests/`, `reference/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만). 규정 문서(`data-api/data/docs`)를 고쳐서 테스트를 통과시키지 않는다.
- 답을 검색 결과 밖의 지식으로 쓰지 않는다 (규정에 없으면 없다고 하고 담당자 연결).
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다.
- `.env`나 API 키를 읽어서 출력하지 않는다.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+206
View File
@@ -0,0 +1,206 @@
# Lab 01 · 정답표, 색인, 검색
- 관련 강의: [1단계 · RAG와 색인](../../Lecture/71/01-STAGE1-RAG-AND-INDEX.md)
- 소요 시간: 약 40분 (예제 1 10분 · 예제 2 15분 · 과제 15분)
- 준비: [실습 준비](../../Lecture/71/00-DAY71-PREP.md)의 `uv run tools/flow.py doctor` → `ready` (데이터 API가 **71일차 판**이어야 `/docs`가 보임)
- 목표: 검색을 만들기 **전에** "어느 질문이 어느 문서에서 답을 찾아야 하는지" 정답표를 만들고, 규정 문서를 색인·검색하는 워크플로우를 내가 쓴 요청문으로 만든다.
```mermaid
flowchart LR
E1["예제 1<br/>정답표<br/>(사람)"] --> E2["예제 2<br/>색인 + 검색"] --> T["과제<br/>섹션 출처 · top_k"]
```
> 이 실습에는 **복사해 붙일 요청문이 없습니다.** 막히면 `▶` 를 하나씩 펼칩니다.
## 예제 1 · 정답표 (10분, 에이전트 없이)
`cp templates/RAG-EVAL-TEMPLATE.md my-work/RAG-EVAL.md` 후 1절을 **직접** 채웁니다. 문서는 `data-api/data/docs/`에 있습니다 (또는 `curl -s localhost:8800/docs`).
| 질문 |
| --- |
| 비밀번호는 몇 자 이상이어야 하나요? |
| 암호를 얼마나 자주 바꿔야 해? |
| 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? |
| 첨부 파일이 너무 커서 메일이 안 보내져요 |
| 생성형 AI에 고객 자료를 넣어도 되나요? |
| 연차 휴가는 며칠이에요? |
<details><summary>▶ 1단계 · 왜 먼저 정답표를</summary>
검색 결과를 보고 "그럴듯하다"고 판단하면, 틀린 문서가 나와도 알아채기 어렵습니다. 먼저 사람이 정답(문서 · 섹션 · 답 문구)을 정해 두면 검색이 맞았는지 **숫자로** 셀 수 있습니다 (Lab 03에서 24문항으로).
</details>
<details><summary>▶ 2단계 · 답이 없는 질문</summary>
마지막 질문의 답은 어느 문서에 있나요? "없음"도 정답입니다. 이런 질문에 검색은 무엇을 돌려줄까요? Lab 02에서 다룹니다.
</details>
<details><summary>▶ 정답 (직접 채운 뒤에 펼치기)</summary>
| 질문 | 문서 | 섹션 | 답 문구 | 원문 |
| --- | --- | --- | --- | --- |
| 비밀번호는 몇 자 이상이어야 하나요? | `password-policy` | 1. 비밀번호 만들기 | 12자 이상 | 비밀번호는 12자 이상으로 만든다. |
| 암호를 얼마나 자주 바꿔야 해? | `password-policy` | 2. 변경 주기 | 90일마다 | 비밀번호는 90일마다 바꾼다. |
| 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? | `device-issue-return` | 3. 반납 | 5영업일 이내 | 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다. |
| 첨부 파일이 너무 커서 메일이 안 보내져요 | `email-and-documents` | 2. 첨부 파일 | 25MB, 사내 드라이브 | 메일 첨부 파일은 25MB까지 보낼 수 있다. 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다. |
| 생성형 AI에 고객 자료를 넣어도 되나요? | `software-usage` | 3. 생성형 AI 서비스 | 입력하지 않는다 | 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다. |
| 연차 휴가는 며칠이에요? | **없음** | — | — | 규정 8개 어디에도 없음 |
- "암호"(질문)와 "비밀번호"(문서), "그만두면"(질문)과 "퇴사"(문서)처럼 **질문의 낱말이 문서에 없는** 것이 있습니다. 예제 2에서 검색이 이것을 찾는지 봅니다.
- 노트북 질문의 답은 "1. 지급과 교체"가 아니라 "3. 반납"에 있습니다. "노트북"이라는 낱말은 1절에만 나옵니다. Lab 03에서 이 차이가 실패를 만듭니다.
</details>
## 예제 2 · 색인과 검색 (15분)
**만들 것**: 두 워크플로우.
| 파일 | 하는 일 |
| --- | --- |
| `my-work/index.json` (`my-index`) | 규정 문서를 모두 가져와 **섹션(`## `)마다 청크** 하나 → 임베딩 → 메모리 저장소 `my_policy_docs`. 결과 `indexed_chunks` |
| `my-work/search.json` (`my-search`) | 질문과 가까운 청크 `top_k`(기본 3)개. 결과 `hits[]`에 순위·문서·제목·점수·글 |
계약은 [AGENTS.md](AGENTS.md) 3절, 노드는 5절. 기대 결과 `cases/lab01.json` (`before`가 먼저 색인합니다).
<details><summary>▶ 1단계 · 한 번에 하나씩</summary>
색인과 검색은 따로 만들고 따로 확인합니다. 색인을 먼저 push하고 `call my-index`로 청크 수를 봅니다. 섹션이 모두 몇 개인지 문서에서 세어 보면 결과가 맞는지 알 수 있습니다.
</details>
<details><summary>▶ 2단계 · 청크에 무엇을 붙일까</summary>
청크 글만 저장하면 검색 결과가 **어느 문서**에서 왔는지 모릅니다. 문서 이름·제목을 함께 저장하라고 적었나요? 청크 글 앞에 문서 제목을 붙이는 것도 검색에 도움이 됩니다 (AGENTS.md 5절 Code).
</details>
<details><summary>▶ 3단계 · 메모리 저장소의 함정</summary>
색인을 만든 뒤 검색을 push하면 n8n이 재시작되면서 저장소가 **비워집니다.** 검색 결과가 비면 먼저 `call my-index`. 저장소가 비었을 때 검색이 멈추지 않게 하는 설정도 AGENTS.md 5절에 있습니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 문서를 어디서 ② 청크를 무엇으로 나누나 ③ 청크와 함께 저장할 것 ④ 저장소 이름 ⑤ 결과 칸 이름 ⑥ 비었을 때 ⑦ 기대 결과**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
두 워크플로우를 만들어 줘. 계약은 AGENTS.md 3절, 노드는 5절.
1. my-work/index.json (웹훅 my-index)
- 문서: GET http://data-api:8800/____ 로 목록 → 문서마다 GET /docs/<name>
- 청크: Code 에서 본문을 "____" 마다 하나씩. 청크 글 앞에 ____ 를 붙인다.
- 함께 저장: ____ , ____ , 청크 번호
- 저장: 메모리 Vector Store (insert, memoryKey ____ , 매번 비우고) + OpenAI Embeddings ____
- 결과: { ____ , chunking }
2. my-work/search.json (웹훅 my-search)
- question 이 없으면 ____
- 같은 저장소에서 load, topK = 본문 top_k (없으면 ____ ), 메타데이터 포함
- 저장소가 비어도 멈추지 않게: ____
- 결과: { question, hits: [{ rank, ____ , ____ , score, text }], top_score, indexed }
기대 결과는 cases/lab01.json. 끝나면 check 결과와 내가 실행할 push · test 명령을 알려 줘.
```
채울 값은 모두 [AGENTS.md](AGENTS.md) 3절(계약)과 5절(노드)에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
두 워크플로우를 만들어 줘. 계약은 AGENTS.md 3절, 노드는 5절.
1. my-work/index.json (웹훅 my-index)
- 문서: GET http://data-api:8800/docs 로 목록 → 문서마다 GET /docs/<name>
- 청크: Code 에서 본문을 "## " 섹션마다 하나씩. 청크 글 앞에 문서 제목을 붙인다.
본문 chunking 이 숫자면 그 글자 수로 자른다 (기본 section, Lab 03에서 씀).
- 함께 저장: source(문서 name), title, 청크 번호 (Default Data Loader 메타데이터)
- 저장: 메모리 Vector Store (insert, memoryKey my_policy_docs, clearStore true) + OpenAI Embeddings text-embedding-3-large
- 결과: { indexed_chunks, chunking }
2. my-work/search.json (웹훅 my-search)
- question 이 없으면 400
- 같은 저장소에서 load, topK = 본문 top_k (없으면 3), 메타데이터 포함
- 저장소가 비어도 멈추지 않게: Always Output Data, 다음 Code 에서 document 가 있는 항목만
- 결과: { question, hits: [{ rank, source, title, score(소수 셋째 자리), text }], top_score, indexed }
기대 결과는 cases/lab01.json. 끝나면 check 결과와 내가 실행할 push · test 명령을 알려 줘.
```
- 보내기 전에 내 뼈대와 다른 곳을 찾아 봅니다. 빠뜨린 곳이 에이전트가 **멋대로 정하는 곳**입니다.
- 에이전트가 답하면 바로 push하지 말고, 1절 작업 순서대로 "입력 · 노드 순서 · 결과 모양"을 먼저 보여 주는지 확인합니다.
</details>
```bash
uv run tools/flow.py push my-work/index.json my-work/search.json
uv run tools/flow.py call my-index requests/index-section.json -v
uv run tools/flow.py call my-search requests/q-password-length.json -v
uv run tools/flow.py test cases/lab01.json -v
```
**관찰**: 예제 1의 정답표와 검색 1등이 같은가요? "연차 휴가" 질문의 1등 점수는 다른 질문보다 높나요, 낮나요? 2절 표에 적습니다.
## 과제 · 섹션까지 출처, 개수는 요청에서 (15분)
팀장이 요청했습니다.
> "답에 문서 이름만 나오면 긴 문서에서는 어디를 봐야 할지 몰라요. 섹션 제목까지 주세요. 그리고 몇 개를 찾을지는 부르는 쪽에서 정하게 해 주세요."
기대 결과 `cases/lab01-challenge.json`: `hits[].section`에 섹션 제목(예: `2. 변경 주기`), `top_k` 1 · 5.
<details><summary>▶ 1단계 · 섹션 제목은 언제 정하나</summary>
검색할 때 청크 글에서 찾아낼 수도 있지만, **색인할 때** 저장해 두면 검색은 꺼내기만 하면 됩니다. 색인과 검색 두 파일을 모두 고쳐야 합니다.
</details>
<details><summary>▶ 2단계 · 확인</summary>
색인을 고쳤으면 다시 색인해야 새 정보가 들어갑니다 (`before`가 해 줌). 원래 테스트 `cases/lab01.json`도 그대로 통과해야 합니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 파일 둘 ② 섹션 제목을 언제 · 어디에 저장하나 ③ 결과에 더할 칸 ④ 없을 때 ⑤ `top_k`를 어디서 받나 ⑥ 두 테스트 모두 통과**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/index.json 의 ____ (Code) 에서 청크마다 ____ (그 청크의 "## " 제목 글자)를 정하고,
Loader 메타데이터에 ____ 을 더해 줘.
my-work/search.json 의 hits 에 ____ 을 더해 줘 (없으면 ____ ).
top_k 는 ____ 에서 받는다 (기본 ____ ).
____ , ____ 둘 다 통과.
```
채울 값은 위 과제 설명, [AGENTS.md](AGENTS.md) 5절(Loader 메타데이터)에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
my-work/index.json 의 Make chunks 에서 청크마다 section(그 청크의 "## " 제목 글자)을 정하고,
Loader 메타데이터에 section 을 더해 줘.
my-work/search.json 의 hits 에 section 을 더해 줘 (없으면 빈 글자).
top_k 는 본문에서 받는다 (기본 3).
cases/lab01-challenge.json, cases/lab01.json 둘 다 통과.
```
- `Make chunks`는 내 `index.json`의 Code 노드 이름입니다. 이름이 다르면 내 파일의 이름으로 씁니다.
- "새로 만들어 줘"가 아니라 "**고쳐 줘**"입니다. 이미 있는 파일을 고치게 해야 예제 2에서 맞춘 계약이 그대로 남습니다.
</details>
```bash
uv run tools/flow.py push my-work/index.json my-work/search.json
uv run tools/flow.py test cases/lab01-challenge.json -v
uv run tools/flow.py test cases/lab01.json -v
```
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| `doctor`에 `policy documents /docs` ✘ | 데이터 API가 예전 판: `cd ~/aio-01d4-stu/Last3Mon/71/data-api && docker compose up -d --force-recreate` |
| 검색 결과 `hits: []`, `indexed: false` | 저장소가 비었음 → `call my-index` (push 뒤에는 항상) |
| `indexed_chunks`가 문서 수(8)와 같음 | 섹션으로 나누지 않고 문서 통째로 저장됨 → Make chunks 확인 |
| 계속 안 됨 | `cp reference/lab01-example2-index.json my-work/index.json`, `cp reference/lab01-example2-search.json my-work/search.json` 후 push |
## 확인
- [ ] 예제 1: 질문 6개의 정답표 (답 없는 질문 포함).
- [ ] 예제 2: `cases/lab01.json` 통과, 청크 수가 섹션 수와 같다.
- [ ] 과제: `cases/lab01-challenge.json` 통과.
@@ -0,0 +1,258 @@
# Lab 02 · 규정으로만 답하기, 모르면 넘기기, 답 검사
- 관련 강의: [2단계 · 근거로 답하는 RAG 워크플로우](../../Lecture/71/02-STAGE2-RAG-ANSWER.md)
- 소요 시간: 약 70분 (예제 1 20분 · 예제 2 15분 · 과제 35분)
- 준비: Lab 01의 `my-work/index.json`, `my-work/search.json` (없으면 `reference/lab01-example2-*.json`을 복사 후 push)
- 목표: 검색한 규정 발췌로만 답하고 **출처**를 붙인다. 관련 없는 질문은 LLM을 부르지 않고 넘기고, LLM의 답도 **검사**한 뒤 내보낸다.
```mermaid
flowchart LR
E1["예제 1<br/>발췌로만 답 · 출처"] --> E2["예제 2<br/>관련 없으면<br/>담당자 연결"] --> T["과제<br/>점수는 높은데<br/>답이 없을 때"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다.
## 예제 1 · 발췌로만 답하고 출처 붙이기 (20분)
**만들 것**: `my-work/policy-qa.json` (`my-policy-qa`). 질문 → 가까운 청크 3개 → 번호 붙인 발췌 → LLM이 **발췌로만** 답하고 번호 인용 → 번호를 문서 제목으로 바꾸고 `cited`에 인용한 문서.
| 조건 | 값 |
| --- | --- |
| 계약 | [AGENTS.md](AGENTS.md) 3절 규정 Q&A |
| 답 | 한국어 3문장 이내, 사실마다 `[번호]` → 결과에서는 `[비밀번호·계정 보안 규정]`처럼 제목 |
| 기대 결과 | `cases/lab02.json` 앞 네 케이스 (나머지는 예제 2) |
<details><summary>▶ 1단계 · 검색을 다시 만들까</summary>
`my-search`를 HTTP로 부를 수도, 같은 검색 노드를 Q&A 안에 둘 수도 있습니다. 어느 쪽이든 **같은 저장소**(`my_policy_docs`)를 씁니다. 70일차에 배운 "모듈로 부르기"와 "한 곳에서만 쓰면 그대로"를 떠올려 정합니다.
</details>
<details><summary>▶ 2단계 · LLM에게 주는 지시</summary>
"발췌로만", "모르면 모른다고", "번호로 출처" 세 가지를 지시문에 넣습니다. 번호를 쓰게 하는 이유는 **코드가** 출처를 확인할 수 있게 하기 위해서입니다 (67일차 Lab 01 과제의 `sources`와 같은 생각).
</details>
<details><summary>▶ 3단계 · 번호를 제목으로</summary>
`[2]`를 사람이 읽는 제목으로 바꾸는 것은 LLM이 아니라 **Code**에서. 범위 밖 번호(`[7]`)는 어떻게 할까요?
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 파일 · 웹훅 ② 어느 저장소에서 몇 개 ③ 발췌를 어떤 모양으로 ④ LLM 지시 셋(발췌로만 · 번호 · 모를 때의 문장) ⑤ 번호 → 제목 · `cited`는 어디서 ⑥ 기대 결과**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/policy-qa.json 을 만들어 줘. 계약은 AGENTS.md 3절 ____ . question 없으면 ____ .
1. 같은 메모리 저장소( ____ )에서 load, topK ____ , 메타데이터 포함, Always Output Data.
2. Code "Build context": 청크를 ____ 번호 붙인 발췌로, hits(source, title, score), top_score.
3. AI Agent( ____ , Tool 없음): 발췌로만, 한국어 ____ 문장 이내, 사실마다 ____ ,
없으면 정확히 " ____ "
4. Code "Build answer": [번호] → ____ , cited = 인용한 번호의 ____ , route "자동 답변". 범위 밖 번호는 ____ .
기대 결과 cases/lab02.json 앞 네 케이스.
```
채울 값은 [AGENTS.md](AGENTS.md) 3절(계약) · 5절(노드)과 위 표에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
my-work/policy-qa.json 을 만들어 줘. 계약은 AGENTS.md 3절 규정 Q&A. question 없으면 400.
1. 같은 메모리 저장소(my_policy_docs)에서 load, topK 3, 메타데이터 포함, Always Output Data.
2. Code "Build context": 청크를 [1] [2] [3] 번호 붙인 발췌로, hits(source, title, score), top_score.
3. AI Agent(gpt-4.1-mini, Tool 없음): 발췌로만, 한국어 3문장 이내, 사실마다 [번호],
없으면 정확히 "규정에서 답을 찾지 못했습니다."
4. Code "Build answer": [번호] → [문서 제목], cited = 인용한 번호의 source, route "자동 답변". 범위 밖 번호는 버린다.
기대 결과 cases/lab02.json 앞 네 케이스.
```
- 이 모양은 검색 노드를 Q&A 안에 다시 둡니다 (1단계 힌트의 두 길 중 하나). `my-search`를 HTTP로 부르게 해도 됩니다.
- 점심 메뉴 · 질문 없음 케이스는 아직 실패해도 됩니다 (예제 2에서).
</details>
```bash
uv run tools/flow.py push my-work/policy-qa.json
uv run tools/flow.py test cases/lab02.json -v
```
## 예제 2 · 관련 없으면 담당자 연결 (15분)
```bash
uv run tools/flow.py call my-policy-qa requests/q-off-topic.json -v
```
점심 메뉴 질문에도 가장 가까운 청크 3개는 **언제나** 나옵니다. 그 점수(`top_score`)를 봅니다.
<details><summary>▶ 1단계 · 기준값을 어디서</summary>
Lab 01에서 적어 둔 1등 점수들(규정 질문, 연차 질문)을 봅니다. 그 사이 어디에 선을 그으면 될까요? [AGENTS.md](AGENTS.md) 3절에 수업 준비 때 잰 범위가 있습니다. 기준값은 바꿔 가며 재 볼 수 있게 요청 본문(`min_score`)으로도 받습니다.
</details>
<details><summary>▶ 2단계 · LLM을 부르지 않는 길</summary>
기준 아래면 LLM을 부르지 않고 바로 정해진 답. 빠르고, 돈이 들지 않고, 지어낼 일이 없습니다. 결과 모양(`route`, `answer`, `cited: []`)은 같게.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 파일 ② 기준값과 그 값을 어디서 받나 ③ 무엇을 비교하나 ④ 기준 아래면 무엇을(LLM 없이) ⑤ 결과 모양은 그대로 ⑥ 기대 결과**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/policy-qa.json 을 고쳐 줘.
1. Build context 에 min_score = 본문 ____ (없으면 ____ ), relevant = ____ >= min_score.
2. If "Relevant?" 를 Build context 뒤에 둔다.
3. relevant 가 아니면 LLM 을 ____ 않고 route " ____ ",
answer " ____ ", cited ____ .
4. 결과 모양(route, answer, sources, cited, top_score)은 두 길 모두 같게.
기대 결과 cases/lab02.json 전부.
```
채울 값은 [AGENTS.md](AGENTS.md) 3절(계약) · 5절(노드)과 위 표에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
my-work/policy-qa.json 을 고쳐 줘.
1. Build context 에 min_score = 본문 min_score (없으면 0.33), relevant = top_score >= min_score.
2. If "Relevant?" 를 Build context 뒤에 둔다.
3. relevant 가 아니면 LLM 을 부르지 않고 route "담당자 연결",
answer "규정에서 답을 찾지 못했습니다. IT 문의 창구로 연결해 드릴게요.", cited [].
4. 결과 모양(route, answer, sources, cited, top_score)은 두 길 모두 같게.
기대 결과 cases/lab02.json 전부.
```
- 0.33은 AGENTS.md 3절의 측정 범위(규정 질문 0.46~0.62, 규정 밖 0.20~0.26) 사이입니다. 내 Lab 01 기록과도 맞는지 봅니다.
- AGENTS.md 5절의 `Not in policies`(Edit Fields)가 3번의 모양입니다.
</details>
```bash
uv run tools/flow.py push my-work/policy-qa.json
uv run tools/flow.py test cases/lab02.json -v
```
## 과제 · 점수는 높은데 규정에 답이 없을 때 (35분)
먼저 돌려 봅니다.
```bash
uv run tools/flow.py test cases/lab02-challenge.json -v
```
"VPN 접속 속도가 느리면?", "노트북 사양은?", "비밀번호를 잊어버리면 어디서 바꿔요?", "보안 교육은 1년에 몇 번?" — 비슷한 낱말이 규정에 있어 점수는 기준을 넘지만, **답은 규정에 없습니다.** 지금 결과의 `route`와 `answer`를 봅니다.
팀장이 요청했습니다.
> "'규정에서 찾지 못했다'는 답이 '자동 답변'으로 나가면 안 됩니다. 그리고 답에 숫자가 나오면 그 숫자가 정말 규정에 있는지 확인해 주세요."
예제 2까지 만든 Q&A로 먼저 돌리면 **2/8**입니다 (예제 정답 `reference/lab02-example2-qa.json`으로 수업 전날 2026-10-22에 잰 값, 10/18 수업 준비 때도 2/8). 케이스마다:
| 케이스 | 질문 | LLM이 받은 발췌 (검색 top 3) | 지금 결과 (예제 2까지) | 왜 틀리나 | 나와야 할 결과 (테스트가 보는 칸) |
| --- | --- | --- | --- | --- | --- |
| 규정에 없음 (VPN 속도) | VPN 접속 속도가 느리면 어떻게 해요? | [1] 원격근무·VPN · 1. 접속 원칙 0.462<br>[2] 원격근무·VPN · 2. VPN 인증서 0.435<br>[3] 원격근무·VPN · 3. 재택근무 중 보안 0.409 | ✘ `top_score` 0.463 → `자동 답변`: "…언급하고 있지 않습니다. 따라서 규정에서 답을 찾지 못했습니다." | 규정에 VPN 이야기가 있어 기준을 넘음. LLM은 "못 찾았다"고 했지만 그 답이 자동으로 나감 | `route` = `담당자 연결` |
| 규정에 없음 (노트북 사양) | 노트북 사양은 어떻게 되나요? | [1] 장비 · 1. 지급과 교체 0.383<br>[2] 메일·문서 · 1. 메일함 0.234<br>[3] 장비 · 2. 분실과 도난 0.215 | ✘ 0.379 → `자동 답변`: "…포함되어 있지 않습니다." + 출처 3개 | 장비 규정에 노트북은 있지만 사양은 없음. 출처까지 붙어 근거 있는 답처럼 보임 | `route` = `담당자 연결` |
| 규정에 없음 (비밀번호 잊음) | 비밀번호를 잊어버리면 어디서 바꿔요? | [1] 비밀번호 · 2. 변경 주기 0.551<br>[2] 비밀번호 · 1. 만들기 0.484<br>[3] 비밀번호 · 4. 다단계 인증 0.474 | ✘ 0.551 → `자동 답변`: "규정에서 답을 찾지 못했습니다." | 비밀번호 규정에 잊었을 때의 방법이 없음. 정해 준 문장 그대로인데도 자동으로 나감 | `route` = `담당자 연결` |
| 규정에 없음 (보안 교육) | 보안 교육은 1년에 몇 번 받아야 하나요? | [1] 보안 사고 · 1. 피싱 메일 0.353<br>[2] 원격근무·VPN · 2. VPN 인증서 0.334<br>[3] 비밀번호 · 2. 변경 주기 0.304 | ✘ 0.352 → `자동 답변`: "규정에서 답을 찾지 못했습니다." | 보안 사고 절차에 교육 이야기가 없음 | `route` = `담당자 연결` |
| 점수가 낮아 걸러짐 (연차) | 연차 휴가는 며칠이에요? | [1] 회의실 · 1. 예약 0.259<br>[2] 원격근무·VPN · 3. 재택 보안 0.224<br>[3] 원격근무·VPN · 2. VPN 인증서 0.221<br>(기준 미달이라 LLM에게 가지 않음) | ✔ 0.259 → `담당자 연결` (LLM 없음) | 이미 맞음 — 예제 2의 기준값이 거름. 고친 뒤에도 그대로여야 함 | `route` = `담당자 연결` |
| 틀린 전제 (30일) | 비밀번호는 30일마다 바꾸면 되죠? | [1] 비밀번호 · 2. 변경 주기 0.685<br>[2] 비밀번호 · 1. 만들기 0.585<br>[3] 비밀번호 · 3. 잠금 0.552 | ✔ "30일마다 바꾸는 것이 아니라 90일마다 …" | 이미 맞음 — 발췌로만 답하게 해서. 검사를 더해도 이 답을 막으면 안 됨 | `answer`에 "90일", "30일마다 바꾸면 됩니다"는 없음 |
| 근거 있는 답은 그대로 | 암호를 얼마나 자주 바꿔야 해? | [1] 비밀번호 · 2. 변경 주기 0.576<br>[2] 비밀번호 · 1. 만들기 0.512<br>[3] 비밀번호 · 4. 다단계 인증 0.456 | ✘ `자동 답변` "암호는 90일마다 …" — **답은 맞음**. 결과 칸은 `route` · `answer` · `sources` · `cited` · `top_score` 다섯 개뿐 | 이 케이스는 답 내용과 함께 **`failed` 칸**을 봅니다. `failed`는 답 검사를 만들어야 생기는 칸이라 지금은 없음 → 실패. 확인하려는 것: 검사를 더한 뒤에도 **좋은 답이 막히지 않는지** | `route` = `자동 답변`, `failed` = `[]` (검사를 모두 통과), `answer`에 "90일" |
| 검사 기록 | 컴퓨터가 바이러스에 걸린 것 같아요 | [1] 보안 사고 · 2. 악성코드 감염 의심 0.479<br>[2] 보안 사고 · 1. 피싱 메일 0.357<br>[3] 원격근무·VPN · 3. 재택 보안 0.249 | ✘ `자동 답변` "…랜선을 뽑고 와이파이를 꺼서 …" — **답은 맞음**. `checks` 칸이 없음 | 이 케이스는 답이 아니라 **`checks` 칸**만 봅니다. 검사를 만들고 그 기록을 결과에 남겼는지 확인. 지금은 검사가 없으니 칸도 없음 → 실패 | `checks` 목록에 이름이 `grounded`인 검사가 있음 |
- 발췌 열은 같은 질문을 검색만 따로 불러 본 값입니다 (2026-10-22, 섹션 색인). 1등 점수가 Q&A의 `top_score`와 0.004 이내로 같고, 문서도 Q&A 결과의 `sources`와 같습니다.
- 답이 없는 네 질문도 LLM은 **비슷한 주제의 발췌 3개**를 받았습니다. "노트북 사양"의 [2] · [3]은 0.234 · 0.215로 관련이 거의 없는데도 LLM에게 갔고, LLM은 이것까지 출처로 붙였습니다. 기준값은 **1등 점수**만 보기 때문입니다.
- 위 네 줄(✘)이 과제의 핵심입니다: **답이 없는 질문을 사람에게 넘기기.**
- 아래 두 줄(✘)은 답이 아니라 **결과 JSON의 칸**을 봅니다. 답은 이미 맞지만, 과제에서 만들 칸(`checks`, `failed`)이 아직 없어서 실패합니다. 하나는 좋은 답이 검사에 막히지 않는지(`failed: []`), 하나는 검사 기록이 남는지(`checks`)를 확인합니다.
- 가운데 두 줄(✔)은 지금도 맞습니다. 검사를 더한 뒤에도 **깨지지 않아야** 합니다.
<details><summary>▶ 고치기 전 · 후 응답 비교 (VPN 속도)</summary>
고치기 전 (예제 2까지):
```json
{"route": "자동 답변",
"answer": "규정에서는 VPN 접속 속도가 느릴 때의 대처 방법에 대해 언급하고 있지 않습니다. 따라서 규정에서 답을 찾지 못했습니다.",
"sources": ["remote-work-vpn"], "cited": [], "top_score": 0.463}
```
고친 뒤 나와야 할 모양:
```json
{"route": "담당자 연결",
"answer": "규정에서 확실한 답을 찾지 못해 IT 담당자에게 전달했습니다.",
"draft": "(LLM이 쓴 원래 답)",
"sources": ["remote-work-vpn"], "cited": [], "top_score": 0.462,
"checks": [{"name": "…", "passed": false}, {"name": "grounded", "passed": false}, …],
"failed": ["…", "grounded", …]}
```
검사 이름 · 개수는 내가 정합니다 (1단계 힌트). 테스트는 `route`, `failed`(근거 있는 답에서 빈 목록), `checks` 안의 `grounded`만 봅니다.
</details>
<details><summary>▶ 1단계 · 검사 항목 정하기</summary>
67일차 Check answer를 떠올립니다. RAG 답에서 코드로 확인할 수 있는 것은? (모른다고 했나, 인용 번호가 있나, 답 글이 발췌와 겹치나, 답의 숫자가 발췌에 있나) 검사마다 이름을 붙이고 `checks`, `failed`로 남기게 합니다.
</details>
<details><summary>▶ 2단계 · "모른다"는 여러 모양</summary>
지시문에 정확한 문장을 주어도 LLM은 "포함되어 있지 않습니다", "명시되어 있지 않습니다"처럼 다르게 말하기도 합니다. 보고서에서 실제 답을 보고, 검사가 이 모양들을 잡는지 확인합니다.
</details>
<details><summary>▶ 3단계 · 넘길 때 무엇을 남기나</summary>
담당자에게 넘길 때 LLM이 쓴 초안(`draft`)과 실패한 검사(`failed`)를 남기면 담당자가 빨리 판단합니다. 원래 테스트 `cases/lab02.json`도 계속 통과해야 합니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 노드 ② 검사 넷의 이름과 기준 ③ 결과에 남길 칸 ④ 하나라도 실패하면 무엇을 ⑤ 원래 답은 어디에 ⑥ 두 테스트 모두 통과**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/policy-qa.json 의 ____ 에 검사를 더해 줘. 검사마다 {name, passed} 를 ____ 에, 실패 이름을 ____ 에.
- found_in_policies: "찾지 못했", " ____ ", " ____ ", "언급이 없", "나와 있지 않" 이 없어야
- cites_excerpt: 인용 번호가 ____ 이상
- grounded: 답 글(번호 뺀)의 ____ 글자 묶음 중 ____ % 이상이 발췌에 있어야
- numbers_from_policies: 답에 나온 ____ 가 모두 발췌에 있어야
failed 가 있으면 route " ____ ", answer " ____ ", ____ 에 원래 답.
____ , ____ 둘 다 통과.
```
채울 값은 위 1~3단계 힌트와 먼저 돌려 본 보고서(`runs/lab02-challenge-*.json`)의 실제 답에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
my-work/policy-qa.json 의 Build answer 에 검사를 더해 줘. 검사마다 {name, passed} 를 checks 에, 실패 이름을 failed 에.
- found_in_policies: "찾지 못했", "포함되어 있지 않", "명시되어 있지 않", "언급이 없", "나와 있지 않" 이 없어야
- cites_excerpt: 인용 번호가 하나 이상
- grounded: 답 글(번호 뺀)의 두 글자 묶음 중 30% 이상이 발췌에 있어야
- numbers_from_policies: 답에 나온 숫자가 모두 발췌에 있어야
failed 가 있으면 route "담당자 연결", answer "규정에서 확실한 답을 찾지 못해 IT 담당자에게 전달했습니다.", draft 에 원래 답.
cases/lab02-challenge.json, cases/lab02.json 둘 다 통과.
```
- "모른다"의 모양은 먼저 돌려 본 보고서의 실제 답에서 찾습니다. 위 다섯 개는 수업 준비 때 본 모양이고, 내 결과에 다른 모양이 있으면 더합니다.
- `--repeat 3`으로 세 번 돌려 확인합니다. LLM 답은 매번 조금 다릅니다.
</details>
```bash
uv run tools/flow.py push my-work/policy-qa.json
uv run tools/flow.py test cases/lab02-challenge.json -v --repeat 3
uv run tools/flow.py test cases/lab02.json -v
```
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 모든 질문이 `담당자 연결` | 저장소가 비었을 수 있음 (`top_score: 0`) → `call my-index` |
| 답에 `[1]` 그대로 | 번호를 제목으로 바꾸는 Code 확인 |
| 근거 있는 답도 `담당자 연결` | 보고서의 `failed`를 보고 어느 검사가 너무 엄격한지 확인 |
| 계속 안 됨 | `cp reference/lab02-example2-qa.json my-work/policy-qa.json` 후 push |
## 확인
- [ ] 예제 1·2: `cases/lab02.json` 통과, 출처가 제목으로 나온다.
- [ ] 과제: `cases/lab02-challenge.json` 3번 반복 통과, `lab02`도 통과.
- [ ] `RAG-EVAL.md` 3절에 "기준값에 걸러지지 않았는데 답이 없는 질문"을 적었다.
+283
View File
@@ -0,0 +1,283 @@
# Lab 03 · 검색 품질을 숫자로 재고, 한 가지씩 고치기
- 관련 강의: [3단계 · 검색 품질 측정과 개선](../../Lecture/71/03-STAGE3-RETRIEVAL-QUALITY.md)
- 소요 시간: 약 80분 (예제 1 15분 · 예제 2 25분 · 과제 40분)
- 준비: Lab 01·02의 `my-work/index.json`, `search.json`, `policy-qa.json`이 push되어 있어야 합니다.
- 목표: 검색이 "잘 되는 것 같다"가 아니라 **몇 개 중 몇 개**인지 잰다. 설정을 **한 번에 하나만** 바꿔 다시 재고, 어려운 질문으로 고친 것이 다른 것을 깨뜨리지 않는지 확인한다.
```mermaid
flowchart LR
E1["예제 1<br/>기준 재기<br/>doc@1 · answer@1 · answer@3"] --> E2["예제 2<br/>청크 크기 · 기준값<br/>한 가지씩"] --> T["과제<br/>어려운 질문 ·<br/>개선 · 다시 재기"]
```
> 복사해 붙일 요청문은 없습니다. 이번 Lab은 요청문보다 **재는 순서**가 중요합니다.
## 예제 1 · 기준 재기 (15분)
**할 일**: 지금 설정(섹션 청크)의 검색 점수를 세 지표로 잽니다. 예제 2와 과제에서 무엇을 바꾸든 **이 숫자와 비교**합니다. 에이전트는 쓰지 않습니다 (워크플로우를 고치지 않음).
질문 24개와 정답(문서, 답 문구)이 `cases/retrieval-*.json`에 들어 있습니다. 세 파일은 **같은 24개 질문**을 서로 다른 기준으로 채점합니다.
| 파일 | 지표 | 뜻 | 케이스가 보는 칸 (예: "회사를 그만두면 노트북은 …") |
| --- | --- | --- | --- |
| `retrieval-doc.json` | doc@1 | 1등 청크가 **맞는 문서**에서 왔나 | `hits.0.source` = `device-issue-return` |
| `retrieval-answer.json` | answer@1 | 1등 청크 안에 **답 문구**가 있나 | `hits.0.text`에 "5영업일" |
| `retrieval-answer3.json` | answer@3 | 3등 안에 답 문구가 있나 | `hits`(1~3등 전부)에 "5영업일" |
```bash
uv run tools/flow.py call my-index requests/index-section.json -v
uv run tools/flow.py test cases/retrieval-doc.json -v --no-before
uv run tools/flow.py test cases/retrieval-answer.json -v --no-before
uv run tools/flow.py test cases/retrieval-answer3.json -v --no-before
```
`RAG-EVAL.md` 4절 첫 줄(섹션)을 채웁니다. 아래 `▶`를 순서대로 펼치며 진행합니다.
<details><summary>▶ 1단계 · 먼저 색인 상태를 확인</summary>
첫 명령 `call my-index requests/index-section.json -v`는 저장소를 비우고 **섹션 청크로 다시 색인**합니다. 응답이 이렇게 나와야 합니다.
```text
HTTP 200 · …s
→ request POST /webhook/my-index
{
"chunking": "section"
}
← response HTTP 200
{
"indexed_chunks": 25,
"chunking": "section"
}
```
- `indexed_chunks`가 **25**인지 봅니다. 규정 8개의 `## ` 섹션 수를 더하면 25입니다 (Lab 01 예제 2와 같음).
- 25가 아니면 Lab 01 과제까지 한 내 `my-work/index.json`이 push되어 있는지 확인합니다.
- 왜 굳이 다시 색인하나: 앞에서 무엇을 push했는지에 따라 저장소가 비었거나(push하면 n8n 재시작) 다른 설정으로 색인되어 있을 수 있습니다. **잴 때마다 출발점을 내 손으로 정합니다.**
</details>
<details><summary>▶ 2단계 · 왜 <code>--no-before</code>를 붙이나</summary>
세 케이스 파일에는 `before` 칸(명령이 아니라 파일 안의 칸)이 있어서, `test`는 보통 채점 전에 섹션으로 다시 색인합니다. 예제 1은 방금 섹션으로 색인했으니 붙이지 않아도 결과는 같지만, **예제 2에서는 200자로 색인한 것을 그대로 재야** 하므로 지금부터 붙이는 습관을 들입니다.
- 붙였으면 출력 첫 줄이 `POST http://localhost:5678/webhook/my-search · 24 case(s) × 1`입니다.
- 첫 줄에 `✔ before: POST /webhook/my-index …`가 있으면 `--no-before`가 빠진 것입니다.
</details>
<details><summary>▶ 3단계 · 결과 읽는 법</summary>
`-v`를 붙였으므로 케이스마다 요청 · 응답 JSON이 길게 나옵니다. 먼저 **한 줄씩 된 결과**와 **마지막 두 줄**만 봅니다.
```text
POST http://localhost:5678/webhook/my-search · 24 case(s) × 1
✔ 비밀번호는 몇 자 이상이어야 하나요? · 0.3s
✔ 암호를 얼마나 자주 바꿔야 해? · 0.3s
…
✘ 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? · 0.3s · hits.0.text has none of ['5영업일']
…
Passed 22/24
report: runs/retrieval-answer-20261022-201052.json
```
| 부분 | 뜻 |
| --- | --- |
| `✔` / `✘` | 이 질문을 맞혔나 / 틀렸나 |
| `· 0.3s` | 검색 한 번 걸린 시간 |
| `hits.0.text has none of ['5영업일']` | 틀린 까닭: 1등(`hits.0`) 글에 "5영업일"이 없음 |
| `Passed 22/24` | **이 지표의 점수** → 표에 적을 숫자 |
| `report: runs/…json` | 케이스마다 요청 · 응답이 모두 든 보고서 |
틀린 줄만 모아 보려면:
```bash
uv run tools/flow.py test cases/retrieval-answer.json -v --no-before | grep -E "^(✘|Passed)"
```
</details>
<details><summary>▶ 4단계 · 틀린 질문 하나를 열어 보기</summary>
`✘` 줄 바로 아래에 그 질문의 응답이 있습니다. `hits`의 1~3등에서 **문서(`source`) · 섹션(`section`) · 점수(`score`) · 글(`text`)**을 봅니다.
```text
✘ 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? · 0.3s · hits.0.text has none of ['5영업일']
← response HTTP 200
{ "hits": [
{ "rank": 1, "source": "device-issue-return", "section": "1. 지급과 교체", "score": 0.464,
"text": "…업무용 노트북은 입사일에 지급한다. 노트북 교체 주기는 4년이다. …" },
{ "rank": 2, "source": "device-issue-return", "section": "3. 반납", "score": 0.45,
"text": "…퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다. …" },
{ "rank": 3, "source": "device-issue-return", "section": "2. 분실과 도난", "score": 0.369, … } ] }
```
스스로 답해 봅니다.
1. 1등 문서는 맞았나요? (정답 문서 `device-issue-return`)
2. 답 문구 "5영업일"은 몇 등에 있나요?
3. 1등과 그 등수의 점수 차이는 얼마인가요?
4. 1등 섹션에는 질문의 어떤 낱말이 들어 있나요?
같은 질문이 `retrieval-doc.json`, `retrieval-answer3.json`에서는 ✔인지 ✘인지도 봅니다.
</details>
<details><summary>▶ 5단계 · 세 숫자가 다른 이유</summary>
doc@1은 맞는데 answer@1이 틀린 질문을 보고서에서 찾습니다. 맞는 문서의 **다른 섹션**이 1등이었나요? answer@3에서는 맞았다면, LLM에게 3개를 주는 지금 설정에서는 답할 수 있다는 뜻입니다. 어느 지표가 내 업무에 중요한지 정합니다.
| 세 지표 결과 | 뜻 |
| --- | --- |
| doc@1 ✔ · answer@1 ✔ | 1등 하나로 답할 수 있음 |
| doc@1 ✔ · answer@1 ✘ · answer@3 ✔ | 맞는 문서의 다른 섹션이 1등. 3개를 주면 답할 수 있음 |
| doc@1 ✘ | 다른 문서가 1등. 질문의 낱말이 다른 규정과 더 가까움 |
| answer@3 ✘ | 답이 3등 안에 없음. LLM도 답할 수 없음 |
</details>
<details><summary>▶ 6단계 · <code>RAG-EVAL.md</code>에 적기</summary>
4절 표의 첫 줄(섹션)을 채우고, 표 아래에 **틀린 질문**을 적습니다. 예제 2에서 숫자가 같아도 틀린 질문이 다를 수 있기 때문입니다.
```markdown
| 설정 | 청크 수 | doc@1 | answer@1 | answer@3 |
| --- | --- | --- | --- | --- |
| 섹션 (기준) | 25 | __/24 | __/24 | __/24 |
- 섹션에서 answer@1을 틀린 질문: ______ (답 문구가 __등에 있음), ______
```
</details>
<details><summary>▶ 정답 (직접 잰 뒤에 펼치기)</summary>
수업 준비 때(10/18)와 다시 돌렸을 때(10/22) 모두 같았습니다.
| 설정 | 청크 수 | doc@1 | answer@1 | answer@3 |
| --- | --- | --- | --- | --- |
| 섹션 (기준) | 25 | 24/24 | 22/24 | 24/24 |
answer@1에서 틀린 두 질문 (둘 다 doc@1 ✔, answer@3 ✔):
| 질문 | 1등 | 답이 있는 섹션 | 점수 차이 |
| --- | --- | --- | --- |
| 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? | 장비 · 1. 지급과 교체 0.464 | 장비 · 3. 반납 0.45 ("5영업일", 2등) | 0.014 |
| 고객 정보가 밖으로 샌 것 같아요 | 개인정보 · 2. 이용과 보관 0.395 | 개인정보 · 3. 유출 대응 0.375 ("1시간", 2등) | 0.02 |
- 1등 섹션에는 질문의 명사("노트북", "고객")가 그대로 있고, 답이 있는 섹션은 다른 낱말("장비", "유출")로 쓰여 있습니다.
- 시간은 검색 한 번 0.2~0.9초였습니다.
</details>
## 예제 2 · 한 가지씩 바꾸기 (25분)
**청크 크기**: 색인만 다른 설정으로 다시 하고, 검색 테스트는 `--no-before`로 그대로 돌립니다 (워크플로우는 고치지 않음).
```bash
uv run tools/flow.py call my-index requests/index-200.json -v
uv run tools/flow.py test cases/retrieval-answer.json -v --no-before
uv run tools/flow.py call my-index requests/index-400.json -v
uv run tools/flow.py test cases/retrieval-answer.json -v --no-before
```
doc@1, answer@3도 같은 식으로 재어 4절 표를 채웁니다. 끝나면 섹션으로 되돌립니다.
**관련도 기준**: `cases/lab03-threshold.json`은 규정 질문 5개(답해야 함)와 규정 밖 5개(넘겨야 함)입니다.
```bash
uv run tools/flow.py call my-index requests/index-section.json -v
uv run tools/flow.py test cases/lab03-threshold.json -v --no-before
```
<details><summary>▶ 1단계 · 왜 한 번에 하나만</summary>
청크 크기와 기준값을 함께 바꾸고 숫자가 좋아지면, 무엇 때문인지 모릅니다. 바꾼 것 하나, 잰 숫자 셋을 한 줄에 적습니다.
</details>
<details><summary>▶ 2단계 · 기준값을 바꿔 보려면</summary>
보고서의 `top_score`를 봅니다 (`runs/lab03-threshold-....json`). 규정 질문의 가장 낮은 점수와 규정 밖 질문의 가장 높은 점수 사이가 얼마나 벌어져 있나요? Q&A의 기본 `min_score`를 바꿔 push하고 다시 재 봅니다. Lab 02 과제의 "점수는 높은데 답이 없는 질문"은 기준값으로 거를 수 있을까요?
</details>
<details><summary>▶ 3단계 · 청크 크기의 함정</summary>
글자 수로 자르면 섹션 중간이 잘리고, 청크 수도 달라집니다 (`indexed_chunks`). 우리 문서는 짧아서 400자로 자르면 청크가 오히려 **적어집니다.** 문서의 모양을 보고 정합니다.
</details>
## 과제 · 어려운 질문으로 고치기 (40분)
팀장이 요청했습니다.
> "직원들은 규정 낱말로 묻지 않아요. '챗GPT', '폰', '비번'처럼 물어요. 그런 질문으로 테스트를 만들고, 검색을 고쳐 주세요. 대신 지금 잘 되는 24문항이 나빠지면 안 됩니다."
1. `cases/my-cases.json`에 **어려운 질문 6개 이상** (기대: `hits.0.source`). 예제 1의 정답표처럼 답이 있는 문서를 먼저 확인합니다 (답이 **없는** 질문은 넣지 않음).
2. 한 가지 개선을 골라 에이전트에게 맡깁니다.
3. 어려운 질문과 `retrieval-*` 세 파일을 **모두** 다시 잽니다.
```bash
uv run tools/flow.py test cases/my-cases.json -v
uv run tools/flow.py push my-work/search.json
uv run tools/flow.py test cases/my-cases.json -v
uv run tools/flow.py test cases/retrieval-answer.json -v
```
<details><summary>▶ 1단계 · 어떤 개선이 있나</summary>
| 개선 | 어디를 고치나 |
| --- | --- |
| 질문 다시 쓰기: LLM이 질문을 규정 낱말로 바꾼 뒤 검색 | 검색 (LLM 한 번 더) |
| 청크에 문서 제목·섹션 제목 붙이기 | 색인 |
| 같은 뜻 낱말표(챗GPT → 생성형 AI)로 질문 넓히기 | 검색 (Code) |
| 더 많이 찾고(top_k 5) LLM에게 맡기기 | 검색·Q&A |
하나만 고릅니다. 고른 이유를 `RAG-EVAL.md` 5절에 적습니다.
</details>
<details><summary>▶ 2단계 · 실패를 보고 고치되, 맞추지는 않기</summary>
어려운 질문이 실패한 이유를 보고서에서 봅니다 (1등 문서는 어디였나?). 개선을 그 실패에만 맞추면(질문 하나하나의 낱말을 지시문에 넣기) 다른 질문에서는 안 됩니다. 그래서 `retrieval-*` 24문항을 **함께** 잽니다.
</details>
<details><summary>▶ 3단계 · 고쳐서 깨진 것</summary>
개선 뒤 **새로 틀린** 질문이 있나요? 있다면 그대로 적습니다. 좋아진 것과 나빠진 것을 함께 적는 것이 측정입니다. 시간(보고서 `seconds`)도 비교합니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 파일 ② 어디에 무엇을 더하나 ③ LLM에게 줄 지시(무엇으로 바꾸나 · 예 · 그대로 둘 것) ④ 검색이 무엇을 쓰나 ⑤ 결과에 더할 칸 ⑥ 다시 잴 테스트 전부**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/search.json 을 고쳐 줘. 검색 전에 AI Agent " ____ "( ____ , Tool 없음)를 둔다.
지시: 직원 질문을 ____ 의 낱말로 바꾼 짧은 한국어 검색어 한 줄.
예: ____ → ____ , ____ → ____ , ____ → ____ (내 어려운 질문에서 찾은 낱말)
____ 와 ____ 은 그대로.
검색 prompt 는 ____ 의 output. 결과에 ____ 를 더한다. 나머지 계약은 그대로.
____ 과 retrieval-doc · retrieval-answer · retrieval-answer3 를 모두 다시 잰다.
```
채울 값은 1단계 표, 내 `cases/my-cases.json`에서 틀린 질문, [AGENTS.md](AGENTS.md) 5절(AI Agent · 질문 다시 쓰기)에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
my-work/search.json 을 고쳐 줘. 검색 전에 AI Agent "Rewrite query"(gpt-4.1-mini, Tool 없음)를 둔다.
지시: 직원 질문을 규정 문서의 낱말로 바꾼 짧은 한국어 검색어 한 줄. 예: 암호·비번 → 비밀번호, 그만두면 → 퇴사, 깔다 → 설치,
챗GPT·제미나이 → 생성형 AI 서비스, 개인 메일로 보내다 → 외부로 보내다·첨부 파일, 폰 → 휴대폰. 숫자와 이름은 그대로.
검색 prompt 는 Rewrite query 의 output. 결과에 query 를 더한다. 나머지 계약은 그대로.
cases/my-cases.json 과 retrieval-doc · retrieval-answer · retrieval-answer3 를 모두 다시 잰다.
```
- 이 모양은 1단계 표의 **질문 다시 쓰기**를 고른 경우입니다. 다른 개선을 골랐다면 같은 순서(고칠 파일 → 더할 것 → 그대로 둘 것 → 다시 잴 테스트)로 씁니다.
- 예 낱말을 내 어려운 질문에만 맞추면 그 질문만 좋아집니다 (2단계 힌트). 24문항을 함께 재는 줄을 빼지 않습니다.
- 다시 쓴 검색어는 실행마다 조금씩 다릅니다. `--repeat 3`으로 재 보면 범위가 보입니다.
</details>
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 숫자가 매번 다름 | 색인 설정을 확인 (`call my-index`의 `chunking`), `--no-before`를 붙였는지 |
| 200자 색인 뒤 Q&A가 이상함 | 테스트가 끝나면 `call my-index requests/index-section.json`으로 되돌리기 |
| 계속 안 됨 | `reference/`의 파일로 이어 가기 |
## 확인
- [ ] 예제 1: 섹션 설정의 doc@1 · answer@1 · answer@3.
- [ ] 예제 2: 200자 · 400자 줄, 기준값 테스트, 바꾼 것은 한 번에 하나.
- [ ] 과제: 어려운 질문 6개 이상, 개선 전·후, `retrieval-*` 다시, 새로 틀린 것도 적었다.
- [ ] [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)를 `my-work/TEST-REPORT.md`로 채웠다.
+181
View File
@@ -0,0 +1,181 @@
# 71일차 수강 안내 · 수업 준비부터 마칠 때까지
- 일자: 2026-10-23 (금) · 71일차
- 주제: RAG 기반 검색·응답 Workflow와 검색 품질 개선
- 실습 폴더: [Last3Mon/71](README.md) · 강의 자료: [Lecture/71](../../Lecture/71/README.md)
- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`, 임베딩 `text-embedding-3-large`, 같은 Credential) + **71일차 판 데이터 API**(규정 문서 `/docs`) + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.**
- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요.
```mermaid
flowchart LR
P["전날<br/>준비 · 71 데이터 API"] --> M["아침<br/>켜기 · doctor"] --> L1["1~2교시<br/>Lab 01<br/>정답표 · 색인 · 검색"] --> L2["3~4교시<br/>Lab 02<br/>근거로 답하기"] --> L3["5~6교시<br/>Lab 03<br/>재기 · 고치기"] --> V["7~8교시<br/>tdoc"] --> E["마친 뒤<br/>남기기 · 72 준비"]
```
## 0. 하루 한눈에
| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 |
| --- | --- | --- | --- | --- |
| 전날 | 약 5분 | 자료 받기 · n8n · 71일차 데이터 API · doctor | [PREP](../../Lecture/71/00-DAY71-PREP.md) | `ready` |
| 1 | 09:00~09:50 | 개요 · 1단계 강의 (RAG와 색인) | [개요](../../Lecture/71/00-DAY71-OVERVIEW.md), [1단계](../../Lecture/71/01-STAGE1-RAG-AND-INDEX.md) | |
| 2 | 10:00~10:50 | Lab 01 예제 1 · 2 · 과제 | [Lab 01](LAB-01-Q-INDEX-AND-SEARCH.md) | `lab01-challenge` 통과 |
| 3 | 11:00~11:50 | 2단계 강의 (근거로 답하기) · Lab 02 예제 1 · 2 | [2단계](../../Lecture/71/02-STAGE2-RAG-ANSWER.md), [Lab 02](LAB-02-Q-ANSWER-FROM-POLICIES.md) | `lab02` 통과 |
| 4 | 12:00~12:50 | Lab 02 과제 35분 (점수는 높은데 답이 없을 때) · 풀이 | Lab 02 | `lab02-challenge` 통과 |
| - | 13:00~14:00 | 점심 | | |
| 5 | 14:00~14:50 | 3단계 강의 (검색 품질) · Lab 03 예제 1 · 2 | [3단계](../../Lecture/71/03-STAGE3-RETRIEVAL-QUALITY.md), [Lab 03](LAB-03-Q-MEASURE-AND-IMPROVE.md) | 지표 셋 · 바꾼 설정별 숫자 |
| 6 | 15:00~15:50 | Lab 03 과제 40분 (어려운 질문으로 고치기) · 풀이 | Lab 03 | 개선 전·후 · 보고서 |
| 7 | 16:00~16:50 | 바이브 코딩 `tdoc`: 요청서 → 내 SPEC → 만들기 | [VIBE-REQ-TDOC](VIBE-REQ-TDOC.md) → [VIBE-Q-TDOC](VIBE-Q-TDOC.md) | `SPEC.md` · R1~R5 · `pytest` |
| 8 | 17:00~17:50 | 발표 · 강사 정리 · 강사 바이브 코딩 · 팁 · 72일차 예고 | [진행](../../Lecture/71/04-VIBE-CODING.md) | |
## 1. 전날 준비 (약 5분)
### 1.1 자료 받기
```bash
cd ~/aio-01d4-stu && git pull
```
### 1.2 n8n과 **71일차** 데이터 API 켜기 (Ollama는 끔)
```bash
sudo systemctl disable --now ollama 2>/dev/null # WSL에 설치한 Ollama가 있으면 끄고 자동 시작도 끔
cd ~/aio-01d4-stu/Last3Mon/65/n8n
docker compose rm -sf ollama # Ollama 컨테이너가 있으면 지움 (없으면 그냥 지나감)
docker compose start
cd ~/aio-01d4-stu/Last3Mon/71/data-api
docker compose up -d --force-recreate # 67일차 데이터 API를 71일차 판(+ 규정 문서)으로 바꿈
curl -s localhost:8800/docs | head -c 120 # {"docs": [{"name": "device-issue-return", ...
```
- LLM과 임베딩 모두 **OpenAI API 키**(`Last3Mon/.env`) 하나로 씁니다. n8n은 `start`로 켭니다.
- 데이터 API는 67일차와 이름이 같아 하나만 돕니다. 71일차 폴더에서 `--force-recreate`로 다시 만들어야 `/docs`가 생깁니다.
### 1.3 준비 확인
```bash
cd ~/aio-01d4-stu/Last3Mon/71
uv run tools/flow.py doctor # 마지막 줄 ready
```
### 1.4 미리 읽기 (선택, 5분)
[PREP 3절](../../Lecture/71/00-DAY71-PREP.md): "비밀번호는 며칠마다 바꿔야 하나요?"를 LLM에게 그냥 물으면 그럴듯한 숫자를 말합니다. 우리 규정(90일)으로 답하게 하려면?
### 준비물
노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저, 7교시 `tdoc`에 쓸 내 md 문서 폴더 (없으면 이 과정의 `Lecture/` 폴더).
## 2. 수업 당일 아침 (09:00 전)
1. 노트북을 껐다 켰다면 1.2를 다시 합니다.
2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/71`. ① 에이전트, ② 확인.
3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`.
4. `my-work/PROMPTS.md`를 만들어 둡니다.
## 3. 실습할 때마다 같은 순서
```mermaid
flowchart LR
A["① 기대 결과 읽기<br/>cases/*.json"] --> B["② 요청문을<br/>메모장에 먼저"] --> C["③ 에이전트에게"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test"]
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> C
E -->|✔| G["⑦ PROMPTS.md"]
```
- **오늘만의 주의: 메모리 저장소는 n8n이 재시작되면 지워집니다.** `push`할 때마다 n8n이 재시작되므로 색인을 다시 해야 합니다.
- `test`는 케이스 파일의 `before` 단계가 색인을 다시 해 줍니다.
- 직접 `call` 할 때는 먼저 `uv run tools/flow.py call my-index requests/index-section.json -v`.
- Lab 03에서 색인 설정을 바꿔 가며 잴 때는 `--no-before`로 내가 만든 색인을 그대로 씁니다.
- 바꾸는 것은 **한 번에 하나**, 바꿀 때마다 같은 질문 목록으로 다시 잽니다.
## 4. 1~2교시 · 1단계 · 정답표, 색인, 검색
[Lab 01](LAB-01-Q-INDEX-AND-SEARCH.md) (약 40분).
| 순서 | 할 일 | 파일 · 웹훅 | 확인 |
| --- | --- | --- | --- |
| 예제 1 (10분, 에이전트 없이) | `cp templates/RAG-EVAL-TEMPLATE.md my-work/RAG-EVAL.md` 후 질문 6개의 정답표(답 없는 질문 포함)를 **직접** (`curl -s localhost:8800/docs`, `data-api/data/docs/`) | | 표 |
| 예제 2 (15분) | 색인: 규정 문서를 섹션(`## `)마다 청크 → 임베딩 → 메모리 저장소. 검색: 질문과 가까운 청크 `top_k`개 | `my-work/index.json` · `my-index`, `my-work/search.json` · `my-search` | `push my-work/index.json my-work/search.json` → `call my-index requests/index-section.json`, `call my-search requests/q-password-length.json`, `test cases/lab01.json` |
| 과제 (15분) | 출처를 섹션 제목까지, 찾을 개수는 부르는 쪽에서 | 두 파일 고치기 | `test cases/lab01-challenge.json`, `test cases/lab01.json` |
(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.)
- 볼 것: 정답표와 검색 1등이 같은지, "연차 휴가"(규정에 없는 질문)의 1등 점수가 다른 질문보다 높은지 낮은지. `RAG-EVAL.md` 2절에 적습니다 (Lab 02의 기준값에 씀).
## 5. 3~4교시 · 2단계 · 규정으로만 답하기, 모르면 넘기기, 답 검사
[Lab 02](LAB-02-Q-ANSWER-FROM-POLICIES.md) (약 70분).
| 순서 | 할 일 | 파일 · 웹훅 | 확인 |
| --- | --- | --- | --- |
| 예제 1 (20분) | 질문 → 가까운 청크 3개 → 번호 붙인 발췌 → LLM이 **발췌로만** 답하고 번호 인용 → 번호를 문서 제목으로, `cited` | `my-work/policy-qa.json` · `my-policy-qa` | `push my-work/policy-qa.json` → `test cases/lab02.json` (앞 네 케이스) |
| 예제 2 (15분) | 1등 점수가 기준값 아래면 LLM을 부르지 않고 담당자 연결 (`min_score`로도 받기) | 같은 파일 | 먼저 `call my-policy-qa requests/q-off-topic.json`의 `top_score` 보기 → `test cases/lab02.json` 전체 |
| 과제 (35분, 4교시) | "규정에서 찾지 못했다"는 답은 자동 답변으로 내보내지 않기, 답의 숫자가 규정에 정말 있는지 검사 | 같은 파일 | 고치기 전 `test cases/lab02-challenge.json` → 고친 뒤 `--repeat 3`, `test cases/lab02.json` |
- `RAG-EVAL.md` 3절에 "기준값에 걸러지지 않았는데 답이 없는 질문"을 적습니다.
## 6. 5~6교시 · 3단계 · 검색 품질을 숫자로 재고, 한 가지씩 고치기
[Lab 03](LAB-03-Q-MEASURE-AND-IMPROVE.md) (약 80분).
| 지표 | 테스트 파일 | 뜻 |
| --- | --- | --- |
| doc@1 | `cases/retrieval-doc.json` | 1등 청크가 맞는 문서에서 왔나 |
| answer@1 | `cases/retrieval-answer.json` | 1등 청크 안에 답 문구가 있나 |
| answer@3 | `cases/retrieval-answer3.json` | 3등 안에 답 문구가 있나 |
| 순서 | 할 일 | 확인 |
| --- | --- | --- |
| 예제 1 (15분) | 섹션 청크로 기준 재기 | `call my-index requests/index-section.json` → 위 세 파일을 `test ... --no-before` |
| 예제 2 (25분) | 청크 크기를 바꿔 가며 재기 (200자 · 400자), 기준값 테스트 | `call my-index requests/index-200.json` → `test cases/retrieval-answer.json --no-before`, `index-400`도 같게, 섹션으로 되돌린 뒤 `test cases/lab03-threshold.json --no-before` |
| 과제 (40분, 6교시) | 직원 말투 질문('챗GPT', '폰', '비번') 6개 이상 `cases/my-cases.json`, 검색 고치기. 지금 잘 되는 문항이 나빠지면 안 됨 | 고치기 전 `test cases/my-cases.json` → `push my-work/search.json` → `test cases/my-cases.json`, `test cases/retrieval-answer.json` |
- 개선 전·후와 **새로 틀린 것**도 적습니다. 보고서: `cp templates/TEST-REPORT-TEMPLATE.md my-work/TEST-REPORT.md`.
## 7. 7~8교시 · 바이브 코딩 `tdoc`
[VIBE-REQ-TDOC](VIBE-REQ-TDOC.md)(요청서)를 **먼저** 읽고 `~/vibe/tdoc/SPEC.md`를 에이전트 없이 직접 쓴 뒤, [VIBE-Q-TDOC](VIBE-Q-TDOC.md)와 비교합니다. 만드는 것은 내 md 문서 폴더를 색인하고 근거와 함께 답하는 TUI (SQLite 하나에 저장). 정답본 `VIBE-A-TDOC.md`는 수업이 끝나면 `git pull`로 받습니다.
```bash
mkdir -p ~/vibe/tdoc && cd ~/vibe/tdoc
git init
export OPENAI_API_KEY=$(grep '^OPENAI_API_KEY=' ~/aio-01d4-stu/Last3Mon/.env | cut -d= -f2-) # 처음부터 필요 (임베딩)
# 에이전트는 SPEC.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
```
| 시간 | 단계 | 확인 |
| --- | --- | --- |
| 16:00~16:12 | 0. 준비 · 요청서 → `SPEC.md` (에이전트 없이 10분) → VIBE-Q와 비교 · commit | 요구마다 확인 방법, 비교한 줄 |
| 16:12~16:25 | 1. 색인과 검색 (R1 · R2) | 두 번째 색인은 바뀐 파일만, 점수 · 파일 · 섹션과 함께 `top_k`개 |
| 16:25~16:35 | 2. 답과 근거 (R3) | 근거 번호, 근거가 없으면 모른다고 |
| 16:35~16:45 | 3. TUI와 비교 (R4 · R5) | 근거를 고르면 원문, 같은 질문 목록으로 두 설정 비교 |
| 16:45~16:50 | 4. 정리 | `SPEC.md` 체크, `uv run pytest -q`(가짜 임베딩), `PROMPTS.md` |
- 8교시: 1~2명 발표, 강사 정리 · 시연 · 팁, 72일차 예고.
## 8. 마친 뒤
| 할 일 | 이유 |
| --- | --- |
| `PROMPTS.md`, `RAG-EVAL.md`, `TEST-REPORT.md` 정리 | 최종 프로젝트 재료 (RAG 측정표) |
| `my-work/*.json`, `~/vibe/tdoc` 지우지 않기 | 72일차 이후 최적화 · 측정 연습 |
| 데이터 API는 71일차 판을 그대로 켜 두기 | 67일차 FAQ · 접수 현황도 들어 있음 |
| 72일차 준비: [00-DAY72-PREP](../../Lecture/72/00-DAY72-PREP.md) 1~2절 | **72일차 판 데이터 API `--force-recreate`** · 기준 워크플로우 게시 · doctor |
## 9. 막혔을 때 (오늘 공통)
| 증상 | 할 일 |
| --- | --- |
| `curl localhost:8800/docs`가 404 | 67일차 판이 켜져 있음. 1.2의 71일차 `--force-recreate` |
| 검색 결과가 비어 있음 · `hits: []` | push로 n8n이 재시작되어 색인이 지워짐. `call my-index requests/index-section.json` |
| Lab 03 숫자가 설정을 바꿔도 같음 | `--no-before`를 빠뜨려 `before`가 섹션 색인으로 되돌림 |
| doctor의 credential ✘ | `cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh` |
| `status 500`이 30초 넘게 | OpenAI 쪽 일시 오류일 수 있음. 한 번 더 |
| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 |
## 확인
- [ ] 전날 71일차 데이터 API, `doctor` → `ready`
- [ ] Lab 01: 정답표, `lab01`, `lab01-challenge`
- [ ] Lab 02: `lab02`, `lab02-challenge`, `RAG-EVAL.md` 3절
- [ ] Lab 03: doc@1 · answer@1 · answer@3, 200자 · 400자, 기준값, 개선 전·후, 보고서
- [ ] `tdoc`: `SPEC.md` 먼저, R1~R5, `pytest`, 단계마다 commit
- [ ] 72일차 준비
+85
View File
@@ -0,0 +1,85 @@
# 71일차 실습 · RAG 기반 규정 검색·응답과 검색 품질 개선 (바이브 코딩)
- 일자: 2026-10-23 (71일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: 운영·확장 관점의 AI 워크플로우 설계 고도화
- 강의 자료: [Lecture/71](../../Lecture/71/README.md)
- 하루 수강 안내: [LECTURE-71.md](LECTURE-71.md) (수업 준비부터 마칠 때까지 순서대로)
- 67~70일차처럼 CLI 코딩 에이전트에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다.
## 오늘 만드는 것: 사내 IT 규정 Q&A
사내 IT 규정 문서 8개(25개 섹션)를 잘게 나누어 임베딩하고, 질문과 뜻이 가까운 부분을 찾아 **그 내용으로만** 답합니다. 검색이 잘 되는지는 질문 24개로 **숫자로** 재고, 한 가지씩 바꿔 가며 고칩니다.
```mermaid
flowchart LR
subgraph IDX["색인 my-index (가끔)"]
D[("data-api /docs<br/>규정 8개")] --> C["청크<br/>섹션 25개"] --> E["임베딩<br/>text-embedding-3-large"] --> V[("메모리 저장소<br/>my_policy_docs")]
end
subgraph QA["질문할 때마다 my-policy-qa"]
Q(["질문"]) --> S["가까운 청크 3개<br/>(my-search)"] --> R{"관련 있나?<br/>≥ 0.33"}
R -->|예| L{{"발췌로만 답<br/>gpt-4.1-mini"}} --> K["답 검사"] --> A(["답 + 출처"])
R -->|아니오| H(["담당자 연결"])
K -->|실패| H
end
V --> S
```
## 일하는 방식 (67~70일차와 같음)
| 누가 | 무엇을 |
| --- | --- |
| 나 | 정답표(질문 → 문서·답 문구) 만들기, 요청문 쓰기, 한 가지씩 바꾸고 재기, 결과 판단 |
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기 |
| `tools/flow.py` | check · push · call · test (`before`로 테스트 전에 색인, `--no-before`로 건너뛰기) |
> **메모리 저장소는 n8n이 재시작되면 지워집니다.** `push`할 때마다 n8n이 재시작되므로 색인을 다시 해야 합니다. 테스트 파일의 `before`가 이것을 해 줍니다. 직접 `call` 할 때는 먼저 `uv run tools/flow.py call my-index -v`.
## 실습 문서 읽는 법
- 실습 문서(`LAB-0x-Q-*.md`)는 요청문을 바로 주지 않습니다. 막히면 `▶` 를 위에서부터 하나씩 펼칩니다: 생각할 거리 → **빈칸 채우기**(요청문 뼈대의 `____`를 내가 채움) → **그래도 막히면**(빈칸을 채운 모양, 내 것과 비교). `VIBE-Q-TDOC.md`는 요청서 → 내 SPEC 순서입니다.
- 쓴 요청문은 `my-work/PROMPTS.md`에 모아 둡니다.
- 바이브 코딩은 **요청서(`VIBE-REQ-TDOC.md`)부터** 읽고 요구(`SPEC.md`)를 직접 뽑은 뒤 `VIBE-Q-TDOC.md`와 비교합니다. 정답본 `VIBE-A-TDOC.md`는 수업이 끝나면 올라옵니다.
## 구성
```text
Last3Mon/71/
├── README.md
├── AGENTS.md # CLI 에이전트용 작업 규칙 (계약 3절, 벡터 저장소 노드 5절)
├── LAB-01-Q-INDEX-AND-SEARCH.md # 1단계 · 정답표 → 색인 · 검색 → 섹션 출처 · top_k
├── LAB-02-Q-ANSWER-FROM-POLICIES.md # 2단계 · 발췌로만 답 · 출처 → 관련 없음 → 답 검사
├── LAB-03-Q-MEASURE-AND-IMPROVE.md # 3단계 · doc@1 · answer@1 · answer@3 → 한 가지씩 바꾸기 → 어려운 질문
├── VIBE-REQ-TDOC.md # 7교시 바이브 코딩 · 요청서 (가장 먼저, 여기서 SPEC.md를 뽑음)
├── VIBE-Q-TDOC.md # 7교시 바이브 코딩 · tdoc 요구 · 단계 (SPEC과 비교)
├── *-A-*.md # 정답본 (VIBE-A는 수업이 끝난 뒤 올라옴, LAB-A는 아직 없음)
├── tools/flow.py
├── data-api/ # 71일차 판 데이터 API (67일차 + 규정 문서 /docs)
│ └── data/docs/*.md # 규정 8개
├── cases/ # lab01 · lab01-challenge · lab02 · lab02-challenge · retrieval-doc · retrieval-answer · retrieval-answer3 · lab03-threshold
├── requests/ # 질문 15종, 색인 설정 3종
├── reference/ # 막혔을 때 이어 가는 워크플로우
├── templates/ # 검색 평가표 · 테스트 보고서
├── my-work/
└── runs/
```
## 준비
[실습 준비](../../Lecture/71/00-DAY71-PREP.md)를 끝내 주세요. 요약:
```bash
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기
cd ~/aio-01d4-stu/Last3Mon/71/data-api && docker compose up -d --force-recreate # 71일차 판으로 바꿈 (/docs 추가)
cd ~/aio-01d4-stu/Last3Mon/71 && uv run tools/flow.py doctor # 마지막 줄 ready
```
## `flow.py` 명령
| 명령 | 하는 일 | 수업 준비 때 |
| --- | --- | --- |
| `uv run tools/flow.py push my-work/index.json my-work/search.json` | 게시 (n8n 재시작 → 메모리 저장소 비워짐) | |
| `uv run tools/flow.py call my-index requests/index-section.json -v` | 색인 (섹션 25개) | 약 1.2초 |
| `uv run tools/flow.py call my-search requests/q-password-length.json -v` | 검색만 | 0.2~0.5초 |
| `uv run tools/flow.py test cases/retrieval-answer.json -v` | `before`로 색인한 뒤 24문항. `-v`는 케이스마다 보낸 요청 JSON과 받은 응답 JSON을 화면에 보여 줌 (`call`도 같음) | 케이스당 0.2~0.5초 |
| `uv run tools/flow.py test cases/retrieval-answer.json -v --no-before` | 지금 색인 그대로 (설정 비교할 때) | |
+130
View File
@@ -0,0 +1,130 @@
# 7교시 바이브 코딩 · `tdoc` 만들기
> **먼저 [VIBE-REQ-TDOC.md](VIBE-REQ-TDOC.md)(요청서)를 읽고 `~/vibe/tdoc/SPEC.md`를 직접 쓴 뒤** 이 문서를 엽니다. 이 문서는 요청서에서 요구를 뽑아 낸 **다음 단계**입니다. 내 SPEC과 비교하는 데 쓰고, 에이전트에게 통째로 붙여 넣지 않습니다.
```mermaid
flowchart LR
REQ["VIBE-REQ<br/>요청서"] --> SPEC["내 SPEC.md<br/>10분"] --> Q["VIBE-Q (이 문서)<br/>비교 · 고치기"] --> B["단계별로 만들기"] --> A["수업 뒤<br/>VIBE-A"]
```
- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시
- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/), SQLite
- 결과물: **내 md 문서**를 임베딩해 SQLite에 저장하고, 질문하면 답과 **근거 문단**을 보여 주는 RAG TUI. `top_k`, 청크 크기를 바꿔 가며 결과를 비교합니다. 오늘 n8n으로 한 일을 내 문서(이 과정의 `Lecture/` 교안 등)에 직접 해 봅니다.
```text
$ tdoc ~/aio-01d4-stu/Lecture
┌ tdoc · 문서 N · 청크 M · section ───────────────────────────────┐
│ 질문: [ 메모리 저장소는 언제 지워져요? ] top_k [3] │
│ ┌ 답 ────────────────────────────────────────────────────────────┐ │
│ │ n8n이 재시작되면 지워집니다 [1]. push할 때마다 … │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ┌ 근거 ──────────────────────────────────────────────────────────┐ │
│ │ [1] 0.xx Lecture/71/01-STAGE1-RAG-AND-INDEX.md · 5. 메모리 저장소 │ │
│ │ [2] 0.xx Lecture/71/00-DAY71-PREP.md · 1. n8n과 데이터 API │ │
└──────────────────────────────────────────────────────────────────┘
```
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
## 요구 사항 (요청서에서 뽑은 것)
| 번호 | 요구 | 요청서의 말 | 확인 방법 |
| --- | --- | --- | --- |
| R1 | 폴더의 `*.md`를 청크로 나눠(섹션 또는 글자 수) 임베딩하고 SQLite 파일 하나에 저장한다. 바뀐 파일만 다시 임베딩한다 | ① 폴더 통째로, 매번 처음부터 다시 읽지 않기, 껐다 켜도 남기 | `tdoc index <폴더>` 두 번 (두 번째는 임베딩 0번), 가짜 임베딩 테스트의 호출 수 |
| R2 | 질문하면 가까운 청크 `top_k`개를 점수·파일·섹션과 함께 보여 준다 | ② 어느 파일 어느 부분, 얼마나 가까운지, 몇 개까지 | `tdoc search "..." --top-k 5` → 5줄, 줄마다 점수 · 파일 · 섹션 |
| R3 | 그 청크들로만 답을 쓰고 `[번호]`로 근거를 단다. 1등 점수가 기준 아래면 LLM을 부르지 않고 모른다고 한다 | ③ 문서 내용으로만, 어디서 나왔는지, 지어내지 말고 모른다고 | `tdoc ask "..."`, 문서에 없는 질문 → "문서에서 찾지 못했습니다" (가짜 LLM 호출 0번) |
| R4 | TUI: 질문 입력, 답, 근거 목록. 근거를 고르면 원문 문단 전체 | ④ 찾은 부분 목록, 고르면 원문 전체 | `tdoc <폴더>` |
| R5 | 청크 설정을 바꿔 **같은 질문 목록**으로 비교한다 (질문 · 기대 파일 → 맞은 수) | ⑤ 자르는 크기를 바꿔 숫자로 | `tdoc eval questions.yaml --chunking 200` → 설정마다 "몇 개 중 몇 개" |
숨은 요구 (요청서에 직접 없지만 없으면 안 되는 것):
| 숨은 요구 | 어디서 나왔나 |
| --- | --- |
| 청크마다 **파일 경로 · 섹션 제목 · 글 · 벡터**를 저장하고, 파일마다 **내용 해시 · 청크 설정**을 저장한다 | ② "어느 파일 어느 부분", ① "바뀐 파일만" |
| 테스트는 **가짜 임베딩 · 가짜 LLM**으로 (진짜를 부르지 않음). 임베딩 · LLM을 바꿔 끼울 수 있게 | "테스트하느라 돈이 나가는 건 싫어요" |
| LLM이 붙인 `[번호]`가 발췌 범위 밖이면 **코드가** 버린다 | ③ "어디서 나왔는지" — 없는 근거를 붙이면 지어낸 것과 같음 (오늘 Lab 02 `cited`는 코드가 만듦) |
| 비교하기 전에 **질문 · 기대 파일 정답표**(`questions.yaml`, 5개 이상)를 먼저 쓴다 | ⑤ "숫자로" (오늘 Lab 01 예제 1) |
| 청크 설정마다 **DB 파일을 따로** (두 설정이 한 DB에 섞이지 않게) | ⑤ 크기를 바꿔 비교 |
| 키는 환경 변수에서만 읽고 코드 · SQLite에 남기지 않는다 | "키가 코드나 저장 파일에 찍히면 안 돼요" |
| 임베딩 · LLM을 부르는 동안 화면이 멈추지 않는다 | ④ (색인 수십 초, 답 수 초) |
범위 밖 (오늘 하지 않음): PDF · 워드 읽기, 여러 사람이 함께 쓰는 서버, 벡터 DB 설치, n8n과 직접 연결하기. (강사 바이브 코딩에서 `tdoc serve`로 n8n에서 부르기를 보여 줄 수 있음)
**비교해 보기**: 내 `SPEC.md`에서 빠진 요구, 내가 더 뽑은 요구, 다르게 정한 순서를 `SPEC.md` 끝에 한 줄씩 적고, 필요하면 내 SPEC을 고칩니다. 아래 단계부터는 **고친 내 SPEC.md**를 기준으로 요청문을 씁니다.
알아 둘 것:
- 임베딩 `text-embedding-3-large`, 답 `gpt-4.1-mini`, 키는 `OPENAI_API_KEY` 환경 변수. **키를 코드에 쓰지 않습니다.**
- 벡터 검색은 SQLite에 숫자 목록(벡터)을 저장하고 파이썬에서 코사인 유사도를 계산해도 충분합니다 (문서 수백 개 규모).
- 오늘 Lab의 `cases/retrieval-*.json`이 R5의 질문 목록 모양입니다.
## 진행 (50분)
```mermaid
flowchart LR
S0["0. 준비 · 요청서 → SPEC<br/>12분"] --> S1["1. 색인 · 검색<br/>R1·R2 · 13분"] --> S2["2. 답 · 근거<br/>R3 · 10분"] --> S3["3. TUI · 비교<br/>R4·R5 · 10분"] --> S4["4. 정리<br/>5분"]
```
### 0. 준비 · 요청서 → SPEC (12분)
```bash
mkdir -p ~/vibe/tdoc && cd ~/vibe/tdoc
git init
export OPENAI_API_KEY=$(grep '^OPENAI_API_KEY=' ~/aio-01d4-stu/Last3Mon/.env | cut -d= -f2-)
# 에이전트는 SPEC.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
```
1. **에이전트는 아직 켜지 않습니다.** [VIBE-REQ-TDOC.md](VIBE-REQ-TDOC.md)를 읽고 `SPEC.md`를 직접 씁니다 (10분).
2. 이 문서의 요구 표 · 숨은 요구와 비교해 `SPEC.md`를 고치고 `git commit`.
3. 그다음 에이전트를 켭니다: `codex` (또는 `claude`, `agy`). 첫 요청은 "`SPEC.md`를 읽고, 1단계를 만들기 전에 확인 방법부터 말해 줘".
### 1. 색인과 검색 (13분)
<details><summary>▶ 1단계 · 저장할 것</summary>
청크마다 무엇을 저장해야 검색 결과를 보여 줄 수 있나요? (파일 경로, 섹션 제목, 글, 벡터) 그리고 "바뀐 파일만 다시"를 하려면 파일마다 무엇을 기억해야 할까요?
</details>
<details><summary>▶ 2단계 · 테스트에서 진짜 임베딩을 부르지 않게</summary>
`tman`의 번역기, `tcrew`의 LLM처럼 임베딩도 바꿔 끼울 수 있게 합니다. 가짜 임베딩(예: 낱말 수를 센 벡터)만으로도 "같은 낱말이 많은 청크가 1등"을 테스트할 수 있습니다.
</details>
**확인**: `uv run tdoc index ~/aio-01d4-stu/Lecture/71`, 다시 한 번(빠른가), `uv run tdoc search "메모리 저장소"`, `pytest`, `git commit`.
### 2. 답과 근거 (10분)
<details><summary>▶ 1단계 · 오늘 n8n에서 배운 것 그대로</summary>
발췌로만 답하기, 번호로 근거, 모르면 모른다고, 점수가 낮으면 LLM을 부르지 않기. 요청문에 이 네 가지가 있나요? Lab 02 과제의 "점수는 높은데 답이 없을 때"도 생각해 봅니다.
</details>
### 3. TUI와 비교 (10분)
<details><summary>▶ 1단계 · 비교는 같은 질문으로</summary>
`questions.yaml`(질문 · 기대 파일)을 직접 5개 써 봅니다. 청크 설정을 바꿔 맞은 수를 표로 보여 주게 합니다. 오늘 Lab 03과 같습니다: 한 번에 하나만 바꾸기.
</details>
**확인**: `uv run tdoc ~/aio-01d4-stu/Lecture`, 근거 선택 → 원문, `tdoc eval`로 두 설정 비교, `git commit`.
### 4. 정리 (5분)
`SPEC.md` 1절 표의 확인 방법대로 하나씩 체크하고, 발표 메모를 씁니다.
| 발표 (3분) | 내용 |
| --- | --- |
| SPEC | 내 SPEC과 VIBE-Q가 달랐던 곳 (빠뜨린 요구, 더 뽑은 요구) |
| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 |
| 막힌 곳 | 다시 색인이 느림, 근거 번호가 틀림 등 어떻게 알아챘고 고쳤나 |
| 시연 | 교안 폴더에 질문 하나, 두 설정의 비교 표 |
## 확인
- [ ] `SPEC.md`를 에이전트 없이 먼저 쓰고, VIBE-Q와 비교한 줄을 남겼다.
- [ ] R1 SQLite 하나에 저장, 두 번째 색인은 바뀐 파일만.
- [ ] R2 점수 · 파일 · 섹션과 함께 `top_k`개.
- [ ] R3 근거 번호, 근거가 없으면 모른다고.
- [ ] R4 TUI에서 근거를 고르면 원문.
- [ ] R5 같은 질문 목록으로 두 설정 비교.
- [ ] `pytest`(가짜 임베딩), 단계마다 git commit, `PROMPTS.md`.
+98
View File
@@ -0,0 +1,98 @@
# 7교시 바이브 코딩 · 요청서 `tdoc` (가장 먼저 읽기)
- 순서: **이 요청서** → 내가 뽑은 요구 `~/vibe/tdoc/SPEC.md` (10분) → [VIBE-Q-TDOC.md](VIBE-Q-TDOC.md)와 비교 → 단계별로 만들기 → 수업이 끝나면 `VIBE-A-TDOC.md`(정답본)로 확인
- 이 문서는 **만들어 달라는 사람의 말**입니다. 요구 사항 표도, 단계도 없습니다. 그것을 뽑아 내는 것이 첫 10분의 일입니다. 68 · 69일차와 같은 순서입니다.
```mermaid
flowchart LR
REQ["요청서<br/>(사람의 말)"] --> SPEC["SPEC.md<br/>확인할 수 있는 요구<br/>숨은 요구 · 범위 밖 · 순서"] --> Q["VIBE-Q와 비교<br/>빠뜨린 것 · 더 뽑은 것"] --> P["단계별 요청문<br/>SPEC.md를 에이전트에게"] --> RUN["실행 · 확인<br/>직접 uv run"] --> A["수업 뒤<br/>VIBE-A로 확인"]
RUN -->|다음 단계| P
```
## 왜 요청서부터인가
VIBE-Q 문서를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 말을 확인할 수 있는 요구로 바꾸는 일은 에이전트가 아니라 내가 합니다. 오늘 오전 Lab 01 예제 1에서 검색을 만들기 전에 **정답표**를 먼저 쓴 것과 같은 일입니다.
| 단계 | 누가 | 결과 |
| --- | --- | --- |
| 요청 → 요구 | **나** | `SPEC.md`: 요구마다 "됐다 / 안 됐다"를 말할 수 있는 확인 방법 |
| 요구 → 단계 | **나** | 작게 나눈 순서 (무엇부터 만들고 확인할지) |
| 단계 → 코드 | 에이전트 | 단계마다 요청문 하나, `SPEC.md`를 함께 읽게 함 |
| 확인 | **나** | 직접 `uv run tdoc …`, `pytest`, 요구 표에 체크 |
## 요청 (IT 문의 창구 담당자에게서 온 메시지, 그대로)
> 오늘 n8n으로 만든 규정 Q&A 잘 봤어요. 그런데 저희 팀 업무 안내랑 교육 자료는 규정 말고도 md 파일로 폴더에 잔뜩 쌓여 있거든요. n8n은 다시 켜면 색인이 날아간다면서요? 제 노트북 터미널에서 **제 폴더로 바로 물어보는** 작은 프로그램이 있으면 좋겠어요.
>
> ① 폴더를 통째로 넣고 싶어요. 그런데 파일이 자주 **조금씩** 바뀌어요. 한 줄 고쳤다고 **매번 처음부터 다시 읽느라 오래 걸리고 돈이 나가면** 곤란해요. 그리고 노트북을 껐다 켜도 남아 있어야 해요.
>
> ② 물어보면 **어느 파일 어느 부분**에서 찾았는지, **얼마나 가까운지** 보고 싶어요. 몇 개까지 볼지는 제가 정할게요.
>
> ③ 답은 **문서에 있는 내용으로만**, 어디서 나왔는지 붙여서 주세요. 문서에 없는 걸 물으면 **지어내지 말고 모른다고** 해야 해요. 그럴듯하게 지어낸 답이 제일 무서워요.
>
> ④ 답 옆에 찾은 부분 목록이 있고, 하나 고르면 그 부분 **원문 전체**를 화면에서 바로 읽을 수 있으면 좋겠어요.
>
> ⑤ 문서를 자르는 크기를 바꿔 보고 싶은데, "좋아진 것 같다"는 말고 **숫자로** 비교하고 싶어요. 오늘 Lab 03에서 하신 것처럼요.
>
> 키는 이미 환경 변수에 넣어 뒀어요. 그런데 **키가 코드나 저장 파일에 찍히면 안 돼요.** 그리고 프로그램을 고칠 때마다 테스트하느라 돈이 나가는 건 싫어요.
## 함께 받은 것
| 파일 · 자료 | 메모 |
| --- | --- |
| `~/aio-01d4-stu/Lecture/` (또는 내 md 폴더) | 넣어 볼 문서 폴더. 일차마다 `##` 섹션이 있는 md 여러 개 |
| `~/aio-01d4-stu/Last3Mon/71/cases/retrieval-doc.json` | 오늘 Lab 03의 "질문 · 기대 문서" 목록 모양 |
| 오늘 만든 `my-work/policy-qa.json` (Lab 02) | 발췌 · 번호 · 기준값 · "찾지 못했습니다"를 어떻게 했는지 |
| [AGENTS.md](AGENTS.md) 3절 | n8n 색인 · 검색 · Q&A의 계약 (`hits`의 `source` · `section` · `score`, `top_score`) |
- LLM 키는 `OPENAI_API_KEY` 환경 변수, 임베딩 `text-embedding-3-large`, 답 `gpt-4.1-mini`.
## 할 일 · 요청을 요구로 (10분, 에이전트 없이)
`~/vibe/tdoc/SPEC.md`를 만들고 아래 네 가지를 씁니다. **에이전트에게 맡기지 않고 직접** 씁니다 (나중에 에이전트에게 읽힐 문서입니다).
```markdown
# tdoc SPEC
## 1. 요구 (확인할 수 있게)
| 번호 | 요구 | 요청의 어느 말 | 확인 방법 (파일 · 숫자 · 화면) |
| --- | --- | --- | --- |
| R1 | | ① | |
## 2. 숨은 요구 (요청에 직접 없지만 없으면 안 되는 것)
## 3. 범위 밖 (오늘 50분 안에 하지 않을 것)
## 4. 만드는 순서 (그리고 그 이유)
```
예 하나만 보여 드립니다. 나머지는 직접 바꿔 봅니다.
| 요청의 말 | 그대로 요청문에 넣으면 | 확인할 수 있는 요구로 |
| --- | --- | --- |
| ① "한 줄 고쳤다고 매번 처음부터 다시 읽느라 오래 걸리고 돈이 나가면 곤란해요" | "색인을 빠르게 해 줘" → 무엇이 되든 "됐다"가 됨 | **파일마다 내용 해시를 저장하고, 해시가 같은 파일은 다시 임베딩하지 않는다.** 확인: 가짜 임베딩으로 파일 2개를 색인 → 바로 다시 색인하면 임베딩 호출 **0번**, 한 파일만 고치면 **그 파일의 청크 수만큼만** 호출 |
<details><summary>▶ 1단계 · "얼마나 가까운지", "모른다고", "숫자로"를 어떻게 확인하나</summary>
형용사와 부탁은 확인할 수 없습니다. "몇 번 불리면", "어느 파일에 무엇이 남으면", "화면에 무엇이 몇 개 보이면"으로 바꿉니다. ③ "모른다고"는 **언제** 모른다고 하나요? 오늘 Lab 02에서 LLM을 부르기 전에 무엇을 보고 넘겼는지 떠올립니다. 그리고 모른다고 할 때 화면에 **어떤 문장**이 나와야 확인할 수 있나요?
</details>
<details><summary>▶ 2단계 · 숨은 요구</summary>
- ②를 보여 주려면 청크마다 **무엇을 저장**해 두어야 하나요? (파일, 섹션, 글, 벡터 …) ①의 "바뀐 파일만"을 하려면 파일마다 무엇을 기억해야 하나요?
- "테스트하느라 돈이 나가는 건 싫어요" → 테스트에서 진짜 임베딩 · 진짜 LLM을 부르지 않으려면 무엇이 있어야 하나요?
- ③ "어디서 나왔는지 붙여서": LLM이 없는 번호(`[7]`)를 붙이면 어떻게 하나요? 그것은 누가 확인하나요?
- ⑤ "숫자로 비교"하려면 프로그램보다 먼저 **무엇**이 있어야 하나요? (오늘 Lab 01 예제 1)
- ⑤ 크기를 바꿔 색인하면, 앞서 다른 크기로 만든 색인은 어떻게 되나요? 두 설정이 섞이면 비교가 될까요?
- 임베딩 · LLM을 부르는 동안 화면은 어떻게 되어야 할까요?
</details>
<details><summary>▶ 3단계 · 범위 밖과 순서</summary>
50분입니다. PDF · 워드 파일 읽기, 여러 사람이 함께 쓰는 서버, 벡터 DB 설치, n8n과 직접 연결하기는 요청에 없습니다. 순서는 "무엇을 먼저 만들면 나머지가 그것을 다시 쓰나"로 정합니다. 답(③) · 화면(④) · 비교(⑤)가 모두 **같은 것**(색인과, 질문으로 찾은 청크 목록)을 씁니다.
</details>
## 그다음
1. [VIBE-Q-TDOC.md](VIBE-Q-TDOC.md)를 열어 내 `SPEC.md`와 비교합니다. 빠뜨린 요구, 내가 더 뽑은 요구, 다르게 정한 순서를 `SPEC.md` 끝에 한 줄씩 적습니다 (8교시 발표 거리).
2. 단계마다 요청문을 쓸 때 "`SPEC.md`를 읽고 4절의 1단계만 해 줘"처럼 **내 SPEC을 기준으로** 시킵니다. VIBE-Q 문서를 통째로 붙여 넣지 않습니다.
3. 단계가 끝날 때마다 `SPEC.md` 1절 표의 확인 방법대로 **내가** 확인하고 체크합니다.
4. 수업이 끝나면 `git pull`로 정답본 `VIBE-A-TDOC.md`를 받아, 요구 도출 예시와 요청문 예를 내 것과 비교합니다.
+58
View File
@@ -0,0 +1,58 @@
{
"webhook": "my-search",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "섹션 · 비밀번호 길이",
"request": "requests/q-password-length.json",
"expect": {
"contains": {
"hits.0.section": [
"비밀번호 만들기"
]
}
}
},
{
"name": "섹션 · 변경 주기",
"request": "requests/q-password-cycle.json",
"expect": {
"contains": {
"hits.0.section": [
"변경 주기"
]
}
}
},
{
"name": "top_k 5",
"body": {
"question": "컴퓨터가 바이러스에 걸린 것 같아요",
"top_k": 5
},
"expect": {
"equals": {
"hits.4.rank": 5
}
}
},
{
"name": "top_k 1",
"body": {
"question": "컴퓨터가 바이러스에 걸린 것 같아요",
"top_k": 1
},
"expect": {
"equals": {
"hits.1": null,
"hits.0.rank": 1
}
}
}
]
}
+69
View File
@@ -0,0 +1,69 @@
{
"webhook": "my-search",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "색인 · 청크 수",
"webhook": "my-index",
"request": "requests/index-section.json",
"expect": {
"equals": {
"indexed_chunks": 25
}
}
},
{
"name": "비밀번호 길이 → 비밀번호 규정",
"request": "requests/q-password-length.json",
"expect": {
"equals": {
"hits.0.source": "password-policy"
},
"contains": {
"hits.0.text": [
"12자"
]
}
}
},
{
"name": "퇴사 노트북 → 장비 규정",
"request": "requests/q-return-laptop.json",
"expect": {
"equals": {
"hits.0.source": "device-issue-return"
}
}
},
{
"name": "첨부 파일 → 메일 규정",
"request": "requests/q-big-attachment.json",
"expect": {
"equals": {
"hits.0.source": "email-and-documents"
}
}
},
{
"name": "결과 3개",
"request": "requests/q-virus.json",
"expect": {
"equals": {
"hits.2.rank": 3
}
}
},
{
"name": "질문 없음",
"request": "requests/q-empty.json",
"expect": {
"status": 400
}
}
]
}
+98
View File
@@ -0,0 +1,98 @@
{
"webhook": "my-policy-qa",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "점수는 높은데 규정에 없음 (VPN 속도)",
"request": "requests/q-vpn-slow.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "점수는 높은데 규정에 없음 (노트북 사양)",
"request": "requests/q-laptop-spec.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "점수는 높은데 규정에 없음 (비밀번호 잊음)",
"request": "requests/q-forgot-password.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "점수는 높은데 규정에 없음 (보안 교육)",
"request": "requests/q-security-training.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "점수가 낮아 걸러짐 (연차)",
"request": "requests/q-not-in-policies.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "틀린 전제 (30일)",
"request": "requests/q-wrong-premise.json",
"expect": {
"contains": {
"answer": [
"90일"
]
},
"not_contains": {
"answer": [
"30일마다 바꾸면 됩니다"
]
}
}
},
{
"name": "근거 있는 답은 그대로",
"request": "requests/q-password-cycle.json",
"expect": {
"equals": {
"route": "자동 답변",
"failed": []
},
"contains": {
"answer": [
"90일"
]
}
}
},
{
"name": "검사 기록",
"request": "requests/q-virus.json",
"expect": {
"contains": {
"checks": [
"grounded"
]
}
}
}
]
}
+94
View File
@@ -0,0 +1,94 @@
{
"webhook": "my-policy-qa",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "비밀번호 주기 · 답과 출처",
"request": "requests/q-password-cycle.json",
"expect": {
"equals": {
"route": "자동 답변"
},
"contains": {
"answer": [
"90일"
],
"cited": [
"password-policy"
]
}
}
},
{
"name": "퇴사 노트북",
"request": "requests/q-return-laptop.json",
"expect": {
"contains": {
"answer": [
"5영업일"
],
"cited": [
"device-issue-return"
]
}
}
},
{
"name": "생성형 AI",
"request": "requests/q-ai-customer-data.json",
"expect": {
"contains": {
"cited": [
"software-usage"
]
},
"not_contains": {
"answer": [
"넣어도 됩니다"
]
}
}
},
{
"name": "출처는 제목으로",
"request": "requests/q-big-attachment.json",
"expect": {
"contains": {
"answer": [
"[메일·문서 보안 규정]",
"25MB"
]
},
"not_contains": {
"answer": [
"[1]",
"[2]",
"[3]"
]
}
}
},
{
"name": "점심 메뉴 · 담당자 연결",
"request": "requests/q-off-topic.json",
"expect": {
"equals": {
"route": "담당자 연결",
"cited": []
}
}
},
{
"name": "질문 없음",
"request": "requests/q-empty.json",
"expect": {
"status": 400
}
}
]
}
+108
View File
@@ -0,0 +1,108 @@
{
"webhook": "my-policy-qa",
"description": "relevance threshold: policy questions answered, others handed off",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "규정 질문 · 암호를 얼마나 자주 바꿔야 해?",
"request": "requests/q-password-cycle.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "규정 질문 · 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?",
"request": "requests/q-return-laptop.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "규정 질문 · 첨부 파일이 너무 커서 메일이 안 보내져요",
"request": "requests/q-big-attachment.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "규정 질문 · 생성형 AI에 고객 자료를 넣어도 되나요?",
"request": "requests/q-ai-customer-data.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "규정 질문 · 컴퓨터가 바이러스에 걸린 것 같아요",
"request": "requests/q-virus.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "규정 밖 · 오늘 점심 메뉴 추천해줘",
"request": "requests/q-off-topic.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "규정 밖 · 연차 휴가는 며칠이에요?",
"request": "requests/q-not-in-policies.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "규정 밖 · 주말에 볼 영화 추천해 주세요",
"body": {
"question": "주말에 볼 영화 추천해 주세요"
},
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "규정 밖 · 회사 근처 맛집 알려 줘",
"body": {
"question": "회사 근처 맛집 알려 줘"
},
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "규정 밖 · 올해 연봉 인상률은요?",
"body": {
"question": "올해 연봉 인상률은요?"
},
"expect": {
"equals": {
"route": "담당자 연결"
}
}
}
]
}
+324
View File
@@ -0,0 +1,324 @@
{
"webhook": "my-search",
"description": "answer@1: the first chunk contains the answer phrase",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "비밀번호는 몇 자 이상이어야 하나요?",
"body": {
"question": "비밀번호는 몇 자 이상이어야 하나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"12자"
]
}
}
},
{
"name": "암호를 얼마나 자주 바꿔야 해?",
"body": {
"question": "암호를 얼마나 자주 바꿔야 해?"
},
"expect": {
"contains": {
"hits.0.text": [
"90일"
]
}
}
},
{
"name": "로그인을 여러 번 틀리면 어떻게 돼요?",
"body": {
"question": "로그인을 여러 번 틀리면 어떻게 돼요?"
},
"expect": {
"contains": {
"hits.0.text": [
"30분"
]
}
}
},
{
"name": "카페 와이파이로 회사 시스템에 접속해도 되나요?",
"body": {
"question": "카페 와이파이로 회사 시스템에 접속해도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"공용 와이파이"
]
}
}
},
{
"name": "VPN 인증서는 언제 다시 받아야 해요?",
"body": {
"question": "VPN 인증서는 언제 다시 받아야 해요?"
},
"expect": {
"contains": {
"hits.0.text": [
"30일 전"
]
}
}
},
{
"name": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?",
"body": {
"question": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"5영업일"
]
}
}
},
{
"name": "노트북을 잃어버렸어요. 어떻게 해야 하나요?",
"body": {
"question": "노트북을 잃어버렸어요. 어떻게 해야 하나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"24시간"
]
}
}
},
{
"name": "개인 휴대폰으로 업무 메일 봐도 되나요?",
"body": {
"question": "개인 휴대폰으로 업무 메일 봐도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"MDM"
]
}
}
},
{
"name": "새 프로그램 설치 승인은 며칠 걸려요?",
"body": {
"question": "새 프로그램 설치 승인은 며칠 걸려요?"
},
"expect": {
"contains": {
"hits.0.text": [
"2영업일"
]
}
}
},
{
"name": "생성형 AI에 고객 자료를 넣어도 되나요?",
"body": {
"question": "생성형 AI에 고객 자료를 넣어도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"입력하지 않는다"
]
}
}
},
{
"name": "첨부 파일이 너무 커서 메일이 안 보내져요",
"body": {
"question": "첨부 파일이 너무 커서 메일이 안 보내져요"
},
"expect": {
"contains": {
"hits.0.text": [
"25MB"
]
}
}
},
{
"name": "고객 주민등록번호를 받아도 되나요?",
"body": {
"question": "고객 주민등록번호를 받아도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"주민등록번호는"
]
}
}
},
{
"name": "고객 정보가 밖으로 샌 것 같아요",
"body": {
"question": "고객 정보가 밖으로 샌 것 같아요"
},
"expect": {
"contains": {
"hits.0.text": [
"1시간"
]
}
}
},
{
"name": "회의실을 하루 종일 잡아도 되나요?",
"body": {
"question": "회의실을 하루 종일 잡아도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"3시간"
]
}
}
},
{
"name": "수상한 메일의 링크를 눌렀어요",
"body": {
"question": "수상한 메일의 링크를 눌렀어요"
},
"expect": {
"contains": {
"hits.0.text": [
"비밀번호를 바꾸"
]
}
}
},
{
"name": "컴퓨터가 바이러스에 걸린 것 같아요",
"body": {
"question": "컴퓨터가 바이러스에 걸린 것 같아요"
},
"expect": {
"contains": {
"hits.0.text": [
"랜선"
]
}
}
},
{
"name": "OTP 앱 깔린 휴대폰을 새로 바꿨어요",
"body": {
"question": "OTP 앱 깔린 휴대폰을 새로 바꿨어요"
},
"expect": {
"contains": {
"hits.0.text": [
"다시 등록"
]
}
}
},
{
"name": "출력한 고객 명단은 어떻게 버려요?",
"body": {
"question": "출력한 고객 명단은 어떻게 버려요?"
},
"expect": {
"contains": {
"hits.0.text": [
"파쇄"
]
}
}
},
{
"name": "개인정보 파일을 거래처에 메일로 보내도 되나요?",
"body": {
"question": "개인정보 파일을 거래처에 메일로 보내도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"암호"
]
}
}
},
{
"name": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?",
"body": {
"question": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?"
},
"expect": {
"contains": {
"hits.0.text": [
"5분"
]
}
}
},
{
"name": "화상회의 장비는 언제까지 요청해야 하나요?",
"body": {
"question": "화상회의 장비는 언제까지 요청해야 하나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"30분 전"
]
}
}
},
{
"name": "노트북 배터리가 금방 닳아요",
"body": {
"question": "노트북 배터리가 금방 닳아요"
},
"expect": {
"contains": {
"hits.0.text": [
"배터리 교체"
]
}
}
},
{
"name": "공짜 프로그램은 그냥 써도 되죠?",
"body": {
"question": "공짜 프로그램은 그냥 써도 되죠?"
},
"expect": {
"contains": {
"hits.0.text": [
"라이선스"
]
}
}
},
{
"name": "감염된 것 같은데 컴퓨터를 꺼도 되나요?",
"body": {
"question": "감염된 것 같은데 컴퓨터를 꺼도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"끄거나"
]
}
}
}
]
}
+324
View File
@@ -0,0 +1,324 @@
{
"webhook": "my-search",
"description": "answer@3: one of the top 3 chunks contains the answer phrase",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "비밀번호는 몇 자 이상이어야 하나요?",
"body": {
"question": "비밀번호는 몇 자 이상이어야 하나요?"
},
"expect": {
"contains": {
"hits": [
"12자"
]
}
}
},
{
"name": "암호를 얼마나 자주 바꿔야 해?",
"body": {
"question": "암호를 얼마나 자주 바꿔야 해?"
},
"expect": {
"contains": {
"hits": [
"90일"
]
}
}
},
{
"name": "로그인을 여러 번 틀리면 어떻게 돼요?",
"body": {
"question": "로그인을 여러 번 틀리면 어떻게 돼요?"
},
"expect": {
"contains": {
"hits": [
"30분"
]
}
}
},
{
"name": "카페 와이파이로 회사 시스템에 접속해도 되나요?",
"body": {
"question": "카페 와이파이로 회사 시스템에 접속해도 되나요?"
},
"expect": {
"contains": {
"hits": [
"공용 와이파이"
]
}
}
},
{
"name": "VPN 인증서는 언제 다시 받아야 해요?",
"body": {
"question": "VPN 인증서는 언제 다시 받아야 해요?"
},
"expect": {
"contains": {
"hits": [
"30일 전"
]
}
}
},
{
"name": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?",
"body": {
"question": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?"
},
"expect": {
"contains": {
"hits": [
"5영업일"
]
}
}
},
{
"name": "노트북을 잃어버렸어요. 어떻게 해야 하나요?",
"body": {
"question": "노트북을 잃어버렸어요. 어떻게 해야 하나요?"
},
"expect": {
"contains": {
"hits": [
"24시간"
]
}
}
},
{
"name": "개인 휴대폰으로 업무 메일 봐도 되나요?",
"body": {
"question": "개인 휴대폰으로 업무 메일 봐도 되나요?"
},
"expect": {
"contains": {
"hits": [
"MDM"
]
}
}
},
{
"name": "새 프로그램 설치 승인은 며칠 걸려요?",
"body": {
"question": "새 프로그램 설치 승인은 며칠 걸려요?"
},
"expect": {
"contains": {
"hits": [
"2영업일"
]
}
}
},
{
"name": "생성형 AI에 고객 자료를 넣어도 되나요?",
"body": {
"question": "생성형 AI에 고객 자료를 넣어도 되나요?"
},
"expect": {
"contains": {
"hits": [
"입력하지 않는다"
]
}
}
},
{
"name": "첨부 파일이 너무 커서 메일이 안 보내져요",
"body": {
"question": "첨부 파일이 너무 커서 메일이 안 보내져요"
},
"expect": {
"contains": {
"hits": [
"25MB"
]
}
}
},
{
"name": "고객 주민등록번호를 받아도 되나요?",
"body": {
"question": "고객 주민등록번호를 받아도 되나요?"
},
"expect": {
"contains": {
"hits": [
"주민등록번호는"
]
}
}
},
{
"name": "고객 정보가 밖으로 샌 것 같아요",
"body": {
"question": "고객 정보가 밖으로 샌 것 같아요"
},
"expect": {
"contains": {
"hits": [
"1시간"
]
}
}
},
{
"name": "회의실을 하루 종일 잡아도 되나요?",
"body": {
"question": "회의실을 하루 종일 잡아도 되나요?"
},
"expect": {
"contains": {
"hits": [
"3시간"
]
}
}
},
{
"name": "수상한 메일의 링크를 눌렀어요",
"body": {
"question": "수상한 메일의 링크를 눌렀어요"
},
"expect": {
"contains": {
"hits": [
"비밀번호를 바꾸"
]
}
}
},
{
"name": "컴퓨터가 바이러스에 걸린 것 같아요",
"body": {
"question": "컴퓨터가 바이러스에 걸린 것 같아요"
},
"expect": {
"contains": {
"hits": [
"랜선"
]
}
}
},
{
"name": "OTP 앱 깔린 휴대폰을 새로 바꿨어요",
"body": {
"question": "OTP 앱 깔린 휴대폰을 새로 바꿨어요"
},
"expect": {
"contains": {
"hits": [
"다시 등록"
]
}
}
},
{
"name": "출력한 고객 명단은 어떻게 버려요?",
"body": {
"question": "출력한 고객 명단은 어떻게 버려요?"
},
"expect": {
"contains": {
"hits": [
"파쇄"
]
}
}
},
{
"name": "개인정보 파일을 거래처에 메일로 보내도 되나요?",
"body": {
"question": "개인정보 파일을 거래처에 메일로 보내도 되나요?"
},
"expect": {
"contains": {
"hits": [
"암호"
]
}
}
},
{
"name": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?",
"body": {
"question": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?"
},
"expect": {
"contains": {
"hits": [
"5분"
]
}
}
},
{
"name": "화상회의 장비는 언제까지 요청해야 하나요?",
"body": {
"question": "화상회의 장비는 언제까지 요청해야 하나요?"
},
"expect": {
"contains": {
"hits": [
"30분 전"
]
}
}
},
{
"name": "노트북 배터리가 금방 닳아요",
"body": {
"question": "노트북 배터리가 금방 닳아요"
},
"expect": {
"contains": {
"hits": [
"배터리 교체"
]
}
}
},
{
"name": "공짜 프로그램은 그냥 써도 되죠?",
"body": {
"question": "공짜 프로그램은 그냥 써도 되죠?"
},
"expect": {
"contains": {
"hits": [
"라이선스"
]
}
}
},
{
"name": "감염된 것 같은데 컴퓨터를 꺼도 되나요?",
"body": {
"question": "감염된 것 같은데 컴퓨터를 꺼도 되나요?"
},
"expect": {
"contains": {
"hits": [
"끄거나"
]
}
}
}
]
}
+276
View File
@@ -0,0 +1,276 @@
{
"webhook": "my-search",
"description": "doc@1: the first chunk comes from the right document (24 questions)",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "비밀번호는 몇 자 이상이어야 하나요?",
"body": {
"question": "비밀번호는 몇 자 이상이어야 하나요?"
},
"expect": {
"equals": {
"hits.0.source": "password-policy"
}
}
},
{
"name": "암호를 얼마나 자주 바꿔야 해?",
"body": {
"question": "암호를 얼마나 자주 바꿔야 해?"
},
"expect": {
"equals": {
"hits.0.source": "password-policy"
}
}
},
{
"name": "로그인을 여러 번 틀리면 어떻게 돼요?",
"body": {
"question": "로그인을 여러 번 틀리면 어떻게 돼요?"
},
"expect": {
"equals": {
"hits.0.source": "password-policy"
}
}
},
{
"name": "카페 와이파이로 회사 시스템에 접속해도 되나요?",
"body": {
"question": "카페 와이파이로 회사 시스템에 접속해도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "remote-work-vpn"
}
}
},
{
"name": "VPN 인증서는 언제 다시 받아야 해요?",
"body": {
"question": "VPN 인증서는 언제 다시 받아야 해요?"
},
"expect": {
"equals": {
"hits.0.source": "remote-work-vpn"
}
}
},
{
"name": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?",
"body": {
"question": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?"
},
"expect": {
"equals": {
"hits.0.source": "device-issue-return"
}
}
},
{
"name": "노트북을 잃어버렸어요. 어떻게 해야 하나요?",
"body": {
"question": "노트북을 잃어버렸어요. 어떻게 해야 하나요?"
},
"expect": {
"equals": {
"hits.0.source": "device-issue-return"
}
}
},
{
"name": "개인 휴대폰으로 업무 메일 봐도 되나요?",
"body": {
"question": "개인 휴대폰으로 업무 메일 봐도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "device-issue-return"
}
}
},
{
"name": "새 프로그램 설치 승인은 며칠 걸려요?",
"body": {
"question": "새 프로그램 설치 승인은 며칠 걸려요?"
},
"expect": {
"equals": {
"hits.0.source": "software-usage"
}
}
},
{
"name": "생성형 AI에 고객 자료를 넣어도 되나요?",
"body": {
"question": "생성형 AI에 고객 자료를 넣어도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "software-usage"
}
}
},
{
"name": "첨부 파일이 너무 커서 메일이 안 보내져요",
"body": {
"question": "첨부 파일이 너무 커서 메일이 안 보내져요"
},
"expect": {
"equals": {
"hits.0.source": "email-and-documents"
}
}
},
{
"name": "고객 주민등록번호를 받아도 되나요?",
"body": {
"question": "고객 주민등록번호를 받아도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "privacy"
}
}
},
{
"name": "고객 정보가 밖으로 샌 것 같아요",
"body": {
"question": "고객 정보가 밖으로 샌 것 같아요"
},
"expect": {
"equals": {
"hits.0.source": "privacy"
}
}
},
{
"name": "회의실을 하루 종일 잡아도 되나요?",
"body": {
"question": "회의실을 하루 종일 잡아도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "meeting-room"
}
}
},
{
"name": "수상한 메일의 링크를 눌렀어요",
"body": {
"question": "수상한 메일의 링크를 눌렀어요"
},
"expect": {
"equals": {
"hits.0.source": "security-incident"
}
}
},
{
"name": "컴퓨터가 바이러스에 걸린 것 같아요",
"body": {
"question": "컴퓨터가 바이러스에 걸린 것 같아요"
},
"expect": {
"equals": {
"hits.0.source": "security-incident"
}
}
},
{
"name": "OTP 앱 깔린 휴대폰을 새로 바꿨어요",
"body": {
"question": "OTP 앱 깔린 휴대폰을 새로 바꿨어요"
},
"expect": {
"equals": {
"hits.0.source": "password-policy"
}
}
},
{
"name": "출력한 고객 명단은 어떻게 버려요?",
"body": {
"question": "출력한 고객 명단은 어떻게 버려요?"
},
"expect": {
"equals": {
"hits.0.source": "privacy"
}
}
},
{
"name": "개인정보 파일을 거래처에 메일로 보내도 되나요?",
"body": {
"question": "개인정보 파일을 거래처에 메일로 보내도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "email-and-documents"
}
}
},
{
"name": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?",
"body": {
"question": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?"
},
"expect": {
"equals": {
"hits.0.source": "remote-work-vpn"
}
}
},
{
"name": "화상회의 장비는 언제까지 요청해야 하나요?",
"body": {
"question": "화상회의 장비는 언제까지 요청해야 하나요?"
},
"expect": {
"equals": {
"hits.0.source": "meeting-room"
}
}
},
{
"name": "노트북 배터리가 금방 닳아요",
"body": {
"question": "노트북 배터리가 금방 닳아요"
},
"expect": {
"equals": {
"hits.0.source": "device-issue-return"
}
}
},
{
"name": "공짜 프로그램은 그냥 써도 되죠?",
"body": {
"question": "공짜 프로그램은 그냥 써도 되죠?"
},
"expect": {
"equals": {
"hits.0.source": "software-usage"
}
}
},
{
"name": "감염된 것 같은데 컴퓨터를 꺼도 되나요?",
"body": {
"question": "감염된 것 같은데 컴퓨터를 꺼도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "security-incident"
}
}
}
]
}
+27
View File
@@ -0,0 +1,27 @@
# Sample company data API for day 71 (day 67 API + policy documents), attached to the day 65 n8n network
# (n8n installed in ~/day65-n8n, Docker project day65-n8n, network day65-n8n_default).
# docker compose up -d (run from this folder, after day 65 n8n is running)
# from n8n : http://data-api:8800
# from WSL : http://localhost:8800
name: day67-data
services:
data-api:
image: python:3.12-slim
command: python /app/server.py
environment:
- DATA_DIR=/data
- PYTHONUNBUFFERED=1
ports:
- "8800:8800"
volumes:
- ./server.py:/app/server.py:ro
- ./data:/data:ro
networks:
- n8n
restart: unless-stopped
networks:
n8n:
external: true
name: day65-n8n_default
@@ -0,0 +1,17 @@
# 업무용 장비 지급·반납 규정
## 1. 지급과 교체
- 업무용 노트북은 입사일에 지급한다.
- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.
## 2. 분실과 도난
- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.
- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.
## 3. 반납
- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.
- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.
## 4. 개인 장비 사용
- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.
- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.
@@ -0,0 +1,14 @@
# 메일·문서 보안 규정
## 1. 메일함
- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.
- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.
## 2. 첨부 파일
- 메일 첨부 파일은 25MB까지 보낼 수 있다.
- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.
- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.
## 3. 문서 등급과 보관
- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.
- 업무 문서는 3년 동안 보관한 뒤 정리한다.
@@ -0,0 +1,12 @@
# 회의실 이용 규정
## 1. 예약
- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.
- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.
## 2. 장비
- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.
- 프로젝터는 B회의실과 대회의실에 있다.
## 3. 사용 후
- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.
@@ -0,0 +1,18 @@
# 비밀번호·계정 보안 규정
## 1. 비밀번호 만들기
- 비밀번호는 12자 이상으로 만든다.
- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.
- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.
## 2. 변경 주기
- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.
- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.
## 3. 로그인 실패와 잠금
- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.
- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.
## 4. 다단계 인증
- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.
- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.
+13
View File
@@ -0,0 +1,13 @@
# 개인정보 처리 지침
## 1. 수집
- 업무에 꼭 필요한 개인정보만 수집한다.
- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.
## 2. 이용과 보관
- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.
- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.
## 3. 유출 대응
- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.
- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.
@@ -0,0 +1,15 @@
# 원격근무·VPN 이용 규정
## 1. 접속 원칙
- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.
- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.
## 2. VPN 인증서
- VPN 인증서의 유효 기간은 1년이다.
- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.
- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.
## 3. 재택근무 중 보안
- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다.
- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다.
- VPN 접속 기록은 1년 동안 보관된다.
@@ -0,0 +1,11 @@
# 보안 사고 대응 절차
## 1. 피싱 메일
- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다.
- 메일 화면의 '피싱 신고' 버튼으로 신고한다.
- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다.
## 2. 악성코드 감염 의심
- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다.
- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다.
- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다.
@@ -0,0 +1,14 @@
# 소프트웨어 사용 규정
## 1. 설치와 승인
- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다.
- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다.
- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다.
## 2. 금지 사항
- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다.
## 3. 생성형 AI 서비스
- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다.
- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다.
- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다.
+14
View File
@@ -0,0 +1,14 @@
[
{"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"],
"answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."},
{"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"],
"answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."},
{"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"],
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."},
{"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"],
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."},
{"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"],
"answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."},
{"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"],
"answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
]
+6
View File
@@ -0,0 +1,6 @@
{
"T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다."},
"T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다."},
"T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다."},
"T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요."}
}
+83
View File
@@ -0,0 +1,83 @@
"""Sample company data API for the day 71 labs (day 67 API + policy documents; Python standard library only).
GET /health -> {"status": "ok"}
GET /faq?q=<question> -> {"query": ..., "results": [FAQ entries]}
GET /tickets/<id> -> ticket record, or 404 {"error": "ticket not found"}
GET /tickets/<id>?requester=<name>
-> same, but 403 {"error": "not your ticket"} when the ticket belongs to someone else
GET /docs -> {"docs": [{"name", "title"}]} company IT policy documents (markdown)
GET /docs/<name> -> {"name", "title", "text"}, or 404 {"error": "doc not found"}
"""
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
DATA_DIR = Path(os.getenv("DATA_DIR", Path(__file__).resolve().parent / "data"))
PORT = int(os.getenv("PORT", "8800"))
def load(name):
return json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
def search_faq(query, top_k=2):
words = {word for word in query.lower().split() if len(word) > 1}
scored = []
for item in load("faq.json"):
keywords = {keyword.lower() for keyword in item["keywords"]}
score = sum(1 for word in words if any(keyword in word or word in keyword for keyword in keywords))
if score:
scored.append({"score": score, **item})
scored.sort(key=lambda item: item["score"], reverse=True)
return scored[:top_k]
class Handler(BaseHTTPRequestHandler):
def send_json(self, status, body):
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
# Raw UTF-8 in the request line arrives as latin-1 text; percent-encoded paths are unchanged.
url = urlparse(self.path.encode("latin-1").decode("utf-8", errors="replace"))
if url.path == "/health":
return self.send_json(200, {"status": "ok"})
if url.path == "/faq":
query = parse_qs(url.query).get("q", [""])[0]
return self.send_json(200, {"query": query, "results": search_faq(query)})
if url.path == "/docs":
docs = [{"name": f.stem, "title": f.read_text(encoding="utf-8").splitlines()[0].lstrip("# ").strip()}
for f in sorted((DATA_DIR / "docs").glob("*.md"))]
return self.send_json(200, {"docs": docs})
if url.path.startswith("/docs/"):
name = unquote(url.path.removeprefix("/docs/")).strip()
path = DATA_DIR / "docs" / f"{name}.md"
if "/" in name or not path.exists():
return self.send_json(404, {"error": "doc not found", "name": name})
text = path.read_text(encoding="utf-8")
return self.send_json(200, {"name": name, "title": text.splitlines()[0].lstrip("# ").strip(), "text": text})
if url.path.startswith("/tickets/"):
ticket_id = unquote(url.path.removeprefix("/tickets/")).strip().upper()
ticket = load("tickets.json").get(ticket_id)
if ticket is None:
return self.send_json(404, {"error": "ticket not found", "ticket_id": ticket_id})
requester = parse_qs(url.query).get("requester", [None])[0]
if requester is not None and requester.strip() != ticket["requester"]:
return self.send_json(403, {"error": "not your ticket", "ticket_id": ticket_id})
return self.send_json(200, {"ticket_id": ticket_id, **ticket})
return self.send_json(404, {"error": "unknown path"})
def log_message(self, fmt, *args):
print(f"{self.address_string()} {fmt % args}", flush=True)
if __name__ == "__main__":
print(f"data-api listening on :{PORT} (data: {DATA_DIR})", flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
+5
View File
@@ -0,0 +1,5 @@
# 수업 결과물
- CLI 코딩 에이전트가 만든 워크플로우 JSON (`index.json`, `search.json`, `policy-qa.json`)이 여기에 저장됩니다 (git에 올라가지 않음).
- `RAG-EVAL.md`: [검색 평가표](../templates/RAG-EVAL-TEMPLATE.md)를 복사해 채웁니다.
- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다.
@@ -0,0 +1,271 @@
{
"name": "71 Index policy docs (reference · Lab 01 example 2)",
"nodes": [
{
"name": "Index webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-index",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "List docs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
200,
300
],
"parameters": {
"url": "http://data-api:8800/docs",
"options": {}
}
},
{
"name": "One item per doc",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [
400,
300
],
"parameters": {
"fieldToSplitOut": "docs",
"options": {}
}
},
{
"name": "Get doc",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
600,
300
],
"parameters": {
"url": "=http://data-api:8800/docs/{{ $json.name }}",
"options": {}
}
},
{
"name": "Make chunks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
800,
300
],
"parameters": {
"jsCode": "// Split each policy document into chunks. Body \"chunking\": \"section\" (one chunk per ## section, default) or a number of characters.\nconst body = $('Index webhook').first().json.body ?? {};\nconst CHUNKING = body.chunking ?? 'section';\nconst chunks = [];\nfor (const item of $input.all()) {\n const { name, title, text } = item.json;\n const content = text.split('\\n').slice(1).join('\\n').trim();\n let pieces;\n if (CHUNKING === 'section') {\n pieces = content.split(/\\n(?=## )/).map(part => part.trim()).filter(Boolean);\n } else {\n const size = Number(CHUNKING), step = size - Math.floor(size / 5);\n pieces = [];\n for (let i = 0; i < content.length; i += step) pieces.push(content.slice(i, i + size));\n }\n pieces.forEach((piece, i) => {\n const section = (piece.match(/^## (.+)$/m) ?? [])[1] ?? '';\n chunks.push({ json: { source: name, title, chunk: i + 1, text: `${title}\\n${piece}` } });\n });\n}\nreturn chunks;"
}
},
{
"name": "Store chunks",
"type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory",
"typeVersion": 1.3,
"position": [
1020,
300
],
"parameters": {
"mode": "insert",
"memoryKey": {
"__rl": true,
"mode": "list",
"value": "my_policy_docs",
"cachedResultName": "my_policy_docs"
},
"clearStore": true
}
},
{
"name": "Embeddings",
"type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
"typeVersion": 1.2,
"position": [
960,
520
],
"parameters": {
"model": "text-embedding-3-large",
"options": {}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Chunk loader",
"type": "@n8n/n8n-nodes-langchain.documentDefaultDataLoader",
"typeVersion": 1.1,
"position": [
1120,
520
],
"parameters": {
"jsonMode": "expressionData",
"jsonData": "={{ $json.text }}",
"textSplittingMode": "simple",
"options": {
"metadata": {
"metadataValues": [
{
"name": "source",
"value": "={{ $json.source }}"
},
{
"name": "title",
"value": "={{ $json.title }}"
},
{
"name": "chunk",
"value": "={{ $json.chunk }}"
}
]
}
}
}
},
{
"name": "Count",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1260,
300
],
"parameters": {
"jsCode": "// One summary item after all chunks are stored.\nreturn [{ json: { indexed_chunks: $('Make chunks').all().length, chunking: ($('Index webhook').first().json.body ?? {}).chunking ?? 'section' } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1460,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Index webhook": {
"main": [
[
{
"node": "List docs",
"type": "main",
"index": 0
}
]
]
},
"List docs": {
"main": [
[
{
"node": "One item per doc",
"type": "main",
"index": 0
}
]
]
},
"One item per doc": {
"main": [
[
{
"node": "Get doc",
"type": "main",
"index": 0
}
]
]
},
"Get doc": {
"main": [
[
{
"node": "Make chunks",
"type": "main",
"index": 0
}
]
]
},
"Make chunks": {
"main": [
[
{
"node": "Store chunks",
"type": "main",
"index": 0
}
]
]
},
"Store chunks": {
"main": [
[
{
"node": "Count",
"type": "main",
"index": 0
}
]
]
},
"Count": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Embeddings": {
"ai_embedding": [
[
{
"node": "Store chunks",
"type": "ai_embedding",
"index": 0
}
]
]
},
"Chunk loader": {
"ai_document": [
[
{
"node": "Store chunks",
"type": "ai_document",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,203 @@
{
"name": "71 Search policies (reference · Lab 01 example 2)",
"nodes": [
{
"name": "Search webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-search",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
560
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Search policies",
"type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory",
"typeVersion": 1.3,
"position": [
420,
300
],
"parameters": {
"mode": "load",
"memoryKey": {
"__rl": true,
"mode": "list",
"value": "my_policy_docs",
"cachedResultName": "my_policy_docs"
},
"prompt": "={{ $json.body.question }}",
"topK": "={{ $json.body.top_k ?? 3 }}",
"includeDocumentMetadata": true
},
"alwaysOutputData": true
},
{
"name": "Embeddings",
"type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
"typeVersion": 1.2,
"position": [
560,
520
],
"parameters": {
"model": "text-embedding-3-large",
"options": {}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Build hits",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
860,
300
],
"parameters": {
"jsCode": "// The search contract: { question, hits: [{ rank, source, title, score, text }], top_score, indexed }.\n// Always Output Data is on, so an empty store still gives one empty item: keep only items that have a document.\nconst question = $('Search webhook').first().json.body.question;\nconst hits = $input.all().filter(item => item.json.document).map((item, i) => ({\n rank: i + 1, source: item.json.document.metadata?.source, title: item.json.document.metadata?.title, \n score: Math.round((item.json.score ?? 0) * 1000) / 1000, text: item.json.document.pageContent ?? '' }));\nreturn [{ json: { question, hits, top_score: hits.length ? Math.max(...hits.map(hit => hit.score)) : 0, indexed: hits.length > 0 } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1080,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Search webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Search policies",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Embeddings": {
"ai_embedding": [
[
{
"node": "Search policies",
"type": "ai_embedding",
"index": 0
}
]
]
},
"Search policies": {
"main": [
[
{
"node": "Build hits",
"type": "main",
"index": 0
}
]
]
},
"Build hits": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,398 @@
{
"name": "71 Policy QA (reference · Lab 02 example 2)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-policy-qa",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
560
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Search policies",
"type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory",
"typeVersion": 1.3,
"position": [
420,
300
],
"parameters": {
"mode": "load",
"memoryKey": {
"__rl": true,
"mode": "list",
"value": "my_policy_docs",
"cachedResultName": "my_policy_docs"
},
"prompt": "={{ $json.body.question }}",
"topK": 3,
"includeDocumentMetadata": true
},
"alwaysOutputData": true
},
{
"name": "Embeddings",
"type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
"typeVersion": 1.2,
"position": [
380,
520
],
"parameters": {
"model": "text-embedding-3-large",
"options": {}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Build context",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
300
],
"parameters": {
"jsCode": "// Numbered excerpts for the LLM, and the retrieved sources.\nconst question = $('Question webhook').first().json.body.question;\nconst hits = $input.all().filter(item => item.json.document).map(item => ({\n source: item.json.document.metadata?.source, title: item.json.document.metadata?.title,\n score: Math.round((item.json.score ?? 0) * 1000) / 1000, text: item.json.document.pageContent ?? '' }));\nconst context = hits.map((hit, i) => `[${i + 1}] ${hit.text}`).join('\\n\\n');\nconst minScore = Number($('Question webhook').first().json.body.min_score ?? 0.33);\nconst topScore = hits.length ? Math.max(...hits.map(hit => hit.score)) : 0;\nreturn [{ json: { question, hits, context, top_score: topScore, min_score: minScore, relevant: topScore >= minScore } }];"
}
},
{
"name": "Relevant?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
860,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ $json.relevant }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Answer from policies",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
1080,
200
],
"parameters": {
"promptType": "define",
"text": "=질문: {{ $json.question }}\n\n규정 발췌:\n{{ $json.context }}",
"options": {
"systemMessage": "Answer the employee's question only from the numbered policy excerpts, in Korean, in at most 3 sentences. After each fact, write the number of the excerpt it came from in square brackets, such as [2]. If the excerpts do not contain the answer, answer exactly: 규정에서 답을 찾지 못했습니다.",
"maxIterations": 2
}
}
},
{
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
1040,
420
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Build answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
200
],
"parameters": {
"jsCode": "// Turn excerpt numbers into document titles and keep which documents were cited.\nconst ctx = $('Build context').first().json;\nconst draft = String($json.output ?? '').trim();\nconst numbers = [...draft.matchAll(/\\[(\\d+)\\]/g)].map(m => Number(m[1])).filter(n => n >= 1 && n <= ctx.hits.length);\nconst cited = [...new Set(numbers.map(n => ctx.hits[n - 1].source))];\nconst answer = draft.replace(/\\[(\\d+)\\]/g, (all, n) => ctx.hits[n - 1] ? `[${ctx.hits[n - 1].title}]` : all);\n\nreturn [{ json: { route: '자동 답변', answer, sources: [...new Set(ctx.hits.map(hit => hit.source))], cited, top_score: ctx.top_score } }];"
}
},
{
"name": "Not in policies",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1080,
520
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a1",
"name": "answer",
"value": "규정에서 답을 찾지 못했습니다. IT 문의 창구로 연결해 드릴게요.",
"type": "string"
},
{
"id": "a2",
"name": "sources",
"value": "={{ [] }}",
"type": "array"
},
{
"id": "a3",
"name": "cited",
"value": "={{ [] }}",
"type": "array"
},
{
"id": "a4",
"name": "top_score",
"value": "={{ $json.top_score }}",
"type": "number"
}
]
},
"options": {}
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1540,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Search policies",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Embeddings": {
"ai_embedding": [
[
{
"node": "Search policies",
"type": "ai_embedding",
"index": 0
}
]
]
},
"Search policies": {
"main": [
[
{
"node": "Build context",
"type": "main",
"index": 0
}
]
]
},
"Build context": {
"main": [
[
{
"node": "Relevant?",
"type": "main",
"index": 0
}
]
]
},
"Relevant?": {
"main": [
[
{
"node": "Answer from policies",
"type": "main",
"index": 0
}
],
[
{
"node": "Not in policies",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "Answer from policies",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer from policies": {
"main": [
[
{
"node": "Build answer",
"type": "main",
"index": 0
}
]
]
},
"Build answer": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Not in policies": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+1
View File
@@ -0,0 +1 @@
{"chunking": 200}
+1
View File
@@ -0,0 +1 @@
{"chunking": 400}
+1
View File
@@ -0,0 +1 @@
{"chunking": "section"}
@@ -0,0 +1 @@
{"question": "생성형 AI에 고객 자료를 넣어도 되나요?"}
@@ -0,0 +1 @@
{"question": "첨부 파일이 너무 커서 메일이 안 보내져요"}
+1
View File
@@ -0,0 +1 @@
{"question": ""}
@@ -0,0 +1 @@
{"question": "비밀번호를 잊어버리면 어디서 바꿔요?"}
+1
View File
@@ -0,0 +1 @@
{"question": "노트북 사양은 어떻게 되나요?"}
@@ -0,0 +1 @@
{"question": "연차 휴가는 며칠이에요?"}
+1
View File
@@ -0,0 +1 @@
{"question": "오늘 점심 메뉴 추천해줘"}
@@ -0,0 +1 @@
{"question": "암호를 얼마나 자주 바꿔야 해?"}
@@ -0,0 +1 @@
{"question": "비밀번호는 몇 자 이상이어야 하나요?"}
@@ -0,0 +1 @@
{"question": "프린터 토너는 몇 달마다 바꿔 주나요?"}
@@ -0,0 +1 @@
{"question": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?"}
@@ -0,0 +1 @@
{"question": "보안 교육은 1년에 몇 번 받아야 하나요?"}
+1
View File
@@ -0,0 +1 @@
{"question": "컴퓨터가 바이러스에 걸린 것 같아요"}
+1
View File
@@ -0,0 +1 @@
{"question": "VPN 접속 속도가 느리면 어떻게 해요?"}
@@ -0,0 +1 @@
{"question": "비밀번호는 30일마다 바꾸면 되죠?"}
@@ -0,0 +1,42 @@
# 검색 평가표 · 사내 IT 규정 Q&A
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 이 파일을 `my-work/RAG-EVAL.md`로 복사해 채웁니다.
## 1. 정답표 (Lab 01 예제 1)
| 질문 | 답이 있는 문서 (`name`) | 섹션 | 답 문구 |
| --- | --- | --- | --- |
| | | | |
## 2. 검색 확인 (Lab 01)
| 질문 | 1등 문서 | 1등 점수 | 정답표와 같나 |
| --- | --- | --- | --- |
| | | | |
## 3. 답과 출처 (Lab 02)
| 질문 | route | 답 (요약) | cited | 맞나 |
| --- | --- | --- | --- | --- |
| | | | | |
- 관련도 기준 0.33에 걸러지지 않았는데 규정에 답이 없는 질문:
## 4. 한 가지씩 바꾸고 재기 (Lab 03)
| 설정 | 청크 수 | doc@1 | answer@1 | answer@3 |
| --- | --- | --- | --- | --- |
| 섹션 (기준) | | /24 | /24 | /24 |
| 200자 | | /24 | /24 | /24 |
| 400자 | | /24 | /24 | /24 |
| (내가 바꾼 것) | | /24 | /24 | /24 |
## 5. 어려운 질문 (Lab 03 과제)
| 질문 | 기대 문서 | 바꾸기 전 1등 | 바꾼 뒤 1등 |
| --- | --- | --- | --- |
| | | | |
- 고친 것이 다른 질문을 깨뜨리지 않았나? (`retrieval-*` 다시)
@@ -0,0 +1,32 @@
# 테스트 보고서 · 사내 IT 규정 Q&A
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 워크플로우 파일: `my-work/index.json, my-work/search.json, my-work/policy-qa.json`
## 1. 내가 쓴 테스트 (`cases/my-cases.json`)
| 케이스 | 요청 | 기대 결과 (업무 규칙) |
| --- | --- | --- |
| | | |
## 2. 실패와 고친 내용
| 실패한 케이스 | 처음 결과 (3번 중 통과) | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 (3번 중 통과) |
| --- | --- | --- | --- | --- |
| | | | | |
## 3. 최종 결과
| 테스트 파일 | 결과 |
| --- | --- |
| `cases/lab02.json` (답과 출처) | Passed /6 |
| `cases/lab02-challenge.json` (답 검사) | Passed /8 |
| `cases/retrieval-answer.json` (answer@1) | Passed /24 |
| `cases/lab03-threshold.json` (기준값) | Passed /10 |
| `cases/my-cases.json --repeat 3` | Passed / |
## 4. 남은 문제 · 배운 점
- 지시문(systemMessage)으로 고친 것과 규칙 노드(If·Code)로 고친 것 중 어느 쪽이 더 안정적이었나?
-
+642
View File
@@ -0,0 +1,642 @@
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Course helper: check, push and test the n8n workflows that a CLI coding agent wrote.
uv run tools/flow.py doctor # is everything ready?
uv run tools/flow.py check my-work/policy-qa.json # find mistakes before n8n sees the file
uv run tools/flow.py push my-work/index.json my-work/search.json # import + publish (one n8n restart)
uv run tools/flow.py off my-sms # unpublish by file or webhook path (a role that is down)
uv run tools/flow.py call my-index # build the in-memory index (again after every push)
uv run tools/flow.py call my-search requests/q-password-length.json [-v]
uv run tools/flow.py test cases/retrieval-answer.json [-v] [--repeat 3] [--no-before] # -v: print request and response JSON
Run it from the day folder (Last3Mon/71). n8n is the day 65 install (~/day65-n8n, Docker project
day65-n8n), started from Last3Mon/65/n8n. Environment overrides:
N8N_CONTAINER (default: the running n8n service of project day65-n8n, else day65-n8n-n8n-1)
N8N_HOME (default ~/day65-n8n), N8N_URL (default http://localhost:5678)
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CACHE = Path(__file__).resolve().parent / ".cache"
def n8n_home() -> Path:
"""Where the official n8n install lives: N8N_HOME, else Last3Mon/65/n8n/.env, else ~/day65-n8n."""
if os.getenv("N8N_HOME"):
return Path(os.environ["N8N_HOME"]).expanduser()
env_file = ROOT.parent / "65" / "n8n" / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
if line.startswith("N8N_HOME="):
value = line.split("=", 1)[1].strip().strip("'\"")
if value:
return (env_file.parent / Path(value).expanduser()).resolve()
return Path.home() / "day65-n8n"
N8N_HOME = n8n_home()
START_HINT = "cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start (n8n install: ~/day65-n8n)"
def find_container() -> str:
"""The n8n container of the day 65 project, whichever folder it was started from."""
if os.getenv("N8N_CONTAINER"):
return os.environ["N8N_CONTAINER"]
if shutil.which("docker"):
result = subprocess.run(["docker", "ps", "-a", "--filter", "label=com.docker.compose.project=day65-n8n",
"--filter", "label=com.docker.compose.service=n8n", "--format", "{{.Names}}"],
capture_output=True, text=True)
names = result.stdout.split()
if names:
return names[0]
return "day65-n8n-n8n-1"
CONTAINER = find_container()
N8N_URL = os.getenv("N8N_URL", "http://localhost:5678").rstrip("/")
# Versions written in AGENTS.md. Other versions still work if n8n knows them (warning only).
RECOMMENDED = {
"n8n-nodes-base.webhook": 2,
"n8n-nodes-base.respondToWebhook": 1.1,
"n8n-nodes-base.if": 2.2,
"n8n-nodes-base.switch": 3.2,
"n8n-nodes-base.set": 3.4,
"n8n-nodes-base.code": 2,
"n8n-nodes-base.httpRequest": 4.2,
"n8n-nodes-base.httpRequestTool": 4.2,
"@n8n/n8n-nodes-langchain.agent": 3.1,
"@n8n/n8n-nodes-langchain.lmChatOpenAi": 1.2,
"@n8n/n8n-nodes-langchain.vectorStoreInMemory": 1.3,
"@n8n/n8n-nodes-langchain.embeddingsOpenAi": 1.2,
"@n8n/n8n-nodes-langchain.documentDefaultDataLoader": 1.1,
}
USES_DATA_API = True # day 71: policy documents come from the data API (/docs)
AGENT_HOOKS: tuple[str, ...] = () # no ready-made agents on day 71
COURSE_CREDENTIALS = {"openAiCourse0001": "OpenAI (.env)"} # OpenAI only from day 66 (no Ollama)
CONNECTION_TYPES = {"main", "ai_tool", "ai_languageModel", "ai_memory", "ai_outputParser", "ai_embedding", "ai_document"}
SECRET = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}")
OK, BAD, WARN = "✔", "✘", "!"
# ---------------------------------------------------------------- helpers
def die(message: str) -> None:
print(f"{BAD} {message}", file=sys.stderr)
sys.exit(1)
def docker(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
if not shutil.which("docker"):
die("docker command not found. Start Docker first.")
result = subprocess.run(["docker", *args], input=stdin, capture_output=True, text=True)
if check and result.returncode != 0:
die(f"docker {' '.join(args[:2])} failed:\n{(result.stderr or result.stdout).strip()}")
return result
def n8n(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
return docker("exec", "-i", CONTAINER, "n8n", *args, stdin=stdin, check=check)
def container_running() -> bool:
if not shutil.which("docker"):
return False
result = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], capture_output=True, text=True)
return result.stdout.strip() == "true"
def http(method: str, url: str, body: object | None = None, timeout: float = 300) -> tuple[int, object, float]:
data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
start = time.monotonic()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
status, raw = response.status, response.read()
except urllib.error.HTTPError as error:
status, raw = error.code, error.read()
except (urllib.error.URLError, TimeoutError, ConnectionError) as error:
return 0, {"error": str(getattr(error, "reason", error))}, time.monotonic() - start
text = raw.decode("utf-8", errors="replace")
try:
payload: object = json.loads(text) if text.strip() else ""
except json.JSONDecodeError:
payload = text
return status, payload, time.monotonic() - start
def load_json(path: Path) -> object:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
die(f"file not found: {path}")
except json.JSONDecodeError as error:
die(f"{path} is not valid JSON (line {error.lineno}, column {error.colno}): {error.msg}")
def resolve(path: str) -> Path:
candidate = Path(path)
if candidate.exists() or candidate.is_absolute():
return candidate
return ROOT / path
def node_versions() -> dict[str, list[float]]:
"""Node types and versions that this n8n really has (exported once, then cached)."""
cache = CACHE / "node-versions.json"
if cache.exists():
return json.loads(cache.read_text())
if not container_running():
return {}
n8n("export:nodes", "--output=/tmp/course-nodes.json")
raw = docker("exec", CONTAINER, "sh", "-c", "cat /tmp/course-nodes.json && rm -f /tmp/course-nodes.json").stdout
versions: dict[str, set[float]] = {}
for node in json.loads(raw):
value = node.get("version")
versions.setdefault(node["name"], set()).update(value if isinstance(value, list) else [value])
result = {name: sorted(values) for name, values in versions.items()}
CACHE.mkdir(exist_ok=True)
cache.write_text(json.dumps(result))
return result
def walk_strings(value: object, where: str = ""):
if isinstance(value, str):
yield where, value
elif isinstance(value, dict):
for key, item in value.items():
yield from walk_strings(item, f"{where}.{key}" if where else key)
elif isinstance(value, list):
for index, item in enumerate(value):
yield from walk_strings(item, f"{where}[{index}]")
def webhooks(workflow: dict) -> list[tuple[str, str]]:
found = []
for node in workflow.get("nodes", []):
if node.get("type") == "n8n-nodes-base.webhook":
parameters = node.get("parameters", {})
found.append((str(parameters.get("httpMethod", "GET")).upper(), str(parameters.get("path", "")).strip("/")))
return found
# ---------------------------------------------------------------- check
def check_workflow(workflow: object) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
if not isinstance(workflow, dict):
return ["the file must be one workflow object { name, nodes, connections }"], warnings
if not str(workflow.get("name", "")).strip():
errors.append("missing workflow 'name'")
nodes = workflow.get("nodes")
connections = workflow.get("connections")
if not isinstance(nodes, list) or not nodes:
return errors + ["'nodes' must be a non-empty list"], warnings
if not isinstance(connections, dict):
errors.append("'connections' must be an object")
connections = {}
known = node_versions()
names: dict[str, dict] = {}
for index, node in enumerate(nodes):
label = f"node #{index + 1}"
if not isinstance(node, dict):
errors.append(f"{label} is not an object")
continue
name = node.get("name")
if not name:
errors.append(f"{label} has no 'name'")
continue
label = f"node '{name}'"
if name in names:
errors.append(f"{label} appears twice (node names must be unique)")
names[name] = node
node_type, version = node.get("type"), node.get("typeVersion")
if not node_type or version is None:
errors.append(f"{label} needs 'type' and 'typeVersion'")
elif known and node_type not in known:
errors.append(f"{label}: type '{node_type}' does not exist in this n8n")
elif known and version not in known[node_type]:
errors.append(f"{label}: {node_type} has no typeVersion {version} (available: {known[node_type]})")
elif node_type in RECOMMENDED and version != RECOMMENDED[node_type]:
warnings.append(f"{label}: typeVersion {version} (AGENTS.md uses {RECOMMENDED[node_type]})")
if not isinstance(node.get("parameters"), dict):
errors.append(f"{label} needs 'parameters' (use {{}} when empty)")
position = node.get("position")
if not (isinstance(position, list) and len(position) == 2):
errors.append(f"{label} needs 'position': [x, y]")
for kind, credential in (node.get("credentials") or {}).items():
credential_id = (credential or {}).get("id")
if credential_id not in COURSE_CREDENTIALS:
errors.append(f"{label}: credential '{kind}' must use a course id {list(COURSE_CREDENTIALS)}, got {credential_id!r}")
for where, text in walk_strings(node.get("parameters", {})):
if SECRET.search(text):
errors.append(f"{label}: parameters.{where} looks like an API key. Use a credential instead.")
if "{{" in text and "}}" in text and not text.startswith("="):
warnings.append(f"{label}: parameters.{where} has {{{{ }}}} but does not start with '=' (it stays plain text)")
targets_of: dict[str, set[str]] = {}
for source, outputs in connections.items():
if source not in names:
errors.append(f"connections: source '{source}' is not a node name")
continue
if not isinstance(outputs, dict):
errors.append(f"connections['{source}'] must be an object like {{\"main\": [[...]]}}")
continue
for kind, branches in outputs.items():
if kind not in CONNECTION_TYPES:
errors.append(f"connections['{source}']: unknown connection type '{kind}'")
for branch in branches or []:
for link in branch or []:
target = (link or {}).get("node")
if target not in names:
errors.append(f"connections['{source}'] points to '{target}', which is not a node name")
elif link.get("type") != kind:
errors.append(f"connections['{source}'] -> '{target}': link type must be '{kind}'")
else:
targets_of.setdefault(target, set()).add(kind)
if kind != "main":
targets_of.setdefault(f"{source}::from", set()).add(kind)
has_respond = any(node.get("type") == "n8n-nodes-base.respondToWebhook" for node in names.values())
for name, node in names.items():
node_type = node.get("type", "")
parameters = node.get("parameters", {}) if isinstance(node.get("parameters"), dict) else {}
if node_type == "n8n-nodes-base.webhook":
if not str(parameters.get("path", "")).strip("/"):
errors.append(f"node '{name}': webhook needs a 'path'")
if parameters.get("responseMode") == "responseNode" and not has_respond:
errors.append(f"node '{name}': responseMode is 'responseNode' but there is no Respond to Webhook node")
if node_type == "@n8n/n8n-nodes-langchain.agent" and "ai_languageModel" not in targets_of.get(name, set()):
errors.append(f"node '{name}': AI Agent needs a chat model connected with 'ai_languageModel'")
if node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool"):
if "ai_tool" not in targets_of.get(f"{name}::from", set()):
errors.append(f"node '{name}': a tool must be connected to an AI Agent with 'ai_tool'")
if node_type.startswith("@n8n/n8n-nodes-langchain.lmChat"):
if "ai_languageModel" not in targets_of.get(f"{name}::from", set()):
errors.append(f"node '{name}': a chat model must be connected to an AI Agent with 'ai_languageModel'")
is_sub_node = bool(targets_of.get(f"{name}::from")) # model, tool, embeddings, loader: connected by ai_* links
if node_type != "n8n-nodes-base.webhook" and not node_type.startswith("@n8n/n8n-nodes-langchain.lm") and not is_sub_node \
and not (node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool")) \
and "main" not in targets_of.get(name, set()) and "trigger" not in node_type.lower():
warnings.append(f"node '{name}': nothing is connected into it (it never runs)")
if not webhooks(workflow):
warnings.append("no Webhook node: 'push' works, but 'call' and 'test' need a webhook path")
return errors, warnings
def cmd_check(args: argparse.Namespace) -> int:
path = resolve(args.file)
errors, warnings = check_workflow(load_json(path))
for warning in warnings:
print(f"{WARN} {warning}")
for error in errors:
print(f"{BAD} {error}")
if errors:
print(f"{BAD} {path.name}: {len(errors)} error(s). Fix them and run check again.")
return 1
print(f"{OK} {path.name}: no errors" + (f" ({len(warnings)} warning(s))" if warnings else ""))
return 0
# ---------------------------------------------------------------- push
def stable_id(path: Path) -> str:
return "w" + hashlib.sha1(path.stem.encode("utf-8")).hexdigest()[:15]
def wait_until_ready(paths: list[tuple[str, str]], seconds: int = 90) -> bool:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=3)
if status == 200:
break
time.sleep(1)
else:
return False
for method, path in paths:
while time.monotonic() < deadline:
# A webhook that is registered for POST answers a GET with a hint, not "is not registered".
status, payload, _ = http("GET" if method != "GET" else "HEAD", f"{N8N_URL}/webhook/{path}", timeout=5)
text = json.dumps(payload, ensure_ascii=False)
if "not registered for" in text or status not in (0, 404, 500, 502, 503):
break
time.sleep(1)
time.sleep(2) # webhooks of the other published workflows finish registering right after
return time.monotonic() < deadline
def exported_workflows() -> list[dict]:
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:workflow --all --output=/tmp/course-all.json >/dev/null 2>&1; "
"cat /tmp/course-all.json 2>/dev/null; rm -f /tmp/course-all.json", check=False).stdout
try:
return json.loads(exported) if exported.strip() else []
except json.JSONDecodeError:
return []
def prepare(path: Path) -> dict | None:
workflow = load_json(path)
errors, warnings = check_workflow(workflow)
for warning in warnings:
print(f"{WARN} {path.name}: {warning}")
if errors:
for error in errors:
print(f"{BAD} {path.name}: {error}")
return None
workflow = dict(workflow)
if not re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))):
workflow["id"] = stable_id(path)
workflow["active"] = False
workflow.setdefault("settings", {}).setdefault("executionOrder", "v1")
for node in workflow["nodes"]:
node.setdefault("id", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/{node['name']}")))
if node["type"] == "n8n-nodes-base.webhook":
node.setdefault("webhookId", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/webhook/{node['name']}")))
return workflow
def cmd_push(args: argparse.Namespace) -> int:
paths = [resolve(file) for file in args.files]
workflows = [prepare(path) for path in paths]
if None in workflows:
print(f"{BAD} not pushed. Fix the errors first (uv run tools/flow.py check <file>).")
return 1
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
mine_ids = {workflow["id"] for workflow in workflows}
hooks = {hook for workflow in workflows for hook in webhooks(workflow)}
# Another published workflow with the same webhook path would block this one.
for other in exported_workflows():
if other.get("id") not in mine_ids and other.get("active") and hooks & set(webhooks(other)):
n8n("unpublish:workflow", f"--id={other['id']}")
print(f"{WARN} unpublished '{other.get('name')}' ({other['id']}): it used the same webhook path")
for workflow in workflows:
docker("exec", "-i", CONTAINER, "sh", "-c",
"cat > /tmp/course-workflow.json && n8n import:workflow --input=/tmp/course-workflow.json; "
"status=$?; rm -f /tmp/course-workflow.json; exit $status",
stdin=json.dumps(workflow, ensure_ascii=False))
n8n("publish:workflow", f"--id={workflow['id']}")
# The CLI changes the database only; one restart makes the running n8n load the new versions (about 5 s).
docker("restart", CONTAINER)
ready = wait_until_ready(sorted(hooks))
for workflow in workflows:
print(f"{OK} pushed '{workflow['name']}' (id {workflow['id']})")
for method, hook in sorted(webhooks(workflow)):
print(f" {method} {N8N_URL}/webhook/{hook}")
if not ready:
print(f"{WARN} n8n is still starting. Wait a few seconds before 'call' or 'test'.")
return 0
def cmd_off(args: argparse.Namespace) -> int:
"""Unpublish workflows, given as files or webhook paths, to see how the others behave when one role is down."""
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
exported = exported_workflows()
wanted: dict[str, str] = {}
for item in args.files:
path = resolve(item) if (ROOT / item).exists() or Path(item).exists() else None
if path:
workflow = load_json(path)
workflow_id = workflow.get("id") if re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))) else stable_id(path)
wanted[workflow_id] = item
hooks = {hook for _, hook in webhooks(workflow)}
else:
hooks = {item.strip("/")}
for other in exported:
if other.get("active") and hooks & {hook for _, hook in webhooks(other)}:
wanted[other["id"]] = item
active = {item.get("id") for item in exported if item.get("active")}
for workflow_id, item in wanted.items():
if workflow_id in active:
n8n("unpublish:workflow", f"--id={workflow_id}")
print(f"{OK} unpublished {item} (id {workflow_id})")
docker("restart", CONTAINER)
wait_until_ready([])
print(f"{OK} n8n restarted. Turn it back on with: uv run tools/flow.py push <the workflow file>")
return 0
# ---------------------------------------------------------------- call / test
def request_body(spec: str | dict, base: Path = ROOT) -> object:
if isinstance(spec, dict):
return spec
text = spec.strip()
if text.startswith("{"):
try:
return json.loads(text)
except json.JSONDecodeError as error:
die(f"request is not valid JSON: {error.msg}")
path = Path(text)
if not path.exists():
path = base / text
return load_json(path)
def show_json(label: str, value: object) -> None:
"""Print one JSON value under a label, indented below the result line (call -v, test -v)."""
text = json.dumps(value, ensure_ascii=False, indent=2) if not isinstance(value, str) else value
print(f" {label}")
for row in text.splitlines():
print(f" {row}")
def cmd_call(args: argparse.Namespace) -> int:
body = request_body(args.request) if args.request else {}
hook = args.path.strip('/')
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body)
print(f"HTTP {status} · {seconds:.1f}s")
if args.verbose:
show_json(f"→ request POST /webhook/{hook}", body)
show_json(f"← response HTTP {status}", payload)
return 0 if 200 <= status < 300 else 1
print(json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload)
return 0 if 200 <= status < 300 else 1
def field(payload: object, dotted: str) -> object:
value = payload
for part in dotted.split("."):
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, list) and part.isdigit() and int(part) < len(value):
value = value[int(part)]
else:
return None
return value
def as_text(value: object) -> str:
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def judge(expect: dict, status: int, payload: object) -> list[str]:
problems = []
if status != expect.get("status", 200):
problems.append(f"status {status} (expected {expect.get('status', 200)})")
for name, wanted in (expect.get("equals") or {}).items():
actual = field(payload, name)
if actual != wanted:
problems.append(f"{name} = {as_text(actual)[:60]} (expected {as_text(wanted)})")
for name, words in (expect.get("contains") or {}).items():
words = [words] if isinstance(words, str) else words
text = as_text(field(payload, name) or "")
if not any(word in text for word in words):
problems.append(f"{name} has none of {words}")
for name, words in (expect.get("not_contains") or {}).items():
words = [words] if isinstance(words, str) else words
text = as_text(field(payload, name) or "")
leaked = [word for word in words if word in text]
if leaked:
problems.append(f"{name} must not have {leaked}")
return problems
def cmd_test(args: argparse.Namespace) -> int:
path = resolve(args.cases)
suite = load_json(path)
hook = (args.webhook or suite.get("webhook", "")).strip("/")
cases = suite.get("cases", [])
if not hook or not cases:
die(f"{path.name} needs \"webhook\" and a non-empty \"cases\" list")
# "before": calls to make once before the cases, e.g. rebuild the in-memory index (lost when n8n restarts).
for step in ([] if args.no_before else suite.get("before", [])):
step_hook = str(step.get("webhook", "")).strip("/")
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{step_hook}", request_body(step.get("body") or step.get("request", "{}")))
shown = json.dumps(payload, ensure_ascii=False)[:120] if not isinstance(payload, str) else payload[:120]
print(f"{OK if 200 <= status < 300 else BAD} before: POST /webhook/{step_hook} · HTTP {status} · {seconds:.1f}s · {shown}")
if not 200 <= status < 300:
die(f"the 'before' step failed. Is '{step_hook}' pushed? Try: uv run tools/flow.py call {step_hook}")
print(f"POST {N8N_URL}/webhook/{hook} · {len(cases)} case(s) × {args.repeat}")
results, passed = [], 0
for case in cases:
body = request_body(case.get("body") or case.get("request", "{}"))
case_hook = (case.get("webhook") or hook).strip("/") if not args.webhook else hook
for run in range(1, args.repeat + 1):
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{case_hook}", body)
problems = judge(case.get("expect", {}), status, payload)
passed += not problems
mark = OK if not problems else BAD
suffix = f" #{run}" if args.repeat > 1 else ""
print(f"{mark} {case.get('name', '?')}{suffix} · {seconds:.1f}s" + ("" if not problems else " · " + "; ".join(problems)))
if args.verbose:
show_json(f"→ request POST /webhook/{case_hook}", body)
show_json(f"← response HTTP {status}", payload)
results.append({"case": case.get("name"), "webhook": case_hook, "run": run, "passed": not problems, "problems": problems,
"seconds": round(seconds, 1), "status": status, "request": body, "response": payload})
total = len(results)
print(f"Passed {passed}/{total}")
runs = ROOT / "runs"
runs.mkdir(exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
report = runs / f"{path.stem}-{stamp}.json"
report.write_text(json.dumps({"cases": str(path.name), "webhook": hook, "passed": passed, "total": total,
"results": results}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"report: {report.relative_to(ROOT)}")
return 0 if passed == total else 1
# ---------------------------------------------------------------- doctor
def cmd_doctor(args: argparse.Namespace) -> int:
failures = 0
def line(good: bool, text: str, hint: str = "") -> None:
nonlocal failures
failures += not good
print(f"{OK if good else BAD} {text}" + ("" if good or not hint else f"\n → {hint}"))
installed = (N8N_HOME / "compose.yml").exists()
print(f"{OK if installed else WARN} n8n install folder {N8N_HOME}" + ("" if installed else
"\n → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>"))
running = container_running()
line(running, f"n8n container {CONTAINER}", START_HINT)
status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=5)
line(status == 200, f"n8n {N8N_URL}", "wait 10 seconds after starting n8n, then run doctor again")
status, _, _ = http("GET", "http://localhost:8800/health", timeout=5)
if USES_DATA_API:
line(status == 200, "data API http://localhost:8800", "cd ~/aio-01d4-stu/Last3Mon/71/data-api && docker compose up -d --force-recreate (start n8n first)")
docs_status, docs, _ = http("GET", "http://localhost:8800/docs", timeout=5)
count = len(docs.get("docs", [])) if isinstance(docs, dict) else 0
line(docs_status == 200 and count > 0, f"policy documents /docs ({count})",
"the data API is an older day's version: cd ~/aio-01d4-stu/Last3Mon/71/data-api && docker compose up -d --force-recreate")
else:
print(f"{OK if status == 200 else WARN} data API http://localhost:8800 (not used today)")
if running:
probe = docker("exec", CONTAINER, "node", "-e",
"fetch('http://data-api:8800/health').then(r=>console.log(r.status)).catch(()=>console.log(0))",
check=False).stdout.strip()
if USES_DATA_API:
line(probe == "200", "n8n → http://data-api:8800", "data API must join the day65-n8n_default network (compose.yml)")
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:credentials --all --output=/tmp/course-cred.json >/dev/null 2>&1; "
"cat /tmp/course-cred.json 2>/dev/null; rm -f /tmp/course-cred.json", check=False).stdout
try:
ids = {item.get("id") for item in json.loads(exported)} if exported.strip() else set()
except json.JSONDecodeError:
ids = set()
for credential_id, name in COURSE_CREDENTIALS.items():
line(credential_id in ids, f"credential '{name}' ({credential_id})",
"cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh")
line(bool(node_versions()), "node catalog (for check)")
for hook in AGENT_HOOKS:
_, payload, _ = http("GET", f"{N8N_URL}/webhook/{hook}", timeout=5)
line("not registered for" in json.dumps(payload, ensure_ascii=False), f"agent webhook /{hook}",
"uv run tools/flow.py push <agent workflow file>")
agents = [name for name in ("codex", "claude", "agy") if shutil.which(name)]
line(bool(agents), "CLI coding agent: " + (", ".join(agents) if agents else "none"),
"install one of codex / claude / agy (see Lecture/71/00-DAY71-PREP.md)")
print("ready" if not failures else f"{failures} item(s) to fix")
return 0 if not failures else 1
def main() -> int:
parser = argparse.ArgumentParser(description="Check, push and test course n8n workflows.")
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("doctor", help="check n8n, data API, credentials and CLI agents").set_defaults(run=cmd_doctor)
item = commands.add_parser("check", help="find mistakes in a workflow file")
item.add_argument("file")
item.set_defaults(run=cmd_check)
item = commands.add_parser("push", help="import and publish workflow files (one n8n restart)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_push)
item = commands.add_parser("off", help="unpublish workflow files or webhook paths (a role that is down)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_off)
item = commands.add_parser("call", help="POST one request to a webhook path")
item.add_argument("path", help="webhook path, e.g. my-notice")
item.add_argument("request", nargs="?", help="request file or inline JSON")
item.add_argument("-v", "--verbose", action="store_true", help="print the request JSON as well as the response")
item.set_defaults(run=cmd_call)
item = commands.add_parser("test", help="run a test case file")
item.add_argument("cases")
item.add_argument("-v", "--verbose", action="store_true", help="print the request and response JSON of every run")
item.add_argument("--repeat", type=int, default=1, help="run every case N times")
item.add_argument("--webhook", help="override the webhook path in the case file")
item.add_argument("--no-before", action="store_true", help="skip the 'before' steps (keep the index you built by hand)")
item.set_defaults(run=cmd_test)
args = parser.parse_args()
return args.run(args)
if __name__ == "__main__":
sys.exit(main())
+343
View File
@@ -0,0 +1,343 @@
# 71일차 개요 · RAG 기반 검색·응답과 검색 품질 개선
> 운영·확장 관점의 AI 워크플로우 설계 고도화 · 2026-10-23
> 23장 · 각 장은 가로줄(`---`)로 나뉩니다. 1교시에는 1~7장을 보고, 나머지 장은 각 단계를 설명할 때 함께 띄웁니다.
> 숫자는 2026-10-18 수업 준비 측정과 2026-10-22 같은 정답으로 다시 돌린 기록입니다.
| 장 | 범위 |
| --- | --- |
| 1~7 | 표지 · 목표 · 시간표 · 앞 일차와의 연결 · 왜 RAG인가 · 오늘의 그림 · 오늘의 문서 |
| 8~11 | 1단계 · 점수 읽기 · 정답표 · 청크 하나 · 메모리 저장소 |
| 12~16 | 2단계 · 발췌로만 · 출처 · 관련도 기준 · 답이 없을 때 · 넘기는 길 |
| 17~21 | 3단계 · 지표 셋 · 청크 크기 · 잘린 문장과 섞인 청크 · 질문 다시 쓰기 · 같은 설정 다른 결과 |
| 22~23 | 7교시 바이브 코딩 · 정리와 예고 |
---
## 1 / 23 · 표지
```mermaid
flowchart LR
D[("규정 8개")] --> C["청크"] --> V[("벡터 저장소")]
Q(["질문"]) --> S["검색"] --> A{{"근거로 답"}} --> O(["답 + 출처"])
V --> S
classDef g fill:#8A9BA0,stroke:#8A9BA0,color:#fff
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff
class D,Q g
class C,S o
class V,A t
class O d
```
**71일차**
# RAG 기반 검색·응답과 검색 품질 개선
정답표 · 청크 · 임베딩 · 근거로 답하기 · 답 검사 · doc@1 · answer@1
<sub>운영·확장 관점의 AI 워크플로우 설계 고도화 · 2026-10-23</sub>
---
## 2 / 23 · 오늘 할 수 있게 될 세 가지
| | 오늘 할 수 있게 될 것 |
| :-: | --- |
| **①** | 검색 전에 **정답표**를 만들고, 규정 문서를 색인·검색하는 워크플로우를 요청문으로 만든다 |
| **②** | 검색한 발췌로만 답하고 출처를 붙이며, 관련 없거나 답이 없으면 **넘긴다** |
| **③** | 검색 품질을 **숫자**(doc@1 · answer@1 · answer@3)로 재고, 한 번에 하나씩 바꿔 고친다 |
---
## 3 / 23 · 오늘의 시간표
| ① 1~2교시 | ② 3~4교시 | ③ 5~6교시 | ④ 7~8교시 |
| --- | --- | --- | --- |
| **1단계** | **2단계** | **3단계** | **바이브 코딩** |
| RAG와 색인 | 근거로 답하기 | 검색 품질 | `tdoc` |
| Lab 01 정답표 · 색인 · 검색 | Lab 02 출처 · 기준값 · 답 검사 | Lab 03 재기 · 고치기 | 요청서 → SPEC → 만들기 · 발표 |
> 09:00 시작 · 50분 수업 10분 휴식 · 4교시 뒤 13:00~14:00 점심
---
## 4 / 23 · 앞 일차와의 연결
| 일차 | 그때 | 오늘 이어지는 것 |
| --- | --- | --- |
| 64 | 지시문에 규칙을 적음 | "발췌로만, 번호로, 모르면 이 문장" |
| 67 | FAQ Tool (**낱말**로 찾기), Check answer | 뜻으로 찾기, 답 검사 넷 |
| 69 | 검토 답을 못 읽으면 통과로 치지 않음 | 확실하지 않으면 담당자 연결 |
| 70 | 계약을 정하고 테스트로 지킴 | 색인 · 검색 · Q&A 계약, 24문항 |
| 72 (내일) | 느린 곳 재기 | LLM 없는 길 0.3초 vs LLM 1.8~3.0초 |
---
## 5 / 23 · 왜 RAG인가
| | LLM에 그냥 묻기 | RAG |
| --- | --- | --- |
| "비밀번호 며칠마다?" | 그럴듯한 숫자 | 우리 규정의 **90일** + `[비밀번호·계정 보안 규정]` |
| 규정이 바뀌면 | 모델이 모름 | 문서만 다시 색인 |
| 틀렸는지 확인 | 어려움 | 출처를 열어 봄, 코드로 검사 |
| 다른 방법 | 왜 오늘은 아닌가 |
| --- | --- |
| 지시문에 규정 전부 | 문서가 많으면 길고, 바뀔 때마다 지시문을 고침 |
| 파인튜닝 | 사실을 정확히 외우게 하기 어렵고 출처가 없음 |
| Tool로 조회 (67일차) | 낱말이 맞아야 찾음 ("암호" ≠ "비밀번호") |
---
## 6 / 23 · 오늘의 그림
```mermaid
flowchart LR
subgraph IDX["색인 my-index"]
D[("/docs 8개")] --> C["섹션 청크 25개"] --> E["임베딩"] --> V[("메모리 저장소")]
end
Q(["질문"]) --> S["가까운 청크 3개<br/>my-search"] --> R{"≥ 0.33?"}
R -->|예| L{{"발췌로만 답"}} --> K["답 검사"] --> A(["답 + 출처"])
R -->|아니오| H(["담당자 연결"])
K -->|실패| H
V --> S
```
> 메모리 저장소는 n8n이 재시작되면(push 할 때마다) 지워집니다. `flow.py test`는 케이스 파일의 `before` 칸(명령이 아님)에 적힌 대로 먼저 색인합니다.
---
## 7 / 23 · 오늘의 문서 · 규정 8개, 섹션 25개
| 문서 | 섹션 | 문서 | 섹션 |
| --- | --- | --- | --- |
| 비밀번호·계정 보안 | 4 | 개인정보 처리 지침 | 3 |
| 업무용 장비 지급·반납 | 4 | 원격근무·VPN 이용 | 3 |
| 메일·문서 보안 | 3 | 보안 사고 대응 절차 | 2 |
| 회의실 이용 | 3 | 소프트웨어 사용 | 3 |
> 문서 하나가 526~928바이트로 짧고, 모두 `## ` 섹션으로 나뉩니다. 데이터 API `/docs`(71일차 판)에서 가져옵니다.
---
## 8 / 23 · 1단계 · 점수 읽기 (섹션 청크, 실측)
| 질문 | 1등 | 2등 | 3등 |
| --- | --- | --- | --- |
| 비밀번호는 몇 자 이상? | 만들기 **0.748** | 변경 주기 0.54 | 잠금 0.484 |
| 암호를 얼마나 자주 바꿔야 해? | 변경 주기 **0.576** | 만들기 0.512 | 다단계 인증 0.456 |
| 컴퓨터가 바이러스에 걸린 것 같아요 | 악성코드 **0.478** | 피싱 0.357 | 재택 보안 0.249 |
| 오늘 점심 메뉴 추천해줘 | (규정 아무거나) **0.202** | | |
> "암호"는 규정에 없는 낱말인데 "변경 주기"가 1등 → **뜻**으로 찾았습니다. 점수는 확률이 아니라 **순서**의 근거입니다.
---
## 9 / 23 · 1단계 · 정답표가 먼저
| 질문 | 문서 | 답 문구 |
| --- | --- | --- |
| 비밀번호는 몇 자 이상? | `password-policy` | 12자 |
| 암호를 얼마나 자주 바꿔야 해? | `password-policy` | 90일 |
| 회사를 그만두면 노트북은? | `device-issue-return` | 5영업일 |
| 연차 휴가는 며칠? | **없음** | — |
> 검색 결과를 보고 "그럴듯하다"고 판단하지 않습니다. 먼저 정답을 정해야 **셀 수** 있습니다. 정답표도 틀립니다: "윈도우 업데이트"는 규정에 없었습니다.
---
## 10 / 23 · 1단계 · 청크 하나의 모양
```text
글: 비밀번호·계정 보안 규정 ← 문서 제목을 앞에
## 2. 변경 주기
- 비밀번호는 90일마다 바꾼다. …
메타데이터: source · title (과제: section) ← 출처는 여기서
벡터: 3,072차원 (text-embedding-3-large)
```
| | 결과 (실측) |
| --- | --- |
| `lab01` | 6/6 |
| 섹션 제목까지 출처 (`lab01-challenge`) | 예제 2/4 → 4/4 |
> 예제는 섹션 제목이 **글 안에는** 있지만 **칸으로는** 없어서 2/4였습니다.
---
## 11 / 23 · 1단계 · 메모리 저장소의 한살이
```mermaid
flowchart LR
P["push"] --> R["n8n 재시작"] --> E[("빔")] -->|"call my-index · test(before 칸)"| I[("청크 25개")] --> S["검색"]
```
| | 시간 (실측) |
| --- | --- |
| push 직후 첫 색인 | 3.8초 |
| 다시 색인 (`test`가 `before` 칸으로) | 0.4~2.2초 |
| 검색 한 번 | 0.2~0.9초 |
> 운영에서는 n8n 밖의 벡터 DB(재시작에도 남음, 바뀐 문서만 다시 색인). 흐름은 같습니다.
---
## 12 / 23 · 2단계 · 발췌로만 답하기
```text
질문: 암호를 얼마나 자주 바꿔야 해?
규정 발췌:
[1] 비밀번호·계정 보안 규정 ## 2. 변경 주기 - 비밀번호는 90일마다 바꾼다. …
[2] … ## 1. 비밀번호 만들기 …
[3] … ## 4. 다단계 인증 …
```
→ "암호는 90일마다 변경해야 하며, … [비밀번호·계정 보안 규정]" (`lab02` 6/6)
> 틀린 전제("30일마다 바꾸면 되죠?")에도 "30일이 아니라 90일"이라고 답했습니다. 질문이 아니라 **발췌**가 기준입니다.
---
## 13 / 23 · 2단계 · 번호로 출처, `sources`와 `cited`
| 질문 | `sources` (찾은 문서) | `cited` (인용한 문서) |
| --- | --- | --- |
| 생성형 AI에 고객 자료를 넣어도? | 소프트웨어, 개인정보 | 소프트웨어 |
| 비밀번호를 잊어버리면 어디서 바꿔요? | 비밀번호 | **없음** |
> LLM은 `[번호]`만 쓰고, 번호 → 제목은 **코드**가 바꿉니다. 찾았는데 인용하지 않았다면 답이 발췌를 쓰지 않은 것입니다.
---
## 14 / 23 · 2단계 · 관련도 기준 0.33
| 규정 질문 5개 | 규정 밖 5개 (점심 · 연차 · 영화 · 맛집 · 연봉) |
| --- | --- |
| 0.464 ~ 0.616 | 0.202 ~ 0.261 |
| 기준을 올리면 | 기준을 낮추면 |
| --- | --- |
| 진짜 질문도 넘김 (0.47 → "퇴사 노트북" 0.464) | 엉뚱한 질문에 답을 씀 |
> 답이 **있는데** 0.267인 질문도 있습니다 ("공짜 프로그램은 그냥 써도 되죠?"). 기준값은 모델마다 두 무리를 재서 정합니다.
---
## 15 / 23 · 2단계 · 점수는 높은데 답이 없을 때
| 질문 | 점수 | 기준값만 쓴 결과 |
| --- | --- | --- |
| VPN 접속 속도가 느리면? | 0.463 | `자동 답변`: "…언급하고 있지 않습니다" |
| 노트북 사양은? | 0.379 | `자동 답변`: "…포함되어 있지 않습니다" + **출처 3개** |
| 비밀번호를 잊어버리면? | 0.551 | `자동 답변`: "규정에서 답을 찾지 못했습니다" |
> 기준값으로는 못 거릅니다. **답 검사**(모른다고 했나 · 인용 · 근거 겹침 · 숫자)로 넘깁니다: `lab02-challenge` 2/8 → 8/8 (`--repeat 3` 24/24).
---
## 16 / 23 · 2단계 · 넘기는 길 세 갈래
```mermaid
flowchart LR
T{"기준 통과?"} -->|아니오| H1(["담당자 연결<br/>규정에 없음"])
T -->|예| K{"답 검사 통과?"} -->|예| A(["자동 답변 + 출처"])
K -->|아니오| H2(["담당자 연결<br/>규정이 모자람 + draft"])
```
> 두 담당자 연결은 **말과 넘기는 것이 다릅니다.** 뒤쪽이 쌓이는 곳(VPN 속도, 노트북 사양 …)이 규정을 보완할 곳입니다.
---
## 17 / 23 · 3단계 · 지표 셋
| 지표 | 묻는 것 |
| --- | --- |
| doc@1 | 1등이 맞는 **문서**인가 |
| answer@1 | 1등에 **답 문구**가 있나 |
| answer@3 | 3등 안에 답 문구가 있나 (LLM에 3개를 줌) |
| answer@1에서 틀린 예 | 1등 (답 없음) | 2등 (답 있음) |
| --- | --- | --- |
| 회사를 그만두면 노트북은? | 지급과 교체 0.464 | 반납 0.45 |
> doc@1 ✔ · answer@1 ✘ = 맞는 문서의 **다른 섹션**이 1등.
---
## 18 / 23 · 3단계 · 청크 크기 (24문항)
| 설정 | 청크 | doc@1 | answer@1 | answer@3 |
| --- | --- | --- | --- | --- |
| 섹션 | 25 | 24 | 22 | 24 |
| 200자 | 20 | 24 | 20 | 24 |
| 400자 | 12 | 22 | 22 | 24 |
> 한 번에 **하나만** 바꿉니다. `call my-index` + `test --no-before`. 우리 문서에서는 섹션이 가장 좋았고, 3개를 주면(answer@3) 차이가 줄었습니다.
---
## 19 / 23 · 3단계 · 잘린 문장과 섞인 청크
| 설정 | 일어난 일 |
| --- | --- |
| 200자 | "만료 **30일 전부터** … 새 인증서를 \| 신청한다" — 문장 한가운데가 잘려 답 문구가 앞 청크로. 섹션 이름도 어긋남 |
| 400자 | 원격근무 문서의 섹션 셋이 모두 한 청크에 → "화면 잠금" 질문의 1등이 **0.241** 회의실 규정, 답이 든 청크는 2등 0.236 (섹션 색인에서는 0.354로 1등) |
> 같은 22점이라도 **틀린 질문이 다릅니다.** 표에 틀린 질문까지 적습니다.
---
## 20 / 23 · 3단계 · 질문 다시 쓰기의 득실
| 질문 | 다시 쓴 검색어 | 결과 |
| --- | --- | --- |
| 챗GPT에 회의록 붙여 넣어도 돼요? | 생성형 AI 서비스에 회의록 데이터 입력 정책 | ✘ 회의실 → ✔ 소프트웨어 |
| 고객 명단 엑셀을 개인 메일로 … | 고객 명단 엑셀 외부로 보내기 및 보안 정책 | ✘ 보안 사고 → ✔ 메일·문서 |
| 모르는 프로그램이 자꾸 저절로 떠요 | 알 수 없는 프로그램 자동 실행 정책 | ✔ 보안 사고 → ✘ **소프트웨어** |
| | 다시 쓰기 없이 | 다시 쓰기 |
| --- | --- | --- |
| 어려운 질문 8개 | 6/8 | 7/8 |
| 검색 시간 | 0.2~0.5초 | 0.9~2.0초 |
> 고치면 다른 것이 깨질 수 있습니다. 좋아진 것과 나빠진 것을 **함께** 잽니다.
---
## 21 / 23 · 3단계 · 같은 설정, 다른 결과
| | 10/18 | 10/22 |
| --- | --- | --- |
| "고객 정보가 밖으로 샌 것 같아요" 다시 쓴 검색어 | 유출 **대응 방안** | 유출 **방지 정책** |
| 1등 | 3. 유출 대응 ✔ | 2. 이용과 보관 ✘ |
| 24문항 answer@1 | 24 | 23 |
> 검색 앞에 LLM이 있으면 검색도 매번 달라집니다. `--repeat`으로 재고 **범위**로 적습니다. LLM이 없는 섹션 검색은 두 날 모두 22였습니다.
---
## 22 / 23 · 7교시 · 바이브 코딩 `tdoc`
```text
$ tdoc ~/aio-01d4-stu/Lecture
질문: [메모리 저장소는 언제 지워져요?]
답: n8n이 재시작되면 지워집니다 [1] …
근거: [1] 0.xx Lecture/71/01-STAGE1-RAG-AND-INDEX.md · 5. 메모리 저장소
```
- **요청서(`VIBE-REQ-TDOC`)부터** 읽고 `SPEC.md`를 에이전트 없이 10분 → VIBE-Q와 비교 → 단계별로 만들기
- 내 md 문서를 SQLite에 색인(바뀐 파일만)하고 근거와 함께 답하는 TUI, 설정 비교(`tdoc eval`)
---
## 23 / 23 · 정리와 72일차 예고
| 오늘 | 한 줄 |
| --- | --- |
| 1단계 | 정답표가 먼저, 청크는 뜻 단위로, 메타데이터가 출처, 메모리 저장소는 재시작에 지워짐 |
| 2단계 | 발췌로만 답하고 번호로 출처, 기준값은 엉뚱한 질문을, 답 검사는 답 없는 질문을 |
| 3단계 | 지표 셋으로 재고, 하나씩 바꾸고, 틀린 질문과 나빠진 것도 적는다 |
- 72일차: **Workflow 실행 흐름 최적화** — 느린 곳을 재고, 규칙 먼저·캐시·LLM 호출 줄이기로 빠르게, 고장 나도 정해진 답을 · 바이브 코딩 `tbench`
+86
View File
@@ -0,0 +1,86 @@
# 71일차 실습 준비 · 수업 전에 미리 해 두면 좋은 것
- 수업일: 2026-10-23 (71일차) · RAG 기반 검색·응답 Workflow와 검색 품질 개선
- 준비 시간: 약 10분 (70일차 준비를 했다면 1·2절만)
- 먼저 WSL에서 `cd ~/aio-01d4-stu && git pull`로 오늘 자료를 받습니다 (수강생 저장소: https://gl.isitsoft.kr/publicproj/aio-01d4-stu).
- **수업 전날까지** 1~2절을 끝내 주세요.
```mermaid
flowchart LR
A["1. n8n · 데이터 API 켜기<br/>(71일차 판)"] --> B["2. doctor<br/>ready"] --> C["3. 미리 읽기"]
```
---
## 1. n8n과 데이터 API 켜기 (전날 필수, 3분)
오늘은 **71일차 판 데이터 API**(67일차 + 규정 문서 `/docs`)를 씁니다. 67일차 데이터 API를 켜 두었다면 이 폴더에서 다시 만들면 바뀝니다 (같은 이름이라 하나만 돕니다).
```bash
sudo systemctl disable --now ollama 2>/dev/null # WSL에 설치한 Ollama가 있으면 끄고 자동 시작도 끔
cd ~/aio-01d4-stu/Last3Mon/65/n8n
docker compose rm -sf ollama # Ollama 컨테이너가 있으면 지움 (없으면 그냥 지나감)
docker compose start
cd ~/aio-01d4-stu/Last3Mon/71/data-api
docker compose up -d --force-recreate
curl -s localhost:8800/docs | head -c 120 # {"docs": [{"name": "device-issue-return", ...
```
- **66일차부터 LLM은 OpenAI API 키(`Last3Mon/.env`, Credential `OpenAI (.env)`)만 씁니다.** Ollama는 쓰지 않으므로 위처럼 꺼서 메모리를 아낍니다. 다음 날부터는 같은 명령을 그대로 써도 됩니다.
- `network day65-n8n_default not found` → n8n이 켜져 있지 않음. 위 n8n 부분부터 다시.
- `.../day65-n8n/compose.yml: no such file or directory` → 홈이 아닌 곳에 설치함. `Last3Mon/65/n8n/.env`에 `N8N_HOME=<설치 폴더>` ([67일차 준비](../67/00-DAY67-PREP.md) 1절).
## 2. 준비 확인 (전날 필수, 2분)
```bash
cd ~/aio-01d4-stu/Last3Mon/71
uv run tools/flow.py doctor
```
```text
✔ n8n install folder /home/<사용자>/day65-n8n
✔ n8n container day65-n8n-n8n-1
✔ n8n http://localhost:5678
✔ data API http://localhost:8800
✔ policy documents /docs (8)
✔ n8n → http://data-api:8800
✔ credential 'OpenAI (.env)' (openAiCourse0001)
✔ node catalog (for check)
✔ CLI coding agent: codex, claude
ready
```
| ✘ 항목 | 할 일 |
| --- | --- |
| n8n container · data API | 1절 |
| policy documents /docs | 데이터 API가 예전 판 → 1절 `--force-recreate` |
| credential | `cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh` |
| CLI coding agent | [67일차 준비](../67/00-DAY67-PREP.md) 2절 |
---
## 3. 미리 읽어 오면 좋은 내용 (선택, 약 5분)
### 3.1 LLM은 우리 회사 규정을 모른다
"비밀번호는 며칠마다 바꿔야 하나요?"를 LLM에게 그냥 물으면 그럴듯한 숫자를 말합니다. 우리 규정(90일)을 답하게 하려면, 질문할 때마다 **규정에서 관련 부분을 찾아** LLM에게 같이 주어야 합니다. 이것이 RAG입니다.
### 3.2 용어
| 용어 | 한 줄 설명 |
| --- | --- |
| RAG | 검색(Retrieval)한 문서를 붙여(Augmented) 답을 만든다(Generation) |
| 청크 | 문서를 검색 단위로 나눈 조각 (오늘은 섹션 하나) |
| 임베딩 | 글의 뜻을 숫자 목록(벡터)으로 바꾼 것. 뜻이 가까우면 숫자도 가깝다 |
| 벡터 저장소 | 청크와 그 임베딩을 저장하고 가까운 것을 찾아 주는 곳 (오늘은 n8n 메모리) |
| doc@1 · answer@1 | 1등 청크가 맞는 문서인가 · 답 문구를 담고 있나 (검색 품질 지표) |
## 4. 당일 준비물
- 충전된 노트북과 충전기, WSL의 Docker, Windows Terminal(WSL) 창 **두 개**, 웹 브라우저
- 7교시 `tdoc`에 쓸 내 md 문서 폴더 (없으면 이 과정의 `Lecture/` 폴더를 씀)
## 확인
- [ ] `curl -s localhost:8800/docs`에 규정 8개가 보인다
- [ ] `uv run tools/flow.py doctor` → 마지막 줄 `ready`
+359
View File
@@ -0,0 +1,359 @@
# 1단계 · RAG와 색인
- 교시: 1~2교시 (09:00~10:50) · 1교시 강의(개요 포함) 약 40분, 2교시 Lab 01
- 실습: [Lab 01](../../Last3Mon/71/LAB-01-Q-INDEX-AND-SEARCH.md)
- 이 단계가 끝나면: RAG의 두 흐름(색인 · 질문)을 설명하고, 검색 전에 정답표를 만들고, 규정 문서를 색인·검색하는 n8n 워크플로우를 요청문으로 만들 수 있다.
- 이 교안의 숫자: 2026-10-18 수업 준비 측정, 2026-10-22 같은 정답으로 다시 돌린 기록([Last3Mon/71/answers](../../Last3Mon/71/answers/LAB-TEST-RESULT.md), 수업 뒤 공개)
```mermaid
flowchart LR
A["1. 왜 RAG인가"] --> B["2. 임베딩과<br/>가깝다는 것"] --> C["3. 정답표 먼저"] --> D["4. 청크 · 메타데이터"] --> E["5. 메모리 저장소"] --> F["6. 요청문으로"]
```
| 절 | 말하는 시간 (예) | 띄울 것 |
| --- | --- | --- |
| 1. 왜 RAG인가 | 7분 | 1절 표 · 그림 |
| 2. 임베딩과 가깝다는 것 | 8분 | 실측 점수 표 |
| 3. 정답표 먼저 | 7분 | 정답표 · 질문 종류 |
| 4. 청크 · 메타데이터 | 8분 | 규정 8개 표 · 청크 하나 |
| 5. 메모리 저장소 | 5분 | 한살이 그림 |
| 6. 요청문으로 · 확인 질문 | 5분 | 요청문 예 |
## 1. 왜 RAG인가
### 1.1 LLM이 우리 회사 일을 못 하는 세 가지 까닭
| 까닭 | 예 | 결과 |
| --- | --- | --- |
| **모른다** | 우리 회사 비밀번호 규정은 학습한 적이 없음 | "보통 90일입니다"처럼 **그럴듯한 일반론** |
| **오래됐다** | 규정이 지난주에 바뀜 | 바뀐 것을 모름. 모델을 다시 학습시킬 수도 없음 |
| **출처가 없다** | 답이 맞는지 사람이 확인할 곳이 없음 | 틀려도 모름 (가장 위험) |
LLM은 우리 회사 규정을 모릅니다. "비밀번호는 며칠마다 바꿔요?"에 그럴듯한 숫자를 말할 뿐입니다. 그 숫자가 우연히 맞아도, **왜 맞는지**를 보여 줄 수 없습니다.
### 1.2 우리 문서를 LLM에게 알려 주는 네 가지 방법
| 방법 | 어떻게 | 좋은 점 | 한계 | 이 과정에서 |
| --- | --- | --- | --- | --- |
| 지시문에 모두 넣기 | 규정 전문을 system 메시지에 | 가장 쉬움 | 문서가 많으면 길어지고(비용 · 시간), 바뀔 때마다 지시문을 고침 | 64일차 공지 지시문 |
| 파인튜닝 | 우리 문서로 모델을 더 학습 | 말투 · 형식에 강함 | 사실을 정확히 외우게 하기 어렵고, 바뀔 때마다 다시 학습, 출처 없음 | 하지 않음 |
| Tool로 조회 | LLM이 "FAQ 찾기" Tool을 부름 | 정해진 질의(번호 · 이름)에 정확 | 낱말이 맞아야 찾음 | 67일차 FAQ Tool |
| **RAG** | 질문할 때마다 문서에서 **관련 부분만 찾아** 함께 줌 | 문서가 바뀌면 다시 색인만, **출처**가 남음 | 찾는 것이 틀리면 답도 틀림 | **오늘** |
**RAG**(Retrieval-Augmented Generation, 검색 증강 생성)는 질문할 때마다 규정에서 **관련 부분만 찾아** LLM에게 같이 주고, 그 부분으로만 답하게 합니다. 이름 그대로 **검색(Retrieval)이 생성(Generation)을 돕습니다.**
```mermaid
flowchart LR
subgraph 색인["색인 (문서가 바뀔 때)"]
D[("문서")] --> C["청크로 나누기"] --> E["임베딩"] --> V[("벡터 저장소")]
end
subgraph 질문["질문할 때마다"]
Q(["질문"]) --> QE["질문 임베딩"] --> S["가까운 청크 k개"] --> L{{"그 청크로만 답"}}
end
V --> S
```
- 색인은 **가끔**(문서가 바뀔 때), 질문은 **매번**. 두 흐름은 다른 워크플로우입니다 (오늘 `my-index`, `my-search`).
- 오늘 1단계는 **찾기까지**만 합니다. 답을 쓰는 것은 2단계입니다. 찾는 것이 틀리면 아무리 좋은 LLM도 맞는 답을 쓸 수 없기 때문에, 찾기를 따로 재고 확인합니다.
### 1.3 67일차 FAQ 검색과 무엇이 다른가
| | 67일차 FAQ 검색 | 오늘 RAG |
| --- | --- | --- |
| 찾는 법 | 낱말이 겹치나 (`keywords`) | **뜻**이 가까운가 (임베딩) |
| "암호를 바꿔야 해?" | "암호"가 키워드에 없으면 못 찾음 | "비밀번호" 섹션을 찾음 (실측 아래 2절) |
| 찾는 단위 | FAQ 한 개 | 문서의 한 섹션 (청크) |
| 준비 | 사람이 FAQ마다 키워드를 적음 | 문서를 그대로 색인 |
| 틀리는 방식 | 못 찾음 (0개) | **언제나 무언가를 찾음** (엉뚱한 것도) |
마지막 줄이 오늘 하루의 숙제입니다. 낱말 검색은 못 찾으면 "없음"이라고 하지만, 뜻 검색은 점심 메뉴를 물어도 가장 가까운 규정 3개를 돌려줍니다. 2단계의 관련도 기준과 답 검사가 이것 때문에 필요합니다.
## 2. 임베딩과 "가깝다"는 것
### 2.1 임베딩은 글을 숫자 목록으로
**임베딩**은 글 한 덩어리를 숫자 목록(벡터) 하나로 바꾸는 것입니다. 오늘 쓰는 `text-embedding-3-large`는 글 하나를 **3,072차원** 벡터(숫자 3,072개짜리 목록)로 바꿉니다 (모델 기본값). 뜻이 비슷한 글은 비슷한 숫자 목록이 되도록 학습된 모델입니다.
```mermaid
flowchart LR
A["암호를 얼마나 자주<br/>바꿔야 해?"] --> EA["[0.01, -0.03, …]<br/>3,072차원"]
B["## 2. 변경 주기<br/>비밀번호는 90일마다…"] --> EB["[0.02, -0.02, …]<br/>3,072차원"]
EA --> S{{"얼마나 같은 방향?<br/>(코사인 유사도)"}}
EB --> S
S --> R["0.576"]
```
- 숫자 하나하나에 뜻이 있는 것은 아닙니다. **두 목록이 얼마나 같은 방향을 가리키는지**(코사인 유사도)만 씁니다. 그림의 숫자 목록은 모양을 보여 주는 예이고, 0.576은 실제로 잰 점수입니다.
### 2.2 왜 3,072차원인가
3,072차원은 벡터가 숫자 몇 개로 되어 있는지(**차원 수**)입니다. 글이 섹션 하나든 질문 한 줄이든, 이 모델은 늘 3,072차원 벡터 하나를 돌려줍니다.
| 물음 | 답 |
| --- | --- |
| 왜 하필 3,072차원? | 데이터에서 나온 값이 아니라 **모델을 만들 때 정한 크기**입니다. 같은 회사의 작은 모델 `text-embedding-3-small`은 1,536차원. 많을수록 뜻의 미묘한 차이를 더 담을 수 있지만, 저장 공간과 계산이 늘어납니다 |
| 숫자 하나는 무슨 뜻? | 3,072차원 공간의 **좌표**입니다. 지도에서 (위도, 경도) 두 숫자로 위치를 나타내듯, 글 하나를 3,072차원 공간의 한 점으로 나타냅니다. 다만 각 축에 사람이 읽을 뜻("3번째 = 보안 정도")은 없습니다. 모델이 학습하며 스스로 정한 축입니다 |
| 질문과 청크를 다른 모델로? | 안 됩니다. 차원 수가 다르면 비교가 안 되고, 같아도 모델이 다르면 공간이 달라 점수가 의미 없습니다. **모델을 바꾸면 전부 다시 색인**하고 관련도 기준값도 다시 잽니다 |
| 저장 크기는? | 숫자 하나를 보통 4바이트로 저장하면 청크 하나에 약 12KB. 오늘 섹션 청크 25개면 약 300KB로 작지만, 수십만 청크면 GB 단위가 됩니다. 7교시 `tdoc`이 SQLite에 저장하는 것이 이 숫자 목록입니다 |
| 줄일 수 있나? | text-embedding-3 모델은 요청할 때 `dimensions`(예: 256, 1024)로 더 짧은 벡터를 받을 수 있습니다. 가볍고 빠르지만 품질이 조금 떨어질 수 있습니다 (오늘은 쓰지 않았고 재지도 않음) |
- n8n의 메모리 저장소는 이 점수를 0~1로 돌려줍니다. 클수록 가깝습니다.
### 2.3 실제로 잰 점수 읽기 (섹션 청크 25개, 2026-10-22)
| 질문 | 1등 | 2등 | 3등 |
| --- | --- | --- | --- |
| 비밀번호는 몇 자 이상이어야 하나요? | 비밀번호 · 1. 만들기 **0.748** | 비밀번호 · 2. 변경 주기 0.54 | 비밀번호 · 3. 잠금 0.484 |
| 암호를 얼마나 자주 바꿔야 해? | 비밀번호 · 2. 변경 주기 **0.576** | 비밀번호 · 1. 만들기 0.512 | 비밀번호 · 4. 다단계 인증 0.456 |
| 첨부 파일이 너무 커서 메일이 안 보내져요 | 메일 · 2. 첨부 파일 **0.616** | 메일 · 1. 메일함 0.455 | 보안 사고 · 1. 피싱 메일 0.335 |
| 컴퓨터가 바이러스에 걸린 것 같아요 | 보안 사고 · 2. 악성코드 **0.478** | 보안 사고 · 1. 피싱 0.357 | 원격근무 · 3. 재택 보안 0.249 |
`top_k: 5`로 같은 바이러스 질문을 물으면 4등 0.246(비밀번호 · 잠금), 5등 0.241(소프트웨어 · 설치)이 더 나옵니다.
읽는 법:
1. **"암호"는 규정에 없는 낱말**입니다 (규정은 "비밀번호"). 그래도 1등이 "2. 변경 주기"입니다 → 뜻으로 찾았습니다.
2. 점수는 **확률이나 퍼센트가 아닙니다.** 0.478은 "47.8% 맞음"이 아니라, 같은 질문의 다른 청크보다 가깝다는 **순서**의 근거입니다. 질문마다 1등 점수가 0.478~0.748로 다릅니다.
3. 1등과 2등의 **차이**도 봅니다. 바이러스 질문은 1등 0.478, 2등 0.357 → 1등이 뚜렷합니다. 3등 이하는 0.25 근처로 모두 비슷합니다 → "관련 없는 것들의 점수대"입니다.
4. 점심 메뉴처럼 규정과 상관없는 질문의 1등은 0.202였습니다. 이 "관련 없는 점수대"를 2단계에서 기준값으로 씁니다.
### 2.4 같은 질문인데 점수가 조금 다르다
같은 질문 "비밀번호를 잊어버리면 어디서 바꿔요?"의 1등 점수가 한 실행에서는 0.551, 다른 실행에서는 0.55였습니다 ("보안 교육" 질문도 0.352와 0.353). 임베딩도 셋째 자리에서 조금씩 흔들립니다. 그래서 기준값을 어떤 질문의 점수 바로 옆(예: "보안 교육"이 0.352~0.353이니 0.353)에 두지 않습니다. 그러면 같은 질문이 실행마다 통과했다 걸렸다 합니다. 두 무리 사이 **한가운데**에 여유 있게 둡니다 (오늘 0.33: 규정 밖 최고 0.261, 규정 질문 최저 0.464, 2단계 3절).
## 3. 정답표가 먼저
### 3.1 검색을 만들기 전에 사람이 정답을 정한다
| 질문 | 답이 있는 문서 | 섹션 | 답 문구 |
| --- | --- | --- | --- |
| 비밀번호는 몇 자 이상이어야 하나요? | `password-policy` | 1. 비밀번호 만들기 | 12자 |
| 암호를 얼마나 자주 바꿔야 해? | `password-policy` | 2. 변경 주기 | 90일 |
| 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? | `device-issue-return` | 3. 반납 | 5영업일 |
| 연차 휴가는 며칠이에요? | **없음** | — | — |
- 정답표가 있어야 검색이 맞았는지 **숫자로** 셉니다 (3단계에서 24문항).
- 답이 **없는** 질문도 정답표에 넣습니다. 검색은 언제나 무언가를 돌려주기 때문입니다 (1.3절 마지막 줄).
- 정답표 없이 검색 결과를 보면, 나온 것이 그럴듯해 보여서 "잘 되네요"가 됩니다. **결과를 보기 전에** 써야 합니다.
### 3.2 질문을 고르는 법: 네 종류를 섞는다
| 종류 | 예 | 무엇을 시험하나 |
| --- | --- | --- |
| 규정 낱말 그대로 | 비밀번호는 몇 자 이상? | 기본이 되는가 |
| 직원 말투 · 다른 낱말 | 암호를 얼마나 자주 바꿔야 해? / 회사를 그만두면 | 뜻으로 찾는가 |
| 규정에 답이 없음 | 연차 휴가는 며칠? / 노트북 사양은? | 모를 때 넘기는가 (2단계) |
| 틀린 전제 | 비밀번호는 30일마다 바꾸면 되죠? | 질문이 아니라 규정을 따르는가 (2단계) |
오늘 Lab의 24문항(`cases/retrieval-*.json`)은 규정 8개에서 2~4개씩, 낱말 그대로와 다른 낱말을 섞어 만들었습니다.
### 3.3 정답표도 틀린다
오후 **Lab 03 과제**에서 쓰는 "어려운 질문" 8개를 수업 준비 때 강사가 처음 만들 때, "윈도우 업데이트 안 하고 미루면 안 돼요?"의 기대 문서를 `security-incident`로 적었습니다. 테스트는 실패했습니다 (1등 `software-usage` 0.309, 다시 돌려 0.324). 규정을 다시 열어 보니 윈도우 업데이트 이야기는 **어디에도 없었습니다.** 검색이 틀린 것이 아니라 **정답표가 틀렸습니다.**
그 뒤에 한 일:
| 순서 | 한 일 | 결과 |
| --- | --- | --- |
| ① | 이 질문이 든 어려운 질문 8개를 두 번 돌림 | 두 번 다 **5/8** (보고서 [첫 번째](../../Last3Mon/71/answers/prep-20261018/71-lab03-challenge-cases-20261018-075719.json), [두 번째](../../Last3Mon/71/answers/prep-20261018/71-lab03-challenge-cases-20261018-075742.json)) |
| ② | 규정을 열어 보니 이 질문의 답이 없음 → **정답표가 틀린 것** | — |
| ③ | 이 질문을 "모르는 프로그램이 자꾸 저절로 떠요"로 바꿈 | **6/8** → 지금 Lab 03 과제에서 쓰는 케이스 파일 |
- 보고서 두 개는 수업이 끝난 뒤 공개되는 `Last3Mon/71/answers/prep-20261018/`에 있습니다. 수업 중에는 강사 화면에서 엽니다.
- 정답표는 기억이 아니라 **원문을 열어 확인**하고 씁니다 (문서 이름, 섹션, 답 문구를 복사).
- 검색이 "틀렸다"고 나오면, 고치기 전에 정답표부터 다시 봅니다.
## 4. 청크 · 메타데이터
### 4.1 오늘의 문서: 규정 8개, 섹션 25개
| `name` (데이터 API) | 제목 | 섹션 |
| --- | --- | --- |
| `password-policy` | 비밀번호·계정 보안 규정 | 4 (만들기 · 변경 주기 · 잠금 · 다단계 인증) |
| `device-issue-return` | 업무용 장비 지급·반납 규정 | 4 (지급과 교체 · 분실과 도난 · 반납 · 개인 장비) |
| `email-and-documents` | 메일·문서 보안 규정 | 3 |
| `meeting-room` | 회의실 이용 규정 | 3 |
| `privacy` | 개인정보 처리 지침 | 3 (수집 · 이용과 보관 · 유출 대응) |
| `remote-work-vpn` | 원격근무·VPN 이용 규정 | 3 |
| `security-incident` | 보안 사고 대응 절차 | 2 (피싱 메일 · 악성코드 감염 의심) |
| `software-usage` | 소프트웨어 사용 규정 | 3 (설치와 승인 · 금지 사항 · 생성형 AI) |
문서 하나가 수백 바이트(526~928바이트)로 짧고, 모두 `## ` 섹션으로 나뉘어 있습니다. 실제 사내 규정은 훨씬 길지만 **모양**(제목 → 섹션 → 항목)은 같습니다.
### 4.2 색인 단계와 n8n 노드
| 단계 | 오늘의 선택 | n8n 노드 |
| --- | --- | --- |
| 문서 가져오기 | 데이터 API `/docs`, `/docs/<name>` (규정 8개) | HTTP Request, Split Out |
| 청크 나누기 | `## ` 섹션 하나 = 청크 하나, 글 앞에 문서 제목 | Code |
| 임베딩 | OpenAI `text-embedding-3-large` | Embeddings OpenAI |
| 저장 | 메모리 벡터 저장소 `my_policy_docs` (+ 문서 이름 · 제목 메타데이터) | Vector Store In-Memory (insert) + Default Data Loader |
| 검색 | 질문과 가까운 청크 `top_k`개 + 점수 | Vector Store In-Memory (load) |
### 4.3 청크 하나의 실제 모양
`password-policy`의 두 번째 청크는 이렇게 저장됩니다 (글 + 메타데이터 + 벡터):
```text
글: 비밀번호·계정 보안 규정
## 2. 변경 주기
- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.
- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.
메타데이터: source = password-policy, title = 비밀번호·계정 보안 규정, chunk = 2 (과제에서 section = 2. 변경 주기)
벡터: 3,072차원
```
- **글 앞에 문서 제목을 붙입니다.** 섹션만 떼어 내면 "## 2. 변경 주기"만으로는 무엇의 변경 주기인지 모릅니다. 제목이 붙어야 "비밀번호" 질문과 가까워집니다.
- **청크는 뜻 단위로.** 섹션은 한 가지 주제를 다룹니다. 글자 수로 자르면 한 규칙이 두 청크로 잘리거나, 두 주제가 한 청크에 섞입니다 (3단계에서 측정: 400자는 청크 12개로 줄고 doc@1 22/24).
- **메타데이터가 출처.** 청크와 함께 문서 이름 · 제목(과제에서는 섹션 제목)을 저장해야 답에 출처를 붙일 수 있습니다. 벡터에서 출처를 되살릴 수는 없습니다.
### 4.4 "글 안에 있다"와 "칸으로 있다"는 다르다 (Lab 01 과제 미리 보기)
**무엇을 원했나.** Lab 01 과제에서 팀장이 "문서 이름만 말고 **섹션 제목**까지 알려 주세요"라고 합니다. 검색 결과의 1등마다 `section` 칸에 "1. 비밀번호 만들기"처럼 섹션 제목이 있어야 합니다.
**예제 2까지 만든 검색은 무엇을 돌려주나.** "비밀번호는 몇 자 이상이어야 하나요?"를 물으면 1등 결과가 이렇습니다.
| 예제 2까지 만든 검색 (과제 전) | 과제를 마친 검색 |
| --- | --- |
| `"source": "password-policy"` | `"source": "password-policy"` |
| `"title": "비밀번호·계정 보안 규정"` | `"title": "비밀번호·계정 보안 규정"` |
| (`section` 칸 **없음**) | `"section": "1. 비밀번호 만들기"` |
| `"text": "비밀번호·계정 보안 규정\n## 1. 비밀번호 만들기\n- 비밀번호는 12자 이상으로 …"` | (같음) |
왼쪽을 보면 섹션 제목 "1. 비밀번호 만들기"가 **`text` 안에는 글자로 들어 있습니다.** 사람이 읽으면 어느 섹션인지 압니다. 하지만 `section`이라는 **칸**은 없습니다.
**그래서 테스트가 실패합니다.** 과제 테스트(`cases/lab01-challenge.json`)는 `hits.0.section` 칸을 읽어 "비밀번호 만들기"가 있는지 봅니다. 칸이 없으니 실패입니다.
```text
✘ 섹션 · 비밀번호 길이 · 0.4s · hits.0.section has none of ['비밀번호 만들기']
```
과제 테스트 4개 중 섹션을 보는 2개가 이렇게 실패하고, 개수(`top_k`)를 보는 2개는 통과해 **2/4**입니다 (10/18, 10/22 두 번 다).
**배울 것.** 2단계에서 답에 출처를 붙이는 Code, 3단계에서 채점하는 테스트는 모두 프로그램입니다. 프로그램은 글을 읽고 짐작하지 않고 **정해진 칸**만 읽습니다. 출처로 쓸 것은 처음부터 칸으로 만들어야 합니다.
**어떻게 고치나.** 섹션 제목은 **색인할 때** 청크를 자르면서 알 수 있으므로 그때 메타데이터(`section`)로 함께 저장합니다. 검색은 저장된 메타데이터를 꺼내 `section` 칸에 넣기만 하면 됩니다. 색인 · 검색 두 워크플로우를 모두 고치고, 다시 색인한 뒤 테스트하면 4/4입니다.
### 4.5 청크 크기는 어떻게 정하나 (3단계 예고)
| 너무 작으면 | 너무 크면 |
| --- | --- |
| 규칙 하나가 둘로 잘려 답 문구가 다음 청크로 넘어감 | 여러 주제가 섞여 점수가 흐려짐, LLM에 주는 글이 길어짐 |
| "변경 주기"만 있고 무엇의 주기인지 없음 | 1등 청크에 답이 있어도 어디인지 찾기 어려움 |
정답은 "몇 자"가 아니라 **내 문서의 모양**입니다. 섹션이 있는 문서는 섹션부터 시작하고, 3단계에서 숫자로 확인합니다.
## 5. 메모리 저장소
오늘은 n8n 안의 **메모리** 벡터 저장소를 씁니다. 설치가 필요 없지만 주의할 점이 있습니다.
```mermaid
flowchart LR
P["flow.py push"] --> R["n8n 재시작"] --> E[("저장소 빔")]
E -->|"call my-search"| X["hits: [] · indexed: false"]
E -->|"call my-index<br/>또는 test (케이스 파일의 before 칸)"| I[("청크 25개")]
I --> S["검색 · 답"]
```
| 성질 | 결과 | 대처 |
| --- | --- | --- |
| n8n이 재시작되면 지워짐 | `flow.py push`는 n8n을 재시작 → 저장소가 빔 | push 뒤에는 `call my-index`로 다시 색인. `test`는 아래 `before` 칸 덕분에 알아서 색인 |
| 비어 있어도 검색은 실행됨 | 결과 없음 → 다음 노드가 멈출 수 있음 | `alwaysOutputData`, 다음 Code에서 문서가 있는 항목만 |
| 같은 이름이면 워크플로우끼리 공유 | 색인과 검색이 같은 저장소를 씀 | 이름(`my_policy_docs`)을 계약에 적음 |
| 색인할 때마다 비우고 다시 (`clearStore`) | 같은 청크가 두 번 들어가지 않음 | 두 번 색인하면 두 배가 되는 실수를 막음 |
**`before`는 명령이 아니라 케이스 파일(`cases/*.json`) 안의 칸입니다.** `flow.py test`가 케이스를 돌리기 전에 여기 적힌 호출을 한 번 합니다.
```json
{
"webhook": "my-search",
"before": [{"webhook": "my-index", "request": "requests/index-section.json"}],
"cases": [ ... ]
}
```
그래서 push로 저장소가 비어 있어도 `test`는 먼저 색인하고 시작하며, 출력 첫 줄에 `✔ before: POST /webhook/my-index · HTTP 200 · … {"indexed_chunks": 25, …}`가 찍힙니다. `--no-before`를 붙이면 이 호출을 건너뜁니다 (3단계에서 내가 다른 설정으로 색인해 둔 것을 그대로 잴 때).
잰 시간: push 직후 첫 색인 3.8초(2026-10-22), 그 뒤 `before` 칸으로 한 다시 색인 0.4~2.2초, 검색 한 번 0.2~0.9초 (수업 준비 때 색인 1.2초, 검색 0.2~0.4초).
### 5.1 운영에서는
| | 메모리 (오늘) | 운영용 벡터 DB (Qdrant, PGVector 등) |
| --- | --- | --- |
| 설치 | 없음 | 서버 · 컨테이너 |
| 재시작 | 지워짐 | 남음 |
| 다시 색인 | 매번 전부 | 바뀐 문서만 |
| 여러 워크플로우 · 여러 서버 | 같은 n8n 안에서만 | 어디서나 |
오늘 배운 흐름(청크 → 임베딩 → 저장 → 검색)은 그대로이고, 저장소 노드만 바뀝니다. 7교시 `tdoc`은 SQLite 파일에 저장해 "껐다 켜도 남게" 직접 만듭니다.
## 6. 요청문으로
Lab과 다른 문서(사내 교육 안내 문서)로 예를 듭니다.
```text
두 워크플로우를 만들어 줘. 노드는 AGENTS.md 5절.
1. my-work/course-index.json (웹훅 my-course-index): GET http://data-api:8800/courses 목록 → 과정마다 본문
→ Code: "### " 일정 블록마다 청크, 글 앞에 과정 이름, 메타데이터 course(코드)·name·block(일정 제목)
→ 메모리 저장소 my_courses (insert, 매번 비우고) + text-embedding-3-large. 결과 {indexed_chunks}.
2. my-work/course-search.json (웹훅 my-course-search): question 필수, top_k 기본 3,
결과 {hits: [{rank, course, name, block, score, text}], top_score}. 저장소가 비어도 멈추지 않게.
기대 결과: cases/course-search.json (이 파일의 before 칸으로 먼저 색인).
```
- 청크 단위, 메타데이터, 저장소 이름, **비었을 때**를 적었습니다.
같은 일을 이렇게 시키면:
| 요청문 | 생기는 일 |
| --- | --- |
| "교육 문서로 RAG 만들어 줘" | 청크 크기 · 저장소 이름 · 결과 모양을 에이전트가 정함 → 테스트와 맞지 않음 |
| "문서를 500자씩 잘라 줘" | 일정 블록이 잘림, 출처(블록 이름)가 없음 |
| "검색 결과를 보여 줘" | `hits`의 칸 이름이 매번 달라짐, 저장소가 비면 워크플로우가 멈춤 |
요청문에 꼭 들어갈 것: **① 청크를 나누는 기준 ② 함께 저장할 메타데이터 ③ 저장소 이름(색인 · 검색이 같게) ④ 결과 칸 이름 ⑤ 비었을 때 ⑥ 기대 결과(테스트 파일)**.
## 확인 질문
<details><summary>1. 검색 결과 1등 점수가 0.478이면 "47.8% 맞다"는 뜻인가요?</summary>
아닙니다. 같은 질문에 대한 청크들의 **순서**를 정하는 값입니다. 질문마다 점수대가 다르고(0.478~0.748), 모델을 바꾸면 점수대도 바뀝니다. 그래서 정답표로 "맞았나"를 따로 셉니다.
</details>
<details><summary>2. "암호를 얼마나 자주 바꿔야 해?"는 규정에 "암호"라는 낱말이 없는데 어떻게 찾았나요?</summary>
임베딩은 낱말이 아니라 뜻으로 가까움을 잽니다. "암호"와 "비밀번호", "자주 바꾸다"와 "변경 주기"가 가까운 숫자 목록이 되어 1등(0.576)이 "2. 변경 주기"였습니다.
</details>
<details><summary>3. 청크 글 앞에 문서 제목을 붙이는 까닭은?</summary>
섹션만 떼면 "## 2. 변경 주기"처럼 무엇의 이야기인지 빠집니다. 제목이 있어야 질문의 주제("비밀번호")와 가까워지고, 사람이 읽어도 출처를 압니다.
</details>
<details><summary>4. push한 뒤 바로 <code>call my-search</code>를 하면 무엇이 나오나요?</summary>
push가 n8n을 재시작해 메모리 저장소가 비었으므로 `hits`가 비고 `indexed: false`입니다. `call my-index`를 먼저 하거나, 케이스 파일에 `before` 칸이 있는 `test`를 씁니다.
</details>
<details><summary>5. 정답표에 답이 없는 질문을 넣는 까닭은?</summary>
뜻 검색은 언제나 가장 가까운 무언가를 돌려줍니다. 답이 없는 질문을 넣어 두어야 "찾았지만 관련 없음"을 걸러 내는지(2단계)를 시험할 수 있습니다.
오늘 넣은 곳:
| 어디 | 답이 없는 질문 | 시험하는 것 |
| --- | --- | --- |
| Lab 01 예제 1 정답표 (종이, 에이전트 없이) | 연차 휴가 | 정답표에 "없음"을 쓰는 습관 |
| `cases/lab02.json` | 점심 메뉴 | 관련도 기준 → 담당자 연결 |
| `cases/lab02-challenge.json` | VPN 속도, 노트북 사양, 비밀번호 잊음, 보안 교육, 연차 | 점수는 높은데 답이 없음 → 답 검사 |
| `cases/lab03-threshold.json` | 점심, 연차, 영화, 맛집, 연봉 | 기준값 0.33이 두 무리를 나누는지 |
검색 테스트(`cases/lab01.json`, `retrieval-*.json` 24문항)에는 없습니다. 검색 테스트는 "답이 있는 질문에서 맞는 것을 1등으로 찾나"만 봅니다.
</details>
## 정리
- [ ] RAG = 색인(가끔) + 질문할 때마다 검색 → 그 내용으로만 답. 지시문 · 파인튜닝 · Tool과 무엇이 다른지 말할 수 있다
- [ ] 점수는 순서의 근거이지 확률이 아니다. 1등과 2등의 차이, "관련 없는 점수대"를 본다
- [ ] 정답표가 먼저 (답이 없는 질문 포함), 정답표도 원문으로 확인한다
- [ ] 청크는 뜻 단위, 글 앞에 제목, 메타데이터가 출처. 글 안에 있는 것과 칸으로 있는 것은 다르다
- [ ] 메모리 저장소는 재시작에 지워진다 → 테스트 전에 색인
+330
View File
@@ -0,0 +1,330 @@
# 2단계 · 근거로 답하는 RAG 워크플로우
- 교시: 3~4교시 (11:00~12:50) · 3교시 강의 약 30분 + Lab 02 예제, 4교시 과제 · 풀이
- 실습: [Lab 02](../../Last3Mon/71/LAB-02-Q-ANSWER-FROM-POLICIES.md)
- 이 단계가 끝나면: 검색한 발췌로만 답하고 출처를 붙이는 워크플로우를 만들고, 관련 없는 질문과 **답이 없는 질문**을 서로 다른 장치로 넘길 수 있다.
- 이 교안의 숫자: 2026-10-18 수업 준비 측정, 2026-10-22 같은 정답으로 다시 돌린 기록 (`gpt-4.1-mini`, `text-embedding-3-large`)
```mermaid
flowchart LR
A["1. 발췌로만 답하기"] --> B["2. 번호로 출처"] --> C["3. 관련도 기준"] --> D["4. 점수는 높은데<br/>답이 없을 때"] --> E["5. 답 검사"] --> F["6. 넘기는 길 설계"]
```
| 절 | 말하는 시간 (예) | 띄울 것 |
| --- | --- | --- |
| 1. 발췌로만 답하기 | 6분 | 발췌의 실제 모양, 실측 답 |
| 2. 번호로 출처 | 4분 | `sources` · `cited` 실례 |
| 3. 관련도 기준 | 6분 | 10개 점수표, 기준 정하는 순서 |
| 4. 점수는 높은데 답이 없을 때 | 6분 | 기준값만 쓴 결과 |
| 5. 답 검사 | 6분 | 검사 4가지, 검사 결과 JSON |
| 6. 넘기는 길 설계 · 확인 질문 | 2분 + 과제 뒤 | route 세 갈래 |
## 1. 발췌로만 답하기
```mermaid
flowchart LR
Q(["질문"]) --> S["검색 top 3"] --> C["Build context<br/>[1] [2] [3] 발췌"] --> L{{"Answer from policies"}} --> B["Build answer<br/>번호 → 제목"] --> R(["답 + 출처"])
```
### 1.1 LLM이 실제로 받는 글
"암호를 얼마나 자주 바꿔야 해?"를 물으면 검색 1~3등(2. 변경 주기 0.576, 1. 만들기 0.512, 4. 다단계 인증 0.456)이 번호를 달고 LLM에게 갑니다.
```text
질문: 암호를 얼마나 자주 바꿔야 해?
규정 발췌:
[1] 비밀번호·계정 보안 규정
## 2. 변경 주기
- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.
- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.
[2] 비밀번호·계정 보안 규정
## 1. 비밀번호 만들기
- 비밀번호는 12자 이상으로 만든다. …
[3] 비밀번호·계정 보안 규정
## 4. 다단계 인증
- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다. …
```
LLM은 **이 글만** 봅니다. 규정 전체도, 다른 문서도 보지 않습니다. 그래서 1단계의 검색이 틀리면 여기서 고칠 방법이 없습니다.
### 1.2 지시문의 세 가지
| 지시 | 이유 |
| --- | --- |
| 발췌에 있는 내용으로만 | 지어내지 않게 (LLM이 아는 일반론을 섞지 않게) |
| 사실마다 `[번호]` | 코드가 출처를 확인할 수 있게 |
| 없으면 정확히 "규정에서 답을 찾지 못했습니다." | 모를 때의 답을 정해 둠 (그래도 다르게 말하기도 함, 4절) |
한국어 3문장 이내도 정합니다. 길면 발췌에 없는 설명이 붙기 쉽습니다.
### 1.3 실제 답 (`cases/lab02.json` 6/6, 2026-10-22)
| 질문 | 답 (앞부분) | 시간 |
| --- | --- | --- |
| 암호를 얼마나 자주 바꿔야 해? | "암호는 90일마다 변경해야 하며, 만료 7일 전부터 사내 포털에 변경 안내가 표시됩니다. … [비밀번호·계정 보안 규정]" | 3.0초 |
| 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? | "…퇴사 후 5영업일 이내에 IT 담당자에게 반납해야 합니다. … [업무용 장비 지급·반납 규정]" | 1.8초 |
| 첨부 파일이 너무 커서 메일이 안 보내져요 | "메일 첨부 파일은 25MB까지 … 사내 드라이브에 올리고 공유 링크를 … [메일·문서 보안 규정]" | 2.1초 |
| 오늘 점심 메뉴 추천해줘 | "규정에서 답을 찾지 못했습니다. IT 문의 창구로 연결해 드릴게요." (LLM 없음) | 0.3초 |
- 질문은 "암호"라고 물었는데 답도 "암호는 …"으로 시작합니다. LLM은 질문의 말투를 따르고, **내용**은 발췌를 따릅니다.
- LLM을 부르는 답은 1.8~3.0초, 부르지 않는 답은 0.3초입니다 (수업 준비 때 답 1.7~2.2초). 72일차에 이 차이를 재고 줄입니다.
### 1.4 틀린 전제
"비밀번호는 30일마다 바꾸면 되죠?"에는 "비밀번호는 30일마다 바꾸는 것이 아니라 90일마다 바꿔야 합니다. …"라고 답했습니다 (`top_score` 0.685). 질문이 아니라 **발췌**가 답의 기준이 됩니다. 사용자가 "맞죠?"라고 물으면 맞장구치기 쉬운 LLM을 발췌가 붙잡아 줍니다.
## 2. 번호로 출처
- LLM은 `[2]`처럼 **번호**만 씁니다. 번호를 문서 제목으로 바꾸고 `cited`(인용한 문서)를 만드는 것은 **Code**입니다.
- 범위 밖 번호(`[7]`)는 버립니다. LLM이 쓴 제목을 그대로 믿지 않습니다 (67일차 `sources`와 같은 생각).
- 결과에 `sources`(검색된 문서)와 `cited`(답이 인용한 문서)를 따로 둡니다.
### 2.1 질문 하나를 따라가 보기
질문: **"생성형 AI에 고객 자료를 넣어도 되나요?"** (실측)
**① 검색이 3개를 찾고 번호를 붙입니다.**
| 번호 | 문서 (`source`) | 섹션 | 점수 |
| --- | --- | --- | --- |
| [1] | `software-usage` (소프트웨어 사용 규정) | 3. 생성형 AI 서비스 | 0.51 |
| [2] | `privacy` (개인정보 처리 지침) | 2. 이용과 보관 | 0.367 |
| [3] | `privacy` (개인정보 처리 지침) | 1. 수집 | 0.336 |
**② LLM은 발췌 [1] [2] [3]을 읽고, 쓴 사실 뒤에 번호를 붙입니다.**
```text
"… 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는 것이 규정입니다[1]."
```
**③ Code가 결과의 두 칸을 만듭니다.**
| 칸 | 만드는 법 | 이 질문의 값 |
| --- | --- | --- |
| `sources` | 검색된 3개의 문서를 **중복 없이** 모음 ([1] · [2] · [3]) | `software-usage`, `privacy` (`privacy`가 두 번 나와 하나로) |
| `cited` | 답 안에 나온 **번호만** 문서로 바꿔 모음 | 답에 `[1]`만 있음 → `software-usage` |
| 답의 `[1]` | 번호를 그 문서의 제목으로 바꿈 | `[소프트웨어 사용 규정]` |
→ `sources`는 "LLM에게 **보여 준** 문서", `cited`는 "LLM이 답에 **실제로 쓴** 문서"입니다. 개인정보 지침도 보여 줬지만 답에는 쓰지 않았습니다.
### 2.2 두 칸을 비교해 읽기 (실측)
| 질문 | 보여 준 문서 (`sources`) | 답에 쓴 문서 (`cited`) | 읽는 법 |
| --- | --- | --- | --- |
| 생성형 AI에 고객 자료를 넣어도 되나요? | 소프트웨어, 개인정보 | 소프트웨어 | 위 2.1. 정상 |
| 첨부 파일이 너무 커서 메일이 안 보내져요 | 메일·문서, 보안 사고 | 메일·문서 | 3등(보안 사고 · 피싱 메일 0.335)은 관련 없어 쓰지 않음. 정상 |
| 비밀번호를 잊어버리면 어디서 바꿔요? | 비밀번호 | **없음** | 보여 줬는데 하나도 쓰지 않음. 답이 "규정에서 답을 찾지 못했습니다." → 발췌에 답이 없었다는 **신호** (4절) |
`cited`가 비어 있으면 답이 발췌를 근거로 하지 않았다는 뜻입니다. 5절 답 검사의 `cites_excerpt`가 이것을 봅니다.
왜 제목을 LLM에게 쓰게 하지 않나요? LLM은 발췌에 없는 제목("IT 보안 정책")을 만들어 내거나 비슷한 제목을 섞을 수 있습니다. 번호는 **코드가 범위를 확인**할 수 있고, 제목은 저장해 둔 메타데이터에서 가져오므로 틀릴 수 없습니다.
## 3. 관련도 기준
검색은 **언제나** 가장 가까운 청크 k개를 돌려줍니다. 점심 메뉴를 물어도 무언가 나옵니다. 1등 점수(`top_score`)가 기준보다 낮으면 LLM을 부르지 않고 넘깁니다.
### 3.1 실제로 잰 10개 (`cases/lab03-threshold.json` 10/10, 2026-10-22)
| 규정 질문 | `top_score` | | 규정 밖 질문 | `top_score` |
| --- | --- | --- | --- | --- |
| 첨부 파일이 너무 커서 … | 0.616 | | 올해 연봉 인상률은요? | 0.261 |
| 암호를 얼마나 자주 … | 0.575 | | 연차 휴가는 며칠이에요? | 0.259 |
| 생성형 AI에 고객 자료를 … | 0.51 | | 회사 근처 맛집 알려 줘 | 0.232 |
| 컴퓨터가 바이러스에 … | 0.478 | | 주말에 볼 영화 추천해 주세요 | 0.211 |
| 회사를 그만두면 노트북은 … | 0.464 | | 오늘 점심 메뉴 추천해줘 | 0.202 |
```mermaid
flowchart LR
subgraph out["규정 밖 0.202 ~ 0.261"]
o1["점심 · 영화 · 맛집 · 연차 · 연봉"]
end
T{{"기준 0.33"}}
subgraph in["규정 질문 0.464 ~ 0.616"]
i1["첨부 · 암호 · 생성형 AI · 바이러스 · 노트북"]
end
out --- T --- in
```
- 기준 0.33: 둘 다 맞게 나눔 (10/10). 넘기는 경우는 0.3~0.5초 (LLM 없음).
- 두 무리 사이가 0.2쯤 벌어져 있어 0.33은 여유가 있습니다.
### 3.2 기준값을 정하는 순서
1. 답이 **있는** 질문 몇 개, 답이 **없는**(관련 없는) 질문 몇 개를 정답표에서 고릅니다.
2. 두 무리의 `top_score`를 잽니다 (위 표).
3. 두 무리 **사이**에서, 경계에 딱 붙지 않게 고릅니다 (1단계 2.4절: 점수는 셋째 자리에서 흔들림).
4. 테스트 파일(`lab03-threshold.json`)로 남겨, 모델이나 청크를 바꿀 때마다 다시 돌립니다.
기준값은 **임베딩 모델마다 다릅니다.** 같은 질문도 모델이 바뀌면 점수대가 통째로 움직이므로, 다른 모델에서 가져온 숫자를 그대로 쓰지 않습니다.
### 3.3 기준은 양쪽으로 틀린다
| 기준을 올리면 | 기준을 낮추면 |
| --- | --- |
| 진짜 규정 질문도 넘김 (사람 일이 늘어남) | 관련 없는 질문에 LLM이 답을 씀 (지어낼 위험) |
| 예: 0.47로 올리면 "퇴사 노트북"(0.464)이 넘어감 | 예: 0.2로 내리면 점심 메뉴(0.202)에도 답을 씀 |
실제로 기준 근처에 있는 질문도 있습니다. 3단계 24문항 가운데:
| 질문 | 1등 | `top_score` | 규정에 답이 |
| --- | --- | --- | --- |
| 공짜 프로그램은 그냥 써도 되죠? | 소프트웨어 · 1. 설치와 승인 | **0.267** | **있음** ("라이선스") |
| 자리 비울 때 화면은 몇 분 뒤 잠기게 해요? | 원격근무 · 3. 재택근무 중 보안 | 0.354 | 있음 ("5분") |
"공짜 프로그램"은 규정에 답이 있는데 점수가 0.33보다 낮습니다. 이 점수면 규정 Q&A는 LLM을 부르지 않고 **담당자 연결**로 보냅니다. 기준값은 "답이 있는 질문을 넘기는" 쪽으로도 틀립니다. 그래도 지어낸 답을 내보내는 것보다 사람에게 넘기는 쪽이 안전하다고 보고 0.33을 둡니다. 이런 질문은 3단계의 질문 다시 쓰기로 점수를 올리는 길도 있습니다.
## 4. 점수는 높은데 답이 없을 때
### 4.1 기준값만 쓴 결과 (예제 정답, 2026-10-22, `lab02-challenge` 2/8)
| 질문 | `top_score` | route | LLM의 답 | `cited` |
| --- | --- | --- | --- | --- |
| VPN 접속 속도가 느리면 어떻게 해요? | 0.463 | 자동 답변 | "규정에서는 VPN 접속 속도가 느릴 때의 대처 방법에 대해 언급하고 있지 않습니다. 따라서 규정에서 답을 찾지 못했습니다." | 없음 |
| 노트북 사양은 어떻게 되나요? | 0.379 | 자동 답변 | "규정 발췌에는 노트북 사양에 대한 내용이 포함되어 있지 않습니다. … [업무용 장비 지급·반납 규정][메일·문서 보안 규정][업무용 장비 지급·반납 규정]" | **2개** |
| 비밀번호를 잊어버리면 어디서 바꿔요? | 0.551 | 자동 답변 | "규정에서 답을 찾지 못했습니다." | 없음 |
| 보안 교육은 1년에 몇 번 받아야 하나요? | 0.352 | 자동 답변 | "규정에서 답을 찾지 못했습니다." | 없음 |
| 연차 휴가는 며칠이에요? | 0.259 | 담당자 연결 | (LLM 없음) | 없음 |
**왜 답이 없나.** 이 질문들은 Lab 02 과제용으로 일부러 고른 것입니다. 낱말은 규정과 겹쳐 점수가 기준을 넘지만, 묻는 **내용**은 규정 8개 어디에도 없습니다 (원문 확인).
| 질문 | 점수를 올린 규정 | 그 규정에 있는 것 | 없는 것 (질문이 묻는 것) |
| --- | --- | --- | --- |
| VPN 접속 속도가 느리면? | 원격근무·VPN | VPN 먼저 연결, 공용 와이파이 금지, 인증서 1년 · 30일 전 재발급, 화면 잠금 | 속도가 느릴 때 할 일 |
| 노트북 사양은? | 장비 지급·반납 | 입사일 지급, 교체 주기 4년, 분실 신고, 반납 | 사양 (CPU · 메모리 등) |
| 비밀번호를 잊어버리면 어디서 바꿔요? | 비밀번호·계정 보안 | 12자 이상, 90일마다 변경, 5번 실패 시 30분 잠금, OTP 재등록 | 잊었을 때 다시 만드는 곳 · 방법 |
| 보안 교육은 1년에 몇 번? | 보안 사고 대응 | 피싱 메일 신고, 악성코드 감염 시 네트워크 분리 | 보안 교육 이야기 전부 |
| 연차 휴가는 며칠? | (가까운 규정 없음) | 규정 8개가 모두 IT 규정 | 인사 · 휴가 규정 자체 |
LLM은 발췌를 읽고 "찾지 못했다"고 **옳게** 답했습니다. 잘못은 워크플로우가 그 답을 `자동 답변`으로 내보낸 데 있습니다.
- 규정에 **비슷한 낱말**(VPN, 노트북, 비밀번호, 보안)이 있어 점수가 기준을 넘습니다. 기준을 0.47로 올리면 "퇴사 노트북"(0.464) 같은 진짜 규정 질문도 걸립니다 → **기준값으로는 못 거릅니다.**
- LLM은 모두 "못 찾았다"고 **말했지만**, 워크플로우는 그 답을 `자동 답변`으로 내보냈습니다. 직원 화면에는 "자동 답변: 규정에서 답을 찾지 못했습니다"가 뜨고, 아무도 이 문의를 이어받지 않습니다.
- 정해 준 문장 대신 "언급하고 있지 않습니다", "포함되어 있지 않습니다"처럼 **다르게** 말합니다.
- "노트북 사양"은 "내용이 없다"고 하면서 **출처를 셋이나** 붙였습니다. 출처가 있다고 근거 있는 답은 아닙니다 → 검사 하나로는 부족합니다.
### 4.2 왜 이런 일이 생기나
```mermaid
flowchart LR
Q["노트북 사양은?"] --> S["검색: 장비 규정 0.379<br/>(노트북이라는 말이 있음)"] --> T{"≥ 0.33"} -->|예| L["LLM: 발췌에 사양은 없다"] --> R["route = 자동 답변"]
```
검색은 **주제**가 같은지를 보고, 답은 **내용**이 있는지가 중요합니다. 장비 규정은 노트북 이야기(지급 · 분실 · 반납)이지만 사양은 없습니다. 주제가 같으면 점수가 높고, 내용이 없으면 답이 없습니다. 이 틈은 검색 점수로는 볼 수 없고, **답을 보고** 판단해야 합니다.
## 5. 답 검사
67일차 Check answer처럼, LLM의 답을 코드로 검사하고 하나라도 실패하면 담당자에게 넘깁니다.
### 5.1 검사 넷
| 검사 | 확인 | 잡는 것 | 계산 방법 |
| --- | --- | --- | --- |
| `found_in_policies` | "찾지 못했", "포함되어 있지 않", "명시되어 있지 않", "언급이 없", "나와 있지 않"이 없나 | 답이 없는 질문 | 답 글에서 문구 찾기 (정규식) |
| `cites_excerpt` | 인용 번호가 하나 이상인가 | 출처 없는 답 | 범위 안의 `[번호]` 개수 |
| `grounded` | 답 글의 두 글자 묶음 30% 이상이 발췌에 있나 | 발췌와 상관없는 답 | 아래 5.2 |
| `numbers_from_policies` | 답의 숫자가 모두 발췌에 있나 | 지어낸 숫자 | 답의 숫자마다 발췌에서 찾기 |
### 5.2 `grounded`는 어떻게 세나
답에서 `[번호]`와 띄어쓰기를 빼고, 두 글자씩 묶습니다. "90일마다변경" → `90` `0일` `일마` `마다` `다변` `변경`. 이 묶음 중 발췌에도 있는 것이 30% 이상이면 통과입니다.
- 발췌의 말을 거의 그대로 옮긴 답은 높게 나오고, LLM이 아는 일반론("보통 IT 부서에 문의하시면…")을 쓴 답은 낮게 나옵니다.
- LLM으로 "근거가 있나"를 묻는 방법도 있지만, 그것도 LLM입니다. 두 글자 묶음은 **빠르고, 늘 같은 결과**이고, 왜 실패했는지 보여 줄 수 있습니다. 대신 말을 바꿔 쓴 좋은 답을 떨어뜨릴 수 있어 30%처럼 느슨하게 둡니다.
### 5.3 검사 결과 (과제 정답, `lab02-challenge` 2/8 → 8/8 · `--repeat 3` 24/24, `lab02` 6/6)
```json
{
"route": "담당자 연결",
"answer": "규정에서 확실한 답을 찾지 못해 IT 담당자에게 전달했습니다.",
"draft": "규정에서는 VPN 접속 속도가 느릴 때의 대처 방법에 대해 언급하고 있지 않습니다. 따라서 답변을 드리기 어렵습니다. 규정에서 답을 찾지 못했습니다.",
"cited": [],
"top_score": 0.462,
"checks": [
{"name": "found_in_policies", "passed": false},
{"name": "cites_excerpt", "passed": false},
{"name": "grounded", "passed": false},
{"name": "numbers_from_policies", "passed": true}
],
"failed": ["found_in_policies", "cites_excerpt", "grounded"]
}
```
- 답이 없는 네 질문은 모두 `found_in_policies` · `cites_excerpt` · `grounded` 셋이 실패했습니다. 하나만 있었어도 걸렸겠지만, **여러 검사가 겹쳐** 있어 LLM이 말을 바꾸거나 출처를 붙여도 놓치지 않습니다.
- 근거 있는 답("암호", "바이러스", 틀린 전제)은 넷 다 통과해 그대로 `자동 답변`입니다.
- `draft`에 LLM의 원래 답을 남깁니다. 담당자가 이어받을 때 "무엇을 찾았고 왜 넘어왔는지"를 봅니다.
- 검사는 코드라 시간이 거의 들지 않습니다. 과제 정답에서 LLM을 부른 답은 1.1~1.9초였습니다 (`--repeat 3`, 2026-10-22).
> 장치마다 잡는 실패가 다릅니다. **관련도 기준** = 엉뚱한 질문 (빠르고 쌈, LLM을 부르지 않음), **답 검사** = 비슷하지만 답이 없는 질문, 지어낸 숫자 (LLM을 부른 뒤).
## 6. 넘기는 길 설계
```mermaid
flowchart LR
Q(["질문"]) --> S["검색"] --> T{"top_score ≥ 기준?"}
T -->|아니오| H1(["담당자 연결<br/>'규정에서 답을 찾지 못했습니다. IT 문의 창구로…'"])
T -->|예| L{{"발췌로만 답"}} --> K{"답 검사 통과?"}
K -->|예| A(["자동 답변 + 출처"])
K -->|아니오| H2(["담당자 연결<br/>'확실한 답을 찾지 못해 IT 담당자에게 전달…' + draft"])
```
| 갈래 | 언제 | 직원이 보는 말 | 담당자가 받는 것 |
| --- | --- | --- | --- |
| 자동 답변 | 기준 통과 + 검사 통과 | 답 + `[문서 제목]` | (없음) |
| 담당자 연결 (기준) | 관련 있는 규정이 없음 | "규정에서 답을 찾지 못했습니다. IT 문의 창구로 연결해 드릴게요." | 질문, `top_score` |
| 담당자 연결 (검사) | 규정은 비슷한데 답이 없거나 근거가 약함 | "규정에서 확실한 답을 찾지 못해 IT 담당자에게 전달했습니다." | 질문, `draft`, `failed` |
- 두 담당자 연결의 **말이 다릅니다.** 담당자는 "규정에 아예 없는 질문"과 "규정이 모자란 질문"을 다르게 처리합니다. 뒤쪽이 쌓이면 **규정을 보완할 곳**입니다 (VPN 속도, 노트북 사양, 비밀번호 찾기, 보안 교육 횟수).
- 69일차 검토자의 fail-closed와 같은 생각입니다: **확실하지 않으면 자동으로 내보내지 않습니다.**
### 6.1 앞 일차와의 연결
오늘 2단계는 새로 배우는 것이 아니라 **앞에서 한 세 가지를 RAG에 다시 쓰는 것**입니다.
| 일차 | 그때 만든 것 (장면 하나) | 그때 배운 것 | 오늘 같은 자리 |
| --- | --- | --- | --- |
| 64 Lab 02 | `miniflow`로 날씨 Tool → LLM. `city=제주`면 Tool 결과(`비`, 비 올 확률 80%)가 LLM 지시문에 끼워져 "우산이 필요합니다" | **근거를 지시문에 넣어 주면** LLM은 그것으로 답한다 (Tool → LLM 근거 제공 패턴) | 검색한 발췌 `[1] [2] [3]`을 지시문에 넣음 (1.1절). 날씨 Tool 자리에 검색이 들어감 |
| 67 Lab 01 과제 | IT 문의 Agent가 FAQ Tool로 답하고 `sources`에 `FAQ-001`처럼 출처를 남김 | 출처를 **LLM이 쓰게 할지, Tool 결과에서 코드가 찾게 할지** 정해야 함 | LLM은 번호만, 번호 → 문서 제목 · `cited`는 Code (2절) |
| 67 Lab 03 | Agent 답 뒤에 "결과 검증" 노드: 검사 여러 개, 하나라도 실패하면 `route: 담당자 연결` + 원래 답 `draft` + 걸린 검사 `failed` | LLM 답을 **코드로 검사**하고, 실패하면 사람에게 넘긴다 | 답 검사 넷과 같은 결과 모양 (`checks`, `failed`, `draft`) (5절) |
| 69 Lab 03 | 공지 팀(작성자 → 검토자 → 정리자)에서 **검토자를 꺼 봄**. 검토 결과(`passed`)가 없는데 `게시 가능`이 되면 가장 위험 | **fail-closed**: 확실하지 않으면 내보내지 않는다 (68일차는 fail-open: 하나가 꺼져도 나머지 답은 냄) | 검사 실패, 점수 미달 → `자동 답변` 하지 않고 담당자 연결 (6절) |
| 72 (내일) Lab 01 | `flow.py bench`로 같은 요청을 여러 번 보내 단계별 시간을 재고 가장 느린 단계를 찾음 | 느린 곳은 **재서** 찾는다 | 오늘 잰 것: LLM 없는 길 0.3초 vs LLM을 부르는 길 1.8~3.0초 |
수업에서는 "64일차 우산 질문 기억나나요? 그 날씨 Tool 자리에 오늘은 규정 검색이 들어갑니다"처럼 장면부터 꺼내면 됩니다.
## 확인 질문
<details><summary>1. 관련도 기준을 높이면 "답이 없는 질문" 문제가 풀리지 않나요?</summary>
아닙니다. 답이 없는 질문(0.352~0.551)과 진짜 규정 질문(0.464~0.616)의 점수대가 겹칩니다. 0.47로 올리면 "퇴사 노트북"(0.464)이 넘어가고, 그래도 "비밀번호 잊음"(0.551)은 남습니다. 답을 보고 판단하는 답 검사가 필요합니다.
</details>
<details><summary>2. "노트북 사양" 답에는 출처가 셋이나 있는데 왜 담당자에게 넘기나요?</summary>
답이 "내용이 포함되어 있지 않다"고 말하고 있습니다 (`found_in_policies` 실패). 출처가 있다는 것은 "발췌를 봤다"는 뜻이지 "발췌에 답이 있다"는 뜻이 아닙니다. 과제 정답에서는 출처가 붙지 않았지만, 붙더라도 다른 검사가 잡습니다.
</details>
<details><summary>3. <code>cited</code>를 LLM이 JSON으로 돌려주게 하면 안 되나요?</summary>
LLM은 없는 문서 이름을 만들거나 형식을 어길 수 있습니다. 번호를 쓰게 하고 코드가 범위를 확인한 뒤 저장된 메타데이터에서 이름을 가져오면, 출처가 틀릴 수 없습니다.
</details>
<details><summary>4. "공짜 프로그램은 그냥 써도 되죠?"(0.267)가 담당자에게 넘어가는 것은 실패인가요?</summary>
규정에 답이 있으니 놓친 것은 맞습니다. 하지만 지어낸 답이 나가는 것보다 덜 위험한 쪽으로 틀렸습니다. 이런 질문은 정답표에 남겨 두고, 3단계(질문 다시 쓰기)나 기준값 조정으로 나아지는지 숫자로 확인합니다.
</details>
<details><summary>5. 담당자 연결을 왜 두 가지 말로 나누나요?</summary>
"규정에 없는 주제"(점심 메뉴)와 "규정은 있는데 답이 모자란 질문"(VPN 속도)은 처리가 다릅니다. 뒤쪽은 규정 담당자가 문서를 보완할 거리이고, `draft` · `failed`가 그 근거가 됩니다.
</details>
## 정리
- [ ] 발췌로만, 번호로 출처, 모를 때의 답을 정한다. LLM은 발췌만 본다
- [ ] 번호 → 제목, `cited`는 코드가 만든다. `sources`와 `cited`가 다르면 이유가 있다
- [ ] 관련도 기준은 모델마다 두 무리를 재서 정하고, 양쪽으로 틀릴 수 있음을 안다
- [ ] 기준값으로 못 거르는 "답 없는 질문"은 답 검사(여러 개를 겹쳐)로
- [ ] 넘기는 길은 이유마다 다르게, 담당자가 이어받을 것(`draft`, `failed`)을 남긴다
+398
View File
@@ -0,0 +1,398 @@
# 3단계 · 검색 품질 측정과 개선
- 교시: 5~6교시 (14:00~15:50) · 5교시 강의 약 30분 + Lab 03 예제, 6교시 과제 · 풀이
- 실습: [Lab 03](../../Last3Mon/71/LAB-03-Q-MEASURE-AND-IMPROVE.md)
- 이 단계가 끝나면: 검색 품질을 doc@1 · answer@1 · answer@3로 재고, 설정을 한 번에 하나씩 바꿔 비교하며, 개선이 다른 것을 깨뜨리지 않았는지 확인할 수 있다.
- 이 교안의 숫자: 2026-10-18 수업 준비 측정(1회), 2026-10-22 같은 정답으로 다시 돌린 기록. 두 날이 다른 곳은 따로 적었습니다.
```mermaid
flowchart LR
A["1. 지표 셋"] --> B["2. 재는 순서"] --> C["3. 청크 크기"] --> D["4. 어려운 질문과<br/>질문 다시 쓰기"] --> E["5. 같은 설정,<br/>다른 결과"] --> F["6. 무엇을 고를까"]
```
| 절 | 말하는 시간 (예) | 띄울 것 |
| --- | --- | --- |
| 1. 지표 셋 | 6분 | 지표 표, 1등이 아슬아슬한 예 |
| 2. 재는 순서 | 4분 | 순서 그림, 실험 기록 |
| 3. 청크 크기 | 7분 | 설정 표, 잘린 문장 · 섞인 청크 |
| 4. 어려운 질문과 다시 쓰기 | 7분 | 8개 표 (다시 쓴 검색어) |
| 5. 같은 설정, 다른 결과 | 3분 | 10/18 vs 10/22 |
| 6. 무엇을 고를까 · 확인 질문 | 3분 + 과제 뒤 | 개선 방법 표 |
## 1. 지표 셋
**지표는 검색을 채점하는 방법입니다.** 질문 하나를 검색할 때마다 ✔ 또는 ✘를 하나 매기고, 24개 질문에서 ✔가 몇 개인지 셉니다. 세 지표는 **같은 검색 결과**를 서로 다른 기준으로 채점합니다.
- **doc** = 맞는 **문서**인가 (어느 규정에서 왔나만 봄)
- **answer** = 답 **문구**가 들어 있나 ("5영업일" 같은 글자가 청크 안에 있나)
- **@1, @3** = 위에서 **몇 등까지** 보나 (@1은 1등 하나만, @3은 1~3등 중 하나라도)
### 1.1 직접 채점해 보기 (섹션 청크, 2026-10-22)
질문 A **"비밀번호는 몇 자 이상이어야 하나요?"** 정답: 문서 `password-policy`, 답 문구 "12자"
| 순위 | 문서 · 섹션 | 점수 | 맞는 문서? | "12자" 있나? |
| --- | --- | --- | --- | --- |
| 1 | `password-policy` · 1. 비밀번호 만들기 | 0.748 | ✔ | ✔ |
| 2 | `password-policy` · 2. 변경 주기 | 0.54 | ✔ | |
| 3 | `password-policy` · 3. 로그인 실패와 잠금 | 0.484 | ✔ | |
→ doc@1 ✔ (1등이 맞는 문서) · answer@1 ✔ (1등에 "12자") · answer@3 ✔
질문 B **"회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?"** 정답: 문서 `device-issue-return`, 답 문구 "5영업일"
| 순위 | 문서 · 섹션 | 점수 | 맞는 문서? | "5영업일" 있나? |
| --- | --- | --- | --- | --- |
| 1 | `device-issue-return` · 1. 지급과 교체 | 0.464 | ✔ | |
| 2 | `device-issue-return` · 3. 반납 | 0.45 | ✔ | ✔ |
| 3 | `device-issue-return` · 2. 분실과 도난 | 0.369 | ✔ | |
→ doc@1 ✔ (1등이 맞는 문서) · answer@1 **✘** (1등에는 "5영업일"이 없음) · answer@3 ✔ (2등에 있음)
질문 C **"챗GPT에 회의록 붙여 넣어도 돼요?"** 정답: 문서 `software-usage` (3. 생성형 AI 서비스), 답 문구 "입력하지 않는다"
(24문항이 아니라 Lab 03 과제의 어려운 질문입니다. 같은 섹션 청크로 질문 다시 쓰기 없이 잼. 답 문구는 이 예를 위해 원문 "고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다."에서 고름)
| 순위 | 문서 · 섹션 | 점수 | 맞는 문서? | "입력하지 않는다" 있나? |
| --- | --- | --- | --- | --- |
| 1 | `meeting-room` · 3. 사용 후 | 0.276 | ✘ | |
| 2 | `privacy` · 2. 이용과 보관 | 0.259 | ✘ | |
| 3 | `email-and-documents` · 2. 첨부 파일 | 0.259 | ✘ | |
→ doc@1 **✘** (1등이 회의실 규정) · answer@1 **✘** · answer@3 **✘** (맞는 문서가 3등 안에도 없음)
"회의록"이라는 낱말 때문에 회의실 규정이 1등이 됐고, 질문의 핵심인 "챗GPT"(규정은 "생성형 AI 서비스")는 잡지 못했습니다. 1등 점수도 0.276이라 2단계의 관련도 기준(0.33)에서 담당자 연결로 갑니다. doc@1이 ✘이면 answer 두 지표도 거의 늘 ✘입니다. 맞는 문서를 못 찾으면 답 문구도 없기 때문입니다. 4절에서 질문 다시 쓰기로 이 질문을 고칩니다.
질문 B는 지표마다 결과가 다릅니다. 이것이 지표를 셋이나 두는 까닭입니다.
| 지표 | 이 지표가 ✔이면 말할 수 있는 것 |
| --- | --- |
| doc@1 | 출처로 붙일 **규정은 맞게** 찾았다 |
| answer@1 | **1등 하나만** LLM에게 줘도 답할 수 있다 |
| answer@3 | 지금 Q&A처럼 **3개를 주면** LLM이 답할 수 있다 |
24개 질문 모두에 이렇게 매겨서 "24개 중 몇 개"로 적습니다. 섹션 청크의 결과는 doc@1 24 · answer@1 22 · answer@3 24입니다 (질문 B가 answer@1에서 틀린 두 개 중 하나).
### 1.2 테스트 파일이 채점하는 법
| 지표 | 묻는 것 | 테스트 파일 | 테스트가 보는 칸 |
| --- | --- | --- | --- |
| doc@1 | 1등 청크가 **맞는 문서**에서 왔나 | `retrieval-doc.json` | `hits.0.source` = 기대 문서 |
| answer@1 | 1등 청크에 **답 문구**가 있나 | `retrieval-answer.json` | `hits.0.text`에 "12자", "90일" … |
| answer@3 | 3등 안에 답 문구가 있나 | `retrieval-answer3.json` | `hits`(3개) 어딘가에 답 문구 |
- 질문 24개, 문서 8개. 각 질문의 정답(문서, 답 문구)은 Lab 01 예제 1처럼 사람이 정했습니다.
- 결과는 "24개 중 몇 개"입니다. 퍼센트로 바꾸지 않고 **몇 개 중 몇 개**로 말합니다 (24개에서 하나는 4%나 됩니다).
### 1.3 세 지표가 말해 주는 것
```mermaid
flowchart LR
D{"doc@1 ✔?"} -->|아니오| W1["다른 문서가 1등<br/>→ 낱말 · 뜻이 다른 문서와 가까움"]
D -->|예| A1{"answer@1 ✔?"}
A1 -->|예| OK["1등만 줘도 답할 수 있음"]
A1 -->|아니오| A3{"answer@3 ✔?"}
A3 -->|예| W2["같은 문서의 다른 섹션이 1등<br/>→ 3개를 주면 LLM이 답할 수 있음"]
A3 -->|아니오| W3["답이 3등 밖<br/>→ LLM도 답할 수 없음"]
```
doc@1은 맞는데 answer@1이 틀리면 **맞는 문서의 다른 섹션**이 1등입니다. LLM에 3개를 주는 지금 설정에서는 answer@3이 "답할 수 있는지"를 말해 줍니다.
### 1.4 answer@1에서 틀린 두 질문 들여다보기 (섹션 청크)
섹션 청크의 answer@1은 24개 중 **22개**입니다. 틀린 두 개가 무엇이고 왜 틀렸는지 봅니다. 두 날(10/18, 10/22) 모두 같은 두 질문이 틀렸습니다.
**① 무엇이 틀렸나: 1등과 2등의 점수가 거의 같다**
| 질문 | 1등 (답 없음) | 2등 (**답이 있는 섹션**) | 점수 차이 |
| --- | --- | --- | --- |
| 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? (1.1의 질문 B) | 장비 · 1. 지급과 교체 0.464 | 장비 · 3. 반납 0.45 ("5영업일") | 0.014 |
| 고객 정보가 밖으로 샌 것 같아요 | 개인정보 · 2. 이용과 보관 0.395 | 개인정보 · 3. 유출 대응 0.375 ("1시간") | 0.02 |
둘 다 **문서는 맞았습니다** (doc@1 ✔). 같은 문서 안에서 답이 있는 섹션이 아주 작은 차이로 2등이 됐을 뿐입니다. 0.014는 1단계 2.4절에서 본 "실행마다 흔들리는 폭"(셋째 자리)보다 조금 큰 정도라, 질문을 조금만 바꿔도 순서가 뒤집힐 수 있습니다. 그래서 "아슬아슬"입니다.
**② 왜 틀렸나: 질문에 나온 낱말이 어느 섹션에 있나**
질문마다 원 질문, 질문이 정말 묻는 것, 1등 · 2등 섹션의 원문을 차례로 봅니다. **굵은 글자**가 질문과 섹션에 똑같이 나오는 낱말입니다.
질문 1. "회사를 그만두면 **노트북**은 언제까지 돌려줘야 하나요?"
- 질문이 정말 묻는 것: 퇴사할 때 **반납** 기한 → 답은 3절 "5영업일"
| 순위 | 섹션 | 점수 | 원문 | 질문과 같은 낱말 |
| --- | --- | --- | --- | --- |
| 1등 | 1. 지급과 교체 | 0.464 | 업무용 **노트북**은 입사일에 지급한다. **노트북** 교체 주기는 4년이다. | "노트북" 2번 |
| 2등 (답) | 3. 반납 | 0.45 | 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다. | 없음 ("노트북" 대신 "장비") |
질문 2. "**고객** 정보가 밖으로 샌 것 같아요"
- 질문이 정말 묻는 것: 개인정보 **유출** 때 할 일 → 답은 3절 "1시간 이내 신고"
| 순위 | 섹션 | 점수 | 원문 | 질문과 같은 낱말 |
| --- | --- | --- | --- | --- |
| 1등 | 2. 이용과 보관 | 0.395 | **고객** 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다. | "고객" |
| 2등 (답) | 3. 유출 대응 | 0.375 | 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다. | 없음 ("밖으로 샌" 대신 "유출") |
두 질문 모두 같은 모양입니다.
- 1등 섹션에는 질문에 나온 **명사가 그대로** 있지만, 질문이 묻는 내용(반납, 유출)은 없습니다.
- 2등 섹션에 답이 있지만, 질문과 **다른 낱말**("장비", "유출")로 쓰여 있습니다.
- 그래서 임베딩이 1등 섹션을 아주 조금 더 가깝게 본 것으로 보입니다. 원문과 점수를 보고 한 해석이고, 원인을 따로 실험해 재지는 않았습니다.
**③ 그래서 어떻게 되나: 3개를 주면 답은 맞는다**
answer@3은 두 질문 모두 ✔입니다 (답이 2등에 있으니까). 2단계 Q&A는 발췌 3개를 LLM에게 주므로, 실제로 "회사를 그만두면 노트북은?"에 "퇴사 후 5영업일 이내에 … 반납해야 합니다"라고 맞게 답했습니다 (Lab 02 `lab02.json`, 2026-10-22).
| 이런 틀림이 문제가 되는 때 | 문제가 덜 되는 때 |
| --- | --- |
| 1등 하나만 쓰는 설계 (답 대신 "관련 규정 바로가기" 하나만 보여 주기 등) | 지금처럼 3개를 LLM에게 주는 Q&A |
4절의 질문 다시 쓰기는 "그만두면 → 퇴사"처럼 질문을 규정 낱말로 바꿔 이런 틀림을 줄입니다. 실제로 "노트북" 질문은 다시 쓰기 뒤 answer@1이 맞았습니다.
## 2. 재는 순서
**하고 싶은 것**: "청크를 섹션으로 자르는 것과 200자 · 400자로 자르는 것 중 어느 것이 검색을 잘하나?"를 숫자로 비교하기.
**원칙 두 가지**
1. **출발점(기준)을 먼저 잰다.** 지금 쓰는 설정(섹션)의 점수를 알아야 바꾼 뒤 좋아졌는지 나빠졌는지 압니다.
2. **한 번에 하나만 바꾼다.** 청크 크기와 기준값을 함께 바꾸고 점수가 오르면, 무엇 때문에 올랐는지 모릅니다.
```mermaid
flowchart LR
A["① 기준 재기<br/>섹션"] --> B["② 하나만 바꾸기<br/>200자"] --> C["③ 같은 테스트로 다시"] --> D["④ 표에 한 줄"] --> B2["② 또 하나만<br/>400자"] --> C
```
### 2.1 실제로 한 바퀴씩 (Lab 03 예제 1 · 2, 2026-10-22)
색인 워크플로우(`my-index`)는 요청 본문의 `chunking` 값을 보고 자르는 방식을 정합니다. 그래서 **워크플로우를 고치지 않고** 요청만 바꿔 다시 색인하면 됩니다.
| 바퀴 | 명령 | 하는 일 | 결과 |
| --- | --- | --- | --- |
| ① 기준 | `call my-index requests/index-section.json -v` | 섹션으로 다시 색인 (`{"chunking": "section"}`) | 청크 25개 |
| | `test cases/retrieval-answer.json -v --no-before` | 지금 색인 그대로 24문항 채점 | answer@1 **22**/24<br>틀림: 퇴사 노트북, 고객 정보 유출 |
| ② 200자 | `call my-index requests/index-200.json -v` | 200자로 다시 색인 (`{"chunking": 200}`) | 청크 20개 |
| | `test cases/retrieval-answer.json -v --no-before` | 같은 24문항 다시 채점 | answer@1 **20**/24<br>틀림: VPN 인증서, 퇴사 노트북, 개인정보 파일 메일, 공짜 프로그램 |
| ③ 400자 | `call my-index requests/index-400.json -v` | 400자로 다시 색인 | 청크 12개 |
| | `test cases/retrieval-answer.json -v --no-before` | 같은 24문항 다시 채점 | answer@1 **22**/24<br>틀림: 개인정보 파일 메일, 화면 잠금 |
→ 바꾼 것은 매번 **청크 자르는 방식 하나**뿐이고, 채점은 매번 **같은 24문항**입니다. 그래서 22 → 20 → 22의 차이는 청크 크기 때문이라고 말할 수 있습니다.
섹션과 400자가 둘 다 22인 것은 **우연히 개수가 같을 뿐**입니다. 틀린 질문이 겹치지 않습니다: 섹션이 틀린 두 질문을 400자는 맞혔고, 섹션이 맞힌 다른 두 질문을 400자는 틀렸습니다. 그래서 숫자 옆에 **틀린 질문**을 함께 적습니다 (3.3절).
### 2.2 `--no-before`를 붙이는 까닭
케이스 파일에는 `before` 칸이 있어서, `test`는 원래 **채점 전에 섹션으로 다시 색인**합니다 (1단계 5절). 평소에는 편하지만, 여기서는 방금 200자로 해 둔 색인을 섹션으로 덮어써 버립니다.
| | 출력 첫 줄 | 실제로 잰 것 |
| --- | --- | --- |
| `--no-before`를 붙임 | (before 줄 없음) | 200자 색인 ✔ |
| 빼먹음 | `✔ before: POST /webhook/my-index … {"indexed_chunks": 25, "chunking": "section"}` | **섹션** 색인 ✘ (200자를 쟀다고 착각) |
출력 첫 줄에 `before`가 있으면 다시 잽니다.
### 2.3 실험 기록 한 줄에 들어갈 것
| 칸 | 예 | 왜 |
| --- | --- | --- |
| 날짜 · 모델 | 2026-10-22, `text-embedding-3-large` | 모델이 바뀌면 숫자도 바뀜 |
| 바꾼 것 하나 | 청크 섹션 → 200자 | 무엇의 효과인지 |
| 청크 수 | 25 → 20 | 설정이 정말 적용됐는지 |
| 지표 셋 | doc@1 24 · answer@1 20 · answer@3 24 (10/18에 셋 다 잼) | 좋아진 것과 나빠진 것 |
| 틀린 질문 | "VPN 인증서", "공짜 프로그램" … | 숫자가 같아도 틀린 질문이 다를 수 있음 |
| 보고서 | `runs/retrieval-answer-….json` | 다시 열어 볼 근거 |
`templates/RAG-EVAL-TEMPLATE.md` 4절이 이 표입니다. **틀린 질문** 칸을 빼먹기 쉽습니다 (3.3절).
## 3. 청크 크기
### 3.1 잰 표
| 설정 | 청크 수 | doc@1 | answer@1 | answer@3 |
| --- | --- | --- | --- | --- |
| 섹션 | 25 | 24/24 | 22/24 | 24/24 |
| 200자 | 20 | 24/24 | 20/24 | 24/24 |
| 400자 | 12 | 22/24 | 22/24 | 24/24 |
(10/18 수업 준비. 10/22에는 섹션 지표 셋과 200자 · 400자 answer@1을 다시 재어 같은 값.)
- 우리 규정 문서는 짧아서(문서 하나에 섹션 2~4개, 526~928바이트) 400자로 자르면 청크가 오히려 **줄고** 섹션이 섞입니다. 청크 크기는 **내 문서 모양**을 보고 정합니다.
- answer@3은 모두 24/24 → 3개를 주면 청크 크기 차이가 답에 덜 드러납니다. 대신 LLM에 주는 글이 길어집니다 (75일차 비용).
<details><summary>수강생이 "answer@3이면 다 맞으니 그걸로 되는 것 아닌가요?"라고 물으면</summary>
"이 24문항과 이 짧은 문서에서는 그렇습니다. 그래서 answer@3만 보면 안 됩니다."
| 까닭 | 근거 (오늘 잰 것) |
| --- | --- |
| 3개가 **전체에서 차지하는 몫**이 큼 | 섹션은 25개 중 3개(12%), 400자는 12개 중 3개(25%, 규정 전체의 4분의 1). 25개를 다 주면 answer@25는 늘 24/24지만 그건 검색을 안 한 것과 같음. 문서가 수천 개면 3개는 아주 작은 몫 |
| 24문항은 **쉬운 쪽** | 모두 답이 있고 규정 낱말에 가까움. 어려운 질문 "챗GPT에 회의록 …"은 섹션에서도 맞는 문서가 3등 안에 없음 (1.1절 질문 C) |
| 3등 안에 있어도 **Q&A가 답하지 못할 수 있음** | 400자 "화면 잠금"은 1등 점수 0.241 → 관련도 기준 0.33 아래라 LLM을 부르지 않고 담당자 연결. answer@3은 검색만 채점하고 기준값은 보지 않음 |
| 많이 줄수록 LLM 일이 어렵고 비쌈 | 2단계 "노트북 사양"은 관련 없는 발췌(0.234 · 0.215)까지 출처로 붙임. 글이 길수록 시간 · 비용 (75일차) |
→ answer@3은 "3개를 주면 **답할 재료는 있다**", answer@1 · doc@1은 "검색이 **얼마나 정확한가**". 설정 사이의 차이는 answer@1 · doc@1에서 보입니다.
</details>
### 3.2 200자: 문장이 잘린다
200자 색인에서 "VPN 인증서는 언제 다시 받아야 해요?"(답 "30일 전")가 answer@1을 틀렸습니다. 1등 청크(0.545)는 이렇게 시작합니다:
```text
원격근무·VPN 이용 규정
포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.
- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.
## 3. 재택근무 중 보안 …
```
원래 문장은 "만료 **30일 전부터** IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다."입니다. 200자 경계가 이 문장 **한가운데**를 지나서, 앞부분("30일 전부터 … 새 인증서를")은 앞 청크(2등, 0.505)로, 뒷부분은 이 청크로 갔습니다. 답 문구가 1등 청크에 없습니다.
하나 더: 이 청크의 `section` 칸은 "3. 재택근무 중 보안"이었습니다. 청크 안의 첫 `## ` 제목을 섹션으로 붙이게 했기 때문에, 섹션 2의 뒷부분이 섹션 3 이름을 달았습니다. **글자 수로 자르면 출처 이름도 어긋날 수 있습니다.**
### 3.3 400자: 섹션이 섞인다
400자 색인은 청크 12개입니다. 원격근무 문서는 400자 안에 통째로 들어가서 **섹션 셋(접속 원칙 · VPN 인증서 · 재택근무 중 보안)이 모두 한 청크**가 됐습니다. 그 결과:
| 질문 (400자) | 1등 | 답("5분")이 든 청크 | 섹션 색인에서는 |
| --- | --- | --- | --- |
| 자리 비울 때 화면은 몇 분 뒤 잠기게 해요? | 회의실 · 1. 예약 **0.241** (틀림) | 원격근무 섹션 셋이 섞인 청크, **2등 0.236** | 원격근무 · 3. 재택근무 중 보안 1등 0.354 (맞음) |
여러 주제가 한 청크에 섞이면 그 청크의 벡터는 **어느 주제와도 덜 가깝게** 됩니다. "화면 잠금" 질문의 1등이 0.241로 규정 밖 질문의 점수대(0.20~0.26)까지 내려왔고, 엉뚱한 회의실 규정이 1등이 됐습니다. 답이 든 청크는 2등이라 answer@3은 ✔지만, 1등 점수가 0.241이라 2단계의 관련도 기준(0.33)에서 LLM을 부르지 않고 담당자 연결로 갑니다.
200자와 400자는 answer@1이 20 · 22로 섹션(22)과 비슷해 보이지만, 틀린 질문은 서로 다릅니다:
| 설정 | answer@1에서 틀린 질문 |
| --- | --- |
| 섹션 | 퇴사 노트북 (5영업일), 고객 정보 유출 (1시간) |
| 200자 | VPN 인증서 (30일 전), 퇴사 노트북, 개인정보 파일 메일 (암호), 공짜 프로그램 (라이선스) |
| 400자 | 개인정보 파일 메일 (암호), 화면 잠금 (5분) |
숫자 하나만 보면 "400자 = 섹션"이지만, 400자는 섹션에서 맞히던 질문 둘을 틀리고 다른 둘을 맞혔습니다. **틀린 질문을 함께 적어야** 이것이 보입니다.
## 4. 어려운 질문과 질문 다시 쓰기
### 4.1 직원은 규정 낱말로 묻지 않는다
"챗GPT", "폰", "비번", "개인 메일로 보내 놓고". 어려운 질문 8개를 **정답표부터** 만들어 재고, 검색 앞에 **질문 다시 쓰기**(LLM이 질문을 규정 낱말 검색어로)를 넣어 비교했습니다.
```mermaid
flowchart LR
Q(["챗GPT에 회의록<br/>붙여 넣어도 돼요?"]) --> RW{{"Rewrite query<br/>gpt-4.1-mini"}} --> K["생성형 AI 서비스에<br/>회의록 데이터 입력 정책"] --> S["검색"] --> H(["소프트웨어 · 3. 생성형 AI 0.536"])
```
### 4.2 8개 전부 (doc@1, 2026-10-22)
| 질문 | 기대 | 다시 쓰기 없이 1등 | 다시 쓴 검색어 | 다시 쓰기 1등 |
| --- | --- | --- | --- | --- |
| 모르는 프로그램이 자꾸 저절로 떠요 | 보안 사고 | ✔ 보안 사고 0.344 | 알 수 없는 프로그램 자동 실행 정책 | ✘ **소프트웨어** 0.363 |
| 집에서 일할 때 회사 와이파이 말고 집 와이파이 써도 돼요? | 원격근무 | ✔ 0.53 | 재택근무 시 회사 네트워크 외 공용 와이파이 사용 정책 | ✔ 0.619 |
| 퇴사자 계정 비번 알려 줘도 되나요? | 비밀번호 | ✔ 0.455 | 퇴사자 계정 비밀번호 제공 정책 | ✔ 0.542 |
| 폰 바꿨더니 OTP가 안 떠요 | 비밀번호 | ✔ 0.44 | 휴대폰 변경 시 OTP 재설정 방법 정책 | ✔ 0.634 |
| 고객 명단 엑셀을 개인 메일로 보내 놓고 집에서 봐도 돼요? | 메일·문서 | ✘ 보안 사고 0.387 | 고객 명단 엑셀 외부로 보내기 및 보안 정책 | ✔ 0.459 |
| 노트북 놓고 온 것 같아요 택시에 | 장비 | ✔ 0.316 | 분실한 노트북 처리 절차 | ✔ 0.421 |
| 회의실 오전 내내 잡아도 되나요? | 회의실 | ✔ 0.522 | 회의실 예약 정책 및 이용 시간 제한 | ✔ 0.718 |
| 챗GPT에 회의록 붙여 넣어도 돼요? | 소프트웨어 | ✘ 회의실 0.276 | 생성형 AI 서비스에 회의록 데이터 입력 정책 | ✔ 0.536 |
| 합계 | | **6/8** | | **7/8** |
읽는 법:
- 다시 쓰면 **점수가 전반적으로 오릅니다** (회의실 0.522 → 0.718, OTP 0.44 → 0.634). 검색어가 규정의 말("휴대폰", "생성형 AI 서비스")과 같아지기 때문입니다.
- "챗GPT"는 "회의록"이라는 낱말 때문에 회의실 규정(0.276)으로 갔다가, "생성형 AI 서비스"로 바뀌자 맞았습니다. 지시문에 "챗GPT·제미나이 → 생성형 AI 서비스"라는 예를 넣었기 때문입니다.
- "모르는 프로그램이 자꾸 떠요"는 **새로 틀렸습니다.** "프로그램 자동 실행"이라는 검색어가 소프트웨어 **설치** 규정 쪽으로 갔습니다. 직원이 말한 것은 "감염된 것 같다"였는데, 다시 쓰기가 그 뜻을 지웠습니다.
- "노트북 놓고 온 것 같아요 택시에"는 다시 쓰기가 "분실"이라는 핵심 낱말을 찾아 줬습니다 (0.316 → 0.421). 다시 쓰기 없이 0.316이면 관련도 기준(0.33) 아래라 담당자 연결로 갔을 질문입니다.
### 4.3 지시문을 고친 이야기 (수업 준비 때)
| 판 | 지시문의 예 | 결과 |
| --- | --- | --- |
| 1판 | "암호 → 비밀번호"처럼 예 하나 | "챗GPT"를 바꾸지 못함 → 검색어에 "챗GPT 사용 시 회의록 개인정보" |
| 2판 (정답) | 그만두면 → 퇴사, 깔다 → 설치, 챗GPT·제미나이 → 생성형 AI 서비스, 개인 메일로 보내다 → 외부로 보내다·첨부 파일, 폰 → 휴대폰 | 위 표 7/8 |
2판은 **실패한 질문에서 찾은 낱말**을 넣었습니다. 시험 문제를 보고 답을 외운 것처럼, 이 8개에만 맞춘 것일 수 있습니다. 그래서 원래 24문항으로 **함께** 다시 쟀습니다:
| 24문항 | 다시 쓰기 없이 | 다시 쓰기 (10/18) | 다시 쓰기 (10/22) |
| --- | --- | --- | --- |
| doc@1 | 24 | 24 | 24 |
| answer@1 | 22 | **24** | **23** |
| answer@3 | 24 | 24 | 24 |
| 검색 한 번 | 0.2~0.5초 | 0.9~1.6초 | 1.0~2.0초 |
다른 질문을 망가뜨리지 않았고(doc@1 · answer@3 그대로), 대신 검색마다 LLM을 한 번 더 불러 **1초쯤 느려졌습니다.**
### 4.4 어려운 질문을 만드는 법
| 방법 | 예 |
| --- | --- |
| 줄임말 · 외래어 | 비번, 폰, 챗GPT |
| 상황으로 말하기 | "노트북 놓고 온 것 같아요 택시에" (= 분실) |
| 두 규정에 걸치기 | "고객 명단 엑셀을 개인 메일로" (개인정보? 메일?) |
| 다른 규정의 낱말이 섞임 | "챗GPT에 **회의록**" (회의실 규정으로 끌려감) |
처음 쓴 어려운 질문 하나는 "윈도우 업데이트 안 하고 미루면 안 돼요?"였습니다. 기대 문서를 `security-incident`로 적었는데, 규정에 윈도우 업데이트 이야기가 **없었습니다** (1등은 `software-usage` 0.309, 8개 중 5개 맞음. 자세히는 1단계 3.3절). 어려운 질문도 정답표부터, 원문을 열어 확인합니다. 답이 없는 질문은 검색 테스트가 아니라 2단계 답 검사 테스트로 보냅니다.
## 5. 같은 설정, 다른 결과
같은 정답 워크플로우로 10/22에 다시 재니, 다시 쓰기 뒤 answer@1이 **24 → 23**이 됐습니다.
| | 10/18 | 10/22 |
| --- | --- | --- |
| "고객 정보가 밖으로 샌 것 같아요"의 다시 쓴 검색어 | 고객 정보 외부 유출 **대응 방안** | 고객 정보 외부 유출 **방지 정책** |
| 1등 | 개인정보 · 3. 유출 대응 0.559 ✔ | 개인정보 · 2. 이용과 보관 0.547 ✘ |
| 2등 | 개인정보 · 2. 이용과 보관 0.487 | 개인정보 · 3. 유출 대응 0.51 |
- 검색 앞에 LLM을 두면 **검색도 매번 조금씩 달라집니다.** "대응"이 "방지"로 바뀌자 답 섹션이 2등으로 내려갔습니다.
- 답이 3등 안에는 있어 answer@3은 그대로 24입니다. 2단계 Q&A는 3개를 주므로 답에는 영향이 적습니다.
- 다시 쓰기를 넣었다면 `--repeat 3`으로 재고 "22~24"처럼 **범위**로 적습니다. 한 번 잰 24는 운이 좋았던 것일 수 있습니다.
- LLM이 없는 검색(섹션 청크)은 두 날 모두 22로 같았습니다. 점수도 셋째 자리에서만 흔들립니다 (1단계 2.4절).
## 6. 무엇을 고를까
| 개선 | 좋은 점 | 대가 | 오늘 잰 것 |
| --- | --- | --- | --- |
| 청크를 뜻 단위로 (섹션) | 공짜, 가장 큰 차이일 때가 많음 | 문서에 구조가 있어야 함 | 섹션 22 vs 200자 20 (answer@1) |
| 질문 다시 쓰기 | 구어체 · 다른 이름에 강함 | LLM 한 번 더 (약 1초, 비용), 새로 틀리는 질문, 실행마다 다름 | 어려운 8개 6 → 7, 24문항 answer@1 22 → 23~24 |
| 같은 뜻 낱말표 (Code) | 빠르고 늘 같은 결과 | 표를 사람이 관리 | (재지 않음) |
| `top_k` 늘리기 | answer@k가 오름 | LLM에 주는 글이 길어짐 | answer@1 22 → answer@3 24 |
| 임베딩 모델 바꾸기 | 한국어에 맞는 모델이면 큰 차이 | 기준값도 다시 재야 함 | (재지 않음) |
| 낱말 검색과 섞기 (BM25 + 임베딩) | 고유명사 · 숫자에 강함 | 섞는 비율을 또 재야 함 | (강사 바이브 코딩 거리) |
> 고르는 기준은 "어느 것이 좋다더라"가 아니라 **내 문서 · 내 질문으로 잰 숫자**입니다. 좋아진 것과 나빠진 것을 함께 적습니다.
### 6.1 언제 멈추나
- 정답표의 **중요한 질문**(자주 묻는 것, 틀리면 위험한 것)이 다 맞으면 멈춥니다. 24개 모두를 늘 맞히려고 지시문에 예를 계속 더하면, 그 24개에만 맞춘 검색이 됩니다.
- 남은 실패는 **어디서 막는지** 적습니다: answer@3 안에 있으면 LLM이 답할 수 있고, 아니면 2단계의 기준값 · 답 검사가 담당자에게 넘기는지 확인합니다.
- 새 질문이 들어오면 정답표에 더하고 다시 잽니다. 정답표는 한 번 쓰고 끝나는 것이 아니라 **함께 자랍니다** (77일차 지표, 79일차 골든 질문).
## 확인 질문
<details><summary>1. doc@1이 24/24인데 answer@1이 22/24입니다. 무엇이 문제인가요?</summary>
맞는 문서는 찾았지만 그 문서의 다른 섹션이 1등입니다 (예: "퇴사 노트북"은 지급 섹션 0.464, 반납 섹션 0.45). answer@3이 24/24라면 3개를 주는 Q&A는 답할 수 있습니다.
</details>
<details><summary>2. 200자로 바꿨는데 결과가 섹션과 똑같이 나왔습니다. 무엇을 의심하나요?</summary>
`--no-before`를 빼먹어 테스트의 `before`가 섹션으로 다시 색인했을 수 있습니다. 결과 첫 줄에 `✔ before:`가 있는지, `call my-index` 응답의 `indexed_chunks`가 20인지 확인합니다.
</details>
<details><summary>3. 400자의 answer@1이 22로 섹션과 같습니다. 400자를 써도 되나요?</summary>
숫자는 같지만 틀린 질문이 다릅니다 (400자는 "화면 잠금"을 0.241로 틀림). doc@1은 22로 더 나쁘고, 섞인 청크는 관련도 기준 아래로 떨어질 수 있습니다. 우리 문서에서는 섹션이 낫습니다.
</details>
<details><summary>4. 질문 다시 쓰기로 7/8이 됐습니다. 남은 1개를 맞히려면 지시문에 "모르는 프로그램 → 악성코드"를 넣으면 되나요?</summary>
그 질문은 맞겠지만 이 8개에 맞춘 것이 됩니다. 넣는다면 24문항과 다른 질문으로 함께 다시 재고, 새로 틀리는 것이 없는지 봅니다. 그리고 `--repeat`으로 여러 번 잽니다.
</details>
<details><summary>5. 다시 쓰기 뒤 answer@1이 24였다가 다음 날 23이 됐습니다. 무엇이 바뀌었나요?</summary>
워크플로우는 같습니다. LLM이 다시 쓴 검색어가 실행마다 달라 ("대응 방안" → "방지 정책") 1 · 2등이 바뀌었습니다. LLM이 검색 앞에 있으면 결과를 범위로 적습니다.
</details>
## 정리
- [ ] doc@1 · answer@1 · answer@3을 같은 질문 목록으로. 세 지표의 조합으로 어디서 틀렸는지 읽는다
- [ ] 한 번에 하나만 바꾸고, 표에 한 줄씩 (틀린 질문까지). `--no-before`를 확인한다
- [ ] 청크 크기는 내 문서 모양을 보고. 글자 수는 문장을 자르고, 큰 청크는 주제를 섞는다
- [ ] 개선 뒤에는 원래 테스트도 다시: 새로 틀린 것도 결과다
- [ ] 검색 앞에 LLM이 있으면 결과가 실행마다 다르다 → 여러 번, 범위로
+85
View File
@@ -0,0 +1,85 @@
# 7·8교시 · 바이브 코딩 (`tdoc`)
- 교시: 7교시 16:00~16:50 (각자 만들기), 8교시 17:00~17:50 (발표 · 정리 · 강사 바이브 코딩 · 팁 · 예고)
- 과제: [VIBE-REQ-TDOC](../../Last3Mon/71/VIBE-REQ-TDOC.md)(요청서) → 학생이 직접 쓰는 `SPEC.md` → [VIBE-Q-TDOC](../../Last3Mon/71/VIBE-Q-TDOC.md)(요구 · 단계)와 비교 → 만들기 → 수업 뒤 `VIBE-A-TDOC.md` 공개
- 68 · 69일차와 같은 흐름입니다: 사람의 말(요청서)을 **확인할 수 있는 요구**로 바꾸는 일을 학생이 먼저 하고, VIBE-Q는 그 다음 단계(비교 대상)로 씁니다.
- 오늘은 `tdoc`: 내 md 문서를 임베딩해 SQLite에 저장하고, 질문하면 답과 근거 문단을 보여 주는 RAG TUI. 설정을 바꿔 같은 질문 목록으로 비교합니다. 오늘 n8n으로 한 일을 내 문서에 직접.
```mermaid
flowchart LR
R["요청서 → SPEC<br/>10분"] --> A["7교시<br/>단계별 만들기 40분"] --> B["발표<br/>10분"] --> C["강사 정리<br/>10분"] --> D["강사 바이브 코딩<br/>15분"] --> E["보충 · 팁<br/>10분"] --> F["72일차 예고<br/>5분"]
```
## 1. 7교시 · 각자 만들기 (50분)
| 시간 | 단계 | 확인 |
| --- | --- | --- |
| 16:00~16:02 | 0. 준비 (`~/vibe/tdoc`, `git init`, 키 환경 변수). **에이전트는 아직 켜지 않음** | |
| 16:02~16:12 | 0. 요청서 → `SPEC.md` (직접) → VIBE-Q와 비교 · 고치기 · commit | 요구마다 확인 방법(호출 수 · 줄 수 · 맞은 수), 숨은 요구, 비교한 줄 |
| 16:12~16:25 | 1. 색인 · 검색 (R1 · R2). 첫 요청은 "SPEC.md를 읽고 확인 방법부터 말해" | 두 번째 색인은 임베딩 0번, `tdoc search` |
| 16:25~16:35 | 2. 답 · 근거 (R3) | 근거 번호, 점수 낮으면 LLM 없이 모른다고 |
| 16:35~16:45 | 3. TUI · 비교 (R4 · R5) | `tdoc eval` 두 설정 |
| 16:45~16:50 | 4. 정리 · `SPEC.md` 체크 · 발표 메모 | |
16:00에 화면에 띄울 것: 요청서의 ①~⑤와 예 하나(① → R1). "부탁은 확인할 수 없습니다. 호출 수 · 줄 수 · 맞은 수로 바꿉니다." 16:10쯤 "VIBE-Q를 열어 비교하세요"를 알립니다.
| 볼 것 | 막혔을 때 한마디 |
| --- | --- |
| 색인할 때마다 전부 다시 | "파일 해시로 바뀐 파일만" |
| 테스트가 진짜 임베딩을 부름 | "가짜 임베딩으로" |
| 근거 없이 답함 | "발췌로만, 번호로, 점수 낮으면 LLM 부르지 않기 — 오늘 Lab 02" |
| 비교 없이 "잘 되는 것 같아요" | "질문 5개 정답표를 먼저" |
| SPEC 없이 VIBE-Q를 통째로 붙여 넣었나 | "요청서로 SPEC.md부터. 확인 방법이 없으면 됐는지 모릅니다" |
| SPEC에 부탁만 ("빠르게", "지어내지 않게") | "다시 색인하면 임베딩이 몇 번 불려야 하나요? 숫자로" |
| 실행해 보지 않고 다음 요청 | "에이전트가 됐다고 해도 직접 `uv run tdoc`" |
수업 준비 때 tdoc를 미리 만들어 보지는 않았습니다. 강사 바이브 코딩(4절)에서 걸린 시간을 재어 정답본(`VIBE-A-TDOC.md`)에 남깁니다.
## 2. 8교시 · 발표 (10분)
- 1~2명. **두 설정을 숫자로 비교한 사람**을 먼저 고릅니다.
- 3분 발표 · 2분 질문: **내 SPEC과 VIBE-Q가 달랐던 곳** / 잘 먹힌 요청문 / 안 먹힌 요청문 / 에이전트가 틀린 곳 / 교안 폴더에 질문 하나와 비교 표.
## 3. 강사 정리 (10분)
| | n8n 규정 Q&A | tdoc |
| --- | --- | --- |
| 저장소 | n8n 메모리 (재시작에 지워짐) | SQLite 파일 (남음) |
| 다시 색인 | 매번 전부 (`clearStore`) | 바뀐 파일만 (해시) |
| 검색 | Vector Store 노드 | 코사인 유사도 직접 |
| 근거 | `[번호]` → 제목, `cited` | `[번호]` → 파일 · 섹션 |
| 모르면 | 기준값 + 답 검사 | `--min-score` + 같은 생각 |
| 품질 재기 | `retrieval-*` 24문항 | `tdoc eval questions.yaml` |
## 4. 강사 바이브 코딩 (15분)
| 주제 | 요청문 요지 | 보여 줄 것 |
| --- | --- | --- |
| 오늘 정답표로 eval | "Last3Mon/71/cases/retrieval-doc.json 을 읽어 tdoc eval 형식으로 바꿔 data-api/data/docs 에 돌려" | n8n과 tdoc을 같은 24문항으로 비교 |
| 낱말 + 뜻 섞어 찾기 | "BM25 점수와 임베딩 점수를 섞어(가중치 옵션) 순위" | 3단계 "같은 뜻 낱말표"의 다른 방법 |
| n8n에서 부르기 | "tdoc serve: /ask 웹훅처럼 POST 받는 작은 HTTP 서버" | 72일차 이후 n8n 워크플로우의 Tool로 |
걸린 시간과 결과를 `Last3Mon/71/VIBE-A-TDOC.md`에 적어 둡니다.
## 5. 보충 · 팁 (10분)
| 팁 | 설명 |
| --- | --- |
| 요청서 → 요구가 먼저 | 확인 방법(호출 수 · 줄 수 · 맞은 수)이 있는 요구가 있어야 에이전트의 "완료"를 판단할 수 있음 |
| 정답표가 먼저 | 검색 품질은 "몇 개 중 몇 개"로 |
| 한 번에 하나만 | 청크, top_k, 모델, 기준값을 따로 |
| 바뀐 것만 다시 | 해시로 색인 시간·비용 절약 |
| 근거는 코드가 확인 | 번호 범위, 숫자, 겹침 |
| 비밀은 환경 변수 | 키는 DB·로그에 남기지 않기 |
## 6. 72일차 예고 (5분)
- 오늘: 검색 품질. 내일: **실행 흐름 최적화** — 워크플로우의 어디가 느린지 재고(p50·p95, 단계별 시간), 규칙 먼저·캐시·LLM 호출 줄이기로 빠르게 만들고, 빨라진 만큼 품질이 그대로인지 확인합니다. 데이터 API나 LLM이 멈춰도 정해진 답을 하게 합니다.
- 바이브 코딩: `tbench` (URL·웹훅을 N번 호출해 응답 시간 p50·p95, 실패 수를 표와 스파크라인으로). 66일차 `turl` 요청을 그대로 가져와 씁니다.
## 확인 (강사)
- [ ] 7교시 시작 전 `OPENAI_API_KEY` 꺼내는 명령을 화면에 띄웠다 (키 값은 띄우지 않음).
- [ ] 발표자 1~2명, 강사 정리 표, 강사 바이브 코딩 1개(시간 기록), 팁, 예고.
- [ ] 16:00 요청서 → SPEC 10분을 지켰다 (에이전트 없이), 16:10 VIBE-Q 비교 알림.
- [ ] 수업 뒤: `VIBE-A-TDOC.md`의 "자주 빠뜨리는 것"·강사 바이브 코딩 시간을 채우고 **학생 저장소에 VIBE-A만** 올린다 (LAB-A는 올리지 않음).
+39
View File
@@ -0,0 +1,39 @@
# 71일차 · RAG 기반 검색·응답 Workflow와 검색 품질 개선 (바이브 코딩)
- 일자: 2026-10-23 (71일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: 운영·확장 관점의 AI 워크플로우 설계 고도화
- 학습 내용: RAG 기반 검색·응답 Workflow 구현, 검색 품질 개선 적용
- 실습 폴더: [Last3Mon/71](../../Last3Mon/71/README.md)
- 실습 환경: 65일차 n8n 2.42.6 + OpenAI API(`gpt-4.1-mini`, 임베딩 `text-embedding-3-large`) + 71일차 데이터 API(규정 문서) + **CLI 코딩 에이전트**
- **실습 준비: [00-DAY71-PREP.md](00-DAY71-PREP.md) (수업 전날까지 1~2절 완료)**
## 오늘의 시간표
| 교시 | 시간 | 내용 | 자료 |
| --- | --- | --- | --- |
| 1 | 09:00~09:50 | 오늘의 개요 · 1단계 · RAG와 색인 | [개요](00-DAY71-OVERVIEW.md), [1단계](01-STAGE1-RAG-AND-INDEX.md) |
| 2 | 10:00~10:50 | 1단계 · Lab 01 (정답표, 색인·검색, 과제: 섹션 출처) | [Lab 01](../../Last3Mon/71/LAB-01-Q-INDEX-AND-SEARCH.md) |
| 3 | 11:00~11:50 | 2단계 · 근거로 답하는 RAG 워크플로우, Lab 02 예제 1·2 | [2단계](02-STAGE2-RAG-ANSWER.md), [Lab 02](../../Last3Mon/71/LAB-02-Q-ANSWER-FROM-POLICIES.md) |
| 4 | 12:00~12:50 | 2단계 · Lab 02 과제 35분 (점수는 높은데 답이 없을 때) · 풀이 | [Lab 02](../../Last3Mon/71/LAB-02-Q-ANSWER-FROM-POLICIES.md) |
| - | 13:00~14:00 | 점심 | |
| 5 | 14:00~14:50 | 3단계 · 검색 품질 측정과 개선, Lab 03 예제 1·2 | [3단계](03-STAGE3-RETRIEVAL-QUALITY.md), [Lab 03](../../Last3Mon/71/LAB-03-Q-MEASURE-AND-IMPROVE.md) |
| 6 | 15:00~15:50 | 3단계 · Lab 03 과제 40분 (어려운 질문으로 고치기) · 풀이 | [Lab 03](../../Last3Mon/71/LAB-03-Q-MEASURE-AND-IMPROVE.md) |
| 7 | 16:00~16:50 | 바이브 코딩 · `tdoc` 만들기 (각자, 요청서 → SPEC 먼저) | [VIBE-REQ-TDOC](../../Last3Mon/71/VIBE-REQ-TDOC.md) → [VIBE-Q-TDOC](../../Last3Mon/71/VIBE-Q-TDOC.md), [진행](04-VIBE-CODING.md) |
| 8 | 17:00~17:50 | 바이브 코딩 발표 · 정리 · 강사 바이브 코딩 · 팁 · 72일차 예고 | [진행](04-VIBE-CODING.md) |
```mermaid
flowchart LR
P["개요"] --> S1["1단계<br/>정답표 · 색인 · 검색"] --> S2["2단계<br/>근거로 답 · 넘기기 · 답 검사"] --> S3["3단계<br/>재기 · 한 가지씩 고치기"] --> V["7·8교시<br/>바이브 코딩 tdoc"]
```
## 자료 목록
| 파일 | 내용 |
| --- | --- |
| `00-DAY71-PREP.md` | 실습 준비: n8n · 71일차 판 데이터 API 켜기, `flow.py doctor` (수업 전) |
| `00-DAY71-OVERVIEW.md` | 개요 23장 (PPT 대신 Markdown, 가로줄로 장 구분): 앞 일차와의 연결, 오늘의 문서, 단계마다 실측 장 |
| `01-STAGE1-RAG-AND-INDEX.md` | 왜 RAG인가(네 가지 방법 비교), 임베딩과 점수 읽기(실측), 정답표 먼저(질문 종류 · 정답표도 틀림), 청크 하나의 모양 · 메타데이터, 메모리 저장소의 한살이, 요청문 좋은 예 · 나쁜 예, 확인 질문 |
| `02-STAGE2-RAG-ANSWER.md` | LLM이 받는 발췌의 실제 모양 · 실측 답, `sources`와 `cited`, 관련도 기준 10개 · 정하는 순서 · 양쪽으로 틀림, 점수는 높은데 답이 없을 때, 답 검사 넷(계산 방법 · 결과 JSON), 넘기는 길 세 갈래, 확인 질문 |
| `03-STAGE3-RETRIEVAL-QUALITY.md` | doc@1 · answer@1 · answer@3(아슬아슬한 1등), 재는 순서 · 실험 기록, 청크 크기(잘린 문장 · 섞인 청크 · 틀린 질문이 다름), 어려운 질문 8개와 다시 쓴 검색어, 같은 설정 다른 결과, 개선 방법 고르기 · 멈출 때, 확인 질문 |
| `04-VIBE-CODING.md` | 7·8교시 바이브 코딩 진행 (tdoc), 발표, 강사 정리, 추가 바이브 코딩, 팁 |