@dudousxd/nestjs-agent-rag 0.9.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.d.ts CHANGED
@@ -319,6 +319,68 @@ declare function chunkIndexOf(chunkId: string): number;
319
319
  /** Collapse a chunk id (`${documentId}#<n>`) back to its source document id. */
320
320
  declare function documentIdOf(chunkId: string): string;
321
321
 
322
+ /**
323
+ * What a retrieval was served BY — the static half of retrieval telemetry, separated from the
324
+ * emitting half (`retrieval-telemetry.ts`) so a store or a retriever can describe itself without
325
+ * importing anything that emits. That separation is not tidiness: `retrieval-telemetry.ts` imports
326
+ * the retrievers' shapes to detect them, and the retrievers import their descriptor kind, so putting
327
+ * both halves in one module would make every retriever ↔ telemetry edge a cycle.
328
+ */
329
+ /** Which retrieval strategy answered. `unknown` is a retriever this package did not ship. */
330
+ type RetrieverKind = 'embedding' | 'keyword' | 'lexical' | 'hybrid' | 'filtered' | 'reranking' | 'unknown';
331
+ /** Which backend held the chunks. Omitted entirely for a retriever that holds its own (keyword). */
332
+ type VectorStoreKind = 'memory' | 'pg' | 'redis';
333
+ /**
334
+ * A retriever's (or a store's) self-description, as it appears on a retrieval event. Every field is
335
+ * optional because the describer is answering only for the layer it *is*: a store knows its backend
336
+ * and namespace but not which strategy queried it; `KeywordRetriever` knows its strategy but has no
337
+ * store at all.
338
+ */
339
+ interface RetrievalDescriptor {
340
+ retriever?: RetrieverKind;
341
+ store?: VectorStoreKind;
342
+ /**
343
+ * The namespace the chunks live in — `PgVectorStore`'s table, `RedisVectorStore`'s RediSearch
344
+ * index. This is the STORE-level namespace, not a per-query metadata scope: a host that partitions
345
+ * one index into logical collections with a metadata filter (the usual multi-tenant shape) reports
346
+ * that through `InstrumentRetrieverOptions.collection`, which overrides this.
347
+ */
348
+ collection?: string;
349
+ }
350
+ /**
351
+ * The optional capability a store or retriever implements to appear on a retrieval event as
352
+ * something other than `unknown`. Deliberately NOT a member of `VectorStore` or of core's
353
+ * `Retriever`: both are implemented outside this package, and adding a required member to either
354
+ * would break every existing implementation on upgrade — for a field that only observability reads.
355
+ */
356
+ interface DescribesRetrieval {
357
+ describeRetrieval(): RetrievalDescriptor;
358
+ }
359
+ /** Structural guard, so a `VectorStore`/`Retriever` is narrowed to the capability without a cast. */
360
+ declare function describesRetrieval(value: unknown): value is DescribesRetrieval;
361
+ /** A candidate's descriptor, or an empty one when it does not carry the capability. */
362
+ declare function describeRetrieval(candidate: unknown): RetrievalDescriptor;
363
+ /**
364
+ * The store half of a wrapped retriever's descriptor: the base's `store`/`collection` with its
365
+ * `retriever` dropped, so the wrapper can stamp its own strategy on top.
366
+ *
367
+ * `new FilteredRetriever(new EmbeddingRetriever(embedder, pg), scope)` therefore reports
368
+ * `{ retriever: 'filtered', store: 'pg', collection: 'agent_rag_chunks' }` rather than losing the
369
+ * store: which backend answered is the thing a "retrievals by store" panel exists to show, and a
370
+ * production wiring is almost always two or three wrappers deep.
371
+ */
372
+ declare function describeSource(base: unknown): Omit<RetrievalDescriptor, 'retriever'>;
373
+ /**
374
+ * The store half shared by EVERY leg of a composed retriever, or nothing when they disagree.
375
+ *
376
+ * A hybrid whose legs are the dense and lexical halves of the same RediSearch index (the shape
377
+ * `LexicalRetriever` exists for) genuinely was served by that one store, and reporting it is what
378
+ * makes the store breakdown add up. A hybrid fusing pgvector with an in-process BM25 index was not
379
+ * served by either alone, and picking the first leg would silently attribute every such retrieval to
380
+ * a store that answered half of it.
381
+ */
382
+ declare function describeSharedSource(legs: readonly unknown[]): Omit<RetrievalDescriptor, 'retriever'>;
383
+
322
384
  /**
323
385
  * An in-process {@link VectorStore} — cosine similarity over a Map, no infra. The reference adapter
324
386
  * for tests and small/embedded corpora; for production scale use `PgVectorStore` (or your own).
@@ -331,6 +393,12 @@ declare function documentIdOf(chunkId: string): string;
331
393
  */
