docs: Phase 3 키워드 필터링→LLM 위임 반영, n8n JSON 워크플로우 갱신

- 계획: Phase 3 근거 채택 판정을 LLM 위임으로 전환 반영
- 워크플로우 JSON: 키워드 필터링 노드 제거, Vector Score 선택 + LLM 판단 구조
- 워크플로우 MD: 근거 선별 원칙 추가 (룰베이스 절제 원칙 §B.6)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
happybell80 2026-03-19 18:45:53 +09:00
parent 64ef0deaee
commit dd786dfed6
3 changed files with 109 additions and 35 deletions

View File

@ -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 호출로 대체합니다.

View File

@ -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"
}
}

View File

@ -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 프롬프트 톤 조절용으로만 사용한다.
## 검증 기준
- `오늘전통/옐로펀치` 같은 기준 질문에서 직접 답 + 근거 문서가 함께 나와야 한다.