@iflow-mcp/apple-rag-mcp 4.6.2

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.
Files changed (148) hide show
  1. package/.github/workflows/release.yml +62 -0
  2. package/.releaserc.json +38 -0
  3. package/CHANGELOG.md +161 -0
  4. package/README.md +114 -0
  5. package/README.zh-CN.md +119 -0
  6. package/apple-rag-mcp_process.log +8 -0
  7. package/biome.json +59 -0
  8. package/dist/src/auth/auth-middleware.d.ts +26 -0
  9. package/dist/src/auth/auth-middleware.d.ts.map +1 -0
  10. package/dist/src/auth/auth-middleware.js +77 -0
  11. package/dist/src/auth/auth-middleware.js.map +1 -0
  12. package/dist/src/auth/token-validator.d.ts +22 -0
  13. package/dist/src/auth/token-validator.d.ts.map +1 -0
  14. package/dist/src/auth/token-validator.js +64 -0
  15. package/dist/src/auth/token-validator.js.map +1 -0
  16. package/dist/src/mcp/formatters/response-formatter.d.ts +26 -0
  17. package/dist/src/mcp/formatters/response-formatter.d.ts.map +1 -0
  18. package/dist/src/mcp/formatters/response-formatter.js +119 -0
  19. package/dist/src/mcp/formatters/response-formatter.js.map +1 -0
  20. package/dist/src/mcp/manifest.d.ts +48 -0
  21. package/dist/src/mcp/manifest.d.ts.map +1 -0
  22. package/dist/src/mcp/manifest.js +46 -0
  23. package/dist/src/mcp/manifest.js.map +1 -0
  24. package/dist/src/mcp/middleware/request-validator.d.ts +48 -0
  25. package/dist/src/mcp/middleware/request-validator.d.ts.map +1 -0
  26. package/dist/src/mcp/middleware/request-validator.js +102 -0
  27. package/dist/src/mcp/middleware/request-validator.js.map +1 -0
  28. package/dist/src/mcp/protocol-handler.d.ts +70 -0
  29. package/dist/src/mcp/protocol-handler.d.ts.map +1 -0
  30. package/dist/src/mcp/protocol-handler.js +285 -0
  31. package/dist/src/mcp/protocol-handler.js.map +1 -0
  32. package/dist/src/mcp/tools/fetch-tool.d.ts +18 -0
  33. package/dist/src/mcp/tools/fetch-tool.d.ts.map +1 -0
  34. package/dist/src/mcp/tools/fetch-tool.js +76 -0
  35. package/dist/src/mcp/tools/fetch-tool.js.map +1 -0
  36. package/dist/src/mcp/tools/search-tool.d.ts +20 -0
  37. package/dist/src/mcp/tools/search-tool.d.ts.map +1 -0
  38. package/dist/src/mcp/tools/search-tool.js +86 -0
  39. package/dist/src/mcp/tools/search-tool.js.map +1 -0
  40. package/dist/src/services/database.d.ts +37 -0
  41. package/dist/src/services/database.d.ts.map +1 -0
  42. package/dist/src/services/database.js +166 -0
  43. package/dist/src/services/database.js.map +1 -0
  44. package/dist/src/services/deepinfra-base.d.ts +22 -0
  45. package/dist/src/services/deepinfra-base.d.ts.map +1 -0
  46. package/dist/src/services/deepinfra-base.js +55 -0
  47. package/dist/src/services/deepinfra-base.js.map +1 -0
  48. package/dist/src/services/embedding.d.ts +44 -0
  49. package/dist/src/services/embedding.d.ts.map +1 -0
  50. package/dist/src/services/embedding.js +61 -0
  51. package/dist/src/services/embedding.js.map +1 -0
  52. package/dist/src/services/index.d.ts +10 -0
  53. package/dist/src/services/index.d.ts.map +1 -0
  54. package/dist/src/services/index.js +52 -0
  55. package/dist/src/services/index.js.map +1 -0
  56. package/dist/src/services/ip-authentication.d.ts +12 -0
  57. package/dist/src/services/ip-authentication.d.ts.map +1 -0
  58. package/dist/src/services/ip-authentication.js +39 -0
  59. package/dist/src/services/ip-authentication.js.map +1 -0
  60. package/dist/src/services/rag.d.ts +35 -0
  61. package/dist/src/services/rag.d.ts.map +1 -0
  62. package/dist/src/services/rag.js +106 -0
  63. package/dist/src/services/rag.js.map +1 -0
  64. package/dist/src/services/rate-limit.d.ts +27 -0
  65. package/dist/src/services/rate-limit.d.ts.map +1 -0
  66. package/dist/src/services/rate-limit.js +91 -0
  67. package/dist/src/services/rate-limit.js.map +1 -0
  68. package/dist/src/services/reranker.d.ts +40 -0
  69. package/dist/src/services/reranker.d.ts.map +1 -0
  70. package/dist/src/services/reranker.js +97 -0
  71. package/dist/src/services/reranker.js.map +1 -0
  72. package/dist/src/services/search-engine.d.ts +89 -0
  73. package/dist/src/services/search-engine.d.ts.map +1 -0
  74. package/dist/src/services/search-engine.js +225 -0
  75. package/dist/src/services/search-engine.js.map +1 -0
  76. package/dist/src/services/tool-call-logger.d.ts +36 -0
  77. package/dist/src/services/tool-call-logger.d.ts.map +1 -0
  78. package/dist/src/services/tool-call-logger.js +34 -0
  79. package/dist/src/services/tool-call-logger.js.map +1 -0
  80. package/dist/src/types/env.d.ts +18 -0
  81. package/dist/src/types/env.d.ts.map +1 -0
  82. package/dist/src/types/env.js +2 -0
  83. package/dist/src/types/env.js.map +1 -0
  84. package/dist/src/types/index.d.ts +145 -0
  85. package/dist/src/types/index.d.ts.map +1 -0
  86. package/dist/src/types/index.js +6 -0
  87. package/dist/src/types/index.js.map +1 -0
  88. package/dist/src/utils/d1-utils.d.ts +6 -0
  89. package/dist/src/utils/d1-utils.d.ts.map +1 -0
  90. package/dist/src/utils/d1-utils.js +29 -0
  91. package/dist/src/utils/d1-utils.js.map +1 -0
  92. package/dist/src/utils/logger.d.ts +11 -0
  93. package/dist/src/utils/logger.d.ts.map +1 -0
  94. package/dist/src/utils/logger.js +26 -0
  95. package/dist/src/utils/logger.js.map +1 -0
  96. package/dist/src/utils/query-cleaner.d.ts +20 -0
  97. package/dist/src/utils/query-cleaner.d.ts.map +1 -0
  98. package/dist/src/utils/query-cleaner.js +117 -0
  99. package/dist/src/utils/query-cleaner.js.map +1 -0
  100. package/dist/src/utils/request-info.d.ts +18 -0
  101. package/dist/src/utils/request-info.d.ts.map +1 -0
  102. package/dist/src/utils/request-info.js +32 -0
  103. package/dist/src/utils/request-info.js.map +1 -0
  104. package/dist/src/utils/telegram-notifier.d.ts +4 -0
  105. package/dist/src/utils/telegram-notifier.d.ts.map +1 -0
  106. package/dist/src/utils/telegram-notifier.js +33 -0
  107. package/dist/src/utils/telegram-notifier.js.map +1 -0
  108. package/dist/src/utils/url-processor.d.ts +15 -0
  109. package/dist/src/utils/url-processor.d.ts.map +1 -0
  110. package/dist/src/utils/url-processor.js +54 -0
  111. package/dist/src/utils/url-processor.js.map +1 -0
  112. package/dist/src/worker.d.ts +15 -0
  113. package/dist/src/worker.d.ts.map +1 -0
  114. package/dist/src/worker.js +136 -0
  115. package/dist/src/worker.js.map +1 -0
  116. package/migrations/schema.sql +155 -0
  117. package/package.json +49 -0
  118. package/scripts/semantic-release-server-json.js +34 -0
  119. package/server.json +25 -0
  120. package/src/auth/auth-middleware.ts +104 -0
  121. package/src/auth/token-validator.ts +96 -0
  122. package/src/mcp/formatters/response-formatter.ts +157 -0
  123. package/src/mcp/manifest.ts +48 -0
  124. package/src/mcp/middleware/request-validator.ts +135 -0
  125. package/src/mcp/protocol-handler.ts +412 -0
  126. package/src/mcp/tools/fetch-tool.ts +146 -0
  127. package/src/mcp/tools/search-tool.ts +165 -0
  128. package/src/services/database.ts +202 -0
  129. package/src/services/deepinfra-base.ts +81 -0
  130. package/src/services/embedding.ts +96 -0
  131. package/src/services/index.ts +59 -0
  132. package/src/services/ip-authentication.ts +62 -0
  133. package/src/services/rag.ts +158 -0
  134. package/src/services/rate-limit.ts +141 -0
  135. package/src/services/reranker.ts +171 -0
  136. package/src/services/search-engine.ts +333 -0
  137. package/src/services/tool-call-logger.ts +98 -0
  138. package/src/types/env.ts +22 -0
  139. package/src/types/index.ts +189 -0
  140. package/src/utils/d1-utils.ts +45 -0
  141. package/src/utils/logger.ts +33 -0
  142. package/src/utils/query-cleaner.ts +151 -0
  143. package/src/utils/request-info.ts +47 -0
  144. package/src/utils/telegram-notifier.ts +47 -0
  145. package/src/utils/url-processor.ts +65 -0
  146. package/src/worker.ts +176 -0
  147. package/tsconfig.json +32 -0
  148. package/wrangler.toml.example +39 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Rate Limiting Service with D1 timeout protection
