@arnilo/prism-rag 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ import { MediaContentError, pinnedFetch } from "@arnilo/prism";
2
+ import { RagAbortError, RagLimitError, RagValidationError } from "./errors.js";
3
+ const DEFAULT_TIMEOUT_MS = 2000;
4
+ const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024;
5
+ export function createTeiReranker(options) {
6
+ const { baseUrl, model, timeoutMs = DEFAULT_TIMEOUT_MS, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, ssrf, allowLoopback } = options;
7
+ if (!baseUrl.trim())
8
+ throw new RagValidationError("TEI reranker baseUrl is required");
9
+ let url;
10
+ try {
11
+ url = new URL(baseUrl.trim());
12
+ }
13
+ catch {
14
+ throw new RagValidationError("TEI reranker baseUrl must be an absolute URL");
15
+ }
16
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
17
+ throw new RagValidationError(`TEI reranker baseUrl must use http(s) (got ${url.protocol})`);
18
+ }
19
+ if (url.username || url.password)
20
+ throw new RagValidationError("TEI reranker baseUrl must not embed credentials");
21
+ if (url.hash)
22
+ throw new RagValidationError("TEI reranker baseUrl must not contain a fragment");
23
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
24
+ throw new RagValidationError("TEI reranker timeoutMs must be a positive integer");
25
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
26
+ throw new RagValidationError("TEI reranker maxResponseBytes must be a positive integer");
27
+ }
28
+ // Join without doubling a trailing slash.
29
+ const endpoint = `${url.toString().replace(/\/+$/, "")}/rerank`;
30
+ const pinnedRerankFetch = (input, init) => pinnedFetch(input instanceof URL ? input : new URL(String(input)), init, {
31
+ errorPrefix: "TEI rerank",
32
+ hostnameErrorPrefix: "TEI rerank",
33
+ ssrf,
34
+ allowLoopback,
35
+ maxResponseBytes,
36
+ });
37
+ const transport = options.fetch ?? pinnedRerankFetch;
38
+ return {
39
+ async rerank({ query, hits, signal }) {
40
+ const payload = JSON.stringify({ query, texts: hits.map((hit) => hit.text), raw_scores: false, ...(model ? { model } : {}) });
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
43
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
44
+ let response;
45
+ try {
46
+ response = await transport(endpoint, {
47
+ method: "POST",
48
+ headers: { "content-type": "application/json", accept: "application/json" },
49
+ body: payload,
50
+ signal: combined,
51
+ redirect: "manual",
52
+ });
53
+ }
54
+ catch (error) {
55
+ if (error instanceof MediaContentError) {
56
+ throw new RagValidationError(`TEI rerank request denied: ${error.message}`);
57
+ }
58
+ if (signal?.aborted)
59
+ throw new RagAbortError();
60
+ throw new RagLimitError(`TEI rerank exceeded ${timeoutMs}ms or failed to reach ${url.host}`);
61
+ }
62
+ finally {
63
+ clearTimeout(timer);
64
+ }
65
+ if (!response.ok)
66
+ throw new RagValidationError(`TEI rerank endpoint returned HTTP ${response.status}`);
67
+ const text = await readBoundedBody(response, maxResponseBytes);
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(text);
71
+ }
72
+ catch {
73
+ throw new RagValidationError("TEI rerank response is not valid JSON");
74
+ }
75
+ return orderByScores(hits, parsed);
76
+ },
77
+ };
78
+ }
79
+ /** Strictly parse `{results: [{index, score}]}` and reorder hits by score desc. */
80
+ function orderByScores(hits, parsed) {
81
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.results)) {
82
+ throw new RagValidationError("TEI rerank response missing results array");
83
+ }
84
+ const results = parsed.results;
85
+ if (results.length !== hits.length) {
86
+ throw new RagValidationError(`TEI rerank returned ${results.length} scores for ${hits.length} hits`);
87
+ }
88
+ const scores = new Array(hits.length);
89
+ const seen = new Set();
90
+ for (const item of results) {
91
+ if (typeof item !== "object" || item === null)
92
+ throw new RagValidationError("TEI rerank result must be an object");
93
+ const index = item.index;
94
+ const score = item.score;
95
+ if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index >= hits.length) {
96
+ throw new RagValidationError("TEI rerank returned an out-of-range index");
97
+ }
98
+ if (seen.has(index))
99
+ throw new RagValidationError("TEI rerank returned a duplicate index");
100
+ if (typeof score !== "number" || !Number.isFinite(score))
101
+ throw new RagValidationError("TEI rerank returned a non-finite score");
102
+ seen.add(index);
103
+ scores[index] = score;
104
+ }
105
+ // Stable sort by descending score; ties keep original array order.
106
+ const ordered = hits
107
+ .map((hit, i) => ({ hit, score: scores[i] }))
108
+ .sort((a, b) => b.score - a.score)
109
+ .map((entry) => entry.hit);
110
+ return Object.freeze(ordered);
111
+ }
112
+ async function readBoundedBody(response, maxBytes) {
113
+ const reader = response.body?.getReader();
114
+ if (!reader)
115
+ return "";
116
+ const decoder = new TextDecoder();
117
+ let out = "";
118
+ let total = 0;
119
+ try {
120
+ for (;;) {
121
+ const { done, value } = await reader.read();
122
+ if (done)
123
+ break;
124
+ total += value.byteLength;
125
+ if (total > maxBytes)
126
+ throw new RagLimitError(`TEI rerank response exceeds ${maxBytes} bytes`);
127
+ out += decoder.decode(value, { stream: true });
128
+ }
129
+ return out + decoder.decode();
130
+ }
131
+ catch (error) {
132
+ // The pinned transport may already have errorred the stream (MediaContentError).
133
+ if (error instanceof RagLimitError)
134
+ throw error;
135
+ throw new RagLimitError(`TEI rerank response exceeds ${maxBytes} bytes`);
136
+ }
137
+ }
138
+ //# sourceMappingURL=tei-reranker.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Dependency-free telemetry seam for RAG operations. When `telemetry` is omitted every
3
+ * call site short-circuits on an optional chain — zero allocations, zero per-span work.
4
+ * Implementations own timing/status; the seam only carries structure and attributes.
5
+ */
6
+ export type RagTelemetryAttributeValue = string | number | boolean;
7
+ export interface RagTelemetrySpan {
8
+ setAttribute(name: string, value: RagTelemetryAttributeValue): void;
9
+ addEvent(name: string, attributes?: Readonly<Record<string, RagTelemetryAttributeValue>>): void;
10
+ /** Flags the span as failed without carrying any error text (no user data leaves the host). */
11
+ recordError(): void;
12
+ end(): void;
13
+ }
14
+ export interface RagTelemetry {
15
+ startSpan(name: string, attributes?: Readonly<Record<string, RagTelemetryAttributeValue>>, parent?: RagTelemetrySpan): RagTelemetrySpan;
16
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=telemetry.js.map
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ContextProvider, JsonObject, Message, SecretRedactor } from "@arnilo/prism";
2
2
  import type { Embedder, MemoryVectorRecord, VectorStore } from "@arnilo/prism-memory";
