@dudousxd/nestjs-agent-rag 0.6.0 → 0.8.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.cts CHANGED
@@ -5,6 +5,31 @@ interface ChunkOptions {
5
5
  chunkSize?: number;
6
6
  /** Characters of overlap carried from the end of one chunk into the next. Default 100. */
7
7
  overlap?: number;
8
+ /**
9
+ * Treat the text as records joined by this string, and never split one across chunks.
10
+ *
11
+ * Default chunking is structure-blind: it breaks at the *latest* paragraph/sentence/word boundary
12
+ * in the window, which for record-shaped text means a cut lands mid-record routinely. That is fine
13
+ * for prose, where the two halves are still sentences. It is not fine for a table flattened to one
14
+ * record per row, because the half that keeps the row identifier and the half that keeps the value
15
+ * end up in different chunks, and neither can answer a question about that row on its own.
16
+ *
17
+ * With a separator set, whole records are packed greedily up to `chunkSize` and a record is never
18
+ * cut — except one longer than `chunkSize`, which has nowhere to go and is character-split as
19
+ * usual, since no chunker can avoid that.
20
+ *
21
+ * `overlap` is IGNORED in this mode, deliberately. Overlap exists to rescue a sentence that a
22
+ * boundary cut in half; a boundary that never falls inside a record has nothing to rescue, and
23
+ * carrying the tail of the previous chunk in would duplicate whole records into neighbouring chunks
24
+ * — paying for the same rows twice at embedding time and letting one row match from two places.
25
+ *
26
+ * Measured on a 200-row spreadsheet flattened to one field-labelled record per row (15 columns,
27
+ * ~800-char budget): blind chunking left 27% of records split, and on questions targeting a field
28
+ * on the far side of such a cut, BM25 answered 54/66 (MRR 0.818) against 66/66 (MRR 1.000) with
29
+ * records kept whole. The dense leg scored 7/66 either way — 200 near-identical rows produce
30
+ * near-identical vectors, so that leg cannot do row lookup at all and chunking does not change it.
31
+ */
32
+ separator?: string;
8
33
  }
