@arnilo/prism-rag 0.0.14 → 0.0.16
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 +13 -0
- package/README.md +11 -5
- package/dist/chunk.d.ts +1 -1
- package/dist/chunk.js +1 -1
- package/dist/context.js +6 -4
- package/dist/index.d.ts +10 -5
- package/dist/index.js +7 -3
- package/dist/indexing.d.ts +2 -0
- package/dist/indexing.js +79 -57
- package/dist/ingestion-status.d.ts +7 -0
- package/dist/ingestion-status.js +87 -0
- package/dist/limits.d.ts +21 -0
- package/dist/limits.js +21 -0
- package/dist/loaders.d.ts +24 -0
- package/dist/loaders.js +78 -0
- package/dist/parsers.d.ts +5 -0
- package/dist/parsers.js +101 -0
- package/dist/rerank.d.ts +11 -0
- package/dist/rerank.js +62 -0
- package/dist/retrieve.js +64 -36
- package/dist/sources.d.ts +9 -0
- package/dist/sources.js +118 -0
- package/dist/types.d.ts +128 -1
- package/dist/util.d.ts +1 -2
- package/dist/util.js +1 -7
- package/package.json +11 -3
package/dist/parsers.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { RagLimitError, RagValidationError } from "./errors.js";
|
|
2
|
+
import { resolveRagLimits } from "./limits.js";
|
|
3
|
+
import { assertNotAborted } from "./util.js";
|
|
4
|
+
export const textParser = { parse: (document, options) => parseText(document, options, ["text/plain"]) };
|
|
5
|
+
export const markdownParser = { parse: (document, options) => parseText(document, options, ["text/markdown", "text/x-markdown"]) };
|
|
6
|
+
export const htmlParser = {
|
|
7
|
+
parse: (document, options) => parseText(document, options, ["text/html", "application/xhtml+xml"], htmlToText),
|
|
8
|
+
};
|
|
9
|
+
export const pdfParser = { parse: parsePdf };
|
|
10
|
+
async function parseText(document, options = {}, mediaTypes, transform = (text) => text) {
|
|
11
|
+
assertMediaType(document, mediaTypes);
|
|
12
|
+
const { bytes, limits, started } = boundedDocument(document, options);
|
|
13
|
+
assertNotAborted(options.signal);
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
text = document.text ?? new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new RagValidationError("document is not valid UTF-8 text");
|
|
20
|
+
}
|
|
21
|
+
const parsed = transform(text);
|
|
22
|
+
assertParseTime(started, limits.maxParseMs);
|
|
23
|
+
assertNotAborted(options.signal);
|
|
24
|
+
return Object.freeze({ text: parsed, ...(document.metadata ? { metadata: document.metadata } : {}) });
|
|
25
|
+
}
|
|
26
|
+
async function parsePdf(document, options = {}) {
|
|
27
|
+
assertMediaType(document, ["application/pdf"]);
|
|
28
|
+
const { bytes, limits, started } = boundedDocument(document, options);
|
|
29
|
+
if (document.text !== undefined)
|
|
30
|
+
throw new RagValidationError("PDF parser requires binary document data");
|
|
31
|
+
const pdf = Buffer.from(bytes).toString("latin1");
|
|
32
|
+
if (!pdf.startsWith("%PDF-"))
|
|
33
|
+
throw new RagValidationError("document is not a PDF");
|
|
34
|
+
if (/\/Filter\s*\//u.test(pdf))
|
|
35
|
+
throw new RagValidationError("compressed PDFs require a host parser");
|
|
36
|
+
const pages = [...pdf.matchAll(/\/Type\s*\/Page\b/gu)].length;
|
|
37
|
+
if (pages > limits.maxPdfPages)
|
|
38
|
+
throw new RagLimitError(`PDF exceeds ${limits.maxPdfPages} pages`);
|
|
39
|
+
const blocks = pdf.match(/BT[\s\S]*?ET/gu) ?? [];
|
|
40
|
+
const text = blocks.flatMap(pdfBlockText).join("\n").trim();
|
|
41
|
+
if (!text)
|
|
42
|
+
throw new RagValidationError("PDF has no uncompressed text");
|
|
43
|
+
assertParseTime(started, limits.maxParseMs);
|
|
44
|
+
assertNotAborted(options.signal);
|
|
45
|
+
return Object.freeze({ text, ...(document.metadata ? { metadata: document.metadata } : {}) });
|
|
46
|
+
}
|
|
47
|
+
function boundedDocument(document, options) {
|
|
48
|
+
assertNotAborted(options.signal);
|
|
49
|
+
const limits = resolveRagLimits({
|
|
50
|
+
maxDocumentBytes: options.maxBytes,
|
|
51
|
+
maxParseMs: options.maxParseMs,
|
|
52
|
+
maxPdfPages: options.maxPages,
|
|
53
|
+
});
|
|
54
|
+
const bytes = document.data ?? Buffer.from(document.text ?? "", "utf8");
|
|
55
|
+
if (bytes.byteLength > limits.maxDocumentBytes)
|
|
56
|
+
throw new RagLimitError(`document exceeds ${limits.maxDocumentBytes} bytes`);
|
|
57
|
+
return { bytes, limits, started: Date.now() };
|
|
58
|
+
}
|
|
59
|
+
function assertMediaType(document, expected) {
|
|
60
|
+
if (document.mediaType && !expected.includes(document.mediaType.toLowerCase())) {
|
|
61
|
+
throw new RagValidationError(`parser does not accept ${document.mediaType}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function assertParseTime(started, maxParseMs) {
|
|
65
|
+
if (Date.now() - started > maxParseMs)
|
|
66
|
+
throw new RagLimitError(`document parsing exceeded ${maxParseMs}ms`);
|
|
67
|
+
}
|
|
68
|
+
function htmlToText(html) {
|
|
69
|
+
return decodeEntities(html
|
|
70
|
+
.replace(/<!--[\s\S]*?-->/gu, "")
|
|
71
|
+
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/giu, "")
|
|
72
|
+
.replace(/<(?:br|p|div|li|h[1-6]|tr)\b[^>]*>/giu, "\n")
|
|
73
|
+
.replace(/<[^>]*>/gu, " "))
|
|
74
|
+
.replace(/[ \t]{2,}/gu, " ")
|
|
75
|
+
.replace(/[ \t]+\n/gu, "\n")
|
|
76
|
+
.replace(/\n[ \t]+/gu, "\n")
|
|
77
|
+
.replace(/\n{3,}/gu, "\n\n")
|
|
78
|
+
.trim();
|
|
79
|
+
}
|
|
80
|
+
function decodeEntities(text) {
|
|
81
|
+
const entities = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'" };
|
|
82
|
+
return text.replace(/&([a-z]+|#39);/giu, (_match, name) => entities[name.toLowerCase()] ?? `&${name};`);
|
|
83
|
+
}
|
|
84
|
+
function pdfBlockText(block) {
|
|
85
|
+
const strings = [];
|
|
86
|
+
for (const match of block.matchAll(/\((?:\\.|[^\\)])*\)\s*(?:Tj|['"])/gu))
|
|
87
|
+
strings.push(decodePdfString(match[0].replace(/\s*(?:Tj|['"])$/u, "")));
|
|
88
|
+
for (const match of block.matchAll(/\[([\s\S]*?)\]\s*TJ/gu)) {
|
|
89
|
+
for (const value of match[1].matchAll(/\((?:\\.|[^\\)])*\)/gu))
|
|
90
|
+
strings.push(decodePdfString(value[0]));
|
|
91
|
+
}
|
|
92
|
+
return strings.filter(Boolean);
|
|
93
|
+
}
|
|
94
|
+
function decodePdfString(value) {
|
|
95
|
+
return value.slice(1, -1).replace(/\\([0-7]{1,3}|[nrtbf()\\])/gu, (_match, escape) => {
|
|
96
|
+
if (/^[0-7]+$/u.test(escape))
|
|
97
|
+
return String.fromCharCode(Number.parseInt(escape, 8));
|
|
98
|
+
return { n: "\n", r: "\r", t: "\t", b: "\b", f: "\f", "(": "(", ")": ")", "\\": "\\" }[escape];
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=parsers.js.map
|
package/dist/rerank.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolveRedactor } from "@arnilo/prism";
|
|
2
|
+
import type { RagHit, Reranker } from "./types.js";
|
|
3
|
+
export declare function rerankHits(query: string, hits: readonly RagHit[], options: {
|
|
4
|
+
readonly reranker: Reranker;
|
|
5
|
+
readonly maxBytes: number;
|
|
6
|
+
readonly maxMs: number;
|
|
7
|
+
readonly concurrency: number;
|
|
8
|
+
readonly signal?: AbortSignal;
|
|
9
|
+
readonly redactor?: Parameters<typeof resolveRedactor>[0];
|
|
10
|
+
readonly secrets?: Parameters<typeof resolveRedactor>[1];
|
|
11
|
+
}): Promise<readonly RagHit[]>;
|
package/dist/rerank.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { resolveRedactor } from "@arnilo/prism";
|
|
2
|
+
import { RagAbortError, RagLimitError, RagValidationError } from "./errors.js";
|
|
3
|
+
import { assertNotAborted, byteLength } from "./util.js";
|
|
4
|
+
const active = new WeakMap();
|
|
5
|
+
export async function rerankHits(query, hits, options) {
|
|
6
|
+
assertNotAborted(options.signal);
|
|
7
|
+
const redactor = resolveRedactor(options.redactor, options.secrets);
|
|
8
|
+
const safeQuery = redactor?.redact(query) ?? query;
|
|
9
|
+
const safeHits = hits.map((hit) => redactor?.redact(hit) ?? hit);
|
|
10
|
+
if (byteLength({ query: safeQuery, hits: safeHits }) > options.maxBytes) {
|
|
11
|
+
throw new RagLimitError(`reranker input exceeds ${options.maxBytes} bytes`);
|
|
12
|
+
}
|
|
13
|
+
const running = active.get(options.reranker) ?? 0;
|
|
14
|
+
if (running >= options.concurrency)
|
|
15
|
+
throw new RagLimitError(`reranker concurrency exceeds ${options.concurrency}`);
|
|
16
|
+
active.set(options.reranker, running + 1);
|
|
17
|
+
const release = () => {
|
|
18
|
+
const remaining = (active.get(options.reranker) ?? 1) - 1;
|
|
19
|
+
if (remaining)
|
|
20
|
+
active.set(options.reranker, remaining);
|
|
21
|
+
else
|
|
22
|
+
active.delete(options.reranker);
|
|
23
|
+
};
|
|
24
|
+
try {
|
|
25
|
+
const ordered = await boundedRerank((signal) => options.reranker.rerank({ query: safeQuery, hits: safeHits, signal }), (operation) => {
|
|
26
|
+
void operation.then(release, release);
|
|
27
|
+
}, options.maxMs, options.signal);
|
|
28
|
+
const originals = new Map(hits.map((hit) => [hit.id, hit]));
|
|
29
|
+
if (ordered.length !== hits.length || new Set(ordered.map((hit) => hit.id)).size !== hits.length) {
|
|
30
|
+
throw new RagValidationError("reranker must return each retrieved hit exactly once");
|
|
31
|
+
}
|
|
32
|
+
const output = ordered.map((hit) => originals.get(hit.id));
|
|
33
|
+
if (output.some((hit) => !hit))
|
|
34
|
+
throw new RagValidationError("reranker returned an unknown hit");
|
|
35
|
+
return Object.freeze(output);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof RagAbortError || error instanceof RagLimitError || error instanceof RagValidationError)
|
|
39
|
+
throw error;
|
|
40
|
+
const message = error instanceof Error ? error.message : "reranker failed";
|
|
41
|
+
throw new RagValidationError(`reranker failed: ${redactor?.redact(message) ?? message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function boundedRerank(run, onStart, maxMs, signal) {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const onAbort = () => controller.abort();
|
|
47
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
48
|
+
const timeout = setTimeout(() => controller.abort(), maxMs);
|
|
49
|
+
const aborted = new Promise((_, reject) => controller.signal.addEventListener("abort", () => {
|
|
50
|
+
reject(signal?.aborted ? new RagAbortError() : new RagLimitError(`reranker exceeded ${maxMs}ms`));
|
|
51
|
+
}, { once: true }));
|
|
52
|
+
const operation = Promise.resolve().then(() => run(controller.signal));
|
|
53
|
+
onStart(operation);
|
|
54
|
+
try {
|
|
55
|
+
return await Promise.race([operation, aborted]);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
signal?.removeEventListener("abort", onAbort);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=rerank.js.map
|
package/dist/retrieve.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveRedactor } from "@arnilo/prism";
|
|
2
2
|
import { RagScopeError, RagValidationError } from "./errors.js";
|
|
3
|
-
import {
|
|
3
|
+
import { HARD_CHUNK_SIZE_CAP, resolveRagLimits } from "./limits.js";
|
|
4
|
+
import { rerankHits } from "./rerank.js";
|
|
5
|
+
import { assertBytes, assertNotAborted, assertScope, byteLength, isJsonObject, matchesFilter, nonEmpty, requireScope, requireSourceId, truncateUtf8, } from "./util.js";
|
|
6
|
+
const RETRIEVED_CONTENT_TRUST = Object.freeze({ untrusted: true, inert: true, injectionCapable: true });
|
|
4
7
|
export async function retrieveContext(query, options) {
|
|
5
8
|
nonEmpty(query, "query");
|
|
6
|
-
if (query.length > HARD_CHUNK_SIZE_CAP)
|
|
9
|
+
if (query.length > HARD_CHUNK_SIZE_CAP)
|
|
7
10
|
throw new RagValidationError(`query exceeds ${HARD_CHUNK_SIZE_CAP} characters`);
|
|
8
|
-
}
|
|
9
11
|
const scope = requireScope(options.scope);
|
|
10
12
|
const limits = resolveRagLimits({
|
|
11
13
|
topK: options.topK,
|
|
@@ -14,10 +16,13 @@ export async function retrieveContext(query, options) {
|
|
|
14
16
|
maxContextTokens: options.maxContextTokens,
|
|
15
17
|
maxMetadataBytes: options.maxMetadataBytes,
|
|
16
18
|
maxVectorDimensions: options.maxVectorDimensions,
|
|
19
|
+
maxRerankBytes: options.maxRerankBytes,
|
|
20
|
+
maxRerankMs: options.maxRerankMs,
|
|
21
|
+
rerankConcurrency: options.rerankConcurrency,
|
|
17
22
|
});
|
|
18
|
-
if (!Number.isInteger(options.embedder.dimensions)
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
if (!Number.isInteger(options.embedder.dimensions) ||
|
|
24
|
+
options.embedder.dimensions <= 0 ||
|
|
25
|
+
options.embedder.dimensions > limits.maxVectorDimensions) {
|
|
21
26
|
throw new RagValidationError(`embedder dimensions must be an integer in 1..${limits.maxVectorDimensions}`);
|
|
22
27
|
}
|
|
23
28
|
if (options.filter)
|
|
@@ -27,10 +32,10 @@ export async function retrieveContext(query, options) {
|
|
|
27
32
|
assertNotAborted(options.signal);
|
|
28
33
|
const vectors = await options.embedder.embed([safeQuery], { signal: options.signal });
|
|
29
34
|
const embedding = vectors[0];
|
|
30
|
-
if (vectors.length !== 1
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
if (vectors.length !== 1 ||
|
|
36
|
+
!embedding ||
|
|
37
|
+
embedding.length !== options.embedder.dimensions ||
|
|
38
|
+
embedding.some((value) => !Number.isFinite(value))) {
|
|
34
39
|
throw new RagValidationError("embedder returned invalid query vector");
|
|
35
40
|
}
|
|
36
41
|
const candidates = await options.store.query({
|
|
@@ -42,6 +47,26 @@ export async function retrieveContext(query, options) {
|
|
|
42
47
|
signal: options.signal,
|
|
43
48
|
});
|
|
44
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));
|
|
58
|
+
}
|
|
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;
|
|
45
70
|
const hits = [];
|
|
46
71
|
const citations = [];
|
|
47
72
|
const rendered = [];
|
|
@@ -49,15 +74,10 @@ export async function retrieveContext(query, options) {
|
|
|
49
74
|
let usedBytes = 0;
|
|
50
75
|
let usedChars = 0;
|
|
51
76
|
let truncated = false;
|
|
52
|
-
for (const
|
|
53
|
-
assertScope(scope, candidate);
|
|
54
|
-
const parsed = parseHit(candidate);
|
|
55
|
-
if (!matchesFilter(parsed.metadata, options.filter))
|
|
56
|
-
continue;
|
|
77
|
+
for (const hit of ranked) {
|
|
57
78
|
if (hits.length >= limits.topK)
|
|
58
79
|
break;
|
|
59
|
-
const
|
|
60
|
-
const prefix = `[${parsed.citationId}] `;
|
|
80
|
+
const prefix = `[${hit.citationId}] `;
|
|
61
81
|
const separator = rendered.length ? "\n\n" : "";
|
|
62
82
|
const availableBytes = limits.maxResultBytes - usedBytes - byteLength(separator + prefix);
|
|
63
83
|
const availableChars = maxChars - usedChars - separator.length - prefix.length;
|
|
@@ -65,61 +85,66 @@ export async function retrieveContext(query, options) {
|
|
|
65
85
|
truncated = true;
|
|
66
86
|
break;
|
|
67
87
|
}
|
|
68
|
-
let text =
|
|
88
|
+
let text = hit.text.slice(0, availableChars);
|
|
69
89
|
text = truncateUtf8(text, availableBytes);
|
|
70
90
|
if (!text) {
|
|
71
91
|
truncated = true;
|
|
72
92
|
break;
|
|
73
93
|
}
|
|
74
|
-
if (text.length <
|
|
94
|
+
if (text.length < hit.text.length)
|
|
75
95
|
truncated = true;
|
|
76
|
-
const
|
|
96
|
+
const renderedHit = Object.freeze({ ...hit, text });
|
|
77
97
|
const citation = Object.freeze({
|
|
78
|
-
id:
|
|
79
|
-
sourceId:
|
|
80
|
-
chunkId:
|
|
81
|
-
|
|
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 } : {}),
|
|
82
104
|
});
|
|
83
105
|
const block = `${separator}${prefix}${text}`;
|
|
84
106
|
rendered.push(block);
|
|
85
107
|
usedBytes += byteLength(block);
|
|
86
108
|
usedChars += block.length;
|
|
87
|
-
hits.push(
|
|
109
|
+
hits.push(renderedHit);
|
|
88
110
|
citations.push(citation);
|
|
89
111
|
if (truncated)
|
|
90
112
|
break;
|
|
91
113
|
}
|
|
92
114
|
return Object.freeze({
|
|
93
115
|
query: safeQuery,
|
|
116
|
+
trust: RETRIEVED_CONTENT_TRUST,
|
|
94
117
|
text: rendered.join(""),
|
|
95
118
|
hits: Object.freeze(hits),
|
|
96
119
|
citations: Object.freeze(citations),
|
|
97
120
|
truncated,
|
|
98
121
|
});
|
|
99
122
|
}
|
|
100
|
-
function parseHit(hit) {
|
|
123
|
+
function parseHit(hit, retrievalRank, retrievedAt) {
|
|
101
124
|
const metadata = hit.metadata;
|
|
102
125
|
const rag = metadata?._rag;
|
|
103
126
|
if (!isJsonObject(rag))
|
|
104
127
|
throw new RagScopeError("vector hit is missing RAG source metadata");
|
|
105
128
|
const sourceId = requireSourceId(rag.sourceId);
|
|
106
129
|
const citationId = nonEmpty(rag.citationId, "metadata._rag.citationId");
|
|
107
|
-
if (!Number.isInteger(rag.chunkIndex)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
130
|
+
if (!Number.isInteger(rag.chunkIndex) ||
|
|
131
|
+
Number(rag.chunkIndex) < 0 ||
|
|
132
|
+
!Number.isInteger(rag.start) ||
|
|
133
|
+
Number(rag.start) < 0 ||
|
|
134
|
+
!Number.isInteger(rag.end) ||
|
|
135
|
+
Number(rag.end) < Number(rag.start) ||
|
|
136
|
+
!Number.isFinite(hit.score)) {
|
|
114
137
|
throw new RagValidationError("vector hit has invalid RAG offsets");
|
|
115
138
|
}
|
|
116
|
-
if (hit.id !== citationId || !citationId.startsWith(`${sourceId}#`))
|
|
139
|
+
if (hit.id !== citationId || !citationId.startsWith(`${sourceId}#`))
|
|
117
140
|
throw new RagValidationError("vector hit has inconsistent citation identity");
|
|
118
|
-
}
|
|
119
141
|
const userMetadata = {};
|
|
120
142
|
for (const [key, value] of Object.entries(metadata ?? {}))
|
|
121
143
|
if (key !== "_rag")
|
|
122
144
|
userMetadata[key] = value;
|
|
145
|
+
const web = isJsonObject(userMetadata.web) ? userMetadata.web : undefined;
|
|
146
|
+
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 });
|
|
123
148
|
return {
|
|
124
149
|
id: hit.id,
|
|
125
150
|
citationId,
|
|
@@ -129,6 +154,9 @@ function parseHit(hit) {
|
|
|
129
154
|
end: rag.end,
|
|
130
155
|
text: hit.text,
|
|
131
156
|
score: hit.score,
|
|
157
|
+
retrievalRank,
|
|
158
|
+
provenance,
|
|
159
|
+
trust: RETRIEVED_CONTENT_TRUST,
|
|
132
160
|
...(Object.keys(userMetadata).length ? { metadata: userMetadata } : {}),
|
|
133
161
|
};
|
|
134
162
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DeleteSourceOptions, ReplaceDocumentOptions, ReplaceSourceOptions } from "./types.js";
|
|
2
|
+
export interface SourceMutationResult {
|
|
3
|
+
readonly sourceId: string;
|
|
4
|
+
readonly deleted: number;
|
|
5
|
+
readonly indexed: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function replaceSource(options: ReplaceSourceOptions): Promise<SourceMutationResult>;
|
|
8
|
+
export declare function deleteSource(options: DeleteSourceOptions): Promise<SourceMutationResult>;
|
|
9
|
+
export declare function replaceDocument(options: ReplaceDocumentOptions): Promise<SourceMutationResult>;
|
package/dist/sources.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { resolveRedactor } from "@arnilo/prism";
|
|
2
|
+
import { chunkText } from "./chunk.js";
|
|
3
|
+
import { RagScopeError, RagValidationError } from "./errors.js";
|
|
4
|
+
import { indexChunkBatches } from "./indexing.js";
|
|
5
|
+
import { ingestionStatus } from "./ingestion-status.js";
|
|
6
|
+
import { assertNotAborted, byteLength, requireScope, requireSourceId } from "./util.js";
|
|
7
|
+
export async function replaceSource(options) {
|
|
8
|
+
const sourceId = requireSourceId(options.sourceId);
|
|
9
|
+
const scope = requireScope(options.scope);
|
|
10
|
+
assertTransactionalStore(options.store);
|
|
11
|
+
if (options.chunks.some((chunk) => chunk.sourceId !== sourceId)) {
|
|
12
|
+
throw new RagValidationError("replaceSource chunks must all belong to sourceId");
|
|
13
|
+
}
|
|
14
|
+
const redactor = resolveRedactor(options.redactor, options.secrets);
|
|
15
|
+
const totalBytes = options.chunks.reduce((total, chunk) => total + byteLength(redactor?.redact(chunk.text) ?? chunk.text), 0);
|
|
16
|
+
const setStatus = async (state, error) => {
|
|
17
|
+
if (!options.statusStore)
|
|
18
|
+
return;
|
|
19
|
+
const message = error instanceof Error ? error.message : error === undefined ? undefined : "source replacement failed";
|
|
20
|
+
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
|
+
};
|
|
22
|
+
await setStatus("pending");
|
|
23
|
+
try {
|
|
24
|
+
const staged = [];
|
|
25
|
+
const indexed = await indexChunkBatches({ ...options, statusStore: undefined }, async (records) => {
|
|
26
|
+
staged.push(...records);
|
|
27
|
+
});
|
|
28
|
+
assertNotAborted(options.signal);
|
|
29
|
+
const result = await options.store.transaction(async (store) => {
|
|
30
|
+
const previous = await sourceRecords(store, sourceId, scope, options.signal);
|
|
31
|
+
assertNotAborted(options.signal);
|
|
32
|
+
if (previous.length) {
|
|
33
|
+
await store.delete({ tenantId: scope.tenantId, resourceId: scope.resourceId, threadId: scope.corpusId, ids: previous.map((record) => record.id) }, { signal: options.signal });
|
|
34
|
+
}
|
|
35
|
+
if (staged.length)
|
|
36
|
+
await store.upsert(staged, { signal: options.signal });
|
|
37
|
+
return Object.freeze({ sourceId, deleted: previous.length, indexed: indexed.indexed });
|
|
38
|
+
}, { signal: options.signal });
|
|
39
|
+
await setStatus("indexed");
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
await setStatus("failed", error);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function deleteSource(options) {
|
|
48
|
+
const sourceId = requireSourceId(options.sourceId);
|
|
49
|
+
const scope = requireScope(options.scope);
|
|
50
|
+
assertSourceStore(options.store);
|
|
51
|
+
const records = await sourceRecords(options.store, sourceId, scope, options.signal);
|
|
52
|
+
assertNotAborted(options.signal);
|
|
53
|
+
const deleted = records.length
|
|
54
|
+
? await options.store.delete({ tenantId: scope.tenantId, resourceId: scope.resourceId, threadId: scope.corpusId, ids: records.map((record) => record.id) }, { signal: options.signal })
|
|
55
|
+
: 0;
|
|
56
|
+
await options.statusStore?.delete(scope, sourceId);
|
|
57
|
+
return Object.freeze({ sourceId, deleted, indexed: 0 });
|
|
58
|
+
}
|
|
59
|
+
export async function replaceDocument(options) {
|
|
60
|
+
const loaded = await options.loader.load(options.uri, {
|
|
61
|
+
...options.loaderOptions,
|
|
62
|
+
signal: options.signal ?? options.loaderOptions?.signal,
|
|
63
|
+
});
|
|
64
|
+
assertNotAborted(options.signal);
|
|
65
|
+
const parsed = await options.parser.parse(loaded, {
|
|
66
|
+
...options.parserOptions,
|
|
67
|
+
signal: options.signal ?? options.parserOptions?.signal,
|
|
68
|
+
});
|
|
69
|
+
assertNotAborted(options.signal);
|
|
70
|
+
const sourceId = options.sourceId ?? loaded.sourceId;
|
|
71
|
+
if (!sourceId)
|
|
72
|
+
throw new RagValidationError("replaceDocument requires sourceId when its loader does not provide one");
|
|
73
|
+
const { metadata, ...chunk } = options.chunk ?? {};
|
|
74
|
+
const mergedMetadata = mergeMetadata(parsed.metadata, metadata);
|
|
75
|
+
const chunks = (options.chunker ?? chunkText)(parsed.text, {
|
|
76
|
+
...chunk,
|
|
77
|
+
sourceId,
|
|
78
|
+
...(mergedMetadata ? { metadata: mergedMetadata } : {}),
|
|
79
|
+
});
|
|
80
|
+
return replaceSource({
|
|
81
|
+
...options,
|
|
82
|
+
sourceId,
|
|
83
|
+
chunks,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function mergeMetadata(parser, supplied) {
|
|
87
|
+
if (!parser && !supplied)
|
|
88
|
+
return undefined;
|
|
89
|
+
return { ...supplied, ...parser }; // Loader trust metadata wins over caller-supplied metadata.
|
|
90
|
+
}
|
|
91
|
+
async function sourceRecords(store, sourceId, scope, signal) {
|
|
92
|
+
assertNotAborted(signal);
|
|
93
|
+
const records = await store.getBySource({ tenantId: scope.tenantId, resourceId: scope.resourceId, threadId: scope.corpusId }, sourceId, {
|
|
94
|
+
signal,
|
|
95
|
+
});
|
|
96
|
+
for (const record of records) {
|
|
97
|
+
if (record.tenantId !== scope.tenantId || record.resourceId !== scope.resourceId || record.threadId !== scope.corpusId) {
|
|
98
|
+
throw new RagScopeError("source lookup crossed tenant/resource/corpus boundary");
|
|
99
|
+
}
|
|
100
|
+
const rag = record.metadata?._rag;
|
|
101
|
+
if (typeof rag !== "object" || rag === null || Array.isArray(rag) || rag.sourceId !== sourceId) {
|
|
102
|
+
throw new RagScopeError("source lookup returned a different source");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return records;
|
|
106
|
+
}
|
|
107
|
+
function assertSourceStore(store) {
|
|
108
|
+
if (!store || typeof store !== "object" || typeof store.getBySource !== "function") {
|
|
109
|
+
throw new RagValidationError("source deletion requires a scoped source-aware vector store");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function assertTransactionalStore(store) {
|
|
113
|
+
if (!store || typeof store !== "object" || typeof store.transaction !== "function") {
|
|
114
|
+
throw new RagValidationError("atomic source replacement requires a transactional vector store");
|
|
115
|
+
}
|
|
116
|
+
assertSourceStore(store);
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=sources.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ContextProvider, JsonObject, Message, SecretRedactor } from "@arnilo/prism";
|
|
2
|
-
import type { Embedder, VectorStore } from "@arnilo/prism-memory";
|
|
2
|
+
import type { Embedder, MemoryVectorRecord, VectorStore } from "@arnilo/prism-memory";
|
|
3
3
|
export interface RagScope {
|
|
4
4
|
readonly tenantId: string;
|
|
5
5
|
readonly resourceId: string;
|
|
@@ -23,6 +23,80 @@ export interface ChunkOptions {
|
|
|
23
23
|
readonly maxDocumentChars?: number;
|
|
24
24
|
readonly maxChunks?: number;
|
|
25
25
|
}
|
|
26
|
+
export interface LoadedDocument {
|
|
27
|
+
readonly uri: string;
|
|
28
|
+
readonly sourceId?: string;
|
|
29
|
+
readonly mediaType?: string;
|
|
30
|
+
readonly text?: string;
|
|
31
|
+
readonly data?: Uint8Array;
|
|
32
|
+
readonly metadata?: JsonObject;
|
|
33
|
+
}
|
|
34
|
+
export interface ParsedDocument {
|
|
35
|
+
readonly text: string;
|
|
36
|
+
readonly metadata?: JsonObject;
|
|
37
|
+
}
|
|
38
|
+
export interface DocumentLoadOptions {
|
|
39
|
+
readonly signal?: AbortSignal;
|
|
40
|
+
readonly maxBytes?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface DocumentParseOptions extends DocumentLoadOptions {
|
|
43
|
+
readonly maxParseMs?: number;
|
|
44
|
+
readonly maxPages?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface DocumentLoader {
|
|
47
|
+
load(uri: string, options?: DocumentLoadOptions): Promise<LoadedDocument>;
|
|
48
|
+
}
|
|
49
|
+
export interface Parser {
|
|
50
|
+
parse(document: LoadedDocument, options?: DocumentParseOptions): Promise<ParsedDocument>;
|
|
51
|
+
}
|
|
52
|
+
export type Chunker = (text: string, options: ChunkOptions) => readonly RagChunk[];
|
|
53
|
+
export interface SourceVectorStore extends VectorStore {
|
|
54
|
+
getBySource(scope: {
|
|
55
|
+
readonly tenantId: string;
|
|
56
|
+
readonly resourceId: string;
|
|
57
|
+
readonly threadId: string;
|
|
58
|
+
}, sourceId: string, options?: {
|
|
59
|
+
readonly signal?: AbortSignal;
|
|
60
|
+
}): Promise<readonly MemoryVectorRecord[]>;
|
|
61
|
+
}
|
|
62
|
+
export interface TransactionalVectorStore extends SourceVectorStore {
|
|
63
|
+
transaction<T>(operation: (store: SourceVectorStore) => Promise<T>, options?: {
|
|
64
|
+
readonly signal?: AbortSignal;
|
|
65
|
+
}): Promise<T>;
|
|
66
|
+
}
|
|
67
|
+
export type IngestionState = "pending" | "indexed" | "failed" | "partial";
|
|
68
|
+
export interface IngestionStatus {
|
|
69
|
+
readonly sourceId: string;
|
|
70
|
+
readonly scope: RagScope;
|
|
71
|
+
readonly state: IngestionState;
|
|
72
|
+
readonly bytes: number;
|
|
73
|
+
readonly chunks: number;
|
|
74
|
+
readonly error?: string;
|
|
75
|
+
readonly updatedAt: string;
|
|
76
|
+
}
|
|
77
|
+
export interface IngestionStatusStore {
|
|
78
|
+
set(status: IngestionStatus, options?: {
|
|
79
|
+
readonly signal?: AbortSignal;
|
|
80
|
+
}): Promise<void>;
|
|
81
|
+
delete(scope: RagScope, sourceId: string, options?: {
|
|
82
|
+
readonly signal?: AbortSignal;
|
|
83
|
+
}): Promise<void>;
|
|
84
|
+
list(scope: RagScope, options: {
|
|
85
|
+
readonly limit: number;
|
|
86
|
+
readonly cursor?: string;
|
|
87
|
+
readonly signal?: AbortSignal;
|
|
88
|
+
}): Promise<{
|
|
89
|
+
readonly entries: readonly IngestionStatus[];
|
|
90
|
+
readonly nextCursor?: string;
|
|
91
|
+
}>;
|
|
92
|
+
}
|
|
93
|
+
export interface IngestionStatusQuery {
|
|
94
|
+
readonly store: IngestionStatusStore;
|
|
95
|
+
readonly scope: RagScope;
|
|
96
|
+
readonly limit?: number;
|
|
97
|
+
readonly cursor?: string;
|
|
98
|
+
readonly signal?: AbortSignal;
|
|
99
|
+
}
|
|
26
100
|
export interface IndexChunksOptions {
|
|
27
101
|
readonly chunks: readonly RagChunk[];
|
|
28
102
|
readonly embedder: Embedder;
|
|
@@ -35,20 +109,68 @@ export interface IndexChunksOptions {
|
|
|
35
109
|
readonly maxMetadataBytes?: number;
|
|
36
110
|
readonly redactor?: SecretRedactor;
|
|
37
111
|
readonly secrets?: readonly (string | undefined)[];
|
|
112
|
+
readonly statusStore?: IngestionStatusStore;
|
|
38
113
|
readonly signal?: AbortSignal;
|
|
39
114
|
}
|
|
40
115
|
export interface IndexChunksResult {
|
|
41
116
|
readonly indexed: number;
|
|
42
117
|
readonly sourceIds: readonly string[];
|
|
43
118
|
}
|
|
119
|
+
export interface ReplaceSourceOptions extends Omit<IndexChunksOptions, "chunks" | "store"> {
|
|
120
|
+
readonly sourceId: string;
|
|
121
|
+
readonly chunks: readonly RagChunk[];
|
|
122
|
+
readonly store: TransactionalVectorStore;
|
|
123
|
+
}
|
|
124
|
+
export interface DeleteSourceOptions {
|
|
125
|
+
readonly sourceId: string;
|
|
126
|
+
readonly store: SourceVectorStore;
|
|
127
|
+
readonly scope: RagScope;
|
|
128
|
+
readonly statusStore?: IngestionStatusStore;
|
|
129
|
+
readonly signal?: AbortSignal;
|
|
130
|
+
}
|
|
131
|
+
export interface ReplaceDocumentOptions extends Omit<ReplaceSourceOptions, "sourceId" | "chunks"> {
|
|
132
|
+
readonly uri: string;
|
|
133
|
+
readonly sourceId?: string;
|
|
134
|
+
readonly loader: DocumentLoader;
|
|
135
|
+
readonly parser: Parser;
|
|
136
|
+
readonly chunker?: Chunker;
|
|
137
|
+
readonly chunk?: Omit<ChunkOptions, "sourceId">;
|
|
138
|
+
readonly loaderOptions?: DocumentLoadOptions;
|
|
139
|
+
readonly parserOptions?: DocumentParseOptions;
|
|
140
|
+
}
|
|
141
|
+
export interface RagProvenance {
|
|
142
|
+
readonly sourceId: string;
|
|
143
|
+
readonly chunkId: string;
|
|
144
|
+
readonly citationId: string;
|
|
145
|
+
readonly provider: string;
|
|
146
|
+
readonly retrieval: "vector";
|
|
147
|
+
readonly retrievedAt: string;
|
|
148
|
+
}
|
|
149
|
+
export interface RagContentTrust {
|
|
150
|
+
readonly untrusted: true;
|
|
151
|
+
readonly inert: true;
|
|
152
|
+
readonly injectionCapable: true;
|
|
153
|
+
}
|
|
44
154
|
export interface RagCitation {
|
|
45
155
|
readonly id: string;
|
|
46
156
|
readonly sourceId: string;
|
|
47
157
|
readonly chunkId: string;
|
|
158
|
+
readonly provenance: RagProvenance;
|
|
159
|
+
readonly trust: RagContentTrust;
|
|
48
160
|
readonly metadata?: JsonObject;
|
|
49
161
|
}
|
|
50
162
|
export interface RagHit extends RagChunk {
|
|
51
163
|
readonly score: number;
|
|
164
|
+
readonly retrievalRank: number;
|
|
165
|
+
readonly provenance: RagProvenance;
|
|
166
|
+
readonly trust: RagContentTrust;
|
|
167
|
+
}
|
|
168
|
+
export interface Reranker {
|
|
169
|
+
rerank(input: {
|
|
170
|
+
readonly query: string;
|
|
171
|
+
readonly hits: readonly RagHit[];
|
|
172
|
+
readonly signal?: AbortSignal;
|
|
173
|
+
}): Promise<readonly RagHit[]>;
|
|
52
174
|
}
|
|
53
175
|
export interface RetrieveContextOptions {
|
|
54
176
|
readonly embedder: Embedder;
|
|
@@ -61,12 +183,17 @@ export interface RetrieveContextOptions {
|
|
|
61
183
|
readonly maxContextTokens?: number;
|
|
62
184
|
readonly maxMetadataBytes?: number;
|
|
63
185
|
readonly maxVectorDimensions?: number;
|
|
186
|
+
readonly reranker?: Reranker;
|
|
187
|
+
readonly maxRerankBytes?: number;
|
|
188
|
+
readonly maxRerankMs?: number;
|
|
189
|
+
readonly rerankConcurrency?: number;
|
|
64
190
|
readonly redactor?: SecretRedactor;
|
|
65
191
|
readonly secrets?: readonly (string | undefined)[];
|
|
66
192
|
readonly signal?: AbortSignal;
|
|
67
193
|
}
|
|
68
194
|
export interface RagContextResult {
|
|
69
195
|
readonly query: string;
|
|
196
|
+
readonly trust: RagContentTrust;
|
|
70
197
|
readonly text: string;
|
|
71
198
|
readonly hits: readonly RagHit[];
|
|
72
199
|
readonly citations: readonly RagCitation[];
|