webcrawl 1.2.3 → 1.2.5
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/cli.js +3 -2
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +151 -0
- package/dist/core/engine/crawler.js +885 -0
- package/dist/core/engine/fetcher.d.ts +66 -0
- package/dist/core/engine/fetcher.js +185 -0
- package/dist/core/engine/index.d.ts +4 -0
- package/dist/core/engine/index.js +4 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/safe-url.d.ts +23 -0
- package/dist/core/engine/safe-url.js +92 -0
- package/dist/core/engine/stealth.d.ts +79 -0
- package/dist/core/engine/stealth.js +155 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +8 -0
- package/dist/core/extract/index.js +8 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +49 -0
- package/dist/core/interact/driver.js +264 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +42 -0
- package/dist/core/interact/vm.js +296 -0
- package/dist/core/local-engine.d.ts +35 -0
- package/dist/core/local-engine.js +367 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +642 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +182 -0
- package/dist/core/search/catalog.d.ts +174 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +704 -0
- package/dist/core/search/providers.d.ts +62 -0
- package/dist/core/search/providers.js +1175 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- package/package.json +82 -4
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./lexical.js";
|
|
3
|
+
export * from "./decision.js";
|
|
4
|
+
import { runDecisionModelReranker } from "./decision.js";
|
|
5
|
+
import { computeFreshnessAndAuthorityScore, computeGeoAffinityScore, computePassageMaxSimAndHighlights, computeProximityAndExactScore, computeTokenSimilarity, extractMarkdownHeadings, extractPublisherDomainKey, fuseWithWeightedRrf, tokenizeAndHash, } from "./lexical.js";
|
|
6
|
+
export async function rankDocuments(query, rawCandidates, options = {}) {
|
|
7
|
+
const startTotal = performance.now();
|
|
8
|
+
const topK = Math.min(Math.max(options.topK ?? 10, 1), 50);
|
|
9
|
+
const rrfK = options.rrfK ?? 60;
|
|
10
|
+
const useDecisionModel = options.useDecisionModel !== false;
|
|
11
|
+
const decisionModel = options.decisionModel ?? "auto";
|
|
12
|
+
const decisionTimeoutMs = options.decisionTimeoutMs ?? 1200;
|
|
13
|
+
const decisionTopN = Math.min(options.decisionTopN ?? 10, 16);
|
|
14
|
+
const maxHighlights = options.maxHighlights ?? 2;
|
|
15
|
+
if (rawCandidates.length === 0) {
|
|
16
|
+
return {
|
|
17
|
+
results: [],
|
|
18
|
+
rankingMetrics: {
|
|
19
|
+
totalCandidates: 0,
|
|
20
|
+
uniqueCandidates: 0,
|
|
21
|
+
lexicalLatencyMs: 0,
|
|
22
|
+
decisionLatencyMs: 0,
|
|
23
|
+
totalRankingLatencyMs: 0,
|
|
24
|
+
decisionModelUsed: null,
|
|
25
|
+
enginesContributing: [],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Stage 1 & 2: Canonical deduplication + Weighted RRF
|
|
30
|
+
const { merged, enginesContributing } = fuseWithWeightedRrf(rawCandidates, rrfK);
|
|
31
|
+
const N = merged.length;
|
|
32
|
+
// Tokenize query once
|
|
33
|
+
const rawQueryLower = query.trim().toLowerCase();
|
|
34
|
+
const queryTok = tokenizeAndHash(query, true);
|
|
35
|
+
// Pre-tokenize all document fields for Fielded BM25F+
|
|
36
|
+
const FIELD_WEIGHTS = {
|
|
37
|
+
title: 3.5,
|
|
38
|
+
headings: 2.2,
|
|
39
|
+
url: 2.0,
|
|
40
|
+
description: 1.5,
|
|
41
|
+
body: 1.0,
|
|
42
|
+
};
|
|
43
|
+
const docFields = merged.map((item) => {
|
|
44
|
+
const titleTok = tokenizeAndHash(item.doc.title || "");
|
|
45
|
+
const headingsTok = tokenizeAndHash(extractMarkdownHeadings(item.doc.markdown));
|
|
46
|
+
const urlTok = tokenizeAndHash(item.canonicalUrl.replace(/https?:\/\//, " "));
|
|
47
|
+
const descTok = tokenizeAndHash(item.doc.description || "");
|
|
48
|
+
const bodyTok = tokenizeAndHash((item.doc.markdown || "").slice(0, 12000));
|
|
49
|
+
const weightedLen = titleTok.hashes.length * FIELD_WEIGHTS.title +
|
|
50
|
+
headingsTok.hashes.length * FIELD_WEIGHTS.headings +
|
|
51
|
+
urlTok.hashes.length * FIELD_WEIGHTS.url +
|
|
52
|
+
descTok.hashes.length * FIELD_WEIGHTS.description +
|
|
53
|
+
bodyTok.hashes.length * FIELD_WEIGHTS.body;
|
|
54
|
+
return {
|
|
55
|
+
titleTok,
|
|
56
|
+
headingsTok,
|
|
57
|
+
urlTok,
|
|
58
|
+
descTok,
|
|
59
|
+
bodyTok,
|
|
60
|
+
weightedLen: Math.max(1, weightedLen),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
const avgWeightedLen = docFields.reduce((acc, f) => acc + f.weightedLen, 0) / Math.max(1, N);
|
|
64
|
+
// Compute Document Frequency (DF) per query term across the candidate pool
|
|
65
|
+
const dfMap = new Map();
|
|
66
|
+
for (let qi = 0; qi < queryTok.hashes.length; qi++) {
|
|
67
|
+
const qHash = queryTok.hashes[qi];
|
|
68
|
+
let df = 0;
|
|
69
|
+
for (let di = 0; di < N; di++) {
|
|
70
|
+
const f = docFields[di];
|
|
71
|
+
if (f.titleTok.freqMap.has(qHash) ||
|
|
72
|
+
f.headingsTok.freqMap.has(qHash) ||
|
|
73
|
+
f.urlTok.freqMap.has(qHash) ||
|
|
74
|
+
f.descTok.freqMap.has(qHash) ||
|
|
75
|
+
f.bodyTok.freqMap.has(qHash)) {
|
|
76
|
+
df++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
dfMap.set(qHash, df);
|
|
80
|
+
}
|
|
81
|
+
// Stage 3: Fielded BM25F+ (`k1 = 1.5, b = 0.75, delta = 1.0`)
|
|
82
|
+
const k1 = 1.5;
|
|
83
|
+
const b = 0.75;
|
|
84
|
+
const delta = 1.0;
|
|
85
|
+
const bm25RawScores = new Float32Array(N);
|
|
86
|
+
let maxBm25 = 0;
|
|
87
|
+
let maxRrf = 0;
|
|
88
|
+
for (let di = 0; di < N; di++) {
|
|
89
|
+
const f = docFields[di];
|
|
90
|
+
const normFactor = 1 - b + b * (f.weightedLen / Math.max(1, avgWeightedLen));
|
|
91
|
+
let docBm25 = 0;
|
|
92
|
+
for (let qi = 0; qi < queryTok.hashes.length; qi++) {
|
|
93
|
+
const qHash = queryTok.hashes[qi];
|
|
94
|
+
const pseudoTf = ((f.titleTok.freqMap.get(qHash) ?? 0) * FIELD_WEIGHTS.title +
|
|
95
|
+
(f.headingsTok.freqMap.get(qHash) ?? 0) * FIELD_WEIGHTS.headings +
|
|
96
|
+
(f.urlTok.freqMap.get(qHash) ?? 0) * FIELD_WEIGHTS.url +
|
|
97
|
+
(f.descTok.freqMap.get(qHash) ?? 0) * FIELD_WEIGHTS.description +
|
|
98
|
+
(f.bodyTok.freqMap.get(qHash) ?? 0) * FIELD_WEIGHTS.body) /
|
|
99
|
+
normFactor;
|
|
100
|
+
if (pseudoTf <= 0) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const df = dfMap.get(qHash) ?? 0;
|
|
104
|
+
// Robertson-Sparck Jones IDF with smoothing
|
|
105
|
+
const idf = Math.log(1 + (N - df + 0.5) / (df + 0.5) + 0.5);
|
|
106
|
+
const tfComponent = ((k1 + 1) * pseudoTf) / (k1 + pseudoTf) + delta;
|
|
107
|
+
docBm25 += idf * tfComponent;
|
|
108
|
+
}
|
|
109
|
+
bm25RawScores[di] = docBm25;
|
|
110
|
+
if (docBm25 > maxBm25) {
|
|
111
|
+
maxBm25 = docBm25;
|
|
112
|
+
}
|
|
113
|
+
if (merged[di].rrfRaw > maxRrf) {
|
|
114
|
+
maxRrf = merged[di].rrfRaw;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Stage 4: ColBERT Subword MaxSim + Proximity + Freshness/Authority
|
|
118
|
+
const stage4Candidates = merged.map((entry, di) => {
|
|
119
|
+
const rrfScore = maxRrf > 0 ? entry.rrfRaw / maxRrf : 0.5;
|
|
120
|
+
const bm25fScore = maxBm25 > 0 ? bm25RawScores[di] / maxBm25 : 0;
|
|
121
|
+
const { maxSimScore: passageMaxSimScore, highlights } = computePassageMaxSimAndHighlights(queryTok, rawQueryLower, entry.doc, maxHighlights);
|
|
122
|
+
const proximityScore = computeProximityAndExactScore(queryTok, rawQueryLower, docFields[di].titleTok, docFields[di].descTok, (entry.doc.title || "").toLowerCase(), (entry.doc.description || "").toLowerCase());
|
|
123
|
+
const freshnessAuthorityScore = computeFreshnessAndAuthorityScore(entry.doc, entry.canonicalUrl);
|
|
124
|
+
const hasUserLocation = Boolean(options.userLocation?.country ||
|
|
125
|
+
options.userLocation?.city ||
|
|
126
|
+
options.userLocation?.region);
|
|
127
|
+
const geoAffinityScore = computeGeoAffinityScore(entry.doc, entry.canonicalUrl, options.userLocation, options.filterContext);
|
|
128
|
+
// Strip trailing " - Publisher" or " | Publisher" before computing title dedup signature
|
|
129
|
+
const cleanHeadline = (entry.doc.title || "")
|
|
130
|
+
.replace(/\s+[-|–\u2014]\s+[^-|–\u2014]{2,50}$/, "")
|
|
131
|
+
.trim();
|
|
132
|
+
const cleanHeadlineTok = tokenizeAndHash(cleanHeadline);
|
|
133
|
+
const queryHashSet = new Set(queryTok.hashes);
|
|
134
|
+
const nonQueryTitleHashes = new Set(Array.from(cleanHeadlineTok.hashes).filter((h) => !queryHashSet.has(h)));
|
|
135
|
+
// Dynamic filter penalties for agent-supplied `mustIncludeTerms` and `excludeTerms`
|
|
136
|
+
let filterMultiplier = 1.0;
|
|
137
|
+
const combinedLower = `${entry.doc.title || ""} ${entry.doc.description || ""} ${entry.canonicalUrl}`.toLowerCase();
|
|
138
|
+
if (options.filterContext?.mustIncludeTerms?.length) {
|
|
139
|
+
for (const reqTerm of options.filterContext.mustIncludeTerms) {
|
|
140
|
+
const cleanTerm = reqTerm.trim().toLowerCase();
|
|
141
|
+
if (cleanTerm && !combinedLower.includes(cleanTerm)) {
|
|
142
|
+
filterMultiplier *= 0.55;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (options.filterContext?.excludeTerms?.length) {
|
|
147
|
+
for (const exTerm of options.filterContext.excludeTerms) {
|
|
148
|
+
const cleanTerm = exTerm.trim().toLowerCase();
|
|
149
|
+
if (cleanTerm && combinedLower.includes(cleanTerm)) {
|
|
150
|
+
filterMultiplier *= 0.25;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Calibrated Stage 1-4 Lexical + Structural + Geo Hybrid Score in [0, 1]
|
|
155
|
+
const baseLexical = rrfScore * 0.22 +
|
|
156
|
+
bm25fScore * 0.3 +
|
|
157
|
+
passageMaxSimScore * 0.24 +
|
|
158
|
+
proximityScore * 0.14 +
|
|
159
|
+
freshnessAuthorityScore * 0.1;
|
|
160
|
+
const geoWeight = hasUserLocation
|
|
161
|
+
? options.filterContext?.preferLocalDomains
|
|
162
|
+
? 0.22
|
|
163
|
+
: 0.14
|
|
164
|
+
: options.filterContext?.subcategory ||
|
|
165
|
+
options.filterContext?.boostTerms?.length
|
|
166
|
+
? 0.1
|
|
167
|
+
: 0;
|
|
168
|
+
const lexicalHybridScore = (baseLexical * (1 - geoWeight) + geoAffinityScore * geoWeight) *
|
|
169
|
+
filterMultiplier;
|
|
170
|
+
return {
|
|
171
|
+
entry,
|
|
172
|
+
highlights,
|
|
173
|
+
titleHashSet: new Set(cleanHeadlineTok.hashes),
|
|
174
|
+
nonQueryTitleHashes,
|
|
175
|
+
signatureHashSet: new Set([
|
|
176
|
+
...cleanHeadlineTok.hashes,
|
|
177
|
+
...docFields[di].descTok.hashes.slice(0, 24),
|
|
178
|
+
]),
|
|
179
|
+
publisherKey: extractPublisherDomainKey(entry.canonicalUrl, entry.doc.title || "", entry.doc.description || ""),
|
|
180
|
+
breakdown: {
|
|
181
|
+
rrfScore: Number(rrfScore.toFixed(4)),
|
|
182
|
+
bm25fScore: Number(bm25fScore.toFixed(4)),
|
|
183
|
+
passageMaxSimScore: Number(passageMaxSimScore.toFixed(4)),
|
|
184
|
+
proximityScore: Number(proximityScore.toFixed(4)),
|
|
185
|
+
freshnessAuthorityScore: Number(freshnessAuthorityScore.toFixed(4)),
|
|
186
|
+
...(hasUserLocation
|
|
187
|
+
? { geoAffinityScore: Number(geoAffinityScore.toFixed(4)) }
|
|
188
|
+
: {}),
|
|
189
|
+
lexicalHybridScore: Number(lexicalHybridScore.toFixed(4)),
|
|
190
|
+
multiEngineConsensusCount: entry.sources.length,
|
|
191
|
+
},
|
|
192
|
+
finalScore: lexicalHybridScore,
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
// Sort by Stage 1-4 score, then apply a light domain diversity pre-pass before Stage 5
|
|
196
|
+
// so our Decision Model top-N budget isn't wasted scoring 3 pages from the same domain.
|
|
197
|
+
stage4Candidates.sort((lhs, rhs) => rhs.finalScore - lhs.finalScore);
|
|
198
|
+
const preDomainCounts = new Map();
|
|
199
|
+
for (const cand of stage4Candidates) {
|
|
200
|
+
const prevCount = preDomainCounts.get(cand.publisherKey) ?? 0;
|
|
201
|
+
if (prevCount > 0) {
|
|
202
|
+
cand.finalScore *= Math.pow(0.84, prevCount);
|
|
203
|
+
}
|
|
204
|
+
preDomainCounts.set(cand.publisherKey, prevCount + 1);
|
|
205
|
+
}
|
|
206
|
+
stage4Candidates.sort((lhs, rhs) => rhs.finalScore - lhs.finalScore);
|
|
207
|
+
const lexicalLatencyMs = Number((performance.now() - startTotal).toFixed(2));
|
|
208
|
+
// Stage 5: Vercel AI Gateway Decision Model Reranking (`baseten/mammoth-1` / `convaiinnovations/laya`)
|
|
209
|
+
let decisionLatencyMs = 0;
|
|
210
|
+
let decisionModelUsed = null;
|
|
211
|
+
if (useDecisionModel &&
|
|
212
|
+
decisionModel !== "none" &&
|
|
213
|
+
stage4Candidates.length > 0) {
|
|
214
|
+
const decisionStart = performance.now();
|
|
215
|
+
const topSlice = stage4Candidates.slice(0, decisionTopN);
|
|
216
|
+
const decisionOut = await runDecisionModelReranker(query, topSlice.map((c) => ({
|
|
217
|
+
title: c.entry.doc.title || "",
|
|
218
|
+
url: c.entry.canonicalUrl,
|
|
219
|
+
description: c.entry.doc.description || "",
|
|
220
|
+
highlights: c.highlights,
|
|
221
|
+
})), decisionModel, decisionTimeoutMs, options.userLocation, options.filterContext);
|
|
222
|
+
decisionLatencyMs = Number((performance.now() - decisionStart).toFixed(2));
|
|
223
|
+
if (decisionOut && decisionOut.scores.size > 0) {
|
|
224
|
+
decisionModelUsed = decisionOut.modelUsed;
|
|
225
|
+
for (let i = 0; i < topSlice.length; i++) {
|
|
226
|
+
const dScore = decisionOut.scores.get(i);
|
|
227
|
+
if (dScore) {
|
|
228
|
+
topSlice[i].breakdown.decisionScore = dScore.normalizedScore;
|
|
229
|
+
topSlice[i].breakdown.decisionRawScore = dScore.rawScore;
|
|
230
|
+
topSlice[i].breakdown.decisionConfidence = dScore.confidence;
|
|
231
|
+
topSlice[i].breakdown.decisionModelUsed =
|
|
232
|
+
decisionModelUsed || undefined;
|
|
233
|
+
// Blend 52% Stage 1-4 IR hybrid + 48% Stage 5 Neural Decision Model probability score
|
|
234
|
+
topSlice[i].finalScore =
|
|
235
|
+
topSlice[i].breakdown.lexicalHybridScore * 0.52 +
|
|
236
|
+
dScore.normalizedScore * 0.48;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
stage4Candidates.sort((lhs, rhs) => rhs.finalScore - lhs.finalScore);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Stage 6: Maximal Marginal Relevance (MMR) Near-Duplicate Suppression & optional Per-Domain Cap.
|
|
243
|
+
// Distinct URLs from the same provider are distinct results (e.g. several listings on one
|
|
244
|
+
// portal, several articles from one publisher), so there is no hard cap unless the caller
|
|
245
|
+
// asks for one. A soft MMR decay still spreads providers when scores are close.
|
|
246
|
+
const maxPerDomain = options.maxPerDomain ?? Number.POSITIVE_INFINITY;
|
|
247
|
+
const mmrLambda = options.mmrLambda ?? 0.82;
|
|
248
|
+
const selected = [];
|
|
249
|
+
const deferredOverflow = [];
|
|
250
|
+
const nearDuplicates = [];
|
|
251
|
+
const domainCounts = new Map();
|
|
252
|
+
const remaining = [...stage4Candidates];
|
|
253
|
+
while (selected.length < topK && remaining.length > 0) {
|
|
254
|
+
let bestIdx = -1;
|
|
255
|
+
let bestMmrScore = -Infinity;
|
|
256
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
257
|
+
const cand = remaining[i];
|
|
258
|
+
// Check near-duplicate title or title+snippet similarity against already-selected results
|
|
259
|
+
let maxSim = 0;
|
|
260
|
+
let isHardNearDuplicate = false;
|
|
261
|
+
for (const sel of selected) {
|
|
262
|
+
const titleSim = computeTokenSimilarity(cand.titleHashSet, sel.titleHashSet);
|
|
263
|
+
const sigSim = computeTokenSimilarity(cand.signatureHashSet, sel.signatureHashSet);
|
|
264
|
+
const combinedSim = Math.max(titleSim * 0.95, sigSim);
|
|
265
|
+
if (combinedSim > maxSim) {
|
|
266
|
+
maxSim = combinedSim;
|
|
267
|
+
}
|
|
268
|
+
// Suppress syndicated copies (same story on a different publisher) with >= 0.72
|
|
269
|
+
// title/snippet overlap. Same publisher + different URL is a distinct page
|
|
270
|
+
// (e.g. two listings with templated titles) and is kept.
|
|
271
|
+
if (cand.publisherKey !== sel.publisherKey &&
|
|
272
|
+
(titleSim >= 0.75 || sigSim >= 0.72)) {
|
|
273
|
+
isHardNearDuplicate = true;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (isHardNearDuplicate) {
|
|
278
|
+
// Defer (don't drop): backfilled with a penalty only if the pool runs short.
|
|
279
|
+
nearDuplicates.push(cand);
|
|
280
|
+
remaining.splice(i, 1);
|
|
281
|
+
i--;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const currentDomainCount = domainCounts.get(cand.publisherKey) ?? 0;
|
|
285
|
+
if (currentDomainCount >= maxPerDomain) {
|
|
286
|
+
deferredOverflow.push(cand);
|
|
287
|
+
remaining.splice(i, 1);
|
|
288
|
+
i--;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const domainDecay = Math.pow(0.8, currentDomainCount);
|
|
292
|
+
const mmrScore = mmrLambda * (cand.finalScore * domainDecay) - (1 - mmrLambda) * maxSim;
|
|
293
|
+
if (mmrScore > bestMmrScore) {
|
|
294
|
+
bestMmrScore = mmrScore;
|
|
295
|
+
bestIdx = i;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (bestIdx === -1) {
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
const chosen = remaining.splice(bestIdx, 1)[0];
|
|
302
|
+
const prevDomainCount = domainCounts.get(chosen.publisherKey) ?? 0;
|
|
303
|
+
if (prevDomainCount > 0) {
|
|
304
|
+
chosen.finalScore = Number((chosen.finalScore * Math.pow(0.85, prevDomainCount)).toFixed(4));
|
|
305
|
+
}
|
|
306
|
+
domainCounts.set(chosen.publisherKey, prevDomainCount + 1);
|
|
307
|
+
selected.push(chosen);
|
|
308
|
+
}
|
|
309
|
+
// If strict domain cap left fewer than topK results and we have overflow candidates, backfill
|
|
310
|
+
for (const overflow of deferredOverflow) {
|
|
311
|
+
if (selected.length >= topK) {
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
const prevDomainCount = domainCounts.get(overflow.publisherKey) ?? 1;
|
|
315
|
+
overflow.finalScore = Number((overflow.finalScore * Math.pow(0.82, prevDomainCount)).toFixed(4));
|
|
316
|
+
domainCounts.set(overflow.publisherKey, prevDomainCount + 1);
|
|
317
|
+
selected.push(overflow);
|
|
318
|
+
}
|
|
319
|
+
for (const dup of nearDuplicates) {
|
|
320
|
+
if (selected.length >= topK) {
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
dup.finalScore = Number((dup.finalScore * 0.6).toFixed(4));
|
|
324
|
+
selected.push(dup);
|
|
325
|
+
}
|
|
326
|
+
selected.sort((lhs, rhs) => rhs.finalScore - lhs.finalScore);
|
|
327
|
+
const results = selected
|
|
328
|
+
.slice(0, topK)
|
|
329
|
+
.map((item, idx) => ({
|
|
330
|
+
title: item.entry.doc.title,
|
|
331
|
+
url: item.entry.doc.url,
|
|
332
|
+
canonicalUrl: item.entry.canonicalUrl,
|
|
333
|
+
description: item.entry.doc.description,
|
|
334
|
+
...(item.entry.doc.markdown !== undefined
|
|
335
|
+
? { markdown: item.entry.doc.markdown }
|
|
336
|
+
: {}),
|
|
337
|
+
...(item.entry.doc.links !== undefined
|
|
338
|
+
? { links: item.entry.doc.links }
|
|
339
|
+
: {}),
|
|
340
|
+
...(item.entry.doc.publishedDate !== undefined
|
|
341
|
+
? { publishedDate: item.entry.doc.publishedDate }
|
|
342
|
+
: {}),
|
|
343
|
+
score: Number(item.finalScore.toFixed(4)),
|
|
344
|
+
rank: idx + 1,
|
|
345
|
+
sources: item.entry.sources,
|
|
346
|
+
highlights: item.highlights,
|
|
347
|
+
scoreBreakdown: item.breakdown,
|
|
348
|
+
}));
|
|
349
|
+
return {
|
|
350
|
+
results,
|
|
351
|
+
rankingMetrics: {
|
|
352
|
+
totalCandidates: rawCandidates.length,
|
|
353
|
+
uniqueCandidates: N,
|
|
354
|
+
lexicalLatencyMs,
|
|
355
|
+
decisionLatencyMs,
|
|
356
|
+
totalRankingLatencyMs: Number((performance.now() - startTotal).toFixed(2)),
|
|
357
|
+
decisionModelUsed,
|
|
358
|
+
enginesContributing,
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { RawCandidateDocument, SearchEngineSource, SearchFilterContext, UserLocationContext } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Canonicalize a URL for cross-engine deduplication and RRF fusion.
|
|
4
|
+
*/
|
|
5
|
+
export declare function canonicalizeUrl(rawUrl: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Fast 32-bit FNV-1a integer hash for zero-allocation token/trigram comparison.
|
|
8
|
+
*/
|
|
9
|
+
export declare function fnv1a32(str: string): number;
|
|
10
|
+
/**
|
|
11
|
+
* Ultra-fast Porter-style suffix stripper for English IR normalization.
|
|
12
|
+
*/
|
|
13
|
+
export declare function stemToken(word: string): string;
|
|
14
|
+
export interface TokenizedText {
|
|
15
|
+
tokens: string[];
|
|
16
|
+
hashes: Uint32Array;
|
|
17
|
+
freqMap: Map<number, number>;
|
|
18
|
+
trigramSet: Set<number>;
|
|
19
|
+
}
|
|
20
|
+
export declare function tokenizeAndHash(text: string, includeTrigrams?: boolean): TokenizedText;
|
|
21
|
+
/**
|
|
22
|
+
* Stage 2: Weighted Reciprocal Rank Fusion (W-RRF) across heterogeneous engines.
|
|
23
|
+
* Formula: RRF(d) = sum_{e in Engines} w_e / (k + rank_e(d))
|
|
24
|
+
*/
|
|
25
|
+
export declare function fuseWithWeightedRrf(rawCandidates: RawCandidateDocument[], rrfK?: number): {
|
|
26
|
+
merged: Array<{
|
|
27
|
+
doc: RawCandidateDocument;
|
|
28
|
+
canonicalUrl: string;
|
|
29
|
+
sources: SearchEngineSource[];
|
|
30
|
+
engineRanks: Partial<Record<SearchEngineSource, number>>;
|
|
31
|
+
rrfRaw: number;
|
|
32
|
+
}>;
|
|
33
|
+
enginesContributing: SearchEngineSource[];
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Extract headings (H1-H3) from Markdown text for fielded BM25F scoring.
|
|
37
|
+
*/
|
|
38
|
+
export declare function extractMarkdownHeadings(markdown?: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Stage 4a: ColBERT-inspired Subword + Token MaxSim Passage Scorer & Highlight Extractor.
|
|
41
|
+
* Splits document into semantic blocks and computes maximum late-interaction similarity
|
|
42
|
+
* per query token + minimum window proximity.
|
|
43
|
+
*/
|
|
44
|
+
export declare function computePassageMaxSimAndHighlights(queryTok: TokenizedText, rawQueryLower: string, doc: RawCandidateDocument, maxHighlights?: number): {
|
|
45
|
+
maxSimScore: number;
|
|
46
|
+
highlights: string[];
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Stage 4b: Exact phrase, ordered bigram, and minimum window proximity score.
|
|
50
|
+
*/
|
|
51
|
+
export declare function computeProximityAndExactScore(queryTok: TokenizedText, rawQueryLower: string, titleTok: TokenizedText, descTok: TokenizedText, rawTitleLower: string, rawDescLower: string): number;
|
|
52
|
+
/**
|
|
53
|
+
* Extract effective publisher/domain key for domain diversity.
|
|
54
|
+
* For Google News RSS redirect URLs (`news.google.com/rss/articles/...`), extracts the
|
|
55
|
+
* actual publisher (`[Publisher]` or `- Publisher` suffix) so distinct news outlets
|
|
56
|
+
* aren't collapsed under `news.google.com`, while true same-publisher pages share a key.
|
|
57
|
+
*/
|
|
58
|
+
export declare function extractPublisherDomainKey(canonicalUrl: string, title: string, description: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Compute token-set Jaccard + containment similarity in [0, 1] between two candidates
|
|
61
|
+
* to detect near-duplicate headlines or syndicated wire stories.
|
|
62
|
+
*/
|
|
63
|
+
export declare function computeTokenSimilarity(setA: Set<number>, setB: Set<number>): number;
|
|
64
|
+
/**
|
|
65
|
+
* Dynamically resolve an ISO 3166-1 alpha-2 country code (e.g. "AU", "GB", "JP", "BR")
|
|
66
|
+
* into its full English name ("Australia", "United Kingdom", "Japan", "Brazil") using
|
|
67
|
+
* standard runtime Intl.DisplayNames: zero hardcoded country tables.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveCountryDisplayName(countryCode?: string): string | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Stage 4d: Dynamic Geolocation & Filter Affinity score in [0, 1].
|
|
72
|
+
* Zero hardcoded retailer/city/country lists:
|
|
73
|
+
* - Resolves country display name dynamically via `Intl.DisplayNames`
|
|
74
|
+
* - Matches any 2-letter ccTLD algorithmically (`.${cc}`, `.com.${cc}`, `.co.${cc}`, `.org.${cc}`, `.net.${cc}`, `.gov.${cc}`)
|
|
75
|
+
* - Penalizes foreign 2-letter ccTLDs when `preferLocalDomains` or user country is set
|
|
76
|
+
* - Evaluates dynamic `userLocation` (`city`, `region`, `countryName`) and agent `filterContext` (`boostTerms`, `mustIncludeTerms`, `excludeTerms`)
|
|
77
|
+
*/
|
|
78
|
+
export declare function computeGeoAffinityScore(doc: RawCandidateDocument, canonicalUrl: string, userLocation?: UserLocationContext, filterContext?: SearchFilterContext): number;
|
|
79
|
+
/**
|
|
80
|
+
* Stage 4c: Freshness & Domain Authority score.
|
|
81
|
+
*/
|
|
82
|
+
export declare function computeFreshnessAndAuthorityScore(doc: RawCandidateDocument, canonicalUrl: string): number;
|