@aigne/afs-index 1.12.0-beta.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/LICENSE.md +26 -0
- package/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.cjs +11 -0
- package/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.mjs +10 -0
- package/dist/backend/d1-index-backend.cjs +656 -0
- package/dist/backend/d1-index-backend.d.cts +149 -0
- package/dist/backend/d1-index-backend.d.cts.map +1 -0
- package/dist/backend/d1-index-backend.d.mts +149 -0
- package/dist/backend/d1-index-backend.d.mts.map +1 -0
- package/dist/backend/d1-index-backend.mjs +657 -0
- package/dist/backend/d1-index-backend.mjs.map +1 -0
- package/dist/backend/d1-vector-backend.cjs +83 -0
- package/dist/backend/d1-vector-backend.d.cts +21 -0
- package/dist/backend/d1-vector-backend.d.cts.map +1 -0
- package/dist/backend/d1-vector-backend.d.mts +21 -0
- package/dist/backend/d1-vector-backend.d.mts.map +1 -0
- package/dist/backend/d1-vector-backend.mjs +84 -0
- package/dist/backend/d1-vector-backend.mjs.map +1 -0
- package/dist/backend/memory-backend.cjs +304 -0
- package/dist/backend/memory-backend.d.cts +66 -0
- package/dist/backend/memory-backend.d.cts.map +1 -0
- package/dist/backend/memory-backend.d.mts +66 -0
- package/dist/backend/memory-backend.d.mts.map +1 -0
- package/dist/backend/memory-backend.mjs +305 -0
- package/dist/backend/memory-backend.mjs.map +1 -0
- package/dist/backend/prefix-range.cjs +25 -0
- package/dist/backend/prefix-range.mjs +25 -0
- package/dist/backend/prefix-range.mjs.map +1 -0
- package/dist/backend/schema.cjs +187 -0
- package/dist/backend/schema.mjs +188 -0
- package/dist/backend/schema.mjs.map +1 -0
- package/dist/backend/sqlite-backend.cjs +889 -0
- package/dist/backend/sqlite-backend.d.cts +96 -0
- package/dist/backend/sqlite-backend.d.cts.map +1 -0
- package/dist/backend/sqlite-backend.d.mts +96 -0
- package/dist/backend/sqlite-backend.d.mts.map +1 -0
- package/dist/backend/sqlite-backend.mjs +890 -0
- package/dist/backend/sqlite-backend.mjs.map +1 -0
- package/dist/backend/types.d.cts +209 -0
- package/dist/backend/types.d.cts.map +1 -0
- package/dist/backend/types.d.mts +209 -0
- package/dist/backend/types.d.mts.map +1 -0
- package/dist/backend/upstash-vector-backend.cjs +98 -0
- package/dist/backend/upstash-vector-backend.d.cts +21 -0
- package/dist/backend/upstash-vector-backend.d.cts.map +1 -0
- package/dist/backend/upstash-vector-backend.d.mts +21 -0
- package/dist/backend/upstash-vector-backend.d.mts.map +1 -0
- package/dist/backend/upstash-vector-backend.mjs +98 -0
- package/dist/backend/upstash-vector-backend.mjs.map +1 -0
- package/dist/backend/vector-backend.d.cts +29 -0
- package/dist/backend/vector-backend.d.cts.map +1 -0
- package/dist/backend/vector-backend.d.mts +29 -0
- package/dist/backend/vector-backend.d.mts.map +1 -0
- package/dist/dirty-queue.d.cts +41 -0
- package/dist/dirty-queue.d.cts.map +1 -0
- package/dist/dirty-queue.d.mts +41 -0
- package/dist/dirty-queue.d.mts.map +1 -0
- package/dist/embed.cjs +45 -0
- package/dist/embed.d.cts +20 -0
- package/dist/embed.d.cts.map +1 -0
- package/dist/embed.d.mts +20 -0
- package/dist/embed.d.mts.map +1 -0
- package/dist/embed.mjs +46 -0
- package/dist/embed.mjs.map +1 -0
- package/dist/extraction/defaults.cjs +242 -0
- package/dist/extraction/defaults.d.cts +14 -0
- package/dist/extraction/defaults.d.cts.map +1 -0
- package/dist/extraction/defaults.d.mts +14 -0
- package/dist/extraction/defaults.d.mts.map +1 -0
- package/dist/extraction/defaults.mjs +242 -0
- package/dist/extraction/defaults.mjs.map +1 -0
- package/dist/extraction/field-map.cjs +162 -0
- package/dist/extraction/field-map.d.cts +70 -0
- package/dist/extraction/field-map.d.cts.map +1 -0
- package/dist/extraction/field-map.d.mts +70 -0
- package/dist/extraction/field-map.d.mts.map +1 -0
- package/dist/extraction/field-map.mjs +157 -0
- package/dist/extraction/field-map.mjs.map +1 -0
- package/dist/extraction/manager.cjs +74 -0
- package/dist/extraction/manager.d.cts +47 -0
- package/dist/extraction/manager.d.cts.map +1 -0
- package/dist/extraction/manager.d.mts +47 -0
- package/dist/extraction/manager.d.mts.map +1 -0
- package/dist/extraction/manager.mjs +74 -0
- package/dist/extraction/manager.mjs.map +1 -0
- package/dist/extraction/path-anchors.cjs +86 -0
- package/dist/extraction/path-anchors.d.cts +72 -0
- package/dist/extraction/path-anchors.d.cts.map +1 -0
- package/dist/extraction/path-anchors.d.mts +72 -0
- package/dist/extraction/path-anchors.d.mts.map +1 -0
- package/dist/extraction/path-anchors.mjs +86 -0
- package/dist/extraction/path-anchors.mjs.map +1 -0
- package/dist/follower-scheduler.cjs +92 -0
- package/dist/follower-scheduler.d.cts +58 -0
- package/dist/follower-scheduler.d.cts.map +1 -0
- package/dist/follower-scheduler.d.mts +58 -0
- package/dist/follower-scheduler.d.mts.map +1 -0
- package/dist/follower-scheduler.mjs +92 -0
- package/dist/follower-scheduler.mjs.map +1 -0
- package/dist/follower.cjs +577 -0
- package/dist/follower.d.cts +241 -0
- package/dist/follower.d.cts.map +1 -0
- package/dist/follower.d.mts +241 -0
- package/dist/follower.d.mts.map +1 -0
- package/dist/follower.mjs +571 -0
- package/dist/follower.mjs.map +1 -0
- package/dist/index-provider.cjs +1166 -0
- package/dist/index-provider.d.cts +175 -0
- package/dist/index-provider.d.cts.map +1 -0
- package/dist/index-provider.d.mts +175 -0
- package/dist/index-provider.d.mts.map +1 -0
- package/dist/index-provider.mjs +1167 -0
- package/dist/index-provider.mjs.map +1 -0
- package/dist/index.cjs +53 -0
- package/dist/index.d.cts +22 -0
- package/dist/index.d.mts +22 -0
- package/dist/index.mjs +20 -0
- package/dist/keys/exact.cjs +20 -0
- package/dist/keys/exact.mjs +20 -0
- package/dist/keys/exact.mjs.map +1 -0
- package/dist/keys/location.cjs +13 -0
- package/dist/keys/location.mjs +13 -0
- package/dist/keys/location.mjs.map +1 -0
- package/dist/keys/normalized.cjs +62 -0
- package/dist/keys/normalized.mjs +63 -0
- package/dist/keys/normalized.mjs.map +1 -0
- package/dist/keys/person.cjs +15 -0
- package/dist/keys/person.mjs +15 -0
- package/dist/keys/person.mjs.map +1 -0
- package/dist/keys/registry.cjs +45 -0
- package/dist/keys/registry.d.cts +15 -0
- package/dist/keys/registry.d.cts.map +1 -0
- package/dist/keys/registry.d.mts +15 -0
- package/dist/keys/registry.d.mts.map +1 -0
- package/dist/keys/registry.mjs +46 -0
- package/dist/keys/registry.mjs.map +1 -0
- package/dist/keys/time.cjs +128 -0
- package/dist/keys/time.mjs +128 -0
- package/dist/keys/time.mjs.map +1 -0
- package/dist/keys/topic.cjs +23 -0
- package/dist/keys/topic.mjs +23 -0
- package/dist/keys/topic.mjs.map +1 -0
- package/dist/manifest-index.cjs +29 -0
- package/dist/manifest-index.d.cts +24 -0
- package/dist/manifest-index.d.cts.map +1 -0
- package/dist/manifest-index.d.mts +24 -0
- package/dist/manifest-index.d.mts.map +1 -0
- package/dist/manifest-index.mjs +28 -0
- package/dist/manifest-index.mjs.map +1 -0
- package/dist/offline-rebuild.cjs +48 -0
- package/dist/offline-rebuild.d.cts +57 -0
- package/dist/offline-rebuild.d.cts.map +1 -0
- package/dist/offline-rebuild.d.mts +57 -0
- package/dist/offline-rebuild.d.mts.map +1 -0
- package/dist/offline-rebuild.mjs +49 -0
- package/dist/offline-rebuild.mjs.map +1 -0
- package/dist/query/engine.cjs +433 -0
- package/dist/query/engine.d.cts +34 -0
- package/dist/query/engine.d.cts.map +1 -0
- package/dist/query/engine.d.mts +34 -0
- package/dist/query/engine.d.mts.map +1 -0
- package/dist/query/engine.mjs +433 -0
- package/dist/query/engine.mjs.map +1 -0
- package/dist/query/limits.cjs +12 -0
- package/dist/query/limits.mjs +12 -0
- package/dist/query/limits.mjs.map +1 -0
- package/dist/text/cjk.cjs +30 -0
- package/dist/text/cjk.d.cts +24 -0
- package/dist/text/cjk.d.cts.map +1 -0
- package/dist/text/cjk.d.mts +24 -0
- package/dist/text/cjk.d.mts.map +1 -0
- package/dist/text/cjk.mjs +30 -0
- package/dist/text/cjk.mjs.map +1 -0
- package/dist/text/fts.cjs +244 -0
- package/dist/text/fts.mjs +245 -0
- package/dist/text/fts.mjs.map +1 -0
- package/dist/text/host.cjs +39 -0
- package/dist/text/host.mjs +38 -0
- package/dist/text/host.mjs.map +1 -0
- package/dist/types.cjs +44 -0
- package/dist/types.d.cts +393 -0
- package/dist/types.d.cts.map +1 -0
- package/dist/types.d.mts +393 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +42 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { MatchType, QueryResult } from "../types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/query/engine.d.ts
|
|
4
|
+
/** A ranked list from a single retrieval source, pre-sorted by relevance. */
|
|
5
|
+
interface RankedList {
|
|
6
|
+
source: MatchType;
|
|
7
|
+
entries: {
|
|
8
|
+
entryPath: string;
|
|
9
|
+
score: number;
|
|
10
|
+
}[];
|
|
11
|
+
/**
|
|
12
|
+
* Human-readable descriptor of what this list represents (e.g. the anchor
|
|
13
|
+
* filter or the FTS query text) — only built when `explain` is requested
|
|
14
|
+
* (issue #1069). Used to compose `QueryResult.explanation`.
|
|
15
|
+
*/
|
|
16
|
+
label?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Reciprocal Rank Fusion — merge multiple ranked lists into one.
|
|
20
|
+
*
|
|
21
|
+
* Each entry's score = Σ(1 / (k + rank + 1)) across all lists it appears in.
|
|
22
|
+
* Rank-based fusion naturally handles different score scales.
|
|
23
|
+
*
|
|
24
|
+
* @param lists - Ranked lists from different retrieval sources (anchor, FTS, embedding)
|
|
25
|
+
* @param k - Smoothing constant (default 60, standard in literature)
|
|
26
|
+
* @param explain - When true (issue #1069), decorate each result with a
|
|
27
|
+
* composite `explanation` listing every contributing source's rank/score
|
|
28
|
+
* and the final RRF math. Default false: identical output to before this
|
|
29
|
+
* flag existed (byte-for-byte — no `explanation` key at all).
|
|
30
|
+
*/
|
|
31
|
+
declare function reciprocalRankFusion(lists: RankedList[], k?: number, explain?: boolean): QueryResult[];
|
|
32
|
+
//#endregion
|
|
33
|
+
export { reciprocalRankFusion };
|
|
34
|
+
//# sourceMappingURL=engine.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.mts","names":[],"sources":["../../src/query/engine.ts"],"mappings":";;;;UAoHU,UAAA;EACR,MAAA,EAAQ,SAAA;EACR,OAAA;IAAW,SAAA;IAAmB,KAAA;EAAA;;;;;;EAM9B,KAAA;AAAA;;;;;;;;;;;;;;iBA4Bc,oBAAA,CAAqB,KAAA,EAAO,UAAA,IAAc,CAAA,WAAQ,OAAA,aAAkB,WAAA"}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { transformFtsQuery } from "../text/fts.mjs";
|
|
2
|
+
import { normalizeQueryLimit } from "./limits.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/query/engine.ts
|
|
5
|
+
const QUERY_CURSOR_VERSION = 1;
|
|
6
|
+
const MAX_QUERY_WINDOW = 2e3;
|
|
7
|
+
function stableJson(value) {
|
|
8
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
|
|
9
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
10
|
+
const obj = value;
|
|
11
|
+
return `{${Object.keys(obj).sort().filter((k) => obj[k] !== void 0).map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(",")}}`;
|
|
12
|
+
}
|
|
13
|
+
function hashString(input) {
|
|
14
|
+
let h = 2166136261;
|
|
15
|
+
for (let i = 0; i < input.length; i++) {
|
|
16
|
+
h ^= input.charCodeAt(i);
|
|
17
|
+
h = Math.imul(h, 16777619);
|
|
18
|
+
}
|
|
19
|
+
return (h >>> 0).toString(36);
|
|
20
|
+
}
|
|
21
|
+
function encodeCursor(payload) {
|
|
22
|
+
return `q1.${encodeURIComponent(JSON.stringify(payload))}`;
|
|
23
|
+
}
|
|
24
|
+
function decodeCursor(cursor) {
|
|
25
|
+
if (!cursor.startsWith("q1.")) return null;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(decodeURIComponent(cursor.slice(3)));
|
|
28
|
+
if (parsed.v !== QUERY_CURSOR_VERSION || typeof parsed.offset !== "number" || !Number.isFinite(parsed.offset) || parsed.offset < 0 || Math.floor(parsed.offset) !== parsed.offset || typeof parsed.qh !== "string" || !parsed.qh) return null;
|
|
29
|
+
return parsed;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Human-readable label for an anchor filter (issue #1069 debug info) — e.g.
|
|
36
|
+
* `person:[name:alice] & topic:[cooking]`. Static per query (queryAnchors ANDs
|
|
37
|
+
* across groups, ORs within a group's values — see `anchor_intersection`), so
|
|
38
|
+
* every entry the filter returns satisfies the same label; safe to reuse
|
|
39
|
+
* across all of a list's entries rather than recomputing per-entry.
|
|
40
|
+
*/
|
|
41
|
+
function describeAnchorFilter(keys) {
|
|
42
|
+
return keys.map((k) => `${k.type}:[${k.values.join(",")}]`).join(" & ");
|
|
43
|
+
}
|
|
44
|
+
/** Human-readable label for an FTS query (issue #1069 debug info). */
|
|
45
|
+
function describeFtsQuery(text) {
|
|
46
|
+
return `"${text}"`;
|
|
47
|
+
}
|
|
48
|
+
function formatContribution(c) {
|
|
49
|
+
return `${c.source}${c.label ? ` ${c.label}` : ""} rank=${c.rank} score=${c.rawScore.toFixed(3)}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Reciprocal Rank Fusion — merge multiple ranked lists into one.
|
|
53
|
+
*
|
|
54
|
+
* Each entry's score = Σ(1 / (k + rank + 1)) across all lists it appears in.
|
|
55
|
+
* Rank-based fusion naturally handles different score scales.
|
|
56
|
+
*
|
|
57
|
+
* @param lists - Ranked lists from different retrieval sources (anchor, FTS, embedding)
|
|
58
|
+
* @param k - Smoothing constant (default 60, standard in literature)
|
|
59
|
+
* @param explain - When true (issue #1069), decorate each result with a
|
|
60
|
+
* composite `explanation` listing every contributing source's rank/score
|
|
61
|
+
* and the final RRF math. Default false: identical output to before this
|
|
62
|
+
* flag existed (byte-for-byte — no `explanation` key at all).
|
|
63
|
+
*/
|
|
64
|
+
function reciprocalRankFusion(lists, k = 60, explain = false) {
|
|
65
|
+
const scores = /* @__PURE__ */ new Map();
|
|
66
|
+
for (const list of lists) for (let rank = 0; rank < list.entries.length; rank++) {
|
|
67
|
+
const entry = list.entries[rank];
|
|
68
|
+
const contribution = 1 / (k + rank + 1);
|
|
69
|
+
const existing = scores.get(entry.entryPath);
|
|
70
|
+
const rrfContribution = {
|
|
71
|
+
source: list.source,
|
|
72
|
+
rank,
|
|
73
|
+
rawScore: entry.score,
|
|
74
|
+
label: list.label
|
|
75
|
+
};
|
|
76
|
+
if (existing) {
|
|
77
|
+
existing.rrfScore += contribution;
|
|
78
|
+
existing.contributions.push(rrfContribution);
|
|
79
|
+
if (rank < existing.bestRank) {
|
|
80
|
+
existing.bestRank = rank;
|
|
81
|
+
existing.bestSource = list.source;
|
|
82
|
+
}
|
|
83
|
+
} else scores.set(entry.entryPath, {
|
|
84
|
+
entryPath: entry.entryPath,
|
|
85
|
+
rrfScore: contribution,
|
|
86
|
+
bestSource: list.source,
|
|
87
|
+
bestRank: rank,
|
|
88
|
+
contributions: [rrfContribution]
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return Array.from(scores.values()).sort((a, b) => b.rrfScore - a.rrfScore || a.entryPath.localeCompare(b.entryPath)).map(({ entryPath, rrfScore, bestSource, contributions }) => {
|
|
92
|
+
const result = {
|
|
93
|
+
entryPath,
|
|
94
|
+
matchType: bestSource,
|
|
95
|
+
score: rrfScore
|
|
96
|
+
};
|
|
97
|
+
if (explain) result.explanation = `${contributions.map(formatContribution).join("; ")}; RRF=${rrfScore.toFixed(4)}`;
|
|
98
|
+
return result;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Candidate pool size when `anchorFilter` is on (Epic #932 S2/S3 facet search).
|
|
103
|
+
* The anchor∩text intersection needs more than the final `limit` of text
|
|
104
|
+
* candidates so a facet-matching but lower-text-ranked entry isn't dropped
|
|
105
|
+
* before the filter. Sized for personal (aside-scale) data.
|
|
106
|
+
*/
|
|
107
|
+
const ANCHOR_FILTER_CANDIDATES = 500;
|
|
108
|
+
var QueryEngine = class {
|
|
109
|
+
constructor(backend) {
|
|
110
|
+
this.backend = backend;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the `boundDomains` fallback filter for a query: `undefined` when
|
|
114
|
+
* the caller passed an explicit `opts.scope` (their scope wins outright,
|
|
115
|
+
* never overridden — see `QueryInput.boundDomains`), otherwise the bound
|
|
116
|
+
* set (or `undefined` if none/empty, a strict no-op preserving today's
|
|
117
|
+
* "no domain → query everything" behavior for the global `/modules/index`
|
|
118
|
+
* mount and any other caller that never sets `boundDomains`).
|
|
119
|
+
*/
|
|
120
|
+
domainsFilter(input) {
|
|
121
|
+
if (input.opts?.scope) return void 0;
|
|
122
|
+
return input.boundDomains && input.boundDomains.length > 0 ? input.boundDomains : void 0;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Applies time-decay to a score based on entry's updatedAt timestamp.
|
|
126
|
+
* Formula: score * e^(-lambda * ageDays)
|
|
127
|
+
*/
|
|
128
|
+
async applyDecay(results, lambda) {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const decayed = [];
|
|
131
|
+
for (const r of results) {
|
|
132
|
+
const entry = await this.backend.getEntry(r.entryPath);
|
|
133
|
+
if (!entry) {
|
|
134
|
+
decayed.push(r);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const ageDays = (now - new Date(entry.updatedAt).getTime()) / 864e5;
|
|
138
|
+
decayed.push({
|
|
139
|
+
...r,
|
|
140
|
+
score: r.score * Math.exp(-lambda * ageDays)
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
decayed.sort((a, b) => b.score - a.score);
|
|
144
|
+
return decayed;
|
|
145
|
+
}
|
|
146
|
+
queryHash(input, limit) {
|
|
147
|
+
const opts = input.opts ?? {};
|
|
148
|
+
return hashString(stableJson({
|
|
149
|
+
mode: input.mode,
|
|
150
|
+
ftsMode: input.ftsMode,
|
|
151
|
+
anchors: input.anchors,
|
|
152
|
+
text: input.text,
|
|
153
|
+
embedding: input.embedding,
|
|
154
|
+
boundDomains: input.boundDomains,
|
|
155
|
+
opts: {
|
|
156
|
+
anchorFilter: opts.anchorFilter,
|
|
157
|
+
decay: opts.decay,
|
|
158
|
+
embeddingFloor: opts.embeddingFloor,
|
|
159
|
+
limit,
|
|
160
|
+
metaFilter: opts.metaFilter,
|
|
161
|
+
minConfidence: opts.minConfidence,
|
|
162
|
+
minResults: opts.minResults,
|
|
163
|
+
pathPrefix: opts.pathPrefix,
|
|
164
|
+
scope: opts.scope
|
|
165
|
+
}
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
async queryPage(input) {
|
|
169
|
+
const { mode } = input;
|
|
170
|
+
const limit = normalizeQueryLimit(input.opts?.limit);
|
|
171
|
+
if (limit <= 0) return { results: [] };
|
|
172
|
+
const qh = this.queryHash(input, limit);
|
|
173
|
+
const cursor = input.opts?.cursor;
|
|
174
|
+
const decoded = cursor ? decodeCursor(cursor) : null;
|
|
175
|
+
if (cursor && (!decoded || decoded.qh !== qh)) throw new Error("Invalid or stale query cursor");
|
|
176
|
+
const offset = decoded?.offset ?? 0;
|
|
177
|
+
const requestedWindow = offset + limit + 1;
|
|
178
|
+
const windowLimit = Math.min(requestedWindow, MAX_QUERY_WINDOW);
|
|
179
|
+
const truncated = requestedWindow > MAX_QUERY_WINDOW;
|
|
180
|
+
let results;
|
|
181
|
+
switch (mode) {
|
|
182
|
+
case "anchor":
|
|
183
|
+
results = await this.queryAnchor(input, windowLimit);
|
|
184
|
+
break;
|
|
185
|
+
case "fts":
|
|
186
|
+
results = await this.queryFTS(input, windowLimit);
|
|
187
|
+
break;
|
|
188
|
+
case "combined":
|
|
189
|
+
results = await this.queryCombined(input, windowLimit);
|
|
190
|
+
break;
|
|
191
|
+
case "semantic":
|
|
192
|
+
results = await this.querySemantic(input, windowLimit);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (input.opts?.decay && results.length > 0) results = await this.applyDecay(results, input.opts.decay.lambda);
|
|
196
|
+
if (input.opts?.explain && input.anchors?.length && results.length > 0) results = await this.attachAnchorDetails(results, input.anchors);
|
|
197
|
+
const page = results.slice(offset, offset + limit);
|
|
198
|
+
const hasMore = !truncated && results.length > offset + limit;
|
|
199
|
+
const meta = hasMore || truncated ? {
|
|
200
|
+
...hasMore ? { cursor: encodeCursor({
|
|
201
|
+
v: QUERY_CURSOR_VERSION,
|
|
202
|
+
offset: offset + limit,
|
|
203
|
+
qh
|
|
204
|
+
}) } : {},
|
|
205
|
+
hasMore,
|
|
206
|
+
...truncated ? { truncated: true } : {}
|
|
207
|
+
} : void 0;
|
|
208
|
+
return {
|
|
209
|
+
results: page,
|
|
210
|
+
...meta ? { meta } : {}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
async query(input) {
|
|
214
|
+
return (await this.queryPage(input)).results;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* For each result whose `matchType === "anchor"`, fetch the entry's stored
|
|
218
|
+
* anchors and filter to the types the query actually asked for — the
|
|
219
|
+
* concrete "why was this record matched" evidence issue #1069 wants.
|
|
220
|
+
* `queryAnchors`'s `anchor_intersection` ANDs across groups (see
|
|
221
|
+
* `sqlite-backend.ts`), so any entry in the anchor list satisfied every
|
|
222
|
+
* requested type; we don't re-verify individual values here (that would
|
|
223
|
+
* require re-deriving generated keys, which live in `AFSIndex`'s
|
|
224
|
+
* `KeyGeneratorRegistry`, not the engine) — we report the entry's own
|
|
225
|
+
* anchors of the requested type(s) as the matched evidence.
|
|
226
|
+
*/
|
|
227
|
+
async attachAnchorDetails(results, anchors) {
|
|
228
|
+
const types = new Set(anchors.map((k) => k.type));
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const r of results) {
|
|
231
|
+
if (r.matchType !== "anchor") {
|
|
232
|
+
out.push(r);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const matched = (await this.backend.getAnchorData(r.entryPath)).filter((a) => types.has(a.anchorType));
|
|
236
|
+
out.push({
|
|
237
|
+
...r,
|
|
238
|
+
anchors: matched
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
async queryAnchor(input, limit) {
|
|
244
|
+
if (!input.anchors || input.anchors.length === 0) return [];
|
|
245
|
+
const results = await this.backend.queryAnchors({
|
|
246
|
+
keys: input.anchors,
|
|
247
|
+
scope: input.opts?.scope,
|
|
248
|
+
pathPrefix: input.opts?.pathPrefix,
|
|
249
|
+
domains: this.domainsFilter(input),
|
|
250
|
+
minConfidence: input.opts?.minConfidence,
|
|
251
|
+
limit,
|
|
252
|
+
metaFilter: input.opts?.metaFilter
|
|
253
|
+
});
|
|
254
|
+
const explain = input.opts?.explain === true;
|
|
255
|
+
const label = explain ? describeAnchorFilter(input.anchors) : void 0;
|
|
256
|
+
return results.map((r) => ({
|
|
257
|
+
entryPath: r.entryPath,
|
|
258
|
+
matchType: "anchor",
|
|
259
|
+
score: r.score,
|
|
260
|
+
...explain ? { explanation: `anchor ${label} score=${r.score.toFixed(3)}` } : {}
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
async queryFTS(input, limit) {
|
|
264
|
+
if (!input.text) return [];
|
|
265
|
+
const transformed = transformFtsQuery(input.text, input.ftsMode);
|
|
266
|
+
if (!transformed) return [];
|
|
267
|
+
const scope = input.opts?.scope;
|
|
268
|
+
const domain = scope ? scope.replace(/^\/+/, "") : void 0;
|
|
269
|
+
const domains = this.domainsFilter(input);
|
|
270
|
+
const pathPrefix = input.opts?.pathPrefix;
|
|
271
|
+
const results = await this.backend.queryFTS(transformed, limit, input.opts?.metaFilter, {
|
|
272
|
+
...domain ? { domain } : {},
|
|
273
|
+
...scope ? { scope } : {},
|
|
274
|
+
...domains ? { domains } : {},
|
|
275
|
+
...pathPrefix ? { pathPrefix } : {}
|
|
276
|
+
});
|
|
277
|
+
const explain = input.opts?.explain === true;
|
|
278
|
+
const label = explain ? describeFtsQuery(transformed) : void 0;
|
|
279
|
+
return results.map((r) => ({
|
|
280
|
+
entryPath: r.entryPath,
|
|
281
|
+
matchType: "fts",
|
|
282
|
+
score: r.rank,
|
|
283
|
+
...explain ? { explanation: `fts ${label} rank=${r.rank.toFixed(3)}` } : {}
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
/** Collect anchor + FTS ranked lists from backend. */
|
|
287
|
+
async collectLists(input, limit) {
|
|
288
|
+
const lists = [];
|
|
289
|
+
if (input.anchors?.length) {
|
|
290
|
+
const results = await this.backend.queryAnchors({
|
|
291
|
+
keys: input.anchors,
|
|
292
|
+
scope: input.opts?.scope,
|
|
293
|
+
pathPrefix: input.opts?.pathPrefix,
|
|
294
|
+
domains: this.domainsFilter(input),
|
|
295
|
+
minConfidence: input.opts?.minConfidence,
|
|
296
|
+
limit,
|
|
297
|
+
metaFilter: input.opts?.metaFilter
|
|
298
|
+
});
|
|
299
|
+
if (results.length > 0) {
|
|
300
|
+
const explain = input.opts?.explain === true;
|
|
301
|
+
lists.push({
|
|
302
|
+
source: "anchor",
|
|
303
|
+
entries: results.map((r) => ({
|
|
304
|
+
entryPath: r.entryPath,
|
|
305
|
+
score: r.score
|
|
306
|
+
})),
|
|
307
|
+
...explain ? { label: describeAnchorFilter(input.anchors) } : {}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (input.text) {
|
|
312
|
+
const transformedText = transformFtsQuery(input.text, input.ftsMode);
|
|
313
|
+
if (transformedText) {
|
|
314
|
+
const scope = input.opts?.scope;
|
|
315
|
+
const domain = scope ? scope.replace(/^\/+/, "") : void 0;
|
|
316
|
+
const domains = this.domainsFilter(input);
|
|
317
|
+
const pathPrefix = input.opts?.pathPrefix;
|
|
318
|
+
const results = await this.backend.queryFTS(transformedText, limit, input.opts?.metaFilter, {
|
|
319
|
+
...domain ? { domain } : {},
|
|
320
|
+
...scope ? { scope } : {},
|
|
321
|
+
...domains ? { domains } : {},
|
|
322
|
+
...pathPrefix ? { pathPrefix } : {}
|
|
323
|
+
});
|
|
324
|
+
if (results.length > 0) {
|
|
325
|
+
const explain = input.opts?.explain === true;
|
|
326
|
+
lists.push({
|
|
327
|
+
source: "fts",
|
|
328
|
+
entries: results.map((r) => ({
|
|
329
|
+
entryPath: r.entryPath,
|
|
330
|
+
score: r.rank
|
|
331
|
+
})),
|
|
332
|
+
...explain ? { label: describeFtsQuery(transformedText) } : {}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return lists;
|
|
338
|
+
}
|
|
339
|
+
async queryCombined(input, limit) {
|
|
340
|
+
const anchorFilter = input.opts?.anchorFilter ?? false;
|
|
341
|
+
const lists = await this.collectLists(input, this.candidateLimit(limit, anchorFilter));
|
|
342
|
+
return this.fuseOrFilter(lists, limit, anchorFilter, input.opts?.explain === true);
|
|
343
|
+
}
|
|
344
|
+
async querySemantic(input, limit) {
|
|
345
|
+
const anchorFilter = input.opts?.anchorFilter ?? false;
|
|
346
|
+
const explain = input.opts?.explain === true;
|
|
347
|
+
const candidates = this.candidateLimit(limit, anchorFilter);
|
|
348
|
+
const lists = await this.collectLists(input, candidates);
|
|
349
|
+
if (input.embedding && this.backend.queryVector) {
|
|
350
|
+
const scope = input.opts?.scope;
|
|
351
|
+
const domain = scope ? scope.replace(/^\/+/, "") : void 0;
|
|
352
|
+
const domains = this.domainsFilter(input);
|
|
353
|
+
const pathPrefix = input.opts?.pathPrefix;
|
|
354
|
+
const vectorResults = await this.backend.queryVector(input.embedding, candidates, {
|
|
355
|
+
...domain ? { domain } : {},
|
|
356
|
+
...scope ? { scope } : {},
|
|
357
|
+
...domains ? { domains } : {},
|
|
358
|
+
...pathPrefix ? { pathPrefix } : {}
|
|
359
|
+
});
|
|
360
|
+
if (vectorResults.length > 0) {
|
|
361
|
+
let entries = vectorResults.map((r) => ({
|
|
362
|
+
entryPath: r.entryPath,
|
|
363
|
+
score: r.similarity
|
|
364
|
+
}));
|
|
365
|
+
const floor = input.opts?.embeddingFloor;
|
|
366
|
+
if (floor != null) {
|
|
367
|
+
const explicit = /* @__PURE__ */ new Set();
|
|
368
|
+
for (const l of lists) for (const e of l.entries) explicit.add(e.entryPath);
|
|
369
|
+
entries = entries.filter((e) => explicit.has(e.entryPath) || e.score >= floor);
|
|
370
|
+
}
|
|
371
|
+
if (entries.length > 0) lists.push({
|
|
372
|
+
source: "embedding",
|
|
373
|
+
entries,
|
|
374
|
+
...explain ? { label: "cosine" } : {}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return this.fuseOrFilter(lists, limit, anchorFilter, explain);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Over-fetch candidates when anchor-filtering so the anchor∩text intersection
|
|
382
|
+
* has enough to work with — an anchor-matching, text-relevant entry ranked
|
|
383
|
+
* beyond `limit` would otherwise be dropped before the filter runs. (For
|
|
384
|
+
* aside-scale personal data this window comfortably covers a tag/collection's
|
|
385
|
+
* items; a truly-huge facet set could still clip the long tail — a
|
|
386
|
+
* backend-level `WHERE entry IN (facet-set)` is the follow-up for that.)
|
|
387
|
+
*/
|
|
388
|
+
candidateLimit(limit, anchorFilter) {
|
|
389
|
+
return anchorFilter ? Math.max(limit, ANCHOR_FILTER_CANDIDATES) : limit;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Combine ranked lists into the final result. Default = RRF fusion (union of
|
|
393
|
+
* every source). When `anchorFilter` is on AND an anchor list is present, the
|
|
394
|
+
* anchors are a HARD FILTER instead (Epic #932 S2/S3 facet search): with text
|
|
395
|
+
* → the text ranking intersected with the anchor-matching set; facet-only (no
|
|
396
|
+
* text) → the anchor set itself. No anchor list (no anchors / none matched) →
|
|
397
|
+
* plain text-only, so `anchorFilter` never turns a valid text search empty.
|
|
398
|
+
*/
|
|
399
|
+
fuseOrFilter(lists, limit, anchorFilter, explain = false) {
|
|
400
|
+
if (lists.length === 0) return [];
|
|
401
|
+
const flatten = (l) => l.entries.map((e, rank) => ({
|
|
402
|
+
entryPath: e.entryPath,
|
|
403
|
+
matchType: l.source,
|
|
404
|
+
score: e.score,
|
|
405
|
+
...explain ? { explanation: formatContribution({
|
|
406
|
+
source: l.source,
|
|
407
|
+
rank,
|
|
408
|
+
rawScore: e.score,
|
|
409
|
+
label: l.label
|
|
410
|
+
}) } : {}
|
|
411
|
+
}));
|
|
412
|
+
if (anchorFilter) {
|
|
413
|
+
const anchorList = lists.find((l) => l.source === "anchor");
|
|
414
|
+
const textLists = lists.filter((l) => l.source !== "anchor");
|
|
415
|
+
if (anchorList) {
|
|
416
|
+
const allowed = new Set(anchorList.entries.map((e) => e.entryPath));
|
|
417
|
+
if (textLists.length === 0) return flatten(anchorList).slice(0, limit);
|
|
418
|
+
const filtered = (textLists.length === 1 ? flatten(textLists[0]) : reciprocalRankFusion(textLists, 60, explain)).filter((r) => allowed.has(r.entryPath)).slice(0, limit);
|
|
419
|
+
if (explain && anchorList.label) return filtered.map((r) => ({
|
|
420
|
+
...r,
|
|
421
|
+
explanation: `${r.explanation ?? ""}; hard-filtered by anchor ${anchorList.label}`.replace(/^; /, "")
|
|
422
|
+
}));
|
|
423
|
+
return filtered;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (lists.length === 1) return flatten(lists[0]).slice(0, limit);
|
|
427
|
+
return reciprocalRankFusion(lists, 60, explain).slice(0, limit);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
//#endregion
|
|
432
|
+
export { QueryEngine, reciprocalRankFusion };
|
|
433
|
+
//# sourceMappingURL=engine.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.mjs","names":[],"sources":["../../src/query/engine.ts"],"sourcesContent":["/**\n * QueryEngine — orchestrates anchor, FTS, combined, and semantic queries.\n *\n * Modes:\n * - \"anchor\": Pure anchor-based query\n * - \"fts\": Pure full-text search\n * - \"combined\": RRF fusion of anchor + FTS ranked lists\n * - \"semantic\": RRF fusion + embedding fallback if results insufficient\n *\n * Combined/semantic modes use Reciprocal Rank Fusion (RRF) to merge results\n * from different retrieval signals. RRF is rank-based (not score-based), so\n * it naturally handles signals with different scales (anchor 0-1, FTS 0-25+).\n */\nimport type { IndexBackend } from \"../backend/types.js\";\nimport { transformFtsQuery } from \"../text/fts.js\";\nimport type {\n AnchorData,\n AnchorQuery,\n MatchType,\n QueryOpts,\n QueryPage,\n QueryResult,\n SearchMode,\n} from \"../types.js\";\nimport { normalizeQueryLimit } from \"./limits.js\";\n\nconst QUERY_CURSOR_VERSION = 1;\nexport const MAX_QUERY_WINDOW = 2000;\n\ntype QueryCursorPayload = {\n v: typeof QUERY_CURSOR_VERSION;\n offset: number;\n qh: string;\n};\n\nfunction stableJson(value: unknown): string {\n if (value === null || typeof value !== \"object\") return JSON.stringify(value) ?? \"undefined\";\n if (Array.isArray(value)) return `[${value.map(stableJson).join(\",\")}]`;\n const obj = value as Record<string, unknown>;\n return `{${Object.keys(obj)\n .sort()\n .filter((k) => obj[k] !== undefined)\n .map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)\n .join(\",\")}}`;\n}\n\nfunction hashString(input: string): string {\n let h = 0x811c9dc5;\n for (let i = 0; i < input.length; i++) {\n h ^= input.charCodeAt(i);\n h = Math.imul(h, 0x01000193);\n }\n return (h >>> 0).toString(36);\n}\n\nfunction encodeCursor(payload: QueryCursorPayload): string {\n return `q1.${encodeURIComponent(JSON.stringify(payload))}`;\n}\n\nfunction decodeCursor(cursor: string): QueryCursorPayload | null {\n if (!cursor.startsWith(\"q1.\")) return null;\n try {\n const parsed = JSON.parse(decodeURIComponent(cursor.slice(3))) as Partial<QueryCursorPayload>;\n if (\n parsed.v !== QUERY_CURSOR_VERSION ||\n typeof parsed.offset !== \"number\" ||\n !Number.isFinite(parsed.offset) ||\n parsed.offset < 0 ||\n Math.floor(parsed.offset) !== parsed.offset ||\n typeof parsed.qh !== \"string\" ||\n !parsed.qh\n ) {\n return null;\n }\n return parsed as QueryCursorPayload;\n } catch {\n return null;\n }\n}\n\n/**\n * Human-readable label for an anchor filter (issue #1069 debug info) — e.g.\n * `person:[name:alice] & topic:[cooking]`. Static per query (queryAnchors ANDs\n * across groups, ORs within a group's values — see `anchor_intersection`), so\n * every entry the filter returns satisfies the same label; safe to reuse\n * across all of a list's entries rather than recomputing per-entry.\n */\nfunction describeAnchorFilter(keys: AnchorQuery[\"keys\"]): string {\n return keys.map((k) => `${k.type}:[${k.values.join(\",\")}]`).join(\" & \");\n}\n\n/** Human-readable label for an FTS query (issue #1069 debug info). */\nfunction describeFtsQuery(text: string): string {\n return `\"${text}\"`;\n}\n\nexport interface QueryInput {\n mode: SearchMode;\n /** FTS token-joining semantics. Default \"and\" (precision). Use \"or\" for recall/similarity search. */\n ftsMode?: \"and\" | \"or\";\n anchors?: AnchorQuery[\"keys\"];\n text?: string;\n embedding?: number[];\n opts?: QueryOpts;\n /**\n * Provider-level default domain filter (from `AFSIndexOptions.boundDomains`)\n * — NEVER set from client-facing `.actions/query` args, only threaded\n * through by `AFSIndex.execQuery`/`queryWithMultiQuery`. Applied by\n * `domainsFilter()` below ONLY when the caller didn't pass an explicit\n * `opts.scope`; an explicit scope always wins outright (issue #936 P4 leak\n * fix — see `AFSIndexOptions.boundDomains` for the full rationale).\n */\n boundDomains?: string[];\n}\n\n/** A ranked list from a single retrieval source, pre-sorted by relevance. */\ninterface RankedList {\n source: MatchType;\n entries: { entryPath: string; score: number }[];\n /**\n * Human-readable descriptor of what this list represents (e.g. the anchor\n * filter or the FTS query text) — only built when `explain` is requested\n * (issue #1069). Used to compose `QueryResult.explanation`.\n */\n label?: string;\n}\n\n/** One list's contribution to a fused entry — the raw material for `explanation`. */\ninterface RRFContribution {\n source: MatchType;\n rank: number;\n rawScore: number;\n label?: string;\n}\n\nfunction formatContribution(c: RRFContribution): string {\n return `${c.source}${c.label ? ` ${c.label}` : \"\"} rank=${c.rank} score=${c.rawScore.toFixed(3)}`;\n}\n\n/**\n * Reciprocal Rank Fusion — merge multiple ranked lists into one.\n *\n * Each entry's score = Σ(1 / (k + rank + 1)) across all lists it appears in.\n * Rank-based fusion naturally handles different score scales.\n *\n * @param lists - Ranked lists from different retrieval sources (anchor, FTS, embedding)\n * @param k - Smoothing constant (default 60, standard in literature)\n * @param explain - When true (issue #1069), decorate each result with a\n * composite `explanation` listing every contributing source's rank/score\n * and the final RRF math. Default false: identical output to before this\n * flag existed (byte-for-byte — no `explanation` key at all).\n */\nexport function reciprocalRankFusion(lists: RankedList[], k = 60, explain = false): QueryResult[] {\n const scores = new Map<\n string,\n {\n entryPath: string;\n rrfScore: number;\n bestSource: MatchType;\n bestRank: number;\n contributions: RRFContribution[];\n }\n >();\n\n for (const list of lists) {\n for (let rank = 0; rank < list.entries.length; rank++) {\n const entry = list.entries[rank]!;\n const contribution = 1 / (k + rank + 1);\n const existing = scores.get(entry.entryPath);\n const rrfContribution: RRFContribution = {\n source: list.source,\n rank,\n rawScore: entry.score,\n label: list.label,\n };\n\n if (existing) {\n existing.rrfScore += contribution;\n existing.contributions.push(rrfContribution);\n if (rank < existing.bestRank) {\n existing.bestRank = rank;\n existing.bestSource = list.source;\n }\n } else {\n scores.set(entry.entryPath, {\n entryPath: entry.entryPath,\n rrfScore: contribution,\n bestSource: list.source,\n bestRank: rank,\n contributions: [rrfContribution],\n });\n }\n }\n }\n\n return Array.from(scores.values())\n .sort((a, b) => b.rrfScore - a.rrfScore || a.entryPath.localeCompare(b.entryPath))\n .map(({ entryPath, rrfScore, bestSource, contributions }) => {\n const result: QueryResult = {\n entryPath,\n matchType: bestSource,\n score: rrfScore,\n };\n if (explain) {\n const parts = contributions.map(formatContribution);\n result.explanation = `${parts.join(\"; \")}; RRF=${rrfScore.toFixed(4)}`;\n }\n return result;\n });\n}\n\n/**\n * Candidate pool size when `anchorFilter` is on (Epic #932 S2/S3 facet search).\n * The anchor∩text intersection needs more than the final `limit` of text\n * candidates so a facet-matching but lower-text-ranked entry isn't dropped\n * before the filter. Sized for personal (aside-scale) data.\n */\nconst ANCHOR_FILTER_CANDIDATES = 500;\n\nexport class QueryEngine {\n constructor(private readonly backend: IndexBackend) {}\n\n /**\n * Resolve the `boundDomains` fallback filter for a query: `undefined` when\n * the caller passed an explicit `opts.scope` (their scope wins outright,\n * never overridden — see `QueryInput.boundDomains`), otherwise the bound\n * set (or `undefined` if none/empty, a strict no-op preserving today's\n * \"no domain → query everything\" behavior for the global `/modules/index`\n * mount and any other caller that never sets `boundDomains`).\n */\n private domainsFilter(input: QueryInput): string[] | undefined {\n if (input.opts?.scope) return undefined;\n return input.boundDomains && input.boundDomains.length > 0 ? input.boundDomains : undefined;\n }\n\n /**\n * Applies time-decay to a score based on entry's updatedAt timestamp.\n * Formula: score * e^(-lambda * ageDays)\n */\n private async applyDecay(results: QueryResult[], lambda: number): Promise<QueryResult[]> {\n const now = Date.now();\n const decayed: QueryResult[] = [];\n for (const r of results) {\n const entry = await this.backend.getEntry(r.entryPath);\n if (!entry) {\n decayed.push(r);\n continue;\n }\n const ageDays = (now - new Date(entry.updatedAt).getTime()) / 86_400_000;\n decayed.push({ ...r, score: r.score * Math.exp(-lambda * ageDays) });\n }\n decayed.sort((a, b) => b.score - a.score);\n return decayed;\n }\n\n private queryHash(input: QueryInput, limit: number): string {\n const opts = input.opts ?? {};\n return hashString(\n stableJson({\n mode: input.mode,\n ftsMode: input.ftsMode,\n anchors: input.anchors,\n text: input.text,\n embedding: input.embedding,\n boundDomains: input.boundDomains,\n opts: {\n anchorFilter: opts.anchorFilter,\n decay: opts.decay,\n embeddingFloor: opts.embeddingFloor,\n limit,\n metaFilter: opts.metaFilter,\n minConfidence: opts.minConfidence,\n minResults: opts.minResults,\n pathPrefix: opts.pathPrefix,\n scope: opts.scope,\n },\n }),\n );\n }\n\n async queryPage(input: QueryInput): Promise<QueryPage> {\n const { mode } = input;\n const limit = normalizeQueryLimit(input.opts?.limit);\n if (limit <= 0) return { results: [] };\n const qh = this.queryHash(input, limit);\n const cursor = input.opts?.cursor;\n const decoded = cursor ? decodeCursor(cursor) : null;\n if (cursor && (!decoded || decoded.qh !== qh)) {\n throw new Error(\"Invalid or stale query cursor\");\n }\n const offset = decoded?.offset ?? 0;\n const requestedWindow = offset + limit + 1;\n const windowLimit = Math.min(requestedWindow, MAX_QUERY_WINDOW);\n const truncated = requestedWindow > MAX_QUERY_WINDOW;\n\n let results: QueryResult[];\n switch (mode) {\n case \"anchor\":\n results = await this.queryAnchor(input, windowLimit);\n break;\n case \"fts\":\n results = await this.queryFTS(input, windowLimit);\n break;\n case \"combined\":\n results = await this.queryCombined(input, windowLimit);\n break;\n case \"semantic\":\n results = await this.querySemantic(input, windowLimit);\n break;\n }\n\n if (input.opts?.decay && results.length > 0) {\n results = await this.applyDecay(results, input.opts.decay.lambda);\n }\n\n // Issue #1069 debug info: attach the entry's actual matched anchors for\n // anchor-mode-winning results. Centralized here (rather than duplicated in\n // queryAnchor/queryCombined/querySemantic) since any mode can produce a\n // \"anchor\" bestSource result once RRF fusion is involved. Opt-in only —\n // adds one `getAnchorData` call per anchor result, paid only when\n // `explain` is requested.\n if (input.opts?.explain && input.anchors?.length && results.length > 0) {\n results = await this.attachAnchorDetails(results, input.anchors);\n }\n\n const page = results.slice(offset, offset + limit);\n const hasMore = !truncated && results.length > offset + limit;\n const meta =\n hasMore || truncated\n ? {\n ...(hasMore\n ? { cursor: encodeCursor({ v: QUERY_CURSOR_VERSION, offset: offset + limit, qh }) }\n : {}),\n hasMore,\n ...(truncated ? { truncated: true } : {}),\n }\n : undefined;\n return { results: page, ...(meta ? { meta } : {}) };\n }\n\n async query(input: QueryInput): Promise<QueryResult[]> {\n return (await this.queryPage(input)).results;\n }\n\n /**\n * For each result whose `matchType === \"anchor\"`, fetch the entry's stored\n * anchors and filter to the types the query actually asked for — the\n * concrete \"why was this record matched\" evidence issue #1069 wants.\n * `queryAnchors`'s `anchor_intersection` ANDs across groups (see\n * `sqlite-backend.ts`), so any entry in the anchor list satisfied every\n * requested type; we don't re-verify individual values here (that would\n * require re-deriving generated keys, which live in `AFSIndex`'s\n * `KeyGeneratorRegistry`, not the engine) — we report the entry's own\n * anchors of the requested type(s) as the matched evidence.\n */\n private async attachAnchorDetails(\n results: QueryResult[],\n anchors: AnchorQuery[\"keys\"],\n ): Promise<QueryResult[]> {\n const types = new Set(anchors.map((k) => k.type));\n const out: QueryResult[] = [];\n for (const r of results) {\n if (r.matchType !== \"anchor\") {\n out.push(r);\n continue;\n }\n const all = await this.backend.getAnchorData(r.entryPath);\n const matched: AnchorData[] = all.filter((a) => types.has(a.anchorType));\n out.push({ ...r, anchors: matched });\n }\n return out;\n }\n\n private async queryAnchor(input: QueryInput, limit: number): Promise<QueryResult[]> {\n if (!input.anchors || input.anchors.length === 0) return [];\n\n const results = await this.backend.queryAnchors({\n keys: input.anchors,\n scope: input.opts?.scope,\n pathPrefix: input.opts?.pathPrefix,\n domains: this.domainsFilter(input),\n minConfidence: input.opts?.minConfidence,\n limit,\n metaFilter: input.opts?.metaFilter,\n });\n\n const explain = input.opts?.explain === true;\n const label = explain ? describeAnchorFilter(input.anchors) : undefined;\n return results.map((r) => ({\n entryPath: r.entryPath,\n matchType: \"anchor\" as const,\n score: r.score,\n ...(explain ? { explanation: `anchor ${label} score=${r.score.toFixed(3)}` } : {}),\n }));\n }\n\n private async queryFTS(input: QueryInput, limit: number): Promise<QueryResult[]> {\n if (!input.text) return [];\n\n const transformed = transformFtsQuery(input.text, input.ftsMode);\n if (!transformed) return [];\n\n const scope = input.opts?.scope;\n const domain = scope ? scope.replace(/^\\/+/, \"\") : undefined;\n const domains = this.domainsFilter(input);\n const pathPrefix = input.opts?.pathPrefix;\n const results = await this.backend.queryFTS(transformed, limit, input.opts?.metaFilter, {\n ...(domain ? { domain } : {}),\n ...(scope ? { scope } : {}),\n ...(domains ? { domains } : {}),\n ...(pathPrefix ? { pathPrefix } : {}),\n });\n\n const explain = input.opts?.explain === true;\n const label = explain ? describeFtsQuery(transformed) : undefined;\n return results.map((r) => ({\n entryPath: r.entryPath,\n matchType: \"fts\" as const,\n score: r.rank,\n ...(explain ? { explanation: `fts ${label} rank=${r.rank.toFixed(3)}` } : {}),\n }));\n }\n\n /** Collect anchor + FTS ranked lists from backend. */\n private async collectLists(input: QueryInput, limit: number): Promise<RankedList[]> {\n const lists: RankedList[] = [];\n\n if (input.anchors?.length) {\n const results = await this.backend.queryAnchors({\n keys: input.anchors,\n scope: input.opts?.scope,\n pathPrefix: input.opts?.pathPrefix,\n domains: this.domainsFilter(input),\n minConfidence: input.opts?.minConfidence,\n limit,\n metaFilter: input.opts?.metaFilter,\n });\n if (results.length > 0) {\n const explain = input.opts?.explain === true;\n lists.push({\n source: \"anchor\",\n entries: results.map((r) => ({ entryPath: r.entryPath, score: r.score })),\n ...(explain ? { label: describeAnchorFilter(input.anchors) } : {}),\n });\n }\n }\n\n if (input.text) {\n const transformedText = transformFtsQuery(input.text, input.ftsMode);\n if (transformedText) {\n const scope = input.opts?.scope;\n const domain = scope ? scope.replace(/^\\/+/, \"\") : undefined;\n const domains = this.domainsFilter(input);\n const pathPrefix = input.opts?.pathPrefix;\n const results = await this.backend.queryFTS(\n transformedText,\n limit,\n input.opts?.metaFilter,\n {\n ...(domain ? { domain } : {}),\n ...(scope ? { scope } : {}),\n ...(domains ? { domains } : {}),\n ...(pathPrefix ? { pathPrefix } : {}),\n },\n );\n if (results.length > 0) {\n const explain = input.opts?.explain === true;\n lists.push({\n source: \"fts\",\n entries: results.map((r) => ({ entryPath: r.entryPath, score: r.rank })),\n ...(explain ? { label: describeFtsQuery(transformedText) } : {}),\n });\n }\n }\n }\n\n return lists;\n }\n\n private async queryCombined(input: QueryInput, limit: number): Promise<QueryResult[]> {\n const anchorFilter = input.opts?.anchorFilter ?? false;\n const lists = await this.collectLists(input, this.candidateLimit(limit, anchorFilter));\n return this.fuseOrFilter(lists, limit, anchorFilter, input.opts?.explain === true);\n }\n\n private async querySemantic(input: QueryInput, limit: number): Promise<QueryResult[]> {\n const anchorFilter = input.opts?.anchorFilter ?? false;\n const explain = input.opts?.explain === true;\n const candidates = this.candidateLimit(limit, anchorFilter);\n const lists = await this.collectLists(input, candidates);\n\n // Always include embedding as a parallel signal (fusion) / candidate pool\n // (anchor-filter). Pass through the caller's scope/domain so vector results\n // stay in the same namespace the FTS / anchor lists already are; without\n // this, semantic mode scored across-domain bleed.\n if (input.embedding && this.backend.queryVector) {\n const scope = input.opts?.scope;\n const domain = scope ? scope.replace(/^\\/+/, \"\") : undefined;\n const domains = this.domainsFilter(input);\n const pathPrefix = input.opts?.pathPrefix;\n const vectorResults = await this.backend.queryVector(input.embedding, candidates, {\n ...(domain ? { domain } : {}),\n ...(scope ? { scope } : {}),\n ...(domains ? { domains } : {}),\n ...(pathPrefix ? { pathPrefix } : {}),\n });\n\n if (vectorResults.length > 0) {\n let entries = vectorResults.map((r) => ({ entryPath: r.entryPath, score: r.similarity }));\n // Hybrid relevance floor: an entry that matched ONLY by embedding (no\n // FTS or anchor signal) must clear `embeddingFloor` to be surfaced.\n // Semantic recall otherwise returns the top-K nearest neighbors no\n // matter how weakly they relate — cosine models compress scores into a\n // narrow band (e5-small especially: a cooking note sits just under a\n // real match), so unfloored semantic search is noisy. Entries that ALSO\n // matched FTS/anchor always pass (explicit token/facet signal); the\n // floor only gates the pure-embedding tail. `embeddingFloor` undefined =\n // no floor (existing consumers unchanged). The value is model-specific\n // and empirical — set it where the model is wired (see the forwarding).\n const floor = input.opts?.embeddingFloor;\n if (floor != null) {\n const explicit = new Set<string>();\n for (const l of lists) {\n for (const e of l.entries) explicit.add(e.entryPath);\n }\n entries = entries.filter((e) => explicit.has(e.entryPath) || e.score >= floor);\n }\n if (entries.length > 0) {\n lists.push({\n source: \"embedding\",\n entries,\n ...(explain ? { label: \"cosine\" } : {}),\n });\n }\n }\n }\n\n return this.fuseOrFilter(lists, limit, anchorFilter, explain);\n }\n\n /**\n * Over-fetch candidates when anchor-filtering so the anchor∩text intersection\n * has enough to work with — an anchor-matching, text-relevant entry ranked\n * beyond `limit` would otherwise be dropped before the filter runs. (For\n * aside-scale personal data this window comfortably covers a tag/collection's\n * items; a truly-huge facet set could still clip the long tail — a\n * backend-level `WHERE entry IN (facet-set)` is the follow-up for that.)\n */\n private candidateLimit(limit: number, anchorFilter: boolean): number {\n return anchorFilter ? Math.max(limit, ANCHOR_FILTER_CANDIDATES) : limit;\n }\n\n /**\n * Combine ranked lists into the final result. Default = RRF fusion (union of\n * every source). When `anchorFilter` is on AND an anchor list is present, the\n * anchors are a HARD FILTER instead (Epic #932 S2/S3 facet search): with text\n * → the text ranking intersected with the anchor-matching set; facet-only (no\n * text) → the anchor set itself. No anchor list (no anchors / none matched) →\n * plain text-only, so `anchorFilter` never turns a valid text search empty.\n */\n private fuseOrFilter(\n lists: RankedList[],\n limit: number,\n anchorFilter: boolean,\n explain = false,\n ): QueryResult[] {\n if (lists.length === 0) return [];\n const flatten = (l: RankedList): QueryResult[] =>\n l.entries.map((e, rank) => ({\n entryPath: e.entryPath,\n matchType: l.source,\n score: e.score,\n ...(explain\n ? {\n explanation: formatContribution({\n source: l.source,\n rank,\n rawScore: e.score,\n label: l.label,\n }),\n }\n : {}),\n }));\n\n if (anchorFilter) {\n const anchorList = lists.find((l) => l.source === \"anchor\");\n const textLists = lists.filter((l) => l.source !== \"anchor\");\n if (anchorList) {\n const allowed = new Set(anchorList.entries.map((e) => e.entryPath));\n if (textLists.length === 0) {\n // Facet-only — the anchor set IS the answer (queryAnchors already did\n // the 组间 AND / 组内 OR facet combination).\n return flatten(anchorList).slice(0, limit);\n }\n const ranked =\n textLists.length === 1\n ? flatten(textLists[0]!)\n : reciprocalRankFusion(textLists, 60, explain);\n const filtered = ranked.filter((r) => allowed.has(r.entryPath)).slice(0, limit);\n if (explain && anchorList.label) {\n return filtered.map((r) => ({\n ...r,\n explanation:\n `${r.explanation ?? \"\"}; hard-filtered by anchor ${anchorList.label}`.replace(\n /^; /,\n \"\",\n ),\n }));\n }\n return filtered;\n }\n }\n\n if (lists.length === 1) return flatten(lists[0]!).slice(0, limit);\n return reciprocalRankFusion(lists, 60, explain).slice(0, limit);\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/query/limits.ts
|
|
3
|
+
const DEFAULT_QUERY_LIMIT = 100;
|
|
4
|
+
const MAX_QUERY_LIMIT = 1e3;
|
|
5
|
+
function normalizeQueryLimit(limit) {
|
|
6
|
+
if (limit == null) return DEFAULT_QUERY_LIMIT;
|
|
7
|
+
if (!Number.isFinite(limit)) return DEFAULT_QUERY_LIMIT;
|
|
8
|
+
return Math.min(Math.max(Math.floor(limit), 0), MAX_QUERY_LIMIT);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
//#endregion
|
|
12
|
+
exports.normalizeQueryLimit = normalizeQueryLimit;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/query/limits.ts
|
|
2
|
+
const DEFAULT_QUERY_LIMIT = 100;
|
|
3
|
+
const MAX_QUERY_LIMIT = 1e3;
|
|
4
|
+
function normalizeQueryLimit(limit) {
|
|
5
|
+
if (limit == null) return DEFAULT_QUERY_LIMIT;
|
|
6
|
+
if (!Number.isFinite(limit)) return DEFAULT_QUERY_LIMIT;
|
|
7
|
+
return Math.min(Math.max(Math.floor(limit), 0), MAX_QUERY_LIMIT);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
//#endregion
|
|
11
|
+
export { normalizeQueryLimit };
|
|
12
|
+
//# sourceMappingURL=limits.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"limits.mjs","names":[],"sources":["../../src/query/limits.ts"],"sourcesContent":["export const DEFAULT_QUERY_LIMIT = 100;\nexport const MAX_QUERY_LIMIT = 1000;\n\nexport function normalizeQueryLimit(limit: number | undefined): number {\n if (limit == null) return DEFAULT_QUERY_LIMIT;\n if (!Number.isFinite(limit)) return DEFAULT_QUERY_LIMIT;\n return Math.min(Math.max(Math.floor(limit), 0), MAX_QUERY_LIMIT);\n}\n"],"mappings":";AAAA,MAAa,sBAAsB;AACnC,MAAa,kBAAkB;AAE/B,SAAgB,oBAAoB,OAAmC;AACrE,KAAI,SAAS,KAAM,QAAO;AAC1B,KAAI,CAAC,OAAO,SAAS,MAAM,CAAE,QAAO;AACpC,QAAO,KAAK,IAAI,KAAK,IAAI,KAAK,MAAM,MAAM,EAAE,EAAE,EAAE,gBAAgB"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/text/cjk.ts
|
|
3
|
+
/**
|
|
4
|
+
* CJK text segmentation for FTS5 compatibility.
|
|
5
|
+
*
|
|
6
|
+
* FTS5's unicode61 tokenizer treats consecutive CJK characters as a single token,
|
|
7
|
+
* which prevents character-level matching. This module inserts spaces between CJK
|
|
8
|
+
* characters so FTS5 indexes them individually.
|
|
9
|
+
*
|
|
10
|
+
* Design note: Character-level segmentation is intentional for a recall-optimized
|
|
11
|
+
* memory system. Word-level (Intl.Segmenter) was tested and caused -10%~-46%
|
|
12
|
+
* regression due to vocabulary mismatch between query and corpus word boundaries.
|
|
13
|
+
* Character-level gives broader matching at the cost of precision — the right
|
|
14
|
+
* trade-off when results are top-K ranked and embeddings handle semantic matching.
|
|
15
|
+
*
|
|
16
|
+
* Example: "空调使用模式" → "空 调 使 用 模 式"
|
|
17
|
+
*/
|
|
18
|
+
/** CJK Unified Ideographs + Extension A + Compatibility + Hiragana + Katakana */
|
|
19
|
+
const CJK_CHAR = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\u30a0-\u30ff]/g;
|
|
20
|
+
/**
|
|
21
|
+
* Insert spaces around each CJK character so FTS5 treats them as individual tokens.
|
|
22
|
+
* Non-CJK text passes through unchanged.
|
|
23
|
+
*/
|
|
24
|
+
function segmentCJK(text) {
|
|
25
|
+
if (!text) return text;
|
|
26
|
+
return text.replace(CJK_CHAR, " $& ").replace(/\s+/g, " ").trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
exports.segmentCJK = segmentCJK;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/text/cjk.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* CJK text segmentation for FTS5 compatibility.
|
|
4
|
+
*
|
|
5
|
+
* FTS5's unicode61 tokenizer treats consecutive CJK characters as a single token,
|
|
6
|
+
* which prevents character-level matching. This module inserts spaces between CJK
|
|
7
|
+
* characters so FTS5 indexes them individually.
|
|
8
|
+
*
|
|
9
|
+
* Design note: Character-level segmentation is intentional for a recall-optimized
|
|
10
|
+
* memory system. Word-level (Intl.Segmenter) was tested and caused -10%~-46%
|
|
11
|
+
* regression due to vocabulary mismatch between query and corpus word boundaries.
|
|
12
|
+
* Character-level gives broader matching at the cost of precision — the right
|
|
13
|
+
* trade-off when results are top-K ranked and embeddings handle semantic matching.
|
|
14
|
+
*
|
|
15
|
+
* Example: "空调使用模式" → "空 调 使 用 模 式"
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Insert spaces around each CJK character so FTS5 treats them as individual tokens.
|
|
19
|
+
* Non-CJK text passes through unchanged.
|
|
20
|
+
*/
|
|
21
|
+
declare function segmentCJK(text: string): string;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { segmentCJK };
|
|
24
|
+
//# sourceMappingURL=cjk.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cjk.d.cts","names":[],"sources":["../../src/text/cjk.ts"],"mappings":";;AAuBA;;;;;;;;;;;;;;;;;;iBAAgB,UAAA,CAAW,IAAA"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/text/cjk.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* CJK text segmentation for FTS5 compatibility.
|
|
4
|
+
*
|
|
5
|
+
* FTS5's unicode61 tokenizer treats consecutive CJK characters as a single token,
|
|
6
|
+
* which prevents character-level matching. This module inserts spaces between CJK
|
|
7
|
+
* characters so FTS5 indexes them individually.
|
|
8
|
+
*
|
|
9
|
+
* Design note: Character-level segmentation is intentional for a recall-optimized
|
|
10
|
+
* memory system. Word-level (Intl.Segmenter) was tested and caused -10%~-46%
|
|
11
|
+
* regression due to vocabulary mismatch between query and corpus word boundaries.
|
|
12
|
+
* Character-level gives broader matching at the cost of precision — the right
|
|
13
|
+
* trade-off when results are top-K ranked and embeddings handle semantic matching.
|
|
14
|
+
*
|
|
15
|
+
* Example: "空调使用模式" → "空 调 使 用 模 式"
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Insert spaces around each CJK character so FTS5 treats them as individual tokens.
|
|
19
|
+
* Non-CJK text passes through unchanged.
|
|
20
|
+
*/
|
|
21
|
+
declare function segmentCJK(text: string): string;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { segmentCJK };
|
|
24
|
+
//# sourceMappingURL=cjk.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cjk.d.mts","names":[],"sources":["../../src/text/cjk.ts"],"mappings":";;AAuBA;;;;;;;;;;;;;;;;;;iBAAgB,UAAA,CAAW,IAAA"}
|