@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.
- package/.github/workflows/release.yml +62 -0
- package/.releaserc.json +38 -0
- package/CHANGELOG.md +161 -0
- package/README.md +114 -0
- package/README.zh-CN.md +119 -0
- package/apple-rag-mcp_process.log +8 -0
- package/biome.json +59 -0
- package/dist/src/auth/auth-middleware.d.ts +26 -0
- package/dist/src/auth/auth-middleware.d.ts.map +1 -0
- package/dist/src/auth/auth-middleware.js +77 -0
- package/dist/src/auth/auth-middleware.js.map +1 -0
- package/dist/src/auth/token-validator.d.ts +22 -0
- package/dist/src/auth/token-validator.d.ts.map +1 -0
- package/dist/src/auth/token-validator.js +64 -0
- package/dist/src/auth/token-validator.js.map +1 -0
- package/dist/src/mcp/formatters/response-formatter.d.ts +26 -0
- package/dist/src/mcp/formatters/response-formatter.d.ts.map +1 -0
- package/dist/src/mcp/formatters/response-formatter.js +119 -0
- package/dist/src/mcp/formatters/response-formatter.js.map +1 -0
- package/dist/src/mcp/manifest.d.ts +48 -0
- package/dist/src/mcp/manifest.d.ts.map +1 -0
- package/dist/src/mcp/manifest.js +46 -0
- package/dist/src/mcp/manifest.js.map +1 -0
- package/dist/src/mcp/middleware/request-validator.d.ts +48 -0
- package/dist/src/mcp/middleware/request-validator.d.ts.map +1 -0
- package/dist/src/mcp/middleware/request-validator.js +102 -0
- package/dist/src/mcp/middleware/request-validator.js.map +1 -0
- package/dist/src/mcp/protocol-handler.d.ts +70 -0
- package/dist/src/mcp/protocol-handler.d.ts.map +1 -0
- package/dist/src/mcp/protocol-handler.js +285 -0
- package/dist/src/mcp/protocol-handler.js.map +1 -0
- package/dist/src/mcp/tools/fetch-tool.d.ts +18 -0
- package/dist/src/mcp/tools/fetch-tool.d.ts.map +1 -0
- package/dist/src/mcp/tools/fetch-tool.js +76 -0
- package/dist/src/mcp/tools/fetch-tool.js.map +1 -0
- package/dist/src/mcp/tools/search-tool.d.ts +20 -0
- package/dist/src/mcp/tools/search-tool.d.ts.map +1 -0
- package/dist/src/mcp/tools/search-tool.js +86 -0
- package/dist/src/mcp/tools/search-tool.js.map +1 -0
- package/dist/src/services/database.d.ts +37 -0
- package/dist/src/services/database.d.ts.map +1 -0
- package/dist/src/services/database.js +166 -0
- package/dist/src/services/database.js.map +1 -0
- package/dist/src/services/deepinfra-base.d.ts +22 -0
- package/dist/src/services/deepinfra-base.d.ts.map +1 -0
- package/dist/src/services/deepinfra-base.js +55 -0
- package/dist/src/services/deepinfra-base.js.map +1 -0
- package/dist/src/services/embedding.d.ts +44 -0
- package/dist/src/services/embedding.d.ts.map +1 -0
- package/dist/src/services/embedding.js +61 -0
- package/dist/src/services/embedding.js.map +1 -0
- package/dist/src/services/index.d.ts +10 -0
- package/dist/src/services/index.d.ts.map +1 -0
- package/dist/src/services/index.js +52 -0
- package/dist/src/services/index.js.map +1 -0
- package/dist/src/services/ip-authentication.d.ts +12 -0
- package/dist/src/services/ip-authentication.d.ts.map +1 -0
- package/dist/src/services/ip-authentication.js +39 -0
- package/dist/src/services/ip-authentication.js.map +1 -0
- package/dist/src/services/rag.d.ts +35 -0
- package/dist/src/services/rag.d.ts.map +1 -0
- package/dist/src/services/rag.js +106 -0
- package/dist/src/services/rag.js.map +1 -0
- package/dist/src/services/rate-limit.d.ts +27 -0
- package/dist/src/services/rate-limit.d.ts.map +1 -0
- package/dist/src/services/rate-limit.js +91 -0
- package/dist/src/services/rate-limit.js.map +1 -0
- package/dist/src/services/reranker.d.ts +40 -0
- package/dist/src/services/reranker.d.ts.map +1 -0
- package/dist/src/services/reranker.js +97 -0
- package/dist/src/services/reranker.js.map +1 -0
- package/dist/src/services/search-engine.d.ts +89 -0
- package/dist/src/services/search-engine.d.ts.map +1 -0
- package/dist/src/services/search-engine.js +225 -0
- package/dist/src/services/search-engine.js.map +1 -0
- package/dist/src/services/tool-call-logger.d.ts +36 -0
- package/dist/src/services/tool-call-logger.d.ts.map +1 -0
- package/dist/src/services/tool-call-logger.js +34 -0
- package/dist/src/services/tool-call-logger.js.map +1 -0
- package/dist/src/types/env.d.ts +18 -0
- package/dist/src/types/env.d.ts.map +1 -0
- package/dist/src/types/env.js +2 -0
- package/dist/src/types/env.js.map +1 -0
- package/dist/src/types/index.d.ts +145 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +6 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/utils/d1-utils.d.ts +6 -0
- package/dist/src/utils/d1-utils.d.ts.map +1 -0
- package/dist/src/utils/d1-utils.js +29 -0
- package/dist/src/utils/d1-utils.js.map +1 -0
- package/dist/src/utils/logger.d.ts +11 -0
- package/dist/src/utils/logger.d.ts.map +1 -0
- package/dist/src/utils/logger.js +26 -0
- package/dist/src/utils/logger.js.map +1 -0
- package/dist/src/utils/query-cleaner.d.ts +20 -0
- package/dist/src/utils/query-cleaner.d.ts.map +1 -0
- package/dist/src/utils/query-cleaner.js +117 -0
- package/dist/src/utils/query-cleaner.js.map +1 -0
- package/dist/src/utils/request-info.d.ts +18 -0
- package/dist/src/utils/request-info.d.ts.map +1 -0
- package/dist/src/utils/request-info.js +32 -0
- package/dist/src/utils/request-info.js.map +1 -0
- package/dist/src/utils/telegram-notifier.d.ts +4 -0
- package/dist/src/utils/telegram-notifier.d.ts.map +1 -0
- package/dist/src/utils/telegram-notifier.js +33 -0
- package/dist/src/utils/telegram-notifier.js.map +1 -0
- package/dist/src/utils/url-processor.d.ts +15 -0
- package/dist/src/utils/url-processor.d.ts.map +1 -0
- package/dist/src/utils/url-processor.js +54 -0
- package/dist/src/utils/url-processor.js.map +1 -0
- package/dist/src/worker.d.ts +15 -0
- package/dist/src/worker.d.ts.map +1 -0
- package/dist/src/worker.js +136 -0
- package/dist/src/worker.js.map +1 -0
- package/migrations/schema.sql +155 -0
- package/package.json +49 -0
- package/scripts/semantic-release-server-json.js +34 -0
- package/server.json +25 -0
- package/src/auth/auth-middleware.ts +104 -0
- package/src/auth/token-validator.ts +96 -0
- package/src/mcp/formatters/response-formatter.ts +157 -0
- package/src/mcp/manifest.ts +48 -0
- package/src/mcp/middleware/request-validator.ts +135 -0
- package/src/mcp/protocol-handler.ts +412 -0
- package/src/mcp/tools/fetch-tool.ts +146 -0
- package/src/mcp/tools/search-tool.ts +165 -0
- package/src/services/database.ts +202 -0
- package/src/services/deepinfra-base.ts +81 -0
- package/src/services/embedding.ts +96 -0
- package/src/services/index.ts +59 -0
- package/src/services/ip-authentication.ts +62 -0
- package/src/services/rag.ts +158 -0
- package/src/services/rate-limit.ts +141 -0
- package/src/services/reranker.ts +171 -0
- package/src/services/search-engine.ts +333 -0
- package/src/services/tool-call-logger.ts +98 -0
- package/src/types/env.ts +22 -0
- package/src/types/index.ts +189 -0
- package/src/utils/d1-utils.ts +45 -0
- package/src/utils/logger.ts +33 -0
- package/src/utils/query-cleaner.ts +151 -0
- package/src/utils/request-info.ts +47 -0
- package/src/utils/telegram-notifier.ts +47 -0
- package/src/utils/url-processor.ts +65 -0
- package/src/worker.ts +176 -0
- package/tsconfig.json +32 -0
- 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
|
+
}
|