@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 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
- - `ingestDocuments(docs, { embedder, store, chunkSize?, overlap? })` — chunk → embed → upsert.
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,