9
34
  /**
10
35
  * Splits text into overlapping chunks for embedding. Greedy up to `chunkSize`, but prefers to break
@@ -595,6 +620,29 @@ declare class RedisVectorStore implements LexicalVectorStore {
595
620
  searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>;
596
621
  }
597
622
 
623
+ interface EmbeddingRetrieverOptions {
624
+ /**
625
+ * Drop passages scoring below this, so a query the corpus cannot answer returns nothing.
626
+ *
627
+ * A vector search always returns its `topK` nearest neighbours, and "nearest" is not "relevant".
628
+ * Ask a corpus of maintenance policy about the dining hall menu and it hands back the five least
629
+ * unrelated paragraphs it has, each with a score, in confident rank order. Nothing in that result
630
+ * says the answer is absent, so without a floor the consumer either feeds a model five irrelevant
631
+ * passages or invents a threshold of its own.
632
+ *
633
+ * **This is the only place in the package where a threshold is meaningful, which is why it lives
634
+ * here and not on `HybridRetriever`.** The score here comes straight from the store's vector
635
+ * search: a calibrated similarity, comparable across queries and monotonic in how well the passage
636
+ * matches. A fused score is neither — see the score section on `HybridRetriever`. Threshold each
637
+ * leg, then fuse; never the other way round.
638
+ *
639
+ * Pick the number by measuring, not by taste: score questions you know the corpus answers against
640
+ * questions you know it does not, and put the floor in the gap between the two populations. It is
641
+ * specific to the embedding model and the distance metric, so it does not survive changing either.
642
+ * Left unset nothing is dropped, which is the previous behaviour exactly.
643
+ */
644
+ minScore?: number;
645
+ }
598
646
  /**
599
647
  * Bridges an {@link EmbeddingProvider} + {@link VectorStore} into the core {@link Retriever} SPI:
600
648
  * embed the query, then vector-search. This is what you wire as the agent's retriever (as a tool
@@ -603,7 +651,8 @@ declare class RedisVectorStore implements LexicalVectorStore {
603
651
  declare class EmbeddingRetriever implements Retriever {
604
652
  private readonly embedder;
605
653
  private readonly store;
606
- constructor(embedder: EmbeddingProvider, store: VectorStore);
654
+ private readonly options;
655
+ constructor(embedder: EmbeddingProvider, store: VectorStore, options?: EmbeddingRetrieverOptions);
607
656
  retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
608
657
  }
609
658
 
@@ -724,6 +773,36 @@ interface HybridRetrieverOptions {
724
773
  * vector search with lexical BM25. RRF scores each passage by `Σ weight / (k + rank)` across the
725
774
  * lists it appears in, so it needs no score normalization between incompatible scales (cosine vs
726
775
  * BM25). Deduplicates by passage id, so aligned chunk ids from the two retrievers reinforce.
776
+ *
777
+ * ## The score on a fused passage is not a similarity. Do not threshold it.
778
+ *
779
+ * Read this before using `passage.score` for anything other than the order it already produced.
780
+ *
781
+ * Not normalizing between scales is what makes RRF robust, and the price is that the number it
782
+ * emits is a function of **rank alone** — it never sees a similarity at all. Two consequences that
783
+ * bite in practice:
784
+ *
785
+ * - **It is not comparable to the score on a passage from {@link EmbeddingRetriever}**, even though
786
+ * it arrives in the same field, of the same type, from the same `Retriever` interface. There is no
787
+ * discriminator; a consumer holding a `Passage[]` cannot tell which kind of number it has. If you
788
+ * surface scores to a user or a model, know which retriever produced them.
789
+ * - **Its range is narrow, fixed, and says nothing about quality.** Every possible score lies in
790
+ * `[minWeight / (k + fetchTopK), Σ weights / (k + 1)]` — with the defaults (`k = 60`,
791
+ * `fetchTopK = 20`, two unweighted legs) that is `[0.0125, 0.0328]`. A perfect match and the
792
+ * least-bad member of a set of terrible matches both come back around `0.016`.
793
+ *
794
+ * The second point is why a relevance floor cannot live here. What the fused score actually measures
795
+ * is how strongly the legs *agree* — and agreement tracks nothing about correctness. When both legs
796
+ * return the same nearest nothing for an unanswerable query, they reinforce it to the ceiling, the
797
+ * same ceiling a unanimous correct answer gets. Measured on one corpus, the dense leg alone
798
+ * separated cleanly (weakest real answer 0.244, loudest absent-topic answer 0.143) while those same
799
+ * queries after fusion scored 0.032 and 0.033 — inverted. On another corpus the inversion may not
800
+ * appear; what does not change is that both numbers live in a band roughly one part in fifty wide,
801
+ * with no room for a threshold to mean anything.
802
+ *
803
+ * So: put the floor on the legs, with {@link EmbeddingRetrieverOptions.minScore}, and fuse what
804
+ * survives. `HybridRetriever` deliberately has no `minScore` of its own; a threshold option here
805
+ * would be an invitation to apply it to a number that cannot support one.
727
806
  */
