@dudousxd/nestjs-agent-rag 0.9.0 → 0.10.1

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
@@ -43,7 +43,7 @@ in different chunks, where **neither can answer a question about that row**. Tel
43
43
  records are:
44
44
 
45
45
  ```ts
46
- await ingestDocuments([{ id: 'mvr-2026', text: rows.join('\n') }], {
46
+ await ingestDocuments([{ id: 'pallet-manifest-2026', text: rows.join('\n') }], {
47
47
  embedder,
48
48
  store,
49
49
  chunkSize: 800,
@@ -96,6 +96,61 @@ Either way, retrieved passages carry a `source` and surface as citations through
96
96
  tool-call machinery (rendered in the chat UI, shown in the `-telescope` Agent tab). Each retrieval
97
97
  also emits an `aviary:agent:retrieved` diagnostics event.
98
98
 
99
+ ## Retrieval telemetry
100
+
101
+ A tool-call row says a retrieval *happened*. It cannot say how long it took, how much came back, how
102
+ good the scores were, which store answered, or whether it found nothing at all — a retrieval that
103
+ returns zero passages and one that returns five perfect ones are the same row with the same `ok`
104
+ status. So retrieval also publishes its own event, on `aviary:rag:retrieval`:
105
+
106
+ ```ts
107
+ // envelope: { durationMs, ts, traceId } · payload:
108
+ {
109
+ retriever: 'embedding' | 'keyword' | 'lexical' | 'hybrid' | 'filtered' | 'reranking' | 'unknown',
110
+ store?: 'memory' | 'pg' | 'redis', // absent for an in-process retriever
111
+ collection?: string, // pg table / RediSearch index, or your own label
112
+ topK: number,
113
+ chunks: number,
114
+ zeroHit: boolean,
115
+ topScore?: number, // 4dp; absent on a zero-hit
116
+ meanScore?: number,
117
+ failed: boolean,
118
+ error?: string,
119
+ }
120
+ ```
121
+
122
+ Counts, durations and kinds only — never the query text, never passage text.
123
+
124
+ `createRetrievalTool` instruments the retriever it wraps **by default**, so agentic retrieval needs
125
+ no wiring. Wrap it yourself for inject mode, or for any code path that calls `retrieve` directly:
126
+
127
+ ```ts
128
+ import { instrumentRetriever } from '@dudousxd/nestjs-agent-rag';
129
+
130
+ const retriever = instrumentRetriever(
131
+ new HybridRetriever([new EmbeddingRetriever(embedder, store), new LexicalRetriever(store)]),
132
+ // optional: name the collection per call, when one index holds many
133
+ { collection: (filter) => (typeof filter?.collectionId === 'string' ? filter.collectionId : undefined) },
134
+ );
135
+ ```
136
+
137
+ Three things worth knowing:
138
+
139
+ - **A composed retrieval is ONE event.** The outermost instrumented retriever reports; anything
140
+ instrumented inside it stays silent. `RerankingRetriever` over a `HybridRetriever` over two legs is
141
+ one retrieval, reported as `retriever: 'reranking'` with the duration and scores the caller got.
142
+ Wrapping a leg as well as the composite is safe — it will not double-count.
143
+ - **It costs nothing when nobody listens.** `emit` is gated on `channel.hasSubscribers`, and the
144
+ wrapper checks the same gate before it reads a clock or touches a score, so an install with no
145
+ Telescope (or no subscriber at all) delegates straight through.
146
+ - **It never changes behaviour.** Passages come back verbatim; a throwing retriever is reported as
147
+ `failed` and rethrown unchanged.
148
+
149
+ `@dudousxd/nestjs-agent-telescope` subscribes to this channel and renders the **Retrieval** and
150
+ **Retrieval sources** sections of the Agent tab. Anything else can subscribe too —
151
+ `RAG_RETRIEVAL_CHANNEL` is the exported wire contract, and a host that needs retrieval history to
152
+ outlive a restart persists it from there.
153
+
99
154
  ## pgvector store
100
155
 
101
156
  `PgVectorStore` takes an injected `PgClient` — adapt your own `pg` / `postgres.js`:
@@ -328,7 +383,10 @@ library upgrade. The interface is open, so it can be added later without changin
328
383
  - `applyMetadataPatch(metadata, patch)` — the `MetadataPatch` merge semantics, exported so your own
329
384
  `VectorStore` can implement `updateMetadata` with exactly the same ones.
330
385
  - `isLexicalVectorStore(store)` — type guard for the optional `LexicalVectorStore` capability.
331
- - `createRetrievalTool(retriever, { name?, description?, topK? })` — the agentic-retrieval tool.
386
+ - `createRetrievalTool(retriever, { name?, description?, topK?, telemetry? })` — the
387
+ agentic-retrieval tool. `telemetry` defaults to on; pass `false` to opt out.
388
+ - `instrumentRetriever(retriever, { describe?, collection? })` — wrap any `Retriever` so each
389
+ retrieval publishes on `aviary:rag:retrieval` (`RAG_RETRIEVAL_CHANNEL`).
332
390
 
333
391
  ## Collection maintenance
334
392
 
package/dist/index.cjs CHANGED
@@ -28,6 +28,9 @@ __export(index_exports, {
28
28
  LexicalRetriever: () => LexicalRetriever,
29
29
  MemoryVectorStore: () => MemoryVectorStore,
30
30
  PgVectorStore: () => PgVectorStore,
31
+ RAG_DIAGNOSTIC_LIB: () => RAG_DIAGNOSTIC_LIB,
32
+ RAG_RETRIEVAL_CHANNEL: () => RAG_RETRIEVAL_CHANNEL,
33
+ RAG_RETRIEVAL_EVENT: () => RAG_RETRIEVAL_EVENT,
31
34
  RedisVectorSchemaMismatchError: () => RedisVectorSchemaMismatchError,
32
35
  RedisVectorStore: () => RedisVectorStore,
33
36
  RerankingRetriever: () => RerankingRetriever,
@@ -37,9 +40,15 @@ __export(index_exports, {
37
40
  chunkIndexOf: () => chunkIndexOf,
38
41
  chunkText: () => chunkText,
39
42
  createRetrievalTool: () => createRetrievalTool,
43
+ describeRetrieval: () => describeRetrieval,
44
+ describeSharedSource: () => describeSharedSource,
45
+ describeSource: () => describeSource,
46
+ describesRetrieval: () => describesRetrieval,
40
47
  documentIdOf: () => documentIdOf,
48
+ emitRagRetrieval: () => emitRagRetrieval,
41
49
  ingestChunks: () => ingestChunks,
42
50
  ingestDocuments: () => ingestDocuments,
51
+ instrumentRetriever: () => instrumentRetriever,
43
52
  isLexicalVectorStore: () => isLexicalVectorStore
44
53
  });
45
54
  module.exports = __toCommonJS(index_exports);
@@ -208,6 +217,16 @@ var MemoryVectorStore = class {
208
217
  __name(this, "MemoryVectorStore");
209
218
  }
210
219
  records = /* @__PURE__ */ new Map();
220
+ /**
221
+ * Telemetry self-description. No `collection`: there is no namespace to name — the corpus IS this
222
+ * instance, and two `MemoryVectorStore`s in one process are two corpora with nothing to tell them
223
+ * apart on a dashboard.
224
+ */
225
+ describeRetrieval() {
226
+ return {
227
+ store: "memory"
228
+ };
229
+ }
211
230
  async upsert(records) {
212
231
  for (const record of records) {
213
232
  this.records.set(record.id, record);
@@ -402,6 +421,13 @@ var PgVectorStore = class {
402
421
  this.table = options.table ?? "agent_rag_chunks";
403
422
  this.dimensions = options.dimensions ?? 1536;
404
423
  }
424
+ /** Telemetry self-description — the table is the namespace a retrieval was served from. */
425
+ describeRetrieval() {
426
+ return {
427
+ store: "pg",
428
+ collection: this.table
429
+ };
430
+ }
405
431
  /** Idempotent DDL: the `vector` extension, the chunks table, and the cosine HNSW index. */
406
432
  async ensureSchema() {
407
433
  await this.client.query("CREATE EXTENSION IF NOT EXISTS vector");
@@ -664,6 +690,17 @@ var RedisVectorStore = class {
664
690
  this.lexicalScorer = options.lexicalScorer ?? "BM25";
665
691
  }
666
692
  /**
693
+ * Telemetry self-description — the RediSearch INDEX, not the key prefix: the index is what both
694
+ * legs query, and two stores sharing one index (the dense and lexical halves of a hybrid) must
695
+ * report the same namespace or `describeSharedSource` would see them disagree and drop the store.
696
+ */
697
+ describeRetrieval() {
698
+ return {
699
+ store: "redis",
700
+ collection: this.index
701
+ };
702
+ }
703
+ /**
667
704
  * Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
668
705
  * and if it DOES exist, compare what's there against this store's configuration instead of
669
706
  * assuming they agree. An index that outlives a config change is the normal case (schemas live in
@@ -1492,6 +1529,31 @@ function parseMetadata(raw) {
1492
1529
  }
1493
1530
  __name(parseMetadata, "parseMetadata");
1494
1531
 
1532
+ // src/retrieval-descriptor.ts
1533
+ function describesRetrieval(value) {
1534
+ return typeof value === "object" && value !== null && typeof Reflect.get(value, "describeRetrieval") === "function";
1535
+ }
1536
+ __name(describesRetrieval, "describesRetrieval");
1537
+ function describeRetrieval(candidate) {
1538
+ return describesRetrieval(candidate) ? candidate.describeRetrieval() : {};
1539
+ }
1540
+ __name(describeRetrieval, "describeRetrieval");
1541
+ function describeSource(base) {
1542
+ const { retriever: _ignored, ...source } = describeRetrieval(base);
1543
+ return source;
1544
+ }
1545
+ __name(describeSource, "describeSource");
1546
+ function describeSharedSource(legs) {
1547
+ const sources = legs.map(describeSource);
1548
+ const [first] = sources;
1549
+ if (first === void 0) {
1550
+ return {};
1551
+ }
1552
+ const agrees = sources.every((source) => source.store === first.store && source.collection === first.collection);
1553
+ return agrees ? first : {};
1554
+ }
1555
+ __name(describeSharedSource, "describeSharedSource");
1556
+
1495
1557
  // src/embedding-retriever.ts
1496
1558
  var EmbeddingRetriever = class {
1497
1559
  static {
@@ -1505,6 +1567,13 @@ var EmbeddingRetriever = class {
1505
1567
  this.store = store;
1506
1568
  this.options = options;
1507
1569
  }
1570
+ /** Telemetry self-description — the dense strategy, over whatever store it was handed. */
1571
+ describeRetrieval() {
1572
+ return {
1573
+ retriever: "embedding",
1574
+ ...describeSource(this.store)
1575
+ };
1576
+ }
1508
1577
  async retrieve(query, options = {}) {
1509
1578
  const [embedding] = await this.embedder.embed([
1510
1579
  query
@@ -1537,6 +1606,16 @@ var KeywordRetriever = class {
1537
1606
  this.k1 = options.k1 ?? 1.5;
1538
1607
  this.b = options.b ?? 0.75;
1539
1608
  }
1609
+ /**
1610
+ * Telemetry self-description. No `store`: this retriever holds its own copy of the corpus in
1611
+ * process, so attributing its retrievals to whichever vector store happens to hold the same chunks
1612
+ * would credit a backend that was never queried.
1613
+ */
1614
+ describeRetrieval() {
1615
+ return {
1616
+ retriever: "keyword"
1617
+ };
1618
+ }
1540
1619
  /** How many chunks are currently indexed. */
1541
1620
  get size() {
1542
1621
  return this.docs.size;
@@ -1654,6 +1733,13 @@ var LexicalRetriever = class {
1654
1733
  constructor(store) {
1655
1734
  this.store = store;
1656
1735
  }
1736
+ /** Telemetry self-description — the store's own lexical index, so the store is the same one. */
1737
+ describeRetrieval() {
1738
+ return {
1739
+ retriever: "lexical",
1740
+ ...describeSource(this.store)
1741
+ };
1742
+ }
1657
1743
  async retrieve(query, options = {}) {
1658
1744
  return this.store.searchText(query, {
1659
1745
  topK: options.topK ?? 5,
@@ -1675,6 +1761,13 @@ var HybridRetriever = class {
1675
1761
  this.retrievers = retrievers;
1676
1762
  this.options = options;
1677
1763
  }
1764
+ /** Telemetry self-description — the store only when every leg agrees; see `describeSharedSource`. */
1765
+ describeRetrieval() {
1766
+ return {
1767
+ retriever: "hybrid",
1768
+ ...describeSharedSource(this.retrievers)
1769
+ };
1770
+ }
1678
1771
  async retrieve(query, options = {}) {
1679
1772
  const k = this.options.k ?? 60;
1680
1773
  const fetchTopK = this.options.fetchTopK ?? 20;
@@ -1723,6 +1816,13 @@ var RerankingRetriever = class {
1723
1816
  this.reranker = reranker;
1724
1817
  this.options = options;
1725
1818
  }
1819
+ /** Telemetry self-description — the two-stage strategy, over the base's store. */
1820
+ describeRetrieval() {
1821
+ return {
1822
+ retriever: "reranking",
1823
+ ...describeSource(this.base)
1824
+ };
1825
+ }
1726
1826
  async retrieve(query, options = {}) {
1727
1827
  const candidates = await this.base.retrieve(query, {
1728
1828
  topK: this.options.fetchTopK ?? 20,
@@ -1747,6 +1847,13 @@ var FilteredRetriever = class {
1747
1847
  this.base = base;
1748
1848
  this.filter = filter;
1749
1849
  }
1850
+ /** Telemetry self-description — this scope, over the base's store. */
1851
+ describeRetrieval() {
1852
+ return {
1853
+ retriever: "filtered",
1854
+ ...describeSource(this.base)
1855
+ };
1856
+ }
1750
1857
  async retrieve(query, options = {}) {
1751
1858
  return this.base.retrieve(query, {
1752
1859
  ...options,
@@ -1796,6 +1903,108 @@ async function ingestDocuments(documents, options) {
1796
1903
  }
1797
1904
  __name(ingestDocuments, "ingestDocuments");
1798
1905
 
1906
+ // src/retrieval-telemetry.ts
1907
+ var import_node_async_hooks = require("async_hooks");
1908
+ var import_nestjs_diagnostics = require("@dudousxd/nestjs-diagnostics");
1909
+ var RAG_DIAGNOSTIC_LIB = "rag";
1910
+ var RAG_RETRIEVAL_EVENT = "retrieval";
1911
+ var RAG_RETRIEVAL_CHANNEL = (0, import_nestjs_diagnostics.channelName)(RAG_DIAGNOSTIC_LIB, RAG_RETRIEVAL_EVENT);
1912
+ function emitRagRetrieval(event, durationMs) {
1913
+ (0, import_nestjs_diagnostics.emit)(RAG_DIAGNOSTIC_LIB, RAG_RETRIEVAL_EVENT, event, {
1914
+ durationMs
1915
+ });
1916
+ }
1917
+ __name(emitRagRetrieval, "emitRagRetrieval");
1918
+ var DEFAULT_TOP_K = 5;
1919
+ var inRetrieval = new import_node_async_hooks.AsyncLocalStorage();
1920
+ function round4(value) {
1921
+ return Math.round(value * 1e4) / 1e4;
1922
+ }
1923
+ __name(round4, "round4");
1924
+ function scoreStats(passages) {
1925
+ const scores = passages.map((passage) => passage.score).filter((score) => Number.isFinite(score));
1926
+ if (scores.length === 0) {
1927
+ return {};
1928
+ }
1929
+ const sum = scores.reduce((total, score) => total + score, 0);
1930
+ return {
1931
+ topScore: round4(Math.max(...scores)),
1932
+ meanScore: round4(sum / scores.length)
1933
+ };
1934
+ }
1935
+ __name(scoreStats, "scoreStats");
1936
+ var InstrumentedRetriever = class InstrumentedRetriever2 {
1937
+ static {
1938
+ __name(this, "InstrumentedRetriever");
1939
+ }
1940
+ base;
1941
+ options;
1942
+ constructor(base, options = {}) {
1943
+ this.base = base;
1944
+ this.options = options;
1945
+ }
1946
+ /** Transparent to detection: wrapping a retriever must not turn it into an `unknown` one. */
1947
+ describeRetrieval() {
1948
+ return {
1949
+ ...describeRetrieval(this.base),
1950
+ ...this.options.describe
1951
+ };
1952
+ }
1953
+ async retrieve(query, options = {}) {
1954
+ if (!(0, import_nestjs_diagnostics.getChannel)(RAG_DIAGNOSTIC_LIB, RAG_RETRIEVAL_EVENT).hasSubscribers) {
1955
+ return this.base.retrieve(query, options);
1956
+ }
1957
+ if (inRetrieval.getStore() === true) {
1958
+ return this.base.retrieve(query, options);
1959
+ }
1960
+ const descriptor = this.describeRetrieval();
1961
+ const topK = options.topK ?? DEFAULT_TOP_K;
1962
+ const startedAt = Date.now();
1963
+ try {
1964
+ const passages = await inRetrieval.run(true, () => this.base.retrieve(query, options));
1965
+ emitRagRetrieval({
1966
+ retriever: descriptor.retriever ?? "unknown",
1967
+ ...descriptor.store !== void 0 ? {
1968
+ store: descriptor.store
1969
+ } : {},
1970
+ ...this.collectionOf(options.filter, descriptor),
1971
+ topK,
1972
+ chunks: passages.length,
1973
+ zeroHit: passages.length === 0,
1974
+ ...scoreStats(passages),
1975
+ failed: false
1976
+ }, Date.now() - startedAt);
1977
+ return passages;
1978
+ } catch (error) {
1979
+ emitRagRetrieval({
1980
+ retriever: descriptor.retriever ?? "unknown",
1981
+ ...descriptor.store !== void 0 ? {
1982
+ store: descriptor.store
1983
+ } : {},
1984
+ ...this.collectionOf(options.filter, descriptor),
1985
+ topK,
1986
+ chunks: 0,
1987
+ zeroHit: true,
1988
+ failed: true,
1989
+ error: error instanceof Error ? error.message : String(error)
1990
+ }, Date.now() - startedAt);
1991
+ throw error;
1992
+ }
1993
+ }
1994
+ /** The host's collection for this call, else the store namespace, else nothing. */
1995
+ collectionOf(filter, descriptor) {
1996
+ const { collection } = this.options;
1997
+ const resolved = typeof collection === "function" ? collection(filter) : collection ?? descriptor.collection;
1998
+ return resolved !== void 0 ? {
1999
+ collection: resolved
2000
+ } : {};
2001
+ }
2002
+ };
2003
+ function instrumentRetriever(retriever, options = {}) {
2004
+ return new InstrumentedRetriever(retriever, options);
2005
+ }
2006
+ __name(instrumentRetriever, "instrumentRetriever");
2007
+
1799
2008
  // src/retrieval-tool.ts
1800
2009
  function isQueryInput(value) {
1801
2010
  return typeof value === "object" && value !== null && "query" in value && typeof value.query === "string";
@@ -1822,6 +2031,8 @@ var querySchema = {
1822
2031
  };
1823
2032
  function createRetrievalTool(retriever, options = {}) {
1824
2033
  const topK = options.topK;
2034
+ const { telemetry = true } = options;
2035
+ const instrumented = telemetry === false ? retriever : instrumentRetriever(retriever, telemetry === true ? {} : telemetry);
1825
2036
  return {
1826
2037
  spec: {
1827
2038
  name: options.name ?? "search_knowledge",
@@ -1834,7 +2045,7 @@ function createRetrievalTool(retriever, options = {}) {
1834
2045
  if (!isQueryInput(input)) {
1835
2046
  throw new Error("retrieval tool expects { query: string }");
1836
2047
  }
1837
- const passages = await retriever.retrieve(input.query, topK !== void 0 ? {
2048
+ const passages = await instrumented.retrieve(input.query, topK !== void 0 ? {
1838
2049
  topK
1839
2050
  } : {});
1840
2051
  return {
@@ -1854,6 +2065,9 @@ __name(createRetrievalTool, "createRetrievalTool");
1854
2065
  LexicalRetriever,
1855
2066
  MemoryVectorStore,
1856
2067
  PgVectorStore,
2068
+ RAG_DIAGNOSTIC_LIB,
2069
+ RAG_RETRIEVAL_CHANNEL,
2070
+ RAG_RETRIEVAL_EVENT,
1857
2071
  RedisVectorSchemaMismatchError,
1858
2072
  RedisVectorStore,
1859
2073
  RerankingRetriever,
@@ -1863,9 +2077,15 @@ __name(createRetrievalTool, "createRetrievalTool");
1863
2077
  chunkIndexOf,
1864
2078
  chunkText,
1865
2079
  createRetrievalTool,
2080
+ describeRetrieval,
2081
+ describeSharedSource,
2082
+ describeSource,
2083
+ describesRetrieval,
1866
2084
  documentIdOf,
2085
+ emitRagRetrieval,
1867
2086
  ingestChunks,
1868
2087
  ingestDocuments,
2088
+ instrumentRetriever,
1869
2089
  isLexicalVectorStore
1870
2090
  });
1871
2091
  //# sourceMappingURL=index.cjs.map