@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/README.md CHANGED
@@ -29,6 +29,48 @@ await ingestDocuments(
29
29
  const retriever = new EmbeddingRetriever(embedder, store);
30
30
  ```
31
31
 
32
+ ## Chunking record-shaped text
33
+
34
+ The default chunker is structure-blind: it fills a `chunkSize` window, breaks at the latest
35
+ paragraph → sentence → word boundary inside it, and carries `overlap` characters across the seam.
36
+ That is the right rule for prose — the two halves are still sentences, and the overlap rescues the
37
+ one that got cut.
38
+
39
+ It is the wrong rule for text whose boundaries mean something. A spreadsheet flattened to one
40
+ field-labelled record per row has real boundaries the chunker does not know exist, so a cut lands
41
+ mid-record routinely — and the half holding the row identifier and the half holding the value end up
42
+ in different chunks, where **neither can answer a question about that row**. Tell it where the
43
+ records are:
44
+
45
+ ```ts
46
+ await ingestDocuments([{ id: 'mvr-2026', text: rows.join('\n') }], {
47
+ embedder,
48
+ store,
49
+ chunkSize: 800,
50
+ separator: '\n', // one record per line; a record is never split across chunks
51
+ });
52
+ ```
53
+
54
+ Records are packed greedily up to `chunkSize` and never split — except one longer than `chunkSize`,
55
+ which has nowhere to go and is character-split as usual. That fallback is confined to the single
56
+ record that could not fit, rather than applied to the whole document.
57
+
58
+ **`overlap` is ignored when `separator` is set**, deliberately. Overlap exists to rescue a sentence a
59
+ boundary cut in half; a boundary that never falls inside a record has nothing to rescue, and carrying
60
+ the previous chunk's tail in would duplicate whole records into their neighbours — paying for the
61
+ same rows twice at embedding time and letting one row match from two places.
62
+
63
+ Measured on a 200-row, 15-column sheet ingested twice — same bytes, same embedder, same questions,
64
+ only the cut positions differing — blind chunking left 27% of records split, and on questions
65
+ targeting a field on the far side of such a cut, BM25 answered 54/66 (MRR 0.818) against 66/66
66
+ (MRR 1.000) with records kept whole. The dense leg scored 7/66 either way: 200 near-identical rows
67
+ produce near-identical vectors, so that leg cannot do row lookup at all and chunking does not change
68
+ it. The win is the lexical leg's, and it only shows up on questions whose answer sits far from the
69
+ row's rare identifying token — ask about a field next to the vehicle id and both arms score 1.000,
70
+ because BM25 finds that token whatever the chunking did around it.
71
+
72
+ Leave `separator` unset for prose. Nothing about the default path changed.
73
+
32
74
  ## Two ways to use it with the agent
33
75
 
34
76
  **Agentic (default) — the model decides when to search:**
@@ -171,6 +213,38 @@ const retriever = new HybridRetriever([
171
213
  ]);
172
214
  ```
173
215
 
216
+ ### The fused score is not a similarity — do not threshold it
217
+
218
+ Rank-based fusion is what makes RRF robust across incompatible scales, and the price is that its
219
+ output is a function of **rank alone**. It never sees a similarity. Two things follow:
220
+
221
+ - The score on a fused passage is **not comparable** to the score on one from `EmbeddingRetriever`,
222
+ even though it arrives in the same field, of the same type, through the same `Retriever` interface.
223
+ There is no discriminator — a consumer holding a `Passage[]` cannot tell which kind of number it
224
+ has. If you show scores to a user or a model, know which retriever produced them.
225
+ - Its range is narrow, fixed and quality-blind: every possible value lies in
226
+ `[minWeight / (k + fetchTopK), Σ weights / (k + 1)]`, which with the defaults is
227
+ `[0.0125, 0.0328]`. A perfect match and the least-bad member of a set of terrible matches both land
228
+ around `0.016`.
229
+
230
+ What a fused score measures is how strongly the legs *agree*, and agreement tracks nothing about
231
+ correctness: two legs returning the same nearest nothing reinforce it to the same ceiling a unanimous
232
+ correct answer gets. Measured on one corpus, the dense leg alone separated cleanly (weakest real
233
+ answer `0.244`, loudest absent-topic answer `0.143`) while those same queries after fusion scored
234
+ `0.032` and `0.033` — inverted.
235
+
236
+ So a relevance floor goes on the legs, never on the fusion:
237
+
238
+ ```ts
239
+ const retriever = new HybridRetriever([
240
+ new EmbeddingRetriever(embedder, store, { minScore: 0.2 }), // ← floor here
241
+ new LexicalRetriever(store),
242
+ ]);
243
+ ```
244
+
245
+ `HybridRetriever` has no `minScore` of its own on purpose: the option would be an invitation to
246
+ apply one to a number that cannot support it.
247
+
174
248
  Nothing else changes: no chunks to hand the retriever, no state in the process, no deletes to mirror.
175
249
  This is the only option that works when **ingestion and search run in different processes** — a
176
250
  worker that ingests and an API pod that searches. The API pod never sees the chunks, so an in-process
@@ -236,9 +310,13 @@ library upgrade. The interface is open, so it can be added later without changin
236
310
 
237
311
  ## API
238
312
 
239
- - `chunkText(text, { chunkSize?, overlap? })` — overlapping, boundary-aware chunks.
240
- - `ingestDocuments(docs, { embedder, store, chunkSize?, overlap? })` — chunk → embed → upsert.
241
- - `EmbeddingRetriever(embedder, store)` — the core `Retriever` from an embedder + vector store.
313
+ - `chunkText(text, { chunkSize?, overlap?, separator? })` — overlapping, boundary-aware chunks;
314
+ `separator` packs whole records instead and never splits one (`overlap` is ignored in that mode).
315
+ - `ingestDocuments(docs, { embedder, store, chunkSize?, overlap?, separator? })` — chunk → embed →
316
+ upsert.
317
+ - `EmbeddingRetriever(embedder, store, { minScore? })` — the core `Retriever` from an embedder +
318
+ vector store. `minScore` drops passages below a similarity floor, so an unanswerable query can
319
+ return nothing.
242
320
  - `LexicalRetriever(store)` — the core `Retriever` from a store's own full-text index (BM25).
243
321
  - `KeywordRetriever` — in-process BM25 over chunks you feed it; the single-process lexical half:
244
322
  `add(chunks)`, `remove(documentId)`, `clear()`, `size`.
package/dist/index.cjs CHANGED
@@ -51,6 +51,9 @@ function chunkText(text, options = {}) {
51
51
  if (normalized.length === 0) {
52
52
  return [];
53
53
  }
54
+ if (options.separator !== void 0 && options.separator.length > 0) {
55
+ return chunkByRecord(normalized, chunkSize, options.separator);
56
+ }
54
57
  if (normalized.length <= chunkSize) {
55
58
  return [
56
59
  normalized
@@ -80,6 +83,40 @@ function chunkText(text, options = {}) {
80
83
  return chunks;
81
84
  }
82
85
  __name(chunkText, "chunkText");
86
+ function chunkByRecord(text, chunkSize, separator) {
87
+ const chunks = [];
88
+ let current = "";
89
+ const flush = /* @__PURE__ */ __name(() => {
90
+ if (current.length > 0) {
91
+ chunks.push(current);
92
+ current = "";
93
+ }
94
+ }, "flush");
95
+ for (const record of text.split(separator)) {
96
+ const trimmed = record.trim();
97
+ if (trimmed.length === 0) {
98
+ continue;
99
+ }
100
+ if (trimmed.length > chunkSize) {
101
+ flush();
102
+ chunks.push(...chunkText(trimmed, {
103
+ chunkSize,
104
+ overlap: 0
105
+ }));
106
+ continue;
107
+ }
108
+ const candidate = current.length > 0 ? `${current}${separator}${trimmed}` : trimmed;
109
+ if (candidate.length > chunkSize) {
110
+ flush();
111
+ current = trimmed;
112
+ } else {
113
+ current = candidate;
114
+ }
115
+ }
116
+ flush();
117
+ return chunks;
118
+ }
119
+ __name(chunkByRecord, "chunkByRecord");
83
120
 
84
121
  // src/vector-store.ts
85
122
  function isLexicalVectorStore(store) {
@@ -1337,9 +1374,11 @@ var EmbeddingRetriever = class {
1337
1374
  }
1338
1375
  embedder;
1339
1376
  store;
1340
- constructor(embedder, store) {
1377
+ options;
1378
+ constructor(embedder, store, options = {}) {
1341
1379
  this.embedder = embedder;
1342
1380
  this.store = store;
1381
+ this.options = options;
1343
1382
  }
1344
1383
  async retrieve(query, options = {}) {
1345
1384
  const [embedding] = await this.embedder.embed([
@@ -1348,12 +1387,14 @@ var EmbeddingRetriever = class {
1348
1387
  if (embedding === void 0) {
1349
1388
  return [];
1350
1389
  }
1351
- return this.store.search(embedding, {
1390
+ const passages = await this.store.search(embedding, {
1352
1391
  topK: options.topK ?? 5,
1353
1392
  ...options.filter !== void 0 ? {
1354
1393
  filter: options.filter
1355
1394
  } : {}
1356
1395
  });
1396
+ const { minScore } = this.options;
1397
+ return minScore === void 0 ? passages : passages.filter((passage) => passage.score >= minScore);
1357
1398
  }
1358
1399
  };
1359
1400