Skip to content

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 요구판정
서비스 토큰 APIPOST /api/internal/service-token서비스 인증✔ 존재
호출자 식별client_id 하드코딩 process-centerrag-center 소비자 허용✗ 단일 서비스 전용
audience하드코딩 ui-center-themerag-center✗ 불일치(다른 audience는 401)
scope하드코딩 theme:catalog:read theme:policy:read theme:policy:writerag:read·rag:sync·rag:admin✗ 미구현
클레임azp·portal_id·scope·token_usetenant_id·organization_id·clearance_level(필수)✗ 미발급
서명HS256 + PORTAL_JWT_SECRETHS256 + PORTAL_CONSOLE_JWT_SIGNING_KEY△ 같은 값으로 설정하면 정합
만료10분—✔

결론: 현재 상태로는 연동 불가. DirectoryService가 다음 중 하나를 구현해야 한다.

  1. 서비스 토큰 일반화(최소 변경) — SERVICE_ID·SERVICE_AUDIENCE·SERVICE_SCOPES 상수를 소비자별 등록(config/DB 테이블) 으로 바꾸고 rag-center(scope rag:read·rag:sync)를 추가한다.
  2. 사용자 위임 토큰(on-behalf-of) — 사용자 JWT(portal/user audience)에는 tenant_id·organization_id·clearance_level이 없으므로, DS가 사용자 컨텍스트를 담은 위임 토큰(예: sub=user, act=rag-center)을 발급해야 한다. 그래야 사용자별 RLS가 유지된다.
  3. 대안(정책 결정) — 전사 검색이 "서비스 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-center audience·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