3
+ import type { RagTelemetry, RagTelemetrySpan } from "./telemetry.js";
3
4
  export interface RagScope {
4
5
  readonly tenantId: string;
5
6
  readonly resourceId: string;
@@ -15,6 +16,11 @@ export interface RagChunk {
15
16
  readonly text: string;
16
17
  readonly metadata?: JsonObject;
17
18
  }
19
+ /** Stored embedding of a previous record offered for reuse when its text is unchanged. */
20
+ export interface ReusableEmbedding {
21
+ readonly text: string;
22
+ readonly embedding: readonly number[];
23
+ }
18
24
  export interface ChunkOptions {
19
25
  readonly sourceId: string;
20
26
  readonly metadata?: JsonObject;
@@ -111,6 +117,13 @@ export interface IndexChunksOptions {
111
117
  readonly secrets?: readonly (string | undefined)[];
112
118
  readonly statusStore?: IngestionStatusStore;
113
119
  readonly signal?: AbortSignal;
120
+ /** Host-supplied document digest stamped into each record's `_rag.contentHash`. */
121
+ readonly contentHash?: string;
122
+ /** Chunk id → previous text+embedding; embeddings are reused (no embed call) when texts match. */
123
+ readonly reuseEmbeddings?: ReadonlyMap<string, ReusableEmbedding>;
124
+ /** Optional telemetry seam; spans nest under `telemetryParent` when supplied. */
125
+ readonly telemetry?: RagTelemetry;
126
+ readonly telemetryParent?: RagTelemetrySpan;
114
127
  }
115
128
  export interface IndexChunksResult {
116
129
  readonly indexed: number;
@@ -120,6 +133,10 @@ export interface ReplaceSourceOptions extends Omit<IndexChunksOptions, "chunks"
120
133
  readonly sourceId: string;
121
134
  readonly chunks: readonly RagChunk[];
122
135
  readonly store: TransactionalVectorStore;
136
+ /** Host-computed document digest; enables the unchanged-source skip. */
137
+ readonly contentHash?: string;
138
+ /** Skip re-indexing when the stored document hash matches. Default true when contentHash is present. */
139
+ readonly skipIfUnchanged?: boolean;
123
140
  }
124
141
  export interface DeleteSourceOptions {
125
142
  readonly sourceId: string;
@@ -143,7 +160,11 @@ export interface RagProvenance {
143
160
  readonly chunkId: string;
144
161
  readonly citationId: string;
145
162
  readonly provider: string;
146
- readonly retrieval: "vector";
163
+ readonly tenantId: string;
164
+ readonly resourceId: string;
165
+ readonly corpusId: string;
166
+ /** Which retrieval leg(s) surfaced this hit after RRF fusion. */
167
+ readonly retrieval: "vector" | "lexical" | "hybrid";
147
168
  readonly retrievedAt: string;
148
169
  }
149
170
  export interface RagContentTrust {
@@ -175,9 +196,21 @@ export interface Reranker {
175
196
  export interface RetrieveContextOptions {
176
197
  readonly embedder: Embedder;
177
198
  readonly store: VectorStore;
178
- readonly scope: RagScope;
199
+ /** Single exact scope. Provide `scope` or `scopes`, never both. */
200
+ readonly scope?: RagScope;
201
+ /** One or more exact scopes (empty = no hits, no embed). */
202
+ readonly scopes?: readonly RagScope[];
203
+ /** Optional telemetry seam; when omitted, instrumentation costs nothing. */
204
+ readonly telemetry?: RagTelemetry;
179
205
  readonly topK?: number;
180
206
  readonly queryCandidates?: number;
207
+ /** Lexical leg mode. Default runs fts when the store supports it and silently skips otherwise;
208
+ * explicitly requesting "fts"/"bm25" on an unsupported store fails closed. */
209
+ readonly lexical?: "fts" | "bm25" | "off";
210
+ /** Only reciprocal-rank fusion is supported. */
211
+ readonly fusion?: "rrf";
212
+ /** RRF smoothing constant (default 60, capped). */
213
+ readonly rrfK?: number;
181
214
  readonly filter?: JsonObject;
182
215
  readonly maxResultBytes?: number;
183
216
  readonly maxContextTokens?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism-rag",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Optional bounded text and Markdown RAG primitives for Prism.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,7 +33,7 @@
33
33
  "pack:dry-run": "npm pack --dry-run"
34
34
  },
35
35
  "peerDependencies": {
36
- "@arnilo/prism": "^0.3.0",
36
+ "@arnilo/prism": "^0.3.1",
37
37
  "@arnilo/prism-memory": "^0.3.0"
38
38
  },
39
39
  "devDependencies": {