// src/main/services/RAGService.ts // Phase 13.2: 로컬 RAG — 문서 임베딩 + 코사인 유사도 검색 + LLM 컨텍스트 주입. // // 이 서비스는 오케스트레이션만 한다. 세부 책임은 포트/순수 모듈로 나뉜다. // - rag/document-text.ts : 원문 추출(PDF/DOCX)·청킹 (순수 함수) // - rag/embedding-port.ts: 임베딩 포트 + Ollama 어댑터(모델 존재 확인 포함) // - rag/chunk-store.ts : 문서·청크 저장소 포트 + SQLite 구현 // - rag/retrieval.ts : 유사도 순위·답변 프롬프트 (순수 함수) import { EventEmitter } from 'events' import path from 'path' import fs from 'fs' import { getLogger } from './LoggerService' import { getLlmGateway } from './llm/LlmGateway' import { getOllamaServerUrl } from './LocalLLMService' import { getMainWindow } from '../windows/WindowManager' import { getCloudSyncService } from './CloudSyncService' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { RAGDocument, RAGQueryResult, RAGState, RAGStateInfo, RAGIndexProgress } from '@d3ro/core/types' import { chunkText, documentParseError, extractBinaryDocumentText, fileTypeForExtension, isBinaryDocumentType, isPlainTextType, unsupportedFormatError, type RagFileType, } from './rag/document-text' import { OllamaEmbeddingAdapter, type EmbeddingPort } from './rag/embedding-port' import { SqliteChunkStore, type ChunkStore } from './rag/chunk-store' import { buildAnswerSystemPrompt, rankChunks } from './rag/retrieval' const logger = getLogger('RAGService') /** 검색 기본 topK */ const DEFAULT_TOP_K = 5 /** 청크마다 이벤트 루프를 양보하는 시간(ms) — UI 블로킹 방지 */ const INDEX_YIELD_MS = 10 /** 색인 실패 알림 (서비스 이벤트 'index-failed' 와 IPC rag:indexFailed 의 내용) */ export interface RAGIndexFailure { documentId: string fileName: string code: ErrorCode message: string } /** 답변 생성기 — 사용자가 고른 LLM 백엔드(LlmGateway)의 최소 면 */ export interface RagAnswerGenerator { generate(text: string, options?: { systemPrompt?: string }): Promise<{ text: string }> } /** 로컬 변경을 다른 기기로 알리는 면 (CloudSyncService의 최소 면) */ export interface RagSyncSink { pushOne(entity: 'knowledge_documents', id: string): void pushDelete(entity: 'knowledge_documents', id: string): void } export interface RAGServiceDeps { store: ChunkStore embedder: EmbeddingPort /** 호출 때마다 현재 게이트웨이를 받는다(백엔드 전환을 따른다) */ answerer: () => RagAnswerGenerator sync: () => RagSyncSink /** 렌더러로 이벤트 전송 */ notify: (channel: string, data: unknown) => void readFile: (filePath: string) => Promise fileExists: (filePath: string) => boolean /** Pro+ 라이선스 확인. 허용되지 않으면 D3ROError를 던진다. */ assertLicensed: () => Promise /** 청크 사이 양보 시간(ms) */ yieldMs: number } interface RAGServiceEvents { 'index-failed': (failure: RAGIndexFailure) => void } function errorText(err: unknown): string { return err instanceof Error ? err.message : String(err) } function sendToMainWindow(channel: string, data: unknown): void { try { const mainWindow = getMainWindow() if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, data) } catch { // 창이 없거나 닫히는 중 — 알림만 건너뛴다 } } async function assertLocalRagLicense(): Promise { try { const { getLicenseService } = await import('./LicenseService') const { Feature } = await import('@d3ro/core/types') const license = getLicenseService() const access = license.canUse(Feature.LOCAL_RAG) if (!access.allowed) { license.promptUpgrade(Feature.LOCAL_RAG, 'tier_required') throw new D3ROError(ErrorCode.FeatureNotAvailable, 'Pro+ required for Local RAG') } } catch (err) { if (err instanceof D3ROError) throw err } } export function defaultRAGServiceDeps(): RAGServiceDeps { return { store: new SqliteChunkStore(), embedder: new OllamaEmbeddingAdapter({ serverUrl: getOllamaServerUrl }), answerer: () => getLlmGateway(), sync: () => getCloudSyncService(), notify: sendToMainWindow, readFile: (filePath) => fs.promises.readFile(filePath), fileExists: (filePath) => fs.existsSync(filePath), assertLicensed: assertLocalRagLicense, yieldMs: INDEX_YIELD_MS, } } export class RAGService extends EventEmitter { private readonly deps: RAGServiceDeps /** 진행 중인 색인·질의 수. 상태는 여기서 파생한다 — 겹쳐 실행돼도 서로의 상태를 덮지 않는다. */ private activeIndexing = 0 private activeQueries = 0 constructor(deps: RAGServiceDeps = defaultRAGServiceDeps()) { super() this.deps = deps } get state(): RAGState { if (this.activeIndexing > 0) return 'indexing' if (this.activeQueries > 0) return 'querying' return 'idle' } getStateInfo(): RAGStateInfo { return { state: this.state, ...this.deps.store.counts() } } getDocuments(): RAGDocument[] { return this.deps.store.listDocuments() } /** * 문서 추가 + 인덱싱 (청킹 → 임베딩 → DB 저장) */ async addDocument(filePath: string): Promise { await this.deps.assertLicensed() const ext = path.extname(filePath).toLowerCase() const fileType = fileTypeForExtension(ext) if (!fileType) { throw new D3ROError(ErrorCode.RAGUnsupportedFormat, `Unsupported format: ${ext}. Supported: .txt, .md, .pdf, .docx`) } const fileName = path.basename(filePath) const docId = crypto.randomUUID() // 텍스트 추출 let content: string try { content = await this._extractText(filePath, fileType) } catch (err) { if (err instanceof D3ROError) throw err throw new D3ROError(ErrorCode.RAGIndexingFailed, `Text extraction failed: ${errorText(err)}`) } if (!content || content.trim().length < 20) { throw new D3ROError(ErrorCode.RAGIndexingFailed, 'No readable text content found in document') } logger.info(`RAG text extracted: ${fileName} (${content.length} chars)`) const chunks = chunkText(content) if (chunks.length === 0) { throw new D3ROError(ErrorCode.RAGIndexingFailed, 'Document produced no valid text chunks') } const doc: RAGDocument = { id: docId, fileName, filePath, fileType, chunkCount: chunks.length, indexed: false, indexedAt: null, addedAt: Date.now(), } this.deps.store.insertDocument(doc) // 청크 원문을 먼저 저장한다 — 임베딩이 실패해도 원문은 남아 재색인·기기 간 동기화가 가능하다. this.deps.store.replaceChunks(docId, chunks) this.deps.sync().pushOne('knowledge_documents', docId) // 비동기 인덱싱 (임베딩 생성). 실패는 rag:indexFailed 로 렌더러에 알린다. this._embedStoredChunks(docId, fileName).catch((err) => { logger.error(`Indexing failed for ${fileName}:`, err) }) return doc } /** * 문서 제거 (청크 포함) */ removeDocument(documentId: string): void { this.removeRemote(documentId) this.deps.sync().pushDelete('knowledge_documents', documentId) logger.info(`RAG document removed: ${documentId}`) } /** 동기화: 다른 기기에서 지운 문서를 지운다(outbox에 넣지 않는다). */ removeRemote(documentId: string): boolean { return this.deps.store.removeDocument(documentId) } /** * 동기화: 다른 기기(모바일·웹)의 지식 문서를 원문 청크로 받아 저장하고, 이 기기의 임베딩 모델로 색인한다. * 임베딩 공간이 기기마다 달라 벡터는 옮기지 않는다. 원본 파일은 없으므로 filePath는 비워 둔다. */ applyRemoteDocument(doc: { id: string fileName: string fileType: RAGDocument['fileType'] chunks: string[] addedAt: number }): boolean { const { store } = this.deps if (store.hasDocument(doc.id)) return false const chunks = doc.chunks.filter((c) => c.trim().length > 0) if (chunks.length === 0) return false store.insertDocument({ id: doc.id, fileName: doc.fileName, filePath: '', fileType: doc.fileType, chunkCount: chunks.length, indexed: false, indexedAt: null, addedAt: doc.addedAt, }) store.replaceChunks(doc.id, chunks) this._embedStoredChunks(doc.id, doc.fileName).catch((err) => { logger.warn(`Synced document ${doc.fileName} is stored but not embedded yet:`, err) }) return true } /** 저장된 청크 원문(chunkIndex 순) — 동기화 업로드용 */ getStoredChunks(documentId: string): string[] { return this.deps.store.listChunks(documentId).map((c) => c.content) } /** * 문서 재인덱싱 */ async reindex(documentId: string): Promise { const doc = this.deps.store.getDocument(documentId) if (!doc) { throw new D3ROError(ErrorCode.RAGDocumentNotFound, 'Document not found') } // 원본 파일이 없으면(다른 기기에서 동기화된 문서 등) 저장된 원문 청크로 다시 임베딩한다. if (!doc.filePath || !this.deps.fileExists(doc.filePath)) { await this._embedStoredChunks(documentId, doc.fileName, { force: true }) return } // 텍스트 재추출 + 재인덱싱 const content = await this._extractText(doc.filePath, doc.fileType) const chunks = chunkText(content) this.deps.store.updateDocument(documentId, { chunkCount: chunks.length, indexed: false }) this.deps.store.replaceChunks(documentId, chunks) this.deps.sync().pushOne('knowledge_documents', documentId) await this._embedStoredChunks(documentId, doc.fileName) } /** * 벡터 검색 + LLM 답변 생성 */ async query(queryText: string, topK: number = DEFAULT_TOP_K): Promise { this.activeQueries++ try { // 원문만 있고 아직 임베딩되지 않은 청크는 검색 대상이 아니다. const allChunks = this.deps.store.listEmbeddedChunks() if (allChunks.length === 0) { throw new D3ROError(ErrorCode.RAGQueryFailed, 'No indexed chunks to query') } const queryEmbedding = await this.deps.embedder.embed(queryText) const fileNames = new Map(this.deps.store.listDocuments().map((d) => [d.id, d.fileName])) const topResults = rankChunks(queryEmbedding, allChunks, fileNames, topK) const systemPrompt = buildAnswerSystemPrompt(topResults) // 답변 LLM 은 사용자가 고른 백엔드를 따른다(로컬 선택 시 문서 조각을 클라우드로 보내지 않는다) const result = await this.deps.answerer().generate(queryText, { systemPrompt }) return { query: queryText, results: topResults, answer: result.text.trim() } } finally { this.activeQueries-- } } // ── 내부 메서드 ── private async _extractText(filePath: string, fileType: RagFileType): Promise { if (isPlainTextType(fileType)) { return (await this.deps.readFile(filePath)).toString('utf-8') } if (!isBinaryDocumentType(fileType)) throw unsupportedFormatError(fileType) try { return extractBinaryDocumentText(await this.deps.readFile(filePath), fileType) } catch (err) { logger.warn(`${fileType.toUpperCase()} parsing failed: ${errorText(err)}`) throw documentParseError(fileType, err) } } /** * 저장된 청크 중 임베딩이 없는 것(force면 전부)을 이 기기의 임베딩 모델로 채운다. * 실패하면 문서를 색인 안 됨으로 두고 'index-failed'·rag:indexFailed 를 알린 뒤, * 렌더러의 진행 표시가 멈춰 있지 않도록 rag:indexComplete(실행 종료)도 보낸다. */ private async _embedStoredChunks(docId: string, fileName: string, options: { force?: boolean } = {}): Promise { this.activeIndexing++ try { await this._indexChunks(docId, fileName, options) } catch (err) { this._reportIndexFailure(docId, fileName, err) throw err } finally { this.activeIndexing-- } } private async _indexChunks(docId: string, fileName: string, options: { force?: boolean }): Promise { const { store, embedder, notify } = this.deps const chunks = store.listChunks(docId) logger.info(`RAG indexing started: ${fileName} (${chunks.length} chunks)`) // 임베딩할 청크가 있으면 먼저 모델을 확인한다 — 모델이 없으면 청크마다 404를 기다리지 않고 바로 실패한다. if (chunks.some((c) => options.force || c.embedding.length === 0)) { try { await embedder.ensureModel() } catch (err) { store.updateDocument(docId, { indexed: false, indexedAt: null }) throw err } } // 초기 진행률 즉시 전송 notify(IPC_CHANNELS.RAG.INDEX_PROGRESS, { documentId: docId, fileName, currentChunk: 0, totalChunks: chunks.length, percent: 0, } satisfies RAGIndexProgress) let successCount = 0 for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i] if (chunk.embedding.length > 0 && !options.force) { successCount++ continue } try { store.setEmbedding(chunk.id, await embedder.embed(chunk.content)) successCount++ } catch (err) { logger.warn(`RAG embedding failed for chunk ${i}/${chunks.length} of ${fileName}:`, err) // 개별 청크 실패는 건너뛰고 계속 진행 — 원문은 남아 있어 재색인할 수 있다 } notify(IPC_CHANNELS.RAG.INDEX_PROGRESS, { documentId: docId, fileName, currentChunk: i + 1, totalChunks: chunks.length, percent: Math.round(((i + 1) / chunks.length) * 100), } satisfies RAGIndexProgress) await new Promise((r) => setTimeout(r, this.deps.yieldMs)) } // 한 청크도 임베딩하지 못했으면(임베딩 서버 없음 등) 색인됐다고 표시하지 않는다. // indexed=true + 0 chunks 로 두면 문서가 검색 가능한 것처럼 보이지만 질의에 걸리지 않는다. if (successCount === 0) { store.updateDocument(docId, { indexed: false, indexedAt: null }) throw new D3ROError(ErrorCode.RAGEmbeddingFailed, `No chunk of ${fileName} could be embedded (${chunks.length} attempted)`) } // 인덱싱 완료 표시 (chunkCount는 원문 청크 수 — 서버·모바일과 같은 기준) store.updateDocument(docId, { indexed: true, indexedAt: Date.now(), chunkCount: chunks.length }) notify(IPC_CHANNELS.RAG.INDEX_COMPLETE, { documentId: docId, fileName }) logger.info(`RAG indexing complete: ${fileName} (${successCount}/${chunks.length} chunks embedded)`) } private _reportIndexFailure(documentId: string, fileName: string, err: unknown): void { const failure: RAGIndexFailure = { documentId, fileName, code: err instanceof D3ROError ? err.code : ErrorCode.RAGIndexingFailed, message: errorText(err), } this.deps.notify(IPC_CHANNELS.RAG.INDEX_FAILED, failure) this.deps.notify(IPC_CHANNELS.RAG.INDEX_COMPLETE, { documentId, fileName }) this.emit('index-failed', failure) } dispose(): void { this.removeAllListeners() } // ── 타입 안전 이벤트 ── override on(event: K, listener: RAGServiceEvents[K]): this { return super.on(event, listener) } override emit(event: K, ...args: Parameters): boolean { return super.emit(event, ...args) } } // ── 싱글톤 ── let instance: RAGService | null = null /** 테스트용: 싱글톤을 비운다. deps를 주면 그 포트로 새 인스턴스를 만든다. */ export function resetRAGServiceForTests(deps?: Partial): void { if (instance) instance.removeAllListeners() instance = deps ? new RAGService({ ...defaultRAGServiceDeps(), ...deps }) : null } export function getRAGService(): RAGService { if (!instance) { instance = new RAGService() } return instance }