@seanhogg/builderforce-memory 2026.9.29 → 2026.9.31
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/bridges/AnthropicBridge.d.ts.map +1 -1
- package/dist/bridges/AnthropicBridge.js +24 -65
- package/dist/bridges/AnthropicBridge.js.map +1 -1
- package/dist/bridges/FetchBridge.d.ts +12 -5
- package/dist/bridges/FetchBridge.d.ts.map +1 -1
- package/dist/bridges/FetchBridge.js +6 -4
- package/dist/bridges/FetchBridge.js.map +1 -1
- package/dist/bridges/OpenAIBridge.d.ts +17 -4
- package/dist/bridges/OpenAIBridge.d.ts.map +1 -1
- package/dist/bridges/OpenAIBridge.js +71 -94
- package/dist/bridges/OpenAIBridge.js.map +1 -1
- package/dist/bridges/TransformerBridge.d.ts +6 -0
- package/dist/bridges/TransformerBridge.d.ts.map +1 -1
- package/dist/bridges/VertexAIBridge.d.ts.map +1 -1
- package/dist/bridges/VertexAIBridge.js +3 -34
- package/dist/bridges/VertexAIBridge.js.map +1 -1
- package/dist/distillation/DistillationEngine.d.ts +71 -54
- package/dist/distillation/DistillationEngine.d.ts.map +1 -1
- package/dist/distillation/DistillationEngine.js +112 -95
- package/dist/distillation/DistillationEngine.js.map +1 -1
- package/dist/distillation/index.d.ts +7 -1
- package/dist/distillation/index.d.ts.map +1 -1
- package/dist/distillation/index.js +5 -0
- package/dist/distillation/index.js.map +1 -1
- package/dist/distillation/ports.d.ts +43 -0
- package/dist/distillation/ports.d.ts.map +1 -0
- package/dist/distillation/ports.js +29 -0
- package/dist/distillation/ports.js.map +1 -0
- package/dist/eval/EvalHarness.d.ts +2 -1
- package/dist/eval/EvalHarness.d.ts.map +1 -1
- package/dist/eval/EvalHarness.js +13 -1
- package/dist/eval/EvalHarness.js.map +1 -1
- package/dist/eval/baseline.d.ts +33 -0
- package/dist/eval/baseline.d.ts.map +1 -0
- package/dist/eval/baseline.js +41 -0
- package/dist/eval/baseline.js.map +1 -0
- package/dist/eval/index.d.ts +1 -0
- package/dist/eval/index.d.ts.map +1 -1
- package/dist/eval/index.js +1 -0
- package/dist/eval/index.js.map +1 -1
- package/dist/eval/types.d.ts +7 -0
- package/dist/eval/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/rag/EnterpriseRetriever.d.ts.map +1 -1
- package/dist/rag/EnterpriseRetriever.js +2 -1
- package/dist/rag/EnterpriseRetriever.js.map +1 -1
- package/dist/retrieval/HybridRetriever.d.ts.map +1 -1
- package/dist/retrieval/HybridRetriever.js +2 -1
- package/dist/retrieval/HybridRetriever.js.map +1 -1
- package/dist/retrieval/decay.d.ts +24 -0
- package/dist/retrieval/decay.d.ts.map +1 -0
- package/dist/retrieval/decay.js +29 -0
- package/dist/retrieval/decay.js.map +1 -0
- package/dist/retrieval/fusion.d.ts +13 -19
- package/dist/retrieval/fusion.d.ts.map +1 -1
- package/dist/retrieval/fusion.js +17 -44
- package/dist/retrieval/fusion.js.map +1 -1
- package/dist/retrieval/index.d.ts +13 -6
- package/dist/retrieval/index.d.ts.map +1 -1
- package/dist/retrieval/index.js +10 -5
- package/dist/retrieval/index.js.map +1 -1
- package/dist/retrieval/mmr.d.ts +60 -0
- package/dist/retrieval/mmr.d.ts.map +1 -0
- package/dist/retrieval/mmr.js +94 -0
- package/dist/retrieval/mmr.js.map +1 -0
- package/dist/retrieval/weightedFusion.d.ts +64 -0
- package/dist/retrieval/weightedFusion.d.ts.map +1 -0
- package/dist/retrieval/weightedFusion.js +80 -0
- package/dist/retrieval/weightedFusion.js.map +1 -0
- package/dist/similarity/index.d.ts +6 -4
- package/dist/similarity/index.d.ts.map +1 -1
- package/dist/similarity/index.js +5 -3
- package/dist/similarity/index.js.map +1 -1
- package/dist/wire/chatClient.d.ts +100 -0
- package/dist/wire/chatClient.d.ts.map +1 -0
- package/dist/wire/chatClient.js +123 -0
- package/dist/wire/chatClient.js.map +1 -0
- package/dist/wire/index.d.ts +20 -0
- package/dist/wire/index.d.ts.map +1 -0
- package/dist/wire/index.js +18 -0
- package/dist/wire/index.js.map +1 -0
- package/dist/wire/sse.d.ts +37 -0
- package/dist/wire/sse.d.ts.map +1 -0
- package/dist/wire/sse.js +104 -0
- package/dist/wire/sse.js.map +1 -0
- package/dist/wire/usage.d.ts +46 -0
- package/dist/wire/usage.d.ts.map +1 -0
- package/dist/wire/usage.js +78 -0
- package/dist/wire/usage.js.map +1 -0
- package/package.json +11 -3
- package/src/bridges/AnthropicBridge.ts +24 -68
- package/src/bridges/FetchBridge.ts +15 -6
- package/src/bridges/OpenAIBridge.ts +84 -113
- package/src/bridges/TransformerBridge.ts +6 -0
- package/src/bridges/VertexAIBridge.ts +3 -32
- package/src/distillation/DistillationEngine.ts +176 -141
- package/src/distillation/index.ts +15 -1
- package/src/distillation/ports.ts +55 -0
- package/src/eval/EvalHarness.ts +12 -1
- package/src/eval/baseline.ts +51 -0
- package/src/eval/index.ts +1 -0
- package/src/eval/types.ts +7 -0
- package/src/index.ts +5 -2
- package/src/rag/EnterpriseRetriever.ts +2 -1
- package/src/retrieval/HybridRetriever.ts +2 -1
- package/src/retrieval/decay.ts +29 -0
- package/src/retrieval/fusion.ts +19 -52
- package/src/retrieval/index.ts +24 -6
- package/src/retrieval/mmr.ts +126 -0
- package/src/retrieval/weightedFusion.ts +101 -0
- package/src/similarity/index.ts +6 -4
- package/src/wire/chatClient.ts +196 -0
- package/src/wire/index.ts +20 -0
- package/src/wire/sse.ts +96 -0
- package/src/wire/usage.ts +95 -0
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { bm25Search, type Bm25Options } from './bm25.js';
|
|
18
|
-
import { reciprocalRankFusion
|
|
18
|
+
import { reciprocalRankFusion } from './fusion.js';
|
|
19
|
+
import { maximalMarginalRelevance, type MmrCandidate } from './mmr.js';
|
|
19
20
|
import { denseSearch } from './hnsw.js';
|
|
20
21
|
|
|
21
22
|
export interface RetrievalCandidate {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recency decay — exponential half-life down-weighting of an older result's score.
|
|
3
|
+
*
|
|
4
|
+
* `score · e^(−λ·age)` with `λ = ln2 / halfLife`, so a result `halfLife` days old
|
|
5
|
+
* keeps half its score. The maths is here; deciding how old a result IS (a dated
|
|
6
|
+
* file name, an mtime, a `created_at` column) and which results never decay stays
|
|
7
|
+
* with the caller, because that is a property of its corpus.
|
|
8
|
+
*
|
|
9
|
+
* Pure and zero-dependency.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The decay rate for a half-life in days; 0 (no decay) for a non-positive or non-finite half-life. */
|
|
13
|
+
export function toDecayLambda(halfLifeDays: number): number {
|
|
14
|
+
if (!Number.isFinite(halfLifeDays) || halfLifeDays <= 0) return 0;
|
|
15
|
+
return Math.LN2 / halfLifeDays;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The factor in (0,1] a score is multiplied by at `ageInDays` (negative ages count as 0). */
|
|
19
|
+
export function calculateTemporalDecayMultiplier(params: { ageInDays: number; halfLifeDays: number }): number {
|
|
20
|
+
const lambda = toDecayLambda(params.halfLifeDays);
|
|
21
|
+
const clampedAge = Math.max(0, params.ageInDays);
|
|
22
|
+
if (lambda <= 0 || !Number.isFinite(clampedAge)) return 1;
|
|
23
|
+
return Math.exp(-lambda * clampedAge);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `score` decayed for its age. */
|
|
27
|
+
export function applyTemporalDecayToScore(params: { score: number; ageInDays: number; halfLifeDays: number }): number {
|
|
28
|
+
return params.score * calculateTemporalDecayMultiplier(params);
|
|
29
|
+
}
|
package/src/retrieval/fusion.ts
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rank
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* parameter-light way to combine hybrid retrieval signals.
|
|
8
|
-
* • Maximal Marginal Relevance (MMR) reranks the fused list to trade off
|
|
9
|
-
* relevance against novelty, so the top-k isn't five near-duplicate chunks.
|
|
2
|
+
* Reciprocal Rank Fusion (RRF) — merges rankings (dense vector, sparse BM25, …) into
|
|
3
|
+
* one list without needing their score scales to be commensurable: it fuses on
|
|
4
|
+
* RANK, not raw score. The standard, parameter-light way to combine hybrid signals.
|
|
5
|
+
* Score-based fusion lives in {@link ./weightedFusion}; diversity reranking in
|
|
6
|
+
* {@link ./mmr}.
|
|
10
7
|
*
|
|
11
8
|
* Pure and zero-dependency.
|
|
12
9
|
*/
|
|
13
10
|
|
|
14
|
-
import { cosineSimilarity } from '../similarity/index.js';
|
|
15
|
-
|
|
16
11
|
export interface RankedList {
|
|
17
12
|
/** Ordered ids, most relevant first. */
|
|
18
13
|
ids: string[];
|
|
@@ -25,60 +20,32 @@ export interface FusedHit {
|
|
|
25
20
|
score: number;
|
|
26
21
|
}
|
|
27
22
|
|
|
23
|
+
/** The canonical TREC damping constant for {@link rrfTerm}. */
|
|
24
|
+
export const RRF_DEFAULT_K = 60;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One list's RRF contribution for the item at zero-based `rank`: `1 / (k + rank + 1)`.
|
|
28
|
+
* Exported for a ranker that blends the rank signal into a score of its own rather
|
|
29
|
+
* than fusing whole lists.
|
|
30
|
+
*/
|
|
31
|
+
export function rrfTerm(rank: number, k: number = RRF_DEFAULT_K): number {
|
|
32
|
+
return 1 / (k + rank + 1);
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
/**
|
|
29
36
|
* Reciprocal Rank Fusion over any number of ranked lists.
|
|
30
37
|
* score(d) = Σ_lists weight / (k + rank(d)). `k` (default 60) damps the
|
|
31
38
|
* contribution of low-ranked items; the canonical TREC value.
|
|
32
39
|
*/
|
|
33
|
-
export function reciprocalRankFusion(lists: RankedList[], k =
|
|
40
|
+
export function reciprocalRankFusion(lists: RankedList[], k = RRF_DEFAULT_K): FusedHit[] {
|
|
34
41
|
const acc = new Map<string, number>();
|
|
35
42
|
for (const list of lists) {
|
|
36
43
|
const weight = list.weight ?? 1;
|
|
37
44
|
list.ids.forEach((id, rank) => {
|
|
38
|
-
acc.set(id, (acc.get(id) ?? 0) + weight
|
|
45
|
+
acc.set(id, (acc.get(id) ?? 0) + weight * rrfTerm(rank, k));
|
|
39
46
|
});
|
|
40
47
|
}
|
|
41
48
|
return [...acc.entries()]
|
|
42
49
|
.map(([id, score]) => ({ id, score }))
|
|
43
50
|
.sort((a, b) => b.score - a.score);
|
|
44
51
|
}
|
|
45
|
-
|
|
46
|
-
export interface MmrCandidate {
|
|
47
|
-
id: string;
|
|
48
|
-
vector: Float32Array;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Maximal Marginal Relevance rerank. Greedily selects up to `topK` candidates,
|
|
53
|
-
* each step maximising `λ·sim(query, d) − (1−λ)·max sim(d, already-selected)`.
|
|
54
|
-
* λ=1 is pure relevance; lower λ injects diversity. Candidates without vectors
|
|
55
|
-
* should be filtered out by the caller (they cannot be MMR-scored).
|
|
56
|
-
*/
|
|
57
|
-
export function maximalMarginalRelevance(
|
|
58
|
-
queryVec: Float32Array,
|
|
59
|
-
candidates: MmrCandidate[],
|
|
60
|
-
topK: number,
|
|
61
|
-
lambda = 0.7,
|
|
62
|
-
): string[] {
|
|
63
|
-
const remaining = [...candidates];
|
|
64
|
-
const selected: MmrCandidate[] = [];
|
|
65
|
-
const relevance = new Map<string, number>();
|
|
66
|
-
for (const c of remaining) relevance.set(c.id, cosineSimilarity(queryVec, c.vector));
|
|
67
|
-
|
|
68
|
-
while (selected.length < topK && remaining.length > 0) {
|
|
69
|
-
let bestIdx = 0;
|
|
70
|
-
let bestScore = -Infinity;
|
|
71
|
-
for (let i = 0; i < remaining.length; i++) {
|
|
72
|
-
const c = remaining[i]!;
|
|
73
|
-
let maxSimToSelected = 0;
|
|
74
|
-
for (const s of selected) {
|
|
75
|
-
const sim = cosineSimilarity(c.vector, s.vector);
|
|
76
|
-
if (sim > maxSimToSelected) maxSimToSelected = sim;
|
|
77
|
-
}
|
|
78
|
-
const mmr = lambda * relevance.get(c.id)! - (1 - lambda) * maxSimToSelected;
|
|
79
|
-
if (mmr > bestScore) { bestScore = mmr; bestIdx = i; }
|
|
80
|
-
}
|
|
81
|
-
selected.push(remaining.splice(bestIdx, 1)[0]!);
|
|
82
|
-
}
|
|
83
|
-
return selected.map(s => s.id);
|
|
84
|
-
}
|
package/src/retrieval/index.ts
CHANGED
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Retrieval layer — chunking, BM25, rank fusion,
|
|
2
|
+
* Retrieval layer — chunking, BM25, rank and score fusion, MMR, recency decay, the
|
|
3
|
+
* similarity primitives they rest on, and the HybridRetriever.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Zero-dependency and engine-free: this is the `@seanhogg/builderforce-memory/retrieval`
|
|
6
|
+
* subpath, which a Cloudflare Worker, Node and the browser can all import without
|
|
7
|
+
* pulling the WebGPU engine. Every host ranks with THESE functions rather than a copy.
|
|
7
8
|
*/
|
|
8
9
|
|
|
10
|
+
export { cosineSimilarity, jaccardSimilarity, tokenize } from '../similarity/index.js';
|
|
11
|
+
|
|
9
12
|
export { chunkText } from './chunk.js';
|
|
10
13
|
export type { Chunk, ChunkOptions } from './chunk.js';
|
|
11
14
|
|
|
12
15
|
export { bm25Search, bm25Idf, bm25LengthNorm, bm25TermScore, BM25_DEFAULT_K1, BM25_DEFAULT_B } from './bm25.js';
|
|
13
16
|
export type { Bm25Doc, Bm25Hit, Bm25Options, Bm25Tokenizer } from './bm25.js';
|
|
14
17
|
|
|
15
|
-
export { reciprocalRankFusion,
|
|
16
|
-
export type { RankedList, FusedHit
|
|
18
|
+
export { reciprocalRankFusion, rrfTerm, RRF_DEFAULT_K } from './fusion.js';
|
|
19
|
+
export type { RankedList, FusedHit } from './fusion.js';
|
|
20
|
+
|
|
21
|
+
export { mmrSelect, maximalMarginalRelevance, textMaximalMarginalRelevance } from './mmr.js';
|
|
22
|
+
export type { MmrSelectOptions, MmrCandidate, TextMmrItem } from './mmr.js';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
DEFAULT_HYBRID_VECTOR_WEIGHT,
|
|
26
|
+
DEFAULT_HYBRID_TEXT_WEIGHT,
|
|
27
|
+
normalizeHybridWeights,
|
|
28
|
+
hybridScore,
|
|
29
|
+
bm25RankToScore,
|
|
30
|
+
fuseHybridArms,
|
|
31
|
+
} from './weightedFusion.js';
|
|
32
|
+
export type { HybridWeights, HybridArmScores } from './weightedFusion.js';
|
|
33
|
+
|
|
34
|
+
export { toDecayLambda, calculateTemporalDecayMultiplier, applyTemporalDecayToScore } from './decay.js';
|
|
17
35
|
|
|
18
36
|
export { hybridRetrieve } from './HybridRetriever.js';
|
|
19
37
|
export type {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maximal Marginal Relevance — ONE greedy selection loop, two similarity bases.
|
|
3
|
+
*
|
|
4
|
+
* MMR reranks a relevance-ordered list so the top of it is not five near-duplicates:
|
|
5
|
+
* each step picks the candidate maximising `λ·relevance − (1−λ)·max sim(candidate,
|
|
6
|
+
* already-selected)`. λ=1 is pure relevance; lower λ buys diversity.
|
|
7
|
+
*
|
|
8
|
+
* What differs between callers is only what "similar" means:
|
|
9
|
+
* • {@link maximalMarginalRelevance} — cosine over embedding vectors, relevance =
|
|
10
|
+
* cosine to the query vector. For retrievers that hold vectors;
|
|
11
|
+
* • {@link textMaximalMarginalRelevance} — Jaccard over word tokens, relevance =
|
|
12
|
+
* the candidate's own score min-max normalised onto [0,1]. For retrievers that
|
|
13
|
+
* only have text and a fused score (an FTS5 + sqlite-vec hybrid, say).
|
|
14
|
+
*
|
|
15
|
+
* Both run {@link mmrSelect}, so the selection rule itself exists once.
|
|
16
|
+
*
|
|
17
|
+
* Pure and zero-dependency.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { cosineSimilarity, jaccardSimilarity, tokenize } from '../similarity/index.js';
|
|
21
|
+
|
|
22
|
+
export interface MmrSelectOptions<T> {
|
|
23
|
+
/** Relevance of one candidate, on the same scale as {@link similarity}. */
|
|
24
|
+
relevance: (item: T) => number;
|
|
25
|
+
/** Similarity of two candidates. */
|
|
26
|
+
similarity: (a: T, b: T) => number;
|
|
27
|
+
/** 0 = maximum diversity, 1 = maximum relevance. Default 0.7. */
|
|
28
|
+
lambda?: number;
|
|
29
|
+
/** Stop after this many picks. Default: every candidate. */
|
|
30
|
+
topK?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Breaks an exact MMR tie in favour of the higher value. Without it the earlier
|
|
33
|
+
* candidate wins, so input order decides.
|
|
34
|
+
*/
|
|
35
|
+
tieScore?: (item: T) => number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Greedy MMR selection. Returns the picked candidates in pick order. */
|
|
39
|
+
export function mmrSelect<T>(items: readonly T[], opts: MmrSelectOptions<T>): T[] {
|
|
40
|
+
const lambda = opts.lambda ?? 0.7;
|
|
41
|
+
const limit = Math.min(opts.topK ?? items.length, items.length);
|
|
42
|
+
const remaining = [...items];
|
|
43
|
+
const relevance = new Map<T, number>();
|
|
44
|
+
for (const item of remaining) relevance.set(item, opts.relevance(item));
|
|
45
|
+
|
|
46
|
+
const selected: T[] = [];
|
|
47
|
+
while (selected.length < limit && remaining.length > 0) {
|
|
48
|
+
let bestIdx = -1;
|
|
49
|
+
let bestScore = -Infinity;
|
|
50
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
51
|
+
const candidate = remaining[i]!;
|
|
52
|
+
let maxSimToSelected = 0;
|
|
53
|
+
for (const s of selected) {
|
|
54
|
+
const sim = opts.similarity(candidate, s);
|
|
55
|
+
if (sim > maxSimToSelected) maxSimToSelected = sim;
|
|
56
|
+
}
|
|
57
|
+
const score = lambda * relevance.get(candidate)! - (1 - lambda) * maxSimToSelected;
|
|
58
|
+
const wins = score > bestScore
|
|
59
|
+
|| (score === bestScore && bestIdx >= 0 && opts.tieScore !== undefined
|
|
60
|
+
&& opts.tieScore(candidate) > opts.tieScore(remaining[bestIdx]!));
|
|
61
|
+
if (wins) { bestScore = score; bestIdx = i; }
|
|
62
|
+
}
|
|
63
|
+
if (bestIdx < 0) break;
|
|
64
|
+
selected.push(remaining.splice(bestIdx, 1)[0]!);
|
|
65
|
+
}
|
|
66
|
+
return selected;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MmrCandidate {
|
|
70
|
+
id: string;
|
|
71
|
+
vector: Float32Array;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Vector MMR. Selects up to `topK` candidates by cosine relevance to `queryVec`,
|
|
76
|
+
* penalising cosine similarity to what is already selected. Candidates without
|
|
77
|
+
* vectors should be filtered out by the caller (they cannot be MMR-scored).
|
|
78
|
+
*/
|
|
79
|
+
export function maximalMarginalRelevance(
|
|
80
|
+
queryVec: Float32Array,
|
|
81
|
+
candidates: MmrCandidate[],
|
|
82
|
+
topK: number,
|
|
83
|
+
lambda = 0.7,
|
|
84
|
+
): string[] {
|
|
85
|
+
return mmrSelect(candidates, {
|
|
86
|
+
relevance: (c) => cosineSimilarity(queryVec, c.vector),
|
|
87
|
+
similarity: (a, b) => cosineSimilarity(a.vector, b.vector),
|
|
88
|
+
lambda,
|
|
89
|
+
topK,
|
|
90
|
+
}).map((c) => c.id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TextMmrItem {
|
|
94
|
+
id: string;
|
|
95
|
+
/** The candidate's fused relevance score (any scale — it is normalised here). */
|
|
96
|
+
score: number;
|
|
97
|
+
/** The text diversity is judged on. */
|
|
98
|
+
content: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Text MMR. Reorders every item, judging diversity by Jaccard similarity of word
|
|
103
|
+
* tokens, and relevance by the item's `score` min-max normalised onto [0,1] (all
|
|
104
|
+
* equal → 1). An exact tie goes to the higher raw score. `lambda` is clamped to
|
|
105
|
+
* [0,1]; at 1 this is a plain score sort.
|
|
106
|
+
*/
|
|
107
|
+
export function textMaximalMarginalRelevance<T extends TextMmrItem>(items: readonly T[], lambda = 0.7): T[] {
|
|
108
|
+
if (items.length <= 1) return [...items];
|
|
109
|
+
const l = Math.max(0, Math.min(1, lambda));
|
|
110
|
+
if (l === 1) return [...items].sort((a, b) => b.score - a.score);
|
|
111
|
+
|
|
112
|
+
const tokens = new Map<string, Set<string>>();
|
|
113
|
+
for (const item of items) tokens.set(item.id, new Set(tokenize(item.content)));
|
|
114
|
+
const tokensOf = (item: T) => tokens.get(item.id) ?? new Set(tokenize(item.content));
|
|
115
|
+
|
|
116
|
+
const scores = items.map((i) => i.score);
|
|
117
|
+
const min = Math.min(...scores);
|
|
118
|
+
const range = Math.max(...scores) - min;
|
|
119
|
+
|
|
120
|
+
return mmrSelect(items, {
|
|
121
|
+
relevance: (item) => (range === 0 ? 1 : (item.score - min) / range),
|
|
122
|
+
similarity: (a, b) => jaccardSimilarity(tokensOf(a), tokensOf(b)),
|
|
123
|
+
lambda: l,
|
|
124
|
+
tieScore: (item) => item.score,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weighted-score fusion — the ONE formula for fusing a semantic arm with a lexical
|
|
3
|
+
* arm by SCORE, where {@link ./fusion reciprocalRankFusion} fuses by RANK.
|
|
4
|
+
*
|
|
5
|
+
* Score fusion is the choice when two different engines must rank the same way: an
|
|
6
|
+
* on-prem `sqlite-vec` cosine + FTS5/BM25 pair and a cloud pgvector cosine + ILIKE
|
|
7
|
+
* pair answer the same question, and a weighted sum of scores already on roughly
|
|
8
|
+
* [0,1] makes "recall ranks the same everywhere" a property of one function.
|
|
9
|
+
*
|
|
10
|
+
* Union semantics: a candidate found by only one arm scores 0 on the other rather
|
|
11
|
+
* than being dropped, because "the lexical arm did not match" is evidence about the
|
|
12
|
+
* query's wording, not about the candidate's relevance.
|
|
13
|
+
*
|
|
14
|
+
* Pure and zero-dependency.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Weight on the semantic arm when a caller states none. */
|
|
18
|
+
export const DEFAULT_HYBRID_VECTOR_WEIGHT = 0.7;
|
|
19
|
+
/** Weight on the lexical arm when a caller states none. */
|
|
20
|
+
export const DEFAULT_HYBRID_TEXT_WEIGHT = 0.3;
|
|
21
|
+
|
|
22
|
+
export interface HybridWeights {
|
|
23
|
+
vectorWeight: number;
|
|
24
|
+
textWeight: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Renormalise a pair of weights so it sums to 1 — a fused score is only comparable
|
|
29
|
+
* across queries if the weights always sum the same. A degenerate pair (both zero
|
|
30
|
+
* or non-finite) falls back to the defaults.
|
|
31
|
+
*
|
|
32
|
+
* The pair is a RATIO, so only the lower bound is clamped: `(3, 1)` means "three
|
|
33
|
+
* parts semantic to one part lexical" and must come back as 0.75/0.25, not be
|
|
34
|
+
* capped into an even split. Normalisation is what bounds the result.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeHybridWeights(vectorWeight?: number, textWeight?: number): HybridWeights {
|
|
37
|
+
const clamp = (n: number | undefined, fallback: number): number =>
|
|
38
|
+
typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : fallback;
|
|
39
|
+
const v = clamp(vectorWeight, DEFAULT_HYBRID_VECTOR_WEIGHT);
|
|
40
|
+
const t = clamp(textWeight, DEFAULT_HYBRID_TEXT_WEIGHT);
|
|
41
|
+
const sum = v + t;
|
|
42
|
+
if (sum <= 0) {
|
|
43
|
+
return { vectorWeight: DEFAULT_HYBRID_VECTOR_WEIGHT, textWeight: DEFAULT_HYBRID_TEXT_WEIGHT };
|
|
44
|
+
}
|
|
45
|
+
return { vectorWeight: v / sum, textWeight: t / sum };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One candidate's per-arm scores, both already normalised to roughly [0,1]. */
|
|
49
|
+
export interface HybridArmScores {
|
|
50
|
+
vectorScore?: number;
|
|
51
|
+
textScore?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The fused score for one candidate under the given (already normalised) weights. */
|
|
55
|
+
export function hybridScore(scores: HybridArmScores, weights: HybridWeights): number {
|
|
56
|
+
const v = Number.isFinite(scores.vectorScore) ? (scores.vectorScore as number) : 0;
|
|
57
|
+
const t = Number.isFinite(scores.textScore) ? (scores.textScore as number) : 0;
|
|
58
|
+
return weights.vectorWeight * v + weights.textWeight * t;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Map a BM25 rank (lower is better, unbounded — FTS5's `bm25()`) onto a (0,1]
|
|
63
|
+
* score so it can be summed with a cosine similarity. A non-finite rank is treated
|
|
64
|
+
* as very poor rather than as an error.
|
|
65
|
+
*/
|
|
66
|
+
export function bm25RankToScore(rank: number): number {
|
|
67
|
+
const normalized = Number.isFinite(rank) ? Math.max(0, rank) : 999;
|
|
68
|
+
return 1 / (1 + normalized);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fuse two arms keyed by a caller-supplied id and return the candidates ordered
|
|
73
|
+
* best-first. `merge` builds the output row from the winning payload of each id;
|
|
74
|
+
* later duplicates of the same id keep the first payload seen (arm order decides).
|
|
75
|
+
*/
|
|
76
|
+
export function fuseHybridArms<TVector extends { id: string }, TText extends { id: string }, TOut>(params: {
|
|
77
|
+
vector: readonly TVector[];
|
|
78
|
+
text: readonly TText[];
|
|
79
|
+
vectorScoreOf: (row: TVector) => number;
|
|
80
|
+
textScoreOf: (row: TText) => number;
|
|
81
|
+
merge: (row: TVector | TText, score: number) => TOut;
|
|
82
|
+
weights?: Partial<HybridWeights>;
|
|
83
|
+
}): TOut[] {
|
|
84
|
+
const weights = normalizeHybridWeights(params.weights?.vectorWeight, params.weights?.textWeight);
|
|
85
|
+
const byId = new Map<string, { row: TVector | TText; scores: HybridArmScores }>();
|
|
86
|
+
for (const row of params.vector) {
|
|
87
|
+
byId.set(row.id, { row, scores: { vectorScore: params.vectorScoreOf(row), textScore: 0 } });
|
|
88
|
+
}
|
|
89
|
+
for (const row of params.text) {
|
|
90
|
+
const existing = byId.get(row.id);
|
|
91
|
+
if (existing) existing.scores.textScore = params.textScoreOf(row);
|
|
92
|
+
else byId.set(row.id, { row, scores: { vectorScore: 0, textScore: params.textScoreOf(row) } });
|
|
93
|
+
}
|
|
94
|
+
return [...byId.values()]
|
|
95
|
+
.map((entry) => {
|
|
96
|
+
const score = hybridScore(entry.scores, weights);
|
|
97
|
+
return { out: params.merge(entry.row, score), score };
|
|
98
|
+
})
|
|
99
|
+
.sort((a, b) => b.score - a.score)
|
|
100
|
+
.map((entry) => entry.out);
|
|
101
|
+
}
|
package/src/similarity/index.ts
CHANGED
|
@@ -24,12 +24,14 @@ export function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Cosine similarity between two vectors in the range [-1, 1] (0 when either is
|
|
28
|
-
* zero vector). Compares over the shorter length when lengths differ. Vectors
|
|
27
|
+
* Cosine similarity between two vectors in the range [-1, 1] (0 when either is empty
|
|
28
|
+
* or a zero vector). Compares over the shorter length when lengths differ. Vectors
|
|
29
29
|
* from MambaSession.embed() are already L2-normalised — this reduces to a dot
|
|
30
|
-
* product — but we normalise defensively for vectors from other sources.
|
|
30
|
+
* product — but we normalise defensively for vectors from other sources. Takes any
|
|
31
|
+
* numeric array, so a `Float32Array` embedding and a JSON-decoded `number[]` (a
|
|
32
|
+
* pgvector row, a KV entry) go through the same function.
|
|
31
33
|
*/
|
|
32
|
-
export function cosineSimilarity(a:
|
|
34
|
+
export function cosineSimilarity(a: ArrayLike<number>, b: ArrayLike<number>): number {
|
|
33
35
|
const n = Math.min(a.length, b.length);
|
|
34
36
|
let dot = 0, na = 0, nb = 0;
|
|
35
37
|
for (let i = 0; i < n; i++) {
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenAI-compatible chat-completions client — ONE request builder, error mapping
|
|
3
|
+
* and stream reader for every caller that speaks `/chat/completions`: this package's
|
|
4
|
+
* `OpenAIBridge` / `FetchBridge`, and any host that reaches models through an
|
|
5
|
+
* OpenAI-compatible gateway (tools, streamed tool-call deltas, vendor extras).
|
|
6
|
+
*
|
|
7
|
+
* The body carries exactly what the request states — no sampling defaults are added
|
|
8
|
+
* here, so a routing gateway that picks the model and limits can be called with none.
|
|
9
|
+
* Callers that want defaults (the bridges) state them.
|
|
10
|
+
*
|
|
11
|
+
* Zero-dependency and engine-free (part of the `/wire` subpath).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readSseDataFrames } from './sse.js';
|
|
15
|
+
|
|
16
|
+
/** An OpenAI-compatible chat message. */
|
|
17
|
+
export interface ChatMessage {
|
|
18
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
19
|
+
/** Text, or an OpenAI multi-part array (text + `image_url`, …). */
|
|
20
|
+
content?: string | null | unknown[];
|
|
21
|
+
tool_calls?: unknown;
|
|
22
|
+
tool_call_id?: string;
|
|
23
|
+
[k: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An OpenAI-compatible function-tool schema. */
|
|
27
|
+
export interface ChatToolSchema {
|
|
28
|
+
type: 'function';
|
|
29
|
+
function: { name: string; description: string; parameters: Record<string, unknown> };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ChatRequest {
|
|
33
|
+
messages: ChatMessage[];
|
|
34
|
+
tools?: ChatToolSchema[];
|
|
35
|
+
model?: string;
|
|
36
|
+
maxTokens?: number;
|
|
37
|
+
temperature?: number;
|
|
38
|
+
topP?: number;
|
|
39
|
+
/**
|
|
40
|
+
* On a stream, ask for the closing usage block (`stream_options.include_usage`).
|
|
41
|
+
* Opt-in: not every OpenAI-compatible server accepts the field.
|
|
42
|
+
*/
|
|
43
|
+
includeUsage?: boolean;
|
|
44
|
+
/** Vendor extras merged into the body last (a routing `provider`, `response_format`, …). */
|
|
45
|
+
extra?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ChatToolCall {
|
|
49
|
+
id?: string;
|
|
50
|
+
type?: string;
|
|
51
|
+
function?: { name?: string; arguments?: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ChatResult {
|
|
55
|
+
/** The reply text ('' when there was none — e.g. a tool-call-only turn). */
|
|
56
|
+
content: string;
|
|
57
|
+
/** True when the server sent text content (a string), false when it sent none. */
|
|
58
|
+
hasTextContent: boolean;
|
|
59
|
+
toolCalls: ChatToolCall[];
|
|
60
|
+
finishReason?: string;
|
|
61
|
+
/** The model the server reports having used. */
|
|
62
|
+
model?: string;
|
|
63
|
+
/** The raw `usage` object, when the server sent one — read it with `readUsageFields`. */
|
|
64
|
+
usage?: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Incremental events of a streamed completion. */
|
|
68
|
+
export type ChatStreamEvent =
|
|
69
|
+
| { type: 'text-delta'; delta: string }
|
|
70
|
+
| { type: 'tool-call'; index: number; id?: string; name?: string; argsDelta?: string }
|
|
71
|
+
| { type: 'done'; result: ChatResult };
|
|
72
|
+
|
|
73
|
+
export interface ChatClient {
|
|
74
|
+
/** The OpenAI-style base, e.g. `https://api.openai.com/v1`; `/chat/completions` is appended. */
|
|
75
|
+
baseUrl: string;
|
|
76
|
+
apiKey: string;
|
|
77
|
+
/** Abort a request after this long. Default: no limit beyond the caller's signal. */
|
|
78
|
+
timeoutMs?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A non-2xx answer, with the status and (bounded) body kept apart for the caller's message. */
|
|
82
|
+
export class ChatCompletionError extends Error {
|
|
83
|
+
constructor(readonly status: number, readonly body: string) {
|
|
84
|
+
super(`chat completion failed with HTTP ${status}: ${body}`);
|
|
85
|
+
this.name = 'ChatCompletionError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ERROR_BODY_CHARS = 300;
|
|
90
|
+
|
|
91
|
+
function chatBody(req: ChatRequest, stream: boolean): string {
|
|
92
|
+
return JSON.stringify({
|
|
93
|
+
...(req.model ? { model: req.model } : {}),
|
|
94
|
+
messages: req.messages,
|
|
95
|
+
...(req.tools?.length ? { tools: req.tools, tool_choice: 'auto' } : {}),
|
|
96
|
+
...(typeof req.maxTokens === 'number' ? { max_tokens: req.maxTokens } : {}),
|
|
97
|
+
...(typeof req.temperature === 'number' ? { temperature: req.temperature } : {}),
|
|
98
|
+
...(typeof req.topP === 'number' ? { top_p: req.topP } : {}),
|
|
99
|
+
...(stream ? { stream: true } : {}),
|
|
100
|
+
// Without include_usage a streamed response carries no usage block at all.
|
|
101
|
+
...(stream && req.includeUsage ? { stream_options: { include_usage: true } } : {}),
|
|
102
|
+
...(req.extra ?? {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function post(client: ChatClient, req: ChatRequest, stream: boolean, signal?: AbortSignal): Promise<Response> {
|
|
107
|
+
const timeout = client.timeoutMs ? AbortSignal.timeout(client.timeoutMs) : undefined;
|
|
108
|
+
const res = await fetch(`${client.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: {
|
|
111
|
+
'Content-Type': 'application/json',
|
|
112
|
+
Authorization: `Bearer ${client.apiKey}`,
|
|
113
|
+
...(stream ? { Accept: 'text/event-stream' } : {}),
|
|
114
|
+
},
|
|
115
|
+
body: chatBody(req, stream),
|
|
116
|
+
signal: signal && timeout ? AbortSignal.any([signal, timeout]) : (signal ?? timeout),
|
|
117
|
+
});
|
|
118
|
+
if (!res.ok || (stream && !res.body)) {
|
|
119
|
+
const body = await res.text().catch(() => '');
|
|
120
|
+
throw new ChatCompletionError(res.status, body.slice(0, ERROR_BODY_CHARS));
|
|
121
|
+
}
|
|
122
|
+
return res;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One non-streamed completion. Throws {@link ChatCompletionError} on a non-2xx answer. */
|
|
126
|
+
export async function chatComplete(client: ChatClient, req: ChatRequest, signal?: AbortSignal): Promise<ChatResult> {
|
|
127
|
+
const res = await post(client, req, false, signal);
|
|
128
|
+
const json = (await res.json().catch(() => null)) as {
|
|
129
|
+
model?: unknown;
|
|
130
|
+
usage?: unknown;
|
|
131
|
+
choices?: Array<{ message?: { content?: unknown; tool_calls?: unknown }; finish_reason?: string }>;
|
|
132
|
+
} | null;
|
|
133
|
+
const choice = json?.choices?.[0];
|
|
134
|
+
const msg = choice?.message;
|
|
135
|
+
return {
|
|
136
|
+
content: typeof msg?.content === 'string' ? msg.content : '',
|
|
137
|
+
hasTextContent: typeof msg?.content === 'string',
|
|
138
|
+
toolCalls: Array.isArray(msg?.tool_calls) ? (msg.tool_calls as ChatToolCall[]) : [],
|
|
139
|
+
finishReason: choice?.finish_reason,
|
|
140
|
+
...(typeof json?.model === 'string' ? { model: json.model } : {}),
|
|
141
|
+
...(json?.usage !== undefined ? { usage: json.usage } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A streamed completion as events: text and tool-call deltas as they arrive, then one
|
|
147
|
+
* `done` carrying the assembled result (tool-call arguments concatenated per index).
|
|
148
|
+
*/
|
|
149
|
+
export async function* chatStream(client: ChatClient, req: ChatRequest, signal?: AbortSignal): AsyncGenerator<ChatStreamEvent> {
|
|
150
|
+
const res = await post(client, req, true, signal);
|
|
151
|
+
let content = '';
|
|
152
|
+
let finishReason: string | undefined;
|
|
153
|
+
let model: string | undefined;
|
|
154
|
+
let usage: unknown;
|
|
155
|
+
const calls: Array<{ id?: string; name?: string; args: string }> = [];
|
|
156
|
+
|
|
157
|
+
for await (const frame of readSseDataFrames(res.body!)) {
|
|
158
|
+
const chunk = frame as {
|
|
159
|
+
model?: unknown;
|
|
160
|
+
usage?: unknown;
|
|
161
|
+
choices?: Array<{
|
|
162
|
+
delta?: { content?: unknown; tool_calls?: Array<{ index?: number; id?: string; function?: { name?: string; arguments?: string } }> };
|
|
163
|
+
finish_reason?: string;
|
|
164
|
+
}>;
|
|
165
|
+
};
|
|
166
|
+
if (typeof chunk.model === 'string') model = chunk.model;
|
|
167
|
+
if (chunk.usage) usage = chunk.usage;
|
|
168
|
+
const choice = chunk.choices?.[0];
|
|
169
|
+
if (!choice) continue;
|
|
170
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
171
|
+
const delta = choice.delta;
|
|
172
|
+
if (typeof delta?.content === 'string' && delta.content) {
|
|
173
|
+
content += delta.content;
|
|
174
|
+
yield { type: 'text-delta', delta: delta.content };
|
|
175
|
+
}
|
|
176
|
+
for (const tc of Array.isArray(delta?.tool_calls) ? delta.tool_calls : []) {
|
|
177
|
+
const index = typeof tc.index === 'number' ? tc.index : 0;
|
|
178
|
+
const acc = (calls[index] ??= { args: '' });
|
|
179
|
+
if (tc.id) acc.id = tc.id;
|
|
180
|
+
if (tc.function?.name) acc.name = tc.function.name;
|
|
181
|
+
const argsDelta = tc.function?.arguments;
|
|
182
|
+
if (typeof argsDelta === 'string') acc.args += argsDelta;
|
|
183
|
+
yield { type: 'tool-call', index, id: tc.id, name: tc.function?.name, argsDelta };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const result: ChatResult = {
|
|
188
|
+
content,
|
|
189
|
+
hasTextContent: content !== '',
|
|
190
|
+
finishReason,
|
|
191
|
+
toolCalls: calls.filter(Boolean).map((t) => ({ id: t.id, type: 'function', function: { name: t.name, arguments: t.args } })),
|
|
192
|
+
...(model ? { model } : {}),
|
|
193
|
+
...(usage !== undefined ? { usage } : {}),
|
|
194
|
+
};
|
|
195
|
+
yield { type: 'done', result };
|
|
196
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM wire primitives — the protocol-level pieces every client of an
|
|
3
|
+
* OpenAI/Anthropic/Gemini-style HTTP API needs, in ONE place:
|
|
4
|
+
*
|
|
5
|
+
* • Server-Sent-Events `data:` framing — one line rule ({@link parseSseDataLine}),
|
|
6
|
+
* applied to a whole buffered body ({@link parseSseDataFrames}) or to a live
|
|
7
|
+
* byte stream ({@link readSseDataFrames});
|
|
8
|
+
* • token-usage field reading across vendor shapes ({@link readUsageFields}) and
|
|
9
|
+
* the Anthropic streaming usage fold ({@link AnthropicStreamUsage}).
|
|
10
|
+
*
|
|
11
|
+
* The package's bridges and the Builderforce gateway both frame and meter with
|
|
12
|
+
* these, so a fix to the line rule (a spaceless `data:{…}` frame, say) lands once.
|
|
13
|
+
* Zero-dependency and engine-free: the `@seanhogg/builderforce-memory/wire` subpath.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export { sseDataPayload, parseSseDataLine, parseSseDataFrames, readSseDataPayloads, readSseDataFrames, isSseDoneLine } from './sse.js';
|
|
17
|
+
export { finiteNumber, readUsageFields, AnthropicStreamUsage } from './usage.js';
|
|
18
|
+
export type { UsageFields, AnthropicUsageTotals } from './usage.js';
|
|
19
|
+
export { chatComplete, chatStream, ChatCompletionError } from './chatClient.js';
|
|
20
|
+
export type { ChatClient, ChatMessage, ChatToolSchema, ChatRequest, ChatToolCall, ChatResult, ChatStreamEvent } from './chatClient.js';
|