71일차 자료 추가(답 제외)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
505dcd313f
commit
176e195dc2
62 files changed
+6288
No files matched your search
@@ -0,0 +1,3 @@
|
||||
runs/
|
||||
tools/.cache/
|
||||
my-work/*.json
|
||||
@@ -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 키를 읽어서 출력하지 않는다.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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절에 "기준값에 걸러지지 않았는데 답이 없는 질문"을 적었다.
|
||||
@@ -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`로 채웠다.
|
||||
@@ -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일차 준비
|
||||
@@ -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` | 지금 색인 그대로 (설정 비교할 때) | |
|
||||
@@ -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`.
|
||||
@@ -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`를 받아, 요구 도출 예시와 요청문 예를 내 것과 비교합니다.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": "담당자 연결"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
"끄거나"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
"끄거나"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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를 다시 등록한다.
|
||||
@@ -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가 만든 결과는 사람이 확인한 뒤에 사용한다.
|
||||
@@ -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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
|
||||
]
|
||||
@@ -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": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요."}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"chunking": 200}
|
||||
@@ -0,0 +1 @@
|
||||
{"chunking": 400}
|
||||
@@ -0,0 +1 @@
|
||||
{"chunking": "section"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "생성형 AI에 고객 자료를 넣어도 되나요?"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "첨부 파일이 너무 커서 메일이 안 보내져요"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": ""}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "비밀번호를 잊어버리면 어디서 바꿔요?"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "노트북 사양은 어떻게 되나요?"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "연차 휴가는 며칠이에요?"}
|
||||
@@ -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년에 몇 번 받아야 하나요?"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "컴퓨터가 바이러스에 걸린 것 같아요"}
|
||||
@@ -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)로 고친 것 중 어느 쪽이 더 안정적이었나?
|
||||
-
|
||||
@@ -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())
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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등의 차이, "관련 없는 점수대"를 본다
|
||||
- [ ] 정답표가 먼저 (답이 없는 질문 포함), 정답표도 원문으로 확인한다
|
||||
- [ ] 청크는 뜻 단위, 글 앞에 제목, 메타데이터가 출처. 글 안에 있는 것과 칸으로 있는 것은 다르다
|
||||
- [ ] 메모리 저장소는 재시작에 지워진다 → 테스트 전에 색인
|
||||
@@ -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`)을 남긴다
|
||||
@@ -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이 있으면 결과가 실행마다 다르다 → 여러 번, 범위로
|
||||
@@ -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는 올리지 않음).
|
||||
@@ -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), 발표, 강사 정리, 추가 바이브 코딩, 팁 |
|
||||
Reference in new issue
Block a user