@aiwayds/dsh-topics-memory 0.21.0 → 0.23.0
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/README.md +5 -0
- package/README.zh.md +5 -0
- package/lib/commands.js +27 -11
- package/lib/config.d.ts +54 -1
- package/lib/config.js +38 -1
- package/lib/digest.d.ts +73 -0
- package/lib/digest.js +384 -0
- package/lib/distill.d.ts +39 -0
- package/lib/distill.js +250 -15
- package/lib/embedding.d.ts +113 -0
- package/lib/embedding.js +208 -0
- package/lib/ilog.d.ts +145 -0
- package/lib/ilog.js +236 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +23 -6
- package/lib/pipeline-events.d.ts +36 -0
- package/lib/pipeline-events.js +121 -0
- package/lib/quality.d.ts +47 -7
- package/lib/quality.js +124 -20
- package/lib/retrieval.d.ts +119 -0
- package/lib/retrieval.js +290 -0
- package/lib/service.d.ts +60 -3
- package/lib/service.js +320 -44
- package/lib/store.d.ts +15 -12
- package/lib/store.js +43 -3
- package/lib/tools.js +6 -2
- package/package.json +2 -2
- package/src/commands.ts +35 -16
- package/src/config.ts +47 -1
- package/src/digest.ts +416 -0
- package/src/distill.ts +275 -14
- package/src/embedding.ts +294 -0
- package/src/ilog.ts +306 -0
- package/src/index.ts +23 -5
- package/src/pipeline-events.ts +132 -0
- package/src/quality.ts +145 -21
- package/src/retrieval.ts +357 -0
- package/src/service.ts +326 -52
- package/src/store.ts +46 -10
- package/src/tools.ts +6 -2
package/src/retrieval.ts
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
* @module retrieval
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { join } from 'node:path'
|
|
15
17
|
import type { TopicMeta } from './store.ts'
|
|
16
18
|
import { dependsSlugs } from './okf.ts'
|
|
19
|
+
import type { EmbedResult } from './embedding.ts'
|
|
17
20
|
|
|
18
21
|
export interface RetrievableTopic extends TopicMeta {
|
|
19
22
|
/** `# Conclusion` section text (may be empty). */
|
|
@@ -49,12 +52,26 @@ export interface RetrievalConfig {
|
|
|
49
52
|
usageBoost: number
|
|
50
53
|
/** Usage signals per slug (hits/opens in the window); absent map = no boost. */
|
|
51
54
|
usage?: ReadonlyMap<string, { hits: number; opens: number }>
|
|
55
|
+
/**
|
|
56
|
+
* Zero-open decay (v5, default off — config `zeroOpenDecay`): halve the
|
|
57
|
+
* score of slugs on the zero-open list (≥5 rendered injections in the
|
|
58
|
+
* rolling 30 days, zero opens ever) at score-finalization time, BEFORE
|
|
59
|
+
* the threshold cut — a decayed candidate that drops below the threshold
|
|
60
|
+
* lands in nearMisses with the `zero-open-decay` reason, so the log
|
|
61
|
+
* shows why it stopped injecting. Absent/off = zero behavior change.
|
|
62
|
+
*/
|
|
63
|
+
zeroOpenDecay?: boolean
|
|
64
|
+
/** Zero-open slugs for this round (service-built from the ilog tail). */
|
|
65
|
+
zeroOpenSlugs?: ReadonlySet<string>
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
/** Hard cap for the usage bonus — below threshold 0.3 so usage can never
|
|
55
69
|
* carry a candidate across the gate on its own (ADR 0015). */
|
|
56
70
|
export const USAGE_BOOST_CAP = 0.2
|
|
57
71
|
|
|
72
|
+
/** Score multiplier for zero-open slugs when zeroOpenDecay is on (v5). */
|
|
73
|
+
export const ZERO_OPEN_DECAY_FACTOR = 0.5
|
|
74
|
+
|
|
58
75
|
export const DEFAULT_RETRIEVAL: RetrievalConfig = {
|
|
59
76
|
threshold: 0.3,
|
|
60
77
|
topK: 4,
|
|
@@ -224,6 +241,14 @@ export function scoreTopic(
|
|
|
224
241
|
score *= 0.3
|
|
225
242
|
reasons.push('conflicted-demoted')
|
|
226
243
|
}
|
|
244
|
+
// Zero-open decay (v5): same demotion family as conflicts — applied to
|
|
245
|
+
// the content score BEFORE gateScore is taken, so the threshold cut and
|
|
246
|
+
// the near-miss band both see the halved score; recency still rides
|
|
247
|
+
// outside it as the ranking tiebreaker.
|
|
248
|
+
if (cfg.zeroOpenDecay === true && cfg.zeroOpenSlugs?.has(topic.slug) === true) {
|
|
249
|
+
score *= ZERO_OPEN_DECAY_FACTOR
|
|
250
|
+
reasons.push('zero-open-decay')
|
|
251
|
+
}
|
|
227
252
|
const gateScore = score
|
|
228
253
|
score += recencyBonus
|
|
229
254
|
const bodySet = new Set([...description, ...conclusion])
|
|
@@ -318,3 +343,335 @@ export function searchTopics(query: string, roster: readonly RetrievableTopic[],
|
|
|
318
343
|
}
|
|
319
344
|
return { hits, nearMisses, rosterSize: roster.length }
|
|
320
345
|
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Injection-quality line (wave/injection-quality, spec §2.1/§3.1/§3.3) —
|
|
349
|
+
// CAPABILITY ONLY. None of this is wired into searchTopics or any service
|
|
350
|
+
// path; the caller gates it on packageMode ≠ 'off' (Phase 2 wiring). With
|
|
351
|
+
// the config keys at their defaults every code path below is unreachable
|
|
352
|
+
// from the hot lane, so default-off behavior stays byte-for-byte.
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
// --- ① Query sanitization (A4 决议 4, spec §3.3) ---------------------------
|
|
356
|
+
// Pure, LLM-free, millisecond-scale. Treats the queryTokenCount p90=462
|
|
357
|
+
// paste-pollution tail: fenced code blocks, URLs, line-leading shell
|
|
358
|
+
// prompts, over-long lines, and a hard token cap.
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Token cap in tokenize() units — the SAME unit injections.jsonl logs as
|
|
362
|
+
* queryTokenCount (p90=462 baseline; real-query p50=153 stays untouched).
|
|
363
|
+
* Raised or lowered via SanitizeOptions.maxTokens.
|
|
364
|
+
*/
|
|
365
|
+
export const QUERY_TOKEN_CAP = 96
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Lines longer than this are folded to their head (log-paste guard). Sized
|
|
369
|
+
* so the 96-token cap is the binding constraint, not the fold: 96 latin
|
|
370
|
+
* tokens ≈ 650+ chars, so a legitimate long single-line query passes the
|
|
371
|
+
* fold intact and only the token cap trims it. (实现期定 constant.)
|
|
372
|
+
*/
|
|
373
|
+
export const MAX_LINE_CHARS = 720
|
|
374
|
+
|
|
375
|
+
export interface SanitizeOptions {
|
|
376
|
+
maxTokens?: number
|
|
377
|
+
maxLineChars?: number
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const FENCE_OPEN = /^[ \t]*(```|~~~)/
|
|
381
|
+
// Line-leading shell prompt: `user@host:~/repo$ `, `C:\> `, or a bare
|
|
382
|
+
// `$ `/`# `/`% `/`> `/`❯ ` marker. Requires trailing whitespace so words
|
|
383
|
+
// like `$PATH` or `#tag` survive untouched.
|
|
384
|
+
const LINE_PROMPT = /^[ \t]*(?:[\w.@:/\\~-]*[$#%>❯➜])[ \t]+/
|
|
385
|
+
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+/gi
|
|
386
|
+
// Chunks the cap walks: one latin/digit word (or hyphenated compound) per
|
|
387
|
+
// match, or one CJK run (sliced when a run alone busts the cap).
|
|
388
|
+
const CHUNK_RUN = new RegExp(`${WORD_RUN.source}|${CJK_RUN.source}`, 'gu')
|
|
389
|
+
|
|
390
|
+
/** Remove fenced code blocks wholesale (markers AND body): fenced code is
|
|
391
|
+
* the paste-pollution payload; the prose around it is the actual query. */
|
|
392
|
+
function stripFencedBlocks(text: string): string {
|
|
393
|
+
const out: string[] = []
|
|
394
|
+
let open: string | null = null
|
|
395
|
+
for (const line of text.split('\n')) {
|
|
396
|
+
const m = line.match(FENCE_OPEN)
|
|
397
|
+
if (open === null) {
|
|
398
|
+
if (m !== null) open = m[1]
|
|
399
|
+
else out.push(line)
|
|
400
|
+
} else if (m !== null && m[1] === open) {
|
|
401
|
+
open = null // closing fence — block fully dropped
|
|
402
|
+
}
|
|
403
|
+
// inside an open fence: drop the line
|
|
404
|
+
}
|
|
405
|
+
return out.join('\n')
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Cap the sanitized text to ≤ cap distinct retrieval tokens. Fast path:
|
|
409
|
+
* at or under the cap the text passes through VERBATIM (no re-chunking —
|
|
410
|
+
* hyphens, punctuation and spacing survive byte-exact; retrieval scores
|
|
411
|
+
* tokens, not bytes, but the string may be echoed into logs/injection).
|
|
412
|
+
* Only an over-cap text rebuilds from chunks: head-first (a query's intent
|
|
413
|
+
* leads; pollution trails), with CJK runs sliced at the character level so
|
|
414
|
+
* a single giant run truncates instead of being dropped whole. */
|
|
415
|
+
function capTokens(text: string, cap: number): string {
|
|
416
|
+
const total = tokenize(text).length
|
|
417
|
+
if (total === 0) return '' // nothing lexical survives — caller sees a zero-token query
|
|
418
|
+
if (total <= cap) return text
|
|
419
|
+
const seen = new Set<string>()
|
|
420
|
+
const kept: string[] = []
|
|
421
|
+
for (const chunk of text.match(CHUNK_RUN) ?? []) {
|
|
422
|
+
const fresh = tokenize(chunk).filter((t) => !seen.has(t))
|
|
423
|
+
if (fresh.length === 0) continue // already-covered chunk adds nothing
|
|
424
|
+
if (seen.size + fresh.length <= cap) {
|
|
425
|
+
for (const t of fresh) seen.add(t)
|
|
426
|
+
kept.push(chunk)
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
const room = cap - seen.size
|
|
430
|
+
if (room <= 0) break
|
|
431
|
+
// Over budget: CJK runs truncate exactly (slice room+1 chars → ≤ room
|
|
432
|
+
// fresh bigrams, so the cap invariant holds). Latin chunks are single
|
|
433
|
+
// tokens by construction — they never reach here with room > 0, but if
|
|
434
|
+
// one ever does, dropping it beats busting the cap.
|
|
435
|
+
if (/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(chunk)) {
|
|
436
|
+
kept.push(chunk.slice(0, room + 1))
|
|
437
|
+
}
|
|
438
|
+
break
|
|
439
|
+
}
|
|
440
|
+
return kept.join(' ')
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Sanitize a raw user query for retrieval (spec §3.3): strip fenced code
|
|
445
|
+
* blocks and URLs, fold line-leading shell prompts, fold over-long lines to
|
|
446
|
+
* their head, cap the distinct-token count. Returns '' when nothing lexical
|
|
447
|
+
* survives — the caller treats that as a zero-token query (natural skip).
|
|
448
|
+
* Pure function; the hot lane only reaches it behind packageMode.
|
|
449
|
+
*/
|
|
450
|
+
export function sanitizeQuery(raw: string, opts: SanitizeOptions = {}): string {
|
|
451
|
+
const cap = opts.maxTokens ?? QUERY_TOKEN_CAP
|
|
452
|
+
const maxLineChars = opts.maxLineChars ?? MAX_LINE_CHARS
|
|
453
|
+
if (raw === '') return ''
|
|
454
|
+
let text = stripFencedBlocks(raw)
|
|
455
|
+
text = text.replace(URL_RE, ' ')
|
|
456
|
+
text = text
|
|
457
|
+
.split('\n')
|
|
458
|
+
.map((line) => {
|
|
459
|
+
let l = line.replace(LINE_PROMPT, '')
|
|
460
|
+
if (l.length > maxLineChars) l = l.slice(0, maxLineChars)
|
|
461
|
+
return l
|
|
462
|
+
})
|
|
463
|
+
.join('\n')
|
|
464
|
+
text = text.replace(/\s+/g, ' ').trim()
|
|
465
|
+
if (text === '') return ''
|
|
466
|
+
return capTokens(text, cap)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// --- ② Embedding index (A4 决议 2, spec §3.1) -------------------------------
|
|
470
|
+
// meta/embeddings.json: { slug: { model, dim, vec } }. Loading and auditing
|
|
471
|
+
// never throws; a missing/corrupt index reads as EMPTY, which routes every
|
|
472
|
+
// semantic path into the explicit passthrough below.
|
|
473
|
+
|
|
474
|
+
export interface EmbeddingIndexEntry {
|
|
475
|
+
model: string
|
|
476
|
+
dim: number
|
|
477
|
+
vec: number[]
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** slug -> entry, exactly as persisted in meta/embeddings.json. */
|
|
481
|
+
export type EmbeddingIndex = Record<string, EmbeddingIndexEntry>
|
|
482
|
+
|
|
483
|
+
export function embeddingIndexPath(bundleDir: string): string {
|
|
484
|
+
return join(bundleDir, 'meta', 'embeddings.json')
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function parseEmbeddingIndexEntry(value: unknown): EmbeddingIndexEntry | null {
|
|
488
|
+
if (typeof value !== 'object' || value === null) return null
|
|
489
|
+
const e = value as Record<string, unknown>
|
|
490
|
+
if (typeof e.model !== 'string' || e.model === '') return null
|
|
491
|
+
if (typeof e.dim !== 'number' || !Number.isInteger(e.dim) || e.dim <= 0) return null
|
|
492
|
+
if (!Array.isArray(e.vec) || e.vec.length !== e.dim) return null
|
|
493
|
+
if (!e.vec.every((n) => typeof n === 'number' && Number.isFinite(n))) return null
|
|
494
|
+
return { model: e.model, dim: e.dim, vec: e.vec as number[] }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Load meta/embeddings.json from a bundle dir. Missing file, corrupt JSON,
|
|
498
|
+
* or malformed entries are skipped/empty — never throws (a broken index
|
|
499
|
+
* must degrade to the lexical path, not break retrieval). */
|
|
500
|
+
export function loadEmbeddingIndex(bundleDir: string): EmbeddingIndex {
|
|
501
|
+
let raw: string
|
|
502
|
+
try {
|
|
503
|
+
raw = readFileSync(embeddingIndexPath(bundleDir), 'utf8')
|
|
504
|
+
} catch {
|
|
505
|
+
return {}
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
const json: unknown = JSON.parse(raw)
|
|
509
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) return {}
|
|
510
|
+
const out: EmbeddingIndex = {}
|
|
511
|
+
for (const [slug, entry] of Object.entries(json as Record<string, unknown>)) {
|
|
512
|
+
if (slug === '') continue
|
|
513
|
+
const parsed = parseEmbeddingIndexEntry(entry)
|
|
514
|
+
if (parsed !== null) out[slug] = parsed
|
|
515
|
+
}
|
|
516
|
+
return out
|
|
517
|
+
} catch {
|
|
518
|
+
return {}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Persist the index in the canonical shape (the rebuild's write side —
|
|
523
|
+
* embedding/rebuilding itself stays with the caller). Offline rebuild
|
|
524
|
+
* path, not the hot lane. */
|
|
525
|
+
export function saveEmbeddingIndex(bundleDir: string, index: EmbeddingIndex, mkdir = true): void {
|
|
526
|
+
if (mkdir) mkdirSync(join(bundleDir, 'meta'), { recursive: true })
|
|
527
|
+
writeFileSync(embeddingIndexPath(bundleDir), JSON.stringify(index))
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* An embed function bound to a config, carrying the RESOLVED model identity
|
|
532
|
+
* the index must match (build once via embedding.ts's `embedderWith`).
|
|
533
|
+
* ensureIndexFresh reads `.model` only — it never calls the function; the
|
|
534
|
+
* rebuild is the caller's job.
|
|
535
|
+
*/
|
|
536
|
+
export type EmbedderFn = ((texts: readonly string[]) => Promise<EmbedResult>) & { model: string }
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Freshness audit: which slugs need (re)embedding — missing from the index,
|
|
540
|
+
* built by a different model, or shape-broken (dim/vec disagree). Returns
|
|
541
|
+
* them in roster order; an EMPTY list means the index covers the roster.
|
|
542
|
+
* A '' embedder model means the semantic layer is off → nothing to maintain.
|
|
543
|
+
*
|
|
544
|
+
* NOTE the freshness boundary: content-change invalidation needs a stored
|
|
545
|
+
* content hash, which the pinned {model,dim,vec} entry shape does not carry
|
|
546
|
+
* — the rebuild trigger policy is spec-实现期定 and stays with the caller.
|
|
547
|
+
*/
|
|
548
|
+
export function ensureIndexFresh(index: EmbeddingIndex, topics: readonly { slug: string }[], embedFn: EmbedderFn): string[] {
|
|
549
|
+
if ((embedFn.model ?? '') === '') return []
|
|
550
|
+
const stale: string[] = []
|
|
551
|
+
for (const t of topics) {
|
|
552
|
+
const e = index[t.slug]
|
|
553
|
+
if (e === undefined || e.model !== embedFn.model || e.dim !== e.vec.length || !(e.dim > 0)) stale.push(t.slug)
|
|
554
|
+
}
|
|
555
|
+
return stale
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// --- ③ nearFloor semantic rerank (A4 决议 2, spec §3.1) ---------------------
|
|
559
|
+
// Cosine reordering of the nearFloor band ONLY (0.20–0.30 — the 93%
|
|
560
|
+
// near-miss mass). Thresholds, gateScore and the structural gate are
|
|
561
|
+
// untouched: the reranker reorders and tags, never re-scores.
|
|
562
|
+
|
|
563
|
+
export const RERANK_FLOOR = 0.2
|
|
564
|
+
export const RERANK_CEILING = 0.3
|
|
565
|
+
export const SEMANTIC_RERANK_REASON = 'semantic-rerank'
|
|
566
|
+
|
|
567
|
+
export interface RerankedHit extends SearchHit {
|
|
568
|
+
/** Cosine(query, slug-vector), rounded 4dp. Present only on reranked
|
|
569
|
+
* hits; absent = the candidate rode through unchanged. */
|
|
570
|
+
similarity?: number
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export type RerankPassthroughReason =
|
|
574
|
+
| 'no-candidates' // nothing to rank
|
|
575
|
+
| 'no-query-vector' // embed failed upstream — the explicit degradation
|
|
576
|
+
| 'empty-index' // meta/embeddings.json absent/corrupt
|
|
577
|
+
| 'no-rerankable-candidates' // band candidates lack usable index vectors
|
|
578
|
+
|
|
579
|
+
export interface RerankOptions {
|
|
580
|
+
/** Expected index model; entries built by another model count as
|
|
581
|
+
* vector-less (they ride through at the band tail). */
|
|
582
|
+
expectedModel?: string
|
|
583
|
+
floor?: number
|
|
584
|
+
ceiling?: number
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export interface RerankResult {
|
|
588
|
+
/** Candidates in final order. On passthrough: the input array, same
|
|
589
|
+
* order, no tags — byte-identical ordering, explicit why. */
|
|
590
|
+
hits: RerankedHit[]
|
|
591
|
+
/** true = the semantic layer engaged (order may or may not have moved). */
|
|
592
|
+
reranked: boolean
|
|
593
|
+
passthroughReason?: RerankPassthroughReason
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Cosine similarity; 0 for dim mismatch or zero vectors (never throws). */
|
|
597
|
+
export function cosineSimilarity(a: readonly number[], b: readonly number[]): number {
|
|
598
|
+
if (a.length === 0 || a.length !== b.length) return 0
|
|
599
|
+
let dot = 0
|
|
600
|
+
let na = 0
|
|
601
|
+
let nb = 0
|
|
602
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
603
|
+
dot += a[i]! * b[i]!
|
|
604
|
+
na += a[i]! * a[i]!
|
|
605
|
+
nb += b[i]! * b[i]!
|
|
606
|
+
}
|
|
607
|
+
if (na === 0 || nb === 0) return 0
|
|
608
|
+
return dot / Math.sqrt(na * nb)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Reorder the nearFloor band (0.20–0.30 by gateScore) by cosine similarity
|
|
613
|
+
* against the query vector. Band members with a usable index vector sort by
|
|
614
|
+
* similarity (stable — ties keep input order) and gain the
|
|
615
|
+
* `semantic-rerank` reason plus their similarity; everyone else — out of
|
|
616
|
+
* band, unindexed, model-mismatched — passes through UNCHANGED at the tail
|
|
617
|
+
* in input order. Scores are never touched.
|
|
618
|
+
*
|
|
619
|
+
* Degradation is explicit and total: provider/model unconfigured, probe
|
|
620
|
+
* failure, missing query vector or missing index all return the input as-is
|
|
621
|
+
* with `reranked:false` and a passthroughReason (原序透传,不许静默 — the
|
|
622
|
+
* caller logs it into the degraded status). `queryText` rides the signature
|
|
623
|
+
* for provenance/debug parity with the caller's logging; the v1 math is
|
|
624
|
+
* vector-only.
|
|
625
|
+
*/
|
|
626
|
+
export function rerankNearFloor(
|
|
627
|
+
queryText: string,
|
|
628
|
+
queryVec: readonly number[] | null | undefined,
|
|
629
|
+
nearFloorCandidates: readonly SearchHit[],
|
|
630
|
+
index: EmbeddingIndex,
|
|
631
|
+
opts: RerankOptions = {},
|
|
632
|
+
): RerankResult {
|
|
633
|
+
void queryText // v1 math is vector-only — see doc above
|
|
634
|
+
if (nearFloorCandidates.length === 0) {
|
|
635
|
+
return { hits: [], reranked: false, passthroughReason: 'no-candidates' }
|
|
636
|
+
}
|
|
637
|
+
if (!Array.isArray(queryVec) || queryVec.length === 0) {
|
|
638
|
+
return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'no-query-vector' }
|
|
639
|
+
}
|
|
640
|
+
if (Object.keys(index).length === 0) {
|
|
641
|
+
return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'empty-index' }
|
|
642
|
+
}
|
|
643
|
+
const floor = opts.floor ?? RERANK_FLOOR
|
|
644
|
+
const ceiling = opts.ceiling ?? RERANK_CEILING
|
|
645
|
+
const simOf = new Map<SearchHit, number | null>()
|
|
646
|
+
for (const hit of nearFloorCandidates) {
|
|
647
|
+
const entry = index[hit.slug]
|
|
648
|
+
const usable =
|
|
649
|
+
entry !== undefined &&
|
|
650
|
+
entry.dim === entry.vec.length &&
|
|
651
|
+
entry.dim > 0 &&
|
|
652
|
+
entry.dim === queryVec.length &&
|
|
653
|
+
(opts.expectedModel === undefined || entry.model === opts.expectedModel)
|
|
654
|
+
simOf.set(hit, usable ? cosineSimilarity(queryVec, entry.vec) : null)
|
|
655
|
+
}
|
|
656
|
+
const band = nearFloorCandidates.filter((h) => {
|
|
657
|
+
const sim = simOf.get(h)
|
|
658
|
+
return sim !== null && sim !== undefined && h.gateScore !== undefined && h.gateScore >= floor && h.gateScore < ceiling
|
|
659
|
+
})
|
|
660
|
+
if (band.length === 0) {
|
|
661
|
+
return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'no-rerankable-candidates' }
|
|
662
|
+
}
|
|
663
|
+
const rerankedSet = new Set(band)
|
|
664
|
+
const tail = nearFloorCandidates.filter((h) => !rerankedSet.has(h))
|
|
665
|
+
// Stable sort: V8's Array#sort keeps input order for equal similarities.
|
|
666
|
+
const ordered = [...band].sort((x, y) => (simOf.get(y) as number) - (simOf.get(x) as number))
|
|
667
|
+
return {
|
|
668
|
+
hits: [
|
|
669
|
+
...ordered.map((h) => {
|
|
670
|
+
const sim = simOf.get(h) as number
|
|
671
|
+
return { ...h, reasons: [...h.reasons, SEMANTIC_RERANK_REASON], similarity: Math.round(sim * 10_000) / 10_000 }
|
|
672
|
+
}),
|
|
673
|
+
...tail.map((h) => ({ ...h })),
|
|
674
|
+
],
|
|
675
|
+
reranked: true,
|
|
676
|
+
}
|
|
677
|
+
}
|