# 로컬 LLM 채팅 + 지식 증강 (RAG) 플랜 작성 시점: 2026-05-12 대상: 탭 #13 로컬 LLM 채팅 + 신규 탭 #14 RAG 관리 --- ## 0. 배경 및 목적 운전원이 채팅 UI에서 자연어로 "공장 상황 보고 / 계기 상태 / 현장 재고 / 고장 이력" 등을 질문하고, 시스템이 다음 두 가지 소스를 합성해 답한다. 1. **실시간/이력 데이터** — PostgreSQL(history_table, realtime_table, event_history_table, tag_metadata, node_map_master) 2. **사용자 지식 베이스(RAG)** — 관리자가 첨부한 문서(엑셀, 워드, PDF, MD, TXT 등)에서 추출한 청크를 Qdrant에 인덱스, 의미 검색으로 활용 본 문서는 두 갈래를 통합한 **컨셉 합의 결과**와 **구현 계획**을 기록한다. 코드는 골격/예시 위주이며, 실제 구현은 본 플랜을 기준으로 단계별 진행한다. --- ## 1. 현 상태 검토 요약 ### 1.1 잘 짜여 있는 부분 - `OllamaController` — Ollama / vLLM 이중 백엔드, SSE 스트리밍, MCP 툴콜 루프(최대 10라운드), `tool_calls` API 미지원 모델용 JSON 텍스트 폴백 추출(`ExtractFirstJsonObject`). - `app.js` 채팅 UI — localStorage 세션 관리, 모델/툴 토글, 중단 버튼, 코드블록 포맷팅. - MCP 툴셋(`mcp-server/server.py`) — `run_sql`, `query_pv_history`, `get_tag_metadata`, `list_drawings`, `query_with_nl`, RAG 3종(`search_codebase`, `search_r530_docs`, `rag_query`, `ask_iiot_llm`). - 임베딩·OCR·PDF/DXF 추출 인프라가 이미 존재(`_embed`, `_ocr`, `_extract_text_from_pdf`, `_extract_text_from_pdf_ocr`, `_extract_text_from_dxf`). ### 1.2 "공장 상황 보고" 사용 대비 격차 | # | 격차 | 비고 | |---|---|---| | G1 | 이벤트/알람 질의 도구 부재 | event_history_table은 적재만 됨, MCP 노출 X | | G2 | "보고서" 합성 도구 부재 | 모델이 멀티 툴을 알아서 호출해야 함 | | G3 | 현장 재고 데이터 자체 없음 | 별도 자료 수급 또는 RAG로 흡수 | | G4 | 태그 의미 → 태그명 시맨틱 검색 부재 | `get_tag_metadata`는 패턴 매칭만 | | G5 | 시스템 프롬프트 빈약(영어 1줄 하드코딩) | 플랜트 용어집·계기 prefix·예시 미주입 | | G6 | 툴 결과가 raw JSON으로 버블에 박힘 | 표/차트 렌더링 없음, 실행된 SQL 미노출 | | G7 | 툴 실행 중 UX 공백 | tool_calls 1라운드 비스트리밍 → 침묵 | | G8 | SQL 안전장치 약함 | run_sql LIMIT/타임아웃 없음 | | G9 | 장기 대화 컨텍스트 관리 없음 | 매 턴 전체 messages 전송 | | G10 | 추천 질문/빠른 액션 없음 | 빈 화면 진입 장벽 | | G11 | 사용자 지식 베이스(KB) 부재 | 본 플랜의 핵심 추가 | ### 1.3 발견된 자잘한 결함 - `mcp-server/worker/nl2sql_worker.py:244` — `time_bucket('1 min', ts)` 사용. `realtime_table`에는 `ts` 컬럼이 없고 시계열은 `history_table.recorded_at`. 호출 시 깨짐. 본체 `server.py`는 정상. - `mcp-server/llm-model.json` — `"Qwen3.6-27B-FP8"`. 실제 vLLM 서빙 모델명과 동기화 확인 필요 (메모리에는 Qwen3-Coder-Next-FP8 운영 기록). - `OllamaController.cs:608` — 시스템 프롬프트 하드코딩 영어 문자열. 한글 + plant_context 합성으로 교체 권장. --- ## 2. 합의된 결정 사항 (체크리스트) 채팅 전 합의 과정에서 확정된 항목. ### 2.1 채팅 페이지 개선 방향 (별도 사용자 결정 대기 항목 있음) | # | 항목 | 합의 | |---|---|---| | C1 | 추천 질문 칩(welcome 화면) | 추천 — 구현 예정 | | C2 | plant_context.md 시스템 프롬프트 주입 | 추천 — 구현 예정 | | C3 | event MCP 툴 3종 (`query_events`/`summarize_events`/`active_alarms`) | 추천 — 구현 예정 | | C4 | SSE `tool_start`/`tool_result` 이벤트 + UI 가시화 | 추천 — 구현 예정 | | C5 | 테이블/시계열 자동 렌더링 | 추천 — 구현 예정 | | C6 | `generate_status_report` 매크로 툴 | 추천 — 구현 예정 | | C7 | 태그 시맨틱 검색(`find_tags`) | 추천 — 구현 예정 | | C8 | SQL 안전장치 (LIMIT 자동/statement_timeout) | 추천 — 구현 예정 | | C9 | NL2SQL 의도 라우터 | 검토 후 결정 | | C10 | 대화 요약/압축 | 후순위 | | C11 | 에이전트 모드(자율 멀티스텝) | 후순위 | | C12 | 위 1.3 결함 픽스 | 즉시 진행 | ### 2.2 지식 증강(RAG ingest) 결정 | # | 항목 | 결정 | |---|---|---| | Q1 | ingest 권한 | **관리자만** | | Q2 | 컬렉션 구조 | **doc_type별 분리 컬렉션** (마스터 사전 정의 + 자유 태그 세분화) | | Q3 | 관리자 권한 | **비번 인증** (해시 + 솔트, 세션 토큰 60분) | | Q4 | 출처 인용 | **위치(시트/페이지/헤딩) + 다운로드 링크까지** | | Q5 | 엑셀 청킹 | **행 단위 + 시트 단위 둘 다 저장** | | Q6 | ingest 트리거 | **첨부 버튼 (관리 탭 안)** — 채팅창에는 첨부 없음 | | Q7 | 처리 방식 | **전 시스템 비동기 큐** | | R | 재업로드 정책 | **누적** (버전 관리 없음, 시간순 누적) | | N1 | 카테고리 운용 | **마스터 사전 정의(5종) + 자유 태그 보조** | | N2 | PDF 청킹 | **섹션 + 표 별도 추출**, OCR PDF는 페이지 단위 fallback | | N3 | 누적 잡음 완화 | **검색 시 최신 가중치 + 관리탭 일괄 비활성화 버튼** | | N4 | 다운로드 인증 | **누구나 다운로드 가능** | | M1 | 저장소 cleanup | **수동만** — 관리탭 "오래된 비활성화 일괄 영구삭제" 버튼만 제공 | | M2 | 임베딩 모델 | **추천 채택** — BGE-M3 또는 multilingual-e5-large (한국어 품질 우선) | | M3 | 비번 저장 | **해시(PBKDF2/Argon2) + 솔트**, 초기 비번은 환경변수 또는 콘솔 출력, 관리탭에서 변경 | --- ## 3. 통합 시스템 설계 ### 3.1 전체 아키텍처 ``` ┌─────────────────────────┐ │ 채팅 탭 #13 (운전원) │ │ - 추천 질문 칩 │ │ - 스트리밍 + 툴 가시화 │ │ - 테이블/차트 자동 렌더 │ └──────────┬──────────────┘ │ /api/ollama/vllm/chat/stream ▼ ┌──────────────────────────┐ │ OllamaController │ │ - tool_calls 루프 │ │ - SSE 이벤트 발행 │ └──────────┬───────────────┘ │ MCP tool call ▼ ┌──────────────────────────────────────────────────────┐ │ MCP Server (server.py) │ │ ┌────────────┬─────────────┬──────────────────────┐ │ │ │ DB 도구 │ KB 도구 │ R530 docs / Code │ │ │ │ run_sql │ search_kb │ search_r530_docs │ │ │ │ q_pv_hist │ parse_doc │ search_codebase │ │ │ │ events* │ ... │ ... │ │ │ └────────────┴─────────────┴──────────────────────┘ │ └──────┬─────────────────┬──────────────────────┬──────┘ │ │ │ ▼ ▼ ▼ PostgreSQL Qdrant collections Filesystem (시계열 + KB 메타) (kb_* 5개 + 기존 2개) (storage/kb/) ┌─────────────────────────┐ │ RAG 관리 탭 #14 (관리자) │ │ - 비번 인증 │ │ - 업로드 (드래그앤드롭) │ │ - 문서 목록/상태/삭제 │ │ - 진행률 폴링 │ └──────────┬──────────────┘ │ /api/kb/* ▼ ┌──────────────────────────┐ │ KbController │ │ + KbIngestWorker │ │ (BackgroundService) │ └──────────────────────────┘ ``` ### 3.2 시드 카테고리 (kb_collections 초기 데이터) | `collection_key` | 표시명 | 청킹 정책 (chunking_policy JSONB) | 대표 자료 | |---|---|---|---| | `system_instrument` | 시스템 & 계기 정보 | `{"pdf":"section+table","xlsx":"row+sheet","docx":"heading"}` | 계기 datasheet, P&ID, 사양서, 노드맵 | | `plant_operation` | 공장 운전 정보 | `{"xlsx":"row","docx":"heading","md":"heading"}` | 재고, 생산현황, 고장이력, 교대일지 | | `procedure` | 절차서/SOP | `{"docx":"heading","md":"heading","pdf":"section"}` | SOP, 정비 절차, 알람 대응 매뉴얼 | | `report` | 보고서 | `{"pdf":"section+table","docx":"heading"}` | 일/주/월 보고, 사고보고, 분석보고 | | `vendor_doc` | 벤더 자료 | `{"pdf":"section+table","docx":"heading"}` | 카탈로그, 매뉴얼, 인증서 | **공통 보조 태그** (kb_documents.tags, 자유 입력): - `area` — Unit A, Unit B 등 - `equipment` — P-6201, FIC-6113 등 - `date` — 자료 기준일 (YYYY-MM-DD) - `language` — ko / en --- ### 3.3 데이터 모델 (PostgreSQL) ```sql -- 컬렉션 레지스트리 CREATE TABLE kb_collections ( collection_key TEXT PRIMARY KEY, display_name TEXT NOT NULL, qdrant_name TEXT NOT NULL UNIQUE, chunking_policy JSONB NOT NULL, description TEXT, is_active BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW() ); -- 문서 메타 CREATE TABLE kb_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), collection_key TEXT REFERENCES kb_collections, title TEXT NOT NULL, original_path TEXT NOT NULL, -- storage/kb/2026-05/{uuid}.{ext} file_sha256 TEXT NOT NULL, file_size BIGINT, mime_type TEXT, tags TEXT[], status TEXT NOT NULL DEFAULT 'pending', -- pending / parsing / embedding / indexed / failed / disabled chunk_count INT DEFAULT 0, error_message TEXT, uploaded_by TEXT, -- 관리자 세션 식별자 uploaded_at TIMESTAMPTZ DEFAULT NOW(), indexed_at TIMESTAMPTZ, disabled_at TIMESTAMPTZ ); CREATE INDEX idx_kb_docs_coll_status ON kb_documents(collection_key, status, uploaded_at DESC); CREATE INDEX idx_kb_docs_title ON kb_documents(title); -- 비동기 작업 큐 CREATE TABLE kb_ingest_jobs ( id BIGSERIAL PRIMARY KEY, doc_id UUID REFERENCES kb_documents, stage TEXT NOT NULL, -- parse / embed / index attempts INT DEFAULT 0, last_error TEXT, enqueued_at TIMESTAMPTZ DEFAULT NOW(), started_at TIMESTAMPTZ, finished_at TIMESTAMPTZ ); CREATE INDEX idx_kb_jobs_pending ON kb_ingest_jobs(stage, finished_at) WHERE finished_at IS NULL; -- 관리자 인증 (단일 행) CREATE TABLE kb_admin_credential ( id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1), password_hash TEXT NOT NULL, -- Argon2 또는 PBKDF2 salt TEXT NOT NULL, algorithm TEXT NOT NULL DEFAULT 'argon2id', updated_at TIMESTAMPTZ DEFAULT NOW() ); -- 관리자 세션 CREATE TABLE kb_admin_sessions ( token TEXT PRIMARY KEY, issued_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, client_ip TEXT ); ``` ### 3.4 Qdrant payload 표준 모든 KB 컬렉션 공통: ```json { "doc_id": "uuid", "collection_key": "plant_operation", "title": "정비이력_2026Q1.xlsx", "chunk_kind": "row | sheet | section | table | page | paragraph", "locator": "sheet=Pump-A; row=12-15", "uploaded_at": "2026-05-12T10:00:00Z", "tags": ["unit-a", "P-6201"] } ``` - `locator` — UI에 그대로 표시될 사람용 위치 문자열 - `uploaded_at` — 검색 시 최신 가중치(decay) 계산용 - `chunk_kind` — 같은 문서에서 행 청크 vs 시트 청크 중복 발생 시 dedup/우선순위 결정용 --- ### 3.5 API 엔드포인트 (.NET) ``` [관리자 인증] POST /api/kb/auth/login {password} → {token, expiresAt} POST /api/kb/auth/logout header: X-Kb-Token GET /api/kb/auth/status header: X-Kb-Token → {valid, expiresAt} POST /api/kb/auth/change-password {oldPassword, newPassword} [컬렉션 — 누구나 조회 가능] GET /api/kb/collections → [{key, name, chunkCount, ...}] [업로드 / 관리 — admin only] POST /api/kb/upload multipart → {docId, status:"pending"} fields: file, collectionKey, title?, tags[]? GET /api/kb/documents?collection=&status=&q=&page= → 페이지네이션 목록 GET /api/kb/documents/{id} → 상세 + chunks 미리보기(상위 N) DELETE /api/kb/documents/{id} → Qdrant + storage 동시 정리 POST /api/kb/documents/{id}/reindex → status 초기화 + 재큐 POST /api/kb/documents/{id}/disable → status='disabled' POST /api/kb/documents/bulk-disable {title} → 동일 제목 일괄 비활성화 POST /api/kb/documents/purge-disabled {olderThanDays?} → 비활성화 영구삭제(수동) GET /api/kb/jobs?status=&docId= → 진행 중/실패 작업 [다운로드 — 누구나] GET /api/kb/download/{docId} → 원본 스트림 (Content-Disposition) ``` ### 3.6 MCP 측 신규/변경 도구 (server.py) ```python # 신규 — KB 인덱싱 (워커 전용, 채팅 노출 X) parse_document(doc_id: str) -> dict """형식별 청킹. xlsx는 행+시트 둘 다, pdf는 섹션+표 별도, OCR PDF는 페이지 fallback.""" # 신규 — KB 검색 (채팅 노출 O) search_kb(query: str, collection_keys: list[str] = None, # None이면 전체 top_k: int = 8, tags: list[str] = None, since: str = None, # ISO date — 이후 업로드된 것만 boost_recent: bool = True) -> str """다중 컬렉션 의미 검색. uploaded_at 기반 최신 가중치 적용.""" # 신규 — 이벤트/알람 query_events(time_from: str, time_to: str, severity: str = None, area: str = None) -> str summarize_events(time_window: str = "24h") -> str active_alarms() -> str # 신규 — 태그 시맨틱 검색 find_tags(query: str, top_k: int = 10) -> str """tag_metadata.value(desc, area) 벡터 인덱스 사용. '냉각수 펌프 토출' 같은 표현 지원.""" # 신규 — 보고서 합성 generate_status_report(scope: str = "shift", # shift / daily / event area: str = None) -> str # 통합 — 기존 rag_query 확장 rag_query(question: str, search_code: bool = False, search_docs: bool = True, search_kb: bool = True, # 신규 — KB 통합 kb_collections: list[str] = None) ``` ### 3.7 비동기 파이프라인 ``` [관리자 PC] ── multipart 업로드 ──▶ [.NET KbController] │ (응답 즉시 반환) ├─ storage/kb/{yyyy-mm}/{uuid}.{ext} 저장 ├─ SHA256 계산 ├─ kb_documents INSERT (status='pending') └─ kb_ingest_jobs INSERT (stage='parse') ▼ [KbIngestWorker — BackgroundService] ├─ FOR UPDATE SKIP LOCKED 큐 폴링(2초) ├─ stage='parse' │ └─ MCP parse_document → chunks 목록 반환 ├─ stage='embed' │ └─ MCP _embed 배치 호출 ├─ stage='index' │ └─ Qdrant upsert (collection_key별) ├─ 성공: kb_documents.status='indexed', chunk_count=N └─ 실패: attempts++, ≥3이면 status='failed' ▼ [관리 탭] 1초 폴링으로 진행률 표시 "정비이력_2026Q1.xlsx — 임베딩 중 (43/120)" 완료 시 ✓ 마크 ``` #### 형식별 청킹 디테일 **xlsx (행 + 시트 둘 다)** - 시트 청크: 시트 전체를 Markdown 표로 직렬화 → 1개 chunk per 시트 - 행 청크: 각 행을 `{시트명}: 컬럼1=값1, 컬럼2=값2, …` 한 줄로 직렬화 → N개 chunk - locator: `sheet=Pump-A`, `sheet=Pump-A; row=12` **pdf (섹션 + 표 별도)** - pdfplumber로 헤딩 구조 추출 시도 - 헤딩 추출 성공: 섹션별 chunk + 페이지 내 표만 별도 chunk - 헤딩 추출 실패(OCR 등): 페이지 단위 fallback - locator: `section=Specifications`, `page=5; table=Performance` **docx** - python-docx로 헤딩 트리 추출 → 헤딩 path별 chunk - locator: `heading=5.2 펌프 기동` **md / txt** - `#` 헤딩 기반 splitter (md), 빈 줄 두 개 기반 (txt) - locator: `heading=…` 또는 `paragraph=N` #### 청크 크기 가이드 - 평균 400~800 토큰, 헤딩 단위가 너무 크면 추가 분할 - overlap 100 토큰 --- ### 3.8 채팅 통합 채팅 탭은 **읽기 전용** — 첨부 버튼 없음 (Q6 결정). ``` 사용자 질문 │ ▼ [vLLM tool calling] │ ├─ 의도: 시스템/제품 사양 질문 │ → search_r530_docs + search_kb(['system_instrument','vendor_doc']) │ ├─ 의도: 운전 데이터 질문 │ → query_with_nl (SQL) + search_kb(['plant_operation','procedure']) │ + 필요시 query_events / active_alarms │ └─ 의도: 보고서 작성 → generate_status_report + search_kb(['report']) │ ▼ [SSE 이벤트 발행] event: tool_start data: {name, args} event: tool_result data: {name, ok, summary, rows} event: message data: 모델 출력 토큰 │ ▼ [프론트엔드] ├─ 툴 실행 카드 (접이식) 렌더 ├─ 모델 인용("정비이력_2026Q1.xlsx > 시트:Pump-A > 행 12-15")을 정규식으로 잡아 │ → [정비이력_2026Q1.xlsx 다운로드 ↓] 링크로 치환 └─ 시계열 결과 → mini sparkline, 일반 표 → HTML table ``` --- ### 3.9 관리 탭 #14 UI 스케치 ``` ┌─ 14 RAG 관리 ────────────────────────────────────┐ │ 🔒 관리자 비번 [______________] [로그인] │ │ 세션 만료: 60분 (재로그인 필요) │ ├─────────────────────────────────────────────────│ │ 필터 컬렉션 [▼ 전체] 상태 [▼ indexed] │ │ 태그 [#unit-a #P-6201] │ │ 검색 [제목/태그 검색.................] │ ├─────────────────────────────────────────────────│ │ [📁 파일 업로드] [🔄 새로고침] [비번 변경] │ │ │ │ ┌────────────────────────────────────────────┐ │ │ │ ☐ 제목 컬렉션 chunk 업로드 상태│ │ │ │ ──────────────────────────────────────────│ │ │ │ ☐ 정비이력… 운전 127 05-12 ✓ │ │ │ │ ☐ FIC-6113… 계기 48 05-12 ⏳│ │ │ │ ☐ SOP-펌프… 절차서 23 05-11 ✓ │ │ │ │ ☐ 사양서… 계기 0 05-10 ✗ │ │ │ │ … │ │ │ └────────────────────────────────────────────┘ │ │ [선택삭제] [재인덱스] [일괄 비활성화(동일제목)] │ │ [비활성화 영구삭제(수동)] │ └─────────────────────────────────────────────────┘ ``` **업로드 모달**: - 드래그앤드롭 + 파일 선택 버튼 - `collection_key` 드롭다운 **강제 선택** - 제목(기본: 파일명, 편집 가능) - 태그(자유 입력, 콤마 분리) - 업로드 → 즉시 doc_id 반환 → 모달은 닫히고 목록에 status=pending으로 새 행 등장 --- ### 3.10 보안 / 저장소 관리 / 임베딩 모델 **관리자 비번** - 첫 실행 시 `kb_admin_credential` 비어있으면 환경변수 `KB_ADMIN_INITIAL_PASSWORD`로 초기화, 없으면 콘솔에 랜덤 비번 1회 출력 후 강제 변경 요구. - 알고리즘: Argon2id (또는 PBKDF2-SHA256 100k iter) - 세션 토큰: `Guid.NewGuid().ToString("N")` 또는 RandomNumberGenerator 32바이트, `X-Kb-Token` 헤더로 전달. - 세션 만료 60분, 슬라이딩 갱신 옵션은 추후. **저장소 cleanup (M1 = c 수동만)** - 자동 삭제 잡 없음. - 관리탭에 `[비활성화 영구삭제]` 버튼: `disabled_at < NOW() - INTERVAL '90 days'` 등 옵션 입력 후 일괄 영구삭제(Qdrant + storage). **임베딩 모델 (M2 추천 채택)** - 후보: `BAAI/bge-m3` (멀티링구얼 + 1024차원, dense+sparse 지원) 또는 `intfloat/multilingual-e5-large` (1024차원, 단순 사용 용이). - 점검 액션: 현재 `_embed`가 사용하는 모델/차원 확인 → 한국어 sample 검색 품질 A/B 테스트 후 결정. - 컬렉션을 doc_type별로 새로 만드므로 모델 교체에 따른 마이그레이션 부담은 적음. --- ## 4. 구현 순서 (Todo) ### Phase 0 — 사전 정비 (반나절) 0.1 `mcp-server/worker/nl2sql_worker.py:244` `time_bucket('1 min', ts)` 버그 수정 0.2 `mcp-server/llm-model.json` 모델명을 실제 vLLM 서빙명과 일치 0.3 `OllamaController.cs:608` 시스템 프롬프트 한글화 + plant_context.md 외부 파일화 ### Phase 1 — 데이터 모델 & 인증 (1일) 1.1 PostgreSQL 마이그레이션: `kb_collections`, `kb_documents`, `kb_ingest_jobs`, `kb_admin_credential`, `kb_admin_sessions` 테이블 생성 1.2 시드 데이터 INSERT: kb_collections 5건 (system_instrument, plant_operation, procedure, report, vendor_doc) 1.3 Qdrant 컬렉션 5개 생성 (kb_system_instrument, kb_plant_operation, kb_procedure, kb_report, kb_vendor_doc) — 임베딩 차원에 맞춰 1.4 `KbAuthController` (login/logout/status/change-password) + Argon2 해시 유틸 1.5 첫 실행 시 초기 비번 시드 로직 ### Phase 2 — 업로드 & 비동기 워커 (2일) 2.1 `KbController.Upload` — multipart 수신 → storage 저장 → kb_documents/kb_ingest_jobs INSERT 2.2 `KbIngestWorker (BackgroundService)` — 큐 폴링 + 단계별 처리 2.3 MCP `parse_document` — xlsx (행+시트), pdf (섹션+표), docx, md/txt 2.4 MCP `_embed` 배치 호출 + Qdrant upsert (collection_key 기반 라우팅) 2.5 `KbController.Documents/Jobs` — 목록·상세·진행률 폴링 ### Phase 3 — 관리 탭 #14 (1일) 3.1 사이드바 14번 탭 추가, `
` 신설 3.2 비번 입력 → 토큰 받아 sessionStorage 저장 3.3 컬렉션 필터, 상태/태그 필터, 검색 3.4 업로드 모달(드래그앤드롭 + collection_key 드롭다운 + 태그) 3.5 목록, 상세 보기, 삭제, 재인덱스, 일괄 비활성화, 비활성화 영구삭제 3.6 1초 폴링으로 ingesting 진행률 표시 ### Phase 4 — 다운로드 & 검색 (반나절) 4.1 `/api/kb/download/{docId}` — 원본 스트림, Content-Disposition 4.2 MCP `search_kb` — 다중 컬렉션 + uploaded_at 최신 가중치 + 태그 필터 4.3 기존 `rag_query` 확장: `search_kb` 통합 옵션 ### Phase 5 — 채팅 통합 (1~2일) 5.1 SSE 이벤트 추가: `tool_start`, `tool_result` (백엔드 `VllmChatStreamWithTools` 안) 5.2 프론트 채팅 메시지에 툴 실행 카드 렌더 (접이식) 5.3 모델 인용 자동 → 다운로드 링크 치환 5.4 테이블/시계열 자동 렌더 (`{success, columns, data}` JSON 감지) 5.5 추천 질문 칩(welcome 화면) 5.6 system prompt 합성 로직 (plant_context.md + 도구 가이드 + 사용자 입력) ### Phase 6 — 보강 도구 (1일) 6.1 MCP `query_events`, `summarize_events`, `active_alarms` (event_history_table 기반) 6.2 MCP `find_tags` — tag_metadata 시맨틱 검색 (별도 Qdrant 컬렉션 또는 KB와 통합) 6.3 MCP `generate_status_report` — 매크로 툴 6.4 `run_sql` LIMIT 자동 + `SET LOCAL statement_timeout = 10s` ### Phase 7 — 운영 보강 (옵션) 7.1 NL2SQL 의도 라우터 7.2 대화 요약/압축 (장기 세션) 7.3 에이전트 모드 (자율 멀티스텝 계획) 7.4 KB 청크 미리보기/편집 UI --- ## 5. 영향 받는 파일 목록 (예상) ### 신규 - `plans/LLM채팅+지식증강플랜.md` (본 문서) - `prompts/plant_context.md` (도메인 용어집/계기 prefix/예시) - `src/Web/Controllers/KbController.cs` - `src/Web/Controllers/KbAuthController.cs` - `src/Infrastructure/Kb/KbIngestWorker.cs` (BackgroundService) - `src/Infrastructure/Kb/PasswordHasher.cs` - `src/Core/Domain/Entities/KbEntities.cs` (KbCollection, KbDocument, KbIngestJob, KbAdminCredential, KbAdminSession) - `src/Core/Application/Interfaces/IKbServices.cs` - `mcp-server/parsers/xlsx_parser.py` - `mcp-server/parsers/pdf_parser.py` - `mcp-server/parsers/docx_parser.py` - `mcp-server/parsers/text_parser.py` ### 수정 - `src/Web/Program.cs` — `KbIngestWorker` HostedService 등록, HttpClient 추가 (필요 시) - `src/Web/Controllers/OllamaController.cs` — SSE tool_start/tool_result 이벤트, system prompt 합성 - `src/Web/wwwroot/index.html` — 14번 탭 + pane-kbadmin 섹션, 채팅 추천 질문 칩 - `src/Web/wwwroot/js/app.js` — kbAuth/kbUpload/kbList/kbDelete/kbDownload, 채팅 툴카드 렌더 + 인용 변환 - `src/Web/wwwroot/css/style.css` — KB 관리 스타일, 툴카드 스타일 - `src/Infrastructure/Database/ExperionDbContext.cs` — kb_* DbSet + 마이그레이션 DDL - `mcp-server/server.py` — `parse_document`, `search_kb`, `query_events`, `summarize_events`, `active_alarms`, `find_tags`, `generate_status_report` 추가; `rag_query` 확장 - `mcp-server/worker/nl2sql_worker.py` — time_bucket 버그 픽스 - `mcp-server/llm-model.json` — 모델명 정정 ### 미수정 (영향 없음 명시) - 인증서 관련 (`ExperionCertificateService.cs` 등) — 손대지 않음 - OPC UA 서버/클라이언트 — 손대지 않음 - 기존 nodemap/포인트빌더/이력조회 탭 — 손대지 않음 --- ## 6. 부록 ### 6.1 골격 코드 예시 (참고용, 실제 구현 시 상세 추가) #### Argon2 해시 유틸 (PasswordHasher.cs) ```csharp using Konscious.Security.Cryptography; // NuGet: Konscious.Security.Cryptography.Argon2 using System.Security.Cryptography; using System.Text; public static class PasswordHasher { public static (string Hash, string Salt) Hash(string password) { var saltBytes = RandomNumberGenerator.GetBytes(16); var argon = new Argon2id(Encoding.UTF8.GetBytes(password)) { Salt = saltBytes, DegreeOfParallelism = 4, MemorySize = 65536, Iterations = 3 }; var hashBytes = argon.GetBytes(32); return (Convert.ToBase64String(hashBytes), Convert.ToBase64String(saltBytes)); } public static bool Verify(string password, string hashB64, string saltB64) { var argon = new Argon2id(Encoding.UTF8.GetBytes(password)) { Salt = Convert.FromBase64String(saltB64), DegreeOfParallelism = 4, MemorySize = 65536, Iterations = 3 }; var computed = argon.GetBytes(32); return CryptographicOperations.FixedTimeEquals(computed, Convert.FromBase64String(hashB64)); } } ``` #### xlsx parser 골격 (parsers/xlsx_parser.py) ```python from openpyxl import load_workbook def parse_xlsx(path: str, doc_id: str, title: str) -> list[dict]: """행 단위 + 시트 단위 청크 둘 다 생성.""" wb = load_workbook(path, read_only=True, data_only=True) chunks = [] for sheet in wb.worksheets: rows = list(sheet.iter_rows(values_only=True)) if not rows: continue header = [str(c) if c is not None else "" for c in rows[0]] # (1) 시트 단위 chunk — markdown 표 md_lines = ["| " + " | ".join(header) + " |", "| " + " | ".join(["---"] * len(header)) + " |"] for r in rows[1:]: md_lines.append("| " + " | ".join(str(c) if c is not None else "" for c in r) + " |") chunks.append({ "text": "\n".join(md_lines), "chunk_kind": "sheet", "locator": f"sheet={sheet.title}" }) # (2) 행 단위 chunk for i, r in enumerate(rows[1:], start=2): parts = [f"{header[j]}={r[j]}" for j in range(len(header)) if j < len(r) and r[j] is not None] if not parts: continue chunks.append({ "text": f"{sheet.title}: " + ", ".join(parts), "chunk_kind": "row", "locator": f"sheet={sheet.title}; row={i}" }) return chunks ``` #### SSE tool_start/tool_result 이벤트 (OllamaController.cs 발췌) ```csharp // VllmChatStreamWithTools 안, MCP 호출 직전/직후 await Response.WriteAsync( $"event: tool_start\ndata: {JsonSerializer.Serialize(new {name=funcName, args=funcArgs})}\n\n"); await Response.Body.FlushAsync(); var toolResult = await _mcpClient.CallToolAsync(funcName, args, HttpContext.RequestAborted); // 결과 요약(처음 200자) — 큰 JSON은 전체 전송 대신 요약 var preview = toolResult.Length > 200 ? toolResult.Substring(0, 200) + "..." : toolResult; await Response.WriteAsync( $"event: tool_result\ndata: {JsonSerializer.Serialize(new {name=funcName, ok=true, preview, length=toolResult.Length})}\n\n"); await Response.Body.FlushAsync(); ``` #### 프론트 SSE 처리(app.js 발췌) ```javascript // llmSend 안의 SSE 파싱 루프에서 for (const line of lines) { if (line.startsWith('event: tool_start')) { eventType = 'tool_start'; } else if (line.startsWith('event: tool_result')) { eventType = 'tool_result'; } else if (line.startsWith('data: ')) { eventData = line.slice(6); } } if (eventType === 'tool_start') { const t = JSON.parse(eventData); llmAppendToolCard(t.name, t.args, 'running'); } else if (eventType === 'tool_result') { const t = JSON.parse(eventData); llmUpdateToolCard(t.name, t.preview, t.length, t.ok); } ``` ### 6.2 추천 질문 칩 (welcome 화면 시드) ``` - "지금 활성 알람을 보여줘" - "Unit A의 24시간 운전 상황을 요약해줘" - "FIC-6113.PV 최근 1시간 추이" - "오늘 발생한 디지털 이벤트 정리" - "P-6201 펌프의 정비 이력" ← KB 연동 - "이번 주 보고서를 작성해줘" - "냉각수 펌프 토출 압력 태그를 찾아줘" ← find_tags ``` ### 6.3 plant_context.md 시드 (개요) ```markdown # 플랜트 운전 컨텍스트 ## 단위(Area / Unit) - Unit A: ... - Unit B: ... - ... ## 계기 명명 약어 - FIC: Flow Indicator Controller (유량 지시 제어) - FT: Flow Transmitter (유량 발신기) - PT: Pressure Transmitter - TI: Temperature Indicator - LIC: Level Indicator Controller - XV: Digital On/Off Valve - ... ## 태그 명명 규칙 - 모두 소문자 (예: ficq-6113.pv) - 접미사: .pv (Process Value), .sp (Setpoint), .op (Output), .instate0..7 (Boolean 상태비트) ## 시간대 - DB 저장: UTC - 사용자 입력: KST (UTC+9), 자동 변환 - 응답 표시: KST ## 사용 가능 도구 - run_sql: PostgreSQL SELECT 실행 - query_pv_history: 태그 이력 조회 - query_events: 이벤트/알람 조회 - active_alarms: 현재 진행 중 알람 - search_kb: 사용자 지식 베이스 검색 - find_tags: 자연어 → 태그 시맨틱 매칭 - generate_status_report: 상황 보고 합성 ## 예시 질문 - "지금 활성 알람 보여줘" → active_alarms - "Unit A 24시간 요약" → generate_status_report(scope='daily', area='unit-a') - "정비이력 알려줘" → search_kb(['plant_operation','procedure']) ``` ### 6.4 빌드/테스트 시 확인 사항 - `dotnet build` — 경고는 기존 대비 신규 발생 없을 것 - Qdrant 컬렉션 5개 생성 확인 (`curl http://localhost:6333/collections`) - 비번 첫 설정 후 로그인/만료/재로그인 동작 - 작은 텍스트 업로드 → 30초 내 status='indexed' - 큰 PDF(OCR) 업로드 → 워커가 stage 단계 진행, 실패 시 attempts 카운트 - 채팅에서 KB 인용 → 다운로드 링크 클릭 시 원본 다운로드 --- ## 7. 결정 보류/추후 합의 항목 다음은 본 플랜에서는 결정하지 않고 구현 진행 중 별도 합의 예정: - **C9** NL2SQL 의도 라우터 도입 시점 — Phase 5 후 모델 호출 패턴을 측정 후 결정 - **C10/C11** 대화 요약 / 에이전트 모드 — Phase 5 완료 후 사용성 평가에 따라 - **현장 재고** 데이터 소스 — RAG로 흡수(MES 엑셀 정기 업로드) vs 별도 inventory_table — 데이터 출처 확정 후 결정 - **임베딩 모델 최종 선택** — BGE-M3 vs multilingual-e5-large 한국어 sample 검증 후 결정 - **세션 토큰 슬라이딩 갱신 / 다중 관리자 / IP 제한** — 운영 형태 확정 후