@dudousxd/nestjs-agent-rag 0.4.0 → 0.5.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 +122 -3
- package/dist/index.cjs +250 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -4
- package/dist/index.d.ts +177 -4
- package/dist/index.js +246 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# @dudousxd/nestjs-agent-rag
|
|
2
2
|
|
|
3
3
|
Retrieval-Augmented Generation for [`@dudousxd/nestjs-agent`](../nestjs). Chunking, ingestion, an
|
|
4
|
-
embedding-backed `Retriever`, and
|
|
5
|
-
it depends only on `-core`, and you bring your own embedding model and (for
|
|
4
|
+
embedding-backed `Retriever`, and three vector stores — in-memory, pgvector and RediSearch.
|
|
5
|
+
Framework-agnostic: it depends only on `-core`, and you bring your own embedding model and (for the
|
|
6
|
+
pgvector / RediSearch stores) database driver.
|
|
6
7
|
|
|
7
8
|
## Install
|
|
8
9
|
|
|
@@ -69,10 +70,128 @@ const store = new PgVectorStore(
|
|
|
69
70
|
await store.ensureSchema(); // CREATE EXTENSION vector + table + HNSW cosine index
|
|
70
71
|
```
|
|
71
72
|
|
|
73
|
+
## RediSearch store
|
|
74
|
+
|
|
75
|
+
`RedisVectorStore` takes anything that can send a raw command — `node-redis`, or `ioredis` wrapped —
|
|
76
|
+
so this package pulls in no Redis driver either. Needs the RediSearch module (Redis Stack / Redis 8+):
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { createClient } from 'redis';
|
|
80
|
+
import { RedisVectorStore } from '@dudousxd/nestjs-agent-rag';
|
|
81
|
+
|
|
82
|
+
const client = createClient({ url: process.env.REDIS_URL });
|
|
83
|
+
await client.connect();
|
|
84
|
+
|
|
85
|
+
const store = new RedisVectorStore(
|
|
86
|
+
{ sendCommand: (args) => client.sendCommand(args) },
|
|
87
|
+
{ dimensions: 1536, filterableFields: ['audience'] },
|
|
88
|
+
);
|
|
89
|
+
await store.ensureSchema(); // FT.CREATE: TEXT + TAG + HNSW cosine vector field
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`filterableFields` declares which metadata keys become filterable `TAG` fields — RediSearch needs them
|
|
93
|
+
up front, so only those keys can be used in a `filter`. This store also does lexical search; see below.
|
|
94
|
+
|
|
95
|
+
`ensureSchema()` belongs at boot. It creates the index if missing — and if the index already exists,
|
|
96
|
+
it compares it against your configuration rather than assuming they agree. A `filterableFields` entry
|
|
97
|
+
added after the index was created is repaired in place with `FT.ALTER … SCHEMA ADD` (chunks written
|
|
98
|
+
before the repair only become filterable on that key once re-ingested). A changed `dimensions` — a
|
|
99
|
+
model swap — can't be repaired without a full reindex, so it throws `RedisVectorSchemaMismatchError`
|
|
100
|
+
instead of silently leaving the index on the old width.
|
|
101
|
+
|
|
102
|
+
## Hybrid search (dense + lexical)
|
|
103
|
+
|
|
104
|
+
`HybridRetriever` fuses several retrievers with Reciprocal Rank Fusion. RRF is **rank**-based, so the
|
|
105
|
+
two halves need no common score scale — cosine similarity and BM25 fuse as they are.
|
|
106
|
+
|
|
107
|
+
There are two ways to get the lexical half, and which one you want is decided by your deployment, not
|
|
108
|
+
your taste.
|
|
109
|
+
|
|
110
|
+
**Store-backed (the default for anything multi-process).** `RedisVectorStore` already declares the
|
|
111
|
+
chunk text as a RediSearch `TEXT` field, so the lexical index is *already there* — `LexicalRetriever`
|
|
112
|
+
just queries it with BM25:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { EmbeddingRetriever, HybridRetriever, LexicalRetriever } from '@dudousxd/nestjs-agent-rag';
|
|
116
|
+
|
|
117
|
+
const retriever = new HybridRetriever([
|
|
118
|
+
new EmbeddingRetriever(embedder, store),
|
|
119
|
+
new LexicalRetriever(store), // ← same store, no second index
|
|
120
|
+
]);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Nothing else changes: no chunks to hand the retriever, no state in the process, no deletes to mirror.
|
|
124
|
+
This is the only option that works when **ingestion and search run in different processes** — a
|
|
125
|
+
worker that ingests and an API pod that searches. The API pod never sees the chunks, so an in-process
|
|
126
|
+
index there would be permanently empty (or kept warm by scanning the corpus into JS heap on every
|
|
127
|
+
pod, which is the workaround this exists to delete). It also means a document is lexically findable
|
|
128
|
+
the moment it is upserted, with no refresh window.
|
|
129
|
+
|
|
130
|
+
`searchText` applies `filter` with exactly the same semantics as `search` — array = OR, **empty array
|
|
131
|
+
= deny** — so a lexical hit can never escape a filter a vector hit would have respected. That matters
|
|
132
|
+
when the filter is carrying capability tokens rather than a hint.
|
|
133
|
+
|
|
134
|
+
A query is reduced to letter/digit/`_` terms before it is sent, so RediSearch query syntax in user
|
|
135
|
+
input (`*`, `@field:{…}`, `|`, `-`, `=>[KNN …]`) is inert rather than escaped-but-live. A query with
|
|
136
|
+
no searchable terms left — empty, whitespace, or pure punctuation — returns `[]` rather than matching
|
|
137
|
+
the corpus.
|
|
138
|
+
|
|
139
|
+
**In-process (`KeywordRetriever`).** Still the right answer for `MemoryVectorStore`, for a store with
|
|
140
|
+
no lexical capability, and for single-process deployments where you control both halves. It is a full
|
|
141
|
+
BM25 implementation over chunks you feed it, so chunk once and give the SAME chunks to both halves —
|
|
142
|
+
their ids line up and `HybridRetriever` can fuse the rankings:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const chunks = chunkDocuments(docs);
|
|
146
|
+
await ingestChunks(chunks, { embedder, store });
|
|
147
|
+
keyword.add(chunks); // its own copy of the corpus, in this process only
|
|
148
|
+
|
|
149
|
+
const retriever = new HybridRetriever([new EmbeddingRetriever(embedder, store), keyword]);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Because that is a second index, **deletes have to hit both**, and only this process can see it.
|
|
153
|
+
`KeywordRetriever` keeps its own copy of each chunk's text, so a document dropped from the vector
|
|
154
|
+
store alone stays fully retrievable — `retrieve` will keep returning the removed passage:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
await store.remove(documentId);
|
|
158
|
+
keyword.remove(documentId); // ← the other half. Same document id; chunk ids collapse for you.
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`KeywordRetriever` also exposes `clear()` and `size` for rebuilding the index wholesale. None of this
|
|
162
|
+
bookkeeping exists on the store-backed path above, which is the point of it.
|
|
163
|
+
|
|
164
|
+
Ask the store which it supports rather than hard-coding:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { isLexicalVectorStore } from '@dudousxd/nestjs-agent-rag';
|
|
168
|
+
|
|
169
|
+
const lexical = isLexicalVectorStore(store) ? new LexicalRetriever(store) : keywordRetriever;
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Which stores implement `LexicalVectorStore`
|
|
173
|
+
|
|
174
|
+
| Store | Lexical | Why |
|
|
175
|
+
| --- | --- | --- |
|
|
176
|
+
| `RedisVectorStore` | ✅ | RediSearch indexes the chunk text as `TEXT`; BM25 is already there |
|
|
177
|
+
| `PgVectorStore` | ❌ | the chunks table has no `tsvector`/GIN index, and adding one is a migration |
|
|
178
|
+
| `MemoryVectorStore` | ❌ | single-process by definition — use `KeywordRetriever` |
|
|
179
|
+
|
|
180
|
+
Postgres is a deliberate omission. Lexical search there needs new DDL (a GIN index), and `CREATE
|
|
181
|
+
INDEX` in `ensureSchema` would take a write lock on an already-populated chunks table at boot; it also
|
|
182
|
+
forces a text-search-configuration choice that must match between index and query or Postgres silently
|
|
183
|
+
falls back to a sequential scan. That wants a migration you run and watch, not a side effect of a
|
|
184
|
+
library upgrade. The interface is open, so it can be added later without changing anything else.
|
|
185
|
+
|
|
72
186
|
## API
|
|
73
187
|
|
|
74
188
|
- `chunkText(text, { chunkSize?, overlap? })` — overlapping, boundary-aware chunks.
|
|
75
189
|
- `ingestDocuments(docs, { embedder, store, chunkSize?, overlap? })` — chunk → embed → upsert.
|
|
76
190
|
- `EmbeddingRetriever(embedder, store)` — the core `Retriever` from an embedder + vector store.
|
|
77
|
-
- `
|
|
191
|
+
- `LexicalRetriever(store)` — the core `Retriever` from a store's own full-text index (BM25).
|
|
192
|
+
- `KeywordRetriever` — in-process BM25 over chunks you feed it; the single-process lexical half:
|
|
193
|
+
`add(chunks)`, `remove(documentId)`, `clear()`, `size`.
|
|
194
|
+
- `HybridRetriever(retrievers, { k?, fetchTopK?, weights? })` — RRF fusion of the two.
|
|
195
|
+
- `MemoryVectorStore` / `PgVectorStore` / `RedisVectorStore` — `VectorStore` adapters.
|
|
196
|
+
- `isLexicalVectorStore(store)` — type guard for the optional `LexicalVectorStore` capability.
|
|
78
197
|
- `createRetrievalTool(retriever, { name?, description?, topK? })` — the agentic-retrieval tool.
|
package/dist/index.cjs
CHANGED
|
@@ -25,8 +25,10 @@ __export(index_exports, {
|
|
|
25
25
|
FilteredRetriever: () => FilteredRetriever,
|
|
26
26
|
HybridRetriever: () => HybridRetriever,
|
|
27
27
|
KeywordRetriever: () => KeywordRetriever,
|
|
28
|
+
LexicalRetriever: () => LexicalRetriever,
|
|
28
29
|
MemoryVectorStore: () => MemoryVectorStore,
|
|
29
30
|
PgVectorStore: () => PgVectorStore,
|
|
31
|
+
RedisVectorSchemaMismatchError: () => RedisVectorSchemaMismatchError,
|
|
30
32
|
RedisVectorStore: () => RedisVectorStore,
|
|
31
33
|
RerankingRetriever: () => RerankingRetriever,
|
|
32
34
|
chunkDocuments: () => chunkDocuments,
|
|
@@ -34,7 +36,8 @@ __export(index_exports, {
|
|
|
34
36
|
createRetrievalTool: () => createRetrievalTool,
|
|
35
37
|
documentIdOf: () => documentIdOf,
|
|
36
38
|
ingestChunks: () => ingestChunks,
|
|
37
|
-
ingestDocuments: () => ingestDocuments
|
|
39
|
+
ingestDocuments: () => ingestDocuments,
|
|
40
|
+
isLexicalVectorStore: () => isLexicalVectorStore
|
|
38
41
|
});
|
|
39
42
|
module.exports = __toCommonJS(index_exports);
|
|
40
43
|
|
|
@@ -77,6 +80,10 @@ function chunkText(text, options = {}) {
|
|
|
77
80
|
__name(chunkText, "chunkText");
|
|
78
81
|
|
|
79
82
|
// src/vector-store.ts
|
|
83
|
+
function isLexicalVectorStore(store) {
|
|
84
|
+
return typeof store.searchText === "function";
|
|
85
|
+
}
|
|
86
|
+
__name(isLexicalVectorStore, "isLexicalVectorStore");
|
|
80
87
|
function documentIdOf(chunkId) {
|
|
81
88
|
return chunkId.replace(/#\d+$/, "");
|
|
82
89
|
}
|
|
@@ -306,6 +313,19 @@ function toVectorLiteral(embedding) {
|
|
|
306
313
|
__name(toVectorLiteral, "toVectorLiteral");
|
|
307
314
|
|
|
308
315
|
// src/redis-vector-store.ts
|
|
316
|
+
var RedisVectorSchemaMismatchError = class extends Error {
|
|
317
|
+
static {
|
|
318
|
+
__name(this, "RedisVectorSchemaMismatchError");
|
|
319
|
+
}
|
|
320
|
+
index;
|
|
321
|
+
field;
|
|
322
|
+
expected;
|
|
323
|
+
actual;
|
|
324
|
+
constructor(index, field, expected, actual, remedy) {
|
|
325
|
+
super(`RediSearch index "${index}" disagrees with this RedisVectorStore's configuration: field "${field}" is ${actual}, expected ${expected}. ${remedy}`), this.index = index, this.field = field, this.expected = expected, this.actual = actual;
|
|
326
|
+
this.name = "RedisVectorSchemaMismatchError";
|
|
327
|
+
}
|
|
328
|
+
};
|
|
309
329
|
var RedisVectorStore = class {
|
|
310
330
|
static {
|
|
311
331
|
__name(this, "RedisVectorStore");
|
|
@@ -315,23 +335,83 @@ var RedisVectorStore = class {
|
|
|
315
335
|
prefix;
|
|
316
336
|
dimensions;
|
|
317
337
|
filterableFields;
|
|
338
|
+
lexicalScorer;
|
|
318
339
|
constructor(client, options = {}) {
|
|
319
340
|
this.client = client;
|
|
320
341
|
this.index = options.index ?? "agent_rag_idx";
|
|
321
342
|
this.prefix = options.prefix ?? "agent_rag:";
|
|
322
343
|
this.dimensions = options.dimensions ?? 1536;
|
|
323
344
|
this.filterableFields = options.filterableFields ?? [];
|
|
345
|
+
this.lexicalScorer = options.lexicalScorer ?? "BM25";
|
|
324
346
|
}
|
|
325
|
-
/**
|
|
347
|
+
/**
|
|
348
|
+
* Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
|
|
349
|
+
* and if it DOES exist, compare what's there against this store's configuration instead of
|
|
350
|
+
* assuming they agree. An index that outlives a config change is the normal case (schemas live in
|
|
351
|
+
* Redis, config lives in a redeployed process), and the two failure modes it produces are both
|
|
352
|
+
* invisible at runtime: a `filterableFields` entry added later has no `meta_*` TAG, so every search
|
|
353
|
+
* filtering on it matches *nothing*; a changed `dimensions` leaves the index on the old width, so
|
|
354
|
+
* writes are rejected or KNN ranks garbage. Neither raises anything on its own.
|
|
355
|
+
*
|
|
356
|
+
* The two are repaired differently, because only one of them CAN be repaired:
|
|
357
|
+
*
|
|
358
|
+
* - **A missing filterable TAG is additive** → `FT.ALTER … SCHEMA ADD`. Nothing that already works
|
|
359
|
+
* changes, so doing it automatically is safe. Note the chunks written *before* the alter carry no
|
|
360
|
+
* `meta_*` hash field at all (`upsert` only writes the fields configured at the time), so they
|
|
361
|
+
* only become filterable on that key once they're re-ingested — the schema is repaired here, the
|
|
362
|
+
* backfill is the host's call.
|
|
363
|
+
* - **A dimension (or field-type) change is not repairable** → {@link RedisVectorSchemaMismatchError}.
|
|
364
|
+
* It needs a drop + full reindex, which would destroy the host's corpus; failing loudly at boot is
|
|
365
|
+
* the only honest option.
|
|
366
|
+
*
|
|
367
|
+
* If `FT.INFO` comes back in a shape this parser doesn't recognise, no drift is inferred — an
|
|
368
|
+
* unreadable reply must not be turned into a false alarm that blocks a boot.
|
|
369
|
+
*/
|
|
326
370
|
async ensureSchema() {
|
|
371
|
+
let info;
|
|
327
372
|
try {
|
|
328
|
-
await this.client.sendCommand([
|
|
373
|
+
info = await this.client.sendCommand([
|
|
329
374
|
"FT.INFO",
|
|
330
375
|
this.index
|
|
331
376
|
]);
|
|
332
|
-
return;
|
|
333
377
|
} catch {
|
|
378
|
+
await this.createIndex();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
await this.reconcileSchema(info);
|
|
382
|
+
}
|
|
383
|
+
/** Repair what can be repaired in place; throw on what can't. */
|
|
384
|
+
async reconcileSchema(info) {
|
|
385
|
+
const attributes = parseIndexAttributes(info);
|
|
386
|
+
if (attributes.size === 0) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const vector = attributes.get("embedding");
|
|
390
|
+
if (vector?.dimensions !== void 0 && vector.dimensions !== this.dimensions) {
|
|
391
|
+
throw new RedisVectorSchemaMismatchError(this.index, "embedding", `DIM ${this.dimensions}`, `DIM ${vector.dimensions}`, `A vector field's width is fixed at FT.CREATE and every stored embedding has the old one, so this cannot be altered in place: drop the index (FT.DROPINDEX ${this.index} DD) and re-ingest with the new embedding model.`);
|
|
392
|
+
}
|
|
393
|
+
const missing = [];
|
|
394
|
+
for (const field of this.filterableFields) {
|
|
395
|
+
const name = `meta_${field}`;
|
|
396
|
+
const existing = attributes.get(name);
|
|
397
|
+
if (existing === void 0) {
|
|
398
|
+
missing.push(name);
|
|
399
|
+
} else if (existing.type !== "TAG") {
|
|
400
|
+
throw new RedisVectorSchemaMismatchError(this.index, name, "TAG", existing.type, `A field's type cannot be changed in place: drop the index (FT.DROPINDEX ${this.index} DD) and re-ingest, or rename the metadata key.`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
for (const name of missing) {
|
|
404
|
+
await this.client.sendCommand([
|
|
405
|
+
"FT.ALTER",
|
|
406
|
+
this.index,
|
|
407
|
+
"SCHEMA",
|
|
408
|
+
"ADD",
|
|
409
|
+
name,
|
|
410
|
+
"TAG"
|
|
411
|
+
]);
|
|
334
412
|
}
|
|
413
|
+
}
|
|
414
|
+
async createIndex() {
|
|
335
415
|
const schema = [
|
|
336
416
|
"text",
|
|
337
417
|
"TEXT",
|
|
@@ -484,7 +564,100 @@ var RedisVectorStore = class {
|
|
|
484
564
|
]);
|
|
485
565
|
return parseSearchReply(reply, this.prefix);
|
|
486
566
|
}
|
|
567
|
+
/**
|
|
568
|
+
* Lexical (BM25) search over the same index `search` uses — the {@link LexicalVectorStore} half of
|
|
569
|
+
* hybrid retrieval, served by RediSearch's own keyword index rather than an in-process copy of the
|
|
570
|
+
* corpus. `options.filter` is ANDed onto the text clause with the identical TAG semantics as
|
|
571
|
+
* `search` (array = OR, empty array = deny), so a lexical hit can never escape a filter a vector
|
|
572
|
+
* hit would have respected.
|
|
573
|
+
*
|
|
574
|
+
* A query with no searchable terms — empty, whitespace, or pure punctuation — returns `[]`. That is
|
|
575
|
+
* deliberate: RediSearch's `*` would match the entire corpus, which is the opposite of what a
|
|
576
|
+
* relevance query with nothing to be relevant to should do.
|
|
577
|
+
*/
|
|
578
|
+
async searchText(query, options) {
|
|
579
|
+
if (filterMatchesNothing(options.filter)) {
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
const terms = tokenizeQuery(query);
|
|
583
|
+
if (terms.length === 0) {
|
|
584
|
+
return [];
|
|
585
|
+
}
|
|
586
|
+
const filterQuery = buildFilter(options.filter);
|
|
587
|
+
const textQuery = `@text:(${terms.join("|")})`;
|
|
588
|
+
const reply = await this.client.sendCommand([
|
|
589
|
+
"FT.SEARCH",
|
|
590
|
+
this.index,
|
|
591
|
+
filterQuery === "*" ? textQuery : `(${filterQuery} ${textQuery})`,
|
|
592
|
+
"SCORER",
|
|
593
|
+
this.lexicalScorer,
|
|
594
|
+
"WITHSCORES",
|
|
595
|
+
"RETURN",
|
|
596
|
+
"3",
|
|
597
|
+
"text",
|
|
598
|
+
"source",
|
|
599
|
+
"metadata_json",
|
|
600
|
+
"DIALECT",
|
|
601
|
+
"2",
|
|
602
|
+
"LIMIT",
|
|
603
|
+
"0",
|
|
604
|
+
String(options.topK)
|
|
605
|
+
]);
|
|
606
|
+
return parseSearchReply(reply, this.prefix, "lexical");
|
|
607
|
+
}
|
|
487
608
|
};
|
|
609
|
+
function parseIndexAttributes(reply) {
|
|
610
|
+
const attributes = /* @__PURE__ */ new Map();
|
|
611
|
+
for (const entry of readInfoField(reply, "attributes")) {
|
|
612
|
+
const fields = readKeyValues(entry);
|
|
613
|
+
const name = fields.attribute ?? fields.identifier;
|
|
614
|
+
if (name === void 0) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const dim = Number(fields.dim);
|
|
618
|
+
attributes.set(name, {
|
|
619
|
+
type: (fields.type ?? "").toUpperCase(),
|
|
620
|
+
...fields.dim !== void 0 && Number.isFinite(dim) ? {
|
|
621
|
+
dimensions: dim
|
|
622
|
+
} : {}
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
return attributes;
|
|
626
|
+
}
|
|
627
|
+
__name(parseIndexAttributes, "parseIndexAttributes");
|
|
628
|
+
function readInfoField(reply, key) {
|
|
629
|
+
if (Array.isArray(reply)) {
|
|
630
|
+
for (let index = 0; index + 1 < reply.length; index += 2) {
|
|
631
|
+
if (toStr(reply[index]) === key) {
|
|
632
|
+
const value = reply[index + 1];
|
|
633
|
+
return Array.isArray(value) ? value : [];
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return [];
|
|
637
|
+
}
|
|
638
|
+
if (typeof reply === "object" && reply !== null && key in reply) {
|
|
639
|
+
const value = reply[key];
|
|
640
|
+
return Array.isArray(value) ? value : [];
|
|
641
|
+
}
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
__name(readInfoField, "readInfoField");
|
|
645
|
+
function readKeyValues(entry) {
|
|
646
|
+
const fields = {};
|
|
647
|
+
if (Array.isArray(entry)) {
|
|
648
|
+
for (let index = 0; index + 1 < entry.length; index += 2) {
|
|
649
|
+
fields[toStr(entry[index]).toLowerCase()] = toStr(entry[index + 1]);
|
|
650
|
+
}
|
|
651
|
+
return fields;
|
|
652
|
+
}
|
|
653
|
+
if (typeof entry === "object" && entry !== null) {
|
|
654
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
655
|
+
fields[key.toLowerCase()] = toStr(value);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return fields;
|
|
659
|
+
}
|
|
660
|
+
__name(readKeyValues, "readKeyValues");
|
|
488
661
|
function encodeVector(embedding) {
|
|
489
662
|
return Buffer.from(new Float32Array(embedding).buffer);
|
|
490
663
|
}
|
|
@@ -510,6 +683,10 @@ function filterMatchesNothing(filter) {
|
|
|
510
683
|
return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
|
|
511
684
|
}
|
|
512
685
|
__name(filterMatchesNothing, "filterMatchesNothing");
|
|
686
|
+
function tokenizeQuery(query) {
|
|
687
|
+
return query.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
688
|
+
}
|
|
689
|
+
__name(tokenizeQuery, "tokenizeQuery");
|
|
513
690
|
function escapeTag(value) {
|
|
514
691
|
return value.replace(/[^a-zA-Z0-9_]/g, (char) => `\\${char}`);
|
|
515
692
|
}
|
|
@@ -546,17 +723,19 @@ function toStr(value) {
|
|
|
546
723
|
return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
|
|
547
724
|
}
|
|
548
725
|
__name(toStr, "toStr");
|
|
549
|
-
function parseSearchReply(reply, prefix) {
|
|
726
|
+
function parseSearchReply(reply, prefix, scoreSource = "vector") {
|
|
727
|
+
const stride = scoreSource === "lexical" ? 3 : 2;
|
|
550
728
|
if (Array.isArray(reply)) {
|
|
551
729
|
const passages = [];
|
|
552
|
-
for (let index = 1; index + 1 < reply.length; index +=
|
|
553
|
-
const rawFields = reply[index + 1];
|
|
730
|
+
for (let index = 1; index + stride - 1 < reply.length; index += stride) {
|
|
731
|
+
const rawFields = reply[index + stride - 1];
|
|
554
732
|
if (Array.isArray(rawFields)) {
|
|
555
733
|
const attrs = {};
|
|
556
734
|
for (let field = 0; field + 1 < rawFields.length; field += 2) {
|
|
557
735
|
attrs[toStr(rawFields[field])] = toStr(rawFields[field + 1]);
|
|
558
736
|
}
|
|
559
|
-
|
|
737
|
+
const inlineScore = scoreSource === "lexical" ? toStr(reply[index + 1]) : void 0;
|
|
738
|
+
passages.push(toPassage(toStr(reply[index]), attrs, prefix, scoreSource, inlineScore));
|
|
560
739
|
}
|
|
561
740
|
}
|
|
562
741
|
return passages;
|
|
@@ -564,12 +743,19 @@ function parseSearchReply(reply, prefix) {
|
|
|
564
743
|
if (typeof reply === "object" && reply !== null && "results" in reply) {
|
|
565
744
|
const results = reply.results;
|
|
566
745
|
if (Array.isArray(results)) {
|
|
567
|
-
return results.map((result) => toPassage(readId(result), readAttributes(result), prefix));
|
|
746
|
+
return results.map((result) => toPassage(readId(result), readAttributes(result), prefix, scoreSource, scoreSource === "lexical" ? readScore(result) : void 0));
|
|
568
747
|
}
|
|
569
748
|
}
|
|
570
749
|
return [];
|
|
571
750
|
}
|
|
572
751
|
__name(parseSearchReply, "parseSearchReply");
|
|
752
|
+
function readScore(result) {
|
|
753
|
+
if (typeof result === "object" && result !== null && "score" in result) {
|
|
754
|
+
return toStr(result.score);
|
|
755
|
+
}
|
|
756
|
+
return void 0;
|
|
757
|
+
}
|
|
758
|
+
__name(readScore, "readScore");
|
|
573
759
|
function readId(result) {
|
|
574
760
|
if (typeof result === "object" && result !== null && "id" in result) {
|
|
575
761
|
return toStr(result.id);
|
|
@@ -590,13 +776,13 @@ function readAttributes(result) {
|
|
|
590
776
|
return attrs;
|
|
591
777
|
}
|
|
592
778
|
__name(readAttributes, "readAttributes");
|
|
593
|
-
function toPassage(rawId, attrs, prefix) {
|
|
594
|
-
const
|
|
779
|
+
function toPassage(rawId, attrs, prefix, scoreSource = "vector", inlineScore) {
|
|
780
|
+
const score = scoreSource === "lexical" ? Number(inlineScore ?? "0") : 1 - Number(attrs.vector_score ?? "1");
|
|
595
781
|
const source = attrs.source;
|
|
596
782
|
return {
|
|
597
783
|
id: rawId.startsWith(prefix) ? rawId.slice(prefix.length) : rawId,
|
|
598
784
|
text: attrs.text ?? "",
|
|
599
|
-
score:
|
|
785
|
+
score: Number.isFinite(score) ? score : 0,
|
|
600
786
|
...source !== void 0 ? {
|
|
601
787
|
source
|
|
602
788
|
} : {},
|
|
@@ -662,10 +848,14 @@ var KeywordRetriever = class {
|
|
|
662
848
|
this.k1 = options.k1 ?? 1.5;
|
|
663
849
|
this.b = options.b ?? 0.75;
|
|
664
850
|
}
|
|
851
|
+
/** How many chunks are currently indexed. */
|
|
852
|
+
get size() {
|
|
853
|
+
return this.docs.size;
|
|
854
|
+
}
|
|
665
855
|
/** Index (or re-index) records. Re-adding an existing id replaces its posting and updates stats. */
|
|
666
856
|
add(records) {
|
|
667
857
|
for (const record of records) {
|
|
668
|
-
this.
|
|
858
|
+
this.removeChunk(record.id);
|
|
669
859
|
const tokens = tokenize(record.text);
|
|
670
860
|
const frequencies = /* @__PURE__ */ new Map();
|
|
671
861
|
for (const token of tokens) {
|
|
@@ -682,7 +872,30 @@ var KeywordRetriever = class {
|
|
|
682
872
|
this.totalLength += tokens.length;
|
|
683
873
|
}
|
|
684
874
|
}
|
|
685
|
-
|
|
875
|
+
/**
|
|
876
|
+
* Forget a source document — every chunk whose id is `${documentId}` or `${documentId}#<n>`, the
|
|
877
|
+
* same id scheme (and the same {@link documentIdOf} collapse) the vector stores use for their
|
|
878
|
+
* `remove`. The keyword half of a hybrid index keeps its own copy of the chunk text, so a document
|
|
879
|
+
* dropped from the vector store stays fully retrievable here until this is called. Unknown ids are
|
|
880
|
+
* a no-op.
|
|
881
|
+
*/
|
|
882
|
+
remove(documentId) {
|
|
883
|
+
for (const id of [
|
|
884
|
+
...this.docs.keys()
|
|
885
|
+
]) {
|
|
886
|
+
if (id === documentId || documentIdOf(id) === documentId) {
|
|
887
|
+
this.removeChunk(id);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
/** Drop every indexed chunk, resetting the corpus statistics with it. */
|
|
892
|
+
clear() {
|
|
893
|
+
this.docs.clear();
|
|
894
|
+
this.documentFrequency.clear();
|
|
895
|
+
this.totalLength = 0;
|
|
896
|
+
}
|
|
897
|
+
/** Drop ONE chunk by its exact id, decrementing the corpus statistics it contributed. */
|
|
898
|
+
removeChunk(id) {
|
|
686
899
|
const existing = this.docs.get(id);
|
|
687
900
|
if (existing === void 0) {
|
|
688
901
|
return;
|
|
@@ -743,6 +956,25 @@ function tokenize(text) {
|
|
|
743
956
|
}
|
|
744
957
|
__name(tokenize, "tokenize");
|
|
745
958
|
|
|
959
|
+
// src/lexical-retriever.ts
|
|
960
|
+
var LexicalRetriever = class {
|
|
961
|
+
static {
|
|
962
|
+
__name(this, "LexicalRetriever");
|
|
963
|
+
}
|
|
964
|
+
store;
|
|
965
|
+
constructor(store) {
|
|
966
|
+
this.store = store;
|
|
967
|
+
}
|
|
968
|
+
async retrieve(query, options = {}) {
|
|
969
|
+
return this.store.searchText(query, {
|
|
970
|
+
topK: options.topK ?? 5,
|
|
971
|
+
...options.filter !== void 0 ? {
|
|
972
|
+
filter: options.filter
|
|
973
|
+
} : {}
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
|
|
746
978
|
// src/hybrid-retriever.ts
|
|
747
979
|
var HybridRetriever = class {
|
|
748
980
|
static {
|
|
@@ -930,8 +1162,10 @@ __name(createRetrievalTool, "createRetrievalTool");
|
|
|
930
1162
|
FilteredRetriever,
|
|
931
1163
|
HybridRetriever,
|
|
932
1164
|
KeywordRetriever,
|
|
1165
|
+
LexicalRetriever,
|
|
933
1166
|
MemoryVectorStore,
|
|
934
1167
|
PgVectorStore,
|
|
1168
|
+
RedisVectorSchemaMismatchError,
|
|
935
1169
|
RedisVectorStore,
|
|
936
1170
|
RerankingRetriever,
|
|
937
1171
|
chunkDocuments,
|
|
@@ -939,6 +1173,7 @@ __name(createRetrievalTool, "createRetrievalTool");
|
|
|
939
1173
|
createRetrievalTool,
|
|
940
1174
|
documentIdOf,
|
|
941
1175
|
ingestChunks,
|
|
942
|
-
ingestDocuments
|
|
1176
|
+
ingestDocuments,
|
|
1177
|
+
isLexicalVectorStore
|
|
943
1178
|
});
|
|
944
1179
|
//# sourceMappingURL=index.cjs.map
|