From dd786dfed6e1bd7c2741517d0d3408ca6a014774 Mon Sep 17 00:00:00 2001 From: happybell80 Date: Thu, 19 Mar 2026 18:45:53 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20Phase=203=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EB=A7=81=E2=86=92LLM=20=EC=9C=84=EC=9E=84?= =?UTF-8?q?=20=EB=B0=98=EC=98=81,=20n8n=20JSON=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계획: Phase 3 근거 채택 판정을 LLM 위임으로 전환 반영 - 워크플로우 JSON: 키워드 필터링 노드 제거, Vector Score 선택 + LLM 판단 구조 - 워크플로우 MD: 근거 선별 원칙 추가 (룰베이스 절제 원칙 §B.6) Co-Authored-By: Claude Opus 4.6 --- ...합성_시나리오동시종결_계획.md | 12 +- .../03_rag/companyx_grounding_pipeline.json | 114 ++++++++++++++---- .../03_rag/companyx_grounding_pipeline.md | 18 ++- 3 files changed, 109 insertions(+), 35 deletions(-) diff --git a/journey/plans/260315_companyx_rag_답변합성_시나리오동시종결_계획.md b/journey/plans/260315_companyx_rag_답변합성_시나리오동시종결_계획.md index 663027d..0275202 100644 --- a/journey/plans/260315_companyx_rag_답변합성_시나리오동시종결_계획.md +++ b/journey/plans/260315_companyx_rag_답변합성_시나리오동시종결_계획.md @@ -276,14 +276,12 @@ tags: [plans, companyx, rag, answer-composition, scenario, troubleshooting] - `그럼 컴퍼니엑스 내부 규정 상 휴가는 얼마나 쓸 수 있어?` -> 사실 확인형 또는 규정 확인형 성격 - `오늘전통 프로그램을 Company X가 옐로펀치랑 같이 운영한다는 근거 있어?` -> 사실 확인형 -### Phase 3. 근거 채택 판정 추가 — **구현 완료** +### Phase 3. 근거 채택 판정 — **LLM 위임으로 전환** - 검색 결과를 그대로 상위 3개 노출하지 않습니다. -- 질문 유형에 맞지 않는 청크는 버립니다. -- 필요 최소 판정: - - 엔티티 일치 여부 - - 문서 성격 일치 여부 - - 답변 가능 근거인지 여부 -- `휴가` 질문에 `todaytradition` 청크가 잡히는 경우는 `무관한 결과`로 실패 처리해야 합니다. +- **키워드 기반 룰 필터링은 룰베이스 절제 원칙(global-principles.md §B.6)에 따라 제거했습니다.** +- 벡터 유사도 상위 결과를 문서 다양성 기준으로 선택한 뒤, LLM에 컨텍스트로 전달합니다. +- LLM이 컨텍스트를 보고 질문 적합도를 재판단하며, 무관한 청크는 LLM이 `failure_reason`으로 처리합니다. +- `휴가` 질문에 `todaytradition` 청크가 잡히는 경우는 LLM이 `질문과 맞는 문서 미확인`으로 답해야 합니다. ### Phase 4. LLM 기반 답변 생성 + Pydantic 출력 검증 — **구현 완료** - 현재 `_build_direct_answer()` 규칙 문자열 조합을 LLM 호출로 대체합니다. diff --git a/workflow/03_rag/companyx_grounding_pipeline.json b/workflow/03_rag/companyx_grounding_pipeline.json index d22f2f8..013d865 100644 --- a/workflow/03_rag/companyx_grounding_pipeline.json +++ b/workflow/03_rag/companyx_grounding_pipeline.json @@ -12,47 +12,80 @@ "name": "Webhook In", "type": "n8n-nodes-base.webhook", "typeVersion": 1, - "position": [200, 240] + "position": [200, 240], + "notes": "Company X 소속 사용자의 내부 문서 질문 수신" }, { "parameters": { - "jsCode": "const body = $json.body || {};\nreturn {\n query: body.query || '',\n user_uuid: body.user_id || '',\n team_id: '79441171-3951-4870-beb8-916d07fe8be5', // Company X Team ID\n limit: body.limit || 5\n};" + "jsCode": "const body = $json.body || {};\nreturn {\n query: body.query || '',\n user_uuid: body.user_id || '',\n team_id: '79441171-3951-4870-beb8-916d07fe8be5',\n limit: body.limit || 5,\n threshold: 0.35\n};" }, "id": "cx-rag-002", "name": "Set Context (Company X)", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [440, 240] + "position": [440, 240], + "notes": "Company X 팀 ID 고정, 검색 파라미터 설정" + }, + { + "parameters": { + "jsCode": "const message = $json.query;\nconst lowered = message.toLowerCase();\nlet questionType = 'fact_check';\nif (['다시 정리', '정리해줘', '문서명만', '근거 문서', '다시 보여'].some(k => lowered.includes(k))) {\n questionType = 'recap';\n} else if (['휴가', '연차', '반차', '규정', '복지', '취업규칙', '인사'].some(k => lowered.includes(k))) {\n questionType = 'fact_check';\n} else if (['몇개', '몇 개', '건수', '몇 건', '얼마나', '수는', '수가', '개수'].some(k => lowered.includes(k))) {\n questionType = 'quantitative';\n} else if (['뭐야', '무엇', '설명', '소개', '알려줘'].some(k => lowered.includes(k))) {\n questionType = 'explanatory';\n}\nreturn { ...$json, question_type: questionType };" + }, + "id": "cx-rag-002b", + "name": "Classify Question Type", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [660, 240], + "notes": "질문 유형 분류 (LLM 프롬프트 톤 조절용)" }, { "parameters": { "method": "POST", - "url": "=http://192.168.219.52:8508/api/search", + "url": "=http://192.168.0.106:8508/api/search", "sendBody": true, "specifyBody": "json", - "jsonBody": "={\n \"query\": \"{{ $json.query }}\",\n \"team_id\": \"{{ $json.team_id }}\",\n \"limit\": {{ $json.limit }}\n}", + "jsonBody": "={\n \"query\": \"{{ $json.query }}\",\n \"team_id\": \"{{ $json.team_id }}\",\n \"limit\": {{ $json.limit }},\n \"threshold\": {{ $json.threshold }}\n}", "options": {} }, "id": "cx-rag-003", - "name": "Search Internal Docs", + "name": "Vector Search (skill-rag-file)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [680, 240] + "position": [900, 240], + "notes": "벡터 유사도 검색. 키워드 필터링 없이 유사도 상위 결과를 그대로 반환한다." }, { "parameters": { - "jsCode": "const results = $json.body || [];\nconst context = results.map(r => `[출처: ${r.filename}]\\n${r.text}`).join('\\n---\\n');\nconst original = $('Set Context (Company X)').item.json;\nreturn {\n message: original.query,\n grounding_context: context,\n user_id: original.user_uuid,\n robeing_id: 'rb8001'\n};" + "jsCode": "const results = $json.body?.results || [];\nif (results.length === 0) {\n return { has_results: false, context: '', evidence_filenames: [], question_type: $('Classify Question Type').item.json.question_type };\n}\n\n// 벡터 유사도 순으로 정렬, 문서 다양성 기준 상위 5개 선택 (키워드 필터링 없음)\nconst sorted = results.sort((a, b) => (b.relevance_score || 0) - (a.relevance_score || 0));\nconst seenDocs = new Set();\nconst selected = [];\nfor (const r of sorted) {\n const docId = r.document_id || '';\n if (docId && seenDocs.has(docId)) continue;\n if (docId) seenDocs.add(docId);\n selected.push(r);\n if (selected.length >= 5) break;\n}\n\nconst context = selected.map(r => `파일명: ${r.filename}\\n내용: ${r.chunk_text}`).join('\\n\\n---\\n\\n');\nconst filenames = selected.map(r => r.filename);\nconst original = $('Classify Question Type').item.json;\n\nreturn {\n has_results: true,\n message: original.query,\n question_type: original.question_type,\n grounding_context: context,\n evidence_filenames: filenames,\n user_id: original.user_uuid,\n result_count: selected.length\n};" }, "id": "cx-rag-004", - "name": "Prepare LLM Prompt", + "name": "Select Top Results (Vector Score)", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [940, 240] + "position": [1140, 240], + "notes": "벡터 유사도 점수 기준 상위 선택. 룰베이스 절제 원칙(§B.6)에 따라 키워드 필터링을 사용하지 않는다. LLM이 적합도를 재판단한다." + }, + { + "parameters": { + "conditions": { + "boolean": [ + { + "value1": "={{ $json.has_results }}", + "value2": true + } + ] + } + }, + "id": "cx-rag-004b", + "name": "Has Results?", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [1380, 240], + "notes": "검색 결과 0건이면 실패 경로로 분기" }, { "parameters": { "method": "POST", - "url": "=http://192.168.219.52:8001/api/chat", + "url": "=http://192.168.0.106:8001/api/message", "sendHeaders": true, "headerParameters": { "parameters": [ @@ -64,36 +97,73 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "={\n \"message\": \"{{ $json.message }}\",\n \"context\": \"아래의 컴퍼니엑스 내부 문서 내용을 바탕으로 답변하세요:\\n{{ $json.grounding_context }}\",\n \"source\": \"n8n-grounding\"\n}", + "jsonBody": "={\n \"message\": \"{{ $json.message }}\",\n \"context\": {\n \"system_instruction\": \"당신은 Company X 내부 문서 기반 답변 전문가입니다. 제공된 컨텍스트만 근거로 사용하세요. 질문 유형: {{ $json.question_type }}. 컨텍스트만으로 부족하면 failure_reason에 이유를 적고 direct_answer는 빈 문자열로 두세요.\\n\\n컨텍스트:\\n{{ $json.grounding_context }}\"\n },\n \"source\": \"n8n-grounding\"\n}", "options": {} }, "id": "cx-rag-005", - "name": "Call rb8001 with Context", + "name": "LLM Answer with Context", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [1200, 240] + "position": [1620, 140], + "notes": "LLM이 컨텍스트를 보고 답변 가능 여부를 직접 판단한다. Pydantic 검증은 rb8001 코드에서 수행." }, { "parameters": { "respondWith": "json", - "responseBody": "={{ $json.body }}", + "responseBody": "={{ JSON.stringify({ success: true, type: 'companyx_rag', message: $json.body?.message || $json.body, rag_used: true, grounding_present: true }) }}", "options": {} }, "id": "cx-rag-006", "name": "Return Grounded Answer", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1, - "position": [1460, 240] + "position": [1860, 140], + "notes": "LLM 답변 + 근거 문서 반환" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ success: true, type: 'companyx_rag', message: '현재 확인된 Company X 내부 문서 기준으로는 질문과 맞는 근거를 확인하지 못했습니다.', rag_used: true, grounding_present: false }) }}", + "options": {} + }, + "id": "cx-rag-007", + "name": "Return Failure (No Results)", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [1620, 380], + "notes": "검색 결과 0건 — 실패 가시성 원칙(§B.5)에 따라 명시적 실패 응답" } ], "connections": { - "Webhook In": { "main": [[{ "node": "Set Context (Company X)", "type": "main", "index": 0 }]] }, - "Set Context (Company X)": { "main": [[{ "node": "Search Internal Docs", "type": "main", "index": 0 }]] }, - "Search Internal Docs": { "main": [[{ "node": "Prepare LLM Prompt", "type": "main", "index": 0 }]] }, - "Prepare LLM Prompt": { "main": [[{ "node": "Call rb8001 with Context", "type": "main", "index": 0 }]] }, - "Call rb8001 with Context": { "main": [[{ "node": "Return Grounded Answer", "type": "main", "index": 0 }]] } + "Webhook In": { + "main": [[{ "node": "Set Context (Company X)", "type": "main", "index": 0 }]] + }, + "Set Context (Company X)": { + "main": [[{ "node": "Classify Question Type", "type": "main", "index": 0 }]] + }, + "Classify Question Type": { + "main": [[{ "node": "Vector Search (skill-rag-file)", "type": "main", "index": 0 }]] + }, + "Vector Search (skill-rag-file)": { + "main": [[{ "node": "Select Top Results (Vector Score)", "type": "main", "index": 0 }]] + }, + "Select Top Results (Vector Score)": { + "main": [[{ "node": "Has Results?", "type": "main", "index": 0 }]] + }, + "Has Results?": { + "main": [ + [{ "node": "LLM Answer with Context", "type": "main", "index": 0 }], + [{ "node": "Return Failure (No Results)", "type": "main", "index": 0 }] + ] + }, + "LLM Answer with Context": { + "main": [[{ "node": "Return Grounded Answer", "type": "main", "index": 0 }]] + } }, "settings": {}, "pinData": {}, - "meta": { "templateCredsSetupCompleted": true, "instanceId": "robeing-rag" } + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "robeing-rag" + } } diff --git a/workflow/03_rag/companyx_grounding_pipeline.md b/workflow/03_rag/companyx_grounding_pipeline.md index cd1c287..393fc02 100644 --- a/workflow/03_rag/companyx_grounding_pipeline.md +++ b/workflow/03_rag/companyx_grounding_pipeline.md @@ -26,13 +26,19 @@ tags: [workflow, rag, companyx, grounding, answer] ## 처리 순서 1. 질문이 Company X 근거응답 대상인지 판단한다. -2. 검색된 문서 중 질문과 관련도가 높은 근거만 선별한다. -3. 직접 답을 먼저 만든다. -4. 근거 문서명과 요약을 붙인다. -5. 근거가 부족하면 추정 답변 대신 실패 응답으로 끝낸다. +2. 벡터 유사도 검색으로 상위 결과를 수집한다. +3. 검색 결과를 LLM에 컨텍스트로 전달하고, LLM이 질문 적합도를 판단해 답변한다. +4. LLM 응답을 Pydantic으로 검증한다. +5. 근거가 부족하면 LLM이 `failure_reason`을 명시하고, 추정 답변 대신 실패 응답으로 끝낸다. + +## 근거 선별 원칙 +- 키워드 기반 룰로 검색 결과를 필터링하지 않는다 (룰베이스 절제 원칙 — global-principles.md §B.6). +- 벡터 검색이 반환한 유사도 순서를 신뢰하고, LLM이 컨텍스트를 보고 적합도를 재판단한다. +- 근거 선별 책임은 LLM에 있으며, 코드 레벨에서는 유사도 상위 결과를 문서 다양성 기준으로 선택만 한다. ## 실패 분기 -- 질문과 맞지 않는 청크를 근거처럼 반환하지 않는다. +- 검색 결과가 0건이면 `문서 없음`으로 실패한다. +- LLM이 컨텍스트만으로 답변 불가로 판단하면 `failure_reason`을 채우고, 성공처럼 반환하지 않는다. - 수치형 질문에서 값이 없으면 추정하지 않는다. - 내부 규정이나 최신 집계가 없으면 `문서 없음`, `미확인`, `불일치` 중 하나로 명시한다. - 메타 대화로 회피하지 않는다. @@ -40,7 +46,7 @@ tags: [workflow, rag, companyx, grounding, answer] ## 현재 기준 - 이 흐름은 `rb8001` 답변 합성 규칙과 연결된다. - 검색 결과를 그대로 붙이는 방식은 허용하지 않는다. -- 질문 유형별 분기와 근거 적합도 평가가 필요하다. +- 질문 유형 분류는 LLM 프롬프트 톤 조절용으로만 사용한다. ## 검증 기준 - `오늘전통/옐로펀치` 같은 기준 질문에서 직접 답 + 근거 문서가 함께 나와야 한다.