조직(미지정)unassigneduuid5(organization, "unassigned")→ 139dc4f9-6828-5f9c-b972-910f58f44b25
조직(공용)publicuuid5(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, Python uuid.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 소스
portalIdprocess-center
sourceDocumentIdprocessNodeSourceDocumentId(nodeId) = uuid5(document, "process-node:<id>") — 버전과 무관하게 노드당 1건
sourceVersion게시본 version (불변 스냅샷 ✔)
tenantIdragTenantIdFromEnvironment() = uuid5(tenant, PORTAL_CONSOLE_TENANT_ID ‖ default)
organizationIdsragOrganizationId(조직.directoryId) — 노드 organizationCode → 디렉터리 조직 → 부서 id(없으면 unassigned 버킷)
securityLevel·classification노드 보안등급(현재 컬럼 없음 → INTERNAL, 후속 과제)
resourceType·retentionUntil문서 유형(process-node-version·attachment)·보존 정책(운영 설정)
contentLocatorPC가 제공하는 문서 조회 엔드포인트(예: 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 securityLevelRAGCenter clearance_level(RLS enum)registry classification
PUBLICPUBLICPUBLIC 또는 PUBLIC: … 표시 문자열
INTERNALINTERNAL동일
CONFIDENTIALCONFIDENTIAL동일
SECRETSECRET동일
그 외400 payload_invalid:securityLevel—
  • 네 이름은 PC·RAGCenter가 같은 철자(대문자)를 쓴다. 등급을 늘리려면 RLS enum(RagService/infra/postgres/init/10-b2b-rag-rls.sql)·ClearanceLevel을 먼저 확장한다.
  • classification은 표시용 자유 텍스트지만 <등급>: 접두사를 쓰면 접두사 == securityLevel이어야 한다(다르면 400) — 표시 문자열이 강제 등급과 모순될 수 없다.

checksum·sizeBytes 의미 (색인한 바이트만 신뢰):

문서 종류contentTypeRAGCenter가 검증하는 바이트
프로세스 노드 게시본application/json스냅샷 {publication, content}의 content 문자열(봉투 제외)
첨부(asset)원본 MIME응답 본문 그대로
  • 따라서 노드의 checksum·sizeBytes는 content 기준(현행 loadRagDocument() 그대로)이며, 첨부는 원본 파일 기준 = FileStore asset.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를 재시도 대상으로만 취급):

statuscode
400payload_invalid:<field>·payload_too_long:<field>·content_locator_invalid·content_json_invalid·content_extraction_failed·content_empty
401directory_bearer_token_required·invalid_directory_token
403tenant_not_allowed·organization_not_allowed·clearance_not_allowed·content_locator_host_not_allowed
409checksum_mismatch·size_bytes_mismatch·ingestion_rejected
413 / 502content_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.tspropagateRagDocumentPublication()(결과 확인) · scheduleRagDocumentPublication()(비차단). 연동 비활성·origin 미설정은 경고 없이 건너뛴다
트리거services/processGovernanceService.ts의 approveProcess(게시 확정)폐기 시 삭제 전파(retireProcess)와 짝. 색인 실패가 게시를 막지 않는다
수집 originRAG_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.tsDS 확장 후 토큰 검증 단위 테스트
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.tsRAG_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.tsGET /api/rag/v1/documents/:nodeId?version= — 서비스 토큰(RAG_SERVICE_TOKEN)으로만 접근, 게시본 불변 스냅샷을 { publication, content }로 반환(Cache-Control: no-store)
수집 payloadserver/ragPublicationService.tsloadRagDocument() — portalId·sourceDocumentId·sourceVersion(게시본 version)·tenantId·organizationIds·securityLevel·classification·resourceType·contentLocator(자기 엔드포인트 URL)·checksum(sha256)·contentType·sizeBytes (식별자는 §2-1-2 투영식)
식별자 투영server/rag/ragIdentity.tsuuidV5 + 테넌트·조직·노드·첨부 식별자. PortalConsole 발급부와 같은 상수·벡터(server/rag/ragIdentity.test.ts)
삭제 전파동일notifyRagCenterDocumentDeleted() — rag:admin scope 위임 토큰으로 DELETE /api/v1/admin/documents/{id} 호출, 실패는 결과로만 반환(호출 흐름 유지)
삭제 전파 예약server/services/ragDeletionNotifier.tsscheduleRagDocumentDeletion()(비차단) · propagateRagDocumentDeletion()(결과 확인). 연동 비활성은 경고 없이 건너뛴다(server/services/ragDeletionNotifier.test.ts)
전파 훅processGovernanceService.ts(폐기) · routers/processes.ts(첨부 삭제) · routers/notices.ts(공지 첨부 삭제)첨부는 asset 단위 문서 1건 → attachmentSourceDocumentId(assetId)
조직 조회repositories/organizationRepository.tsgetOrganizationByCode() — 노드 organizationCode를 directoryId로 옮겨 §2-1-2 조직 투영에 넘긴다
테스트server/ragPublication.test.tspayload·체크섬·식별자 투영(테넌트·조직·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사용자 토큰 재사용 가능성 — 해소(2026-09-26): 사용자 토큰을 재사용하지 않고 위임 토큰 경로를 채택했다. PC는 directorySubjectFromUser()로 sub만 넘기고 PortalConsole이 aud=rag-center·scope=rag:read|rag:admin 토큰을 발급한다(개발 스택 소비자 등록으로 확인, §5 P0). DS 서비스 토큰 일반화는 불필요해졌다✔
2공지 첨부 조직 버킷 — 해소(2026-09-26): 공지는 **공용 버킷(public)**으로 색인하고 RAGCenter RLS가 같은 테넌트의 모든 조직에 허용한다(등급 조건 유지, organizationIds 1건 계약 그대로). 남은 것: ① 미등록 조직 코드는 현행 unassigned 유지(운영이 디렉터리 원장 정비) ② 문서가 여러 조직에 동시 노출되어야 하는 새 요구가 생기면 그때 다중 조직(organizationIds 배열) 지원을 검토부분 해소 · 제품 결정
3보안등급 매핑 — 해소(§2-5): PC securityLevel(PUBLIC/INTERNAL/CONFIDENTIAL/SECRET) = RAGCenter clearance_level 이름 1:1, classification은 자유 텍스트 + <등급>: 접두사 일치 강제. 등급 추가 시 RLS enum·ClearanceLevel 동시 확장✔
4수집 트리거 소유 — 해소(2026-09-26): PC 이벤트(push)가 정본이다. 게시 확정·ACTIVE 업로드·공지 공개 시 POST /api/v1/documents를 예약 호출하고, 비 2xx는 "색인 안 됨"으로 다음 게시에서 재시도한다. CDC는 쓰지 않는다(server/**에 CDC 참조 없음 — RagService/services/cdc.py는 RAGCenter 소유 기능)✔
5삭제 전파 방식 — 해소(2026-09-26): RAGCenter admin DELETE 채택. 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에 스트리밍을 얹지 않는다
8Rate limit·동시성 — 대량 색인 시 상한잔여 · 양측 운영 정책 — 제안: 게이트웨이의 (portalId, purpose) 분당·일별 토큰 한도는 그대로 두고, PC 쪽은 rag.ask 동시 요청 상한과 대량 색인(게시 확정·일괄 가져오기)의 시간 분산을 운영 정책으로 정한다. 색인은 이미 비차단·백그라운드라 사용자 흐름을 막지 않는다
9첨부 원본 소스 — 해소(§2-5): GET /api/rag/v1/attachments/:assetId가 서명 URL을 서버에서 받아 원본 바이트를 스트리밍한다(302 금지 준수, 실패 502). checksum은 업로드 시 process_node_attachments·notice_attachments에 보존한다✔
10색인 트리거 배선 — 해소(§2-5): 노드 게시 확정 + 첨부(확정본 전체·ACTIVE 업로드·PUBLISHED 공지·비공개 전환 시 삭제)✔

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)를 만족하고 배포 스택에 기본 포함될 때 다시 판단