@dudousxd/nestjs-agent-rag 0.7.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:**
@@ -268,8 +310,10 @@ library upgrade. The interface is open, so it can be added later without changin
268
310
 
269
311
  ## API
270
312
 
271
- - `chunkText(text, { chunkSize?, overlap? })` — overlapping, boundary-aware chunks.
272
- - `ingestDocuments(docs, { embedder, store, chunkSize?, overlap? })` — chunk → embed → upsert.
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.
273
317
  - `EmbeddingRetriever(embedder, store, { minScore? })` — the core `Retriever` from an embedder +
274
318
  vector store. `minScore` drops passages below a similarity floor, so an unanswerable query can
275
319
  return nothing.
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) {