332
394
  declare class MemoryVectorStore implements VectorStore {
333
395
  private readonly records;
396
+ /**
397
+ * Telemetry self-description. No `collection`: there is no namespace to name — the corpus IS this
398
+ * instance, and two `MemoryVectorStore`s in one process are two corpora with nothing to tell them
399
+ * apart on a dashboard.
400
+ */
401
+ describeRetrieval(): RetrievalDescriptor;
334
402
  upsert(records: VectorRecord[]): Promise<void>;
335
403
  remove(documentId: string): Promise<void>;
336
404
  /**
@@ -411,6 +479,8 @@ declare class PgVectorStore implements VectorStore {
411
479
  private readonly table;
412
480
  private readonly dimensions;
413
481
  constructor(client: PgClient, options?: PgVectorStoreOptions);
482
+ /** Telemetry self-description — the table is the namespace a retrieval was served from. */
483
+ describeRetrieval(): RetrievalDescriptor;
414
484
  /** Idempotent DDL: the `vector` extension, the chunks table, and the cosine HNSW index. */
415
485
  ensureSchema(): Promise<void>;
416
486
  upsert(records: VectorRecord[]): Promise<void>;
@@ -531,6 +601,12 @@ declare class RedisVectorStore implements LexicalVectorStore {
531
601
  private readonly filterableFields;
532
602
  private readonly lexicalScorer;
533
603
  constructor(client: RedisSearchClient, options?: RedisVectorStoreOptions);
604
+ /**
605
+ * Telemetry self-description — the RediSearch INDEX, not the key prefix: the index is what both
606
+ * legs query, and two stores sharing one index (the dense and lexical halves of a hybrid) must
607
+ * report the same namespace or `describeSharedSource` would see them disagree and drop the store.
608
+ */
609
+ describeRetrieval(): RetrievalDescriptor;
534
610
  /**
535
611
  * Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
536
612
  * and if it DOES exist, compare what's there against this store's configuration instead of
@@ -740,6 +816,8 @@ declare class EmbeddingRetriever implements Retriever {
740
816
  private readonly store;
741
817
  private readonly options;
742
818
  constructor(embedder: EmbeddingProvider, store: VectorStore, options?: EmbeddingRetrieverOptions);
819
+ /** Telemetry self-description — the dense strategy, over whatever store it was handed. */
820
+ describeRetrieval(): RetrievalDescriptor;
743
821
  retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
744
822
  }
745
823
 
@@ -799,6 +877,12 @@ declare class KeywordRetriever implements Retriever {
799
877
  private readonly k1;
800
878
  private readonly b;
801
879
  constructor(options?: KeywordRetrieverOptions);
880
+ /**
881
+ * Telemetry self-description. No `store`: this retriever holds its own copy of the corpus in
882
+ * process, so attributing its retrievals to whichever vector store happens to hold the same chunks
883
+ * would credit a backend that was never queried.
884
+ */
885
+ describeRetrieval(): RetrievalDescriptor;
802
886
  /** How many chunks are currently indexed. */
803
887
  get size(): number;
804
888
  /** Index (or re-index) records. Re-adding an existing id replaces its posting and updates stats. */
@@ -844,6 +928,8 @@ declare class KeywordRetriever implements Retriever {
844
928
  declare class LexicalRetriever implements Retriever {
845
929
  private readonly store;
846
930
  constructor(store: LexicalVectorStore);
931
+ /** Telemetry self-description — the store's own lexical index, so the store is the same one. */
932
+ describeRetrieval(): RetrievalDescriptor;
847
933
  retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
848
934
  }
849
935
 
@@ -895,6 +981,8 @@ declare class HybridRetriever implements Retriever {
895
981
  private readonly retrievers;
896
982
  private readonly options;
897
983
  constructor(retrievers: Retriever[], options?: HybridRetrieverOptions);
984
+ /** Telemetry self-description — the store only when every leg agrees; see `describeSharedSource`. */
985
+ describeRetrieval(): RetrievalDescriptor;
898
986
  retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
899
987
  }
900
988
 
@@ -913,6 +1001,8 @@ declare class RerankingRetriever implements Retriever {
913
1001
  private readonly reranker;
914
1002
  private readonly options;
915
1003
  constructor(base: Retriever, reranker: Reranker, options?: RerankingRetrieverOptions);
1004
+ /** Telemetry self-description — the two-stage strategy, over the base's store. */
1005
+ describeRetrieval(): RetrievalDescriptor;
916
1006
  retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
917
1007
  }
918
1008
 
@@ -929,15 +1019,178 @@ declare class FilteredRetriever implements Retriever {
929
1019
  private readonly base;
930
1020
  private readonly filter;
931
1021
  constructor(base: Retriever, filter: Record<string, unknown>);
1022
+ /** Telemetry self-description — this scope, over the base's store. */
1023
+ describeRetrieval(): RetrievalDescriptor;
932
1024
  retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
933
1025
  }
934
1026
 
1027
+ /**
1028
+ * Retrieval telemetry: one diagnostics event per retrieval, carrying how long it took, how much came
1029
+ * back, how good it was, and what served it.
1030
+ *
1031
+ * Until this existed, retrieval reached Telescope only as the *tool call* that wrapped it (see
1032
+ * `retrieval-tool.ts`), which says a retrieval happened and nothing else. The questions an operator
1033
+ * actually arrives with — is retrieval slow, is it returning nothing, is one collection colder than
1034
+ * the rest, did the scores collapse after a model swap — are all invisible in a tool-call row,
1035
+ * because a tool call that returns zero passages and one that returns five perfect ones are the same
1036
+ * row with the same `ok` status.
1037
+ *
1038
+ * ## One seam, not six
1039
+ *
1040
+ * Instrumentation is a WRAPPER over the core `Retriever` SPI ({@link instrumentRetriever}), not code
1041
+ * inside each retriever. Every retriever in this package — and every retriever outside it — is the
1042
+ * same one-method interface, so one wrapper covers all of them, including ones this package has
1043
+ * never heard of. The alternative, an `emit` inside each of the six `retrieve` methods, would have
1044
+ * had to be repeated in each, skipped by third-party retrievers entirely, and would have double- and
1045
+ * triple-counted the composed ones (a hybrid over two legs emitting three events for one retrieval).
1046
+ *
1047
+ * ## How a composed retrieval reports
1048
+ *
1049
+ * **The outermost instrumented retriever in a call tree emits; anything instrumented inside it stays
1050
+ * silent.** A retrieval is one question the caller asked, so it is one event no matter how many
1051
+ * retrievers cooperated to answer it: `RerankingRetriever` over a `HybridRetriever` over a dense and
1052
+ * a lexical leg is ONE retrieval, reported as `retriever: 'reranking'` with the duration of the
1053
+ * whole thing and the scores the caller actually got back.
1054
+ *
1055
+ * The suppression is enforced with an {@link AsyncLocalStorage} flag rather than by asking hosts to
1056
+ * wrap carefully, because the wrapping that double-counts is the natural one: a host that wraps its
1057
+ * top-level retriever and *also* wraps the dense leg it shares with another code path has not made a
1058
+ * mistake, and would otherwise get two events per retrieval, a zero-hit rate computed over a mixture
1059
+ * of legs and composites, and a retrieval count roughly twice its query count.
1060
+ *
1061
+ * ## Opt-out safety, and what the hot path pays
1062
+ *
1063
+ * The event goes out on `aviary:rag:retrieval` via `@dudousxd/nestjs-diagnostics`, whose `emit` is
1064
+ * gated on `channel.hasSubscribers` and never throws. This wrapper checks that same gate BEFORE it
1065
+ * does anything at all — no clock read, no `AsyncLocalStorage.run`, no score arithmetic — and
1066
+ * delegates straight through when nothing is listening. A host that wires this package without
1067
+ * Telescope (or any subscriber) pays two `Map` lookups and a boolean per retrieval, and nothing else
1068
+ * changes: the wrapper returns the base retriever's passages verbatim, and rethrows its errors
1069
+ * unchanged.
1070
+ *
1071
+ * ## Why this channel is not merged into the typed `ChannelRegistry`
1072
+ *
1073
+ * `@dudousxd/nestjs-agent-rag-media` already emits `media.ingested`/`media.removed`/`media.skipped`/
1074
+ * `media.failed` on the same `rag` lib, and flip's host app emits `rag:search` on it. Declaring
1075
+ * `ChannelRegistry['rag'] = { retrieval: RagRetrievalEvent }` here would narrow `EventOf<'rag'>` to
1076
+ * this one key process-wide and turn every one of those sibling emits into a compile error in any
1077
+ * project that installs both packages. The payload stays typed where it matters — {@link emitRagRetrieval}
1078
+ * takes {@link RagRetrievalEvent} — and the wire contract is documented below instead.
1079
+ */
1080
+ /** The diagnostics lib segment. Shared with `@dudousxd/nestjs-agent-rag-media`'s `media.*` events. */
1081
+ declare const RAG_DIAGNOSTIC_LIB = "rag";
1082
+ /** The diagnostics event segment. */
1083
+ declare const RAG_RETRIEVAL_EVENT = "retrieval";
1084
+ /**
1085
+ * The full channel name a subscriber listens on: `aviary:rag:retrieval`. This is the wire contract —
1086
+ * `@dudousxd/nestjs-agent-telescope` subscribes to this exact string rather than importing this
1087
+ * package, which is what keeps the telescope extension free of a dependency on the retrieval stack
1088
+ * (and lets a host that uses neither still observe retrieval with a bare `diagnostics_channel`
1089
+ * subscription).
1090
+ */
1091
+ declare const RAG_RETRIEVAL_CHANNEL: string;
1092
+ /**
1093
+ * The payload of one retrieval event. Counts, durations, kinds and scores only — never the query
1094
+ * text and never passage text. Both are user content, and this lands in an ops store with a short
1095
+ * retention window and a Slack-facing redaction budget; everything here is safe to put on a
1096
+ * dashboard and is what tells the failure modes apart anyway.
1097
+ *
1098
+ * The DURATION is not in here: it rides the envelope (`emit`'s `opts.durationMs`), which is the field
1099
+ * Telescope's OTel exporter turns into a latency histogram. Putting it in the payload instead would
1100
+ * have made retrieval latency a number somebody reads out of a JSON blob rather than a graph.
1101
+ */
1102
+ interface RagRetrievalEvent {
1103
+ /** Which strategy answered — the OUTERMOST one, for a composed retriever. */
1104
+ retriever: RetrieverKind;
1105
+ /** Which backend held the chunks. Absent for an in-process retriever, or when legs disagree. */
1106
+ store?: VectorStoreKind;
1107
+ /** Store namespace (pg table / RediSearch index), or the host's logical collection. */
1108
+ collection?: string;
1109
+ /** What the caller asked for, so `chunks < topK` is readable as "the corpus ran out". */
1110
+ topK: number;
1111
+ /** How many passages came back. */
1112
+ chunks: number;
1113
+ /**
1114
+ * `chunks === 0`. Carried as its own flag rather than left to be derived, because the zero-hit RATE
1115
+ * is the headline number and deriving it means every consumer re-deriving the same predicate.
1116
+ */
1117
+ zeroHit: boolean;
1118
+ /** Best score in the result, rounded to 4dp. Absent on a zero-hit. */
1119
+ topScore?: number;
1120
+ /** Mean score across the result, rounded to 4dp. Absent on a zero-hit. */
1121
+ meanScore?: number;
1122
+ /**
1123
+ * The retrieval threw. Reported rather than dropped because a failed retrieval and an empty one
1124
+ * look identical from the agent's side — both become "I couldn't find anything" — so a channel
1125
+ * that only carried successes would go quiet exactly when retrieval broke.
1126
+ */
1127
+ failed: boolean;
1128
+ /** The error message, when `failed`. */
1129
+ error?: string;
1130
+ }
1131
+ /** Publish one retrieval event. `durationMs` rides the envelope; see {@link RagRetrievalEvent}. */
1132
+ declare function emitRagRetrieval(event: RagRetrievalEvent, durationMs: number): void;
1133
+ interface InstrumentRetrieverOptions {
1134
+ /**
1135
+ * Override the auto-detected {@link RetrievalDescriptor}, field by field. Needed for a retriever or
1136
+ * store from outside this package (which reports `retriever: 'unknown'` and no store), and for a
1137
+ * host that wants its own naming.
1138
+ */
1139
+ describe?: RetrievalDescriptor;
1140
+ /**
1141
+ * The logical collection this retrieval searched, when the store namespace is not it. Pass a
1142
+ * function to read it off the call's metadata filter — the multi-tenant shape, where one index
1143
+ * holds every collection and the filter picks one:
1144
+ *
1145
+ * ```ts
1146
+ * instrumentRetriever(retriever, {
1147
+ * collection: (filter) => (typeof filter?.collectionId === 'string' ? filter.collectionId : undefined),
1148
+ * })
1149
+ * ```
1150
+ *
1151
+ * Returning `undefined` falls back to the store namespace, which is the right answer for a query
1152
+ * scoped to many collections at once: a breakdown panel needs ONE label per retrieval, and
1153
+ * inventing a synthetic `"a+b+c"` bucket would produce a chart whose slices are combinations
1154
+ * rather than collections.
1155
+ */
1156
+ collection?: string | ((filter: Record<string, unknown> | undefined) => string | undefined);
1157
+ }
1158
+ /**
1159
+ * Wrap a retriever so every `retrieve` publishes a {@link RagRetrievalEvent} on
1160
+ * {@link RAG_RETRIEVAL_CHANNEL}. Wrap the retriever you actually hand to the agent — the outermost
1161
+ * one — and nothing else needs changing:
1162
+ *
1163
+ * ```ts
1164
+ * const retriever = instrumentRetriever(
1165
+ * new HybridRetriever([new EmbeddingRetriever(embedder, store), new LexicalRetriever(store)]),
1166
+ * );
1167
+ * ```
1168
+ *
1169
+ * {@link createRetrievalTool} already does this for the tool it builds, so agentic retrieval is
1170
+ * instrumented with no host change at all; call this directly for inject-mode retrieval
1171
+ * (`AgentModule.forRoot({ retrieval })`) or for a host that calls `retrieve` itself. Wrapping twice
1172
+ * is harmless — see the composed-retrieval section above.
1173
+ */
1174
+ declare function instrumentRetriever(retriever: Retriever, options?: InstrumentRetrieverOptions): Retriever;
1175
+
935
1176
  interface RetrievalToolOptions {
936
1177
  /** Tool name the model sees. Default `search_knowledge`. */
937
1178
  name?: string;
938
1179
  description?: string;
939
1180
  /** Passages per search. Default lets the retriever decide (typically 5). */
940
1181
  topK?: number;
1182
+ /**
1183
+ * Retrieval telemetry (`aviary:rag:retrieval` — duration, chunk count, score distribution, store
1184
+ * and collection). **On by default**, because this tool is the once-per-retrieval seam in agentic
1185
+ * mode: instrumenting here is what makes the RAG panels work for a host that only ever calls
1186
+ * `createRetrievalTool`, with no wiring at all. It costs nothing when nobody subscribes (see
1187
+ * {@link instrumentRetriever}), and cannot double-count when the host has already wrapped the
1188
+ * retriever itself.
1189
+ *
1190
+ * Pass `false` to opt a single tool out — a host that reports retrieval its own way, or one whose
1191
+ * corpus is small enough that the events are noise.
1192
+ */
1193
+ telemetry?: boolean | InstrumentRetrieverOptions;
941
1194
  }
942
1195
  /** A functional tool (`{ spec, handler }`) — pass it to nestjs `provideAgentTool`. */
943
1196
  interface RetrievalTool {
@@ -949,10 +1202,14 @@ interface RetrievalTool {
949
1202
  * decides it needs context. Its output `{ passages }` persists as the tool call's result, so sources
950
1203
  * flow to the frontend and telescope through the normal tool-call machinery (the citation surface).
951
1204
  *
1205
+ * The retrieval ITSELF is also instrumented (`aviary:rag:retrieval`) unless `telemetry: false` — the
1206
+ * tool-call row says a retrieval happened, the retrieval event says how long it took, how much came
1207
+ * back and how good it was. See {@link instrumentRetriever}.
1208
+ *
952
1209
  * ```ts
953
1210
  * providers: [provideAgentTool(createRetrievalTool(retriever))]
954
1211
  * ```
955
1212
  */
956
1213
  declare function createRetrievalTool(retriever: Retriever, options?: RetrievalToolOptions): RetrievalTool;
957
1214
 
958
- export { type ChunkOptions, type ChunkRecord, EmbeddingRetriever, type EmbeddingRetrieverOptions, FilteredRetriever, HybridRetriever, type HybridRetrieverOptions, type IndexedDocument, type IngestChunksOptions, type IngestDocument, type IngestOptions, KeywordRetriever, type KeywordRetrieverOptions, LexicalRetriever, type LexicalVectorStore, type ListChunksOptions, MemoryVectorStore, type MetadataPatch, type PgClient, PgVectorStore, type PgVectorStoreOptions, type RedisSearchClient, RedisVectorSchemaMismatchError, RedisVectorStore, type RedisVectorStoreOptions, RerankingRetriever, type RerankingRetrieverOptions, type RetrievalTool, type RetrievalToolOptions, type StoredChunk, UnsafeRemovalError, type VectorRecord, type VectorSearchOptions, type VectorStore, applyMetadataPatch, chunkDocuments, chunkIndexOf, chunkText, createRetrievalTool, documentIdOf, ingestChunks, ingestDocuments, isLexicalVectorStore };
1215
+ export { type ChunkOptions, type ChunkRecord, type DescribesRetrieval, EmbeddingRetriever, type EmbeddingRetrieverOptions, FilteredRetriever, HybridRetriever, type HybridRetrieverOptions, type IndexedDocument, type IngestChunksOptions, type IngestDocument, type IngestOptions, type InstrumentRetrieverOptions, KeywordRetriever, type KeywordRetrieverOptions, LexicalRetriever, type LexicalVectorStore, type ListChunksOptions, MemoryVectorStore, type MetadataPatch, type PgClient, PgVectorStore, type PgVectorStoreOptions, RAG_DIAGNOSTIC_LIB, RAG_RETRIEVAL_CHANNEL, RAG_RETRIEVAL_EVENT, type RagRetrievalEvent, type RedisSearchClient, RedisVectorSchemaMismatchError, RedisVectorStore, type RedisVectorStoreOptions, RerankingRetriever, type RerankingRetrieverOptions, type RetrievalDescriptor, type RetrievalTool, type RetrievalToolOptions, type RetrieverKind, type StoredChunk, UnsafeRemovalError, type VectorRecord, type VectorSearchOptions, type VectorStore, type VectorStoreKind, applyMetadataPatch, chunkDocuments, chunkIndexOf, chunkText, createRetrievalTool, describeRetrieval, describeSharedSource, describeSource, describesRetrieval, documentIdOf, emitRagRetrieval, ingestChunks, ingestDocuments, instrumentRetriever, isLexicalVectorStore };
package/dist/index.js CHANGED
@@ -165,6 +165,16 @@ var MemoryVectorStore = class {
165
165
  __name(this, "MemoryVectorStore");
166
166
  }
167
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
+ }
168
178
  async upsert(records) {
169
179
  for (const record of records) {
170
180
  this.records.set(record.id, record);
@@ -359,6 +369,13 @@ var PgVectorStore = class {
359
369
  this.table = options.table ?? "agent_rag_chunks";
360
370
  this.dimensions = options.dimensions ?? 1536;
361
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
+ }
362
379
  /** Idempotent DDL: the `vector` extension, the chunks table, and the cosine HNSW index. */
363
380
  async ensureSchema() {
364
381
  await this.client.query("CREATE EXTENSION IF NOT EXISTS vector");
@@ -621,6 +638,17 @@ var RedisVectorStore = class {
621
638
  this.lexicalScorer = options.lexicalScorer ?? "BM25";
622
639
  }
623
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
+ /**
624
652
  * Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
625
653
  * and if it DOES exist, compare what's there against this store's configuration instead of
626
654
  * assuming they agree. An index that outlives a config change is the normal case (schemas live in
@@ -1449,6 +1477,31 @@ function parseMetadata(raw) {
1449
1477
  }
1450
1478
  __name(parseMetadata, "parseMetadata");
1451
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
+
1452
1505
  // src/embedding-retriever.ts
1453
1506
  var EmbeddingRetriever = class {
1454
1507
  static {
@@ -1462,6 +1515,13 @@ var EmbeddingRetriever = class {
1462
1515
  this.store = store;
1463
1516
  this.options = options;
1464
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
+ }
1465
1525
  async retrieve(query, options = {}) {
1466
1526
  const [embedding] = await this.embedder.embed([
1467
1527
  query
@@ -1494,6 +1554,16 @@ var KeywordRetriever = class {
1494
1554
  this.k1 = options.k1 ?? 1.5;
1495
1555
  this.b = options.b ?? 0.75;
1496
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
+ }
1497
1567
  /** How many chunks are currently indexed. */
1498
1568
  get size() {
1499
1569
  return this.docs.size;
@@ -1611,6 +1681,13 @@ var LexicalRetriever = class {
1611
1681
  constructor(store) {
1612
1682
  this.store = store;
1613
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
+ }
1614
1691
  async retrieve(query, options = {}) {
1615
1692
  return this.store.searchText(query, {
1616
1693
  topK: options.topK ?? 5,
@@ -1632,6 +1709,13 @@ var HybridRetriever = class {
1632
1709
  this.retrievers = retrievers;
1633
1710
  this.options = options;
1634
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
+ }
1635
1719
  async retrieve(query, options = {}) {
1636
1720
  const k = this.options.k ?? 60;
1637
1721
  const fetchTopK = this.options.fetchTopK ?? 20;
@@ -1680,6 +1764,13 @@ var RerankingRetriever = class {
1680
1764
  this.reranker = reranker;
1681
1765
  this.options = options;
1682
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
+ }
1683
1774
  async retrieve(query, options = {}) {
1684
1775
  const candidates = await this.base.retrieve(query, {
1685
1776
  topK: this.options.fetchTopK ?? 20,
@@ -1704,6 +1795,13 @@ var FilteredRetriever = class {
1704
1795
  this.base = base;
1705
1796
  this.filter = filter;
1706
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
+ }
1707
1805
  async retrieve(query, options = {}) {
1708
1806
  return this.base.retrieve(query, {
1709
1807
  ...options,
@@ -1753,6 +1851,108 @@ async function ingestDocuments(documents, options) {
1753
1851
  }
1754
1852
  __name(ingestDocuments, "ingestDocuments");
1755
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
+
1756
1956
  // src/retrieval-tool.ts
1757
1957
  function isQueryInput(value) {
1758
1958
  return typeof value === "object" && value !== null && "query" in value && typeof value.query === "string";
@@ -1779,6 +1979,8 @@ var querySchema = {
1779
1979
  };
1780
1980
  function createRetrievalTool(retriever, options = {}) {
1781
1981
  const topK = options.topK;
1982
+ const { telemetry = true } = options;
1983
+ const instrumented = telemetry === false ? retriever : instrumentRetriever(retriever, telemetry === true ? {} : telemetry);
1782
1984
  return {
1783
1985
  spec: {
1784
1986
  name: options.name ?? "search_knowledge",
@@ -1791,7 +1993,7 @@ function createRetrievalTool(retriever, options = {}) {
1791
1993
  if (!isQueryInput(input)) {
1792
1994
  throw new Error("retrieval tool expects { query: string }");
1793
1995
  }
1794
- const passages = await retriever.retrieve(input.query, topK !== void 0 ? {
1996
+ const passages = await instrumented.retrieve(input.query, topK !== void 0 ? {
1795
1997
  topK
1796
1998
  } : {});
1797
1999
  return {
@@ -1810,6 +2012,9 @@ export {
1810
2012
  LexicalRetriever,
1811
2013
  MemoryVectorStore,
1812
2014
  PgVectorStore,
2015
+ RAG_DIAGNOSTIC_LIB,
2016
+ RAG_RETRIEVAL_CHANNEL,
2017
+ RAG_RETRIEVAL_EVENT,
1813
2018
  RedisVectorSchemaMismatchError,
1814
2019
  RedisVectorStore,
1815
2020
  RerankingRetriever,
@@ -1819,9 +2024,15 @@ export {
1819
2024
  chunkIndexOf,
1820
2025
  chunkText,
1821
2026
  createRetrievalTool,
2027
+ describeRetrieval,
2028
+ describeSharedSource,
2029
+ describeSource,
2030
+ describesRetrieval,
1822
2031
  documentIdOf,
2032
+ emitRagRetrieval,
1823
2033
  ingestChunks,
1824
2034
  ingestDocuments,
2035
+ instrumentRetriever,
1825
2036
  isLexicalVectorStore
1826
2037
  };
1827
2038
  //# sourceMappingURL=index.js.map