728
807
  declare class HybridRetriever implements Retriever {
729
808
  private readonly retrievers;
@@ -789,4 +868,4 @@ interface RetrievalTool {
789
868
  */
790
869
  declare function createRetrievalTool(retriever: Retriever, options?: RetrievalToolOptions): RetrievalTool;
791
870
 
792
- export { type ChunkOptions, type ChunkRecord, EmbeddingRetriever, FilteredRetriever, HybridRetriever, type HybridRetrieverOptions, type IndexedDocument, type IngestChunksOptions, type IngestDocument, type IngestOptions, KeywordRetriever, type KeywordRetrieverOptions, LexicalRetriever, type LexicalVectorStore, MemoryVectorStore, type MetadataPatch, type PgClient, PgVectorStore, type PgVectorStoreOptions, type RedisSearchClient, RedisVectorSchemaMismatchError, RedisVectorStore, type RedisVectorStoreOptions, RerankingRetriever, type RerankingRetrieverOptions, type RetrievalTool, type RetrievalToolOptions, UnsafeRemovalError, type VectorRecord, type VectorSearchOptions, type VectorStore, applyMetadataPatch, chunkDocuments, chunkText, createRetrievalTool, documentIdOf, ingestChunks, ingestDocuments, isLexicalVectorStore };
871
+ 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, MemoryVectorStore, type MetadataPatch, type PgClient, PgVectorStore, type PgVectorStoreOptions, type RedisSearchClient, RedisVectorSchemaMismatchError, RedisVectorStore, type RedisVectorStoreOptions, RerankingRetriever, type RerankingRetrieverOptions, type RetrievalTool, type RetrievalToolOptions, UnsafeRemovalError, type VectorRecord, type VectorSearchOptions, type VectorStore, applyMetadataPatch, chunkDocuments, chunkText, createRetrievalTool, documentIdOf, ingestChunks, ingestDocuments, isLexicalVectorStore };
package/dist/index.d.ts CHANGED
@@ -5,6 +5,31 @@ interface ChunkOptions {
5
5
  chunkSize?: number;
6
6
  /** Characters of overlap carried from the end of one chunk into the next. Default 100. */
7
7
  overlap?: number;
8
+ /**
9
+ * Treat the text as records joined by this string, and never split one across chunks.
10
+ *
11
+ * Default chunking is structure-blind: it breaks at the *latest* paragraph/sentence/word boundary
12
+ * in the window, which for record-shaped text means a cut lands mid-record routinely. That is fine
13
+ * for prose, where the two halves are still sentences. It is not fine for a table flattened to one
14
+ * record per row, because the half that keeps the row identifier and the half that keeps the value
15
+ * end up in different chunks, and neither can answer a question about that row on its own.
16
+ *
17
+ * With a separator set, whole records are packed greedily up to `chunkSize` and a record is never
18
+ * cut — except one longer than `chunkSize`, which has nowhere to go and is character-split as
19
+ * usual, since no chunker can avoid that.
20
+ *
21
+ * `overlap` is IGNORED in this mode, deliberately. Overlap exists to rescue a sentence that a
22
+ * boundary cut in half; a boundary that never falls inside a record has nothing to rescue, and
23
+ * carrying the tail of the previous chunk in would duplicate whole records into neighbouring chunks
24
+ * — paying for the same rows twice at embedding time and letting one row match from two places.
25
+ *
26
+ * Measured on a 200-row spreadsheet flattened to one field-labelled record per row (15 columns,
27
+ * ~800-char budget): blind chunking left 27% of records split, and on questions targeting a field
28
+ * on the far side of such a cut, BM25 answered 54/66 (MRR 0.818) against 66/66 (MRR 1.000) with
29
+ * records kept whole. The dense leg scored 7/66 either way — 200 near-identical rows produce
30
+ * near-identical vectors, so that leg cannot do row lookup at all and chunking does not change it.
31
+ */
32
+ separator?: string;
8
33
  }
9
34
  /**
10
35
  * Splits text into overlapping chunks for embedding. Greedy up to `chunkSize`, but prefers to break
@@ -595,6 +620,29 @@ declare class RedisVectorStore implements LexicalVectorStore {
595
620
  searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>;
596
621
  }
597
622
 
623
+ interface EmbeddingRetrieverOptions {
624
+ /**
625
+ * Drop passages scoring below this, so a query the corpus cannot answer returns nothing.
626
+ *
627
+ * A vector search always returns its `topK` nearest neighbours, and "nearest" is not "relevant".
628
+ * Ask a corpus of maintenance policy about the dining hall menu and it hands back the five least
629
+ * unrelated paragraphs it has, each with a score, in confident rank order. Nothing in that result
630
+ * says the answer is absent, so without a floor the consumer either feeds a model five irrelevant
631
+ * passages or invents a threshold of its own.
632
+ *
633
+ * **This is the only place in the package where a threshold is meaningful, which is why it lives
634
+ * here and not on `HybridRetriever`.** The score here comes straight from the store's vector
635
+ * search: a calibrated similarity, comparable across queries and monotonic in how well the passage
636
+ * matches. A fused score is neither — see the score section on `HybridRetriever`. Threshold each
637
+ * leg, then fuse; never the other way round.
638
+ *
639
+ * Pick the number by measuring, not by taste: score questions you know the corpus answers against
640
+ * questions you know it does not, and put the floor in the gap between the two populations. It is
641
+ * specific to the embedding model and the distance metric, so it does not survive changing either.
642
+ * Left unset nothing is dropped, which is the previous behaviour exactly.
643
+ */
644
+ minScore?: number;
645
+ }
598
646
  /**
599
647
  * Bridges an {@link EmbeddingProvider} + {@link VectorStore} into the core {@link Retriever} SPI:
600
648
  * embed the query, then vector-search. This is what you wire as the agent's retriever (as a tool
@@ -603,7 +651,8 @@ declare class RedisVectorStore implements LexicalVectorStore {
603
651
  declare class EmbeddingRetriever implements Retriever {
604
652
  private readonly embedder;
605
653
  private readonly store;
606
- constructor(embedder: EmbeddingProvider, store: VectorStore);
654
+ private readonly options;
655
+ constructor(embedder: EmbeddingProvider, store: VectorStore, options?: EmbeddingRetrieverOptions);
607
656
  retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
608
657
  }
609
658
 
@@ -724,6 +773,36 @@ interface HybridRetrieverOptions {
724
773
  * vector search with lexical BM25. RRF scores each passage by `Σ weight / (k + rank)` across the
725
774
  * lists it appears in, so it needs no score normalization between incompatible scales (cosine vs
726
775
  * BM25). Deduplicates by passage id, so aligned chunk ids from the two retrievers reinforce.
776
+ *
777
+ * ## The score on a fused passage is not a similarity. Do not threshold it.
778
+ *
779
+ * Read this before using `passage.score` for anything other than the order it already produced.
780
+ *
781
+ * Not normalizing between scales is what makes RRF robust, and the price is that the number it
782
+ * emits is a function of **rank alone** — it never sees a similarity at all. Two consequences that
783
+ * bite in practice:
784
+ *
785
+ * - **It is not comparable to the score on a passage from {@link EmbeddingRetriever}**, even though
786
+ * it arrives in the same field, of the same type, from the same `Retriever` interface. There is no
787
+ * discriminator; a consumer holding a `Passage[]` cannot tell which kind of number it has. If you
788
+ * surface scores to a user or a model, know which retriever produced them.
789
+ * - **Its range is narrow, fixed, and says nothing about quality.** Every possible score lies in
790
+ * `[minWeight / (k + fetchTopK), Σ weights / (k + 1)]` — with the defaults (`k = 60`,
791
+ * `fetchTopK = 20`, two unweighted legs) that is `[0.0125, 0.0328]`. A perfect match and the
792
+ * least-bad member of a set of terrible matches both come back around `0.016`.
793
+ *
794
+ * The second point is why a relevance floor cannot live here. What the fused score actually measures
795
+ * is how strongly the legs *agree* — and agreement tracks nothing about correctness. When both legs
796
+ * return the same nearest nothing for an unanswerable query, they reinforce it to the ceiling, the
797
+ * same ceiling a unanimous correct answer gets. Measured on one corpus, the dense leg alone
798
+ * separated cleanly (weakest real answer 0.244, loudest absent-topic answer 0.143) while those same
799
+ * queries after fusion scored 0.032 and 0.033 — inverted. On another corpus the inversion may not
800
+ * appear; what does not change is that both numbers live in a band roughly one part in fifty wide,
801
+ * with no room for a threshold to mean anything.
802
+ *
803
+ * So: put the floor on the legs, with {@link EmbeddingRetrieverOptions.minScore}, and fuse what
804
+ * survives. `HybridRetriever` deliberately has no `minScore` of its own; a threshold option here
805
+ * would be an invitation to apply it to a number that cannot support one.
727
806
  */
