RAGCenter 연동 계획 (ProcessCenter)
작성 시각: 2026-09-13 · 상태: 계획(실사 완료, P0 확인 필요) 상대 서비스: RAGCenter 저장소(기본 브랜치 feat/private-document-pipeline) — 문서 정제·청킹·임베딩·권한 인식 검색·근거 기반 응답 소유 경계 근거: RAGCenter/docs/ARCHITECTURE.md, UiCenter/docs/DECISION_LOG.md(2026-09-13 서비스 경계 정정)
1. 실사 요약
| 항목 | 내용 |
|---|---|
| 스택 | Python · FastAPI · SQLAlchemy · psycopg · PyJWT · PostgreSQL(+pgvector) |
| API 모듈 | ragcenter/api.py (관리 UI는 web/, 임베딩 전용 서비스는 services/local_embedding/app.py) |
| 인증 | DirectoryService가 서명한 Bearer JWT — 필수 클레임 exp·sub·tenant_id·organization_id·clearance_level, 선택 active·scope(s)·directory_admin·department_code. audience 기본 rag-center, 서명키 PORTAL_CONSOLE_JWT_SIGNING_KEY |
| 권한 모델 | RLS 우선 — db_context.RlsPrincipal로 PostgreSQL RLS 후보 제한 후 embedding 유사도·LLM 컨텍스트 조립. tenant/조직/clearance를 헤더·본문으로 받지 않는다(서버가 JWT 클레임만 신뢰) |
| 안전장치 | PII guardrail(질의 마스킹 + pii_labels), 감사 기록(SEARCH·CHAT 이벤트), 근거 없음 시 고정 응답(NO_CONTEXT_ANSWER) |
| 수집 계약 | portalId·sourceDocumentId·sourceVersion·tenantId·organizationIds·securityLevel·resourceType·classification·retentionUntil·contentLocator(또는 임시 URL)·checksum·contentType·sizeBytes |
| DirectoryService 연동 | rag-center principal + scope rag:read·rag:sync·rag:admin |
엔드포인트(실측)
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| POST | /api/v1/questions/embedding | { question } | 임베딩 결과 |
| POST | /api/v1/search | { question, limit(3~5) } | { contexts: [{ chunk_id, document_id, title, chunk_text, classification, security_level, source_version, registrant_department_code, vector_score, keyword_score, hybrid_score }] } |
| POST | /api/v1/chat/stream | { question, limit } | SSE 스트리밍 답변 |
| GET | /healthz | — | 상태 |
| 관리 | /api/v1/admin/{settings,overview,operations,tuning/recommendations} · DELETE /api/v1/admin/documents/{source_document_id} | 관리자 principal 필요 | 정책·운영·삭제 |
2. ProcessCenter 연동 방식
2-1. 인증(핵심 제약)
RAGCenter는 tenant/organization/clearance를 본문·헤더로 받지 않고 JWT 클레임만 신뢰한다. 따라서 ProcessCenter는 다음 중 하나로 사용자 컨텍스트를 전달해야 한다.
| 방식 | 내용 | 판단 |
|---|---|---|
| ⓐ 사용자 JWT 그대로 전달 | PC가 보유한 DirectoryService 세션 토큰을 Authorization으로 전달 | 선호 — 단 audience·서명키가 RAGCenter 설정(rag-center, PORTAL_CONSOLE_JWT_SIGNING_KEY)과 일치해야 함(확인 필요) |
| ⓑ 서비스 토큰 교환 | DS의 service-token 발급(PROCESS_CENTER_SERVICE_CLIENT_SECRET + audience rag-center)으로 서비스 principal 발급 | 사용자별 RLS가 사라지므로 검색·답변에는 부적합하고 수집·동기화에만 사용 |
| ⓒ 자체 principal 주입 | PC가 tenant/org를 직접 전송 | 금지(RAGCenter가 거부) |
2-1-1. P0 실사 결과 (2026-09-13 · 선행 확장 필요)
DirectoryService 현황 (server/serviceTokenRoute.ts, 2.2KB)
| 항목 | 현재 값 | RAGCenter 요구 | 판정 |
|---|---|---|---|
| 서비스 토큰 API | POST /api/internal/service-token | 서비스 인증 | ✔ 존재 |
| 호출자 식별 | client_id 하드코딩 process-center | rag-center 소비자 허용 | ✗ 단일 서비스 전용 |
| audience | 하드코딩 ui-center-theme | rag-center | ✗ 불일치(다른 audience는 401) |
| scope | 하드코딩 theme:catalog:read theme:policy:read theme:policy:write | rag:read·rag:sync·rag:admin | ✗ 미구현 |
| 클레임 | azp·portal_id·scope·token_use | tenant_id·organization_id·clearance_level(필수) | ✗ 미발급 |
| 서명 | HS256 + PORTAL_JWT_SECRET | HS256 + PORTAL_CONSOLE_JWT_SIGNING_KEY | △ 같은 값으로 설정하면 정합 |
| 만료 | 10분 | — | ✔ |
결론: 현재 상태로는 연동 불가. DirectoryService가 다음 중 하나를 구현해야 한다.
- 서비스 토큰 일반화(최소 변경) —
SERVICE_ID·SERVICE_AUDIENCE·SERVICE_SCOPES상수를 소비자별 등록(config/DB 테이블) 으로 바꾸고rag-center(scoperag:read·rag:sync)를 추가한다. - 사용자 위임 토큰(on-behalf-of) — 사용자 JWT(portal/user audience)에는
tenant_id·organization_id·clearance_level이 없으므로, DS가 사용자 컨텍스트를 담은 위임 토큰(예:sub=user,act=rag-center)을 발급해야 한다. 그래야 사용자별 RLS가 유지된다. - 대안(정책 결정) — 전사 검색이 "서비스 principal + 조직 단위 RLS"로 충분하다면 ⓑ(서비스 토큰)만으로도 성립하지만, 사용자별 열람 제한은 포기하게 된다.
운영 설정: RAGCenter의 PORTAL_CONSOLE_JWT_SIGNING_KEY를 DS의 PORTAL_JWT_SECRET와 동일 값으로 주입한다(코드 변경 없이 서명 검증 정합).
위임 토큰 계약(제안, DS 저장소): DirectoryService/docs/delegation-token-contract.md
- 발급 경로: A. 사용자 토큰 교환(권장, 사용자 JWT +
audience·scope) / B. 서버 위임 발급(client_id·secret +subject) - 신규 클레임:
tenant_id·organization_id(+organizationIds)·clearance_level·act·token_use=delegation,scope는 부분집합만(상승 금지), 만료 5~10분 - DS 구현 변경:
serviceTokenRoute소비자별 등록화(+rag-centeraudience·rag:*scope) · 위임 발급 엔드포인트 신규 ·directory_users.clearance_level추가 ·PORTAL_CONSOLE_TENANT_ID(기본default) - 단계: D0 계약 합의 → D1 서비스 토큰 일반화 → D2 등급·테넌트 → D3 위임 발급 → D4 소비 서비스 적용
2-1-2. 식별자 정합 — uuidv5 투영 (2026-09-25) ✔
RAGCenter는 tenant_id·organization_id·source_document_id를 모두 PostgreSQL **uuid**로 다룬다 (ragcenter/db_context.py의 RlsPrincipal, ragcenter/cdc.py의 uuid5(BOARD_NAMESPACE, ...) 선례, RLS 정책 infra/postgres/init/10-b2b-rag-rls.sql). 반면 PC·DirectoryService의 원천 값은 문자열(테넌트 키 default, 조직 코드, process-node:19)이 섞여 있어 양 서비스가 같은 식별자를 계산해야만 수집 문서와 사용자 위임 토큰의 주체가 만난다.
| 원천 | 키 | 투영식 | 공유 벡터(예) |
|---|---|---|---|
| 테넌트 | PORTAL_CONSOLE_TENANT_ID(미설정 default) | uuid5(tenant, 키) | tenant-a → 13ea07d6-7f58-5a65-ab33-f8a06d31a498 |
| 조직 | DirectoryService 부서 id(organizations.directoryId = 위임 토큰 organization_id 원천) | uuid5(organization, 부서 id) | dept-uuid-9 → 646d68df-ed6e-5c1f-b187-71918f5e0deb |
| 조직(미지정) | unassigned | uuid5(organization, "unassigned") | → 139dc4f9-6828-5f9c-b972-910f58f44b25 |
| 조직(공용) | public | uuid5(organization, "public") | → 55452885-74ca-54e4-bff2-d345d36ffad0 — 전사 공지용. RAGCenter RLS가 같은 테넌트의 모든 조직에 허용한다 |
| 프로세스 노드 | process-node:<nodeId> | uuid5(document, 이름) | 노드 19 → f397db6f-b9a4-5396-8456-52cb31ee4cf3 |
| 첨부(asset) | attachment:<assetId> | uuid5(document, 이름) | asset-123 → 161e0b91-e5e9-5eca-a46e-cf135fb1adfa |
- 네임스페이스:
root = uuid5(NAMESPACE_URL, "https://portal-foundation.local/rag-identity")→tenant·organization·document. - 구현: 동일 상수·알고리즘을 양 저장소에 둔다 —
ProcessCenter/server/rag/ragIdentity.ts,PortalConsole/server/ragIdentity.ts(RFC 4122 v5, Pythonuuid.uuid5및ragcenter/cdc.py와 같은 결과). 두 파일이 갈라지면 RAG 검색이 조용히 0건이 되므로 양쪽ragIdentity.test.ts가 동일 벡터를 검증한다. - 조직 해석 순서: 노드
organizationCode→organizations.code조회 →organizations.directoryId→ uuid 투영. 코드가 없거나 디렉터리에 없는 조직은unassigned버킷(부서 미지정 사용자와 동일 주체)으로 모아 과다 노출을 만들지 않는다. - 첨부는 asset 단위 문서 1건이며, 색인·삭제 전파가
attachmentSourceDocumentId(assetId)를 공유한다.
2-2. 검색·답변 위임(P1)
- PC
rag.search→POST /api/v1/search(응답contexts→ PC 화면용 인용 목록으로 매핑) - PC
rag.ask→POST /api/v1/chat/stream(1차는 전체 수신 후 반환 — tRPC는 스트리밍 미지원, 2차에서 SSE 프록시 검토) - 실패 시 기존 로컬 구현으로 폴백(플래그:
RAG_SERVICE_URL·RAG_SERVICE_ENABLED)
2-3. 수집(발행) (P2)
ProcessCenter가 문서(게시본)를 RAGCenter 계약으로 발행한다.
| 계약 필드 | PC 소스 |
|---|---|
portalId | process-center |
sourceDocumentId | processNodeSourceDocumentId(nodeId) = uuid5(document, "process-node:<id>") — 버전과 무관하게 노드당 1건 |
sourceVersion | 게시본 version (불변 스냅샷 ✔) |
tenantId | ragTenantIdFromEnvironment() = uuid5(tenant, PORTAL_CONSOLE_TENANT_ID ‖ default) |
organizationIds | ragOrganizationId(조직.directoryId) — 노드 organizationCode → 디렉터리 조직 → 부서 id(없으면 unassigned 버킷) |
securityLevel·classification | 노드 보안등급(현재 컬럼 없음 → INTERNAL, 후속 과제) |
resourceType·retentionUntil | 문서 유형(process-node-version·attachment)·보존 정책(운영 설정) |
contentLocator | PC가 제공하는 문서 조회 엔드포인트(예: GET /api/rag/v1/documents/{id}?version={n}, 서비스 토큰 + x-rag-subject-id) — 탐색 어댑터와 동일 패턴 |
checksum·contentType·sizeBytes | 스냅샷 기준 계산 |
- 게시 확정 시 색인 예약, 폐기·삭제 시 삭제 전파(RAGCenter admin API 또는 이벤트)로 정합 유지.
- 삭제 전파 범위: 게시본 폐기(
retireProcess) · 프로세스 첨부 삭제(process.deleteAttachment) · 공지 첨부 삭제(notices.remove). 모두server/services/ragDeletionNotifier.ts를 지나며 실패해도 원래 흐름을 막지 않는다(로그만 남김). - PC의 기존 로컬 청킹·임베딩(
server/rag/**)은 연동 완료 후 축소한다(RAGCenter가 소유).
2-4. 임베딩 경로
RAGCenter는 자체 임베딩 서비스(services/local_embedding, OpenAI 호환 /v1/embeddings)를 갖는다. PC가 임베딩을 직접 호출할 필요가 없어지므로(P1·P2 이후) PRIVATE_EMBEDDING_URL 의존은 RAGCenter 경유로 대체한다. 결정(§3 P3): 로컬 임베딩·벡터 검색(server/rag/**)은 폴백 전용으로 유지한다 — RAGService 장애 시 유일한 검색 수단이고, VectorStore 계약이 외부 벡터 DB 어댑터 주입 지점이기 때문이다. ModelGateway 경유가 필요하면 업스트림으로 이 임베딩 서비스를 지정한다.
2-4-1. 온톨로지 grounding (2026-10-04 신규) ✔
답변 품질의 절반은 같은 어휘로 읽는 것이다. PC는 답변 생성 두 경로에 공통 온톨로지(승인된 용어팩)를 함께 넣는다.
| 경로 | 주입 위치 | 비고 |
|---|---|---|
로컬 RAG(answerPrivateRag) | 시스템 프롬프트 뒤에 ## 공통 용어(회사 표준)·## 표기 정규화·## 개념 관계 블록 | 근거 문서 메시지는 그대로 별도다 |
RAGCenter 위임(rag.ask) | 질문 앞에 같은 블록 | 외부 서비스 프롬프트는 PC가 바꿀 수 없다 |
- 팩은
GET /api/directory/v1/ontology/pack(PortalConsole,glossary:read)에서 받고, 승인된 개념·관계만 쓴다. - 질문에 걸린 개념만 고른다(대표 표기·별칭·개념 ID 대조). 관련 개념이 없으면 블록을 만들지 않는다 — 무관한 정의는 답변을 흐린다.
- 표기 이형은 표준 표기로 이어 준다(
"결재 라인" → 결재선). 용어 지식은 해석 기준일 뿐, 문서 밖 사실의 근거로 쓰지 않는다(시스템 프롬프트의 근거 제한 규칙 유지). - 팩을 못 받으면(자격증명 없음·원장 장애) grounding 없이 평소대로 답한다 — 실패는 60초만 캐시해 로그 폭주를 막고, 성공 팩은 10분 캐시한다(
server/ontologyGrounding.ts). - 검증:
server/ontologyGrounding.test.ts(매칭·블록·캐시·실패 캐시),server/rag/ragAnswerGateway.test.ts(시스템 프롬프트 주입),server/ragRouter.test.ts(위임 질문 주입).
2-5. 수집 실행 — RAGCenter HTTP 수집 API (2026-09-25 신규) ✔
§2-3의 payload는 이제 **RAGCenter POST /api/v1/documents**로 실제 색인된다(이전에는 HTTP 수집 API가 없어 payload 제공까지만 가능). PC는 게시 확정·첨부 업로드 시 payload를 그대로 POST 하고, 비 2xx는 "색인 안 됨"으로 보고 다음 게시에서 재시도한다(삭제 전파와 같은 비차단 원칙).
요청 — §2-3 payload + 색인 메타데이터:
| 필드 | 필수 | 비고 |
|---|---|---|
title | ✔ | registry title → 검색 응답의 인용 제목 |
registrant.id·registrant.departmentCode | ✔ | 위임 토큰 sub·department_code 그대로 |
registrant.name | ✖ | 없으면 id로 대체 |
registeredAt | ✔ | ISO-8601 오프셋 필수(+09:00·Z) — naive 값은 거부 |
assetId·originalFileName | ✖ | 첨부만(색인 문서 ↔ asset 삭제 전파 연결) |
lifecycleMode | ✖ | HARD(기본)·SOFT |
retentionUntil | ✖ | 오프셋 포함 ISO-8601 |
응답(200) — { indexed, documentId, sourceDocumentId, sourceVersion, organizationId, securityLevel, checksum, contentType, sizeBytes, chunks, lifecycleMode }. documentId는 삭제 전파가 지목하는 uuid이므로 PC는 sourceDocumentId와 같은지 확인한 뒤 색인 성공으로 기록한다.
권한 규칙(§2-1의 "tenant/org/clearance는 본문으로 받지 않는다"의 수집 전용 완화):
| 주체 | 규칙 |
|---|---|
| 모든 토큰 | tenantId = 토큰 tenant_id (다르면 403 tenant_not_allowed) · organizationIds는 정확히 1건 |
rag:admin(삭제 전파와 같은 scope) | 게시본이 선언한 조직·등급을 그대로 채택(is_admin 분기 = RLS admin과 동일 권위) |
| 비 admin | 토큰의 organization_id·clearance_level과 정확히 같을 때만 통과(403 organization_not_allowed·403 clearance_not_allowed) |
보안등급 매핑(§4 항목 3 해소) — 별도 변환표 없이 이름 1:1:
PC securityLevel | RAGCenter clearance_level(RLS enum) | registry classification |
|---|---|---|
PUBLIC | PUBLIC | PUBLIC 또는 PUBLIC: … 표시 문자열 |
INTERNAL | INTERNAL | 동일 |
CONFIDENTIAL | CONFIDENTIAL | 동일 |
SECRET | SECRET | 동일 |
| 그 외 | 400 payload_invalid:securityLevel | — |
- 네 이름은 PC·RAGCenter가 같은 철자(대문자)를 쓴다. 등급을 늘리려면 RLS enum(
RagService/infra/postgres/init/10-b2b-rag-rls.sql)·ClearanceLevel을 먼저 확장한다. classification은 표시용 자유 텍스트지만<등급>:접두사를 쓰면 접두사 ==securityLevel이어야 한다(다르면 400) — 표시 문자열이 강제 등급과 모순될 수 없다.
checksum·sizeBytes 의미 (색인한 바이트만 신뢰):
| 문서 종류 | contentType | RAGCenter가 검증하는 바이트 |
|---|---|---|
| 프로세스 노드 게시본 | application/json | 스냅샷 {publication, content}의 content 문자열(봉투 제외) |
| 첨부(asset) | 원본 MIME | 응답 본문 그대로 |
- 따라서 노드의
checksum·sizeBytes는content기준(현행loadRagDocument()그대로)이며, 첨부는 원본 파일 기준 = FileStoreasset.checksum(어댑터가 업로드 시sha256(bytes)계산)·asset.bytes다. - 불일치 시 색인 전에
409 checksum_mismatch·409 size_bytes_mismatch로 중단한다(본문publication봉투의 locator·보존 시각은 코퍼스에 들어가지 않는다).
contentLocator 제약(PC 엔드포인트 설계 제약):
- 절대
http(s)URL + 허용 호스트(RAG_SERVICE_CONTENT_ALLOWED_HOSTS), 리다이렉트 미추적(follow_redirects=False) → PC 엔드포인트는 302로 FileStore 서명 URL을 넘기지 말고 원본 바이트를 스트리밍해야 한다. - 서비스 토큰: RAGCenter는
Authorization: Bearer <RAG_SERVICE_CONTENT_TOKEN ‖ RAG_SERVICE_TOKEN>을 그대로 보내고, PC는 현행isRagDocumentRequestAuthorized(RAG_SERVICE_TOKEN비교)로 검증한다 — 양쪽 env를 같은 값으로 주입한다. - 기본 상한 64MiB(
RAG_SERVICE_CONTENT_MAX_BYTES)·타임아웃 60초(RAG_SERVICE_CONTENT_TIMEOUT_SECONDS).
오류 코드(detail = 결정적 snake_case, PC는 비 2xx를 재시도 대상으로만 취급):
| status | code |
|---|---|
| 400 | payload_invalid:<field>·payload_too_long:<field>·content_locator_invalid·content_json_invalid·content_extraction_failed·content_empty |
| 401 | directory_bearer_token_required·invalid_directory_token |
| 403 | tenant_not_allowed·organization_not_allowed·clearance_not_allowed·content_locator_host_not_allowed |
| 409 | checksum_mismatch·size_bytes_mismatch·ingestion_rejected |
| 413 / 502 | content_locator_too_large / content_locator_fetch_failed·content_locator_unreachable |
첨부 원본 소스 — 구현 완료(§4 항목 9): FileStore 어댑터(@hohyun94/portal-filestore)는 readUrl만 제공하므로 PC가 GET /api/rag/v1/attachments/:assetId를 연다. ① 서비스 토큰 검증(RAG_SERVICE_TOKEN) → ② attachment 행 조회(프로세스 첨부 → 공지 첨부, 없으면 404 rag_attachment_not_found) → ③ issueProcessAttachmentReadUrl(assetId, "rag-service") → ④ 서명 URL을 서버에서 받아 content-type·content-length·content-disposition(RFC 5987 이중 표기)과 함께 원본 바이트를 스트리밍한다. 302를 쓰지 않는다(RAGCenter는 리다이렉트를 추적하지 않는다). FileStore 실패는 502 rag_attachment_unavailable로 알린다. checksum은 asset 메타(asset.checksum = sha256(bytes) hex — RAGCenter ^[0-9a-f]{64}$와 같은 형식)에만 있으므로 업로드 시 process_node_attachments.checksum·notice_attachments.checksum(varchar(64))에 보존한다(drizzle/0030_rag_attachment_checksum.sql·drizzle/postgres-migrations/0019_rag_attachment_checksum.sql + MySQL 기동 보정 server/db-connection.ts). checksum이 NULL인 기존 행은 색인 대상에서만 빠지고 다운로드는 그대로 동작한다(fileSize는 sizeBytes 검증에 그대로 쓴다).
PC 구현(2026-09-25):
| 구성 | 위치 | 내용 |
|---|---|---|
| 색인 발행 | server/ragPublicationService.ts의 publishRagDocument() | 발행 payload + title·registrant(subject·이름·부서 코드)·registeredAt(offset 포함 ISO-8601) → POST /api/v1/documents, 응답 documentId가 삭제 전파 대상과 같은 uuid인지 확인해야 성공으로 본다 |
| 비차단 예약 | server/services/ragPublicationNotifier.ts | propagateRagDocumentPublication()(결과 확인) · scheduleRagDocumentPublication()(비차단). 연동 비활성·origin 미설정은 경고 없이 건너뛴다 |
| 트리거 | services/processGovernanceService.ts의 approveProcess(게시 확정) | 폐기 시 삭제 전파(retireProcess)와 짝. 색인 실패가 게시를 막지 않는다 |
| 수집 origin | RAG_SERVICE_CONTENT_ORIGIN(없으면 APP_ORIGIN) | contentLocator·본문 제공 주소. 비어 있으면 색인하지 않는다(가짜 URL을 만들지 않는다) |
| 색인 토큰 | rag:admin 위임 토큰 | 게시본은 노드 조직·등급을 그대로 선언하므로 admin 분기가 필요하다(삭제 전파와 같은 소비자 등록·캐시) |
| 스냅샷 응답 | server/ragDocumentEndpoint.ts | 응답에 title·organizationCode가 추가됐다 — RAGCenter는 content만 읽으므로 계약 영향 없음(등록 정보는 수집 payload에서만 쓰인다) |
| 첨부 원본 | server/ragAttachmentEndpoint.ts (+ app.ts 등록) | 위 문단의 스트리밍 엔드포인트. 소유 행이 없는 asset은 404 — PC가 소유하지 않은 asset을 대신 내주지 않는다 |
| 첨부 색인 발행 | server/ragPublicationService.ts의 publishRagAttachment() | resourceType=ASSET_ATTACHMENT·assetId·originalFileName·sourceVersion="1"(asset은 불변이라 버전이 없다) + 원본 기준 checksum·sizeBytes. 체크섬 부재·등급 이름 불일치는 네트워크 왕복 없이 {indexed:false, reason} |
| 첨부 트리거 | approveProcess(확정본 첨부 전체) · routers/processes.ts(ACTIVE 노드 업로드) · routers/notices.ts(PUBLISHED 공지 저장) | 비공개 전환·폐기·첨부 삭제는 scheduleRagDocumentDeletion으로 짝을 맞춘다(첨부는 asset 단위 문서라 노드 문서 삭제로 지워지지 않는다) |
| 첨부 색인 입력 | server/services/ragPublicationNotifier.ts의 attachmentPublicationInputs() | 첨부 행 → 색인 입력. asset id 없는 행은 건너뛰고, 조직·등급은 소유 문서를 따른다. 공지는 조직 컬럼이 없어 공용 버킷(public)·INTERNAL로 색인해 부서가 있는 사용자도 검색된다(§2-1-2·§4-2) |
| checksum 보존 | drizzle/0030_rag_attachment_checksum.sql · drizzle/postgres-migrations/0019_rag_attachment_checksum.sql · MySQL 기동 보정 | 양쪽 dialect 스키마(schema.mysql.ts/schema.pg.ts)에 checksum(varchar(64)) 추가. journal 마지막 항목을 고정하던 테스트는 등록 여부 검증으로 완화했다(마이그레이션은 계속 추가된다) |
| 테스트 | server/ragAttachmentEndpoint.test.ts(5) · server/ragAttachmentPublication.test.ts(9) · 트리거: processPublishGovernance.test.ts·processRouter.test.ts·noticesRouter.test.ts | 토큰·소유 확인(404)·FileStore 실패(502)·헤더·원본 바이트 일치, payload 계약·체크섬 부재·식별자 불일치·등급명, 게시/미게시 트리거 |
| 테스트 | server/ragPublicationIngest.test.ts(8) · server/processPublishGovernance.test.ts | 계약 본문·rag:admin·origin 해석·식별자 불일치·오류 사유·게시 확정 시 예약(반려는 미예약) |
소비자 계약 테스트: RagService/tests/test_document_ingest.py(6테스트) — 노드 스냅샷 색인·uuid 결정성(재게시 동일 chunk_id)·첨부 스트림 추출 시임·비 admin 경계·결정적 오류코드·체크섬 선차단. RagService 전체 unittest discover 52테스트 OK(2 skip).
3. 단계별 계획
| 단계 | 내용 | 변경 위치 | 검증·롤백 |
|---|---|---|---|
| P0 | 토큰 정합 확인 — 실사 완료(불가 판정, DS 확장 선행) ⚠️ DS는 service-token을 process-center·ui-center-theme 전용으로 하드코딩하고 있으며 tenant_id·organization_id·clearance_level·rag:* scope를 발급하지 않는다(§2-1-1) | DirectoryService/server/serviceTokenRoute.ts(+ 소비자별 등록·위임 토큰 설계), ProcessCenter/server/_core/directory-auth.ts | DS 확장 후 토큰 검증 단위 테스트 |
| P1 | 검색·답변 위임 — POST /api/v1/search·/api/v1/chat/stream 클라이언트 + rag.* 라우터 이중화(플래그·로컬 폴백) | server/ragCenterClient.ts(신규), server/routers/rag.ts | ✅ 완료(2026-09-13) — 아래 §3-1 |
| P2 | 수집 발행 — PC 문서 조회 엔드포인트(서비스 토큰) + 게시 확정 시 색인 예약·폐기 시 삭제 전파 | server/ragDocumentEndpoint.ts(신규), processGovernanceService(게시·폐기 훅) | ✅ 완료(2026-09-13) — 아래 §3-2(삭제 전파 포함). 색인 실행은 RAGCenter 수집 API로 배선됐다(§2-5: 노드 = 게시 확정 / 첨부 = 게시 확정·ACTIVE 업로드·공지 공개 전환, 비공개 전환·폐기 시 삭제) |
| P3 | 로컬 RAG 축소 — server/rag/**(청킹·임베딩·pgvector 검색) 제거 또는 폴백 전용 축소, drizzle.config.rag.ts·RAG 마이그레이션 정리 | server/rag/**, drizzle/rag-* | ✅ 결정(2026-09-26): 폴백 유지 — 삭제하지 않는다. 근거·재검토 조건은 §5 P3 |
3-1. P1 구현(2026-09-13) ✔
| 구성 | 위치 | 내용 |
|---|---|---|
| RAGCenter 클라이언트 | server/ragCenterClient.ts | 위임 토큰(aud=rag-center·scope=rag:read)으로 /api/v1/search 호출 → 컨텍스트를 PC 검색 계약(chunkId·sourceDocumentId·content·similarity·rank)으로 정규화 · /api/v1/chat/stream SSE(event: contexts → token 조각 → done)를 조립해 {answer, citations} 반환 |
| 라우터 이중화 | server/routers/rag.ts | RAG_SERVICE_ENABLED=true + RAG_SERVICE_URL + 위임 설정이 모두 있으면 RAGCenter로 위임, 실패 시 로컬 폴백(경고 로그). health에 ragCenter.configured 노출 |
| 사용자 컨텍스트 | directorySubject() | PC openId(directory:<id>) → 위임 토큰 subject |
| 테스트 | server/ragCenterClient.test.ts | 활성 조건 · 검색 정규화(위임 audience·scope 검증) · SSE 조립 · 서버 오류 · 빈 스트림 |
개발 스택: RAG_SERVICE_ENABLED=false(기본) + RAG_SERVICE_URL=http://rag-service:8000 — 개발 스택에 RagService 컨테이너가 없으므로 기본 비활성이며, 배포 시 플래그·URL만 켜면 된다. 인증 경로(PortalConsole 위임 토큰 aud=rag-center)는 제어평면 결정 D1·D3로 이미 검증됐다.
3-2. P2 구현(2026-09-13) ✔
| 구성 | 위치 | 내용 |
|---|---|---|
| 문서 조회 엔드포인트 | server/ragDocumentEndpoint.ts | GET /api/rag/v1/documents/:nodeId?version= — 서비스 토큰(RAG_SERVICE_TOKEN)으로만 접근, 게시본 불변 스냅샷을 { publication, content }로 반환(Cache-Control: no-store) |
| 수집 payload | server/ragPublicationService.ts | loadRagDocument() — portalId·sourceDocumentId·sourceVersion(게시본 version)·tenantId·organizationIds·securityLevel·classification·resourceType·contentLocator(자기 엔드포인트 URL)·checksum(sha256)·contentType·sizeBytes (식별자는 §2-1-2 투영식) |
| 식별자 투영 | server/rag/ragIdentity.ts | uuidV5 + 테넌트·조직·노드·첨부 식별자. PortalConsole 발급부와 같은 상수·벡터(server/rag/ragIdentity.test.ts) |
| 삭제 전파 | 동일 | notifyRagCenterDocumentDeleted() — rag:admin scope 위임 토큰으로 DELETE /api/v1/admin/documents/{id} 호출, 실패는 결과로만 반환(호출 흐름 유지) |
| 삭제 전파 예약 | server/services/ragDeletionNotifier.ts | scheduleRagDocumentDeletion()(비차단) · propagateRagDocumentDeletion()(결과 확인). 연동 비활성은 경고 없이 건너뛴다(server/services/ragDeletionNotifier.test.ts) |
| 전파 훅 | processGovernanceService.ts(폐기) · routers/processes.ts(첨부 삭제) · routers/notices.ts(공지 첨부 삭제) | 첨부는 asset 단위 문서 1건 → attachmentSourceDocumentId(assetId) |
| 조직 조회 | repositories/organizationRepository.ts | getOrganizationByCode() — 노드 organizationCode를 directoryId로 옮겨 §2-1-2 조직 투영에 넘긴다 |
| 테스트 | server/ragPublication.test.ts | payload·체크섬·식별자 투영(테넌트·조직·unassigned 폴백)·없는 노드/버전 · 엔드포인트 401/400/404/200 · 삭제 전파(비활성·정상·실패) |
| 테스트 | server/noticesRouter.test.ts·server/processRouter.test.ts | 첨부 삭제 시 FileStore 삭제 성공분만 색인 삭제 예약 · 실패분은 재시도 큐로 |
| 계약 테스트(소비자) | RagService/tests/test_admin_document_deletion.py | 위임 클레임(uuid 투영 + rag:admin)이 RlsPrincipal로 해석되고 DELETE /api/v1/admin/documents/{uuid}가 변형 없이 저장소까지 가는지 고정. 투영 전 원시값(default·dept-uuid-9)은 거부되고 unassigned 버킷은 통과한다 |
검증(2026-09-25): ProcessCenter vitest run 88파일/579테스트 · tsc --noEmit exit 0(첨부 배선 +19테스트: 엔드포인트 5·첨부 발행 9·트리거 5), PortalConsole vitest run 74파일/403테스트(2 skip) · tsc --noEmit exit 0, RagService unittest discover 52테스트 OK(Python 3.11 컨테이너 — 식별자 벡터·삭제 계약 + 수집 API 계약 6테스트: tests/test_document_ingest.py). 위임 토큰 실 경로는 개발 스택 docker-compose.dev.yml의 portal-console 소비자 등록(delegationAudiences: [rag-center, …]·delegationScopes: […, rag:admin])으로 확인했다 — 목록에서 rag:admin이 빠지면 삭제가 scope_not_allowed(400)로 조용히 실패한다.
RAGCenter 측 확인 사항: HTTP 수집 API POST /api/v1/documents(§2-5)·노드 발행·첨부 발행 배선이 모두 완료됐다. ① 공지 첨부의 조직 버킷 — 해소(2026-09-26): 공지는 **공용 버킷(public)**으로 색인하고, RAGCenter RLS(app_rls_allows + my.app_public_org_id)가 같은 테넌트의 모든 조직에 허용한다(등급 조건은 그대로). 남은 것은 ② process_nodes.securityLevel 컬럼(없으면 INTERNAL로 색인) ③ 보존 정책(retentionUntil)이다.
4. 미해결 · 확인 필요
점검(2026-09-26): 아래 10항목을 현재 코드 기준으로 다시 확인했다. 1·4·5는 해소, 6은 부분 해소(PC 전송 경로만), 2·7·8은 그대로 남는다. 근거 코드: server/routers/rag.ts(위임·폴백) · server/ragCenterClient.ts(검색·답변) · server/ragPublicationService.ts(수집·삭제, rag:admin) · server/rag/ragIdentity.ts(식별자).
| # | 항목 | 상태 · 확인 대상 |
|---|---|---|
| 1 | directorySubjectFromUser()로 sub만 넘기고 PortalConsole이 aud=rag-center·scope=rag:read|rag:admin 토큰을 발급한다(개발 스택 소비자 등록으로 확인, §5 P0). DS 서비스 토큰 일반화는 불필요해졌다 | ✔ |
| 2 | public)**으로 색인하고 RAGCenter RLS가 같은 테넌트의 모든 조직에 허용한다(등급 조건 유지, organizationIds 1건 계약 그대로). 남은 것: ① 미등록 조직 코드는 현행 unassigned 유지(운영이 디렉터리 원장 정비) ② 문서가 여러 조직에 동시 노출되어야 하는 새 요구가 생기면 그때 다중 조직(organizationIds 배열) 지원을 검토 | 부분 해소 · 제품 결정 |
| 3 | securityLevel(PUBLIC/INTERNAL/CONFIDENTIAL/SECRET) = RAGCenter clearance_level 이름 1:1, classification은 자유 텍스트 + <등급>: 접두사 일치 강제. 등급 추가 시 RLS enum·ClearanceLevel 동시 확장 | ✔ |
| 4 | POST /api/v1/documents를 예약 호출하고, 비 2xx는 "색인 안 됨"으로 다음 게시에서 재시도한다. CDC는 쓰지 않는다(server/**에 CDC 참조 없음 — RagService/services/cdc.py는 RAGCenter 소유 기능) | ✔ |
| 5 | rag:admin 위임 토큰으로 notifyRagCenterDocumentDeleted()가 호출하고 실패는 원래 흐름을 막지 않는다(소비자 계약 테스트 RagService/tests/test_admin_document_deletion.py) | ✔ |
| 6 | 보존 정책(retentionUntil) — 부분 해소(2026-09-26): PC가 RAG_RETENTION_DAYS(일수)로 계산해 발행 payload에 싣는다(server/ragPublicationService.ts). 값·최종 보존 기준은 운영·제품 결정 | 부분 · 운영 정책 — 제안: 전역 일수 하나 대신 등급·문서유형별(게시본 N년 · 첨부 M년)로 나누고, 만료 시 실제 파기 주체(RAGCenter) 동작을 확인한다(계약이 값을 받는다고 파기되는 것은 아니다) |
| 7 | 스트리밍 UX — 1차는 전체 수신 후 반환으로 구현 완료(§3-1). tRPC SSE 프록시·토큰 단위 표시 여부는 제품 결정 | 잔여 · 제품 결정 — 제안: 1차를 유지하고(로딩 표시로 체감 완화) 답변 길이가 길어 대기가 문제가 될 때 별도 SSE 라우트(/api/rag/chat/stream, 쿠키 인증)를 연다. tRPC에 스트리밍을 얹지 않는다 |
| 8 | Rate limit·동시성 — 대량 색인 시 상한 | 잔여 · 양측 운영 정책 — 제안: 게이트웨이의 (portalId, purpose) 분당·일별 토큰 한도는 그대로 두고, PC 쪽은 rag.ask 동시 요청 상한과 대량 색인(게시 확정·일괄 가져오기)의 시간 분산을 운영 정책으로 정한다. 색인은 이미 비차단·백그라운드라 사용자 흐름을 막지 않는다 |
| 9 | GET /api/rag/v1/attachments/:assetId가 서명 URL을 서버에서 받아 원본 바이트를 스트리밍한다(302 금지 준수, 실패 502). checksum은 업로드 시 process_node_attachments·notice_attachments에 보존한다 | ✔ |
| 10 | ✔ |
5. 체크리스트
- [x] P0 토큰 정합 확인 — 위임 토큰 경로로 정합(PortalConsole
POST /api/internal/delegation-token+ 개발 스택 소비자 등록에rag-center·rag:admin, 2026-09-25 확인) - [x] 식별자 투영 확정 — 테넌트·조직·노드·첨부 uuidv5, PortalConsole 발급부와 동일 상수·벡터(§2-1-2)
- [x] 조직·보안등급 매핑표 작성 — 조직 §2-1-2, 보안등급 §2-5(이름 1:1 +
classification접두사 규칙) - [x] P1
server/ragCenterClient.ts+rag.*이중화 + 계약 테스트 —ragCenterClient.test.ts(5) +ragRouter.test.ts(6, 위임·폴백·권한 보존) - [x] P2 문서 조회 엔드포인트 + 수집 계약 필드 검증 + 색인 예약 훅(2026-09-25 검증)
- [x] 삭제 전파(폐기·삭제 시 RAGCenter 반영) 구현 — 소비자 계약 테스트(
RagService/tests/test_admin_document_deletion.py) 포함 - [x] RAGCenter HTTP 수집 API(
POST /api/v1/documents) — 소비자 계약 테스트(RagService/tests/test_document_ingest.py, §2-5) - [x] PC 색인 배선(노드) —
publishRagDocument()+ 비차단 예약 + 게시 확정 훅, 테스트server/ragPublicationIngest.test.ts(§2-5 PC 구현) - [x] 첨부 색인 배선 — 첨부 원본 엔드포인트
GET /api/rag/v1/attachments/:assetId+process_node_attachments.checksum·notice_attachments.checksum컬럼 + 게시/비공개 트리거(2026-09-25 검증, §2-5·§4 항목 9·10) - [x] LLM 호출 단일 창구 — RAG 답변도
resolveAiProvider()(ModelGateway 우선) 경유,server/rag/privateAi.ts에는 임베딩만 남김(2026-09-26) - [x] 위임 폴백 정리 —
delegateOrFallback()으로 통합, 실패 원인 보존(위임·폴백 동시 실패는BAD_GATEWAY), 위임 subject 계산 단일화(2026-09-26) - [x] RAGCenter 연동 상태를
DB_STRATEGY.md§8에 반영(2026-09-26) - [x] 전사 공지 공용 버킷 — 공지는
organizationIds: [ragOrganizationId("public")]로 발행하고, RAGCenter RLS(app_rls_allows+my.app_public_org_id)가 같은 테넌트의 모든 조직에 허용한다(등급 조건 유지).RagService/tests/test_rag_identity.py·server/rag/ragIdentity.test.ts·PortalConsole/server/ragIdentity.test.ts가 같은 벡터를 고정(2026-09-26) - [x] P3 로컬 RAG 축소 범위 확정 — 폴백 유지(삭제하지 않음, 2026-09-26):
server/rag/**·drizzle/rag-schema.pg.ts·drizzle/rag-migrations/·drizzle.config.rag.ts를 그대로 둔다. 근거는 ① RAGService 장애 시 로컬이 유일한 검색 수단(운영 연속성) ② 코드 표면이 작고 테스트가 있으며VectorStore계약이 외부 벡터 DB 어댑터 주입 지점 ③ 삭제 이득(코드 감소)보다 손실(장애 시 검색 불가)이 크다. 재검토 조건: RAGService 가용성이 목표(SLO)를 만족하고 배포 스택에 기본 포함될 때 다시 판단