companionbot 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ai/claude.js CHANGED
@@ -1,48 +1,21 @@
1
1
  import Anthropic, { APIError } from "@anthropic-ai/sdk";
2
2
  import { tools, executeTool } from "../tools/index.js";
3
- import { sleep } from "../utils/time.js";
4
- import { MAX_RETRIES, BASE_RETRY_DELAY_MS, MAX_TOOL_ITERATIONS, TOOL_RESULT_MAX_LENGTH, TOOL_INPUT_SUMMARY_LENGTH, TOOL_OUTPUT_SUMMARY_LENGTH, } from "../utils/constants.js";
5
- async function withRetry(fn, retries = MAX_RETRIES) {
6
- let lastError = null;
7
- for (let attempt = 0; attempt < retries; attempt++) {
8
- try {
9
- return await fn();
10
- }
11
- catch (error) {
12
- // APIError 타입 체크
13
- if (error instanceof APIError) {
14
- lastError = error;
15
- // Rate limit (429)
16
- if (error.status === 429) {
17
- const retryAfter = error.headers?.["retry-after"];
18
- const delay = retryAfter
19
- ? parseInt(retryAfter) * 1000
20
- : BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
21
- console.log(`[RateLimit] 429 received, waiting ${delay}ms (attempt ${attempt + 1}/${retries})`);
22
- await sleep(delay);
23
- continue;
24
- }
25
- // 서버 에러 (500+)
26
- if (error.status >= 500) {
27
- const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
28
- console.log(`[ServerError] ${error.status}, waiting ${delay}ms (attempt ${attempt + 1}/${retries})`);
29
- await sleep(delay);
30
- continue;
31
- }
32
- }
33
- // 일반 Error 처리
34
- if (error instanceof Error) {
35
- lastError = error;
36
- }
37
- else {
38
- lastError = new Error(String(error));
39
- }
40
- // 다른 에러는 바로 throw
41
- throw error;
42
- }
43
- }
44
- throw lastError;
45
- }
3
+ import { MAX_TOOL_ITERATIONS, TOOL_INPUT_SUMMARY_LENGTH, TOOL_OUTPUT_SUMMARY_LENGTH, } from "../utils/constants.js";
4
+ import { withRetry, withTimeout, } from "../utils/retry.js";
5
+ import { getToolTimeout } from "../tools/timeout.js";
6
+ import { compressToolResult } from "../tools/compress.js";
7
+ // API 호출 타임아웃 (2분) - Claude의 응답 시간 고려
8
+ const API_TIMEOUT_MS = 120000;
9
+ // 재시도 설정
10
+ const API_RETRY_OPTIONS = {
11
+ maxRetries: 3,
12
+ initialDelayMs: 1000,
13
+ maxDelayMs: 30000,
14
+ onRetry: (attempt, error, delay) => {
15
+ const errMsg = error instanceof Error ? error.message : String(error);
16
+ console.log(`[API Retry] Attempt ${attempt}, waiting ${delay}ms: ${errMsg.slice(0, 100)}`);
17
+ },
18
+ };
46
19
  let anthropic = null;
