@arnilo/prism-rag 0.3.0 → 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 +5 -0
- package/dist/chunk.js +32 -3
- package/dist/fusion.d.ts +18 -0
- package/dist/fusion.js +0 -0
- package/dist/hash.d.ts +6 -0
- package/dist/hash.js +9 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +4 -1
- package/dist/indexing.js +33 -3
- package/dist/limits.d.ts +4 -0
- package/dist/limits.js +4 -0
- package/dist/retrieve.js +262 -92
- package/dist/sources.d.ts +2 -0
- package/dist/sources.js +65 -3
- package/dist/tei-reranker.d.ts +35 -0
- package/dist/tei-reranker.js +138 -0
- package/dist/telemetry.d.ts +16 -0
- package/dist/telemetry.js +2 -0
- package/dist/types.d.ts +35 -2
- package/package.json +1 -1
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
|
-
|
|
22
|
-
|
|
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
|
-
...(
|
|
75
|
+
...(metadata ? { metadata } : {}),
|
|
47
76
|
}));
|
|
48
77
|
if (chunks.length > limits.maxChunks)
|
|
49
78
|
throw new RagLimitError(`chunk count exceeds ${limits.maxChunks}`);
|
package/dist/fusion.d.ts
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
68
|
-
|
|
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: {
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
+
}
|
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
|
|
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
|
-
|
|
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;
|