@dudousxd/nestjs-agent-rag 0.8.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
@@ -323,7 +323,8 @@ library upgrade. The interface is open, so it can be added later without changin
323
323
  - `HybridRetriever(retrievers, { k?, fetchTopK?, weights? })` — RRF fusion of the two.
324
324
  - `MemoryVectorStore` / `PgVectorStore` / `RedisVectorStore` — `VectorStore` adapters:
325
325
  `upsert(records)`, `search(embedding, opts)`, `remove(documentId)`,
326
- `updateMetadata(documentId, patch)`, `listDocuments(filter?)`.
326
+ `updateMetadata(documentId, patch)`, `listDocuments(filter?)`,
327
+ `listChunks(documentId, { limit?, offset? })`.
327
328
  - `applyMetadataPatch(metadata, patch)` — the `MetadataPatch` merge semantics, exported so your own
328
329
  `VectorStore` can implement `updateMetadata` with exactly the same ones.
329
330
  - `isLexicalVectorStore(store)` — type guard for the optional `LexicalVectorStore` capability.
@@ -363,6 +364,33 @@ would mean stamping the document id as an indexed field at write time, and chunk
363
364
  change carry no such field — the search would find none of them and `remove` would silently stop
364
365
  deleting. The methods above need no new field, so they have no migration.
365
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
+
366
394
  ### Why these are required and `searchText` is optional
367
395
 
368
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,
@@ -133,6 +134,11 @@ var UnsafeRemovalError = class extends Error {
133
134
  this.name = "UnsafeRemovalError";
134
135
  }
135
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");
136
142
  function documentIdOf(chunkId) {
137
143
  return chunkId.replace(/#\d+$/, "");
138
144
  }
@@ -257,6 +263,34 @@ var MemoryVectorStore = class {
257
263
  ...documents.values()
258
264
  ];
259
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
+ }
260
294
  async listDocumentIds(filter) {
261
295
  if (filterMatchesNothing(filter)) {
262
296
  return [];
@@ -443,6 +477,44 @@ var PgVectorStore = class {
443
477
  } : {}
444
478
  }));
445
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
+ }
446
518
  /** {@link listDocuments} without the metadata: one `DISTINCT` over the derived document id. */
447
519
  async listDocumentIds(filter) {
448
520
  if (filterMatchesNothing(filter)) {
@@ -553,6 +625,7 @@ function buildWhere(filter, startIndex) {
553
625
  }
554
626
  __name(buildWhere, "buildWhere");
555
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)";
556
629
  function toVectorLiteral(embedding) {
557
630
  return `[${embedding.join(",")}]`;
558
631
  }
@@ -845,6 +918,58 @@ var RedisVectorStore = class {
845
918
  ...keys
846
919
  ];
847
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
+ }
848
973
  async listDocuments(filter) {
849
974
  if (filterMatchesNothing(filter)) {
850
975
  return [];
@@ -1735,6 +1860,7 @@ __name(createRetrievalTool, "createRetrievalTool");
1735
1860
  UnsafeRemovalError,
1736
1861
  applyMetadataPatch,
1737
1862
  chunkDocuments,
1863
+ chunkIndexOf,
1738
1864
  chunkText,
1739
1865
  createRetrievalTool,
1740
1866
  documentIdOf,