Files
ExperionCrawler/plans/LLM채팅+지식증강플랜.md
windpacer 35136ba91e feat: 로컬 LLM 채팅 기능 추가 (Ollama + vLLM, 스트리밍, MCP 도구 호출)
- OllamaController: Ollama/vLLM 프록시 API (채팅, 스트리밍, 모델 목록, 설정)
- UI: 새 대화 탭, 세션 관리, Markdown 렌더링, 스트리밍 응답
- vLLM: OpenAI-compatible API 지원, MCP function calling 통합
- Fix: McpClient DI 팩토리 등록 (HttpClient BaseAddress 문제 해결)
- Fix: llm-model.json 직렬화 JsonSerializer 사용
- Fix: nl2sql_worker KST 시간대 표시 (AT TIME ZONE Asia/Seoul)
- Program.cs: Ollama/vLLM HttpClient 등록 (1800s timeout)
2026-05-12 19:59:31 +09:00

35 KiB

로컬 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:244time_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)

-- 컬렉션 레지스트리
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 컬렉션 공통:

{
  "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)

# 신규 — 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번 탭 추가, <section id="pane-kbadmin"> 신설 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.csKbIngestWorker 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.pyparse_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)

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)

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 발췌)

// 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 발췌)

// 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 시드 (개요)

# 플랜트 운전 컨텍스트

## 단위(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 제한 — 운영 형태 확정 후