47
20
  function getClient() {
48
21
  if (!anthropic) {
@@ -163,31 +136,57 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", thinkingL
163
136
  return params;
164
137
  };
165
138
  let response;
166
- response = await withRetry(() => client.messages.create(buildRequestParams()));
139
+ response = await withRetry(() => withTimeout(() => client.messages.create(buildRequestParams()), API_TIMEOUT_MS, "API 응답 시간 초과"), API_RETRY_OPTIONS);
167
140
  // Tool use 루프 - Claude가 도구 사용을 멈출 때까지 반복
168
141
  let iterations = 0;
169
142
  while (response.stop_reason === "tool_use" && iterations < MAX_TOOL_ITERATIONS) {
170
143
  iterations++;
171
144
  const toolUseBlocks = response.content.filter((block) => block.type === "tool_use");
172
- // 도구 실행 결과 수집
173
- const toolResults = [];
174
- for (const toolUse of toolUseBlocks) {
175
- console.log(`[Tool] ${toolUse.name}:`, JSON.stringify(toolUse.input));
176
- const result = await executeTool(toolUse.name, toolUse.input);
177
- // 결과가 너무 길면 자르기
178
- const truncatedResult = result.length > TOOL_RESULT_MAX_LENGTH
179
- ? result.slice(0, TOOL_RESULT_MAX_LENGTH) + "\n... (truncated)"
180
- : result;
181
- toolResults.push({
182
- type: "tool_result",
183
- tool_use_id: toolUse.id,
184
- content: truncatedResult,
185
- });
186
- // 도구 사용 기록 (히스토리 참조용)
145
+ // 도구 병렬 실행 (성능 최적화)
146
+ console.log(`[Tool] Executing ${toolUseBlocks.length} tool(s) in parallel`);
147
+ const toolExecutions = await Promise.all(toolUseBlocks.map(async (toolUse) => {
148
+ const startTime = Date.now();
149
+ console.log(`[Tool] ${toolUse.name}:`, JSON.stringify(toolUse.input).slice(0, 200));
150
+ try {
151
+ // 도구별 타임아웃 적용
152
+ const timeout = getToolTimeout(toolUse.name);
153
+ const result = await Promise.race([
154
+ executeTool(toolUse.name, toolUse.input),
155
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Tool ${toolUse.name} timed out after ${timeout}ms`)), timeout)),
156
+ ]);
157
+ const elapsed = Date.now() - startTime;
158
+ console.log(`[Tool] ${toolUse.name} completed in ${elapsed}ms`);
159
+ // 스마트 결과 압축
160
+ const compressedResult = compressToolResult(toolUse.name, result);
161
+ return {
162
+ toolUse,
163
+ result: compressedResult,
164
+ success: true,
165
+ };
166
+ }
167
+ catch (error) {
168
+ const elapsed = Date.now() - startTime;
169
+ const errorMsg = error instanceof Error ? error.message : String(error);
170
+ console.error(`[Tool] ${toolUse.name} failed after ${elapsed}ms:`, errorMsg);
171
+ return {
172
+ toolUse,
173
+ result: `Error: ${errorMsg}`,
174
+ success: false,
175
+ };
176
+ }
177
+ }));
178
+ // 결과 수집
179
+ const toolResults = toolExecutions.map((exec) => ({
180
+ type: "tool_result",
181
+ tool_use_id: exec.toolUse.id,
182
+ content: exec.result,
183
+ }));
184
+ // 도구 사용 기록
185
+ for (const exec of toolExecutions) {
187
186
  toolsUsed.push({
188
- name: toolUse.name,
189
- input: JSON.stringify(toolUse.input).slice(0, TOOL_INPUT_SUMMARY_LENGTH),
190
- output: truncatedResult.slice(0, TOOL_OUTPUT_SUMMARY_LENGTH),
187
+ name: exec.toolUse.name,
188
+ input: JSON.stringify(exec.toolUse.input).slice(0, TOOL_INPUT_SUMMARY_LENGTH),
189
+ output: exec.result.slice(0, TOOL_OUTPUT_SUMMARY_LENGTH),
191
190
  });
192
191
  }
193
192
  // 어시스턴트 메시지와 도구 결과 추가
@@ -200,7 +199,7 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", thinkingL
200
199
  content: toolResults,
201
200
  });
202
201
  // 다음 응답 요청 (도구 루프에서도 thinking 유지)
203
- response = await withRetry(() => client.messages.create(buildRequestParams()));
202
+ response = await withRetry(() => withTimeout(() => client.messages.create(buildRequestParams()), API_TIMEOUT_MS, "API 응답 시간 초과"), API_RETRY_OPTIONS);
204
203
  }
205
204
  // 반복 횟수 초과 시 경고
206
205
  if (iterations >= MAX_TOOL_ITERATIONS) {
@@ -226,8 +225,12 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", thinkingL
226
225
  * 스트리밍 중 에러 발생 시 적절한 에러 메시지를 반환하거나 예외를 전파함
227
226
  */
228
227
  export async function chatSmart(messages, systemPrompt, modelId, thinkingLevel = "medium", onChunk) {
228
+ // 콜백 정규화
229
+ const callbacks = typeof onChunk === 'function'
230
+ ? { onChunk }
231
+ : (onChunk ?? {});
229
232
  // 스트리밍 콜백이 없으면 그냥 일반 chat 사용
230
- if (!onChunk) {
233
+ if (!callbacks.onChunk) {
231
234
  const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
232
235
  return { text: result.text, usedTools: result.toolsUsed.length > 0, toolsUsed: result.toolsUsed };
233
236
  }
@@ -279,7 +282,7 @@ export async function chatSmart(messages, systemPrompt, modelId, thinkingLevel =
279
282
  streamingStarted = true;
280
283
  accumulated += text;
281
284
  try {
282
- await onChunk(text, accumulated);
285
+ await callbacks.onChunk(text, accumulated);
283
286
  }
284
287
  catch (err) {
285
288
  // editMessageText 실패 등은 무시하고 계속
@@ -293,6 +296,17 @@ export async function chatSmart(messages, systemPrompt, modelId, thinkingLevel =
293
296
  // 주의: chat()은 내부에서 withRetry를 사용하므로 여기서 추가 재시도 불필요
294
297
  if (stopReason === "tool_use") {
295
298
  console.log("[Stream] Tool use detected, falling back to chat()");
299
+ // 도구 이름 추출하여 콜백 호출
300
+ const toolUseBlocks = finalMessage.content.filter((block) => block.type === "tool_use");
301
+ const toolNames = toolUseBlocks.map(t => t.name);
302
+ if (callbacks.onToolStart && toolNames.length > 0) {
303
+ try {
304
+ await callbacks.onToolStart(toolNames);
305
+ }
306
+ catch (err) {
307
+ console.warn("[Stream] Tool start callback error (ignored):", err);
308
+ }
309
+ }
296
310
  const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
297
311
  return { text: result.text, usedTools: true, toolsUsed: result.toolsUsed };
298
312
  }
package/dist/cli/main.js CHANGED
@@ -6,6 +6,7 @@ import { createBot } from "../telegram/bot.js";
6
6
  import { cleanupHeartbeats } from "../heartbeat/index.js";
7
7
  import { cleanupBriefings } from "../briefing/index.js";
8
8
  import { cleanupReminders } from "../reminders/index.js";
9
+ import { preloadEmbeddingModel, preloadVectorStore } from "../memory/index.js";
9
10
  function createPrompt() {
10
11
  return readline.createInterface({
11
12
  input: process.stdin,
@@ -233,7 +234,21 @@ async function main() {
233
234
  }
234
235
  // 4. 환경변수 설정
235
236
  process.env.ANTHROPIC_API_KEY = apiKey;
236
- // 5. 시작
237
+ // 5. 🚀 사전 로딩 (첫 응답 속도 개선)
238
+ console.log(`
239
+ ╔═══════════════════════════════════════════════════════════════╗
240
+ ║ ⏳ 시스템 사전 로딩... ║
241
+ ╚═══════════════════════════════════════════════════════════════╝
242
+ `);
243
+ const preloadStart = Date.now();
244
+ // 임베딩 모델 + 벡터 저장소 병렬 로딩
245
+ await Promise.all([
246
+ preloadEmbeddingModel(),
247
+ preloadVectorStore(),
248
+ ]);
249
+ console.log(` ✓ 사전 로딩 완료 (${Date.now() - preloadStart}ms)
250
+ `);
251
+ // 6. 봇 시작
237
252
  console.log(`
238
253
  ╔═══════════════════════════════════════════════════════════════╗
239
254
  ║ 🚀 봇 시작! ║
@@ -70,14 +70,81 @@ export const MEMORY = {
70
70
  // 텔레그램/UI 관련 설정
71
71
  // ============================================
72
72
  export const TELEGRAM = {
73
- /** 스트리밍 업데이트 간격 (밀리초) */
74
- STREAM_UPDATE_INTERVAL_MS: 500,
73
+ /** 스트리밍 업데이트 간격 - 적응형 (밀리초) */
74
+ STREAM_UPDATE_INTERVAL_MS: 500, // 레거시 호환용
75
+ /** 스트리밍 적응형 간격 설정 */
76
+ STREAM_INTERVAL: {
77
+ /** 첫 번째 업데이트 (즉시) */
78
+ FIRST_MS: 0,
79
+ /** 초기 빠른 업데이트 (처음 5회) */
80
+ FAST_MS: 200,
81
+ /** 이후 일반 간격 */
82
+ NORMAL_MS: 400,
83
+ /** 빠른 업데이트 횟수 */
84
+ FAST_COUNT: 5,
85
+ },
86
+ /** 텔레그램 메시지 최대 길이 */
87
+ MAX_MESSAGE_LENGTH: 4096,
75
88
  /** 최대 이미지 크기 (바이트) - 10MB */
76
89
  MAX_IMAGE_SIZE: 10 * 1024 * 1024,
77
90
  /** URL 처리 최대 개수 */
78
91
  MAX_URL_FETCH: 3,
79
92
  /** 캘린더 미리보기 이벤트 수 */
80
93
  CALENDAR_PREVIEW_COUNT: 3,
94
+ /** 스트리밍 UI 아이콘 */
95
+ STREAM_ICONS: {
96
+ THINKING: "💭",
97
+ TYPING: "▌",
98
+ TOOL: "🔧",
99
+ DONE: "",
100
+ },
101
+ /** Typing indicator 자동 갱신 간격 (밀리초) - 텔레그램은 5초 후 만료 */
102
+ TYPING_REFRESH_MS: 4000,
103
+ /** 도구별 친화적 상태 메시지 */
104
+ TOOL_STATUS_MESSAGES: {
105
+ // 검색/정보 조회
106
+ web_search: { icon: "🔍", text: "웹에서 검색하는 중", estimate: "5-10초" },
107
+ web_fetch: { icon: "📄", text: "웹페이지 읽는 중", estimate: "3-5초" },
108
+ get_weather: { icon: "🌤️", text: "날씨 확인 중", estimate: "2-3초" },
109
+ memory_search: { icon: "🧠", text: "기억 검색 중", estimate: "1-2초" },
110
+ memory_reindex: { icon: "🧠", text: "기억 재색인 중", estimate: "10-30초" },
111
+ // 파일 작업
112
+ read_file: { icon: "📖", text: "파일 읽는 중", estimate: "1초" },
113
+ write_file: { icon: "✍️", text: "파일 쓰는 중", estimate: "1초" },
114
+ edit_file: { icon: "✏️", text: "파일 수정 중", estimate: "1초" },
115
+ list_directory: { icon: "📁", text: "폴더 살펴보는 중", estimate: "1초" },
116
+ // 명령어 실행
117
+ run_command: { icon: "⚡", text: "명령어 실행 중", estimate: "변동" },
118
+ list_sessions: { icon: "📋", text: "세션 목록 확인 중", estimate: "1초" },
119
+ get_session_log: { icon: "📜", text: "로그 가져오는 중", estimate: "1초" },
120
+ kill_session: { icon: "🛑", text: "세션 종료 중", estimate: "1초" },
121
+ // 일정/리마인더
122
+ get_calendar_events: { icon: "📅", text: "일정 확인 중", estimate: "2-3초" },
123
+ add_calendar_event: { icon: "📅", text: "일정 추가 중", estimate: "2-3초" },
124
+ delete_calendar_event: { icon: "📅", text: "일정 삭제 중", estimate: "2초" },
125
+ set_reminder: { icon: "⏰", text: "알림 설정 중", estimate: "1초" },
126
+ list_reminders: { icon: "⏰", text: "알림 목록 확인 중", estimate: "1초" },
127
+ cancel_reminder: { icon: "⏰", text: "알림 취소 중", estimate: "1초" },
128
+ // 브리핑/하트비트
129
+ control_briefing: { icon: "☀️", text: "브리핑 설정 중", estimate: "1초" },
130
+ send_briefing_now: { icon: "☀️", text: "브리핑 준비 중", estimate: "5-10초" },
131
+ control_heartbeat: { icon: "💓", text: "하트비트 설정 중", estimate: "1초" },
132
+ run_heartbeat_check: { icon: "💓", text: "체크 실행 중", estimate: "3-5초" },
133
+ // 서브에이전트
134
+ spawn_agent: { icon: "🤖", text: "서브에이전트 생성 중", estimate: "2-3초" },
135
+ list_agents: { icon: "🤖", text: "에이전트 목록 확인 중", estimate: "1초" },
136
+ cancel_agent: { icon: "🤖", text: "에이전트 취소 중", estimate: "1초" },
137
+ // Cron
138
+ add_cron: { icon: "🕐", text: "예약 작업 추가 중", estimate: "1초" },
139
+ list_crons: { icon: "🕐", text: "예약 작업 확인 중", estimate: "1초" },
140
+ remove_cron: { icon: "🕐", text: "예약 작업 삭제 중", estimate: "1초" },
141
+ toggle_cron: { icon: "🕐", text: "예약 작업 설정 중", estimate: "1초" },
142
+ run_cron: { icon: "🕐", text: "예약 작업 실행 중", estimate: "변동" },
143
+ // 기타
144
+ change_model: { icon: "🔄", text: "모델 변경 중", estimate: "1초" },
145
+ save_memory: { icon: "💾", text: "기억 저장 중", estimate: "1초" },
146
+ save_persona: { icon: "✨", text: "페르소나 저장 중", estimate: "2초" },
147
+ },
81
148
  };
82
149
  // ============================================
83
150
  // 보안/토큰 관련 설정
@@ -86,3 +153,27 @@ export const SECURITY = {
86
153
  /** 리셋 토큰 만료 시간 (밀리초) - 1분 */
87
154
  RESET_TOKEN_TTL_MS: 60000,
88
155
  };
156
+ // ============================================
157
+ // API/네트워크 설정
158
+ // ============================================
159
+ export const API = {
160
+ /** Claude API 타임아웃 (밀리초) - 2분 */
161
+ TIMEOUT_MS: 120000,
162
+ /** 최대 재시도 횟수 */
163
+ MAX_RETRIES: 3,
164
+ /** 초기 재시도 대기 시간 (밀리초) */
165
+ INITIAL_RETRY_DELAY_MS: 1000,
166
+ /** 최대 재시도 대기 시간 (밀리초) */
167
+ MAX_RETRY_DELAY_MS: 30000,
168
+ /** 재시도 백오프 배수 */
169
+ BACKOFF_MULTIPLIER: 2,
170
+ };
171
+ // ============================================
172
+ // 메모리 검색 타임아웃 설정
173
+ // ============================================
174
+ export const SEARCH = {
175
+ /** 전체 검색 타임아웃 (밀리초) */
176
+ TIMEOUT_MS: 5000,
177
+ /** 임베딩 생성 타임아웃 (밀리초) */
178
+ EMBED_TIMEOUT_MS: 3000,
179
+ };
@@ -3,6 +3,7 @@
3
3
  * 봇의 상태를 추적하고 모니터링합니다.
4
4
  * @module health
5
5
  */
6
+ import { getWarmupStatus } from "../warmup.js";
6
7
  let startTime = Date.now();
7
8
  let lastActivity = Date.now();
8
9
  let messageCount = 0;
@@ -36,7 +37,8 @@ export function getHealthStatus() {
36
37
  lastActivity,
37
38
  messageCount,
38
39
  errorCount,
39
- isHealthy
40
+ isHealthy,
41
+ warmup: getWarmupStatus(),
40
42
  };
41
43
  }
42
44
  /**
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 메모리 검색 벤치마크
3
+ *
4
+ * 사용법: npx tsx src/memory/benchmark.ts
5
+ */
6
+ import { performance } from "perf_hooks";
7
+ // 현재 구현
8
+ import * as currentVector from "./vectorStore.js";
9
+ import * as currentFts from "./ftsIndex.js";
10
+ import * as currentHybrid from "./hybridSearch.js";
11
+ // 최적화 구현 (주석 해제하여 비교)
12
+ // import * as optimizedVector from "./vectorStore.optimized.js";
13
+ // import * as optimizedFts from "./ftsIndex.optimized.js";
14
+ // import * as optimizedHybrid from "./hybridSearch.optimized.js";
15
+ const TEST_QUERIES = [
16
+ "오늘 무슨 일이 있었어?",
17
+ "지난주 회의 내용",
18
+ "프로젝트 마감일",
19
+ "API 키 설정",
20
+ "에러 해결 방법",
21
+ "CompanionBot feature",
22
+ "일정 확인",
23
+ "메모리 검색 최적화",
24
+ "테스트 코드 작성",
25
+ "버그 수정",
26
+ ];
27
+ async function benchmark(name, fn, iterations = 10) {
28
+ const times = [];
29
+ // 워밍업
30
+ await fn();
31
+ await fn();
32
+ for (let i = 0; i < iterations; i++) {
33
+ const start = performance.now();
34
+ await fn();
35
+ const end = performance.now();
36
+ times.push(end - start);
37
+ }
38
+ times.sort((a, b) => a - b);
39
+ const avgMs = times.reduce((a, b) => a + b, 0) / times.length;
40
+ const p95Idx = Math.floor(times.length * 0.95);
41
+ return {
42
+ name,
43
+ avgMs: Math.round(avgMs * 100) / 100,
44
+ minMs: Math.round(times[0] * 100) / 100,
45
+ maxMs: Math.round(times[times.length - 1] * 100) / 100,
46
+ p95Ms: Math.round(times[p95Idx] * 100) / 100,
47
+ ops: Math.round(1000 / avgMs),
48
+ };
49
+ }
50
+ function printResult(result) {
51
+ console.log(`
52
+ ${result.name}:
53
+ Average: ${result.avgMs}ms
54
+ Min: ${result.minMs}ms
55
+ Max: ${result.maxMs}ms
56
+ P95: ${result.p95Ms}ms
57
+ Ops/sec: ${result.ops}
58
+ `);
59
+ }
60
+ async function main() {
61
+ console.log("=".repeat(60));
62
+ console.log("Memory Search Benchmark");
63
+ console.log("=".repeat(60));
64
+ console.log(`\nTest queries: ${TEST_QUERIES.length}`);
65
+ console.log(`Iterations per query: 10\n`);
66
+ // 1. 벡터 검색 벤치마크
67
+ console.log("\n--- Vector Search ---");
68
+ for (const query of TEST_QUERIES.slice(0, 3)) {
69
+ const result = await benchmark(`Vector search: "${query.slice(0, 20)}..."`, async () => {
70
+ return currentHybrid.searchVector(query, 5, 0.3);
71
+ }, 10);
72
+ printResult(result);
73
+ }
74
+ // 2. FTS 검색 벤치마크
75
+ console.log("\n--- FTS Keyword Search ---");
76
+ for (const query of TEST_QUERIES.slice(0, 3)) {
77
+ const result = await benchmark(`FTS search: "${query.slice(0, 20)}..."`, async () => {
78
+ return currentFts.searchKeyword(query, 10);
79
+ }, 10);
80
+ printResult(result);
81
+ }
82
+ // 3. 하이브리드 검색 벤치마크
83
+ console.log("\n--- Hybrid Search ---");
84
+ for (const query of TEST_QUERIES.slice(0, 3)) {
85
+ const result = await benchmark(`Hybrid search: "${query.slice(0, 20)}..."`, async () => {
86
+ return currentHybrid.hybridSearch(query, { topK: 5 });
87
+ }, 10);
88
+ printResult(result);
89
+ }
90
+ // 4. 청크 로딩 벤치마크
91
+ console.log("\n--- Chunk Loading ---");
92
+ // 캐시 무효화 후 로딩 시간
93
+ currentVector.invalidateCache();
94
+ const loadResult = await benchmark("Load all chunks (cold)", async () => {
95
+ currentVector.invalidateCache();
96
+ return currentVector.loadAllMemoryChunks();
97
+ }, 5);
98
+ printResult(loadResult);
99
+ // 캐시된 로딩 시간
100
+ const cachedLoadResult = await benchmark("Load all chunks (cached)", async () => {
101
+ return currentVector.loadAllMemoryChunks();
102
+ }, 10);
103
+ printResult(cachedLoadResult);
104
+ // 5. 통계
105
+ console.log("\n--- Statistics ---");
106
+ const chunks = await currentVector.loadAllMemoryChunks();
107
+ const ftsCount = currentFts.getDocumentCount();
108
+ console.log(`Total chunks in vector store: ${chunks.length}`);
109
+ console.log(`Total documents in FTS: ${ftsCount}`);
110
+ console.log("\n" + "=".repeat(60));
111
+ console.log("Benchmark complete");
112
+ console.log("=".repeat(60));
113
+ }
114
+ main().catch(console.error);
@@ -8,6 +8,24 @@ let embeddingPipeline = null;
8
8
  // 모델 로딩 중인지 추적
9
9
  let isLoading = false;
10
10
  let loadingPromise = null;
11
+ // ============== 쿼리 임베딩 LRU 캐시 ==============
12
+ // 같은 검색 쿼리가 반복될 때 임베딩 재계산 방지
13
+ const QUERY_CACHE_MAX_SIZE = 100;
14
+ const queryEmbeddingCache = new Map();
15
+ /**
16
+ * LRU 방식으로 캐시 정리
17
+ */
18
+ function pruneQueryCache() {
19
+ if (queryEmbeddingCache.size <= QUERY_CACHE_MAX_SIZE)
20
+ return;
21
+ // lastUsed 기준 정렬하여 오래된 것 삭제
22
+ const entries = [...queryEmbeddingCache.entries()];
23
+ entries.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
24
+ const toRemove = entries.slice(0, entries.length - QUERY_CACHE_MAX_SIZE);
25
+ for (const [key] of toRemove) {
26
+ queryEmbeddingCache.delete(key);
27
+ }
28
+ }
11
29
  /**
12
30
  * 임베딩 파이프라인을 초기화합니다.
13
31
  * 작고 빠른 모델 사용 (384 차원)
@@ -21,42 +39,73 @@ async function getEmbeddingPipeline() {
21
39
  return loadingPromise;
22
40
  }
23
41
  isLoading = true;
42
+ console.log("[Embedding] Loading model...");
43
+ const startTime = Date.now();
24
44
  loadingPromise = pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2" // 384차원, 빠르고 가벼움
25
45
  );
26
46
  try {
27
47
  embeddingPipeline = await loadingPromise;
48
+ console.log(`[Embedding] Model loaded in ${Date.now() - startTime}ms`);
28
49
  return embeddingPipeline;
29
50
  }
30
51
  finally {
31
52
  isLoading = false;
32
53
  }
33
54
  }
55
+ /**
56
+ * 🚀 사전 로딩: 봇 시작 시 호출하여 첫 요청 지연 방지
57
+ */
58
+ export async function preloadEmbeddingModel() {
59
+ try {
60
+ await getEmbeddingPipeline();
61
+ }
62
+ catch (error) {
63
+ console.warn("[Embedding] Preload failed:", error);
64
+ }
65
+ }
34
66
  /**
35
67
  * 텍스트를 임베딩 벡터로 변환합니다.
68
+ * LRU 캐시로 반복 쿼리 성능 향상.
36
69
  * @param text 변환할 텍스트
70
+ * @param useCache 캐시 사용 여부 (기본 true, 청크 임베딩 시 false 권장)
37
71
  * @returns 384차원 임베딩 벡터
38
72
  */
39
- export async function embed(text) {
73
+ export async function embed(text, useCache = true) {
40
74
  // null/undefined 처리
41
75
  if (text == null) {
42
76
  return new Array(384).fill(0);
43
77
  }
44
- const pipe = await getEmbeddingPipeline();
45
78
  // 텍스트 정규화
46
79
  const cleanText = text.trim().slice(0, 512); // 최대 512자
47
80
  if (!cleanText) {
48
81
  return new Array(384).fill(0);
49
82
  }
83
+ // 캐시 확인
84
+ if (useCache) {
85
+ const cached = queryEmbeddingCache.get(cleanText);
86
+ if (cached) {
87
+ cached.lastUsed = Date.now();
88
+ return cached.embedding;
89
+ }
90
+ }
91
+ const pipe = await getEmbeddingPipeline();
50
92
  const result = await pipe(cleanText, {
51
93
  pooling: "mean",
52
94
  normalize: true,
53
95
  });
54
96
  // Tensor를 배열로 변환
55
- return Array.from(result.data);
97
+ const embedding = Array.from(result.data);
98
+ // 캐시 저장
99
+ if (useCache) {
100
+ queryEmbeddingCache.set(cleanText, { embedding, lastUsed: Date.now() });
101
+ pruneQueryCache();
102
+ }
103
+ return embedding;
56
104
  }
57
105
  /**
58
106
  * 여러 텍스트를 배치로 임베딩합니다.
59
107
  * 병렬로 처리하여 성능 향상 (모델 내부에서 순차 처리되더라도 Promise 오버헤드 감소)
108
+ * 청크용이므로 쿼리 캐시 사용 안 함 (vectorStore의 영속 캐시 사용).
60
109
  * @param texts 변환할 텍스트 배열
61
110
  * @returns 임베딩 벡터 배열
62
111
  */
@@ -65,19 +114,32 @@ export async function embedBatch(texts) {
65
114
  if (!texts || texts.length === 0)
66
115
  return [];
67
116
  if (texts.length === 1)
68
- return [await embed(texts[0])];
117
+ return [await embed(texts[0], false)];
69
118
  // 동시성 제한 (메모리 보호)
70
119
  const CONCURRENCY = 5;
71
120
  const results = new Array(texts.length);
72
121
  for (let i = 0; i < texts.length; i += CONCURRENCY) {
73
122
  const batch = texts.slice(i, i + CONCURRENCY);
74
- const batchResults = await Promise.all(batch.map(text => embed(text)));
123
+ // 청크 임베딩은 캐시 사용 (useCache=false)
124
+ const batchResults = await Promise.all(batch.map(text => embed(text, false)));
75
125
  for (let j = 0; j < batchResults.length; j++) {
76
126
  results[i + j] = batchResults[j];
77
127
  }
78
128
  }
79
129
  return results;
80
130
  }
131
+ /**
132
+ * 쿼리 임베딩 캐시 통계를 반환합니다.
133
+ */
134
+ export function getQueryCacheStats() {
135
+ return { size: queryEmbeddingCache.size, maxSize: QUERY_CACHE_MAX_SIZE };
136
+ }
137
+ /**
138
+ * 쿼리 임베딩 캐시를 초기화합니다.
139
+ */
140
+ export function clearQueryCache() {
141
+ queryEmbeddingCache.clear();
142
+ }
81
143
  /**
82
144
  * 두 벡터 간의 코사인 유사도를 계산합니다.
83
145
  *