@dudousxd/nestjs-agent-rag 0.8.0 → 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/dist/index.js CHANGED
@@ -91,6 +91,11 @@ var UnsafeRemovalError = class extends Error {
91
91
  this.name = "UnsafeRemovalError";
92
92
  }
93
93
  };
94
+ function chunkIndexOf(chunkId) {
95
+ const match = /#(\d+)$/.exec(chunkId);
96
+ return match?.[1] === void 0 ? 0 : Number(match[1]);
97
+ }
98
+ __name(chunkIndexOf, "chunkIndexOf");
94
99
  function documentIdOf(chunkId) {
95
100
  return chunkId.replace(/#\d+$/, "");
96
101
  }
@@ -160,6 +165,16 @@ var MemoryVectorStore = class {
160
165
  __name(this, "MemoryVectorStore");
161
166
  }
162
167
  records = /* @__PURE__ */ new Map();
168
+ /**
169
+ * Telemetry self-description. No `collection`: there is no namespace to name — the corpus IS this
170
+ * instance, and two `MemoryVectorStore`s in one process are two corpora with nothing to tell them
171
+ * apart on a dashboard.
172
+ */
173
+ describeRetrieval() {
174
+ return {
175
+ store: "memory"
176
+ };
177
+ }
163
178
  async upsert(records) {
164
179
  for (const record of records) {
165
180
  this.records.set(record.id, record);
@@ -215,6 +230,34 @@ var MemoryVectorStore = class {
215
230
  ...documents.values()
216
231
  ];
217
232
  }
233
+ /**
234
+ * The chunks of one document, in document order. See {@link VectorStore.listChunks}.
235
+ *
236
+ * Insertion order is not document order — `upsert` stores whatever the caller passed, and a
237
+ * re-ingest that rewrites chunk 3 alone moves it to the end of the Map. So this sorts by the index
238
+ * parsed from the id, the same thing the SQL and Redis adapters order by, rather than trusting the
239
+ * Map. A reference adapter that returned "usually ordered" would let a consumer ship a bug that
240
+ * only shows up on the store tests are NOT written against.
241
+ */
242
+ async listChunks(documentId, options) {
243
+ const chunks = [];
244
+ for (const record of this.records.values()) {
245
+ if (documentIdOf(record.id) !== documentId) {
246
+ continue;
247
+ }
248
+ chunks.push({
249
+ id: record.id,
250
+ index: chunkIndexOf(record.id),
251
+ text: record.text,
252
+ ...record.metadata !== void 0 ? {
253
+ metadata: record.metadata
254
+ } : {}
255
+ });
256
+ }
257
+ chunks.sort((a, b) => a.index - b.index);
258
+ const offset = options?.offset ?? 0;
259
+ return options?.limit === void 0 ? chunks.slice(offset) : chunks.slice(offset, offset + options.limit);
260
+ }
218
261
  async listDocumentIds(filter) {
219
262
  if (filterMatchesNothing(filter)) {
220
263
  return [];
@@ -326,6 +369,13 @@ var PgVectorStore = class {
326
369
  this.table = options.table ?? "agent_rag_chunks";
327
370
  this.dimensions = options.dimensions ?? 1536;
328
371
  }
372
+ /** Telemetry self-description — the table is the namespace a retrieval was served from. */
373
+ describeRetrieval() {
374
+ return {
375
+ store: "pg",
376
+ collection: this.table
377
+ };
378
+ }
329
379
  /** Idempotent DDL: the `vector` extension, the chunks table, and the cosine HNSW index. */
330
380
  async ensureSchema() {
331
381
  await this.client.query("CREATE EXTENSION IF NOT EXISTS vector");
@@ -401,6 +451,44 @@ var PgVectorStore = class {
401
451
  } : {}
402
452
  }));
403
453
  }
454
+ /**
455
+ * The chunks of one document, in document order. See {@link VectorStore.listChunks}.
456
+ *
457
+ * The order is computed from the id rather than stored: `#12` must sort after `#2`, which a plain
458
+ * `ORDER BY id` gets wrong on every document past ten chunks because it compares text. So the
459
+ * trailing number is extracted and cast to `int`, and a bare id (a single-chunk document, which
460
+ * carries no `#n` suffix) collapses to `0` — the same rule `chunkIndexOf` applies in the other two
461
+ * adapters.
462
+ *
463
+ * `embedding` is left out of the projection: it is the widest column in the table and useless to a
464
+ * caller reading text back.
465
+ */
466
+ async listChunks(documentId, options) {
467
+ const params = [
468
+ documentId
469
+ ];
470
+ let sql = `SELECT id, ${CHUNK_INDEX_FROM_ID} AS chunk_index, text, metadata
471
+ FROM ${this.table}
472
+ WHERE ${DOCUMENT_ID_FROM_CHUNK} = $1
473
+ ORDER BY chunk_index`;
474
+ if (options?.limit !== void 0) {
475
+ params.push(options.limit);
476
+ sql += ` LIMIT $${params.length}`;
477
+ }
478
+ if (options?.offset !== void 0) {
479
+ params.push(options.offset);
480
+ sql += ` OFFSET $${params.length}`;
481
+ }
482
+ const rows = await this.client.query(sql, params);
483
+ return rows.map((row) => ({
484
+ id: row.id,
485
+ index: Number(row.chunk_index),
486
+ text: row.text,
487
+ ...row.metadata !== null ? {
488
+ metadata: row.metadata
489
+ } : {}
490
+ }));
491
+ }
404
492
  /** {@link listDocuments} without the metadata: one `DISTINCT` over the derived document id. */
405
493
  async listDocumentIds(filter) {
406
494
  if (filterMatchesNothing(filter)) {
@@ -511,6 +599,7 @@ function buildWhere(filter, startIndex) {
511
599
  }
512
600
  __name(buildWhere, "buildWhere");
513
601
  var DOCUMENT_ID_FROM_CHUNK = "regexp_replace(id, '#[0-9]+$', '')";
602
+ var CHUNK_INDEX_FROM_ID = "COALESCE((substring(id from '#([0-9]+)$'))::int, 0)";
514
603
  function toVectorLiteral(embedding) {
515
604
  return `[${embedding.join(",")}]`;
516
605
  }
@@ -549,6 +638,17 @@ var RedisVectorStore = class {
549
638
  this.lexicalScorer = options.lexicalScorer ?? "BM25";
550
639
  }
551
640
  /**
641
+ * Telemetry self-description — the RediSearch INDEX, not the key prefix: the index is what both
642
+ * legs query, and two stores sharing one index (the dense and lexical halves of a hybrid) must
643
+ * report the same namespace or `describeSharedSource` would see them disagree and drop the store.
644
+ */
645
+ describeRetrieval() {
646
+ return {
647
+ store: "redis",
648
+ collection: this.index
649
+ };
650
+ }
651
+ /**
552
652
  * Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
553
653
  * and if it DOES exist, compare what's there against this store's configuration instead of
554
654
  * assuming they agree. An index that outlives a config change is the normal case (schemas live in
@@ -803,6 +903,58 @@ var RedisVectorStore = class {
803
903
  ...keys
804
904
  ];
805
905
  }
906
+ /**
907
+ * The chunks of one document, in document order. See {@link VectorStore.listChunks}.
908
+ *
909
+ * Reads the hashes directly (`HMGET text metadata_json`) rather than going through `FT.SEARCH`.
910
+ * Two reasons, and the second is the one that matters: the query language has no way to ask for
911
+ * "every chunk of this document" — `id` is the key name, not an indexed field — and `FT.SEARCH`
912
+ * caps at `LIMIT`, so paging a document through it would mean ordering by a field the index does
913
+ * not hold. `chunkKeys` is the same enumeration {@link RedisVectorStore.remove} and
914
+ * {@link RedisVectorStore.updateMetadata} already use, so "the chunks this returns" and "the chunks
915
+ * a delete would take" cannot drift apart.
916
+ *
917
+ * It inherits that method's cost too: one keyspace `SCAN` per call. Acceptable here because this
918
+ * is an inspection path (one document, on demand) and not something retrieval runs — but it is why
919
+ * this must not be put on a hot path or called in a loop over a corpus.
920
+ *
921
+ * `embedding` is deliberately not fetched: it is by far the largest field on the hash and nothing
922
+ * a caller reading text back can do with it.
923
+ */
924
+ async listChunks(documentId, options) {
925
+ const keys = await this.chunkKeys(documentId);
926
+ const ordered = keys.map((key) => ({
927
+ key,
928
+ id: key.slice(this.prefix.length)
929
+ })).map((entry) => ({
930
+ ...entry,
931
+ index: chunkIndexOf(entry.id)
932
+ })).sort((a, b) => a.index - b.index);
933
+ const offset = options?.offset ?? 0;
934
+ const page = options?.limit === void 0 ? ordered.slice(offset) : ordered.slice(offset, offset + options.limit);
935
+ const chunks = [];
936
+ for (const entry of page) {
937
+ const reply = await this.client.sendCommand([
938
+ "HMGET",
939
+ entry.key,
940
+ "text",
941
+ "metadata_json"
942
+ ]);
943
+ const fields = Array.isArray(reply) ? reply : [];
944
+ const text = fields[0] === null || fields[0] === void 0 ? void 0 : toStr(fields[0]);
945
+ if (text === void 0) {
946
+ continue;
947
+ }
948
+ const raw = fields[1] === null || fields[1] === void 0 ? void 0 : toStr(fields[1]);
949
+ chunks.push({
950
+ id: entry.id,
951
+ index: entry.index,
952
+ text,
953
+ ...parseMetadata(raw)
954
+ });
955
+ }
956
+ return chunks;
957
+ }
806
958
  async listDocuments(filter) {
807
959
  if (filterMatchesNothing(filter)) {
808
960
  return [];
@@ -1325,6 +1477,31 @@ function parseMetadata(raw) {
1325
1477
  }
1326
1478
  __name(parseMetadata, "parseMetadata");
1327
1479
 
1480
+ // src/retrieval-descriptor.ts
1481
+ function describesRetrieval(value) {
1482
+ return typeof value === "object" && value !== null && typeof Reflect.get(value, "describeRetrieval") === "function";
1483
+ }
1484
+ __name(describesRetrieval, "describesRetrieval");
1485
+ function describeRetrieval(candidate) {
1486
+ return describesRetrieval(candidate) ? candidate.describeRetrieval() : {};
1487
+ }
1488
+ __name(describeRetrieval, "describeRetrieval");
1489
+ function describeSource(base) {
1490
+ const { retriever: _ignored, ...source } = describeRetrieval(base);
1491
+ return source;
1492
+ }
1493
+ __name(describeSource, "describeSource");
1494
+ function describeSharedSource(legs) {
1495
+ const sources = legs.map(describeSource);
1496
+ const [first] = sources;
1497
+ if (first === void 0) {
1498
+ return {};
1499
+ }
1500
+ const agrees = sources.every((source) => source.store === first.store && source.collection === first.collection);
1501
+ return agrees ? first : {};
1502
+ }
1503
+ __name(describeSharedSource, "describeSharedSource");
1504
+
1328
1505
  // src/embedding-retriever.ts
1329
1506
  var EmbeddingRetriever = class {
1330
1507
  static {
@@ -1338,6 +1515,13 @@ var EmbeddingRetriever = class {
1338
1515
  this.store = store;
1339
1516
  this.options = options;
1340
1517
  }
1518
+ /** Telemetry self-description — the dense strategy, over whatever store it was handed. */
1519
+ describeRetrieval() {
1520
+ return {
1521
+ retriever: "embedding",
1522
+ ...describeSource(this.store)
1523
+ };
1524
+ }
1341
1525
  async retrieve(query, options = {}) {
1342
1526
  const [embedding] = await this.embedder.embed([
1343
1527
  query
@@ -1370,6 +1554,16 @@ var KeywordRetriever = class {
1370
1554
  this.k1 = options.k1 ?? 1.5;
1371
1555
  this.b = options.b ?? 0.75;
1372
1556
  }
1557
+ /**
1558
+ * Telemetry self-description. No `store`: this retriever holds its own copy of the corpus in
1559
+ * process, so attributing its retrievals to whichever vector store happens to hold the same chunks
1560
+ * would credit a backend that was never queried.
1561
+ */
1562
+ describeRetrieval() {
1563
+ return {
1564
+ retriever: "keyword"
1565
+ };
1566
+ }
1373
1567
  /** How many chunks are currently indexed. */
1374
1568
  get size() {
1375
1569
  return this.docs.size;
@@ -1487,6 +1681,13 @@ var LexicalRetriever = class {
1487
1681
  constructor(store) {
1488
1682
  this.store = store;
1489
1683
  }
1684
+ /** Telemetry self-description — the store's own lexical index, so the store is the same one. */
1685
+ describeRetrieval() {
1686
+ return {
1687
+ retriever: "lexical",
1688
+ ...describeSource(this.store)
1689
+ };
1690
+ }
1490
1691
  async retrieve(query, options = {}) {
1491
1692
  return this.store.searchText(query, {
1492
1693
  topK: options.topK ?? 5,
@@ -1508,6 +1709,13 @@ var HybridRetriever = class {
1508
1709
  this.retrievers = retrievers;
1509
1710
  this.options = options;
1510
1711
  }
1712
+ /** Telemetry self-description — the store only when every leg agrees; see `describeSharedSource`. */
1713
+ describeRetrieval() {
1714
+ return {
1715
+ retriever: "hybrid",
1716
+ ...describeSharedSource(this.retrievers)
1717
+ };
1718
+ }
1511
1719
  async retrieve(query, options = {}) {
1512
1720
  const k = this.options.k ?? 60;
1513
1721
  const fetchTopK = this.options.fetchTopK ?? 20;
@@ -1556,6 +1764,13 @@ var RerankingRetriever = class {
1556
1764
  this.reranker = reranker;
1557
1765
  this.options = options;
1558
1766
  }
1767
+ /** Telemetry self-description — the two-stage strategy, over the base's store. */
1768
+ describeRetrieval() {
1769
+ return {
1770
+ retriever: "reranking",
1771
+ ...describeSource(this.base)
1772
+ };
1773
+ }
1559
1774
  async retrieve(query, options = {}) {
1560
1775
  const candidates = await this.base.retrieve(query, {
1561
1776
  topK: this.options.fetchTopK ?? 20,
@@ -1580,6 +1795,13 @@ var FilteredRetriever = class {
1580
1795
  this.base = base;
1581
1796
  this.filter = filter;
1582
1797
  }
1798
+ /** Telemetry self-description — this scope, over the base's store. */
1799
+ describeRetrieval() {
1800
+ return {
1801
+ retriever: "filtered",
1802
+ ...describeSource(this.base)
1803
+ };
1804
+ }
1583
1805
  async retrieve(query, options = {}) {
1584
1806
  return this.base.retrieve(query, {
1585
1807
  ...options,
@@ -1629,6 +1851,108 @@ async function ingestDocuments(documents, options) {
1629
1851
  }
1630
1852
  __name(ingestDocuments, "ingestDocuments");
1631
1853
 
1854
+ // src/retrieval-telemetry.ts
1855
+ import { AsyncLocalStorage } from "node:async_hooks";
1856
+ import { channelName, emit, getChannel } from "@dudousxd/nestjs-diagnostics";
1857
+ var RAG_DIAGNOSTIC_LIB = "rag";
1858
+ var RAG_RETRIEVAL_EVENT = "retrieval";
1859
+ var RAG_RETRIEVAL_CHANNEL = channelName(RAG_DIAGNOSTIC_LIB, RAG_RETRIEVAL_EVENT);
1860
+ function emitRagRetrieval(event, durationMs) {
1861
+ emit(RAG_DIAGNOSTIC_LIB, RAG_RETRIEVAL_EVENT, event, {
1862
+ durationMs
1863
+ });
1864
+ }
1865
+ __name(emitRagRetrieval, "emitRagRetrieval");
1866
+ var DEFAULT_TOP_K = 5;
1867
+ var inRetrieval = new AsyncLocalStorage();
1868
+ function round4(value) {
1869
+ return Math.round(value * 1e4) / 1e4;
1870
+ }
1871
+ __name(round4, "round4");
1872
+ function scoreStats(passages) {
1873
+ const scores = passages.map((passage) => passage.score).filter((score) => Number.isFinite(score));
1874
+ if (scores.length === 0) {
1875
+ return {};
1876
+ }
1877
+ const sum = scores.reduce((total, score) => total + score, 0);
1878
+ return {
1879
+ topScore: round4(Math.max(...scores)),
1880
+ meanScore: round4(sum / scores.length)
1881
+ };
1882
+ }
1883
+ __name(scoreStats, "scoreStats");
1884
+ var InstrumentedRetriever = class InstrumentedRetriever2 {
1885
+ static {
1886
+ __name(this, "InstrumentedRetriever");
1887
+ }
1888
+ base;
1889
+ options;
1890
+ constructor(base, options = {}) {
1891
+ this.base = base;
1892
+ this.options = options;
1893
+ }
1894
+ /** Transparent to detection: wrapping a retriever must not turn it into an `unknown` one. */
1895
+ describeRetrieval() {
1896
+ return {
1897
+ ...describeRetrieval(this.base),
1898
+ ...this.options.describe
1899
+ };
1900
+ }
1901
+ async retrieve(query, options = {}) {
1902
+ if (!getChannel(RAG_DIAGNOSTIC_LIB, RAG_RETRIEVAL_EVENT).hasSubscribers) {
1903
+ return this.base.retrieve(query, options);
1904
+ }
1905
+ if (inRetrieval.getStore() === true) {
1906
+ return this.base.retrieve(query, options);
1907
+ }
1908
+ const descriptor = this.describeRetrieval();
1909
+ const topK = options.topK ?? DEFAULT_TOP_K;
1910
+ const startedAt = Date.now();
1911
+ try {
1912
+ const passages = await inRetrieval.run(true, () => this.base.retrieve(query, options));
1913
+ emitRagRetrieval({
1914
+ retriever: descriptor.retriever ?? "unknown",
1915
+ ...descriptor.store !== void 0 ? {
1916
+ store: descriptor.store
1917
+ } : {},
1918
+ ...this.collectionOf(options.filter, descriptor),
1919
+ topK,
1920
+ chunks: passages.length,
1921
+ zeroHit: passages.length === 0,
1922
+ ...scoreStats(passages),
1923
+ failed: false
1924
+ }, Date.now() - startedAt);
1925
+ return passages;
1926
+ } catch (error) {
1927
+ emitRagRetrieval({
1928
+ retriever: descriptor.retriever ?? "unknown",
1929
+ ...descriptor.store !== void 0 ? {
1930
+ store: descriptor.store
1931
+ } : {},
1932
+ ...this.collectionOf(options.filter, descriptor),
1933
+ topK,
1934
+ chunks: 0,
1935
+ zeroHit: true,
1936
+ failed: true,
1937
+ error: error instanceof Error ? error.message : String(error)
1938
+ }, Date.now() - startedAt);
1939
+ throw error;
1940
+ }
1941
+ }
1942
+ /** The host's collection for this call, else the store namespace, else nothing. */
1943
+ collectionOf(filter, descriptor) {
1944
+ const { collection } = this.options;
1945
+ const resolved = typeof collection === "function" ? collection(filter) : collection ?? descriptor.collection;
1946
+ return resolved !== void 0 ? {
1947
+ collection: resolved
1948
+ } : {};
1949
+ }
1950
+ };
1951
+ function instrumentRetriever(retriever, options = {}) {
1952
+ return new InstrumentedRetriever(retriever, options);
1953
+ }
1954
+ __name(instrumentRetriever, "instrumentRetriever");
1955
+
1632
1956
  // src/retrieval-tool.ts
1633
1957
  function isQueryInput(value) {
1634
1958
  return typeof value === "object" && value !== null && "query" in value && typeof value.query === "string";
@@ -1655,6 +1979,8 @@ var querySchema = {
1655
1979
  };
1656
1980
  function createRetrievalTool(retriever, options = {}) {
1657
1981
  const topK = options.topK;
1982
+ const { telemetry = true } = options;
1983
+ const instrumented = telemetry === false ? retriever : instrumentRetriever(retriever, telemetry === true ? {} : telemetry);
1658
1984
  return {
1659
1985
  spec: {
1660
1986
  name: options.name ?? "search_knowledge",
@@ -1667,7 +1993,7 @@ function createRetrievalTool(retriever, options = {}) {
1667
1993
  if (!isQueryInput(input)) {
1668
1994
  throw new Error("retrieval tool expects { query: string }");
1669
1995
  }
1670
- const passages = await retriever.retrieve(input.query, topK !== void 0 ? {
1996
+ const passages = await instrumented.retrieve(input.query, topK !== void 0 ? {
1671
1997
  topK
1672
1998
  } : {});
1673
1999
  return {
@@ -1686,17 +2012,27 @@ export {
1686
2012
  LexicalRetriever,
1687
2013
  MemoryVectorStore,
1688
2014
  PgVectorStore,
2015
+ RAG_DIAGNOSTIC_LIB,
2016
+ RAG_RETRIEVAL_CHANNEL,
2017
+ RAG_RETRIEVAL_EVENT,
1689
2018
  RedisVectorSchemaMismatchError,
1690
2019
  RedisVectorStore,
1691
2020
  RerankingRetriever,
1692
2021
  UnsafeRemovalError,
1693
2022
  applyMetadataPatch,
1694
2023
  chunkDocuments,
2024
+ chunkIndexOf,
1695
2025
  chunkText,
1696
2026
  createRetrievalTool,
2027
+ describeRetrieval,
2028
+ describeSharedSource,
2029
+ describeSource,
2030
+ describesRetrieval,
1697
2031
  documentIdOf,
2032
+ emitRagRetrieval,
1698
2033
  ingestChunks,
1699
2034
  ingestDocuments,
2035
+ instrumentRetriever,
1700
2036
  isLexicalVectorStore
1701
2037
  };
1702
2038
  //# sourceMappingURL=index.js.map