From d550401fe1632eb0d97061d807ae74670228cc7f Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 1 Nov 2025 12:17:37 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK.md | 67 ------------------------ src/app.ts | 4 +- src/config.ts | 1 + src/controllers/ai.controller.ts | 19 ++++--- src/controllers/ai.v2.controller.ts | 2 +- src/controllers/search.controller.ts | 1 + src/llm/index.ts | 13 ++--- src/llm/model-registry.ts | 4 +- src/llm/providers/gemini.ts | 10 ++-- src/llm/providers/openai-responses.ts | 55 +++++++++---------- src/llm/types.ts | 2 +- src/middlewares/auth.middleware.ts | 1 + src/prompts/qa.prompts.ts | 2 + src/prompts/qa.v2.prompts.ts | 8 +-- src/repositories/persona.repository.ts | 1 + src/repositories/post.repository.ts | 44 +++++++++------- src/repositories/user.repository.ts | 2 +- src/routes/ai.routes.ts | 1 + src/routes/ai.v2.routes.ts | 2 +- src/routes/search.routes.ts | 4 +- src/server.ts | 1 + src/services/embedding.service.ts | 6 ++- src/services/hybrid-search.service.ts | 21 ++++---- src/services/qa.service.ts | 7 ++- src/services/qa.v2.service.ts | 17 +++--- src/services/retrieval-presets.ts | 6 +-- src/services/search-aggregate.service.ts | 6 +-- src/services/search-plan.service.ts | 57 ++++++++++---------- src/services/semantic-search.service.ts | 1 + src/types/ai.types.ts | 6 +-- src/types/ai.v2.types.ts | 22 ++++---- src/utils/cost.ts | 9 ++-- src/utils/db.ts | 1 + src/utils/debug-logger.ts | 1 + src/utils/time.ts | 35 +++++++------ src/utils/tokenizer.ts | 9 ++-- src/worker/queue-consumer.ts | 6 ++- 37 files changed, 213 insertions(+), 241 deletions(-) diff --git a/TASK.md b/TASK.md index a8e2463..e69de29 100644 --- a/TASK.md +++ b/TASK.md @@ -1,67 +0,0 @@ -## Redis 기반 임베딩 큐 도입 계획 - -### 1. 적합성 검토 -- 기존 Node.js 임베딩 API는 유지하면서도 Redis 큐를 사이에 두면 Spring Boot → Node.js 간의 느슨한 결합과 재시도를 확보할 수 있음. -- Spring Boot 프로듀서는 이미 Redis LPUSH 로직을 보유하고 있어 추가 개발 부담이 낮음. -- Node.js 컨슈머는 BRPOP 기반 무한 루프로 구현 가능하며, 현재 OpenAI 임베딩 호출 흐름과 자연스럽게 연결됨. -- Redis 리스트는 선입선출 특성을 제공하고, 장애 시 실패 큐(`embedding:failed`)로 분리하여 운영팀이 모니터링/재처리하기 용이함. -- 고가용성 Redis 인프라가 전제돼야 하며, 큐 적체/중복 처리에 대한 모니터링과 알람 체계가 필요함. - -### 2. 아키텍처 개요 -``` -[Spring Boot] → LPUSH → [Redis List embedding:queue] → BRPOP → [Node.js Consumer] → OpenAI 임베딩 → DB 저장 - ↘ 실패 시 LPUSH embedding:failed -``` - -### 3. 구현 계획 -1. **컨슈머 워커 초안 작성** - - `services/embedding.service.ts` 를 호출하는 `processEmbeddingQueue` 모듈 작성. - - Graceful shutdown, Concurrency(동시 워커 수) 옵션, 로깅(성공/실패/처리시간)을 포함. -2. **큐 메시지 스키마 확정** - - `post_id`, `title`, `content`, `retryCount` 등을 포함하는 JSON 구조 정의 및 문서화. - - 추후 schema 변경 대비 버전 필드 도입 검토. -3. **실패 처리 및 재시도 정책** - - 실패 시 `embedding:failed` 로 이동 후 경고 로그 기록. - - 재시도 워커(주기적 RPOP → LPUSH) 또는 Ops 수동 트리거 전략 결정. -4. **운영 모니터링** - - `LLEN embedding:queue`, `embedding:failed` 메트릭을 Prometheus/Grafana 또는 기존 모니터링에 연동. - - 알람 기준: 큐 길이 임계치, 실패 큐 누적, 워커 미응답. -5. **배포 전략** - - Node.js 컨슈머를 기존 서버 프로세스와 분리( Docker 컨테이너)하여 독립 운영. - - Spring Boot 측은 이미 구현된 LPUSH 로직을 활성화하고, 기존 REST 임베딩 호출은 점진적으로 감축. - -### 4. 추가 고려사항 -- 멱등성 확보를 위해 컨슈머 처리 완료 후 Redis 측에서 메시지를 제거했는지(이미 BRPOP 로 제거) 확인하고, 실패 재처리 시 중복 삽입 방지 로직 검토. -- OpenAI API 호출 실패 시 exponential backoff 적용 여부. -- 긴 콘텐츠 임베딩 시 chunking 로직(`chunkText`)과 큐 메시지 크기 제한 검토. -- 보안: Redis 접근 제어, TLS 필요 여부 확인. - -### 5. 다음 단계 -- [x] Node.js 컨슈머 초안 코드 작성 및 환경 변수(`REDIS_URL`, `EMBEDDING_QUEUE_KEY`) 정리. -- [ ] 개발 환경에서 Redis 로컬 인스턴스와 통합 테스트 진행. -- [ ] 모니터링/알람 구성 논의. - -### 6. 컨테이너/배포 설계 -- **기본 이미지 재사용**: 기존 `Dockerfile` 로 빌드한 동일 이미지를 `api`(Express)와 `worker`(컨슈머)가 공유, 각 컨테이너는 `command` 만 다르게 지정. -- **엔트리포인트 분리**: `src/worker/queue-consumer.ts` 추가 → `tsc` 결과가 `dist/worker/queue-consumer.js` 로 생성되도록 빌드 경로 확인. `package.json` 에 `worker` 스크립트(`node dist/worker/queue-consumer.js`) 등록. -- **Docker Compose 초안** - ```yaml - services: - api: - build: . - command: ["node", "dist/server.js"] - ports: ["3000:3000"] - env_file: .env - depends_on: [redis] - - worker: - build: . - command: ["node", "dist/worker/queue-consumer.js"] - env_file: .env - depends_on: [redis] - - redis: - image: redis:7-alpine - ``` -- **환경 변수 공유**: `.env` 에 Redis 접속 정보(`REDIS_HOST`, `REDIS_PORT`, `REDIS_URL` 등)와 큐 이름, 실패 큐 이름 등을 명시하고 두 서비스 모두 로드. -- **운영 고려**: `worker` 컨테이너 스케일 아웃(예: `docker compose up --scale worker=3`)에 대비해 작업 멱등성 확인. 장애 시 개별 컨테이너 재시작 전략, 로그 수집 경로(예: stdout→EFK) 정의. diff --git a/src/app.ts b/src/app.ts index cbda0e6..17f9ddf 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,7 @@ import aiRouter from './routes/ai.routes'; import aiV2Router from './routes/ai.v2.routes'; import searchRouter from './routes/search.routes'; +// Express 애플리케이션과 공용 미들웨어를 초기화 const app: Express = express(); // CORS 설정 @@ -26,7 +27,8 @@ app.use('/ai', aiRouter); app.use('/ai/v2', aiV2Router); app.use('/search', searchRouter); -// Central Error Handler +// 중앙 에러 처리기 +// 전역 에러 처리기로 예기치 못한 서버 오류를 JSON으로 반환 app.use((err: Error, req: Request, res: Response, next: NextFunction) => { console.error(err.stack); res.status(500).json({ diff --git a/src/config.ts b/src/config.ts index 7b02ac9..a2ff720 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; dotenv.config(); +// 환경 변수를 스키마로 검증하여 타입 안전한 설정 객체 생성 const configSchema = z.object({ NODE_ENV: z.string().default('development'), PORT: z.coerce.number().default(3000), diff --git a/src/controllers/ai.controller.ts b/src/controllers/ai.controller.ts index ebb688b..6bfcec1 100644 --- a/src/controllers/ai.controller.ts +++ b/src/controllers/ai.controller.ts @@ -14,6 +14,7 @@ export const embedTitleHandler = async ( res: Response, next: NextFunction ) => { + // 포스트 제목 임베딩을 생성하고 저장 try { const { post_id, title } = req.body; await storeTitleEmbedding(post_id, title); @@ -28,6 +29,7 @@ export const embedContentHandler = async ( res: Response, next: NextFunction ) => { + // 본문을 청크 단위로 임베딩 생성 후 DB에 반영 try { const { post_id, content } = req.body; const chunks = chunkText(content); @@ -49,29 +51,30 @@ export const askHandler = async ( res: Response, next: NextFunction ) => { + // RAG 기반 QA 결과를 SSE 스트림으로 클라이언트에 전달 try { const { question, user_id, category_id, speech_tone, post_id, llm } = req.body as any; - // SSE headers and anti-buffering hints + // SSE를 위한 헤더 설정과 버퍼링 완화 옵션 res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); - // Nginx buffering off + // Nginx 버퍼링 비활성화 res.setHeader('X-Accel-Buffering', 'no'); - // Flush headers early so clients start processing immediately + // 헤더를 먼저 전송해 클라이언트 처리를 즉시 시작 (res as any).flushHeaders?.(); - // Reduce Nagle’s algorithm buffering on the socket for faster flush + // 소켓의 네이글 알고리즘 버퍼링을 줄여 전송 지연 완화 (res.socket as any)?.setNoDelay?.(true); - // Prime the SSE stream to break proxy buffering thresholds + // 프록시 버퍼링 임계값을 넘기기 위한 초기 keep-alive 전송 res.write(':ok\n\n'); const stream = await answerStream(question, user_id, category_id, speech_tone, post_id, llm); - // Manually bridge to ensure flushing of SSE deltas + // SSE 델타가 즉시 전송되도록 수동 브리징 stream.on('data', (chunk) => { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); res.write(buf); const canFlush = typeof (res as any).flush === 'function'; - // try to flush if supported by runtime/middleware + // 런타임 또는 미들웨어가 지원하면 즉시 플러시 (res as any).flush?.(); DebugLogger.log('sse', { type: 'debug.sse.write', at: Date.now(), bytes: buf.length, flushed: canFlush }); }); @@ -82,7 +85,7 @@ export const askHandler = async ( res.end(); }); - // Cleanup if client disconnects + // 클라이언트 연결이 끊기면 스트림 자원 해제 req.on('close', () => { try { stream.destroy(); diff --git a/src/controllers/ai.v2.controller.ts b/src/controllers/ai.v2.controller.ts index ed1f579..1fdf5be 100644 --- a/src/controllers/ai.v2.controller.ts +++ b/src/controllers/ai.v2.controller.ts @@ -7,6 +7,7 @@ export const askV2Handler = async ( res: Response, next: NextFunction ) => { + // 검색 계획 기반 v2 QA를 SSE로 중계 try { const { question, user_id, category_id, speech_tone, post_id, llm } = req.body as any; res.setHeader('Content-Type', 'text/event-stream'); @@ -26,4 +27,3 @@ export const askV2Handler = async ( next(error); } }; - diff --git a/src/controllers/search.controller.ts b/src/controllers/search.controller.ts index 429df17..88215bb 100644 --- a/src/controllers/search.controller.ts +++ b/src/controllers/search.controller.ts @@ -4,6 +4,7 @@ import { runHybridSearch } from '../services/hybrid-search.service'; import { runSemanticSearch } from '../services/semantic-search.service'; import { aggregatePosts } from '../services/search-aggregate.service'; +// 하이브리드 검색 API로 질문을 받아 검색 계획과 결과를 반환 export const hybridSearchHandler = async (req: Request, res: Response) => { try { const question = String(req.query.question || '').trim(); diff --git a/src/llm/index.ts b/src/llm/index.ts index 3382d74..7429044 100644 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -9,6 +9,7 @@ import config from '../config'; import { randomUUID } from 'crypto'; import { DebugLogger } from '../utils/debug-logger'; +// LLM 제공자별 스트림을 추상화하여 공통 SSE 포맷으로 반환 export const generate = async (req: GenerateRequest): Promise => { const merged = { ...req }; if (!merged.provider || !merged.model) { @@ -23,13 +24,13 @@ export const generate = async (req: GenerateRequest): Promise => { const model = merged.model as string; const provider = merged.provider as string; - // Pre-call logging: prompt tokens + estimated input cost + // 호출 전에 프롬프트 토큰 수와 예상 입력 비용을 기록 const messages = merged.messages || []; let promptTokens = 0; try { promptTokens = countChatMessagesTokens(messages as any, model); } catch { - // ignore + // 무시 } const pricing = getModelPricing(model); const estInputCost = pricing ? calcCost(promptTokens, pricing.input_per_1k) : 0; @@ -63,20 +64,20 @@ export const generate = async (req: GenerateRequest): Promise => { return s; })(); - // Wrap provider stream to accumulate output tokens + // 공급자 스트림을 감싸 출력 토큰을 집계 const outer = new PassThrough(); let buffer = ''; let outputText = ''; - // Debug: start info + // 디버그: 호출 시작 정보 try { DebugLogger.log('llm', { type: 'debug.llm.start', provider, model, messages: (messages || []).length }); } catch {} const flushBuffer = () => { - // Split by double newline to get SSE events + // 두 줄바꿈을 기준으로 SSE 이벤트 단위로 분할 const chunks = buffer.split('\n\n'); - // Keep last partial + // 마지막 미완성 조각은 버퍼에 보존 buffer = chunks.pop() || ''; for (const block of chunks) { const lines = block.split('\n'); diff --git a/src/llm/model-registry.ts b/src/llm/model-registry.ts index 14cbdfe..7587be8 100644 --- a/src/llm/model-registry.ts +++ b/src/llm/model-registry.ts @@ -5,8 +5,8 @@ type ModelEntry = { modelId: string; }; -// Minimal registry for now; can expand with tokenizer/pricing later. +// 현재는 기본 레지스트리만 제공하며 추후 토크나이저나 과금 정보로 확장 가능 const DEFAULT_CHAT: ModelEntry = { provider: 'openai', modelId: 'gpt-5-mini' }; +// 프로젝트 기본 채팅 모델 정보를 반환 export const getDefaultChat = (): ModelEntry => DEFAULT_CHAT; - diff --git a/src/llm/providers/gemini.ts b/src/llm/providers/gemini.ts index a399317..63d78c9 100644 --- a/src/llm/providers/gemini.ts +++ b/src/llm/providers/gemini.ts @@ -2,17 +2,18 @@ import { PassThrough } from 'stream'; import config from '../../config'; import { GenerateRequest } from '../types'; -// Using @google/genai per project plan; keep types loose for compatibility +// 프로젝트 계획에 따라 @google/genai를 사용하며 호환성을 위해 타입을 느슨하게 유지 // eslint-disable-next-line @typescript-eslint/no-var-requires const { GoogleGenAI } = require('@google/genai'); const buildPromptFromMessages = (messages: { role: string; content: string }[]) => { - // Simple concatenation preserving roles + // 역할 정보를 유지한 채 단순 연결 return messages .map((m) => `[${m.role}]\n${m.content}`) .join('\n\n'); }; +// Gemini SDK를 호출해 응답 텍스트를 SSE로 분할 export const generateGeminiStream = async (req: GenerateRequest): Promise => { const stream = new PassThrough(); try { @@ -37,7 +38,7 @@ export const generateGeminiStream = async (req: GenerateRequest): Promise 0 ? { thinkingConfig: { thinkingBudget } } : {}; - // Non-streaming first, then chunk SSE + // 먼저 동기 호출로 응답을 받고 이후 SSE 조각으로 분할 const result = await ai.models.generateContent({ model: modelId, contents: [ @@ -50,7 +51,7 @@ export const generateGeminiStream = async (req: GenerateRequest): Promise { - // Convert simple chat-style messages to Responses API input format - // Responses API expects 'input_text' as the content type (not 'text'). + // 단순 채팅 메시지를 Responses API 입력 구조로 변환 + // Responses API는 content 타입으로 'text'가 아닌 'input_text'를 요구함 return messages.map((m) => ({ role: m.role, content: [{ type: 'input_text', text: m.content }], @@ -16,7 +16,7 @@ const toResponsesInput = (messages: OpenAIStyleMessage[] = []) => { }; const toResponsesTools = (tools: OpenAIStyleTool[] = []) => { - // Map Chat Completions style tool definitions to Responses API format + // Chat Completions 형식의 툴 정의를 Responses API 형식으로 변환 // Chat: { type: 'function', function: { name, description, parameters } } // Responses: { type: 'function', name, description, parameters } return tools @@ -29,9 +29,10 @@ const toResponsesTools = (tools: OpenAIStyleTool[] = []) => { })); }; +// OpenAI Responses API를 사용해 SSE 스트림을 구성 export const generateOpenAIStream = async (req: GenerateRequest): Promise => { const stream = new PassThrough(); - // Guard to avoid writing after stream end + // 스트림 종료 후에도 쓰지 않도록 보호 장치 설정 let closed = false; const safeWrite = (chunk: string) => { if (!closed && !stream.writableEnded && !stream.destroyed) { @@ -50,10 +51,10 @@ export const generateOpenAIStream = async (req: GenerateRequest): Promise 0 ? toResponsesTools(toolsChat) : undefined, max_output_tokens: req.options?.max_output_tokens, }; - // GPT-5 family: omit temperature/top_p; allow reasoning/text controls + // GPT-5 계열은 temperature/top_p 없이 추론·텍스트 옵션만 전달 if (req.options?.reasoning_effort) { respParams.reasoning = { effort: req.options.reasoning_effort }; } else { @@ -88,7 +89,7 @@ export const generateOpenAIStream = async (req: GenerateRequest): Promise { const text = typeof ev === 'string' ? ev : ev?.delta ?? ''; if (text) { safeWrite(`event: answer\n`); safeWrite(`data: ${JSON.stringify(text)}\n\n`); - // try { console.log(JSON.stringify({ type: 'debug.openai.delta', len: String(text).length, at: Date.now() })); } catch {} - // if (!loggedFirstDelta) { - // try { console.log(JSON.stringify({ type: 'debug.openai.delta', len: String(text).length })); } catch {} - // loggedFirstDelta = true; - // } } }); - // Stream tool-call arguments as answer chunks to maintain SSE shape + // 툴 호출 인수 델타를 SSE 답변 이벤트로 전달 responsesStream.on('response.tool_call.delta', (ev: any) => { const argsDelta = ev?.arguments_delta || ev?.arguments || ev?.delta || ''; if (argsDelta) { @@ -120,7 +115,7 @@ export const generateOpenAIStream = async (req: GenerateRequest): Promise { const args = ev?.arguments || ev?.arguments_delta || ''; if (args) { @@ -129,27 +124,27 @@ export const generateOpenAIStream = async (req: GenerateRequest): Promise { try { const m = typeof msg === 'string' ? JSON.parse(msg) : msg; if (!m) return; - // Prefer explicit output_text delta + // output_text 델타가 있으면 우선 처리 if (m.type === 'response.output_text.delta' && m.delta) { safeWrite(`event: answer\n`); safeWrite(`data: ${JSON.stringify(m.delta)}\n\n`); } - // Some SDKs may emit full output_text chunk at once + // 일부 SDK는 전체 output_text를 한 번에 전송할 수 있음 else if (m.type === 'response.output_text' && typeof m.text === 'string') { safeWrite(`event: answer\n`); safeWrite(`data: ${JSON.stringify(m.text)}\n\n`); } - // Generic delta fallback + // 일반 델타 이벤트에 대한 폴백 처리 else if (m.type === 'response.delta' && typeof m.delta === 'string') { safeWrite(`event: answer\n`); safeWrite(`data: ${JSON.stringify(m.delta)}\n\n`); } - // Log for visibility + // 관찰을 위해 로그 남기기 DebugLogger.log('openai', { type: 'debug.openai.msg', mtype: m.type, @@ -178,7 +173,7 @@ export const generateOpenAIStream = async (req: GenerateRequest): Promise { try { await responsesStream.done(); @@ -186,12 +181,12 @@ export const generateOpenAIStream = async (req: GenerateRequest): Promise { try { for await (const chunk of chatStream) { diff --git a/src/llm/types.ts b/src/llm/types.ts index 3c12a6f..291aa1f 100644 --- a/src/llm/types.ts +++ b/src/llm/types.ts @@ -23,7 +23,7 @@ export type GenerateRequest = { temperature?: number; top_p?: number; max_output_tokens?: number; - // GPT-5 family specific controls + // GPT-5 계열 전용 제어 옵션 reasoning_effort?: 'minimal' | 'low' | 'medium' | 'high'; text_verbosity?: 'low' | 'medium' | 'high'; }; diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 9aa2d3f..432ed18 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -6,6 +6,7 @@ export interface AuthRequest extends Request { user?: string | jwt.JwtPayload; } +// Bearer 토큰을 검증해 요청 사용자 정보를 주입 export const authMiddleware = ( req: AuthRequest, res: Response, diff --git a/src/prompts/qa.prompts.ts b/src/prompts/qa.prompts.ts index 18c8722..dfea2cd 100644 --- a/src/prompts/qa.prompts.ts +++ b/src/prompts/qa.prompts.ts @@ -51,6 +51,7 @@ const buildBlogMetaSection = (meta?: BlogMetadata): { block: string; topicsLine: }; }; +// 단일 포스트를 중심으로 QA 시스템 프롬프트를 구성 export const createPostContextPrompt = ( post: Post, processedContent: string, @@ -163,6 +164,7 @@ const buildRetrievalSummary = ( return lines.join('\n'); }; +// 검색 결과 청크를 활용한 RAG 시스템 프롬프트를 생성 export const createRagPrompt = ( question: string, similarChunks: RagContextChunk[], diff --git a/src/prompts/qa.v2.prompts.ts b/src/prompts/qa.v2.prompts.ts index 8068b80..15531bd 100644 --- a/src/prompts/qa.v2.prompts.ts +++ b/src/prompts/qa.v2.prompts.ts @@ -1,7 +1,8 @@ -// Search Plan prompt templates for v2 +// v2 검색 계획 프롬프트 템플릿 모음 import { SearchPlan } from '../types/ai.v2.types'; +// Responses API에 전달할 검색 계획 JSON 스키마 정의 export const getSearchPlanSchemaJson = (): Record => ({ type: 'object', additionalProperties: false, @@ -31,8 +32,8 @@ export const getSearchPlanSchemaJson = (): Record => ({ }, required: ['enabled', 'retrieval_bias', 'max_rewrites', 'max_keywords'], }, - // Only time is allowed under filters. Responses JSON Schema requires closed objects - // with explicit required fields at each level. + // Responses JSON 스키마 제약으로 filters에는 time만 허용 + // 각 계층마다 필요한 필드를 명시해야 함 filters: { type: 'object', additionalProperties: false, @@ -137,6 +138,7 @@ export const getSearchPlanSchemaJson = (): Record => ({ required: ['mode', 'top_k', 'threshold', 'weights', 'rewrites', 'keywords', 'hybrid', 'filters', 'sort', 'limit'], }); +// 검색 계획 생성을 위해 LLM에 전달할 프롬프트 문자열 구성 export const buildSearchPlanPrompt = (params: { now_utc: string; now_kst: string; diff --git a/src/repositories/persona.repository.ts b/src/repositories/persona.repository.ts index 3e9a713..2204584 100644 --- a/src/repositories/persona.repository.ts +++ b/src/repositories/persona.repository.ts @@ -5,6 +5,7 @@ export interface Persona { description: string; } +// 사용자별 커스텀 페르소나 정보를 조회 export const findPersonaById = async ( personaId: number, userId: string diff --git a/src/repositories/post.repository.ts b/src/repositories/post.repository.ts index ce6f5ac..7209a4c 100644 --- a/src/repositories/post.repository.ts +++ b/src/repositories/post.repository.ts @@ -1,7 +1,7 @@ import pgvector from 'pgvector/pg'; import { getDb } from '../utils/db'; -// ========= INTERFACES ========= +// ========= 인터페이스 정의 ========= export interface Post { id: number; title: string; @@ -39,11 +39,12 @@ export interface TextSearchHit { textScore: number; } -// ========= READ QUERIES ========= +// ========= 조회 쿼리 ========= +// 포스트 ID로 본문과 메타데이터를 조회 export const findPostById = async (postId: number): Promise => { const pool = getDb(); - // Some databases may not have a `tags` column on blog_post. - // Select existing columns and populate `tags` as an empty array fallback. + // 일부 데이터베이스에는 blog_post 테이블에 `tags` 컬럼이 없을 수 있음 + // 존재하는 컬럼만 조회하고 `tags`는 빈 배열로 대체해 안정성을 확보 const { rows } = await pool.query( 'SELECT id, title, content, created_at, user_id, is_public FROM blog_post WHERE id = $1', [postId] @@ -55,7 +56,7 @@ export const findPostById = async (postId: number): Promise => { id: row.id, title: row.title, content: row.content, - // Fallback: DB has no tags column; keep empty list so prompts render gracefully + // DB에 tags 컬럼이 없을 경우 프롬프트가 자연스럽도록 빈 배열 유지 tags: Array.isArray(row.tags) ? row.tags : [], created_at: row.created_at, user_id: row.user_id, @@ -64,6 +65,7 @@ export const findPostById = async (postId: number): Promise => { return post; }; +// 기본 RAG 경로에서 사용할 상위 유사 청크를 조회 export const findSimilarChunks = async ( userId: string, questionEmbedding: number[], @@ -129,7 +131,8 @@ export const findSimilarChunks = async ( })); }; -// ========= WRITE QUERIES ========= +// ========= 쓰기 쿼리 ========= +// 제목 임베딩을 upsert로 저장 export const storeTitleEmbedding = async (postId: number, embedding: number[]) => { const pool = getDb(); await pool.query( @@ -140,6 +143,7 @@ export const storeTitleEmbedding = async (postId: number, embedding: number[]) = ); }; +// 본문 청크 임베딩을 트랜잭션으로 갱신 export const storeContentEmbeddings = async ( postId: number, chunks: string[], @@ -169,15 +173,16 @@ export const storeContentEmbeddings = async ( } }; -// ========= READ QUERIES (V2 dynamic) ========= +// ========= 조회 쿼리 (V2 동적) ========= +// v2 검색 계획 파라미터에 맞춰 유사 청크를 조회 export const findSimilarChunksV2 = async (params: { userId: string; embedding: number[]; categoryId?: number; - from?: string; // ISO UTC - to?: string; // ISO UTC - threshold?: number; // 0..1 - topK?: number; // default 5, max 10 + from?: string; // ISO UTC 문자열 + to?: string; // ISO UTC 문자열 + threshold?: number; // 0..1 범위 + topK?: number; // 기본 5, 최대 10 weights?: { chunk: number; title: number }; sort?: 'created_at_desc' | 'created_at_asc'; }): Promise => { @@ -190,7 +195,7 @@ export const findSimilarChunksV2 = async (params: { const parts: string[] = []; const values: any[] = []; - // $1: userId, $2: embedding + // $1은 userId, $2는 embedding values.push(params.userId); values.push(pgvector.toSql(params.embedding)); @@ -198,7 +203,7 @@ export const findSimilarChunksV2 = async (params: { const hasTime = !!(params.from && params.to); if (hasCategory) { - const catParam = values.length + 1; // next index + const catParam = values.length + 1; // 다음 플레이스홀더 인덱스 parts.push(` WITH category_ids AS ( SELECT DISTINCT cc.descendant_id @@ -220,7 +225,7 @@ export const findSimilarChunksV2 = async (params: { )`); } - // base select and threshold + // 기본 SELECT 구문과 임계값 조건 const thrParam = values.length + 1; parts.push(` SELECT @@ -266,6 +271,7 @@ export const findSimilarChunksV2 = async (params: { })); }; +// 키워드와 텍스트 유사도 기반 검색 결과를 조회 export const textSearchChunksV2 = async (params: { userId: string; query?: string; @@ -382,14 +388,15 @@ LIMIT $${limitParam}`; })); }; -// ========= GLOBAL (no user/category filter) ========= +// ========= 글로벌 쿼리 (사용자·카테고리 제한 없음) ========= +// 전체 블로그 데이터를 대상으로 ANN 기반 유사 청크 조회 export const findSimilarChunksGlobalANN = async (params: { embedding: number[]; - threshold?: number; // applied on chunk similarity only - topK?: number; // final number to return + threshold?: number; // 청크 유사도에만 적용되는 임계값 + topK?: number; // 최종 반환할 개수 weights?: { chunk: number; title: number }; sort?: 'created_at_desc' | 'created_at_asc'; - annFactor?: number; // multiplier for initial ANN candidates + annFactor?: number; // 초기 ANN 후보 개수에 곱할 배수 }): Promise => { const pool = getDb(); const wChunk = Math.max(0, Math.min(1, params.weights?.chunk ?? 0.7)); @@ -452,6 +459,7 @@ export const findSimilarChunksGlobalANN = async (params: { })); }; +// 전역 텍스트 검색으로 하이브리드 보조 후보를 수집 export const textSearchChunksGlobal = async (params: { query?: string; keywords?: string[]; diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index 4483162..61ae92e 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -11,6 +11,7 @@ export type UserBlogMetadata = { * Loads blog-specific metadata (nickname, profile image, category names) for a user. * Returns null when the user does not exist or userId is a non-real sentinel value (e.g., "global"). */ +// 사용자 블로그 메타데이터를 수집해 QA 프롬프트에 제공 export const findUserBlogMetadata = async (userId: string | null | undefined): Promise => { if (!userId || userId === 'global') { return null; @@ -45,4 +46,3 @@ export const findUserBlogMetadata = async (userId: string | null | undefined): P categoryNames: Array.isArray(row.category_names) ? row.category_names.filter((name: any) => typeof name === 'string') : [], }; }; - diff --git a/src/routes/ai.routes.ts b/src/routes/ai.routes.ts index ba6e294..a62f6dc 100644 --- a/src/routes/ai.routes.ts +++ b/src/routes/ai.routes.ts @@ -6,6 +6,7 @@ import { } from '../controllers/ai.controller'; import { authMiddleware } from '../middlewares/auth.middleware'; +// AI 관련 1세대 엔드포인트를 정의 const aiRouter = Router(); aiRouter.get('/health', (req, res) => { diff --git a/src/routes/ai.v2.routes.ts b/src/routes/ai.v2.routes.ts index 2558023..08bc65e 100644 --- a/src/routes/ai.v2.routes.ts +++ b/src/routes/ai.v2.routes.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { askV2Handler } from '../controllers/ai.v2.controller'; import { authMiddleware } from '../middlewares/auth.middleware'; +// 검색 계획을 사용하는 v2 ASK 엔드포인트 라우터 const aiV2Router = Router(); aiV2Router.get('/health', (req, res) => { @@ -11,4 +12,3 @@ aiV2Router.get('/health', (req, res) => { aiV2Router.post('/ask', authMiddleware, askV2Handler); export default aiV2Router; - diff --git a/src/routes/search.routes.ts b/src/routes/search.routes.ts index 1f657ac..3441cce 100644 --- a/src/routes/search.routes.ts +++ b/src/routes/search.routes.ts @@ -1,10 +1,10 @@ import { Router } from 'express'; import { hybridSearchHandler } from '../controllers/search.controller'; +// 공개 검색용 하이브리드 엔드포인트 라우터 const searchRouter = Router(); -// Public JSON endpoint (no SSE) +// SSE 없이 JSON으로 응답하는 공개 엔드포인트 searchRouter.get('/hybrid', hybridSearchHandler); export default searchRouter; - diff --git a/src/server.ts b/src/server.ts index b903b80..2ef1232 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ import app from './app'; import config from './config'; +// 환경설정에서 지정한 포트로 HTTP 서버를 실행 const PORT = config.PORT; app.listen(PORT, () => { diff --git a/src/services/embedding.service.ts b/src/services/embedding.service.ts index 21393d1..9dddc05 100644 --- a/src/services/embedding.service.ts +++ b/src/services/embedding.service.ts @@ -15,6 +15,7 @@ const CHUNK_OVERLAP = 50; * @param content The text content to be split. * @returns An array of text chunks. */ +// 본문을 토큰 길이 기준으로 청크 분할 export const chunkText = (content: string): string[] => { const tokenizer = get_encoding('cl100k_base'); const sentences = content.split(/(?<=[.?!])\s+/); @@ -46,6 +47,7 @@ export const chunkText = (content: string): string[] => { * @param texts The texts to be embedded. * @returns A promise that resolves to an array of embeddings. */ +// OpenAI 임베딩 API를 호출해 텍스트 배열을 벡터로 변환 export const createEmbeddings = async (texts: string[]): Promise => { const response = await openai.embeddings.create({ model: config.EMBED_MODEL, @@ -59,6 +61,7 @@ export const createEmbeddings = async (texts: string[]): Promise => * @param postId The ID of the post. * @param title The title of the post. */ +// 단일 제목 임베딩을 생성하여 저장 export const storeTitleEmbedding = async (postId: number, title: string) => { const [embedding] = await createEmbeddings([title]); await postRepository.storeTitleEmbedding(postId, embedding); @@ -70,10 +73,11 @@ export const storeTitleEmbedding = async (postId: number, title: string) => { * @param chunks The text chunks of the content. * @param embeddings The vector embeddings of the chunks. */ +// 본문 청크와 임베딩을 트랜잭션으로 저장 export const storeContentEmbeddings = async ( postId: number, chunks: string[], embeddings: number[][] ) => { await postRepository.storeContentEmbeddings(postId, chunks, embeddings); -}; \ No newline at end of file +}; diff --git a/src/services/hybrid-search.service.ts b/src/services/hybrid-search.service.ts index f2d013e..306f6cb 100644 --- a/src/services/hybrid-search.service.ts +++ b/src/services/hybrid-search.service.ts @@ -23,6 +23,7 @@ type Candidate = { postCreatedAt?: string; }; +// 하이브리드 검색 계획에 따라 벡터·텍스트 검색을 결합 export const runHybridSearch = async ( question: string, userId: string, @@ -41,7 +42,7 @@ export const runHybridSearch = async ( const embeddings = await createEmbeddings(queries); - // Compute per-rewrite similarity weights (index 0 = original question) + // 재작성 문장별 유사도 가중치 계산 (0번째는 원 질문) const dot = (a: number[], b: number[]) => { let s = 0; const n = Math.min(a.length, b.length); @@ -59,7 +60,7 @@ export const runHybridSearch = async ( const qEmb = embeddings[0]; const weightsByIndex: number[] = new Array(embeddings.length).fill(1); const keepIndex: boolean[] = new Array(embeddings.length).fill(true); - const floor = 0.35; // similarity floor in [0,1] + const floor = 0.35; // [0,1] 범위에서 허용할 최소 유사도 const isDeclarative = (s: string): boolean => { const str = (s || '').trim(); if (!str) return false; @@ -72,19 +73,19 @@ export const runHybridSearch = async ( }; for (let i = 1; i < embeddings.length; i++) { - const sim = cos(qEmb, embeddings[i]); // [-1,1] + const sim = cos(qEmb, embeddings[i]); // [-1,1] 범위의 코사인 유사도 const sim01 = Math.max(0, Math.min(1, (sim + 1) / 2)); - let weight = 0.6 + 0.6 * sim01; // map to [0.6, 1.2] + let weight = 0.6 + 0.6 * sim01; // [0.6, 1.2] 범위로 스케일링 const rw = rewrites[i - 1] || ''; if (isDeclarative(rw)) { const floorBase = bias === 'semantic' ? 1.0 : 0.95; if (weight < floorBase) weight = floorBase; } weightsByIndex[i] = weight; - if (sim01 < floor) keepIndex[i] = false; // drop low-quality rewrites for vector path + if (sim01 < floor) keepIndex[i] = false; // 기준 이하 재작성은 벡터 후보에서 제외 } - // Telemetry: rewrite weights + // 재작성 가중치에 대한 디버그 로깅 DebugLogger.log('hybrid', { type: 'debug.hybrid.rewrite_weights', rewrites, @@ -162,7 +163,7 @@ export const runHybridSearch = async ( let semBoostCount = 0; let lexBoostCount = 0; - // Extend lexical search to rewrites as queries for recall + // 재작성 문장을 텍스트 검색에도 활용해 검색 폭 확장 for (let i = 1; i < embeddings.length; i++) { if (!keepIndex[i]) continue; const q = rewrites[i - 1]; @@ -211,7 +212,7 @@ export const runHybridSearch = async ( const boosted = list.map((c) => { let v = normalize01(c.vecScore || 0, vMin, vMax); let t = normalize01(c.textScore || 0, tMin, tMax); - // Threshold-based boosts + // 임계값 기반 보정 if (v >= preset.sem_boost_threshold) { v = Math.min(1, v + 0.1); semBoostCount++; @@ -224,7 +225,7 @@ export const runHybridSearch = async ( return { postId: c.postId, postTitle: c.postTitle, postChunk: c.postChunk, similarityScore: score, chunkIndex: c.chunkIndex, postCreatedAt: c.postCreatedAt }; }); - // Telemetry for boosts + // 보정 결과를 로깅하여 모니터링 DebugLogger.log('hybrid', { type: 'debug.hybrid.boosts', bias, @@ -234,7 +235,7 @@ export const runHybridSearch = async ( counts: { sem: semBoostCount, lex: lexBoostCount }, }); - // Post-level diversity: max N chunks per post before final limit + // 포스트 단위 다양성: 각 포스트당 최대 N개 청크만 유지 const MAX_CHUNKS_PER_POST = 2; const sorted = boosted.sort((a, b) => b.similarityScore - a.similarityScore); const byPostCount = new Map(); diff --git a/src/services/qa.service.ts b/src/services/qa.service.ts index 35ae7b9..a8966d0 100644 --- a/src/services/qa.service.ts +++ b/src/services/qa.service.ts @@ -8,11 +8,13 @@ import { generate } from '../llm'; import { DebugLogger } from '../utils/debug-logger'; import * as userRepository from '../repositories/user.repository'; +// HTML 태그를 제거하고 길이를 제한하여 LLM 컨텍스트를 정제 const preprocessContent = (content: string): string => { const plainText = content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); return plainText.length > 40000 ? plainText.substring(0, 40000) : plainText; }; +// 사용자 말투 ID에 따라 프롬프트 지시문을 반환 const getSpeechTonePrompt = async (speechTone: number, userId: string): Promise => { if (speechTone === -1) return "간결하고 명확한 말투로 답변해"; if (speechTone === -2) return "아래의 블로그 본문 컨텍스트를 참고하여 본문의 말투를 파악해 최대한 비슷한 말투로 답변해"; @@ -22,7 +24,7 @@ const getSpeechTonePrompt = async (speechTone: number, userId: string): Promise< if (persona) { return `${persona.name}: ${persona.description}`; } - return "간결하고 명확한 말투로 답변해"; // Default + return "간결하고 명확한 말투로 답변해"; // 기본 말투 } type LlmOverride = { @@ -31,6 +33,7 @@ type LlmOverride = { options?: { temperature?: number; top_p?: number; max_output_tokens?: number }; }; +// 질문에 대한 RAG 답변을 SSE 스트림으로 생성 export const answerStream = async ( question: string, userId: string, @@ -82,7 +85,7 @@ export const answerStream = async ( return; } - // Enforce conditional ownership: if post is not public, require owner + // 비공개 글이면 소유자만 접근하도록 검증 if (!post.is_public && post.user_id !== userId) { stream.write(`event: error\n`); stream.write(`data: ${JSON.stringify({ code: 403, message: 'Forbidden' })}\n\n`); diff --git a/src/services/qa.v2.service.ts b/src/services/qa.v2.service.ts index 02d0140..209b5d4 100644 --- a/src/services/qa.v2.service.ts +++ b/src/services/qa.v2.service.ts @@ -11,11 +11,13 @@ import { runHybridSearch } from './hybrid-search.service'; import { createEmbeddings } from './embedding.service'; import { DebugLogger } from '../utils/debug-logger'; +// HTML을 제거하고 길이를 제한해 LLM 컨텍스트를 정리 const preprocessContent = (content: string): string => { const plainText = content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); return plainText.length > 40000 ? plainText.substring(0, 40000) : plainText; }; +// 말투 ID나 프리셋에 따른 지시문을 구성 const getSpeechTonePrompt = async (speechTone: number, userId: string): Promise => { if (speechTone === -1) return '간결하고 명확한 말투로 답변해'; if (speechTone === -2) @@ -32,6 +34,7 @@ type LlmOverride = { options?: { temperature?: number; top_p?: number; max_output_tokens?: number }; }; +// 검색 계획을 활용한 v2 QA 스트림을 생성 export const answerStreamV2 = async ( question: string, userId: string, @@ -66,7 +69,7 @@ export const answerStreamV2 = async ( }; if (postId) { - // Post-centric path (same as v1 with added v2 pre-events) + // 단일 포스트 컨텍스트 흐름 (v1과 동일하되 v2 이벤트 추가) const post = await postRepository.findPostById(postId); if (!post) { stream.write(`event: error\n`); @@ -80,7 +83,7 @@ export const answerStreamV2 = async ( stream.end(); return; } - // Emit plan event for transparency + // 검색 계획 정보를 스트림으로 먼저 공지 stream.write(`event: search_plan\n`); stream.write( `data: ${JSON.stringify({ mode: 'post', filters: { post_id: postId, user_id: userId } })}\n\n` @@ -99,10 +102,10 @@ export const answerStreamV2 = async ( qaPrompts.createPostContextPrompt(post, processed, question, speechTonePrompt, blogMeta ?? undefined) ); } else { - // Plan generation + // 질문 기반 검색 계획 생성 경로 const planPair = await generateSearchPlan(question, { user_id: userId, category_id: categoryId }); if (!planPair) { - // Fallback to v1 RAG silently + // 계획 생성 실패 시 v1 RAG로 조용히 폴백 const [questionEmbedding] = await createEmbeddings([question]); const similarChunks = await postRepository.findSimilarChunks(userId, questionEmbedding, categoryId); const context = similarChunks.map((c) => ({ postId: c.postId, postTitle: c.postTitle })); @@ -135,7 +138,7 @@ export const answerStreamV2 = async ( const plan: any = planPair.normalized; stream.write(`event: search_plan\n`); stream.write(`data: ${JSON.stringify(plan)}\n\n`); - // Console debug for emitted search plan + // 전송된 검색 계획을 디버그 로그로 남김 DebugLogger.log('sse', { type: 'debug.sse.search_plan', userId, @@ -180,7 +183,7 @@ export const answerStreamV2 = async ( const hybridContext = rows.map((r) => ({ postId: r.postId, postTitle: r.postTitle })); stream.write(`event: hybrid_result\n`); stream.write(`data: ${JSON.stringify(hybridContext)}\n\n`); - // Optional enriched metadata for clients that opt-in + // 메타데이터를 선택적으로 구독하는 클라이언트를 위한 추가 이벤트 try { const hybridMeta = rows.map((r) => ({ postId: r.postId, @@ -202,7 +205,7 @@ export const answerStreamV2 = async ( const context = rows.map((r) => ({ postId: r.postId, postTitle: r.postTitle })); stream.write(`event: search_result\n`); stream.write(`data: ${JSON.stringify(context)}\n\n`); - // Optional enriched metadata for clients that opt-in + // 메타데이터를 선택적으로 요청하는 클라이언트를 위한 추가 이벤트 try { const resultMeta = rows.map((r) => ({ postId: r.postId, diff --git a/src/services/retrieval-presets.ts b/src/services/retrieval-presets.ts index 3dbcddf..613734f 100644 --- a/src/services/retrieval-presets.ts +++ b/src/services/retrieval-presets.ts @@ -2,8 +2,8 @@ export type RetrievalBias = 'lexical' | 'balanced' | 'semantic'; export type RetrievalPreset = { alpha: number; - sem_boost_threshold: number; // normalized vector score threshold - lex_boost_threshold: number; // normalized text score threshold + sem_boost_threshold: number; // 정규화된 벡터 유사도 임계값 + lex_boost_threshold: number; // 정규화된 텍스트 유사도 임계값 }; export const RETRIEVAL_BIAS_PRESETS: Record = { @@ -12,8 +12,8 @@ export const RETRIEVAL_BIAS_PRESETS: Record = { semantic: { alpha: 0.75, sem_boost_threshold: 0.80, lex_boost_threshold: 0.65 }, }; +// 편향 라벨에 맞는 하이브리드 검색 파라미터를 반환 export const getPreset = (bias?: RetrievalBias | null): RetrievalPreset => { if (!bias) return RETRIEVAL_BIAS_PRESETS.balanced; return RETRIEVAL_BIAS_PRESETS[bias] || RETRIEVAL_BIAS_PRESETS.balanced; }; - diff --git a/src/services/search-aggregate.service.ts b/src/services/search-aggregate.service.ts index 7f192dd..101ab34 100644 --- a/src/services/search-aggregate.service.ts +++ b/src/services/search-aggregate.service.ts @@ -15,6 +15,7 @@ export type PostHit = { best: { chunkIndex?: number; snippet: string; score: number }; }; +// 청크 결과를 포스트 단위로 묶어 페이징 결과를 작성 export const aggregatePosts = ( chunks: ChunkHit[], opts?: { perPostMax?: number; limit?: number; offset?: number } @@ -23,7 +24,7 @@ export const aggregatePosts = ( const limit = Math.max(1, Math.min(10, opts?.limit ?? 10)); const offset = Math.max(0, opts?.offset ?? 0); - // Group by post + // 포스트 단위로 묶기 const byPost = new Map(); for (const c of chunks) { const arr = byPost.get(c.postId) || []; @@ -31,7 +32,7 @@ export const aggregatePosts = ( byPost.set(c.postId, arr); } - // Build post-level hits + // 포스트별 점수 계산 const posts: PostHit[] = []; for (const [postId, arr] of byPost.entries()) { const sorted = arr.slice().sort((a, b) => b.similarityScore - a.similarityScore); @@ -58,4 +59,3 @@ export const aggregatePosts = ( const page = posts.slice(offset, offset + limit); return { posts: page, total }; }; - diff --git a/src/services/search-plan.service.ts b/src/services/search-plan.service.ts index 19547a0..84e2d42 100644 --- a/src/services/search-plan.service.ts +++ b/src/services/search-plan.service.ts @@ -12,9 +12,10 @@ export type PlanContext = { user_id: string; category_id?: number; post_id?: number; - timezone?: string; // default Asia/Seoul + timezone?: string; // 기본값: Asia/Seoul }; +// 질문과 컨텍스트를 기반으로 LLM에 검색 계획을 요청 export const generateSearchPlan = async ( question: string, ctx: PlanContext @@ -36,7 +37,7 @@ export const generateSearchPlan = async ( try { - // Debug prompt before request + // 요청 전 프롬프트 내용을 디버그 출력 DebugLogger.log('plan', { type: 'debug.plan.prompt', model: 'gpt-5-mini', @@ -53,7 +54,7 @@ export const generateSearchPlan = async ( max_output_tokens: 1500, }); - // Debug peek: log response shapes before JSON extraction + // JSON 추출 전 응답 구조를 미리 로깅 try { const outputs = (response as any)?.output || []; const outputSummary = outputs.map((o: any) => ({ @@ -74,7 +75,7 @@ export const generateSearchPlan = async ( }); } catch {} - // Extract structured JSON if available, otherwise parse text + // 구조화된 JSON이 있으면 우선 사용하고 없으면 텍스트를 파싱 let parsed: any = null; try { const outputs = (response as any)?.output || []; @@ -88,7 +89,7 @@ export const generateSearchPlan = async ( if (parsed) break; } } catch {} - // Also check output_text for JSON string if using Responses API response_format + // Responses API의 output_text에 JSON 문자열이 있는지도 확인 if (!parsed && typeof (response as any)?.output_text === 'string') { const s = ((response as any).output_text as string).trim(); if (s.startsWith('{')) { @@ -109,14 +110,14 @@ export const generateSearchPlan = async ( } } catch {} const raw = texts.join('').trim(); - // Debug: log raw text before JSON.parse + // JSON 파싱 전에 원본 텍스트를 디버그 로그로 남김 DebugLogger.log('plan', { type: 'debug.plan.raw_text', len: raw.length, head: raw.slice(0, 200) }); if (!raw) { - // Graceful fallback: unable to parse structured output + // 구조화된 출력을 파싱하지 못한 경우 우아하게 폴백 return null; } - // Robust extraction of first balanced JSON object + // 균형 잡힌 첫 번째 JSON 객체를 견고하게 추출 const tryParse = (s: string): any | null => { try { return JSON.parse(s); } catch { return null; } }; @@ -161,7 +162,7 @@ export const generateSearchPlan = async ( parsed = candidate; } - // If still no parsed plan at this point, try a fallback call without text.format + // 여전히 파싱에 실패하면 text.format 옵션 없이 폴백 호출 시도 if (!parsed) { try { const response2: any = await (openai as any).responses.create({ @@ -169,7 +170,7 @@ export const generateSearchPlan = async ( input: prompt, max_output_tokens: 700, }); - // Debug peek for fallback + // 폴백 호출 응답을 디버그로 확인 try { const outputs = (response2 as any)?.output || []; const outputSummary = outputs.map((o: any) => ({ @@ -189,7 +190,7 @@ export const generateSearchPlan = async ( }); } catch {} - // Parse fallback response + // 폴백 응답을 파싱 let parsed2: any = null; try { const outputs = (response2 as any)?.output || []; @@ -214,15 +215,15 @@ export const generateSearchPlan = async ( } if (!parsed2) { DebugLogger.warn('plan', { type: 'debug.plan.fallback.parse_fail' }); - // proceed to chat completions fallback + // Chat Completions 폴백으로 진행 } if (parsed2) parsed = parsed2; } catch { - // continue to chat completions fallback + // Chat Completions 폴백으로 계속 진행 } } - // Final fallback: Chat Completions with JSON object mode + // 최종 폴백: JSON 객체 모드의 Chat Completions 호출 if (!parsed) { try { const sys = 'You output ONLY a single JSON object matching the SearchPlan shape. No extra text.'; @@ -236,7 +237,7 @@ export const generateSearchPlan = async ( response_format: { type: 'json_object' }, max_tokens: 700, }); - // Debug + // 디버그 용도로 응답 개수를 기록 DebugLogger.log('plan', { type: 'debug.plan.cc.peek', choices: (cc as any)?.choices?.length || 0 }); const content = (cc as any)?.choices?.[0]?.message?.content || ''; if (typeof content === 'string' && content.trim().startsWith('{')) { @@ -249,11 +250,11 @@ export const generateSearchPlan = async ( } const plan = planSchema.parse(parsed); - // Normalize weights sum to 1 + // 가중치 합이 1이 되도록 정규화 const sum = (plan.weights?.chunk ?? 0) + (plan.weights?.title ?? 0); const weights = sum > 0 ? { chunk: plan.weights.chunk / sum, title: plan.weights.title / sum } : { chunk: 0.7, title: 0.3 }; - // Normalize time range to absolute if provided + // 시간 필터가 있으면 절대 범위로 정규화 let normPlan: SearchPlan = { ...plan, weights }; const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n)); @@ -289,11 +290,11 @@ export const generateSearchPlan = async ( for (const s of arr || []) { const raw = String(s || '').trim(); if (!raw) continue; - // Single-token only (no whitespace) + // 공백 없는 단일 토큰만 허용 if (/\s/.test(raw)) continue; - // Drop too short tokens + // 너무 짧은 토큰은 제외 if (raw.length < 2) continue; - // Allow only word-ish tokens with optional hyphen/underscore (Korean/English/numbers) + // 한글·영문·숫자와 하이픈/언더스코어만 허용하여 단어 형태 유지 const token = raw.replace(/[\u200B-\u200D\uFEFF]/g, ''); if (!/^[\p{L}\p{N}_-]+$/u.test(token)) continue; const key = token.toLowerCase(); @@ -301,7 +302,7 @@ export const generateSearchPlan = async ( if (uniq.has(key)) continue; uniq.add(key); } - // cap to 1..5 + // 최종 개수를 1~5 범위로 제한 const list = Array.from(uniq); return list.slice(0, Math.min(5, Math.max(0, max))); }; @@ -313,21 +314,21 @@ export const generateSearchPlan = async ( filters: { ...normPlan.filters, time: { type: 'absolute', from: abs.from, to: abs.to } as any }, }; } else { - // drop invalid time + // 유효하지 않은 시간 필터는 제거 const { time, ...rest } = normPlan.filters || ({} as any); normPlan = { ...normPlan, filters: rest as any }; } } - // Enforce bounds just in case + // 혹시 모를 값에 대비해 범위를 강제 normPlan.top_k = Math.min(10, Math.max(1, normPlan.top_k || 5)); normPlan.limit = Math.min(20, Math.max(1, normPlan.limit || 5)); normPlan.threshold = Math.min(1, Math.max(0, normPlan.threshold ?? 0.2)); const maxRewrites = clamp(plan.hybrid?.max_rewrites ?? 3, 0, 4); - // Even if plan suggests up to 8, we normalize to 1..5 for quality + // 계획에서 최대 8개를 제안해도 품질을 위해 1~5개로 정규화 const maxKeywords = Math.min(5, clamp(plan.hybrid?.max_keywords ?? 6, 0, 8)); - // Map retrieval_bias -> alpha (fallback to provided alpha or default) + // retrieval_bias에 따라 alpha 값을 재계산 (명시 값이 있으면 우선) const bias = (plan.hybrid as any)?.retrieval_bias || 'balanced'; const preset = getPreset(bias as any); const alpha = clamp(((plan.hybrid as any)?.alpha ?? preset.alpha) as number, 0, 1); @@ -343,10 +344,10 @@ export const generateSearchPlan = async ( normPlan.keywords = cleanKeywords(plan.keywords, maxKeywords) as any; if (!normPlan.mode) normPlan.mode = (ctx.post_id ? 'post' : 'rag') as any; - // Note: Only filters.time is kept here to satisfy the SearchPlan schema. - // user_id/category_ids/post_id will be injected later by the query layer. + // 참고: SearchPlan 스키마 요구사항상 filters.time만 유지한다. + // user_id/category_ids/post_id는 쿼리 단계에서 주입된다. - // Console debug: final parsed + normalized plan + // 최종 파싱·정규화된 계획을 로그로 남김 const timeInfo = (normPlan as any)?.filters?.time; DebugLogger.log('plan', { type: 'debug.plan.final', diff --git a/src/services/semantic-search.service.ts b/src/services/semantic-search.service.ts index d9950f4..652dc67 100644 --- a/src/services/semantic-search.service.ts +++ b/src/services/semantic-search.service.ts @@ -11,6 +11,7 @@ export type SemanticSearchResult = { postCreatedAt?: string; }[]; +// 벡터 임베딩만 사용하여 유사 청크를 조회 export const runSemanticSearch = async ( question: string, userId: string, diff --git a/src/types/ai.types.ts b/src/types/ai.types.ts index b9ed539..bffc72a 100644 --- a/src/types/ai.types.ts +++ b/src/types/ai.types.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -// POST /ai/embeddings/title +// POST /ai/embeddings/title 요청 본문 스키마 export const embedTitleSchema = z.object({ body: z.object({ post_id: z.number(), @@ -10,7 +10,7 @@ export const embedTitleSchema = z.object({ export type EmbedTitleRequest = z.infer['body']; -// POST /ai/embeddings/content +// POST /ai/embeddings/content 요청 본문 스키마 export const embedContentSchema = z.object({ body: z.object({ post_id: z.number(), @@ -20,7 +20,7 @@ export const embedContentSchema = z.object({ export type EmbedContentRequest = z.infer['body']; -// POST /ai/ask +// POST /ai/ask 요청 본문 스키마 export const askSchema = z.object({ body: z.object({ question: z.string(), diff --git a/src/types/ai.v2.types.ts b/src/types/ai.v2.types.ts index 6e3353b..c806262 100644 --- a/src/types/ai.v2.types.ts +++ b/src/types/ai.v2.types.ts @@ -1,14 +1,14 @@ import { z } from 'zod'; -// ===== Plan JSON Schema (Zod) ===== +// ===== 검색 계획 JSON 스키마 (Zod) ===== -// Time filter schema: support multiple shapes to reduce LLM fragility +// 시간 필터 스키마: LLM의 취약성을 줄이기 위해 다양한 형태 지원 export const timeFilterSchema = z.discriminatedUnion('type', [ - // Absolute ISO range + // 절대 ISO 범위 z .object({ type: z.literal('absolute'), from: z.string(), to: z.string() }) .strict(), - // Relative window: N units up to today (KST) + // 상대 기간: 오늘(KST)까지 N 단위 z .object({ type: z.literal('relative'), @@ -16,17 +16,17 @@ export const timeFilterSchema = z.discriminatedUnion('type', [ value: z.number().int().min(1).max(365), }) .strict(), - // Month of a year (default year=now) + // 특정 연도의 월 (연도는 기본적으로 현재) z .object({ type: z.literal('month'), year: z.number().int().optional(), month: z.number().int().min(1).max(12) }) .strict(), - // Quarter of a year (default year=now) + // 특정 연도의 분기 (연도는 기본적으로 현재) z .object({ type: z.literal('quarter'), year: z.number().int().optional(), quarter: z.number().int().min(1).max(4) }) .strict(), - // Single year + // 단일 연도 z.object({ type: z.literal('year'), year: z.number().int() }).strict(), - // Named presets (limited set) + // 미리 정의된 기간 프리셋 z .object({ type: z.literal('named'), @@ -43,7 +43,7 @@ export const timeFilterSchema = z.discriminatedUnion('type', [ ]), }) .strict(), - // Free-form label, e.g., "2006_to_now", "2024-Q3", "2019-2022", "2024-09" + // 자유 형식 라벨 예시: "2006_to_now", "2024-Q3", "2019-2022", "2024-09" z.object({ type: z.literal('label'), label: z.string().min(1) }).strict(), ]); @@ -59,7 +59,7 @@ export const planSchema = z.object({ hybrid: z .object({ enabled: z.boolean().default(false), - // LLM outputs retrieval_bias label; server maps to alpha + // LLM이 출력한 retrieval_bias 라벨을 서버에서 alpha로 매핑 retrieval_bias: z.enum(['lexical', 'balanced', 'semantic']).default('balanced'), alpha: z.number().min(0).max(1).optional(), max_rewrites: z.number().int().min(0).max(4).default(3), @@ -78,7 +78,7 @@ export const planSchema = z.object({ export type SearchPlan = z.infer; -// ===== API: /ai/v2/ask ===== +// ===== API: /ai/v2/ask 요청 본문 스키마 ===== export const askV2Schema = z.object({ body: z.object({ diff --git a/src/utils/cost.ts b/src/utils/cost.ts index 62334db..c9f8894 100644 --- a/src/utils/cost.ts +++ b/src/utils/cost.ts @@ -6,14 +6,14 @@ export type Pricing = { }; const PRICING_TABLE: Record = { - // OpenAI + // OpenAI 요금표 'gpt-5-mini': { input_per_1k: 0.00025, output_per_1k: 0.002, cached_input_per_1k: 0.000025, currency: 'USD' }, 'gpt-5-nano': { input_per_1k: 0.00005, output_per_1k: 0.0004, cached_input_per_1k: 0.000005, currency: 'USD' }, 'gpt-4o': { input_per_1k: 0.005, output_per_1k: 0.015, currency: 'USD' }, 'gpt-4o-mini': { input_per_1k: 0.0005, output_per_1k: 0.0015, currency: 'USD' }, - // Embeddings + // 임베딩 모델 요금 'text-embedding-3-small': { input_per_1k: 0.00002, output_per_1k: 0, currency: 'USD' }, - // Gemini (example values — update per official pricing if needed) + // Gemini (예시 값이므로 필요 시 공식 가격으로 갱신) 'gemini-2.5-flash': { input_per_1k: 0.0001, output_per_1k: 0.0004, currency: 'USD' }, }; @@ -21,7 +21,7 @@ export const getModelPricing = (model: string): Pricing | null => { if (!model) return null; const key = model.toLowerCase(); if (PRICING_TABLE[key]) return PRICING_TABLE[key]; - // naive aliasing for common variants + // 자주 쓰는 모델 별칭을 단순 매핑 if (key.startsWith('gpt-4o')) return PRICING_TABLE['gpt-4o']; if (key.startsWith('gpt-5-mini')) return PRICING_TABLE['gpt-5-mini']; return null; @@ -37,4 +37,3 @@ export const formatCost = (amount: number, currency: string, round: number = 4): const rounded = Math.round(amount * factor) / factor; return `${rounded} ${currency}`; }; - diff --git a/src/utils/db.ts b/src/utils/db.ts index 80f18a8..cc73577 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -5,6 +5,7 @@ const pool = new Pool({ connectionString: config.DATABASE_URL, }); +// 재사용 가능한 PG 풀 인스턴스를 반환 export const getDb = () => { return pool; }; diff --git a/src/utils/debug-logger.ts b/src/utils/debug-logger.ts index 94f3c54..c81bbc6 100644 --- a/src/utils/debug-logger.ts +++ b/src/utils/debug-logger.ts @@ -88,6 +88,7 @@ const formatPayload = (channel: DebugChannel, payload: unknown): string => { return `[debug][${channel}] ${toInlineValue(payload)}`; }; +// 채널 기반으로 디버그 로그를 조건부 출력 export const DebugLogger = { isEnabled(channel: DebugChannel): boolean { return enabledAll || enabledChannels.has(channel); diff --git a/src/utils/time.ts b/src/utils/time.ts index b950068..e86ba2e 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,13 +1,13 @@ -// Minimal KST time utilities and range normalization +// KST 기반 시간 계산과 범위 정규화 유틸리티 -const KST_OFFSET_MINUTES = 9 * 60; // UTC+9 +const KST_OFFSET_MINUTES = 9 * 60; // UTC+9 오프셋(분) const toDate = (isoOrDate: string | Date): Date => (isoOrDate instanceof Date ? isoOrDate : new Date(isoOrDate)); export const nowUtc = (): Date => new Date(); export const toKst = (d: Date): Date => { - // Convert UTC date to KST by adding offset + // UTC 시각에 오프셋을 더해 KST로 변환 return new Date(d.getTime() + KST_OFFSET_MINUTES * 60 * 1000); }; @@ -22,28 +22,29 @@ export const startOfMonth = (year: number, monthIndex0: number): Date => new Dat export const endOfMonth = (year: number, monthIndex0: number): Date => new Date(year, monthIndex0 + 1, 0, 23, 59, 59, 999); export const startOfQuarter = (year: number, quarter: number): Date => { - const m0 = (quarter - 1) * 3; // 0-based month index + const m0 = (quarter - 1) * 3; // 0부터 시작하는 분기 첫 달 인덱스 return new Date(year, m0, 1, 0, 0, 0, 0); }; export const endOfQuarter = (year: number, quarter: number): Date => { - const m0 = quarter * 3 - 1; // end month index + const m0 = quarter * 3 - 1; // 분기 마지막 달 인덱스 return new Date(year, m0 + 1, 0, 23, 59, 59, 999); }; export type AbsoluteRange = { from: string; to: string }; +// 다양한 시간 필터 입력을 KST 기준 ISO 범위로 변환 export const toAbsoluteRangeKst = (input: { type: string; [k: string]: any }, base: Date = nowUtc()): AbsoluteRange | null => { try { const baseKst = toKst(base); const year = baseKst.getFullYear(); - // Named presets + // 미리 정의된 기간 프리셋 처리 if (input.type === 'named') { const p = String(input.preset || '').toLowerCase(); const endK = endOfDay(baseKst); const beginOfTodayK = startOfDay(baseKst); const endUtc = fromKstToUtc(endK).toISOString(); const todayStartUtc = fromKstToUtc(beginOfTodayK).toISOString(); - if (p === 'all' || p === 'all_time') return null; // no time filter + if (p === 'all' || p === 'all_time') return null; // 시간 필터 없음 if (p === 'today') return { from: todayStartUtc, to: endUtc }; if (p === 'yesterday') { const yK = new Date(beginOfTodayK.getTime()); @@ -72,7 +73,7 @@ export const toAbsoluteRangeKst = (input: { type: string; [k: string]: any }, ba const toK = endOfMonth(yAdj, mAdj); return { from: fromKstToUtc(fromK).toISOString(), to: fromKstToUtc(toK).toISOString() }; } - return null; // unknown named: drop filter + return null; // 알 수 없는 프리셋이면 필터 제거 } if (input.type === 'relative') { const unit = String(input.unit); @@ -117,7 +118,7 @@ export const toAbsoluteRangeKst = (input: { type: string; [k: string]: any }, ba if (!raw) return null; const s = raw.replace(/\s+/g, '').toLowerCase(); const endK = endOfDay(baseKst); - // Support common named tokens expressed as labels + // 라벨 형식으로 표현된 공통 기간 패턴 지원 const startTodayK = startOfDay(baseKst); const toUtcStr = fromKstToUtc(endK).toISOString(); const fromTodayUtcStr = fromKstToUtc(startTodayK).toISOString(); @@ -127,7 +128,7 @@ export const toAbsoluteRangeKst = (input: { type: string; [k: string]: any }, ba fromK.setDate(fromK.getDate() - (n - 1)); return { from: fromKstToUtc(startOfDay(fromK)).toISOString(), to: fromKstToUtc(toK).toISOString() }; }; - if (s === 'all' || s === 'all_time') return null; // drop filter + if (s === 'all' || s === 'all_time') return null; // 필터 제거 if (s === 'today') return { from: fromTodayUtcStr, to: toUtcStr }; if (s === 'yesterday') { const yK = new Date(startTodayK.getTime()); @@ -150,14 +151,14 @@ export const toAbsoluteRangeKst = (input: { type: string; [k: string]: any }, ba const toK = endOfMonth(yAdj, mAdj); return { from: fromKstToUtc(fromK).toISOString(), to: fromKstToUtc(toK).toISOString() }; } - // 1) YYYY_to_now / YYYY-to-now + // 1) YYYY_to_now / YYYY-to-now 패턴 let m = s.match(/^(\d{4})(?:_|-|to)+now$/); if (m) { const y = parseInt(m[1], 10); const fromK = startOfMonth(y, 0); return { from: fromKstToUtc(fromK).toISOString(), to: fromKstToUtc(endK).toISOString() }; } - // 2) YYYY-YYYY / YYYY..YYYY / YYYY_to_YYYY + // 2) YYYY-YYYY / YYYY..YYYY / YYYY_to_YYYY 패턴 m = s.match(/^(\d{4})(?:\.|_|-|to){1,2}(\d{4})$/); if (m) { const y1 = parseInt(m[1], 10); @@ -168,7 +169,7 @@ export const toAbsoluteRangeKst = (input: { type: string; [k: string]: any }, ba const toK = endOfMonth(b, 11); return { from: fromKstToUtc(fromK).toISOString(), to: fromKstToUtc(toK).toISOString() }; } - // 3) YYYY-Qn or Qn-YYYY + // 3) YYYY-Qn 또는 Qn-YYYY 패턴 m = s.match(/^(\d{4})(?:-|_)q([1-4])$/); if (m) { const y = parseInt(m[1], 10); @@ -185,7 +186,7 @@ export const toAbsoluteRangeKst = (input: { type: string; [k: string]: any }, ba const toK = endOfQuarter(y, q); return { from: fromKstToUtc(fromK).toISOString(), to: fromKstToUtc(toK).toISOString() }; } - // 4) YYYY-MM + // 4) YYYY-MM 형식 m = s.match(/^(\d{4})(?:-|_)?(\d{1,2})$/); if (m) { const y = parseInt(m[1], 10); @@ -194,7 +195,7 @@ export const toAbsoluteRangeKst = (input: { type: string; [k: string]: any }, ba const toK = endOfMonth(y, month - 1); return { from: fromKstToUtc(fromK).toISOString(), to: fromKstToUtc(toK).toISOString() }; } - // 5) YYYY + // 5) YYYY 단일 연도 m = s.match(/^(\d{4})$/); if (m) { const y = parseInt(m[1], 10); @@ -202,10 +203,10 @@ export const toAbsoluteRangeKst = (input: { type: string; [k: string]: any }, ba const toK = endOfMonth(y, 11); return { from: fromKstToUtc(fromK).toISOString(), to: fromKstToUtc(toK).toISOString() }; } - return null; // unrecognized label + return null; // 해석할 수 없는 라벨 } } catch { - // ignore + // 무시 } return null; }; diff --git a/src/utils/tokenizer.ts b/src/utils/tokenizer.ts index bc662f4..62169f6 100644 --- a/src/utils/tokenizer.ts +++ b/src/utils/tokenizer.ts @@ -8,6 +8,7 @@ const encodingForModel = (model?: string): TiktokenEncoding => { return 'cl100k_base' as TiktokenEncoding; }; +// 단일 문자열의 토큰 수를 모델별 인코딩으로 계산 export const countTextTokens = (text: string, model: string): number => { const encKey = encodingForModel(model); const enc = get_encoding(encKey); @@ -15,18 +16,18 @@ export const countTextTokens = (text: string, model: string): number => { const tokens = enc.encode(text || ''); return tokens.length; } finally { - // no explicit free in @dqbd/tiktoken browser build; safe to let GC handle + // @dqbd/tiktoken 브라우저 빌드는 명시적 해제가 없어 GC에 맡김 } }; type SimpleMessage = { role: string; content: string }; +// 메시지 배열의 총 토큰 수를 근사 계산 export const countChatMessagesTokens = (messages: SimpleMessage[], model: string): number => { - // Approximate: sum content token counts + minimal role overhead - const overheadPerMsg = 3; // rough + // 근사 계산: 메시지 내용 토큰 수와 최소 오버헤드를 합산 + const overheadPerMsg = 3; // 대략적인 값 const roleOverhead = 1; return messages.reduce((sum, m) => { return sum + countTextTokens(m.content || '', model) + overheadPerMsg + roleOverhead; }, 0); }; - diff --git a/src/worker/queue-consumer.ts b/src/worker/queue-consumer.ts index bde7f6a..196c3d9 100644 --- a/src/worker/queue-consumer.ts +++ b/src/worker/queue-consumer.ts @@ -40,7 +40,7 @@ const handleShutdown = (signal: NodeJS.Signals) => { try { redis.disconnect(); } catch { - // ignore + // 무시 } setTimeout(() => process.exit(0), 500).unref(); }; @@ -48,6 +48,7 @@ const handleShutdown = (signal: NodeJS.Signals) => { process.on('SIGINT', handleShutdown); process.on('SIGTERM', handleShutdown); +// 큐에서 꺼낸 임베딩 작업을 실행 const processJob = async (job: EmbeddingJob) => { const postId = Number(job.postId); if (!Number.isFinite(postId) || postId <= 0) { @@ -127,6 +128,7 @@ const processJob = async (job: EmbeddingJob) => { } }; +// 반복 실패한 작업을 실패 큐에 적재 const pushToFailedQueue = async (payload: unknown) => { try { await redis.lpush( @@ -144,6 +146,7 @@ const pushToFailedQueue = async (payload: unknown) => { } }; +// Redis에서 수신한 페이로드를 파싱하고 처리 const handlePayload = async (rawPayload: string) => { let job: EmbeddingJob; try { @@ -203,6 +206,7 @@ const handlePayload = async (rawPayload: string) => { } }; +// 워커 메인 루프: BRPOP으로 작업을 소비 const run = async () => { console.info('[embedding-worker]', { type: 'worker.start',