@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 +81 -3
- package/dist/index.cjs +43 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +81 -2
- package/dist/index.d.ts +81 -2
- package/dist/index.js +43 -2
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
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
|
-
|
|
241
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|