학교 부트캠프 1인 프로젝트. 멘토를 통해 제공받은 국내 전기 분야 유관기관의 공개 기술질의 회신사례·행정 FAQ 자료를 지식베이스로 삼아, 접수된 민원을 자동으로 분류·부서배정하고 RAG 기반 답변 초안을 생성해 담당자 검토·승인 절차를 거치는 시스템입니다.
레포: https://github.com/Watchiiee/AiAn
전기 분야 유관기관에는 회원(전기기술인)들이 행정 절차(경력신고, 자격증 발급, 안전관리자 선임 등)와 전기 기술 자체(변압기·차단기·접지 등 설비 고장진단, 법정 기준 문의)에 대해 매일 많은 문의를 남깁니다. 담당자가 이 문의를 읽고, 어느 부서 업무인지 판단하고, 답변을 직접 작성하는 과정에 상당한 시간이 소요됩니다.
이 시스템은 문의가 접수되면:
- 분류 — 어떤 종류의 요청인지, 행정(admin) 업무인지 기술(technical) 문의인지, 어느 업무 카테고리인지 자동 판단
- 부서배정 — 카테고리 기준으로 담당 부서·우선순위를 자동 결정
- 검색·초안생성 — 지식베이스(실제 회신사례·행정FAQ)에서 근거를 찾아 AI가 답변 초안을 작성
- 검증 — 초안이 근거 문서와 실제로 일치하는지, 질문에 적합한지 자동 채점
- 검토·발행 — 담당자가 초안을 확인·수정 후 승인하면 민원인에게 발송
까지의 흐름을 자동화하며, AI가 answer를 대신 발행하는 게 아니라 "초안 + 근거"를 만들어 담당자의 작업을 줄이는 것이 이 시스템의 핵심 가치입니다.
| 구분 | 사용 기술 |
|---|---|
| LLM | CLOVA Studio HCX-005 (기본, 전체 분류·생성) + Upstage Solar (hallucination_grade 검증 게이트, 선택적 이중검증 — 6.6절) |
| 임베딩 | 로컬 jhgan/ko-sroberta-multitask (442MB, 한국어 특화) |
| 벡터DB | ChromaDB (PersistentClient, 로컬 파일 기반) |
| 키워드 검색 | rank_bm25 (경량, 정규식 토큰화) — 현재 기본은 꺼둠(4번 참고) |
| 관계형 DB | PostgreSQL — Supabase(관리형) 사용. 코드는 표준 DATABASE_URL 연결문자열만 쓰므로 자체 운영 Postgres/Cloud SQL 등으로도 변경 없이 전환 가능 |
| 파이프라인 오케스트레이션 | LangGraph (Adaptive RAG, 14개 노드) |
| 인증 | JWT(bcrypt 직접 해싱) + 3단계 RBAC |
| 관측/트레이싱 | Langfuse |
| 백엔드 | FastAPI |
| Python | 3.12 |
| 배포 | 네이버클라우드(NCP) — VPC/Subnet, Load Balancer, Auto Scaling Group, NAT Gateway (11절) |
START → classify → rule → check_urgency
│ (is_urgent면 priority='긴급'로만 표시.
│ RAG는 항상 진행 — "초안 생성"과 "화면 노출
│ 순서"는 분리된 문제로 설계함, 6.4절 참고)
▼
┌─(잡담, is_relevant=False)──→ chitchat_decline ─→ END
└─(그 외 전부)
│
▼
retrieve (엘보우+min_k로 동적 개수 결정)
│
▼
doc_grade (검색된 것 중 실제로 답변에 쓸 것만 개별 선별)
┌────────┴────────┐
(선택된 것 있음) (0개 선택, 재검색<2)
│ └──→ rewrite_query → retrieve (루프)
│ (재검색 소진) → no_evidence → END
▼
generate
│
▼
hallucination_grade ──(근거불일치, 재생성<2)──→ bump_regenerate ─┐
│ │
(근거일치) (generate로 루프)
▼
answer_grade ──(부적합, 재생성<2)──→ bump_regenerate (위와 공유)
│
(적합)
▼
finalize → END (재시도 소진 시 확신도 하향 조정)
- type: 경력인증 / 신청 / 변경 / 취소환불 / 오류장애 / 일반문의 / 긴급문의 / 미분류
- domain: admin(행정) / technical(기술) — 검색할 지식베이스 컬렉션을 결정
- category: 전기안전관리자 / 설계감리 / 경력회원 / 공제사업 / 교육 / 컨소시엄훈련 / 홈페이지·전산 / 기술지원 / 기타
- is_relevant: 협회 업무와 무관한 잡담 여부
각 판단은 독립된 LLM 호출로 이뤄집니다(하나의 프롬프트에 여러 판단을 몰아넣으면 서로 간섭한다는 것을 실제로 겪은 뒤 확립한 원칙 — 8.2절 참고).
| category | 부서 |
|---|---|
| 전기안전관리자 | 안전관리지원팀 |
| 설계감리 | 설계감리지원팀 |
| 경력회원 | 회원관리팀 |
| 공제사업 | 공제운영팀 |
| 교육 | 교육원(교육운영팀) |
| 컨소시엄훈련 | 교육원(인적자원개발팀) |
| 홈페이지·전산 | 정보전략실 |
| 기술지원 | 연구원 |
| 기타 | 경영지원팀 |
문의의 domain(admin/technical)에 따라 서로 다른 ChromaDB 컬렉션에서
검색합니다. 검색 개수는 고정값이 아니라 엘보우(최대 점수격차) + 하한선
(min_k=6) 방식으로 동적으로 결정됩니다 — 벡터 검색 top-10을 받아 점수가
가장 크게 떨어지는 지점까지 채택하되, 그 지점이 6등보다 앞이면 강제로
6개까지는 봅니다. 이 min_k=6은 감으로 정한 값이 아니라, 실패 사례들의
실제 정답 등수(4~6등)를 골든셋으로 확인해 산출한 값입니다.
검색된 문서를 "전체가 관련있냐 없냐"로 한 번에 판단하지 않고, 문서 하나하나를
LLM이 개별적으로 검토해 실제로 답변에 쓸 것만 선별합니다. 선별에서 제외된
문서도 API 응답에는 그대로 노출되지만(selected: false), 실제 답변 생성에는
쓰이지 않습니다 — 담당자가 "AI가 뭘 보고 뭘 실제로 썼는지"를 투명하게 구분할
수 있게 하기 위함입니다.
영문 약어·표준명(TN-S, IEEE Std 142 등)이 임베딩 검색에서 종종 순위가
밀리는 문제를 보완하기 위해 BM25 키워드 검색을 RRF로 융합하는 구조를
구현했으나, 골든셋 실측 결과 recall이 오히려 하락(97.2%→94.4%)해 기본값을
꺼둔 채로 유지합니다(코드는 남겨두고 RAG_USE_HYBRID 환경변수로 옵션
전환만 가능). 이 과정에서 우연히 골든셋 CSV 자체의 이스케이프 버그(백슬래시
오용)를 발견해 함께 수정했습니다.
| 도메인 | 파일 수 | 청크 수 |
|---|---|---|
| admin (행정) | 14개 | 213+ |
| technical (기술) | 18개 | 431+ |
| 합계 | 32개 | 644+ |
실제 공개 기술질의 회신사례·행정 FAQ 원문(700개 이상의 Q&A)을 정제해 markdown 지식베이스로 변환했습니다. 표·영문약어가 포함된 콘텐츠는 문장임베딩 모델(ko-sroberta)에서 검색 순위가 밀리는 경향이 반복 확인되어, 표 위에 핵심 수치를 자연어 문장으로 병기하는("프로즈 보강") 관행을 확립했습니다.
임의로 만든 질문 대신, 원본 Q&A에서 위험도 기반으로 샘플링한
89개 문항(Phase 1: 심층진단용 26개, Phase 2: 전체 카테고리 baseline
54개, 편향진단용 9개)으로 구성했습니다. 각 문항에 정답 청크·정답 도메인·
난이도·유형(A=원문/B=의역/C=복합질문/E=약어형/F=경계형)을 라벨링해,
"검색이 잘 되는지"뿐 아니라 "어떤 표현 축에서 실패하는지"까지 구조적으로
진단할 수 있게 했습니다. 이후 체크포인트4(분류정확도·답변적합도 평가)
대응을 위해 gold_type/gold_category 정답 라벨을 89개 전체에 추가로
검토·부여했습니다(AI 초안 생성 + Claude 교차검토 방식, 5.4절 참고).
- Hit@k: 정답청크가 top-k 안에 있는지 (k가 커지면 구조적으로 개선되는 경향이 있어 단독 근거로는 쓰지 않음)
- Precision@k: top-k 중 실제 정답 비율 (k를 늘리는 것의 비용을 보여줌)
- recall: 여러 정답청크(복합질문)가 있을 때 몇 개를 찾았는지
- 손으로 만든 테스트셋은 오버피팅 위험이 있다 — 처음 만든 22개 질문 세트로 청크를 반복 튜닝하다, 그 22개에만 맞춰 최적화되는 문제를 발견하고 원본 데이터 기반 골든셋으로 전환.
- "국소개선"은 반드시 전체로 실측해야 한다 — 도메인분류 프롬프트에 예시 하나를 추가했다가 무관한 9개 항목이 회귀한 사례, 검색 결과 개수가 늘어난 것이 하필 채점기(doc_grade)와 상호작용해 recall이 19%까지 떨어졌던 사례를 실제로 겪었습니다.
- "검색만 봤을 때 회귀 없음"과 "전체 파이프라인 기준 회귀 없음"은 다른 주장이다 — 컴포넌트 하나만 단독 테스트해 기본값을 승격했다가, 인접 컴포넌트(doc_grade)와의 상호작용에서 큰 회귀가 있었음을 나중에 발견한 사고 이후, "컴포넌트 변경 시 전체 파이프라인 스모크테스트 필수" 원칙을 확립했습니다.
- 노이즈와 확정버그는 반복실행으로 구분한다 — 같은 질문을 5~10회 반복해 결과가 매번 같은지(확정버그) 흔들리는지(LLM 응답의 자연스러운 확률적 변동)를 구분하는 방법론을 정착시켰습니다. 도메인 분류의 안정적 오분류 패턴(CT, 절연저항 등 특정 전문용어 포함 질문)을 이 방법으로 확정하고 수정한 사례가 최근에도 재현되었습니다(5.4절).
- 저비용 사전검증이 실사용 사고를 막은 사례 — 복합질문 분해(query decomposition) 기능은 배선(파이프라인 통합) 전에 함수 단독으로 오분해율을 먼저 측정해 53.6%(기준치의 10배)라는 결과를 확인하고, 실사용에 반영되기 전에 폐기했습니다.
- "회귀처럼 보이는 현상"의 원인을 성급히 코드로 단정하지 않는다 — 분류 정확도 재검증 중 특정 지표가 일시적으로 크게 하락(86.5%→34.8%)하는 현상이 발생했으나, 실제 원인은 코드 버그가 아니라 CLOVA API의 분당 요청한도(rate limit)였습니다. 원본 오류 로그를 직접 확인해서야 확정된 사례로, "숫자가 나빠졌다"를 그대로 코드 문제로 단정하지 않고 원본 로그로 원인을 좁히는 절차를 다시 확인했습니다(5.4절 참고).
목적: "직접 수집한 맞춤형 테스트셋을 기반으로 분류 정확도(Accuracy)와
답변 적합도를 평가했는가"라는 요구사항에 정확히 대응하기 위해, 기존 골든셋
(RAG 검색 평가용으로 설계되어 gold_domain만 있고 type/category
정답 라벨은 없었음)에 gold_type/gold_category 라벨을 추가하고, 전용
평가 스크립트 2개를 신설했습니다. gold_department는 별도로 라벨링하지
않았습니다 — BusinessCategory가 조직도상 부서와 1:1로 고정 매핑되므로
(3.3절), gold_category로부터 rule_engine.apply_rules()를 그대로
재사용해 정답 부서를 유도할 수 있었기 때문입니다(평가 로직이 프로덕션
매핑 로직과 항상 같은 소스를 참조하게 되는 부수적 이점도 있음).
분류 정확도 결과 (89개 전체, scripts/eval_classification_accuracy.py):
| 분류 항목 | 정확도 |
|---|---|
| 유형(inquiry type) | 80.9% (72/89) |
| 업무영역(category) | 86.5% (77/89) |
| 도메인(admin/technical) | 97.8% (87/89) |
| 담당부서(department, category로부터 유도) | 86.5% (77/89) |
답변적합도 결과 (89개 전체 파이프라인 실행, scripts/eval_answer_suitability.py):
| 지표 | 결과 |
|---|---|
| 확신도 sufficient 비율 | 84.3% (75/89) |
정답(gold answer) 대비 정확성 (신규 ANSWER_CORRECTNESS_SYSTEM 채점) |
95.0% (76/80, 9건 채점제외) |
실측 과정에서 발견·해결한 두 가지 문제(상세 과정은 DECISION_LOG.md
BB절 참고):
- 도메인분류 편향 발견 및 수정: 초회 측정(도메인 87.6%)의 불일치
항목을 살펴보니 "CT 2차를 열어놓으면 왜 위험한가요?"류의 구체적
전문용어·수치 질문이 technical→admin으로 안정적으로(반복실행 10/10)
오분류되는 패턴을 확인. 원인은 ①
classify_domain()이 분류 대신 질문에 실제로 답변(설명)해버려 JSON 파싱이 실패하고, ②그 폴백으로 쓰이는 키워드 목록에CT,절연저항,접지저항,활선,IEEE,THD등이 없어 기본값(admin)으로 떨어지는 2단계 실패였음. 프롬프트에 "설명하지 말고 분류만 하라"는 명시적 지시를 추가하고 키워드 목록을 보강해 도메인 정확도가 87.6%→97.8%로 개선되었습니다. - rate limit으로 인한 일시적 대량 실패 발견 및 해결: 위 수정 재검증
과정에서 카테고리·부서 정확도가 86.5%→34.8%로 급락하는 현상이 발생.
코드 버그로 보였으나, 원본 오류 로그를 직접 노출시켜 확인한 결과
42901 Too many requests: rate exceeded(CLOVA API 분당 요청한도)임을 확정. 89개를 쉬지 않고 순회하며 문항당 2회 호출(도메인+메인분류)하는 구조가 한도를 초과시킨 것으로, 즉시 재시도로는 해결되지 않아(한도 회복에 시간이 필요) 요청 간 대기시간과 오류 유형별(rate limit이면 길게, 그 외 일시오류는 짧게) 차등 backoff 재시도를 추가해 해결 —재검증 결과 의도한 지표(도메인)만 개선되고 나머지는 회귀 없이 원래 수치로 복귀함을 확인.
| 권한 | 가능한 동작 |
|---|---|
| general | 문의 등록, 내 문의 조회 |
| staff | + 자기 부서 검토대기 큐 조회, 답변 승인, 부서변경 요청 |
| master | + 전체 부서 큐 조회, 부서/우선순위 실제 재배정 |
staff 계정은 가입 시 항상 general이며, role·부서 승격은 운영자가 SQL로 직접 부여합니다(자기 승격 방지).
각 staff 계정은 배정된 부서로 들어온 문의만 봅니다. master는 전체 부서를 다 봅니다. (실사용 테스트 중 이 필터링이 아예 빠져있던 것을 발견해 추가한 기능 — User 모델에 department가 없어 모든 staff가 전체 큐를 보고 있었던 버그였습니다.)
실제 부서 재배정 권한은 master에게만 있습니다. staff는 "이 문의는 우리
부서가 아닌 것 같다"는 요청만 남기고(PATCH /request-department-change),
master가 검토대기 큐에서 그 표시를 보고 실제 조치(PATCH /rule)하면
요청 표시는 자동으로 해소됩니다.
멘토링 피드백을 반영해, 긴급 민원도 RAG를 그대로 통과해 초안이 생성되도록 재설계했습니다. "긴급"이라는 신호는 라우팅(RAG를 건너뛸지)이 아니라 담당자 화면에서 얼마나 먼저 보이는지(검토대기 큐 최상단 고정)에만 반영됩니다 — 진짜 재난이면 초안이 있어도 대응 속도에 영향이 없고, 격한 어투일 뿐인 일반 민원이면 초안 생성의 혜택을 받아야 하기 때문입니다.
생성된 답변이 근거 문서와 실제로 일치하는지(hallucination_grade), 질문에 적합한지(answer_grade)를 각각 독립적으로 채점합니다. 재시도(최대 2회)를 다 써도 통과하지 못하면, 답변을 숨기지 않고 확신도(sufficient/partial/ insufficient)를 낮춰 담당자에게 신호를 줍니다.
파이프라인의 순차 검증 단계(doc_grade→hallucination_grade→answer_grade)가
전부 같은 모델이면, 그 모델 고유의 편향·사각지대를 이중검증에서도 똑같이
놓칠 수 있습니다("같은 사람이 자기 글을 두 번 검토하는" 구조적 한계). 이를
보완하기 위해 hallucination_grade 하나만 별도 모델(Upstage Solar)로 전환할
수 있게 만들었습니다(HALLUCINATION_GRADER_PROVIDER 환경변수, 기본값은
CLOVA로 기존과 동일).
이 과정에서 CLOVA와 정반대 방향의 흥미로운 버그를 발견했습니다 — CLOVA는
"설명부터 쓰고 JSON을 나중에" 내놓는 경향이 있었는데, Solar는 반대로
"JSON을 먼저 내놓고 그 뒤에 친절하게 설명을 덧붙이는" 경향이 있어, 둘 다
순수 JSON 파싱을 깨뜨렸습니다. classify_domain()에서 검증된 정규식 구제
패턴을 재사용해 해결했으며, grade_answer가 공유하는 헬퍼 함수에는 영향이
가지 않도록 옵트인 파라미터로 분리했습니다.
25개(A~E 5카테고리, 완전일치/수치왜곡/근거없는추가/무관한근거/부분일치)
비교 테스트 결과 CLOVA와 Solar가 완전히 동일한 정확도를 보여, 이 테스트셋
으로는 두 모델의 실질적 차이를 가려내지 못했습니다(왜곡 정도가 두 모델 다
놓치기 어려울 만큼 뚜렷하게 설계된 탓으로 추정). 시간 관계상 더 미묘한
테스트셋 반복 대신, 현재는 Solar를 유지한 채 실사용(Langfuse) 모니터링으로
전환했습니다 — 상세 진행 과정은 DECISION_LOG.md X절 참고.
담당자가 검토 화면에서 "왜 이 문서를 선택했어?", "확신도가 왜 이렇게
나왔어?" 같은 질문을 실시간으로 물어볼 수 있는 기능입니다. selected
필드(6.2절)를 API로 노출만 하고 끝났던 것을, 담당자가 실제로 활용할 수
있는 형태로 완성한 것입니다.
v1 범위를 의도적으로 좁혔습니다: 이미 저장된 데이터(원본문의·분류·검색된 문서·선택여부·생성답변)를 설명하는 것만 지원하고, "비슷한 사례 찾아줘"처럼 새로운 검색이 필요한 질문은 정해진 문구로 정직하게 거절하도록 프롬프트에 명시했습니다 — 근거 없이 답을 지어내는 것을 막기 위한 안전장치입니다.
응답은 SSE(Server-Sent Events)로 스트리밍됩니다. 이 과정에서 CLOVA
Studio의 실제 스트리밍 형식(event:token으로 조각이 오고, 스트림 종료 시
event:result로 전체 답변을 한 번 더 통째로 재전송하는 구조)을 실측으로
처음 확인했고, 이를 무시하면 답변이 중복으로 붙는 문제가 있어 이벤트
종류별로 분기 처리했습니다.
배선 전 10개 질문(정상범위 6개+범위이탈 4개)을 5회씩 반복 검증해, 응답
안정성과 "컨텍스트에 없는 문서번호를 지어내지 않는지"(1차 시도에서 실제로
발견된 문제)를 확인한 뒤 반영했습니다 — 상세 과정은 DECISION_LOG.md Y절
참고.
매일 자정(KST 기준 오전 8시) GitHub Actions 스케줄로 실행되어, 최근 24시간 Langfuse 트레이스를 집계하고 이상 패턴을 이메일로 보고합니다. 이번 세션 내내 사람이 직접 해온 "실측 → 진단" 작업을 배치로 자동화한 것입니다.
"판단"과 "설명"을 명확히 분리했습니다: "이 문제가 특정 카테고리에
국한된 것인지, 전반적으로 광범위한 것인지"는 임계값(카테고리당 재시도
3건 이상 등) 비교로 코드가 결정론적으로 확정하고, LLM은 그 판정을
뒤집을 수 없이 그대로 받아들여 왜 그런 판정이 나왔는지 자연스러운 문장으로
설명하는 역할만 합니다 — 파일럿 검증 중 "이 기준을 프롬프트 문장으로만
맡기면 안정적으로 지켜지지 않는다"는 것을 실제로 확인하고 반영한
설계입니다. 문의가 없었던 날(예: 주말)은 빈 리포트를 보내지 않고 발송을
건너뜁니다. 상세 과정은 DECISION_LOG.md Z절 참고.
POST /api/auth/register
POST /api/auth/login → JWT 발급 (role, department 포함)
POST /api/inquiry 문의 등록 (로그인 필수)
GET /api/inquiry/my 내 문의 목록
GET /api/inquiry/my/{id}
GET /api/inquiry/pending 검토 대기 큐 (staff: 자기부서만 / master: 전체)
PATCH /api/inquiry/{id}/review 검토·승인 (staff+)
PATCH /api/inquiry/{id}/request-department-change 부서변경 요청 (staff+)
PATCH /api/inquiry/{id}/rule 부서/우선순위 실제 재배정 (master만)
POST /api/inquiry/{id}/copilot 담당자 코파일럿 (SSE 스트리밍, staff+, 자기부서만)
GET /health
- 판단 하나당 LLM 호출 하나 — type/category/domain/urgency를 한 프롬프트에 몰아넣지 않고 독립 호출로 분리. 여러 판단을 한 프롬프트에서 하면 서로 간섭한다는 것을 실제로 겪었습니다(도메인 분류 분리, check_urgency 분리).
- 모든 구조적 개선은 골든셋 실측 전까지 반영하지 않는다 — "논리적으로 타당해 보인다"만으로는 프로덕션에 반영하지 않고, 항상 비교 측정(스위치로 on/off 비교)을 거쳤습니다. 실측 결과 하이브리드 검색은 기각, 동적 top_k는 승격— 같은 원칙을 일관되게 적용한 결과가 서로 다른 결론으로 나타난 사례.
- 매직넘버 대신 데이터로 산출한 값 — min_k=6, 도메인분류 예시, 프로즈 보강 대상 등을 감이 아니라 실패사례 분석·원본데이터 비율로 결정했습니다.
- CLOVA의 "설명부터 하는 습성"에 대한 방어적 파싱 — 여러 채점기에서 "JSON만 출력하라"는 지시를 무시하고 설명글을 먼저 쓰는 경우가 반복 확인되어, 순수 JSON 파싱 실패 시 정규식으로 재추출하는 다단계 방어 로직을 표준 패턴으로 정착시켰습니다.
- 국소 기능은 배선 전에 단독 검증 — 파이프라인에 실제로 연결하기 전, 그 기능만 저비용으로 먼저 골든셋에 돌려 부작용을 확인하는 순서를 지켰습니다(복합질문 분해가 대표적 사례).
- 비용보다 정확도 우선, 단 실측 후 결정 — 재시도·개별선별 등으로 LLM 호출이 늘어나는 것은, 회귀 없이 정확도가 개선된다는 게 실측으로 확인된 경우에만 감수했습니다.
- 같은 모델의 이중검증은 편향을 못 잡는다 — 순차 검증 단계가 전부 같은 모델이면 그 모델의 사각지대를 놓칠 수 있다는 우려로, 검증 게이트 하나를 별도 모델(Upstage Solar)로 시험 전환. 골든셋으로는 차이가 안 드러났지만, "실측했는데 차이가 없었다"는 것도 유효한 결론으로 기록하고 다음(실사용 모니터링)으로 넘겼습니다 — 결론이 안 나온다고 검증을 생략하지 않습니다.
- 외부 API의 일시적 실패는 유형을 구분해서 재시도한다 — 모든 오류를 똑같이 "1회 재시도"로 처리하지 않고, rate limit처럼 회복에 시간이 필요한 오류는 더 길게 대기 후 재시도하고, 그 외 일시적 오류는 짧게 재시도하도록 구분했습니다. 오류 원문 메시지를 직접 확인하지 않고 "재시도 횟수를 늘리는 것"만으로는 해결되지 않는다는 것을 실측으로 확인한 뒤 반영한 원칙입니다.
전체 의사결정 과정과 실패·재현 사례는 DECISION_LOG.md에 A~BB절로 상세히
기록되어 있습니다.
AiAn/
├── BE/
│ ├── main.py # FastAPI 진입점
│ ├── api/
│ │ ├── inquiry.py # 문의 처리 라우터
│ │ ├── pipeline.py # LangGraph 파이프라인 (14개 노드)
│ │ └── auth.py # 회원가입/로그인
│ ├── core/
│ │ ├── config.py
│ │ ├── schemas.py
│ │ ├── auth.py # JWT/bcrypt
│ │ └── deps.py # RBAC 의존성
│ ├── rules/rule_engine.py # category→부서·우선순위 매핑
│ └── db/ # SQLAlchemy 모델·CRUD
├── AI/
│ ├── llm.py # CLOVA/Upstage 호출 래퍼 (provider 분기, 스트리밍 포함)
│ ├── embedder.py # 로컬 임베딩
│ ├── prompts.py # 분류·생성·채점·코파일럿 프롬프트 전체
│ ├── copilot.py # 담당자 코파일럿 컨텍스트 조합
│ ├── ops_monitor.py # Ops 모니터링 - 트레이스 집계·패턴판정·리포트
│ ├── classifier/classifier.py # 분류(type/category) + 독립 도메인분류 + rate-limit 대응 재시도
│ └── rag/
│ ├── ingest.py # 지식베이스 → 벡터DB
│ ├── retriever.py # 벡터/하이브리드/동적top_k
│ ├── bm25_index.py # 경량 BM25 인덱스
│ ├── generator.py
│ └── grading.py # 각 채점기(all-or-nothing/개별선별 등)
├── data/
│ ├── knowledge_base/{admin,technical}/ # markdown 지식베이스 원본
│ ├── vector_db/ # ChromaDB 저장소
│ ├── golden_dataset_*.csv # 골든셋 (phase1/phase2/bias_probe, gold_type/gold_category 라벨 포함)
│ └── hallucination_test_set.csv # CLOVA/Solar 이중검증 비교용 (25개)
├── scripts/ # 평가·진단 스크립트 모음
├── docs/midterm_presentation_materials.md # 중간발표 참고제출물(체크포인트 매핑 포함)
├── .github/workflows/ # GitHub Actions (백엔드/프론트 배포, Ops 모니터링 스케줄, 테스트)
├── DECISION_LOG.md # 전체 의사결정 기록 (A~BB절)
└── API_CHANGES_*.md # 프론트 전달용 API 변경분
source .venv/bin/activate
python3 -m AI.rag.ingest # 지식베이스 → 벡터DB (최초 1회 또는 문서 변경 시)
python3 -m uvicorn BE.main:app --reload --reload-dir AI --reload-dir BE --reload-exclude ".venv/*"주요 환경변수(모두 기본값으로 안전하게 동작, 실험 시에만 전환):
| 변수 | 기본값 | 설명 |
|---|---|---|
RAG_USE_HYBRID |
0 |
BM25+RRF 하이브리드 검색 (실측상 비권장) |
RAG_DYNAMIC_TOPK |
1 |
엘보우+min_k 동적 검색개수 |
RAG_DOC_GRADE_MODE |
individual |
문서 개별선별 모드 |
HALLUCINATION_GRADER_PROVIDER |
clova |
hallucination_grade 전용 LLM 공급자(clova/upstage) |
HALLUCINATION_GRADER_MODEL |
HCX-005 |
위 provider가 upstage일 때 실제 Solar 모델명으로 설정 필요 |
계정 승격(가입은 항상 general):
UPDATE users SET role='STAFF', department='연구원' WHERE email='...';
UPDATE users SET role='MASTER' WHERE email='...';평가 스크립트 실행 (체크포인트4 관련):
python3 -m scripts.eval_classification_accuracy # 분류 정확도 (유형/업무영역/도메인/부서)
python3 -m scripts.eval_answer_suitability # 답변적합도 (확신도분포/정답대비정확성)3-tier 구조로 배포했습니다 — 표현(프론트엔드) / 애플리케이션(백엔드, 로드밸런서+Auto Scaling) / 데이터(Supabase) 계층이 독립적으로 분리되어 있고, 프론트엔드가 데이터 계층에 직접 접근하는 경로는 없습니다(항상 백엔드를 거침).
[인터넷]
│
┌────────┴────────┐
▼ ▼
[프론트엔드, Public] [Load Balancer, Public]
(nginx, 정적 SPA) │
[Target Group / Health Check: GET /health]
│
┌─────────────┴─────────────┐
▼ ▼
[백엔드 서버 (Private)] ... [Auto Scaling Group이 관리]
(min=1, desired=2, max=4)
│
▼
[NAT Gateway] ──→ 인터넷 ──→ [Supabase / CLOVA / Upstage]
[Bastion, Public] ── SSH 전용 중계 ──→ 백엔드 서버들(Private, 공인IP 없음)
- 표현 계층: 프론트엔드(Vite+React,
npm run build정적 산출물을 nginx로 서빙) — Public 서브넷, 공인IP 보유 - 애플리케이션 계층: 백엔드(FastAPI) — Private 서브넷(공인IP 없음), Load Balancer를 통해서만 트래픽을 받음. Auto Scaling Group으로 관리되어 Target Group에 자동 등록/해제됨
- 데이터 계층: Supabase(관리형 Postgres, 외부) — 백엔드만 접근, 외부에서 직접 노출되지 않음
VPC 하나(aian, 10.100.0.0/16) 아래 용도별로 서브넷을 분리했습니다:
| 서브넷 | 용도 | Public/Private |
|---|---|---|
aian-sub |
프론트엔드, Bastion | Public |
aian-private-sub |
백엔드(Auto Scaling Group) | Private |
aian-natgw-sub |
NAT Gateway 전용 | Public |
aian-lb-sub |
Load Balancer 전용 | Public |
NCP는 NAT Gateway·Load Balancer가 각각 전용 서브넷을 요구합니다(일반 서버와 같은 서브넷에 두면 동작하지 않음) — 처음엔 이걸 몰라 서브넷을 2개(Public/Private)로만 설계했다가, 실제로는 4개가 필요하다는 것을 진행 중에 발견해 추가했습니다.
백엔드는 Supabase·CLOVA Studio·Upstage Solar 등 NCP 외부의 서비스를 반드시
호출해야 합니다. Private 서브넷은 기본적으로 외부와의 아웃바운드 연결도
막혀 있어, 이대로면 백엔드가 뜨긴 해도 DB 연결부터 실패합니다. **NAT
Gateway + 라우팅 테이블(0.0.0.0/0 → NATGW)**을 추가해 "들어오는 연결은
차단되지만 나가는 연결은 허용"되는 구조를 만들었습니다.
백엔드가 전부 Private(공인IP 없음)라 SSH로 직접 들어갈 방법이 없습니다.
Bastion(Public 서브넷의 최소 스펙 서버, SSH 전용)을 하나 두고,
ssh -A(에이전트 포워딩)로 Bastion을 거쳐 내부 서버로 점프하는 방식으로
관리합니다. 백엔드 서버들의 ACG는 "Bastion의 사설IP에서 오는 22번만 허용"
으로 좁혀, 이 경로 외에는 SSH 자체가 원천적으로 막혀 있습니다.
검증이 끝난 백엔드 서버(이미지)를 Launch Configuration으로 등록하고, 기존
Load Balancer의 Target Group에 그대로 연결했습니다 — ASG가 서버를 만들면
자동으로 Target Group에 등록되고 Health Check를 통과하면 트래픽을 받기
시작합니다. 헬스 체크 유형은 "서버"(단순 생사 확인)가 아니라 "로드밸런서"
(GET /health 실제 응답 확인)로 설정했습니다 — 서버는 켜져 있지만 컨테이너가
죽은 상태(11.6절)를 "서버" 기준 헬스체크는 정상으로 오판하기 때문입니다.
최소 용량: 1 기대 용량: 2 최대 용량: 4
CPU 기반 정책: avg_cpu_used_rto >= 60% → +1대 / <= 20% → -1대 (쿨다운 300초)
- 컨테이너가 재부팅 후 자동으로 안 뜸(
TCP CLOSE): 서버 이미지를 복제해 새 인스턴스를 띄우면, OS는 재부팅되지만 Docker Compose가 관리하던 컨테이너는 자동으로 다시 켜지지 않아 Load Balancer Health Check가 계속 실패했습니다.docker-compose.yml의 backend 서비스에restart: unless-stopped를 추가해 해결 — 이 정책이 반영된 이미지로만 이후의 모든 인스턴스(backend-1/2, ASG가 만드는 서버)를 통일했습니다. - NAT Gateway 리소스를 정리하다 실수로 삭제: 위 정책을 적용하기 전
진단 과정에서 NAT Gateway를 지웠는데, 이후 Private 백엔드가 다시
Connection timed out(Supabase 연결 실패)으로 죽는 사고가 있었음.curl --max-time 10 https://api.upstage.ai로 "아웃바운드 자체가 안 되는지"부터 확인해 원인을 좁히고 재생성으로 해결. - Vite 빌드타임 환경변수: 프론트엔드의
VITE_API_BASE(백엔드 절대 URL)는 런타임이 아니라npm run build시점에 정적 JS에 그대로 박힘 — 배포 주소(로드밸런서 IP)가 정해지기 전까지는 프론트엔드 이미지를 완성할 수 없고, 그 주소가 바뀌면 이미지를 반드시 재빌드해야 합니다. - CORS를 환경변수로 분리: 기존엔 로컬 개발 포트(5173/3000)로 하드코딩
되어 있던
allow_origins를CORS_ALLOWED_ORIGINS환경변수로 바꿔, 배포 주소를 코드 수정 없이 추가할 수 있게 함. tsc -b가 프로덕션 빌드에서 처음 잡아낸 타입 에러:npm run dev(Vite 개발서버)는 타입체크를 하지 않아 로컬 개발 중엔 드러나지 않던 에러 (erasableSyntaxOnly위반,useRef초기값 누락)가npm run build(정식 타입체크 포함)에서 처음 발견됨 — 프로덕션 빌드를 실제로 시도해봐야만 드러나는 문제였음.- NCP 리소스의 "생성 시점 고정 설정" 패턴이 ASG에도 있었음: 헬스체크 유형·Target Group 연결이 그룹 생성 이후 "수정"으로는 반영되지 않아, 그룹을 새로 만들어 해결(11.7절, VPC/Subnet·Launch Configuration에서 겪은 것과 동일한 패턴).
Auto Scaling Group에 "min/desired/max 고정값"만이 아니라, 실제 CPU
사용률에 따라 자동으로 늘고 줄어드는 정책을 연결하고, stress-ng로 실제
부하를 걸어 검증했습니다.
구성: Cloud Insight로 avg_cpu_used_rto 지표를 감시해, 60% 이상이
1분 지속되면 scale-out-cpu(+1대), 20% 이하가 1분 지속되면
scale-in-cpu(-1대) 정책이 실행되도록 알람 규칙 2개를 연결했습니다
(쿨다운 300초). 임계값은 "위험해진 뒤"가 아니라 "새 서버가 준비되는
시간(수십 초1분)을 감안해 미리 대응"하는 것이 목적이라, 통상적으로 쓰이는
6070% 선을 채택했습니다.
겪은 문제 — "헬스체크 유형·Target Group 연결은 생성 시점에만 정할 수 있다": 최신 코드를 반영한 새 이미지로 Launch Configuration을 교체하는 과정에서, ASG 그룹의 헬스체크 유형(서버/로드밸런서)과 Target Group 연결이 "수정"으로는 바뀌지 않는다는 것을 뒤늦게 발견했습니다. 결국 ASG 그룹 자체를 새로 만들고, 그 안에서 Scaling 정책 2개를 재생성하고, 기존 Cloud Insight 알람의 "액션"만 새 그룹의 새 정책으로 다시 연결하는 방식으로 해결했습니다.
부하테스트 결과: 두 백엔드 서버 모두에 stress-ng --cpu 2로 동시에
부하를 걸어 실제로 스케일아웃(2대→3대)이 발동하는 것을 확인했고, 부하를
멈춘 뒤 스케일인(3대→2대)까지 정상 확인했습니다.
부수적으로 확인된 것 — 장애 시 fallback 메커니즘의 실전 작동: 부하
테스트 도중 실제로 CLOVA Studio가 50000 Internal server error를
반환한 사례가 발생했는데, 시스템이 완전히 실패하지 않고 "자동 답변
생성 실패"임을 답변에 투명하게 명시한 채, 검색된 근거 문서를 그대로
인용한 대체 답변을 확신도(partial)를 낮춰서 제공하는 것을 실제
운영 로그로 확인했습니다(NFR-08). 재질의 시 정상 응답을 확인해 일시적
장애였음을 확인 — 부하테스트가 의도치 않게 장애 대응 로직의 실전 검증
기회가 되었습니다.
- 복합질문(하나의 문의에 여러 주제): 정답청크가 2개인 질문 중 일부는 하나만 찾는 경우가 있음. 질문 분해(decompose_query) 기능을 구현해봤으나 단일질문 오분해율이 53.6%로 실사용에 반영하기엔 위험이 커 폐기.
- 콘텐츠 경계 모호 사례: "사고 책임" 같은 주제는 admin·technical 양쪽 지식베이스에 답이 있을 수 있어, 도메인 분류기의 결함이 아니라 콘텐츠 재구성이 필요한 영역으로 별도 관리.
- 법령 원문 미확보: 현재 지식베이스는 Q&A 답변 속에 조문이 요약 인용된 형태만 있고, 조문 전체 원문 컬렉션은 외부자료 확보 후 추가 예정.
- CLOVA/Solar 이중검증 결론 미확정: 골든셋 25개로는 두 모델의 실질적 차이가 드러나지 않음. 더 미묘한 테스트셋 반복 대신, 실사용 중 Langfuse로 두 모델의 판단이 갈리는 사례를 관찰하며 판단할 예정.
- 배포 자동화(SSH 방식)는 보류: 서버 배포용 GitHub Actions 워크플로
(
deploy-backend.yml/deploy-frontend.yml)를 시도했으나 SSH 키 인증 문제로 원인 확인이 지연되어, 현재는 서버마다 직접 접속해git pull+docker compose up --build를 수동으로 반복. 반면 SSH가 필요 없는 배치 작업(Ops 모니터링 리포트, pytest 회귀테스트)은 GitHub Actions 스케줄/트리거로 실제 자동화에 성공했습니다. - 유형(type)·업무영역(category) 분류 정확도의 잔여 격차(80.9%/86.5%): 남은 불일치의 상당수는 "경력인증 vs 신청", "기술지원 vs 전기안전관리자" 처럼 카테고리 정의 자체가 경계적인 사례로 확인됨(5.4절) — 모델 결함이 아니라 카테고리 체계 자체의 특성으로 판단, 필요 시 카테고리 정의를 더 세분화하는 것을 다음 과제로 남김.