@kuralle-agents/rag 0.8.5 → 0.10.0
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/README.md +42 -9
- package/dist/embedders/AiSdkEmbedder.d.ts +2 -0
- package/dist/embedders/AiSdkEmbedder.js +6 -0
- package/dist/fs/KnowledgeFs.d.ts +13 -3
- package/dist/fs/KnowledgeFs.js +25 -15
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/pipeline/IngestManifest.d.ts +57 -0
- package/dist/pipeline/IngestManifest.js +52 -0
- package/dist/pipeline/RagPipeline.d.ts +37 -0
- package/dist/pipeline/RagPipeline.js +109 -0
- package/dist/pipeline/RetrievalQualityChecker.d.ts +6 -0
- package/dist/pipeline/RetrievalQualityChecker.js +3 -2
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/retrievers/FusionRetriever.d.ts +7 -4
- package/dist/retrievers/FusionRetriever.js +3 -3
- package/dist/search/BM25Index.d.ts +12 -2
- package/dist/search/BM25Index.js +13 -4
- package/dist/search/Fts5KeywordIndex.d.ts +45 -0
- package/dist/search/Fts5KeywordIndex.js +61 -0
- package/dist/search/KeywordIndex.d.ts +29 -0
- package/dist/search/KeywordIndex.js +1 -0
- package/dist/search/index.d.ts +4 -1
- package/dist/search/index.js +2 -1
- package/dist/sql.d.ts +18 -0
- package/dist/sql.js +20 -0
- package/dist/types.d.ts +8 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Everything between a raw document and a grounded agent response: chunk documents
|
|
|
22
22
|
- **Vector stores** — `InMemoryVectorStore`; adapters in sibling packages
|
|
23
23
|
- **Retrievers** — `VectorRetriever`, `HybridRetriever`, `FusionRetriever`, `MultiHopRetriever`, `createLLMRetriever`
|
|
24
24
|
- **Rerankers** — `LLMReranker`, `CohereReranker`
|
|
25
|
-
- **Search** — `BM25Index` (
|
|
25
|
+
- **Search** — `KeywordIndex` contract: `BM25Index` (in-memory) and `Fts5KeywordIndex` (persistent SQLite FTS5; survives DO hibernation)
|
|
26
26
|
- **KnowledgeFs** — read-only `FileSystem` over a vector store (`@kuralle-agents/rag/fs`); see [guides/KNOWLEDGEFS.md](./guides/KNOWLEDGEFS.md)
|
|
27
27
|
- **Pipeline** — `RagPipeline`, `RetrievalQualityChecker`
|
|
28
28
|
- **Cache** — `RetrievalCache`, `TurnCache`, `PredictivePreFetcher`
|
|
@@ -58,18 +58,51 @@ const results = await retriever.retrieve('What is Kuralle?');
|
|
|
58
58
|
|
|
59
59
|
## Hybrid retrieval
|
|
60
60
|
|
|
61
|
-
`
|
|
61
|
+
`FusionRetriever` fuses a BM25 keyword tier with vector similarity (weighted,
|
|
62
|
+
min-max normalized). The keyword tier is any `KeywordIndex`: the in-memory
|
|
63
|
+
`BM25Index`, or the persistent `Fts5KeywordIndex` (SQLite FTS5 — on Cloudflare,
|
|
64
|
+
Durable Object SQLite supports FTS5, so the keyword tier survives hibernation
|
|
65
|
+
with zero rebuild):
|
|
62
66
|
|
|
63
67
|
```ts
|
|
64
|
-
import {
|
|
68
|
+
import { FusionRetriever, BM25Index, Fts5KeywordIndex } from '@kuralle-agents/rag';
|
|
69
|
+
|
|
70
|
+
const keywordIndex = new BM25Index(); // in-memory
|
|
71
|
+
// const keywordIndex = new Fts5KeywordIndex({ // persistent (DO SQLite / bun:sqlite)
|
|
72
|
+
// sql: createSqlExecutor(ctx.storage.sql), // from @kuralle-agents/cf-agent
|
|
73
|
+
// });
|
|
74
|
+
|
|
75
|
+
const retriever = new FusionRetriever({
|
|
76
|
+
keywordIndex,
|
|
77
|
+
vectorStore,
|
|
78
|
+
embedder,
|
|
79
|
+
indexName: 'docs',
|
|
80
|
+
bm25Weight: 0.3, // 70% vector, 30% keyword
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`HybridRetriever` is the generic alternative: it fuses any set of `Retriever`s
|
|
85
|
+
with reciprocal rank fusion (`sources: [{ retriever, weight }]`).
|
|
65
86
|
|
|
66
|
-
|
|
67
|
-
// index documents into bm25...
|
|
87
|
+
## Incremental ingest + embedder lock
|
|
68
88
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
Give `RagPipeline` a persistent `IngestManifest` and it (a) skips unchanged
|
|
90
|
+
documents on re-ingest (SHA-256 content hash — zero embed calls for a stable
|
|
91
|
+
corpus), (b) cleans up stale chunks of changed documents, and (c) **locks the
|
|
92
|
+
index to the embedding model that built it**: ingesting or querying with a
|
|
93
|
+
different model — even one with the same dimension — throws instead of
|
|
94
|
+
silently corrupting relevance.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { RagPipeline, SqlIngestManifest, InMemoryIngestManifest } from '@kuralle-agents/rag';
|
|
98
|
+
|
|
99
|
+
const pipeline = new RagPipeline({
|
|
100
|
+
embedder,
|
|
101
|
+
vectorStore,
|
|
102
|
+
chunker,
|
|
103
|
+
indexName: 'docs',
|
|
104
|
+
manifest: new SqlIngestManifest({ sql }), // DO SQLite / bun:sqlite; InMemoryIngestManifest for dev
|
|
105
|
+
keywordIndex, // optional: kept in sync at ingest
|
|
73
106
|
});
|
|
74
107
|
```
|
|
75
108
|
|
|
@@ -34,6 +34,8 @@ export declare class AiSdkEmbedder implements Embedder {
|
|
|
34
34
|
private cachedDimension;
|
|
35
35
|
constructor(options: AiSdkEmbedderOptions);
|
|
36
36
|
get dimension(): number | undefined;
|
|
37
|
+
/** Stable model identity (`provider/modelId`) for the ingest-manifest embedder lock. */
|
|
38
|
+
get id(): string;
|
|
37
39
|
embed(text: string): Promise<readonly number[]>;
|
|
38
40
|
embedMany(texts: string[]): Promise<readonly (readonly number[])[]>;
|
|
39
41
|
}
|
|
@@ -19,6 +19,12 @@ export class AiSdkEmbedder {
|
|
|
19
19
|
get dimension() {
|
|
20
20
|
return this.cachedDimension;
|
|
21
21
|
}
|
|
22
|
+
/** Stable model identity (`provider/modelId`) for the ingest-manifest embedder lock. */
|
|
23
|
+
get id() {
|
|
24
|
+
if (typeof this.model === 'string')
|
|
25
|
+
return this.model;
|
|
26
|
+
return `${this.model.provider}/${this.model.modelId}`;
|
|
27
|
+
}
|
|
22
28
|
async embed(text) {
|
|
23
29
|
const result = await embed({
|
|
24
30
|
model: this.model,
|
package/dist/fs/KnowledgeFs.d.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { CpOptions, FileSystem, FileSystemDirent, FsStat, MkdirOptions, RmOptions } from '@kuralle-agents/core';
|
|
2
|
-
import type {
|
|
2
|
+
import type { KeywordIndex } from '../search/KeywordIndex.js';
|
|
3
3
|
import type { VectorStoreCore } from '../types.js';
|
|
4
4
|
import type { KnowledgeAccessFilter } from './access.js';
|
|
5
5
|
export interface KnowledgeFsOptions {
|
|
6
6
|
store: VectorStoreCore;
|
|
7
7
|
indexName: string;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Keyword index powering `search()` (the grep tier). Pass a fresh
|
|
10
|
+
* `BM25Index` (in-memory; seeded from the store on every `open()`) or a
|
|
11
|
+
* persistent `Fts5KeywordIndex` — a pre-populated persistent index is
|
|
12
|
+
* detected (`size > 0`) and `open()` skips seeding entirely, which is
|
|
13
|
+
* what makes a hibernated Durable Object wake with zero rebuild. A
|
|
14
|
+
* persistent index is kept in sync by the ingest path (e.g.
|
|
15
|
+
* `RagPipeline`'s `keywordIndex` option); call `clear()` on it to force
|
|
16
|
+
* a reseed on the next `open()`.
|
|
17
|
+
*/
|
|
18
|
+
keywordIndex?: KeywordIndex;
|
|
9
19
|
accessFilter?: KnowledgeAccessFilter;
|
|
10
20
|
manifestKey?: string;
|
|
11
21
|
}
|
|
@@ -17,7 +27,7 @@ export interface KnowledgeSearchHit {
|
|
|
17
27
|
export declare class KnowledgeFs implements FileSystem {
|
|
18
28
|
private readonly store;
|
|
19
29
|
private readonly indexName;
|
|
20
|
-
private readonly
|
|
30
|
+
private readonly keywordIndex?;
|
|
21
31
|
private readonly accessFilter?;
|
|
22
32
|
private readonly manifestKey;
|
|
23
33
|
private tree;
|
package/dist/fs/KnowledgeFs.js
CHANGED
|
@@ -21,7 +21,7 @@ const utf8 = new TextEncoder();
|
|
|
21
21
|
export class KnowledgeFs {
|
|
22
22
|
store;
|
|
23
23
|
indexName;
|
|
24
|
-
|
|
24
|
+
keywordIndex;
|
|
25
25
|
accessFilter;
|
|
26
26
|
manifestKey;
|
|
27
27
|
tree;
|
|
@@ -31,7 +31,7 @@ export class KnowledgeFs {
|
|
|
31
31
|
constructor(opts) {
|
|
32
32
|
this.store = opts.store;
|
|
33
33
|
this.indexName = opts.indexName;
|
|
34
|
-
this.
|
|
34
|
+
this.keywordIndex = opts.keywordIndex;
|
|
35
35
|
this.accessFilter = opts.accessFilter;
|
|
36
36
|
this.manifestKey = opts.manifestKey ?? PATH_TREE_MANIFEST_ID;
|
|
37
37
|
}
|
|
@@ -64,8 +64,10 @@ export class KnowledgeFs {
|
|
|
64
64
|
chunkRecords = chunkRecords.filter((c) => allow(c.slug));
|
|
65
65
|
this.tree = prunePathTree(buildPathTree(slugs), allow);
|
|
66
66
|
this.chunksBySlug = groupChunksBySlug(chunkRecords);
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// A pre-populated persistent keyword index (e.g. Fts5KeywordIndex in
|
|
68
|
+
// DO SQLite surviving hibernation) is reused as-is — zero rebuild.
|
|
69
|
+
if (this.keywordIndex && this.keywordIndex.size === 0) {
|
|
70
|
+
this.keywordIndex.add(chunkRecords.map((r) => ({
|
|
69
71
|
id: `${r.slug}#${r.chunkIndex}`,
|
|
70
72
|
text: r.text,
|
|
71
73
|
})));
|
|
@@ -102,17 +104,25 @@ export class KnowledgeFs {
|
|
|
102
104
|
const limit = opts?.limit ?? 50;
|
|
103
105
|
const root = opts?.path ? normalizeKnowledgePath(opts.path) : '/';
|
|
104
106
|
let records = allChunkRecords(this.chunksBySlug).filter((r) => pathUnderRoot(r.slug, root));
|
|
105
|
-
if (this.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
107
|
+
if (this.keywordIndex) {
|
|
108
|
+
// Over-fetch so root filtering can't starve the limit, and return
|
|
109
|
+
// hits in BM25 rank order (not corpus order).
|
|
110
|
+
const ranked = this.keywordIndex.search(pattern, limit * 4);
|
|
111
|
+
const recordById = new Map(records.map((r) => [`${r.slug}#${r.chunkIndex}`, r]));
|
|
112
|
+
const hits = [];
|
|
113
|
+
for (const hit of ranked) {
|
|
114
|
+
const record = recordById.get(hit.id);
|
|
115
|
+
if (!record)
|
|
116
|
+
continue;
|
|
117
|
+
hits.push({
|
|
118
|
+
slug: record.slug,
|
|
119
|
+
chunkIndex: record.chunkIndex,
|
|
120
|
+
text: record.text,
|
|
121
|
+
});
|
|
122
|
+
if (hits.length >= limit)
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
return hits;
|
|
116
126
|
}
|
|
117
127
|
let re;
|
|
118
128
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -24,8 +24,10 @@ export { LLMReranker } from './rerankers/index.js';
|
|
|
24
24
|
export type { LLMRerankerOptions } from './rerankers/index.js';
|
|
25
25
|
export { CohereReranker } from './rerankers/index.js';
|
|
26
26
|
export type { CohereRerankerOptions } from './rerankers/index.js';
|
|
27
|
-
export { BM25Index } from './search/index.js';
|
|
28
|
-
export type { BM25Document, BM25SearchResult, BM25IndexOptions } from './search/index.js';
|
|
27
|
+
export { BM25Index, Fts5KeywordIndex, tokenizeKeywords } from './search/index.js';
|
|
28
|
+
export type { BM25Document, BM25SearchResult, BM25IndexOptions, KeywordIndex, Fts5KeywordIndexOptions, } from './search/index.js';
|
|
29
|
+
export { execSql, assertSqlIdentifier } from './sql.js';
|
|
30
|
+
export type { SqlExecutor } from './sql.js';
|
|
29
31
|
export { RetrievalCache } from './cache/index.js';
|
|
30
32
|
export type { RetrievalCacheConfig } from './cache/index.js';
|
|
31
33
|
export { TurnCache } from './cache/index.js';
|
|
@@ -35,5 +37,7 @@ export { KnowledgeCompiler } from './compiler/index.js';
|
|
|
35
37
|
export type { KnowledgeCompilerConfig, CompilationResult } from './compiler/index.js';
|
|
36
38
|
export { RagPipeline } from './pipeline/index.js';
|
|
37
39
|
export type { RagPipelineOptions } from './pipeline/index.js';
|
|
40
|
+
export { InMemoryIngestManifest, SqlIngestManifest, sha256Hex, } from './pipeline/index.js';
|
|
41
|
+
export type { IngestManifest, IngestManifestData, IngestManifestDocEntry, SqlIngestManifestOptions, } from './pipeline/index.js';
|
|
38
42
|
export { RetrievalQualityChecker } from './pipeline/index.js';
|
|
39
43
|
export type { RetrievalQualityCheckerOptions, QualityCheckResult, QueryReformulator, } from './pipeline/index.js';
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,9 @@ export { MultiHopRetriever } from './retrievers/index.js';
|
|
|
22
22
|
export { LLMReranker } from './rerankers/index.js';
|
|
23
23
|
export { CohereReranker } from './rerankers/index.js';
|
|
24
24
|
// Search
|
|
25
|
-
export { BM25Index } from './search/index.js';
|
|
25
|
+
export { BM25Index, Fts5KeywordIndex, tokenizeKeywords } from './search/index.js';
|
|
26
|
+
// SQL executor contract (shared by Fts5KeywordIndex and SqlIngestManifest)
|
|
27
|
+
export { execSql, assertSqlIdentifier } from './sql.js';
|
|
26
28
|
// Cache
|
|
27
29
|
export { RetrievalCache } from './cache/index.js';
|
|
28
30
|
export { TurnCache } from './cache/index.js';
|
|
@@ -31,6 +33,7 @@ export { PredictivePreFetcher } from './cache/index.js';
|
|
|
31
33
|
export { KnowledgeCompiler } from './compiler/index.js';
|
|
32
34
|
// Pipeline
|
|
33
35
|
export { RagPipeline } from './pipeline/index.js';
|
|
36
|
+
export { InMemoryIngestManifest, SqlIngestManifest, sha256Hex, } from './pipeline/index.js';
|
|
34
37
|
export { RetrievalQualityChecker } from './pipeline/index.js';
|
|
35
38
|
// createVectorRetrievalTool now lives in @kuralle-agents/tools (C-8.4).
|
|
36
39
|
// Under alpha's direct-removal posture the rag-side re-export is gone —
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IngestManifest — persistent record of what an index was built from.
|
|
3
|
+
*
|
|
4
|
+
* Stores two things per index:
|
|
5
|
+
* 1. The embedder identity (model id + dimension) that built the index,
|
|
6
|
+
* so a different model can never silently query or extend it
|
|
7
|
+
* ("provider lock").
|
|
8
|
+
* 2. A content hash + chunk-id list per ingested document, so unchanged
|
|
9
|
+
* documents are skipped on re-ingest and stale chunks of changed
|
|
10
|
+
* documents are cleaned up ("incremental indexing").
|
|
11
|
+
*/
|
|
12
|
+
import { type SqlExecutor } from '../sql.js';
|
|
13
|
+
export interface IngestManifestDocEntry {
|
|
14
|
+
/** SHA-256 hex of the document text at last ingest. */
|
|
15
|
+
hash: string;
|
|
16
|
+
/** Vector-entry ids written for this document at last ingest. */
|
|
17
|
+
chunkIds: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface IngestManifestData {
|
|
20
|
+
/** Identity of the embedder that built this index. */
|
|
21
|
+
embedder?: {
|
|
22
|
+
id?: string;
|
|
23
|
+
dimension?: number;
|
|
24
|
+
};
|
|
25
|
+
/** Per-document ingest record, keyed by document id. */
|
|
26
|
+
docs: Record<string, IngestManifestDocEntry>;
|
|
27
|
+
}
|
|
28
|
+
export interface IngestManifest {
|
|
29
|
+
load(indexName: string): Promise<IngestManifestData | undefined>;
|
|
30
|
+
save(indexName: string, data: IngestManifestData): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/** SHA-256 hex via WebCrypto — identical on Node 18+, Bun, and Workers. */
|
|
33
|
+
export declare function sha256Hex(text: string): Promise<string>;
|
|
34
|
+
/** Process-lifetime manifest for dev/tests and long-lived Node servers. */
|
|
35
|
+
export declare class InMemoryIngestManifest implements IngestManifest {
|
|
36
|
+
private readonly data;
|
|
37
|
+
load(indexName: string): Promise<IngestManifestData | undefined>;
|
|
38
|
+
save(indexName: string, data: IngestManifestData): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
export interface SqlIngestManifestOptions {
|
|
41
|
+
/** Tagged-template SQL executor (DO SQLite, bun:sqlite, better-sqlite3). */
|
|
42
|
+
sql: SqlExecutor;
|
|
43
|
+
/** Table name. Default: 'kuralle_rag_manifest'. */
|
|
44
|
+
tableName?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* SQLite-backed manifest. On Cloudflare, pass the Durable Object's
|
|
48
|
+
* `createSqlExecutor(ctx.storage.sql)` from `@kuralle-agents/cf-agent`;
|
|
49
|
+
* the manifest then survives hibernation alongside the session state.
|
|
50
|
+
*/
|
|
51
|
+
export declare class SqlIngestManifest implements IngestManifest {
|
|
52
|
+
private readonly sql;
|
|
53
|
+
private readonly table;
|
|
54
|
+
constructor(options: SqlIngestManifestOptions);
|
|
55
|
+
load(indexName: string): Promise<IngestManifestData | undefined>;
|
|
56
|
+
save(indexName: string, data: IngestManifestData): Promise<void>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IngestManifest — persistent record of what an index was built from.
|
|
3
|
+
*
|
|
4
|
+
* Stores two things per index:
|
|
5
|
+
* 1. The embedder identity (model id + dimension) that built the index,
|
|
6
|
+
* so a different model can never silently query or extend it
|
|
7
|
+
* ("provider lock").
|
|
8
|
+
* 2. A content hash + chunk-id list per ingested document, so unchanged
|
|
9
|
+
* documents are skipped on re-ingest and stale chunks of changed
|
|
10
|
+
* documents are cleaned up ("incremental indexing").
|
|
11
|
+
*/
|
|
12
|
+
import { assertSqlIdentifier, execSql } from '../sql.js';
|
|
13
|
+
/** SHA-256 hex via WebCrypto — identical on Node 18+, Bun, and Workers. */
|
|
14
|
+
export async function sha256Hex(text) {
|
|
15
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
16
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
17
|
+
}
|
|
18
|
+
/** Process-lifetime manifest for dev/tests and long-lived Node servers. */
|
|
19
|
+
export class InMemoryIngestManifest {
|
|
20
|
+
data = new Map();
|
|
21
|
+
async load(indexName) {
|
|
22
|
+
const entry = this.data.get(indexName);
|
|
23
|
+
return entry ? structuredClone(entry) : undefined;
|
|
24
|
+
}
|
|
25
|
+
async save(indexName, data) {
|
|
26
|
+
this.data.set(indexName, structuredClone(data));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* SQLite-backed manifest. On Cloudflare, pass the Durable Object's
|
|
31
|
+
* `createSqlExecutor(ctx.storage.sql)` from `@kuralle-agents/cf-agent`;
|
|
32
|
+
* the manifest then survives hibernation alongside the session state.
|
|
33
|
+
*/
|
|
34
|
+
export class SqlIngestManifest {
|
|
35
|
+
sql;
|
|
36
|
+
table;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.sql = options.sql;
|
|
39
|
+
this.table = assertSqlIdentifier(options.tableName ?? 'kuralle_rag_manifest');
|
|
40
|
+
execSql(this.sql, `CREATE TABLE IF NOT EXISTS ${this.table} (index_name TEXT PRIMARY KEY, data TEXT NOT NULL)`);
|
|
41
|
+
}
|
|
42
|
+
async load(indexName) {
|
|
43
|
+
const rows = execSql(this.sql, `SELECT data FROM ${this.table} WHERE index_name = ?`, [indexName]);
|
|
44
|
+
if (rows.length === 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
return JSON.parse(rows[0].data);
|
|
47
|
+
}
|
|
48
|
+
async save(indexName, data) {
|
|
49
|
+
execSql(this.sql, `INSERT INTO ${this.table} (index_name, data) VALUES (?, ?) ` +
|
|
50
|
+
`ON CONFLICT(index_name) DO UPDATE SET data = excluded.data`, [indexName, JSON.stringify(data)]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Embedder, VectorStoreCore, Chunker, Reranker, Document, RetrievalResult, RetrievalOptions, Retriever } from '../types.js';
|
|
2
|
+
import type { KeywordIndex } from '../search/KeywordIndex.js';
|
|
3
|
+
import { type IngestManifest } from './IngestManifest.js';
|
|
2
4
|
export interface RagPipelineOptions {
|
|
3
5
|
/** Embedder for converting text to vectors. */
|
|
4
6
|
embedder: Embedder;
|
|
@@ -24,6 +26,24 @@ export interface RagPipelineOptions {
|
|
|
24
26
|
metric?: 'cosine' | 'euclidean' | 'dotproduct';
|
|
25
27
|
/** Batch size for embedMany calls. Default: 100. */
|
|
26
28
|
batchSize?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Persistent ingest manifest. When provided, the pipeline:
|
|
31
|
+
* - locks the index to the embedder that built it ({@link Embedder.id}
|
|
32
|
+
* + dimension) and throws on mismatch at ingest AND retrieve time —
|
|
33
|
+
* a different same-dimension model silently corrupts relevance;
|
|
34
|
+
* - skips unchanged documents on re-ingest (SHA-256 content hash);
|
|
35
|
+
* - deletes stale chunks of changed documents (when the store
|
|
36
|
+
* supports admin deletes).
|
|
37
|
+
* Without a manifest, every ingest re-embeds everything and no
|
|
38
|
+
* embedder lock is enforced (previous behavior).
|
|
39
|
+
*/
|
|
40
|
+
manifest?: IngestManifest;
|
|
41
|
+
/**
|
|
42
|
+
* Keyword index kept in sync with ingestion (the keyword tier for
|
|
43
|
+
* `FusionRetriever`/`HybridRetriever`). Chunk ids match the vector
|
|
44
|
+
* entry ids (`${docId}:${chunkId}`).
|
|
45
|
+
*/
|
|
46
|
+
keywordIndex?: KeywordIndex;
|
|
27
47
|
}
|
|
28
48
|
/**
|
|
29
49
|
* Convenience class that wires embedder, vector store, chunker,
|
|
@@ -41,6 +61,10 @@ export declare class RagPipeline implements Retriever {
|
|
|
41
61
|
private readonly defaultTopK;
|
|
42
62
|
private readonly metric;
|
|
43
63
|
private readonly batchSize;
|
|
64
|
+
private readonly manifest?;
|
|
65
|
+
private readonly keywordIndex?;
|
|
66
|
+
private lockChecked;
|
|
67
|
+
private lockedDimension?;
|
|
44
68
|
constructor(options: RagPipelineOptions);
|
|
45
69
|
/**
|
|
46
70
|
* Ensure the vector index exists, creating it if necessary.
|
|
@@ -52,8 +76,21 @@ export declare class RagPipeline implements Retriever {
|
|
|
52
76
|
* surfaces later at query time via the backend's own error.
|
|
53
77
|
*/
|
|
54
78
|
ensureIndex(): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Throw if the manifest records a different embedder than this
|
|
81
|
+
* pipeline's. Two models with the SAME dimension still produce
|
|
82
|
+
* incompatible vector spaces — without this check the mismatch is
|
|
83
|
+
* silent and every query degrades to near-random.
|
|
84
|
+
*/
|
|
85
|
+
private assertEmbedderLock;
|
|
86
|
+
private loadManifestData;
|
|
55
87
|
/**
|
|
56
88
|
* Ingest documents: chunk, embed, and store in the vector store.
|
|
89
|
+
*
|
|
90
|
+
* With a manifest configured, documents whose content hash is unchanged
|
|
91
|
+
* since the last ingest are skipped entirely (zero embed calls), and
|
|
92
|
+
* stale chunks of changed documents are removed from the vector store
|
|
93
|
+
* (admin stores) and the keyword index.
|
|
57
94
|
*/
|
|
58
95
|
ingest(documents: Document[]): Promise<void>;
|
|
59
96
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hasIndexAdmin } from '../types.js';
|
|
2
|
+
import { sha256Hex, } from './IngestManifest.js';
|
|
2
3
|
/**
|
|
3
4
|
* Convenience class that wires embedder, vector store, chunker,
|
|
4
5
|
* and optional reranker into a single ingestion + retrieval pipeline.
|
|
@@ -15,6 +16,10 @@ export class RagPipeline {
|
|
|
15
16
|
defaultTopK;
|
|
16
17
|
metric;
|
|
17
18
|
batchSize;
|
|
19
|
+
manifest;
|
|
20
|
+
keywordIndex;
|
|
21
|
+
lockChecked = false;
|
|
22
|
+
lockedDimension;
|
|
18
23
|
constructor(options) {
|
|
19
24
|
this.embedder = options.embedder;
|
|
20
25
|
this.vectorStore = options.vectorStore;
|
|
@@ -24,6 +29,8 @@ export class RagPipeline {
|
|
|
24
29
|
this.defaultTopK = options.topK ?? 10;
|
|
25
30
|
this.metric = options.metric ?? 'cosine';
|
|
26
31
|
this.batchSize = options.batchSize ?? 100;
|
|
32
|
+
this.manifest = options.manifest;
|
|
33
|
+
this.keywordIndex = options.keywordIndex;
|
|
27
34
|
}
|
|
28
35
|
/**
|
|
29
36
|
* Ensure the vector index exists, creating it if necessary.
|
|
@@ -53,12 +60,61 @@ export class RagPipeline {
|
|
|
53
60
|
metric: this.metric,
|
|
54
61
|
});
|
|
55
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Throw if the manifest records a different embedder than this
|
|
65
|
+
* pipeline's. Two models with the SAME dimension still produce
|
|
66
|
+
* incompatible vector spaces — without this check the mismatch is
|
|
67
|
+
* silent and every query degrades to near-random.
|
|
68
|
+
*/
|
|
69
|
+
assertEmbedderLock(data) {
|
|
70
|
+
const locked = data.embedder;
|
|
71
|
+
if (!locked)
|
|
72
|
+
return;
|
|
73
|
+
const mismatchedId = locked.id !== undefined &&
|
|
74
|
+
this.embedder.id !== undefined &&
|
|
75
|
+
locked.id !== this.embedder.id;
|
|
76
|
+
const mismatchedDim = locked.dimension !== undefined &&
|
|
77
|
+
this.embedder.dimension !== undefined &&
|
|
78
|
+
locked.dimension !== this.embedder.dimension;
|
|
79
|
+
if (mismatchedId || mismatchedDim) {
|
|
80
|
+
throw new Error(`RagPipeline: index '${this.indexName}' was built with embedder ` +
|
|
81
|
+
`'${locked.id ?? 'unknown'}' (dimension ${locked.dimension ?? '?'}) but this ` +
|
|
82
|
+
`pipeline uses '${this.embedder.id ?? 'unknown'}' (dimension ` +
|
|
83
|
+
`${this.embedder.dimension ?? '?'}). Mixing embedding models silently corrupts ` +
|
|
84
|
+
`relevance. Either restore the original embedder, or re-index: clear the ` +
|
|
85
|
+
`index and its manifest entry, then ingest with the new model.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async loadManifestData() {
|
|
89
|
+
if (!this.manifest)
|
|
90
|
+
return undefined;
|
|
91
|
+
return this.manifest.load(this.indexName);
|
|
92
|
+
}
|
|
56
93
|
/**
|
|
57
94
|
* Ingest documents: chunk, embed, and store in the vector store.
|
|
95
|
+
*
|
|
96
|
+
* With a manifest configured, documents whose content hash is unchanged
|
|
97
|
+
* since the last ingest are skipped entirely (zero embed calls), and
|
|
98
|
+
* stale chunks of changed documents are removed from the vector store
|
|
99
|
+
* (admin stores) and the keyword index.
|
|
58
100
|
*/
|
|
59
101
|
async ingest(documents) {
|
|
60
102
|
await this.ensureIndex();
|
|
103
|
+
const data = await this.loadManifestData();
|
|
104
|
+
if (data) {
|
|
105
|
+
this.assertEmbedderLock(data);
|
|
106
|
+
this.lockedDimension = data.embedder?.dimension;
|
|
107
|
+
this.lockChecked = true;
|
|
108
|
+
}
|
|
109
|
+
const docRecords = { ...(data?.docs ?? {}) };
|
|
110
|
+
const skipped = [];
|
|
61
111
|
for (const doc of documents) {
|
|
112
|
+
const hash = this.manifest ? await sha256Hex(doc.text) : '';
|
|
113
|
+
const previous = this.manifest ? docRecords[doc.id] : undefined;
|
|
114
|
+
if (previous && previous.hash === hash) {
|
|
115
|
+
skipped.push(doc);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
62
118
|
const chunks = this.chunker.chunk(doc.text);
|
|
63
119
|
if (chunks.length === 0)
|
|
64
120
|
continue;
|
|
@@ -82,6 +138,45 @@ export class RagPipeline {
|
|
|
82
138
|
document: chunk.text,
|
|
83
139
|
}));
|
|
84
140
|
await this.vectorStore.upsert(this.indexName, entries);
|
|
141
|
+
const newIds = new Set(entries.map(e => e.id));
|
|
142
|
+
if (previous) {
|
|
143
|
+
const stale = previous.chunkIds.filter(id => !newIds.has(id));
|
|
144
|
+
if (stale.length > 0) {
|
|
145
|
+
if (hasIndexAdmin(this.vectorStore)) {
|
|
146
|
+
await this.vectorStore.deleteVectors(this.indexName, { ids: stale });
|
|
147
|
+
}
|
|
148
|
+
for (const id of stale)
|
|
149
|
+
this.keywordIndex?.remove(id);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
this.keywordIndex?.add(entries.map(e => ({ id: e.id, text: e.document })));
|
|
153
|
+
if (this.manifest) {
|
|
154
|
+
docRecords[doc.id] = { hash, chunkIds: [...newIds] };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Restart recovery for a non-persistent keyword index: manifest-skip
|
|
158
|
+
// means skipped docs are never re-added, so after a process restart an
|
|
159
|
+
// in-memory BM25Index would come back empty and hybrid retrieval would
|
|
160
|
+
// silently degrade to vector-only. Re-seed by chunking alone — zero
|
|
161
|
+
// embed calls. A persistent index (Fts5KeywordIndex) is non-empty here
|
|
162
|
+
// and skips this entirely.
|
|
163
|
+
if (this.keywordIndex && this.keywordIndex.size === 0 && skipped.length > 0) {
|
|
164
|
+
for (const doc of skipped) {
|
|
165
|
+
const chunks = this.chunker.chunk(doc.text);
|
|
166
|
+
this.keywordIndex.add(chunks.map(c => ({ id: `${doc.id}:${c.id}`, text: c.text })));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (this.manifest) {
|
|
170
|
+
// A skip-only ingest may not have embedded anything, leaving
|
|
171
|
+
// embedder.dimension undefined — preserve the recorded identity
|
|
172
|
+
// rather than erasing it.
|
|
173
|
+
await this.manifest.save(this.indexName, {
|
|
174
|
+
embedder: {
|
|
175
|
+
id: this.embedder.id ?? data?.embedder?.id,
|
|
176
|
+
dimension: this.embedder.dimension ?? data?.embedder?.dimension,
|
|
177
|
+
},
|
|
178
|
+
docs: docRecords,
|
|
179
|
+
});
|
|
85
180
|
}
|
|
86
181
|
}
|
|
87
182
|
/**
|
|
@@ -89,9 +184,23 @@ export class RagPipeline {
|
|
|
89
184
|
* Implements the Retriever interface.
|
|
90
185
|
*/
|
|
91
186
|
async retrieve(query, options) {
|
|
187
|
+
if (this.manifest && !this.lockChecked) {
|
|
188
|
+
const data = await this.loadManifestData();
|
|
189
|
+
if (data) {
|
|
190
|
+
this.assertEmbedderLock(data);
|
|
191
|
+
this.lockedDimension = data.embedder?.dimension;
|
|
192
|
+
}
|
|
193
|
+
this.lockChecked = true;
|
|
194
|
+
}
|
|
92
195
|
const topK = options?.topK ?? this.defaultTopK;
|
|
93
196
|
// Use pre-computed query embedding when available to avoid double-embed cost
|
|
94
197
|
const queryVector = options?.queryEmbedding ?? await this.embedder.embed(query);
|
|
198
|
+
if (this.lockedDimension !== undefined && queryVector.length !== this.lockedDimension) {
|
|
199
|
+
throw new Error(`RagPipeline: index '${this.indexName}' stores ${this.lockedDimension}-dimensional ` +
|
|
200
|
+
`vectors but the query embedding has ${queryVector.length} dimensions — the ` +
|
|
201
|
+
`embedding model differs from the one that built the index. Restore the ` +
|
|
202
|
+
`original embedder or re-index with the new model.`);
|
|
203
|
+
}
|
|
95
204
|
const includeVectors = options?.includeEmbeddings ?? false;
|
|
96
205
|
// Fetch more than topK if reranking, to give the reranker a wider pool
|
|
97
206
|
const fetchK = this.reranker ? topK * 3 : topK;
|
|
@@ -19,6 +19,12 @@ export interface QualityCheckResult {
|
|
|
19
19
|
avgScore: number;
|
|
20
20
|
/** 0-1 estimate of how well results cover the query. */
|
|
21
21
|
coverageEstimate: number;
|
|
22
|
+
/**
|
|
23
|
+
* Estimated prompt-token cost of the result set (~chars/4). Retrieval
|
|
24
|
+
* pays for itself in tokens — track this to catch a retriever that is
|
|
25
|
+
* "accurate" only by flooding the context window.
|
|
26
|
+
*/
|
|
27
|
+
estimatedTokens: number;
|
|
22
28
|
/** Whether the query was reformulated and re-retrieved. */
|
|
23
29
|
reformulated: boolean;
|
|
24
30
|
/** The reformulated query string, if reformulation occurred. */
|
|
@@ -24,11 +24,12 @@ export class RetrievalQualityChecker {
|
|
|
24
24
|
*/
|
|
25
25
|
assess(results) {
|
|
26
26
|
if (results.length === 0) {
|
|
27
|
-
return { quality: 'low', topScore: 0, avgScore: 0, coverageEstimate: 0 };
|
|
27
|
+
return { quality: 'low', topScore: 0, avgScore: 0, coverageEstimate: 0, estimatedTokens: 0 };
|
|
28
28
|
}
|
|
29
29
|
const scores = results.map((r) => r.score ?? 0);
|
|
30
30
|
const topScore = Math.max(...scores);
|
|
31
31
|
const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
32
|
+
const estimatedTokens = results.reduce((s, r) => s + Math.ceil(r.text.length / 4), 0);
|
|
32
33
|
// Coverage: fraction of results above the medium threshold
|
|
33
34
|
const aboveThreshold = scores.filter((s) => s >= this.mediumThreshold).length;
|
|
34
35
|
const coverageEstimate = aboveThreshold / results.length;
|
|
@@ -42,7 +43,7 @@ export class RetrievalQualityChecker {
|
|
|
42
43
|
else {
|
|
43
44
|
quality = 'low';
|
|
44
45
|
}
|
|
45
|
-
return { quality, topScore, avgScore, coverageEstimate };
|
|
46
|
+
return { quality, topScore, avgScore, coverageEstimate, estimatedTokens };
|
|
46
47
|
}
|
|
47
48
|
/**
|
|
48
49
|
* Full quality check with optional reformulation for text agents.
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { RagPipeline } from './RagPipeline.js';
|
|
2
2
|
export type { RagPipelineOptions } from './RagPipeline.js';
|
|
3
|
+
export { InMemoryIngestManifest, SqlIngestManifest, sha256Hex, } from './IngestManifest.js';
|
|
4
|
+
export type { IngestManifest, IngestManifestData, IngestManifestDocEntry, SqlIngestManifestOptions, } from './IngestManifest.js';
|
|
3
5
|
export { RetrievalQualityChecker } from './RetrievalQualityChecker.js';
|
|
4
6
|
export type { RetrievalQualityCheckerOptions, QualityCheckResult, QueryReformulator, } from './RetrievalQualityChecker.js';
|
package/dist/pipeline/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { Retriever, Reranker, RetrievalResult, RetrievalOptions, VectorStoreCore, Embedder } from '../types.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { KeywordIndex } from '../search/KeywordIndex.js';
|
|
3
3
|
export interface FusionRetrieverOptions {
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The keyword index for the BM25 tier — in-memory `BM25Index` or
|
|
6
|
+
* persistent `Fts5KeywordIndex`.
|
|
7
|
+
*/
|
|
8
|
+
keywordIndex: KeywordIndex;
|
|
6
9
|
/** The vector store for semantic search. */
|
|
7
10
|
vectorStore: VectorStoreCore;
|
|
8
11
|
/** The embedder to convert query text to vectors. */
|
|
@@ -36,7 +39,7 @@ export interface FusionRetrieverOptions {
|
|
|
36
39
|
* An optional reranker can refine results after fusion.
|
|
37
40
|
*/
|
|
38
41
|
export declare class FusionRetriever implements Retriever {
|
|
39
|
-
private readonly
|
|
42
|
+
private readonly keywordIndex;
|
|
40
43
|
private readonly vectorStore;
|
|
41
44
|
private readonly embedder;
|
|
42
45
|
private readonly indexName;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* An optional reranker can refine results after fusion.
|
|
14
14
|
*/
|
|
15
15
|
export class FusionRetriever {
|
|
16
|
-
|
|
16
|
+
keywordIndex;
|
|
17
17
|
vectorStore;
|
|
18
18
|
embedder;
|
|
19
19
|
indexName;
|
|
@@ -22,7 +22,7 @@ export class FusionRetriever {
|
|
|
22
22
|
defaultTopK;
|
|
23
23
|
fetchK;
|
|
24
24
|
constructor(options) {
|
|
25
|
-
this.
|
|
25
|
+
this.keywordIndex = options.keywordIndex;
|
|
26
26
|
this.vectorStore = options.vectorStore;
|
|
27
27
|
this.embedder = options.embedder;
|
|
28
28
|
this.indexName = options.indexName;
|
|
@@ -39,7 +39,7 @@ export class FusionRetriever {
|
|
|
39
39
|
const queryVector = options?.queryEmbedding ?? await this.embedder.embed(query);
|
|
40
40
|
// Run BM25 and vector search in parallel
|
|
41
41
|
const [bm25Results, vectorResults] = await Promise.all([
|
|
42
|
-
Promise.resolve(this.
|
|
42
|
+
Promise.resolve(this.keywordIndex.search(query, fetchK)),
|
|
43
43
|
this.vectorStore.query(this.indexName, {
|
|
44
44
|
queryVector,
|
|
45
45
|
topK: fetchK,
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* BM25 parameters default to the standard values from Robertson & Zaragoza
|
|
9
9
|
* (2009): k1 = 1.2, b = 0.75.
|
|
10
10
|
*/
|
|
11
|
+
import type { KeywordIndex } from './KeywordIndex.js';
|
|
11
12
|
export interface BM25Document {
|
|
12
13
|
/** Unique identifier for this document. */
|
|
13
14
|
id: string;
|
|
@@ -34,7 +35,16 @@ export interface BM25IndexOptions {
|
|
|
34
35
|
*/
|
|
35
36
|
b?: number;
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Shared keyword tokenizer — exported so `Fts5KeywordIndex` queries are
|
|
40
|
+
* tokenized identically to `BM25Index` for ranking parity.
|
|
41
|
+
*
|
|
42
|
+
* Keeps combining marks (`\p{M}`) as token characters — Indic scripts
|
|
43
|
+
* (Tamil, Sinhala, Hindi, ...) encode vowel signs as combining marks, and
|
|
44
|
+
* treating them as separators splits every word mid-syllable.
|
|
45
|
+
*/
|
|
46
|
+
export declare function tokenizeKeywords(text: string): string[];
|
|
47
|
+
export declare class BM25Index implements KeywordIndex {
|
|
38
48
|
private readonly k1;
|
|
39
49
|
private readonly b;
|
|
40
50
|
/** Ordered list of indexed documents. Index = internal doc ordinal. */
|
|
@@ -48,7 +58,7 @@ export declare class BM25Index {
|
|
|
48
58
|
/** Running sum of document lengths for avgdl computation. */
|
|
49
59
|
private totalLength;
|
|
50
60
|
constructor(options?: BM25IndexOptions);
|
|
51
|
-
/** Number of documents in the index. */
|
|
61
|
+
/** Number of active (non-removed) documents in the index. */
|
|
52
62
|
get size(): number;
|
|
53
63
|
/**
|
|
54
64
|
* Add documents to the index. If a document with the same ID already
|
package/dist/search/BM25Index.js
CHANGED
|
@@ -22,13 +22,22 @@ const STOP_WORDS = new Set([
|
|
|
22
22
|
'such', 'that', 'the', 'their', 'then', 'there', 'these', 'they',
|
|
23
23
|
'this', 'to', 'was', 'will', 'with',
|
|
24
24
|
]);
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Shared keyword tokenizer — exported so `Fts5KeywordIndex` queries are
|
|
27
|
+
* tokenized identically to `BM25Index` for ranking parity.
|
|
28
|
+
*
|
|
29
|
+
* Keeps combining marks (`\p{M}`) as token characters — Indic scripts
|
|
30
|
+
* (Tamil, Sinhala, Hindi, ...) encode vowel signs as combining marks, and
|
|
31
|
+
* treating them as separators splits every word mid-syllable.
|
|
32
|
+
*/
|
|
33
|
+
export function tokenizeKeywords(text) {
|
|
26
34
|
return text
|
|
27
35
|
.toLowerCase()
|
|
28
|
-
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
|
36
|
+
.replace(/[^\p{L}\p{N}\p{M}\s]/gu, ' ')
|
|
29
37
|
.split(/\s+/)
|
|
30
38
|
.filter(t => t.length >= 2 && !STOP_WORDS.has(t));
|
|
31
39
|
}
|
|
40
|
+
const tokenize = tokenizeKeywords;
|
|
32
41
|
export class BM25Index {
|
|
33
42
|
k1;
|
|
34
43
|
b;
|
|
@@ -46,9 +55,9 @@ export class BM25Index {
|
|
|
46
55
|
this.k1 = options?.k1 ?? 1.2;
|
|
47
56
|
this.b = options?.b ?? 0.75;
|
|
48
57
|
}
|
|
49
|
-
/** Number of documents in the index. */
|
|
58
|
+
/** Number of active (non-removed) documents in the index. */
|
|
50
59
|
get size() {
|
|
51
|
-
return this.
|
|
60
|
+
return this.idToOrdinal.size;
|
|
52
61
|
}
|
|
53
62
|
/**
|
|
54
63
|
* Add documents to the index. If a document with the same ID already
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fts5KeywordIndex — persistent BM25 keyword index over SQLite FTS5.
|
|
3
|
+
*
|
|
4
|
+
* The durable counterpart to the in-memory `BM25Index`: rows live in
|
|
5
|
+
* SQLite, so on Cloudflare (Durable Object SQLite supports the FTS5
|
|
6
|
+
* module) the keyword tier survives hibernation — a waking DO opens the
|
|
7
|
+
* index with ZERO rebuild instead of re-seeding the whole corpus.
|
|
8
|
+
*
|
|
9
|
+
* Ranking uses FTS5's built-in `bm25()` (lower = better; negated here so
|
|
10
|
+
* higher = better, matching `BM25Index`). Queries are tokenized with the
|
|
11
|
+
* same tokenizer as `BM25Index` and OR-combined, mirroring its
|
|
12
|
+
* sum-over-terms semantics.
|
|
13
|
+
*/
|
|
14
|
+
import { type SqlExecutor } from '../sql.js';
|
|
15
|
+
import { type BM25Document, type BM25SearchResult } from './BM25Index.js';
|
|
16
|
+
import type { KeywordIndex } from './KeywordIndex.js';
|
|
17
|
+
export interface Fts5KeywordIndexOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Tagged-template SQL executor. On Cloudflare:
|
|
20
|
+
* `createSqlExecutor(ctx.storage.sql)` from `@kuralle-agents/cf-agent`.
|
|
21
|
+
* On Node/Bun: a thin wrapper over `bun:sqlite` / `better-sqlite3`.
|
|
22
|
+
*/
|
|
23
|
+
sql: SqlExecutor;
|
|
24
|
+
/** FTS5 virtual table name. Default: 'kuralle_keyword_index'. */
|
|
25
|
+
tableName?: string;
|
|
26
|
+
/**
|
|
27
|
+
* FTS5 tokenizer spec. Default: `unicode61 categories 'L* N* Co Mn Mc'`
|
|
28
|
+
* — unicode61 extended to keep combining marks as token characters, so
|
|
29
|
+
* Indic scripts (Tamil, Sinhala, Hindi, ...) are not split at vowel
|
|
30
|
+
* signs. Good for all space-delimited languages. For unsegmented
|
|
31
|
+
* languages (Chinese, Japanese, Thai) use 'trigram' (substring
|
|
32
|
+
* matching, query terms must be ≥3 chars).
|
|
33
|
+
*/
|
|
34
|
+
tokenize?: string;
|
|
35
|
+
}
|
|
36
|
+
export declare class Fts5KeywordIndex implements KeywordIndex {
|
|
37
|
+
private readonly sql;
|
|
38
|
+
private readonly table;
|
|
39
|
+
constructor(options: Fts5KeywordIndexOptions);
|
|
40
|
+
get size(): number;
|
|
41
|
+
add(documents: BM25Document[]): void;
|
|
42
|
+
remove(id: string): boolean;
|
|
43
|
+
search(query: string, topK?: number): BM25SearchResult[];
|
|
44
|
+
clear(): void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fts5KeywordIndex — persistent BM25 keyword index over SQLite FTS5.
|
|
3
|
+
*
|
|
4
|
+
* The durable counterpart to the in-memory `BM25Index`: rows live in
|
|
5
|
+
* SQLite, so on Cloudflare (Durable Object SQLite supports the FTS5
|
|
6
|
+
* module) the keyword tier survives hibernation — a waking DO opens the
|
|
7
|
+
* index with ZERO rebuild instead of re-seeding the whole corpus.
|
|
8
|
+
*
|
|
9
|
+
* Ranking uses FTS5's built-in `bm25()` (lower = better; negated here so
|
|
10
|
+
* higher = better, matching `BM25Index`). Queries are tokenized with the
|
|
11
|
+
* same tokenizer as `BM25Index` and OR-combined, mirroring its
|
|
12
|
+
* sum-over-terms semantics.
|
|
13
|
+
*/
|
|
14
|
+
import { assertSqlIdentifier, execSql } from '../sql.js';
|
|
15
|
+
import { tokenizeKeywords } from './BM25Index.js';
|
|
16
|
+
const DEFAULT_TOKENIZE = "unicode61 categories 'L* N* Co Mn Mc'";
|
|
17
|
+
export class Fts5KeywordIndex {
|
|
18
|
+
sql;
|
|
19
|
+
table;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.sql = options.sql;
|
|
22
|
+
this.table = assertSqlIdentifier(options.tableName ?? 'kuralle_keyword_index');
|
|
23
|
+
const tokenize = options.tokenize ?? DEFAULT_TOKENIZE;
|
|
24
|
+
if (!/^[A-Za-z0-9_*' ]+$/.test(tokenize)) {
|
|
25
|
+
throw new Error(`Invalid FTS5 tokenize spec: '${tokenize}'`);
|
|
26
|
+
}
|
|
27
|
+
execSql(this.sql, `CREATE VIRTUAL TABLE IF NOT EXISTS ${this.table} ` +
|
|
28
|
+
`USING fts5(doc_id UNINDEXED, content, tokenize = '${tokenize.replaceAll("'", "''")}')`);
|
|
29
|
+
}
|
|
30
|
+
get size() {
|
|
31
|
+
const rows = execSql(this.sql, `SELECT count(*) AS n FROM ${this.table}`);
|
|
32
|
+
return rows[0]?.n ?? 0;
|
|
33
|
+
}
|
|
34
|
+
add(documents) {
|
|
35
|
+
for (const doc of documents) {
|
|
36
|
+
execSql(this.sql, `DELETE FROM ${this.table} WHERE doc_id = ?`, [doc.id]);
|
|
37
|
+
execSql(this.sql, `INSERT INTO ${this.table} (doc_id, content) VALUES (?, ?)`, [doc.id, doc.text]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
remove(id) {
|
|
41
|
+
const existing = execSql(this.sql, `SELECT count(*) AS n FROM ${this.table} WHERE doc_id = ?`, [id]);
|
|
42
|
+
if ((existing[0]?.n ?? 0) === 0)
|
|
43
|
+
return false;
|
|
44
|
+
execSql(this.sql, `DELETE FROM ${this.table} WHERE doc_id = ?`, [id]);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
search(query, topK = 10) {
|
|
48
|
+
const terms = tokenizeKeywords(query);
|
|
49
|
+
if (terms.length === 0)
|
|
50
|
+
return [];
|
|
51
|
+
// Tokens are alphanumeric after tokenization; quoting keeps FTS5 from
|
|
52
|
+
// interpreting them as query syntax.
|
|
53
|
+
const match = terms.map((t) => `"${t}"`).join(' OR ');
|
|
54
|
+
const rows = execSql(this.sql, `SELECT doc_id AS id, -bm25(${this.table}) AS score FROM ${this.table} ` +
|
|
55
|
+
`WHERE ${this.table} MATCH ? ORDER BY bm25(${this.table}) LIMIT ?`, [match, topK]);
|
|
56
|
+
return rows.map((r) => ({ id: r.id, score: r.score }));
|
|
57
|
+
}
|
|
58
|
+
clear() {
|
|
59
|
+
execSql(this.sql, `DELETE FROM ${this.table}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BM25Document, BM25SearchResult } from './BM25Index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Contract for a BM25-ranked keyword index — the keyword tier of hybrid
|
|
4
|
+
* retrieval. Implementations: `BM25Index` (in-memory, rebuilt per process)
|
|
5
|
+
* and `Fts5KeywordIndex` (SQLite FTS5, persistent — survives Durable
|
|
6
|
+
* Object hibernation with zero rebuild).
|
|
7
|
+
*
|
|
8
|
+
* All methods are synchronous: the in-memory index is pure computation and
|
|
9
|
+
* DO SQLite's `sql.exec` is synchronous, so the contract stays sync to keep
|
|
10
|
+
* `FusionRetriever` / `KnowledgeFs` hot paths allocation-free.
|
|
11
|
+
*/
|
|
12
|
+
export interface KeywordIndex {
|
|
13
|
+
/** Number of documents in the index. */
|
|
14
|
+
readonly size: number;
|
|
15
|
+
/**
|
|
16
|
+
* Add documents. A document with an existing ID is overwritten.
|
|
17
|
+
*/
|
|
18
|
+
add(documents: BM25Document[]): void;
|
|
19
|
+
/**
|
|
20
|
+
* Remove a document by ID. Returns true if it existed.
|
|
21
|
+
*/
|
|
22
|
+
remove(id: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Search for documents matching the query, BM25-scored, descending.
|
|
25
|
+
*/
|
|
26
|
+
search(query: string, topK?: number): BM25SearchResult[];
|
|
27
|
+
/** Remove all documents. */
|
|
28
|
+
clear(): void;
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/search/index.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
export { BM25Index } from './BM25Index.js';
|
|
1
|
+
export { BM25Index, tokenizeKeywords } from './BM25Index.js';
|
|
2
2
|
export type { BM25Document, BM25SearchResult, BM25IndexOptions } from './BM25Index.js';
|
|
3
|
+
export type { KeywordIndex } from './KeywordIndex.js';
|
|
4
|
+
export { Fts5KeywordIndex } from './Fts5KeywordIndex.js';
|
|
5
|
+
export type { Fts5KeywordIndexOptions } from './Fts5KeywordIndex.js';
|
package/dist/search/index.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export { BM25Index } from './BM25Index.js';
|
|
1
|
+
export { BM25Index, tokenizeKeywords } from './BM25Index.js';
|
|
2
|
+
export { Fts5KeywordIndex } from './Fts5KeywordIndex.js';
|
package/dist/sql.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal tagged-template SQL executor contract.
|
|
3
|
+
*
|
|
4
|
+
* Structurally identical to `SqlExecutor` in `@kuralle-agents/cf-agent`
|
|
5
|
+
* (Durable Object `ctx.storage.sql.exec` wrapped by `createSqlExecutor`),
|
|
6
|
+
* and trivially implementable over `bun:sqlite` / `better-sqlite3` for
|
|
7
|
+
* Node-side persistence. Declared here so `@kuralle-agents/rag` stays
|
|
8
|
+
* dependency-free of the Cloudflare package.
|
|
9
|
+
*/
|
|
10
|
+
export type SqlExecutor = <T = unknown>(strings: TemplateStringsArray, ...values: unknown[]) => T[];
|
|
11
|
+
/**
|
|
12
|
+
* Run a dynamically-built query through a tagged-template `SqlExecutor`.
|
|
13
|
+
* `query` uses `?` for each positional parameter. Needed because table
|
|
14
|
+
* names cannot be bound parameters in SQL.
|
|
15
|
+
*/
|
|
16
|
+
export declare function execSql<T = unknown>(sql: SqlExecutor, query: string, params?: unknown[]): T[];
|
|
17
|
+
/** Validate a SQL identifier (table name) — interpolated, never bound. */
|
|
18
|
+
export declare function assertSqlIdentifier(name: string): string;
|
package/dist/sql.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a dynamically-built query through a tagged-template `SqlExecutor`.
|
|
3
|
+
* `query` uses `?` for each positional parameter. Needed because table
|
|
4
|
+
* names cannot be bound parameters in SQL.
|
|
5
|
+
*/
|
|
6
|
+
export function execSql(sql, query, params = []) {
|
|
7
|
+
const parts = query.split('?');
|
|
8
|
+
if (parts.length !== params.length + 1) {
|
|
9
|
+
throw new Error(`execSql: query has ${parts.length - 1} placeholders but ${params.length} params`);
|
|
10
|
+
}
|
|
11
|
+
const strings = Object.assign([...parts], { raw: [...parts] });
|
|
12
|
+
return sql(strings, ...params);
|
|
13
|
+
}
|
|
14
|
+
/** Validate a SQL identifier (table name) — interpolated, never bound. */
|
|
15
|
+
export function assertSqlIdentifier(name) {
|
|
16
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
17
|
+
throw new Error(`Invalid SQL identifier: '${name}'`);
|
|
18
|
+
}
|
|
19
|
+
return name;
|
|
20
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -128,6 +128,14 @@ export interface Embedder {
|
|
|
128
128
|
* May be undefined if the dimension is not known until the first embed() call.
|
|
129
129
|
*/
|
|
130
130
|
readonly dimension?: number;
|
|
131
|
+
/**
|
|
132
|
+
* Stable identity of the underlying embedding model, e.g.
|
|
133
|
+
* `"openai.embedding/text-embedding-3-small"`. Used by the ingest
|
|
134
|
+
* manifest to lock an index to the model that built it — two different
|
|
135
|
+
* models of the same dimension produce incompatible vector spaces, so a
|
|
136
|
+
* silent swap degrades every query without erroring.
|
|
137
|
+
*/
|
|
138
|
+
readonly id?: string;
|
|
131
139
|
}
|
|
132
140
|
/**
|
|
133
141
|
* A single entry to upsert into a vector index.
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"url": "git+https://github.com/kuralle/kuralle-agents.git",
|
|
7
7
|
"directory": "packages/kuralle-rag"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.10.0",
|
|
10
10
|
"description": "RAG primitives for Kuralle",
|
|
11
11
|
"type": "module",
|
|
12
12
|
"main": "dist/index.js",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@kuralle-agents/core": "0.
|
|
33
|
+
"@kuralle-agents/core": "0.10.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"ai": "^6.0.0",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"bun-types": "^1.3.0",
|
|
43
43
|
"typescript": "^5.3.0",
|
|
44
44
|
"zod": "^4.0.0",
|
|
45
|
-
"@kuralle-agents/fs": "0.
|
|
45
|
+
"@kuralle-agents/fs": "0.10.0"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"prebuild": "rm -rf dist",
|
|
56
56
|
"build": "tsc -p tsconfig.json",
|
|
57
57
|
"clean": "rm -rf dist",
|
|
58
|
-
"test": "bun test test/chunkers-tokens.test.ts test/vectorStores test/filters test/e2e-loaders.test.ts test/knowledgefs.test.ts test/knowledgefs-grep.test.ts test/knowledgefs-rbac.test.ts test/knowledgefs-agent.test.ts",
|
|
58
|
+
"test": "bun test test/chunkers-tokens.test.ts test/vectorStores test/filters test/e2e-loaders.test.ts test/knowledgefs.test.ts test/knowledgefs-grep.test.ts test/knowledgefs-rbac.test.ts test/knowledgefs-agent.test.ts test/pipeline-manifest.test.ts test/fts5-keyword-index.test.ts",
|
|
59
59
|
"test:live": "bun test test/e2e-tasktype.test.ts test/e2e-real.test.ts"
|
|
60
60
|
}
|
|
61
61
|
}
|