728
807
  declare class HybridRetriever implements Retriever {
729
808
  private readonly retrievers;
@@ -789,4 +868,4 @@ interface RetrievalTool {
789
868
  */
790
869
  declare function createRetrievalTool(retriever: Retriever, options?: RetrievalToolOptions): RetrievalTool;
791
870
 
792
- export { type ChunkOptions, type ChunkRecord, EmbeddingRetriever, FilteredRetriever, HybridRetriever, type HybridRetrieverOptions, type IndexedDocument, type IngestChunksOptions, type IngestDocument, type IngestOptions, KeywordRetriever, type KeywordRetrieverOptions, LexicalRetriever, type LexicalVectorStore, MemoryVectorStore, type MetadataPatch, type PgClient, PgVectorStore, type PgVectorStoreOptions, type RedisSearchClient, RedisVectorSchemaMismatchError, RedisVectorStore, type RedisVectorStoreOptions, RerankingRetriever, type RerankingRetrieverOptions, type RetrievalTool, type RetrievalToolOptions, UnsafeRemovalError, type VectorRecord, type VectorSearchOptions, type VectorStore, applyMetadataPatch, chunkDocuments, chunkText, createRetrievalTool, documentIdOf, ingestChunks, ingestDocuments, isLexicalVectorStore };
871
+ 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, MemoryVectorStore, type MetadataPatch, type PgClient, PgVectorStore, type PgVectorStoreOptions, type RedisSearchClient, RedisVectorSchemaMismatchError, RedisVectorStore, type RedisVectorStoreOptions, RerankingRetriever, type RerankingRetrieverOptions, type RetrievalTool, type RetrievalToolOptions, UnsafeRemovalError, type VectorRecord, type VectorSearchOptions, type VectorStore, applyMetadataPatch, chunkDocuments, chunkText, createRetrievalTool, documentIdOf, ingestChunks, ingestDocuments, isLexicalVectorStore };
package/dist/index.js CHANGED
@@ -9,6 +9,9 @@ function chunkText(text, options = {}) {
9
9
  if (normalized.length === 0) {
10
10
  return [];
11
11
  }
12
+ if (options.separator !== void 0 && options.separator.length > 0) {
13
+ return chunkByRecord(normalized, chunkSize, options.separator);
14
+ }
12
15
  if (normalized.length <= chunkSize) {
13
16
  return [
14
17
  normalized
@@ -38,6 +41,40 @@ function chunkText(text, options = {}) {
38
41
  return chunks;
39
42
  }
40
43
  __name(chunkText, "chunkText");
44
+ function chunkByRecord(text, chunkSize, separator) {
45
+ const chunks = [];
46
+ let current = "";
47
+ const flush = /* @__PURE__ */ __name(() => {
48
+ if (current.length > 0) {
49
+ chunks.push(current);
50
+ current = "";
51
+ }
52
+ }, "flush");
53
+ for (const record of text.split(separator)) {
54
+ const trimmed = record.trim();
55
+ if (trimmed.length === 0) {
56
+ continue;
57
+ }
58
+ if (trimmed.length > chunkSize) {
59
+ flush();
60
+ chunks.push(...chunkText(trimmed, {
61
+ chunkSize,
62
+ overlap: 0
63
+ }));
64
+ continue;
65
+ }
66
+ const candidate = current.length > 0 ? `${current}${separator}${trimmed}` : trimmed;
67
+ if (candidate.length > chunkSize) {
68
+ flush();
69
+ current = trimmed;
70
+ } else {
71
+ current = candidate;
72
+ }
73
+ }
74
+ flush();
75
+ return chunks;
76
+ }
77
+ __name(chunkByRecord, "chunkByRecord");
41
78
 
42
79
  // src/vector-store.ts
43
80
  function isLexicalVectorStore(store) {
@@ -1295,9 +1332,11 @@ var EmbeddingRetriever = class {
1295
1332
  }
1296
1333
  embedder;
1297
1334
  store;
1298
- constructor(embedder, store) {
1335
+ options;
1336
+ constructor(embedder, store, options = {}) {
1299
1337
  this.embedder = embedder;
1300
1338
  this.store = store;
1339
+ this.options = options;
1301
1340
  }
1302
1341
  async retrieve(query, options = {}) {
1303
1342
  const [embedding] = await this.embedder.embed([
@@ -1306,12 +1345,14 @@ var EmbeddingRetriever = class {
1306
1345
  if (embedding === void 0) {
1307
1346
  return [];
1308
1347
  }
1309
- return this.store.search(embedding, {
1348
+ const passages = await this.store.search(embedding, {
1310
1349
  topK: options.topK ?? 5,
1311
1350
  ...options.filter !== void 0 ? {
1312
1351
  filter: options.filter
1313
1352
  } : {}
1314
1353
  });
1354
+ const { minScore } = this.options;
1355
+ return minScore === void 0 ? passages : passages.filter((passage) => passage.score >= minScore);
1315
1356
  }
1316
1357
  };
1317
1358