@dudousxd/nestjs-agent-rag 0.7.0 → 0.9.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 +75 -3
- package/dist/index.cjs +163 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +113 -1
- package/dist/index.d.ts +113 -1
- package/dist/index.js +162 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -29,6 +29,48 @@ await ingestDocuments(
|
|
|
29
29
|
const retriever = new EmbeddingRetriever(embedder, store);
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
## Chunking record-shaped text
|
|
33
|
+
|
|
34
|
+
The default chunker is structure-blind: it fills a `chunkSize` window, breaks at the latest
|
|
35
|
+
paragraph → sentence → word boundary inside it, and carries `overlap` characters across the seam.
|
|
36
|
+
That is the right rule for prose — the two halves are still sentences, and the overlap rescues the
|
|
37
|
+
one that got cut.
|
|
38
|
+
|
|
39
|
+
It is the wrong rule for text whose boundaries mean something. A spreadsheet flattened to one
|
|
40
|
+
field-labelled record per row has real boundaries the chunker does not know exist, so a cut lands
|
|
41
|
+
mid-record routinely — and the half holding the row identifier and the half holding the value end up
|
|
42
|
+
in different chunks, where **neither can answer a question about that row**. Tell it where the
|
|
43
|
+
records are:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
await ingestDocuments([{ id: 'mvr-2026', text: rows.join('\n') }], {
|
|
47
|
+
embedder,
|
|
48
|
+
store,
|
|
49
|
+
chunkSize: 800,
|
|
50
|
+
separator: '\n', // one record per line; a record is never split across chunks
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Records are packed greedily up to `chunkSize` and never split — except one longer than `chunkSize`,
|
|
55
|
+
which has nowhere to go and is character-split as usual. That fallback is confined to the single
|
|
56
|
+
record that could not fit, rather than applied to the whole document.
|
|
57
|
+
|
|
58
|
+
**`overlap` is ignored when `separator` is set**, deliberately. Overlap exists to rescue a sentence a
|
|
59
|
+
boundary cut in half; a boundary that never falls inside a record has nothing to rescue, and carrying
|
|
60
|
+
the previous chunk's tail in would duplicate whole records into their neighbours — paying for the
|
|
61
|
+
same rows twice at embedding time and letting one row match from two places.
|
|
62
|
+
|
|
63
|
+
Measured on a 200-row, 15-column sheet ingested twice — same bytes, same embedder, same questions,
|
|
64
|
+
only the cut positions differing — blind chunking left 27% of records split, and on questions
|
|
65
|
+
targeting a field on the far side of such a cut, BM25 answered 54/66 (MRR 0.818) against 66/66
|
|
66
|
+
(MRR 1.000) with records kept whole. The dense leg scored 7/66 either way: 200 near-identical rows
|
|
67
|
+
produce near-identical vectors, so that leg cannot do row lookup at all and chunking does not change
|
|
68
|
+
it. The win is the lexical leg's, and it only shows up on questions whose answer sits far from the
|
|
69
|
+
row's rare identifying token — ask about a field next to the vehicle id and both arms score 1.000,
|
|
70
|
+
because BM25 finds that token whatever the chunking did around it.
|
|
71
|
+
|
|
72
|
+
Leave `separator` unset for prose. Nothing about the default path changed.
|
|
73
|
+
|
|
32
74
|
## Two ways to use it with the agent
|
|
33
75
|
|
|
34
76
|
**Agentic (default) — the model decides when to search:**
|
|
@@ -268,8 +310,10 @@ library upgrade. The interface is open, so it can be added later without changin
|
|
|
268
310
|
|
|
269
311
|
## API
|
|
270
312
|
|
|
271
|
-
- `chunkText(text, { chunkSize?, overlap? })` — overlapping, boundary-aware chunks
|
|
272
|
-
|
|
313
|
+
- `chunkText(text, { chunkSize?, overlap?, separator? })` — overlapping, boundary-aware chunks;
|
|
314
|
+
`separator` packs whole records instead and never splits one (`overlap` is ignored in that mode).
|
|
315
|
+
- `ingestDocuments(docs, { embedder, store, chunkSize?, overlap?, separator? })` — chunk → embed →
|
|
316
|
+
upsert.
|
|
273
317
|
- `EmbeddingRetriever(embedder, store, { minScore? })` — the core `Retriever` from an embedder +
|
|
274
318
|
vector store. `minScore` drops passages below a similarity floor, so an unanswerable query can
|
|
275
319
|
return nothing.
|
|
@@ -279,7 +323,8 @@ library upgrade. The interface is open, so it can be added later without changin
|
|
|
279
323
|
- `HybridRetriever(retrievers, { k?, fetchTopK?, weights? })` — RRF fusion of the two.
|
|
280
324
|
- `MemoryVectorStore` / `PgVectorStore` / `RedisVectorStore` — `VectorStore` adapters:
|
|
281
325
|
`upsert(records)`, `search(embedding, opts)`, `remove(documentId)`,
|
|
282
|
-
`updateMetadata(documentId, patch)`, `listDocuments(filter?)
|
|
326
|
+
`updateMetadata(documentId, patch)`, `listDocuments(filter?)`,
|
|
327
|
+
`listChunks(documentId, { limit?, offset? })`.
|
|
283
328
|
- `applyMetadataPatch(metadata, patch)` — the `MetadataPatch` merge semantics, exported so your own
|
|
284
329
|
`VectorStore` can implement `updateMetadata` with exactly the same ones.
|
|
285
330
|
- `isLexicalVectorStore(store)` — type guard for the optional `LexicalVectorStore` capability.
|
|
@@ -319,6 +364,33 @@ would mean stamping the document id as an indexed field at write time, and chunk
|
|
|
319
364
|
change carry no such field — the search would find none of them and `remove` would silently stop
|
|
320
365
|
deleting. The methods above need no new field, so they have no migration.
|
|
321
366
|
|
|
367
|
+
## Reading chunks back
|
|
368
|
+
|
|
369
|
+
Everything above enumerates *documents*. The text itself is otherwise write-only: `search` needs an
|
|
370
|
+
embedding, `searchText` needs a query, and both return what **matched**, in relevance order, capped
|
|
371
|
+
at `topK`. Neither answers "show me what was indexed for this document, in order" — the question you
|
|
372
|
+
ask when retrieval returned the wrong passage and you need to know whether the chunker cut badly or
|
|
373
|
+
the retriever chose badly.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
const chunks = await store.listChunks('handbook.pdf', { limit: 20 });
|
|
377
|
+
// [{ id: 'handbook.pdf#0', index: 0, text: 'Chapter 1 …', metadata: { collection: 'handbook' } }, …]
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Ordered by `index` (the `n` of `${documentId}#<n>`, numerically — `#10` after `#2`), so paging with
|
|
381
|
+
`limit`/`offset` walks the document front to back. A document stored under a bare id is chunk `0`. An
|
|
382
|
+
unknown `documentId` yields `[]`.
|
|
383
|
+
|
|
384
|
+
Document-scoped rather than filter-scoped, matching `remove` and `updateMetadata`: ordering only
|
|
385
|
+
means something once a document is picked, so enumerate with `listDocumentIds(filter)` first when you
|
|
386
|
+
need to sweep. Two things it deliberately is not:
|
|
387
|
+
|
|
388
|
+
- **not an access-control seam** — it takes no filter and applies none, so a consumer that gates
|
|
389
|
+
retrieval on a metadata field must resolve that gate itself before calling, exactly as before
|
|
390
|
+
`remove`. It is shaped for an operator/debug surface, not for serving end users;
|
|
391
|
+
- **not a hot path** — on Redis it costs one keyspace `SCAN`, the same as `remove`. Fine for one
|
|
392
|
+
document on demand, wrong inside a loop over a corpus.
|
|
393
|
+
|
|
322
394
|
### Why these are required and `searchText` is optional
|
|
323
395
|
|
|
324
396
|
A method is **required on `VectorStore`** when every backend can implement it from what it already
|
package/dist/index.cjs
CHANGED
|
@@ -34,6 +34,7 @@ __export(index_exports, {
|
|
|
34
34
|
UnsafeRemovalError: () => UnsafeRemovalError,
|
|
35
35
|
applyMetadataPatch: () => applyMetadataPatch,
|
|
36
36
|
chunkDocuments: () => chunkDocuments,
|
|
37
|
+
chunkIndexOf: () => chunkIndexOf,
|
|
37
38
|
chunkText: () => chunkText,
|
|
38
39
|
createRetrievalTool: () => createRetrievalTool,
|
|
39
40
|
documentIdOf: () => documentIdOf,
|
|
@@ -51,6 +52,9 @@ function chunkText(text, options = {}) {
|
|
|
51
52
|
if (normalized.length === 0) {
|
|
52
53
|
return [];
|
|
53
54
|
}
|
|
55
|
+
if (options.separator !== void 0 && options.separator.length > 0) {
|
|
56
|
+
return chunkByRecord(normalized, chunkSize, options.separator);
|
|
57
|
+
}
|
|
54
58
|
if (normalized.length <= chunkSize) {
|
|
55
59
|
return [
|
|
56
60
|
normalized
|
|
@@ -80,6 +84,40 @@ function chunkText(text, options = {}) {
|
|
|
80
84
|
return chunks;
|
|
81
85
|
}
|
|
82
86
|
__name(chunkText, "chunkText");
|
|
87
|
+
function chunkByRecord(text, chunkSize, separator) {
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let current = "";
|
|
90
|
+
const flush = /* @__PURE__ */ __name(() => {
|
|
91
|
+
if (current.length > 0) {
|
|
92
|
+
chunks.push(current);
|
|
93
|
+
current = "";
|
|
94
|
+
}
|
|
95
|
+
}, "flush");
|
|
96
|
+
for (const record of text.split(separator)) {
|
|
97
|
+
const trimmed = record.trim();
|
|
98
|
+
if (trimmed.length === 0) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (trimmed.length > chunkSize) {
|
|
102
|
+
flush();
|
|
103
|
+
chunks.push(...chunkText(trimmed, {
|
|
104
|
+
chunkSize,
|
|
105
|
+
overlap: 0
|
|
106
|
+
}));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const candidate = current.length > 0 ? `${current}${separator}${trimmed}` : trimmed;
|
|
110
|
+
if (candidate.length > chunkSize) {
|
|
111
|
+
flush();
|
|
112
|
+
current = trimmed;
|
|
113
|
+
} else {
|
|
114
|
+
current = candidate;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
flush();
|
|
118
|
+
return chunks;
|
|
119
|
+
}
|
|
120
|
+
__name(chunkByRecord, "chunkByRecord");
|
|
83
121
|
|
|
84
122
|
// src/vector-store.ts
|
|
85
123
|
function isLexicalVectorStore(store) {
|
|
@@ -96,6 +134,11 @@ var UnsafeRemovalError = class extends Error {
|
|
|
96
134
|
this.name = "UnsafeRemovalError";
|
|
97
135
|
}
|
|
98
136
|
};
|
|
137
|
+
function chunkIndexOf(chunkId) {
|
|
138
|
+
const match = /#(\d+)$/.exec(chunkId);
|
|
139
|
+
return match?.[1] === void 0 ? 0 : Number(match[1]);
|
|
140
|
+
}
|
|
141
|
+
__name(chunkIndexOf, "chunkIndexOf");
|
|
99
142
|
function documentIdOf(chunkId) {
|
|
100
143
|
return chunkId.replace(/#\d+$/, "");
|
|
101
144
|
}
|
|
@@ -220,6 +263,34 @@ var MemoryVectorStore = class {
|
|
|
220
263
|
...documents.values()
|
|
221
264
|
];
|
|
222
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* The chunks of one document, in document order. See {@link VectorStore.listChunks}.
|
|
268
|
+
*
|
|
269
|
+
* Insertion order is not document order — `upsert` stores whatever the caller passed, and a
|
|
270
|
+
* re-ingest that rewrites chunk 3 alone moves it to the end of the Map. So this sorts by the index
|
|
271
|
+
* parsed from the id, the same thing the SQL and Redis adapters order by, rather than trusting the
|
|
272
|
+
* Map. A reference adapter that returned "usually ordered" would let a consumer ship a bug that
|
|
273
|
+
* only shows up on the store tests are NOT written against.
|
|
274
|
+
*/
|
|
275
|
+
async listChunks(documentId, options) {
|
|
276
|
+
const chunks = [];
|
|
277
|
+
for (const record of this.records.values()) {
|
|
278
|
+
if (documentIdOf(record.id) !== documentId) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
chunks.push({
|
|
282
|
+
id: record.id,
|
|
283
|
+
index: chunkIndexOf(record.id),
|
|
284
|
+
text: record.text,
|
|
285
|
+
...record.metadata !== void 0 ? {
|
|
286
|
+
metadata: record.metadata
|
|
287
|
+
} : {}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
chunks.sort((a, b) => a.index - b.index);
|
|
291
|
+
const offset = options?.offset ?? 0;
|
|
292
|
+
return options?.limit === void 0 ? chunks.slice(offset) : chunks.slice(offset, offset + options.limit);
|
|
293
|
+
}
|
|
223
294
|
async listDocumentIds(filter) {
|
|
224
295
|
if (filterMatchesNothing(filter)) {
|
|
225
296
|
return [];
|
|
@@ -406,6 +477,44 @@ var PgVectorStore = class {
|
|
|
406
477
|
} : {}
|
|
407
478
|
}));
|
|
408
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* The chunks of one document, in document order. See {@link VectorStore.listChunks}.
|
|
482
|
+
*
|
|
483
|
+
* The order is computed from the id rather than stored: `#12` must sort after `#2`, which a plain
|
|
484
|
+
* `ORDER BY id` gets wrong on every document past ten chunks because it compares text. So the
|
|
485
|
+
* trailing number is extracted and cast to `int`, and a bare id (a single-chunk document, which
|
|
486
|
+
* carries no `#n` suffix) collapses to `0` — the same rule `chunkIndexOf` applies in the other two
|
|
487
|
+
* adapters.
|
|
488
|
+
*
|
|
489
|
+
* `embedding` is left out of the projection: it is the widest column in the table and useless to a
|
|
490
|
+
* caller reading text back.
|
|
491
|
+
*/
|
|
492
|
+
async listChunks(documentId, options) {
|
|
493
|
+
const params = [
|
|
494
|
+
documentId
|
|
495
|
+
];
|
|
496
|
+
let sql = `SELECT id, ${CHUNK_INDEX_FROM_ID} AS chunk_index, text, metadata
|
|
497
|
+
FROM ${this.table}
|
|
498
|
+
WHERE ${DOCUMENT_ID_FROM_CHUNK} = $1
|
|
499
|
+
ORDER BY chunk_index`;
|
|
500
|
+
if (options?.limit !== void 0) {
|
|
501
|
+
params.push(options.limit);
|
|
502
|
+
sql += ` LIMIT $${params.length}`;
|
|
503
|
+
}
|
|
504
|
+
if (options?.offset !== void 0) {
|
|
505
|
+
params.push(options.offset);
|
|
506
|
+
sql += ` OFFSET $${params.length}`;
|
|
507
|
+
}
|
|
508
|
+
const rows = await this.client.query(sql, params);
|
|
509
|
+
return rows.map((row) => ({
|
|
510
|
+
id: row.id,
|
|
511
|
+
index: Number(row.chunk_index),
|
|
512
|
+
text: row.text,
|
|
513
|
+
...row.metadata !== null ? {
|
|
514
|
+
metadata: row.metadata
|
|
515
|
+
} : {}
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
409
518
|
/** {@link listDocuments} without the metadata: one `DISTINCT` over the derived document id. */
|
|
410
519
|
async listDocumentIds(filter) {
|
|
411
520
|
if (filterMatchesNothing(filter)) {
|
|
@@ -516,6 +625,7 @@ function buildWhere(filter, startIndex) {
|
|
|
516
625
|
}
|
|
517
626
|
__name(buildWhere, "buildWhere");
|
|
518
627
|
var DOCUMENT_ID_FROM_CHUNK = "regexp_replace(id, '#[0-9]+$', '')";
|
|
628
|
+
var CHUNK_INDEX_FROM_ID = "COALESCE((substring(id from '#([0-9]+)$'))::int, 0)";
|
|
519
629
|
function toVectorLiteral(embedding) {
|
|
520
630
|
return `[${embedding.join(",")}]`;
|
|
521
631
|
}
|
|
@@ -808,6 +918,58 @@ var RedisVectorStore = class {
|
|
|
808
918
|
...keys
|
|
809
919
|
];
|
|
810
920
|
}
|
|
921
|
+
/**
|
|
922
|
+
* The chunks of one document, in document order. See {@link VectorStore.listChunks}.
|
|
923
|
+
*
|
|
924
|
+
* Reads the hashes directly (`HMGET text metadata_json`) rather than going through `FT.SEARCH`.
|
|
925
|
+
* Two reasons, and the second is the one that matters: the query language has no way to ask for
|
|
926
|
+
* "every chunk of this document" — `id` is the key name, not an indexed field — and `FT.SEARCH`
|
|
927
|
+
* caps at `LIMIT`, so paging a document through it would mean ordering by a field the index does
|
|
928
|
+
* not hold. `chunkKeys` is the same enumeration {@link RedisVectorStore.remove} and
|
|
929
|
+
* {@link RedisVectorStore.updateMetadata} already use, so "the chunks this returns" and "the chunks
|
|
930
|
+
* a delete would take" cannot drift apart.
|
|
931
|
+
*
|
|
932
|
+
* It inherits that method's cost too: one keyspace `SCAN` per call. Acceptable here because this
|
|
933
|
+
* is an inspection path (one document, on demand) and not something retrieval runs — but it is why
|
|
934
|
+
* this must not be put on a hot path or called in a loop over a corpus.
|
|
935
|
+
*
|
|
936
|
+
* `embedding` is deliberately not fetched: it is by far the largest field on the hash and nothing
|
|
937
|
+
* a caller reading text back can do with it.
|
|
938
|
+
*/
|
|
939
|
+
async listChunks(documentId, options) {
|
|
940
|
+
const keys = await this.chunkKeys(documentId);
|
|
941
|
+
const ordered = keys.map((key) => ({
|
|
942
|
+
key,
|
|
943
|
+
id: key.slice(this.prefix.length)
|
|
944
|
+
})).map((entry) => ({
|
|
945
|
+
...entry,
|
|
946
|
+
index: chunkIndexOf(entry.id)
|
|
947
|
+
})).sort((a, b) => a.index - b.index);
|
|
948
|
+
const offset = options?.offset ?? 0;
|
|
949
|
+
const page = options?.limit === void 0 ? ordered.slice(offset) : ordered.slice(offset, offset + options.limit);
|
|
950
|
+
const chunks = [];
|
|
951
|
+
for (const entry of page) {
|
|
952
|
+
const reply = await this.client.sendCommand([
|
|
953
|
+
"HMGET",
|
|
954
|
+
entry.key,
|
|
955
|
+
"text",
|
|
956
|
+
"metadata_json"
|
|
957
|
+
]);
|
|
958
|
+
const fields = Array.isArray(reply) ? reply : [];
|
|
959
|
+
const text = fields[0] === null || fields[0] === void 0 ? void 0 : toStr(fields[0]);
|
|
960
|
+
if (text === void 0) {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const raw = fields[1] === null || fields[1] === void 0 ? void 0 : toStr(fields[1]);
|
|
964
|
+
chunks.push({
|
|
965
|
+
id: entry.id,
|
|
966
|
+
index: entry.index,
|
|
967
|
+
text,
|
|
968
|
+
...parseMetadata(raw)
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
return chunks;
|
|
972
|
+
}
|
|
811
973
|
async listDocuments(filter) {
|
|
812
974
|
if (filterMatchesNothing(filter)) {
|
|
813
975
|
return [];
|
|
@@ -1698,6 +1860,7 @@ __name(createRetrievalTool, "createRetrievalTool");
|
|
|
1698
1860
|
UnsafeRemovalError,
|
|
1699
1861
|
applyMetadataPatch,
|
|
1700
1862
|
chunkDocuments,
|
|
1863
|
+
chunkIndexOf,
|
|
1701
1864
|
chunkText,
|
|
1702
1865
|
createRetrievalTool,
|
|
1703
1866
|
documentIdOf,
|