@arnilo/prism-rag 0.2.9 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-08-26
4
+
5
+ ### Added
6
+ - Hybrid retrieve (`lexical` / RRF / `retrieval` labels), multi-scope retrieve (`scopes: RagScope[]` — one embed, per-scope legs, one RRF, one rerank; `scope` stays valid), content-hash skip, heading-stack chunk metadata, generation stamping, `RagTelemetry` seam, `createTeiReranker`, `fuseReciprocalRank`, `isValidContentHash`. `HARD_RETRIEVE_SCOPE_CAP`. Provenance carries `tenantId`/`resourceId`/`corpusId`. Retrieve fails closed on embedder id/dimension drift (`ERR_PRISM_RAG_EMBEDDER_MISMATCH`).
7
+
3
8
  ## [0.1.0] - 2026-08-09
4
9
 
5
10
  ### Changed
package/dist/chunk.js CHANGED
@@ -18,12 +18,26 @@ function chunkDocument(text, options, markdown) {
18
18
  if (text.length > limits.maxDocumentChars) {
19
19
  throw new RagLimitError(`document exceeds ${limits.maxDocumentChars} characters`);
20
20
  }
21
- if (options.metadata)
22
- assertBytes(options.metadata, 64 * 1024, "chunk metadata");
21
+ const callerMeta = options.metadata;
22
+ if (callerMeta)
23
+ assertBytes(callerMeta, 64 * 1024, "chunk metadata");
23
24
  if (!text.trim())
24
25
  return Object.freeze([]);
26
+ const headings = [];
27
+ if (markdown) {
28
+ let pos = 0;
29
+ for (const line of text.split("\n")) {
30
+ const trimmed = line.trimStart();
31
+ const match = trimmed.match(/^(#{1,6})\s+(.+)$/);
32
+ if (match)
33
+ headings.push({ pos, level: match[1].length, text: match[2].trim() });
34
+ pos += line.length + 1; // +1 for the newline character
35
+ }
36
+ }
25
37
  const chunks = [];
26
38
  let start = 0;
39
+ let headingIdx = 0;
40
+ const headingStack = [];
27
41
  while (start < text.length) {
28
42
  while (start < text.length && /\s/.test(text[start]))
29
43
  start += 1;
@@ -33,8 +47,23 @@ function chunkDocument(text, options, markdown) {
33
47
  const end = ceiling === text.length ? ceiling : preferredEnd(text, start, ceiling, markdown);
34
48
  const raw = text.slice(start, end).trimEnd();
35
49
  if (raw) {
50
+ // Advance heading stack to the current chunk start position.
51
+ while (headingIdx < headings.length && headings[headingIdx].pos <= start) {
52
+ const h = headings[headingIdx];
53
+ while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= h.level) {
54
+ headingStack.pop();
55
+ }
56
+ headingStack.push({ level: h.level, text: h.text });
57
+ headingIdx++;
58
+ }
36
59
  const index = chunks.length;
37
60
  const citationId = `${sourceId}#${String(index + 1).padStart(4, "0")}`;
61
+ const meta = callerMeta ? { ...callerMeta } : {};
62
+ // Stamp heading metadata unless the caller supplied explicit heading data.
63
+ if (markdown && meta.heading === undefined && headingStack.length > 0) {
64
+ meta.heading = headingStack.map((h) => h.text);
65
+ }
66
+ const metadata = Object.keys(meta).length > 0 ? Object.freeze(meta) : undefined;
38
67
  chunks.push(Object.freeze({
39
68
  id: citationId,
40
69
  citationId,
@@ -43,7 +72,7 @@ function chunkDocument(text, options, markdown) {
43
72
  start,
44
73
  end: start + raw.length,
45
74
  text: raw,
46
- ...(options.metadata ? { metadata: Object.freeze({ ...options.metadata }) } : {}),
75
+ ...(metadata ? { metadata } : {}),
47
76
  }));
48
77
  if (chunks.length > limits.maxChunks)
49
78
  throw new RagLimitError(`chunk count exceeds ${limits.maxChunks}`);
@@ -0,0 +1,18 @@
1
+ import type { MemoryVectorHit } from "@arnilo/prism-memory";
2
+ /** Which retrieval leg(s) surfaced a hit before fusion. */
3
+ export type RetrievalLeg = "vector" | "lexical";
4
+ export interface FusedCandidate {
5
+ readonly hit: MemoryVectorHit;
6
+ readonly retrieval: RetrievalLeg | "hybrid";
7
+ }
8
+ /** N-list RRF. Fusion key includes scope so the same id in two corpora stays two hits. */
9
+ export declare function fuseReciprocalRankLists(lists: readonly {
10
+ readonly hits: readonly MemoryVectorHit[];
11
+ readonly leg: RetrievalLeg;
12
+ }[], rrfK: number): readonly FusedCandidate[];
13
+ /**
14
+ * Reciprocal-rank fusion: score(id) = Σ 1/(rrfK + rank) over each list containing the id.
15
+ * Pure and store-free; ties break by best rank, then id, so output order is deterministic.
16
+ * When a record appears in both legs the vector-leg hit object wins (display score stays cosine).
17
+ */
18
+ export declare function fuseReciprocalRank(vectorHits: readonly MemoryVectorHit[], lexicalHits: readonly MemoryVectorHit[], rrfK: number): readonly FusedCandidate[];
package/dist/fusion.js ADDED
Binary file
package/dist/hash.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Document hashes are supplied by the host (never computed over unbounded bytes inside the
3
+ * engine). Accept any hex digest of 32..128 characters so hosts can pick the algorithm;
4
+ * comparison is exact-match on the normalized lowercase form.
5
+ */
6
+ export declare function isValidContentHash(value: unknown): value is string;
package/dist/hash.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Document hashes are supplied by the host (never computed over unbounded bytes inside the
3
+ * engine). Accept any hex digest of 32..128 characters so hosts can pick the algorithm;
4
+ * comparison is exact-match on the normalized lowercase form.
5
+ */
6
+ export function isValidContentHash(value) {
7
+ return typeof value === "string" && /^[0-9a-f]{32,128}$/.test(value);
8
+ }
9
+ //# sourceMappingURL=hash.js.map
package/dist/index.d.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  export { chunkMarkdown, chunkText } from "./chunk.js";
2
2
  export { createRagContextProvider } from "./context.js";
3
3
  export { RagAbortError, RagError, RagLimitError, RagScopeError, RagValidationError } from "./errors.js";
4
+ export type { FusedCandidate, RetrievalLeg } from "./fusion.js";
5
+ export { fuseReciprocalRank } from "./fusion.js";
6
+ export { isValidContentHash } from "./hash.js";
4
7
  export { indexChunks } from "./indexing.js";
5
8
  export { createMemoryIngestionStatusStore, listIngestionStatus } from "./ingestion-status.js";
6
9
  export type { RagLimits, RagLimitsInput } from "./limits.js";
7
- export { DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE, DEFAULT_EMBED_BATCH_SIZE, DEFAULT_INGESTION_STATUS_PAGE_SIZE, DEFAULT_MAX_CHUNKS, DEFAULT_MAX_CONTEXT_TOKENS, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_DOCUMENT_CHARS, DEFAULT_MAX_METADATA_BYTES, DEFAULT_MAX_PARSE_MS, DEFAULT_MAX_PDF_PAGES, DEFAULT_MAX_RERANK_BYTES, DEFAULT_MAX_RERANK_MS, DEFAULT_MAX_RESULT_BYTES, DEFAULT_MAX_VECTOR_DIMENSIONS, DEFAULT_QUERY_CANDIDATES, DEFAULT_RERANK_CONCURRENCY, DEFAULT_TOP_K, HARD_CHUNK_OVERLAP_CAP, HARD_CHUNK_SIZE_CAP, HARD_EMBED_BATCH_SIZE_CAP, HARD_INGESTION_STATUS_PAGE_SIZE_CAP, HARD_MAX_CHUNKS_CAP, HARD_MAX_CONTEXT_TOKENS_CAP, HARD_MAX_DOCUMENT_BYTES_CAP, HARD_MAX_DOCUMENT_CHARS_CAP, HARD_MAX_METADATA_BYTES_CAP, HARD_MAX_PARSE_MS_CAP, HARD_MAX_PDF_PAGES_CAP, HARD_MAX_RERANK_BYTES_CAP, HARD_MAX_RERANK_MS_CAP, HARD_MAX_RESULT_BYTES_CAP, HARD_QUERY_CANDIDATES_CAP, HARD_RERANK_CONCURRENCY_CAP, HARD_TOP_K_CAP, resolveRagLimits, } from "./limits.js";
10
+ export { DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE, DEFAULT_EMBED_BATCH_SIZE, DEFAULT_INGESTION_STATUS_PAGE_SIZE, DEFAULT_MAX_CHUNKS, DEFAULT_MAX_CONTEXT_TOKENS, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_DOCUMENT_CHARS, DEFAULT_MAX_METADATA_BYTES, DEFAULT_MAX_PARSE_MS, DEFAULT_MAX_PDF_PAGES, DEFAULT_MAX_RERANK_BYTES, DEFAULT_MAX_RERANK_MS, DEFAULT_MAX_RESULT_BYTES, DEFAULT_MAX_VECTOR_DIMENSIONS, DEFAULT_QUERY_CANDIDATES, DEFAULT_RERANK_CONCURRENCY, DEFAULT_TOP_K, HARD_CHUNK_OVERLAP_CAP, HARD_CHUNK_SIZE_CAP, HARD_EMBED_BATCH_SIZE_CAP, HARD_INGESTION_STATUS_PAGE_SIZE_CAP, HARD_MAX_CHUNKS_CAP, HARD_MAX_CONTEXT_TOKENS_CAP, HARD_MAX_DOCUMENT_BYTES_CAP, HARD_MAX_DOCUMENT_CHARS_CAP, HARD_MAX_METADATA_BYTES_CAP, HARD_MAX_PARSE_MS_CAP, HARD_MAX_PDF_PAGES_CAP, HARD_MAX_RERANK_BYTES_CAP, HARD_MAX_RERANK_MS_CAP, HARD_MAX_RESULT_BYTES_CAP, HARD_QUERY_CANDIDATES_CAP, HARD_RERANK_CONCURRENCY_CAP, HARD_RETRIEVE_SCOPE_CAP, HARD_TOP_K_CAP, resolveRagLimits, } from "./limits.js";
8
11
  export { createResourceDocumentLoader, createWebFetchDocumentLoader } from "./loaders.js";
9
12
  export { htmlParser, markdownParser, pdfParser, textParser } from "./parsers.js";
10
13
  export { retrieveContext } from "./retrieve.js";
11
14
  export type { SourceMutationResult } from "./sources.js";
12
15
  export { deleteSource, replaceDocument, replaceSource } from "./sources.js";
13
- export type { Chunker, ChunkOptions, DeleteSourceOptions, DocumentLoader, DocumentLoadOptions, DocumentParseOptions, IndexChunksOptions, IndexChunksResult, IngestionState, IngestionStatus, IngestionStatusQuery, IngestionStatusStore, LoadedDocument, ParsedDocument, Parser, RagChunk, RagCitation, RagContentTrust, RagContextProvider, RagContextProviderOptions, RagContextResult, RagHit, RagProvenance, RagScope, ReplaceDocumentOptions, ReplaceSourceOptions, Reranker, RetrieveContextOptions, SourceVectorStore, TransactionalVectorStore, } from "./types.js";
16
+ export type { CreateTeiRerankerOptions } from "./tei-reranker.js";
17
+ export { createTeiReranker } from "./tei-reranker.js";
18
+ export type { RagTelemetry, RagTelemetryAttributeValue, RagTelemetrySpan } from "./telemetry.js";
19
+ export type { Chunker, ChunkOptions, DeleteSourceOptions, DocumentLoader, DocumentLoadOptions, DocumentParseOptions, IndexChunksOptions, IndexChunksResult, IngestionState, IngestionStatus, IngestionStatusQuery, IngestionStatusStore, LoadedDocument, ParsedDocument, Parser, RagChunk, RagCitation, RagContentTrust, RagContextProvider, RagContextProviderOptions, RagContextResult, RagHit, RagProvenance, RagScope, ReplaceDocumentOptions, ReplaceSourceOptions, Reranker, RetrieveContextOptions, ReusableEmbedding, SourceVectorStore, TransactionalVectorStore, } from "./types.js";
14
20
  export declare const packageName = "@arnilo/prism-rag";
package/dist/index.js CHANGED
@@ -1,12 +1,15 @@
1
1
  export { chunkMarkdown, chunkText } from "./chunk.js";
2
2
  export { createRagContextProvider } from "./context.js";
3
3
  export { RagAbortError, RagError, RagLimitError, RagScopeError, RagValidationError } from "./errors.js";
4
+ export { fuseReciprocalRank } from "./fusion.js";
5
+ export { isValidContentHash } from "./hash.js";
4
6
  export { indexChunks } from "./indexing.js";
5
7
  export { createMemoryIngestionStatusStore, listIngestionStatus } from "./ingestion-status.js";
6
- export { DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE, DEFAULT_EMBED_BATCH_SIZE, DEFAULT_INGESTION_STATUS_PAGE_SIZE, DEFAULT_MAX_CHUNKS, DEFAULT_MAX_CONTEXT_TOKENS, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_DOCUMENT_CHARS, DEFAULT_MAX_METADATA_BYTES, DEFAULT_MAX_PARSE_MS, DEFAULT_MAX_PDF_PAGES, DEFAULT_MAX_RERANK_BYTES, DEFAULT_MAX_RERANK_MS, DEFAULT_MAX_RESULT_BYTES, DEFAULT_MAX_VECTOR_DIMENSIONS, DEFAULT_QUERY_CANDIDATES, DEFAULT_RERANK_CONCURRENCY, DEFAULT_TOP_K, HARD_CHUNK_OVERLAP_CAP, HARD_CHUNK_SIZE_CAP, HARD_EMBED_BATCH_SIZE_CAP, HARD_INGESTION_STATUS_PAGE_SIZE_CAP, HARD_MAX_CHUNKS_CAP, HARD_MAX_CONTEXT_TOKENS_CAP, HARD_MAX_DOCUMENT_BYTES_CAP, HARD_MAX_DOCUMENT_CHARS_CAP, HARD_MAX_METADATA_BYTES_CAP, HARD_MAX_PARSE_MS_CAP, HARD_MAX_PDF_PAGES_CAP, HARD_MAX_RERANK_BYTES_CAP, HARD_MAX_RERANK_MS_CAP, HARD_MAX_RESULT_BYTES_CAP, HARD_QUERY_CANDIDATES_CAP, HARD_RERANK_CONCURRENCY_CAP, HARD_TOP_K_CAP, resolveRagLimits, } from "./limits.js";
8
+ export { DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE, DEFAULT_EMBED_BATCH_SIZE, DEFAULT_INGESTION_STATUS_PAGE_SIZE, DEFAULT_MAX_CHUNKS, DEFAULT_MAX_CONTEXT_TOKENS, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_DOCUMENT_CHARS, DEFAULT_MAX_METADATA_BYTES, DEFAULT_MAX_PARSE_MS, DEFAULT_MAX_PDF_PAGES, DEFAULT_MAX_RERANK_BYTES, DEFAULT_MAX_RERANK_MS, DEFAULT_MAX_RESULT_BYTES, DEFAULT_MAX_VECTOR_DIMENSIONS, DEFAULT_QUERY_CANDIDATES, DEFAULT_RERANK_CONCURRENCY, DEFAULT_TOP_K, HARD_CHUNK_OVERLAP_CAP, HARD_CHUNK_SIZE_CAP, HARD_EMBED_BATCH_SIZE_CAP, HARD_INGESTION_STATUS_PAGE_SIZE_CAP, HARD_MAX_CHUNKS_CAP, HARD_MAX_CONTEXT_TOKENS_CAP, HARD_MAX_DOCUMENT_BYTES_CAP, HARD_MAX_DOCUMENT_CHARS_CAP, HARD_MAX_METADATA_BYTES_CAP, HARD_MAX_PARSE_MS_CAP, HARD_MAX_PDF_PAGES_CAP, HARD_MAX_RERANK_BYTES_CAP, HARD_MAX_RERANK_MS_CAP, HARD_MAX_RESULT_BYTES_CAP, HARD_QUERY_CANDIDATES_CAP, HARD_RERANK_CONCURRENCY_CAP, HARD_RETRIEVE_SCOPE_CAP, HARD_TOP_K_CAP, resolveRagLimits, } from "./limits.js";
7
9
  export { createResourceDocumentLoader, createWebFetchDocumentLoader } from "./loaders.js";
8
10
  export { htmlParser, markdownParser, pdfParser, textParser } from "./parsers.js";
9
11
  export { retrieveContext } from "./retrieve.js";
10
12
  export { deleteSource, replaceDocument, replaceSource } from "./sources.js";
13
+ export { createTeiReranker } from "./tei-reranker.js";
11
14
  export const packageName = "@arnilo/prism-rag";
12
15
  //# sourceMappingURL=index.js.map
package/dist/indexing.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolveRedactor } from "@arnilo/prism";
2
2
  import { RagValidationError } from "./errors.js";
3
+ import { isValidContentHash } from "./hash.js";
3
4
  import { ingestionStatus } from "./ingestion-status.js";
4
5
  import { HARD_CHUNK_SIZE_CAP, resolveRagLimits } from "./limits.js";
5
6
  import { assertBytes, assertNotAborted, byteLength, nonEmpty, requireScope, requireSourceId } from "./util.js";
@@ -20,6 +21,7 @@ export async function indexChunkBatches(options, write) {
20
21
  options.embedder.dimensions > limits.maxVectorDimensions) {
21
22
  throw new RagValidationError(`embedder dimensions must be an integer in 1..${limits.maxVectorDimensions}`);
22
23
  }
24
+ nonEmpty(options.embedder.id, "embedder.id");
23
25
  if (options.chunks.length > limits.maxChunks)
24
26
  throw new RagValidationError(`chunk count exceeds ${limits.maxChunks}`);
25
27
  const redactor = resolveRedactor(options.redactor, options.secrets);
@@ -59,13 +61,29 @@ export async function indexChunkBatches(options, write) {
59
61
  }
60
62
  };
61
63
  await setStatus("pending");
64
+ const embedSpan = options.telemetry?.startSpan("embedding.index", { "rag.embedder_id": options.embedder.id }, options.telemetryParent);
62
65
  try {
63
66
  for (let offset = 0; offset < options.chunks.length; offset += limits.embedBatchSize) {
64
67
  assertNotAborted(options.signal);
65
68
  const batch = options.chunks.slice(offset, offset + limits.embedBatchSize);
66
69
  const texts = batch.map((chunk) => redactor?.redact(chunk.text) ?? chunk.text);
67
- const vectors = await options.embedder.embed(texts, { signal: options.signal });
68
- if (vectors.length !== batch.length)
70
+ // Embed only the delta: chunks whose id+text match a stored record reuse its embedding.
71
+ const vectors = batch.map((chunk, index) => {
72
+ const reused = options.reuseEmbeddings?.get(chunk.id);
73
+ if (!reused || reused.text !== texts[index])
74
+ return undefined;
75
+ return [...reused.embedding];
76
+ });
77
+ const pending = vectors.flatMap((vector, index) => (vector === undefined ? [index] : []));
78
+ if (pending.length) {
79
+ const fresh = await options.embedder.embed(pending.map((index) => texts[index]), { signal: options.signal });
80
+ if (fresh.length !== pending.length)
81
+ throw new RagValidationError("embedder returned unexpected vector count");
82
+ pending.forEach((index, position) => {
83
+ vectors[index] = [...fresh[position]];
84
+ });
85
+ }
86
+ if (vectors.some((vector) => vector === undefined))
69
87
  throw new RagValidationError("embedder returned unexpected vector count");
70
88
  const records = batch.map((chunk, index) => {
71
89
  const embedding = vectors[index];
@@ -75,7 +93,14 @@ export async function indexChunkBatches(options, write) {
75
93
  const safeMetadata = redactor?.redact(chunk.metadata ?? {}) ?? chunk.metadata ?? {};
76
94
  const metadata = {
77
95
  ...safeMetadata,
78
- _rag: { sourceId: chunk.sourceId, citationId: chunk.citationId, chunkIndex: chunk.index, start: chunk.start, end: chunk.end },
96
+ _rag: {
97
+ sourceId: chunk.sourceId,
98
+ citationId: chunk.citationId,
99
+ chunkIndex: chunk.index,
100
+ start: chunk.start,
101
+ end: chunk.end,
102
+ ...(options.contentHash && isValidContentHash(options.contentHash) ? { contentHash: options.contentHash.toLowerCase() } : {}),
103
+ },
79
104
  };
80
105
  assertBytes(metadata, limits.maxMetadataBytes, "chunk metadata");
81
106
  return {
@@ -86,6 +111,7 @@ export async function indexChunkBatches(options, write) {
86
111
  text: texts[index],
87
112
  embedding,
88
113
  sequence: chunk.index,
114
+ embedderId: options.embedder.id,
89
115
  metadata: metadata,
90
116
  createdAt: new Date(0).toISOString(),
91
117
  };
@@ -99,9 +125,13 @@ export async function indexChunkBatches(options, write) {
99
125
  await setStatus("indexed");
100
126
  }
101
127
  catch (error) {
128
+ embedSpan?.recordError();
102
129
  await setStatus([...written.values()].some((progress) => progress.chunks > 0) ? "partial" : "failed", error);
103
130
  throw error;
104
131
  }
132
+ finally {
133
+ embedSpan?.end();
134
+ }
105
135
  return Object.freeze({ indexed: options.chunks.length, sourceIds: Object.freeze([...sourceIds].sort()) });
106
136
  }
107
137
  function add(target, sourceId, bytes, chunks) {
package/dist/limits.d.ts CHANGED
@@ -31,6 +31,9 @@ export declare const DEFAULT_MAX_RERANK_MS = 2000;
31
31
  export declare const HARD_MAX_RERANK_MS_CAP = 10000;
32
32
  export declare const DEFAULT_RERANK_CONCURRENCY = 2;
33
33
  export declare const HARD_RERANK_CONCURRENCY_CAP = 8;
34
+ export declare const DEFAULT_RRF_K = 60;
35
+ export declare const HARD_RRF_K_CAP = 1000;
36
+ export declare const HARD_RETRIEVE_SCOPE_CAP = 8;
34
37
  export declare const DEFAULT_INGESTION_STATUS_PAGE_SIZE = 50;
35
38
  export declare const HARD_INGESTION_STATUS_PAGE_SIZE_CAP = 200;
36
39
  export interface RagLimits {
@@ -51,6 +54,7 @@ export interface RagLimits {
51
54
  readonly maxRerankBytes: number;
52
55
  readonly maxRerankMs: number;
53
56
  readonly rerankConcurrency: number;
57
+ readonly rrfK: number;
54
58
  readonly ingestionStatusPageSize: number;
55
59
  }
56
60
  export type RagLimitsInput = Partial<RagLimits>;
package/dist/limits.js CHANGED
@@ -32,6 +32,9 @@ export const DEFAULT_MAX_RERANK_MS = 2_000;
32
32
  export const HARD_MAX_RERANK_MS_CAP = 10_000;
33
33
  export const DEFAULT_RERANK_CONCURRENCY = 2;
34
34
  export const HARD_RERANK_CONCURRENCY_CAP = 8;
35
+ export const DEFAULT_RRF_K = 60;
36
+ export const HARD_RRF_K_CAP = 1_000;
37
+ export const HARD_RETRIEVE_SCOPE_CAP = 8;
35
38
  export const DEFAULT_INGESTION_STATUS_PAGE_SIZE = 50;
36
39
  export const HARD_INGESTION_STATUS_PAGE_SIZE_CAP = 200;
37
40
  function integer(value, fallback, cap, label, minimum = 1) {
@@ -70,6 +73,7 @@ export function resolveRagLimits(input = {}) {
70
73
  maxRerankBytes: integer(input.maxRerankBytes, DEFAULT_MAX_RERANK_BYTES, HARD_MAX_RERANK_BYTES_CAP, "maxRerankBytes"),
71
74
  maxRerankMs: integer(input.maxRerankMs, DEFAULT_MAX_RERANK_MS, HARD_MAX_RERANK_MS_CAP, "maxRerankMs"),
72
75
  rerankConcurrency: integer(input.rerankConcurrency, DEFAULT_RERANK_CONCURRENCY, HARD_RERANK_CONCURRENCY_CAP, "rerankConcurrency"),
76
+ rrfK: integer(input.rrfK, DEFAULT_RRF_K, HARD_RRF_K_CAP, "rrfK"),
73
77
  ingestionStatusPageSize: integer(input.ingestionStatusPageSize, DEFAULT_INGESTION_STATUS_PAGE_SIZE, HARD_INGESTION_STATUS_PAGE_SIZE_CAP, "ingestionStatusPageSize"),
74
78
  });
75
79
  }
package/dist/retrieve.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { resolveRedactor } from "@arnilo/prism";
2
- import { RagScopeError, RagValidationError } from "./errors.js";
3
- import { HARD_CHUNK_SIZE_CAP, resolveRagLimits } from "./limits.js";
2
+ import { RagError, RagLimitError, RagScopeError, RagValidationError } from "./errors.js";
3
+ import { fuseReciprocalRankLists } from "./fusion.js";
4
+ import { HARD_CHUNK_SIZE_CAP, HARD_RETRIEVE_SCOPE_CAP, resolveRagLimits } from "./limits.js";
4
5
  import { rerankHits } from "./rerank.js";
5
- import { assertBytes, assertNotAborted, assertScope, byteLength, isJsonObject, matchesFilter, nonEmpty, requireScope, requireSourceId, truncateUtf8, } from "./util.js";
6
+ import { assertBytes, assertNotAborted, byteLength, isJsonObject, matchesFilter, nonEmpty, requireScope, requireSourceId, truncateUtf8, } from "./util.js";
6
7
  const RETRIEVED_CONTENT_TRUST = Object.freeze({ untrusted: true, inert: true, injectionCapable: true });
7
8
  export async function retrieveContext(query, options) {
8
9
  nonEmpty(query, "query");
9
10
  if (query.length > HARD_CHUNK_SIZE_CAP)
10
11
  throw new RagValidationError(`query exceeds ${HARD_CHUNK_SIZE_CAP} characters`);
11
- const scope = requireScope(options.scope);
12
+ const scopes = resolveRetrieveScopes(options);
12
13
  const limits = resolveRagLimits({
13
14
  topK: options.topK,
14
15
  queryCandidates: options.queryCandidates,
@@ -19,6 +20,7 @@ export async function retrieveContext(query, options) {
19
20
  maxRerankBytes: options.maxRerankBytes,
20
21
  maxRerankMs: options.maxRerankMs,
21
22
  rerankConcurrency: options.rerankConcurrency,
23
+ rrfK: options.rrfK,
22
24
  });
23
25
  if (!Number.isInteger(options.embedder.dimensions) ||
24
26
  options.embedder.dimensions <= 0 ||
@@ -27,100 +29,220 @@ export async function retrieveContext(query, options) {
27
29
  }
28
30
  if (options.filter)
29
31
  assertBytes(options.filter, limits.maxMetadataBytes, "metadata filter");
30
- const redactor = resolveRedactor(options.redactor, options.secrets);
31
- const safeQuery = redactor?.redact(query) ?? query;
32
- assertNotAborted(options.signal);
33
- const vectors = await options.embedder.embed([safeQuery], { signal: options.signal });
34
- const embedding = vectors[0];
35
- if (vectors.length !== 1 ||
36
- !embedding ||
37
- embedding.length !== options.embedder.dimensions ||
38
- embedding.some((value) => !Number.isFinite(value))) {
39
- throw new RagValidationError("embedder returned invalid query vector");
32
+ const lexical = options.lexical ?? (options.store.lexicalQuery ? "fts" : "off");
33
+ if (lexical !== "fts" && lexical !== "bm25" && lexical !== "off") {
34
+ throw new RagValidationError('lexical must be "fts", "bm25", or "off"');
40
35
  }
41
- const candidates = await options.store.query({
42
- tenantId: scope.tenantId,
43
- resourceId: scope.resourceId,
44
- threadId: scope.corpusId,
45
- embedding,
46
- topK: limits.queryCandidates,
47
- signal: options.signal,
48
- });
49
- assertNotAborted(options.signal);
50
- const retrievedAt = new Date().toISOString();
51
- const retrieved = [];
52
- for (const candidate of candidates.slice(0, limits.queryCandidates)) {
53
- assertScope(scope, candidate);
54
- const parsed = parseHit(candidate, retrieved.length, retrievedAt);
55
- if (!matchesFilter(parsed.metadata, options.filter))
56
- continue;
57
- retrieved.push(Object.freeze(redactor?.redact(parsed) ?? parsed));
36
+ if (options.fusion !== undefined && options.fusion !== "rrf") {
37
+ throw new RagValidationError('fusion must be "rrf"');
38
+ }
39
+ if (lexical !== "off" && !options.store.lexicalQuery) {
40
+ throw new RagValidationError(`lexical "${lexical}" requested but the store has no lexicalQuery capability`);
41
+ }
42
+ if (lexical === "bm25" && !options.store.lexicalModes?.includes("bm25")) {
43
+ throw new RagValidationError('lexical "bm25" requested but the store does not declare BM25 support');
58
44
  }
59
- const ranked = options.reranker
60
- ? await rerankHits(safeQuery, retrieved, {
61
- reranker: options.reranker,
62
- maxBytes: limits.maxRerankBytes,
63
- maxMs: limits.maxRerankMs,
64
- concurrency: limits.rerankConcurrency,
65
- signal: options.signal,
66
- redactor: options.redactor,
67
- secrets: options.secrets,
68
- })
69
- : retrieved;
70
- const hits = [];
71
- const citations = [];
72
- const rendered = [];
73
- const maxChars = limits.maxContextTokens * 4;
74
- let usedBytes = 0;
75
- let usedChars = 0;
76
- let truncated = false;
77
- for (const hit of ranked) {
78
- if (hits.length >= limits.topK)
79
- break;
80
- const prefix = `[${hit.citationId}] `;
81
- const separator = rendered.length ? "\n\n" : "";
82
- const availableBytes = limits.maxResultBytes - usedBytes - byteLength(separator + prefix);
83
- const availableChars = maxChars - usedChars - separator.length - prefix.length;
84
- if (availableBytes <= 0 || availableChars <= 0) {
85
- truncated = true;
86
- break;
45
+ const useLexical = lexical !== "off";
46
+ const redactor = resolveRedactor(options.redactor, options.secrets);
47
+ const telemetry = options.telemetry;
48
+ const root = telemetry?.startSpan("rag_request", {
49
+ ...(scopes[0] ? { "rag.scope.tenant_id": scopes[0].tenantId } : {}),
50
+ "rag.scope_count": scopes.length,
51
+ "rag.embedder_id": options.embedder.id,
52
+ "rag.top_k": limits.topK,
53
+ "rag.lexical_mode": lexical,
54
+ });
55
+ try {
56
+ const safeQuery = redactor?.redact(query) ?? query;
57
+ assertNotAborted(options.signal);
58
+ if (scopes.length === 0) {
59
+ return emptyResult(safeQuery);
60
+ }
61
+ if (scopes.length === 1) {
62
+ const currentGeneration = await options.store.getCurrentGeneration?.({
63
+ tenantId: scopes[0].tenantId,
64
+ resourceId: scopes[0].resourceId,
65
+ threadId: scopes[0].corpusId,
66
+ });
67
+ if (currentGeneration !== undefined)
68
+ root?.setAttribute("rag.index_generation", Number(currentGeneration));
69
+ }
70
+ const vectors = await span(telemetry, "embedding.query", undefined, root, () => options.embedder.embed([safeQuery], { signal: options.signal }));
71
+ const embedding = vectors[0];
72
+ if (vectors.length !== 1 ||
73
+ !embedding ||
74
+ embedding.length !== options.embedder.dimensions ||
75
+ embedding.some((value) => !Number.isFinite(value))) {
76
+ throw new RagValidationError("embedder returned invalid query vector");
87
77
  }
88
- let text = hit.text.slice(0, availableChars);
89
- text = truncateUtf8(text, availableBytes);
90
- if (!text) {
91
- truncated = true;
92
- break;
78
+ const vectorLists = [];
79
+ await span(telemetry, "retrieval.vector_search", undefined, root, async (leg) => {
80
+ let total = 0;
81
+ for (const scope of scopes) {
82
+ assertNotAborted(options.signal);
83
+ const found = await options.store.query({
84
+ tenantId: scope.tenantId,
85
+ resourceId: scope.resourceId,
86
+ threadId: scope.corpusId,
87
+ embedding,
88
+ topK: limits.queryCandidates,
89
+ signal: options.signal,
90
+ });
91
+ const sliced = found.slice(0, limits.queryCandidates);
92
+ vectorLists.push(sliced);
93
+ total += sliced.length;
94
+ }
95
+ leg?.setAttribute("rag.vector_candidates", total);
96
+ });
97
+ const lexicalLists = [];
98
+ if (useLexical && options.store.lexicalQuery) {
99
+ await span(telemetry, "retrieval.lexical", undefined, root, async (leg) => {
100
+ let total = 0;
101
+ for (const scope of scopes) {
102
+ assertNotAborted(options.signal);
103
+ const found = await options.store.lexicalQuery({
104
+ tenantId: scope.tenantId,
105
+ resourceId: scope.resourceId,
106
+ threadId: scope.corpusId,
107
+ text: safeQuery,
108
+ topK: limits.queryCandidates,
109
+ signal: options.signal,
110
+ });
111
+ const sliced = found.slice(0, limits.queryCandidates);
112
+ lexicalLists.push(sliced);
113
+ total += sliced.length;
114
+ }
115
+ leg?.setAttribute("rag.lexical_candidates", total);
116
+ });
117
+ }
118
+ const retrievedAt = new Date().toISOString();
119
+ const retrieved = [];
120
+ const fused = await span(telemetry, "retrieval.fusion", undefined, root, (fusion) => {
121
+ const lists = [
122
+ ...vectorLists.map((hits) => ({ hits, leg: "vector" })),
123
+ ...lexicalLists.map((hits) => ({ hits, leg: "lexical" })),
124
+ ];
125
+ const fusedCandidates = fuseReciprocalRankLists(lists, limits.rrfK);
126
+ fusion?.setAttribute("rag.fused_candidates", fusedCandidates.length);
127
+ return fusedCandidates;
128
+ });
129
+ for (const { hit: candidate, retrieval } of fused) {
130
+ assertRequestedScope(scopes, candidate);
131
+ if (candidate.embedderId === undefined) {
132
+ throw new RagError(`stored record ${candidate.id} has no embedderId; re-index the source to stamp embedder identity`, "ERR_PRISM_RAG_EMBEDDER_MISMATCH");
133
+ }
134
+ if (candidate.embedderId !== options.embedder.id || candidate.embedding.length !== options.embedder.dimensions) {
135
+ throw new RagError(`embedder mismatch: record ${candidate.id} was embedded by "${candidate.embedderId}" (${candidate.embedding.length} dims) but the query embedder is "${options.embedder.id}" (${options.embedder.dimensions} dims)`, "ERR_PRISM_RAG_EMBEDDER_MISMATCH");
136
+ }
137
+ const parsed = parseHit(candidate, retrieved.length, retrievedAt, retrieval);
138
+ if (!matchesFilter(parsed.metadata, options.filter))
139
+ continue;
140
+ retrieved.push(Object.freeze(redactor?.redact(parsed) ?? parsed));
141
+ }
142
+ const reranker = options.reranker;
143
+ const ranked = reranker
144
+ ? await span(telemetry, "retrieval.rerank", undefined, root, () => rerankHits(safeQuery, retrieved, {
145
+ reranker,
146
+ maxBytes: limits.maxRerankBytes,
147
+ maxMs: limits.maxRerankMs,
148
+ concurrency: limits.rerankConcurrency,
149
+ signal: options.signal,
150
+ redactor: options.redactor,
151
+ secrets: options.secrets,
152
+ }))
153
+ : retrieved;
154
+ const hits = [];
155
+ const citations = [];
156
+ const rendered = [];
157
+ const maxChars = limits.maxContextTokens * 4;
158
+ let usedBytes = 0;
159
+ let usedChars = 0;
160
+ let truncated = false;
161
+ const assemblySpan = telemetry?.startSpan("prompt.assembly", undefined, root);
162
+ for (const hit of ranked) {
163
+ if (hits.length >= limits.topK)
164
+ break;
165
+ const prefix = `[${hit.citationId}] `;
166
+ const separator = rendered.length ? "\n\n" : "";
167
+ const availableBytes = limits.maxResultBytes - usedBytes - byteLength(separator + prefix);
168
+ const availableChars = maxChars - usedChars - separator.length - prefix.length;
169
+ if (availableBytes <= 0 || availableChars <= 0) {
170
+ truncated = true;
171
+ break;
172
+ }
173
+ let text = hit.text.slice(0, availableChars);
174
+ text = truncateUtf8(text, availableBytes);
175
+ if (!text) {
176
+ truncated = true;
177
+ break;
178
+ }
179
+ if (text.length < hit.text.length)
180
+ truncated = true;
181
+ const renderedHit = Object.freeze({ ...hit, text });
182
+ const citation = Object.freeze({
183
+ id: renderedHit.citationId,
184
+ sourceId: renderedHit.sourceId,
185
+ chunkId: renderedHit.id,
186
+ provenance: renderedHit.provenance,
187
+ trust: renderedHit.trust,
188
+ ...(renderedHit.metadata ? { metadata: renderedHit.metadata } : {}),
189
+ });
190
+ const block = `${separator}${prefix}${text}`;
191
+ rendered.push(block);
192
+ usedBytes += byteLength(block);
193
+ usedChars += block.length;
194
+ hits.push(renderedHit);
195
+ citations.push(citation);
196
+ if (truncated)
197
+ break;
198
+ }
199
+ assemblySpan?.setAttribute("rag.result_count", hits.length);
200
+ assemblySpan?.end();
201
+ for (const hit of hits) {
202
+ root?.addEvent("chunk_retrieved", {
203
+ "rag.chunk.source_id": hit.sourceId,
204
+ "rag.chunk.id": hit.id,
205
+ "rag.chunk.rank": hit.retrievalRank,
206
+ "rag.chunk.score": hit.score,
207
+ "rag.chunk.embedder_id": options.embedder.id,
208
+ "rag.chunk.tenant_id": hit.provenance.tenantId,
209
+ "rag.chunk.corpus_id": hit.provenance.corpusId,
210
+ });
93
211
  }
94
- if (text.length < hit.text.length)
95
- truncated = true;
96
- const renderedHit = Object.freeze({ ...hit, text });
97
- const citation = Object.freeze({
98
- id: renderedHit.citationId,
99
- sourceId: renderedHit.sourceId,
100
- chunkId: renderedHit.id,
101
- provenance: renderedHit.provenance,
102
- trust: renderedHit.trust,
103
- ...(renderedHit.metadata ? { metadata: renderedHit.metadata } : {}),
212
+ return Object.freeze({
213
+ query: safeQuery,
214
+ trust: RETRIEVED_CONTENT_TRUST,
215
+ text: rendered.join(""),
216
+ hits: Object.freeze(hits),
217
+ citations: Object.freeze(citations),
218
+ truncated,
104
219
  });
105
- const block = `${separator}${prefix}${text}`;
106
- rendered.push(block);
107
- usedBytes += byteLength(block);
108
- usedChars += block.length;
109
- hits.push(renderedHit);
110
- citations.push(citation);
111
- if (truncated)
112
- break;
113
220
  }
114
- return Object.freeze({
115
- query: safeQuery,
116
- trust: RETRIEVED_CONTENT_TRUST,
117
- text: rendered.join(""),
118
- hits: Object.freeze(hits),
119
- citations: Object.freeze(citations),
120
- truncated,
121
- });
221
+ catch (error) {
222
+ root?.recordError();
223
+ throw error;
224
+ }
225
+ finally {
226
+ root?.end();
227
+ }
228
+ }
229
+ /** Opens a child span only when telemetry is present; otherwise runs the section untouched. */
230
+ async function span(telemetry, name, attributes, parent, fn) {
231
+ if (!telemetry)
232
+ return await fn(undefined);
233
+ const child = telemetry.startSpan(name, attributes, parent);
234
+ try {
235
+ return await fn(child);
236
+ }
237
+ catch (error) {
238
+ child.recordError();
239
+ throw error;
240
+ }
241
+ finally {
242
+ child.end();
243
+ }
122
244
  }
123
- function parseHit(hit, retrievalRank, retrievedAt) {
245
+ function parseHit(hit, retrievalRank, retrievedAt, retrieval) {
124
246
  const metadata = hit.metadata;
125
247
  const rag = metadata?._rag;
126
248
  if (!isJsonObject(rag))
@@ -144,7 +266,17 @@ function parseHit(hit, retrievalRank, retrievedAt) {
144
266
  userMetadata[key] = value;
145
267
  const web = isJsonObject(userMetadata.web) ? userMetadata.web : undefined;
146
268
  const provider = typeof web?.provider === "string" && web.provider.trim() ? web.provider : "host";
147
- const provenance = Object.freeze({ sourceId, chunkId: hit.id, citationId, provider, retrieval: "vector", retrievedAt });
269
+ const provenance = Object.freeze({
270
+ sourceId,
271
+ chunkId: hit.id,
272
+ citationId,
273
+ provider,
274
+ tenantId: hit.tenantId,
275
+ resourceId: hit.resourceId,
276
+ corpusId: hit.threadId,
277
+ retrieval,
278
+ retrievedAt,
279
+ });
148
280
  return {
149
281
  id: hit.id,
150
282
  citationId,
@@ -160,4 +292,42 @@ function parseHit(hit, retrievalRank, retrievedAt) {
160
292
  ...(Object.keys(userMetadata).length ? { metadata: userMetadata } : {}),
161
293
  };
162
294
  }
295
+ function resolveRetrieveScopes(options) {
296
+ const hasScope = options.scope !== undefined;
297
+ const hasScopes = options.scopes !== undefined;
298
+ if (hasScope && hasScopes)
299
+ throw new RagValidationError("provide either scope or scopes, not both");
300
+ if (!hasScope && !hasScopes)
301
+ throw new RagValidationError("scope or scopes is required");
302
+ const raw = hasScopes ? options.scopes : [options.scope];
303
+ if (raw.length > HARD_RETRIEVE_SCOPE_CAP)
304
+ throw new RagLimitError(`scopes exceeds hard cap ${HARD_RETRIEVE_SCOPE_CAP}`);
305
+ const seen = new Set();
306
+ const resolved = [];
307
+ for (const item of raw) {
308
+ const scope = requireScope(item);
309
+ const key = `${scope.tenantId}${scope.resourceId}${scope.corpusId}`;
310
+ if (seen.has(key))
311
+ continue;
312
+ seen.add(key);
313
+ resolved.push(scope);
314
+ }
315
+ return resolved;
316
+ }
317
+ function assertRequestedScope(scopes, actual) {
318
+ if (scopes.some((scope) => scope.tenantId === actual.tenantId && scope.resourceId === actual.resourceId && scope.corpusId === actual.threadId)) {
319
+ return;
320
+ }
321
+ throw new RagScopeError("vector hit crossed tenant/resource/corpus boundary");
322
+ }
323
+ function emptyResult(query) {
324
+ return Object.freeze({
325
+ query,
326
+ trust: RETRIEVED_CONTENT_TRUST,
327
+ text: "",
328
+ hits: Object.freeze([]),
329
+ citations: Object.freeze([]),
330
+ truncated: false,
331
+ });
332
+ }
163
333
  //# sourceMappingURL=retrieve.js.map
package/dist/sources.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface SourceMutationResult {
3
3
  readonly sourceId: string;
4
4
  readonly deleted: number;
5
5
  readonly indexed: number;
6
+ /** Set when an unchanged document hash short-circuited the replace. */
7
+ readonly skipped?: true;
6
8
  }
7
9
  export declare function replaceSource(options: ReplaceSourceOptions): Promise<SourceMutationResult>;
8
10
  export declare function deleteSource(options: DeleteSourceOptions): Promise<SourceMutationResult>;
package/dist/sources.js CHANGED
@@ -1,9 +1,14 @@
1
1
  import { resolveRedactor } from "@arnilo/prism";
2
2
  import { chunkText } from "./chunk.js";
3
3
  import { RagScopeError, RagValidationError } from "./errors.js";
4
+ import { isValidContentHash } from "./hash.js";
4
5
  import { indexChunkBatches } from "./indexing.js";
5
6
  import { ingestionStatus } from "./ingestion-status.js";
6
7
  import { assertNotAborted, byteLength, requireScope, requireSourceId } from "./util.js";
8
+ function storedDocHash(record) {
9
+ const value = record.metadata?._rag?.contentHash;
10
+ return isValidContentHash(value) ? value : undefined;
11
+ }
7
12
  export async function replaceSource(options) {
8
13
  const sourceId = requireSourceId(options.sourceId);
9
14
  const scope = requireScope(options.scope);
@@ -12,6 +17,10 @@ export async function replaceSource(options) {
12
17
  throw new RagValidationError("replaceSource chunks must all belong to sourceId");
13
18
  }
14
19
  const redactor = resolveRedactor(options.redactor, options.secrets);
20
+ if (options.contentHash !== undefined && !isValidContentHash(options.contentHash)) {
21
+ throw new RagValidationError("contentHash must be a hex digest of 32..128 characters");
22
+ }
23
+ const contentHash = options.contentHash?.toLowerCase();
15
24
  const totalBytes = options.chunks.reduce((total, chunk) => total + byteLength(redactor?.redact(chunk.text) ?? chunk.text), 0);
16
25
  const setStatus = async (state, error) => {
17
26
  if (!options.statusStore)
@@ -20,29 +29,82 @@ export async function replaceSource(options) {
20
29
  await options.statusStore.set(ingestionStatus(scope, sourceId, state, state === "indexed" ? totalBytes : 0, state === "indexed" ? options.chunks.length : 0, message ? (redactor?.redact(message) ?? message) : undefined));
21
30
  };
22
31
  await setStatus("pending");
32
+ const telemetry = options.telemetry;
33
+ const root = telemetry?.startSpan("rag_index", {
34
+ "rag.scope.tenant_id": scope.tenantId,
35
+ "rag.source_id": sourceId,
36
+ "rag.embedder_id": options.embedder.id,
37
+ "rag.chunk_count": options.chunks.length,
38
+ });
23
39
  try {
40
+ // One read decides the skip; unchanged sources cost zero embeds and zero writes.
41
+ const previous = await sourceRecords(options.store, sourceId, scope, options.signal);
42
+ if (contentHash &&
43
+ options.skipIfUnchanged !== false &&
44
+ previous.length > 0 &&
45
+ previous.every((record) => storedDocHash(record) === contentHash)) {
46
+ // Incoming stats describe the now-live content even though nothing was rewritten.
47
+ await setStatus("indexed", undefined);
48
+ return Object.freeze({ sourceId, deleted: 0, indexed: 0, skipped: true });
49
+ }
50
+ const reuseEmbeddings = new Map();
51
+ if (options.skipIfUnchanged !== false) {
52
+ // skipIfUnchanged: false means rebuild everything — no embedding reuse either.
53
+ for (const record of previous) {
54
+ if (record.embedderId === options.embedder.id) {
55
+ reuseEmbeddings.set(record.id, { text: record.text, embedding: record.embedding });
56
+ }
57
+ }
58
+ }
24
59
  const staged = [];
25
- const indexed = await indexChunkBatches({ ...options, statusStore: undefined }, async (records) => {
60
+ const indexed = await indexChunkBatches({ ...options, statusStore: undefined, contentHash, reuseEmbeddings, telemetry, telemetryParent: root }, async (records) => {
26
61
  staged.push(...records);
27
62
  });
28
63
  assertNotAborted(options.signal);
29
64
  const result = await options.store.transaction(async (store) => {
65
+ // Generation visibility: stamp chunks at N+1 and advance the scope pointer in the
66
+ // same transaction as the swap. Stores without generation tracking keep legacy behavior.
67
+ const getCurrent = store.getCurrentGeneration?.bind(store);
68
+ const setCurrent = store.setCurrentGeneration?.bind(store);
69
+ let nextGeneration;
70
+ if (getCurrent && setCurrent) {
71
+ const current = await getCurrent({
72
+ tenantId: scope.tenantId,
73
+ resourceId: scope.resourceId,
74
+ threadId: scope.corpusId,
75
+ });
76
+ nextGeneration = (current === undefined ? 0 : Number(current)) + 1;
77
+ root?.setAttribute("rag.index_generation", nextGeneration);
78
+ }
30
79
  const previous = await sourceRecords(store, sourceId, scope, options.signal);
31
80
  assertNotAborted(options.signal);
32
81
  if (previous.length) {
33
82
  await store.delete({ tenantId: scope.tenantId, resourceId: scope.resourceId, threadId: scope.corpusId, ids: previous.map((record) => record.id) }, { signal: options.signal });
34
83
  }
35
- if (staged.length)
36
- await store.upsert(staged, { signal: options.signal });
84
+ if (staged.length) {
85
+ const stamped = nextGeneration === undefined ? staged : staged.map((record) => ({ ...record, generation: nextGeneration }));
86
+ await store.upsert(stamped, { signal: options.signal });
87
+ }
88
+ if (setCurrent && nextGeneration !== undefined) {
89
+ await setCurrent({
90
+ tenantId: scope.tenantId,
91
+ resourceId: scope.resourceId,
92
+ threadId: scope.corpusId,
93
+ }, nextGeneration);
94
+ }
37
95
  return Object.freeze({ sourceId, deleted: previous.length, indexed: indexed.indexed });
38
96
  }, { signal: options.signal });
39
97
  await setStatus("indexed");
40
98
  return result;
41
99
  }
42
100
  catch (error) {
101
+ root?.recordError();
43
102
  await setStatus("failed", error);
44
103
  throw error;
45
104
  }
105
+ finally {
106
+ root?.end();
107
+ }
46
108
  }
47
109
  export async function deleteSource(options) {
48
110
  const sourceId = requireSourceId(options.sourceId);
@@ -0,0 +1,35 @@
1
+ import { type SsrfPolicy } from "@arnilo/prism";
2
+ import type { Reranker } from "./types.js";
3
+ /**
4
+ * Hugging Face TEI rerank adapter (plan 034 Task 8 / request P8):
5
+ * `POST <baseUrl>/rerank` with `{query, texts, raw_scores:false}` →
6
+ * `{results: [{index, score}]}` mapped to a permutation-only reorder of the
7
+ * provided `RagHit[]` (same object references — provenance/trust untouched).
8
+ *
9
+ * - URL shape validated at construction (absolute, http/https, no embedded
10
+ * credentials or fragment); SSRF/enforcement is host-side via `ssrf`,
11
+ * `allowLoopback`, or an injected `fetch`. Default transport is the core
12
+ * `pinnedFetch` primitive (DNS-pinned, redirect-free, byte-bounded).
13
+ * - Out-of-range/duplicate/missing indices, non-finite scores, HTTP errors,
14
+ * timeouts, and oversized bodies all fail closed in the rerank error
15
+ * family. Seam caps (`maxRerankBytes`, `maxRerankMs`, `rerankConcurrency`)
16
+ * stay enforced by `rerankHits` around this adapter.
17
+ * - No credentials, no SaaS default URL.
18
+ */
19
+ export interface CreateTeiRerankerOptions {
20
+ /** Base URL of the TEI service, e.g. `http://tei.svc:8080`. `/rerank` is appended. */
21
+ readonly baseUrl: string;
22
+ /** Optional model name sent in the rerank body. */
23
+ readonly model?: string;
24
+ /** Per-call timeout combined with the caller signal; aborts fail closed. */
25
+ readonly timeoutMs?: number;
26
+ /** SSRF policy applied on resolved hosts (default: core default). */
27
+ readonly ssrf?: SsrfPolicy;
28
+ /** Allow loopback destinations (local/dev TEI). Default `false`. */
29
+ readonly allowLoopback?: boolean;
30
+ /** Maximum response body bytes. Default 65,536 (plan 021 ceiling precedent). */
31
+ readonly maxResponseBytes?: number;
32
+ /** Trusted custom transport; host owns DNS/Bonding protection (OPA precedent). */
33
+ readonly fetch?: typeof globalThis.fetch;
34
+ }
35
+ export declare function createTeiReranker(options: CreateTeiRerankerOptions): Reranker;
@@ -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.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "Optional bounded text and Markdown RAG primitives for Prism.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,8 +33,8 @@
33
33
  "pack:dry-run": "npm pack --dry-run"
34
34
  },
35
35
  "peerDependencies": {
36
- "@arnilo/prism": "0.2.9",
37
- "@arnilo/prism-memory": "0.2.9"
36
+ "@arnilo/prism": "^0.3.0",
37
+ "@arnilo/prism-memory": "^0.3.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@arnilo/prism": "file:../..",