3
+ */
4
+
5
+ import type { AuthContext } from "../types/index.js";
6
+ import { withD1Timeout } from "../utils/d1-utils.js";
7
+ import { logger } from "../utils/logger.js";
8
+
9
+ interface RateLimitResult {
10
+ allowed: boolean;
11
+ limit: number;
12
+ remaining: number;
13
+ resetAt: string;
14
+ planType: string;
15
+ limitType: "weekly" | "minute";
16
+ minuteLimit?: number;
17
+ minuteRemaining?: number;
18
+ minuteResetAt?: string;
19
+ }
20
+
21
+ interface PlanLimits {
22
+ weeklyQueries: number;
23
+ requestsPerMinute: number;
24
+ }
25
+
26
+ const PLAN_LIMITS: Record<string, PlanLimits> = {
27
+ anonymous: { weeklyQueries: 30, requestsPerMinute: 3 },
28
+ hobby: { weeklyQueries: 50, requestsPerMinute: 5 },
29
+ pro: { weeklyQueries: 50000, requestsPerMinute: 50 },
30
+ enterprise: { weeklyQueries: -1, requestsPerMinute: -1 },
31
+ };
32
+
33
+ export class RateLimitService {
34
+ constructor(private d1: D1Database) {}
35
+
36
+ async checkLimits(
37
+ clientIP: string,
38
+ authContext: AuthContext
39
+ ): Promise<RateLimitResult> {
40
+ const identifier = authContext.userId || `anon_${clientIP}`;
41
+ const planType = authContext.isAuthenticated && authContext.userId
42
+ ? await this.getPlanType(authContext.userId)
43
+ : "anonymous";
44
+
45
+ const limits = PLAN_LIMITS[planType] || PLAN_LIMITS.hobby;
46
+
47
+ const [weeklyUsage, minuteUsage] = await Promise.all([
48
+ this.getUsageCount(identifier, "weekly"),
49
+ this.getUsageCount(identifier, "minute"),
50
+ ]);
51
+
52
+ const weeklyAllowed = limits.weeklyQueries === -1 || weeklyUsage < limits.weeklyQueries;
53
+ const minuteAllowed = limits.requestsPerMinute === -1 || minuteUsage < limits.requestsPerMinute;
54
+ const allowed = weeklyAllowed && minuteAllowed;
55
+
56
+ if (!allowed) {
57
+ logger.info(
58
+ `Rate limit: ${identifier} (${planType}) weekly=${weeklyUsage}/${limits.weeklyQueries} minute=${minuteUsage}/${limits.requestsPerMinute}`
59
+ );
60
+ }
61
+
62
+ return {
63
+ allowed,
64
+ limit: limits.weeklyQueries,
65
+ remaining: limits.weeklyQueries === -1 ? -1 : Math.max(0, limits.weeklyQueries - weeklyUsage),
66
+ resetAt: this.getWeeklyResetTime(),
67
+ planType,
68
+ limitType: !minuteAllowed ? "minute" : "weekly",
69
+ minuteLimit: limits.requestsPerMinute,
70
+ minuteRemaining: limits.requestsPerMinute === -1 ? -1 : Math.max(0, limits.requestsPerMinute - minuteUsage),
71
+ minuteResetAt: this.getMinuteResetTime(),
72
+ };
73
+ }
74
+
75
+ private async getPlanType(userId: string): Promise<string> {
76
+ return withD1Timeout(
77
+ async () => {
78
+ const result = await this.d1
79
+ .prepare(
80
+ `SELECT plan_type FROM user_subscriptions
81
+ WHERE user_id = ? AND status = 'active' LIMIT 1`
82
+ )
83
+ .bind(userId)
84
+ .first();
85
+ return (result?.plan_type as string) || "hobby";
86
+ },
87
+ "hobby",
88
+ "get_plan_type"
89
+ );
90
+ }
91
+
92
+ private async getUsageCount(
93
+ identifier: string,
94
+ period: "weekly" | "minute"
95
+ ): Promise<number> {
96
+ const since = period === "weekly"
97
+ ? this.getWeekStartTime().toISOString()
98
+ : new Date(Date.now() - 60_000).toISOString();
99
+
100
+ const operator = period === "weekly" ? ">=" : ">";
101
+
102
+ return withD1Timeout(
103
+ async () => {
104
+ const result = await this.d1
105
+ .prepare(
106
+ `SELECT
107
+ (SELECT COUNT(*) FROM search_logs WHERE user_id = ? AND created_at ${operator} ? AND status_code = 200) +
108
+ (SELECT COUNT(*) FROM fetch_logs WHERE user_id = ? AND created_at ${operator} ? AND status_code = 200) as total`
109
+ )
110
+ .bind(identifier, since, identifier, since)
111
+ .first();
112
+ return (result?.total as number) || 0;
113
+ },
114
+ 0,
115
+ `get_${period}_usage`
116
+ );
117
+ }
118
+
119
+ private getWeekStartTime(): Date {
120
+ const now = new Date();
121
+ const start = new Date(now);
122
+ start.setDate(now.getDate() - now.getDay());
123
+ start.setHours(0, 0, 0, 0);
124
+ return start;
125
+ }
126
+
127
+ private getWeeklyResetTime(): string {
128
+ const now = new Date();
129
+ const next = new Date(now);
130
+ next.setDate(now.getDate() + (7 - now.getDay()));
131
+ next.setHours(0, 0, 0, 0);
132
+ return next.toISOString();
133
+ }
134
+
135
+ private getMinuteResetTime(): string {
136
+ const next = new Date();
137
+ next.setSeconds(0, 0);
138
+ next.setMinutes(next.getMinutes() + 1);
139
+ return next.toISOString();
140
+ }
141
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * DeepInfra Reranker Service - MCP Optimized
3
+ * Dual-model fallback: 8B → 4B, 2 attempts each
4
+ */
5
+
6
+ import { logger } from "../utils/logger.js";
7
+ import { DEEPINFRA_CONFIG, DeepInfraService } from "./deepinfra-base.js";
8
+
9
+ interface RerankerInput {
10
+ query: string;
11
+ documents: string[];
12
+ topN: number;
13
+ }
14
+
15
+ interface RerankerPayload {
16
+ queries: [string];
17
+ documents: string[];
18
+ top_n: number;
19
+ }
20
+
21
+ export interface RerankerResponse {
22
+ scores: number[];
23
+ }
24
+
25
+ export interface RankedDocument {
26
+ content: string;
27
+ originalIndex: number;
28
+ relevanceScore: number;
29
+ }
30
+
31
+ type ModelConfig = { endpoint: string; name: string };
32
+
33
+ export class RerankerService extends DeepInfraService<
34
+ RerankerInput,
35
+ RerankerResponse,
36
+ RankedDocument[]
37
+ > {
38
+ protected readonly endpoint =
39
+ `/v1/inference/${DEEPINFRA_CONFIG.RERANKER_MODEL_PRIMARY}`;
40
+
41
+ private static readonly MODELS: readonly ModelConfig[] = [
42
+ {
43
+ endpoint: `/v1/inference/${DEEPINFRA_CONFIG.RERANKER_MODEL_PRIMARY}`,
44
+ name: "8B",
45
+ },
46
+ {
47
+ endpoint: `/v1/inference/${DEEPINFRA_CONFIG.RERANKER_MODEL_FALLBACK}`,
48
+ name: "4B",
49
+ },
50
+ ];
51
+
52
+ private static readonly MAX_ATTEMPTS = 2;
53
+
54
+ async rerank(
55
+ query: string,
56
+ documents: string[],
57
+ topN: number
58
+ ): Promise<RankedDocument[]> {
59
+ if (!query?.trim()) throw new Error("Query cannot be empty for reranking");
60
+ if (!documents?.length)
61
+ throw new Error("Documents cannot be empty for reranking");
62
+
63
+ const validTopN = Math.min(topN, documents.length);
64
+ if (validTopN <= 0) throw new Error("top_n must be greater than 0");
65
+
66
+ return this.call(
67
+ { query: query.trim(), documents, topN: validTopN },
68
+ "Document reranking"
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Dual-model fallback: 8B (2 attempts) → 4B (2 attempts) → fail
74
+ */
75
+ protected override async call(
76
+ input: RerankerInput,
77
+ operationName: string
78
+ ): Promise<RankedDocument[]> {
79
+ const startTime = Date.now();
80
+ const payload = this.buildPayload(input);
81
+ const errors: string[] = [];
82
+
83
+ for (const model of RerankerService.MODELS) {
84
+ const result = await this.tryModel(model, payload, input);
85
+ if (result.success) {
86
+ logger.info(
87
+ `${operationName} completed with ${model.name} (${this.elapsed(startTime)})`
88
+ );
89
+ return result.data!;
90
+ }
91
+ errors.push(`${model.name}: ${result.error!}`);
92
+ logger.warn(
93
+ `${model.name} model failed, ${model === RerankerService.MODELS[0] ? "switching to 4B" : "no more fallbacks"}`
94
+ );
95
+ }
96
+
97
+ logger.error(
98
+ `${operationName} failed after all attempts (${this.elapsed(startTime)}): ${errors.join(" | ")}`
99
+ );
100
+ throw new Error(`Reranking failed: ${errors.join(" | ")}`);
101
+ }
102
+
103
+ private async tryModel(
104
+ model: ModelConfig,
105
+ payload: RerankerPayload,
106
+ input: RerankerInput
107
+ ): Promise<{ success: boolean; data?: RankedDocument[]; error?: string }> {
108
+ let lastError = "";
109
+
110
+ for (let attempt = 1; attempt <= RerankerService.MAX_ATTEMPTS; attempt++) {
111
+ try {
112
+ const response = await this.singleRequest(model.endpoint, payload);
113
+ return { success: true, data: this.processResponse(response, input) };
114
+ } catch (e) {
115
+ lastError = e instanceof Error ? e.message : String(e);
116
+ logger.warn(
117
+ `${model.name} attempt ${attempt}/${RerankerService.MAX_ATTEMPTS} failed: ${lastError}`
118
+ );
119
+ }
120
+ }
121
+
122
+ return { success: false, error: lastError };
123
+ }
124
+
125
+ private elapsed(startTime: number): string {
126
+ return `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
127
+ }
128
+
129
+ protected buildPayload(input: RerankerInput): RerankerPayload {
130
+ return {
131
+ queries: [input.query],
132
+ documents: input.documents,
133
+ top_n: input.topN,
134
+ };
135
+ }
136
+
137
+ protected processResponse(
138
+ response: RerankerResponse,
139
+ input: RerankerInput
140
+ ): RankedDocument[] {
141
+ if (!response.scores?.length) {
142
+ throw new Error("No reranking results received from DeepInfra API");
143
+ }
144
+
145
+ const count = Math.min(
146
+ input.topN,
147
+ response.scores.length,
148
+ input.documents.length
149
+ );
150
+
151
+ return input.documents
152
+ .map((content, index) => ({
153
+ content,
154
+ originalIndex: index,
155
+ relevanceScore: response.scores[index] ?? 0,
156
+ }))
157
+ .sort((a, b) => b.relevanceScore - a.relevanceScore)
158
+ .slice(0, count);
159
+ }
160
+
161
+ async healthCheck(): Promise<boolean> {
162
+ try {
163
+ return (await this.rerank("test query", ["test document"], 1)).length > 0;
164
+ } catch (error) {
165
+ logger.error(
166
+ `Reranker health check failed: ${error instanceof Error ? error.message : String(error)}`
167
+ );
168
+ return false;
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Hybrid Search Engine for Apple Developer Documentation
3
+ *
4
+ * Advanced implementation combining Semantic Search for RAG with precise
5
+ * Keyword Search and Hybrid Search, optimized for developer documentation retrieval.
6
+ *
7
+ * Pipeline: Query → [Vector (4N) + Technical Term (4N)] → Merge → Title Merge → AI Rerank → Results
8
+ *
9
+ * Features:
10
+ * - 4N+4N hybrid candidate strategy
11
+ * - Semantic vector search with pgvector HNSW
12
+ * - Technical term search with PostgreSQL 'simple' configuration
13
+ * - Title-based content merging
14
+ * - AI reranking with Qwen3-Reranker-8B
15
+ */
16
+
17
+ import type {
18
+ AdditionalUrl,
19
+ SearchOptions,
20
+ SearchResult,
21
+ } from "../types/index.js";
22
+ import { logger } from "../utils/logger.js";
23
+ import type { DatabaseService } from "./database.js";
24
+ import type { EmbeddingService } from "./embedding.js";
25
+ import type { RerankerService } from "./reranker.js";
26
+
27
+ export interface ParsedChunk {
28
+ content: string;
29
+ title: string | null;
30
+ }
31
+
32
+ export interface ProcessedResult {
33
+ id: string;
34
+ url: string;
35
+ title: string | null;
36
+ content: string;
37
+ contentLength: number;
38
+ chunk_index: number;
39
+ total_chunks: number;
40
+ mergedChunkIndices?: number[];
41
+ }
42
+
43
+ export interface RankedSearchResult {
44
+ id: string;
45
+ url: string;
46
+ title: string | null;
47
+ content: string;
48
+ chunk_index: number;
49
+ total_chunks: number;
50
+ mergedChunkIndices?: number[];
51
+ original_index: number;
52
+ }
53
+
54
+ export interface SearchEngineResult {
55
+ results: RankedSearchResult[];
56
+ additionalUrls: AdditionalUrl[];
57
+ }
58
+
59
+ export class SearchEngine {
60
+ constructor(
61
+ private database: DatabaseService,
62
+ private embedding: EmbeddingService,
63
+ private reranker: RerankerService
64
+ ) {}
65
+
66
+ /**
67
+ * Execute hybrid search optimized for Apple Developer Documentation
68
+ */
69
+ async search(
70
+ query: string,
71
+ options: SearchOptions = {}
72
+ ): Promise<SearchEngineResult> {
73
+ const { resultCount = 4 } = options;
74
+ return this.hybridSearchWithReranker(query, resultCount);
75
+ }
76
+
77
+ /**
78
+ * Hybrid search with 4N+4N candidate strategy
79
+ *
80
+ * 1. Parallel: Vector search (4N) + Technical term search (4N)
81
+ * 2. Merge and deduplicate by ID
82
+ * 3. Title-based content merging
83
+ * 4. AI reranking for optimal results
84
+ */
85
+ private async hybridSearchWithReranker(
86
+ query: string,
87
+ resultCount: number
88
+ ): Promise<SearchEngineResult> {
89
+ // Step 1: Parallel candidate retrieval (4N each, no minimum limit)
90
+ const candidateCount = resultCount * 4;
91
+
92
+ const [semanticResults, keywordResults] = await Promise.all([
93
+ this.getSemanticCandidates(query, candidateCount),
94
+ this.getKeywordCandidates(query, candidateCount),
95
+ ]);
96
+
97
+ // Step 2: Merge and deduplicate candidates
98
+ const mergedCandidates = this.mergeCandidates(
99
+ semanticResults,
100
+ keywordResults
101
+ );
102
+
103
+ // Step 3: Process results (title-based merging)
104
+ const processedResults = this.processResults(mergedCandidates);
105
+
106
+ // Step 4: AI reranking with fallback mechanism
107
+ let finalResults: RankedSearchResult[];
108
+
109
+ try {
110
+ const rankedDocuments = await this.reranker.rerank(
111
+ query,
112
+ processedResults.map((r) => r.content),
113
+ Math.min(resultCount, processedResults.length)
114
+ );
115
+
116
+ // Step 5: Map back to final results
117
+ finalResults = rankedDocuments.map((doc) => {
118
+ const processed = processedResults[doc.originalIndex];
119
+ return {
120
+ id: processed.id,
121
+ url: processed.url,
122
+ title: processed.title,
123
+ content: processed.content,
124
+ chunk_index: processed.chunk_index,
125
+ total_chunks: processed.total_chunks,
126
+ mergedChunkIndices: processed.mergedChunkIndices,
127
+ original_index: doc.originalIndex,
128
+ };
129
+ });
130
+ } catch (error) {
131
+ logger.error(
132
+ `Reranking failed, falling back to original order (query_length: ${query.length}, candidates: ${processedResults.length}): ${error instanceof Error ? error.message : String(error)}`
133
+ );
134
+
135
+ // Fallback: use original order, truncate to requested count
136
+ finalResults = processedResults
137
+ .slice(0, resultCount)
138
+ .map((processed, index) => ({
139
+ id: processed.id,
140
+ url: processed.url,
141
+ title: processed.title,
142
+ content: processed.content,
143
+ chunk_index: processed.chunk_index,
144
+ total_chunks: processed.total_chunks,
145
+ mergedChunkIndices: processed.mergedChunkIndices,
146
+ original_index: index,
147
+ }));
148
+
149
+ logger.warn(
150
+ `Reranking failed, using original order with ${finalResults.length} results`
151
+ );
152
+ }
153
+
154
+ // Collect additional URLs
155
+ const additionalUrls = this.collectAdditionalUrls(
156
+ processedResults,
157
+ finalResults
158
+ );
159
+
160
+ return { results: finalResults, additionalUrls };
161
+ }
162
+
163
+ /**
164
+ * Retrieve semantic search candidates with error handling
165
+ */
166
+ private async getSemanticCandidates(
167
+ query: string,
168
+ resultCount: number
169
+ ): Promise<SearchResult[]> {
170
+ const startTime = Date.now();
171
+
172
+ try {
173
+ const queryEmbedding = await this.embedding.createEmbedding(query);
174
+ const results = await this.database.semanticSearch(queryEmbedding, {
175
+ resultCount,
176
+ });
177
+
178
+ logger.info(
179
+ `Semantic search completed (${((Date.now() - startTime) / 1000).toFixed(1)}s): ${results.length} results`
180
+ );
181
+
182
+ return results;
183
+ } catch (error) {
184
+ logger.error(
185
+ `Semantic search failed, falling back to keyword-only: ${error instanceof Error ? error.message : String(error)}`
186
+ );
187
+ return [];
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Retrieve keyword search candidates with error handling
193
+ */
194
+ private async getKeywordCandidates(
195
+ query: string,
196
+ resultCount: number
197
+ ): Promise<SearchResult[]> {
198
+ const startTime = Date.now();
199
+
200
+ try {
201
+ const results = await this.database.keywordSearch(query, {
202
+ resultCount,
203
+ });
204
+
205
+ logger.info(
206
+ `Keyword search completed (${((Date.now() - startTime) / 1000).toFixed(1)}s): ${results.length} results`
207
+ );
208
+
209
+ return results;
210
+ } catch (error) {
211
+ logger.error(
212
+ `Keyword search failed: ${error instanceof Error ? error.message : String(error)}`
213
+ );
214
+ return [];
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Merge and deduplicate candidates from semantic and keyword search
220
+ */
221
+ private mergeCandidates(
222
+ semanticResults: SearchResult[],
223
+ keywordResults: SearchResult[]
224
+ ): SearchResult[] {
225
+ const seen = new Set<string>();
226
+
227
+ // Prioritize semantic results, then add unique keyword results
228
+ return [
229
+ ...semanticResults.filter((result) => {
230
+ if (seen.has(result.id)) return false;
231
+ seen.add(result.id);
232
+ return true;
233
+ }),
234
+ ...keywordResults.filter((result) => {
235
+ if (seen.has(result.id)) return false;
236
+ seen.add(result.id);
237
+ return true;
238
+ }),
239
+ ];
240
+ }
241
+
242
+ /**
243
+ * Collect additional URLs from processed results
244
+ */
245
+ private collectAdditionalUrls(
246
+ processedResults: ProcessedResult[],
247
+ finalResults: RankedSearchResult[]
248
+ ): AdditionalUrl[] {
249
+ const finalUrls = new Set(finalResults.map((r) => r.url));
250
+
251
+ return processedResults
252
+ .filter((r) => !finalUrls.has(r.url))
253
+ .reduce((urls, r) => {
254
+ if (!urls.some((u) => u.url === r.url)) {
255
+ urls.push({
256
+ url: r.url,
257
+ title: r.title,
258
+ characterCount: r.contentLength,
259
+ });
260
+ }
261
+ return urls;
262
+ }, [] as AdditionalUrl[])
263
+ .slice(0, 10);
264
+ }
265
+
266
+ /**
267
+ * Process RAG candidates through title-based merging
268
+ */
269
+ private processResults(candidates: SearchResult[]): ProcessedResult[] {
270
+ // Step 1: Merge by title
271
+ return this.mergeByTitle(candidates);
272
+ }
273
+
274
+ private parseChunk(content: string, title: string | null): ParsedChunk {
275
+ // Since data migration is complete, content is now plain text
276
+ // and title comes from the dedicated title field
277
+ return {
278
+ title: title || "",
279
+ content: content,
280
+ };
281
+ }
282
+
283
+ private mergeByTitle(results: SearchResult[]): ProcessedResult[] {
284
+ const titleGroups = new Map<string, SearchResult[]>();
285
+
286
+ // Group by title
287
+ for (const result of results) {
288
+ const { title } = this.parseChunk(result.content, result.title);
289
+ const titleKey = title || "untitled";
290
+ if (!titleGroups.has(titleKey)) {
291
+ titleGroups.set(titleKey, []);
292
+ }
293
+ titleGroups.get(titleKey)!.push(result);
294
+ }
295
+
296
+ return Array.from(titleGroups.entries()).map(([title, group]) => {
297
+ const primary = group[0];
298
+
299
+ // Sort and merge chunks by original index to maintain proper content order
300
+ const chunkIndices = group
301
+ .map((r) => r.chunk_index)
302
+ .sort((a, b) => a - b);
303
+ const mergedContent = group
304
+ .sort((a, b) => a.chunk_index - b.chunk_index)
305
+ .map((r) => this.parseChunk(r.content, r.title).content)
306
+ .join("\n\n---\n\n");
307
+
308
+ // Detect complete document merging
309
+ const isCompleteDocument =
310
+ chunkIndices.length === primary.total_chunks &&
311
+ chunkIndices.every((idx, i) => idx === i);
312
+
313
+ // Determine final chunk representation
314
+ const [chunk_index, total_chunks] =
315
+ chunkIndices.length === 1
316
+ ? [chunkIndices[0], primary.total_chunks]
317
+ : isCompleteDocument
318
+ ? [0, 1]
319
+ : [Math.min(...chunkIndices), primary.total_chunks];
320
+
321
+ return {
322
+ id: primary.id,
323
+ url: primary.url,
324
+ title,
325
+ content: mergedContent,
326
+ mergedChunkIndices: chunkIndices.length > 1 ? chunkIndices : undefined,
327
+ contentLength: mergedContent.length,
328
+ chunk_index,
329
+ total_chunks,
330
+ };
331
+ });
332
+ }
333
+ }