@dudousxd/nestjs-agent-rag 0.12.0 → 0.13.1

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/NOTICE ADDED
@@ -0,0 +1,31 @@
1
+ @dudousxd/nestjs-agent-rag
2
+ Copyright (c) Davide Carvalho. Licensed under the MIT License.
3
+
4
+ The tree navigation retrieval in src/tree/ (buildDocumentTree, TreeNavigationRetriever,
5
+ TwoStageRetriever, navigate_document) is an independent TypeScript implementation of the method of
6
+ PageIndex ("vectorless", reasoning-based RAG: an LLM-navigated table-of-contents tree per document).
7
+ Its tree-search and document-selection prompts are adapted from PageIndex's.
8
+
9
+ PageIndex — https://github.com/VectifyAI/PageIndex
10
+
11
+ MIT License
12
+
13
+ Copyright (c) 2025 Vectify AI
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
package/README.md CHANGED
@@ -439,6 +439,90 @@ Had `PgVectorStore` simply grown `searchText`, every `isLexicalVectorStore(store
439
439
  started using it against an unindexed table on upgrade. Constructing `PgLexicalVectorStore` is the
440
440
  opt-in; the index is a migration you run and watch (`schemaStatements()` prints it).
441
441
 
442
+ ## Tree navigation retrieval (PageIndex-style)
443
+
444
+ Chunk search ranks passages; on a **long structured document** (a regulation, an RFP, a contract, a
445
+ 10-K, a manual) it usually finds the right document and then loses the right page among hundreds of
446
+ look-alike clause or financial-statement chunks. Tree navigation ("vectorless", reasoning-based, the
447
+ method of [PageIndex](https://github.com/VectifyAI/PageIndex)) builds a table of contents per long
448
+ document at ingestion — sections with page ranges and summaries — and at query time an LLM reads that
449
+ outline and walks to the sections that answer, returning their text with the path it took.
450
+
451
+ Measured on Flippy (149 questions, same answering model): on long GovCon regulations it beat hybrid
452
+ dense+lexical search by **+24 points**, on FinanceBench 10-Ks by **+30**, and gave nothing on short
453
+ wiki notes — at seconds of latency and LLM spend per query instead of 0.3 s. Hence the recommended
454
+ shape: **hybrid first, tree navigation only inside the top long documents**.
455
+
456
+ ```ts
457
+ import {
458
+ PgDocumentTreeStore, TreeNavigationRetriever, TwoStageRetriever, HybridRetriever,
459
+ createNavigateDocumentTool, createRetrievalTool, indexDocumentTree, openAiChatTreeLlm,
460
+ } from '@dudousxd/nestjs-agent-rag';
461
+
462
+ const trees = new PgDocumentTreeStore(db); // pg Pool, Drizzle db, postgres.js sql — like PgVectorStore
463
+ await trees.ensureSchema(); // or copy trees.schemaStatements() into a migration
464
+ const llm = openAiChatTreeLlm({ model: 'gpt-4.1-mini', apiKey }); // or treeLlmFromModelProvider(provider)
465
+
466
+ // At upload, next to chunk + embed: only long documents get a tree (default ≥ 20 pages or ≥ 60k chars).
467
+ await indexDocumentTree(
468
+ { pages, title: file.name }, // or { sections: [{ title, level, text, page }] }, + outline
469
+ { store: trees, documentId: file.id, llm, metadata: { tenant }, source: file.name },
470
+ );
471
+
472
+ const navigator = new TreeNavigationRetriever({ store: trees, llm });
473
+ // Hybrid picks documents; long ones among the top 3 get navigated (at most 2), short ones keep chunks.
474
+ const retriever = new TwoStageRetriever(hybrid, navigator, { navigateDocuments: 2 });
475
+ // …or let the agent decide: search first, then open a long document.
476
+ providers: [
477
+ provideAgentTool(createRetrievalTool(hybrid)),
478
+ provideAgentTool(createNavigateDocumentTool(navigator, { filter: (ctx) => ({ tenant: ctx.actor.tenantRef }) })),
479
+ ];
480
+ ```
481
+
482
+ **Building a tree** (`buildDocumentTree` / `indexDocumentTree`) takes the cheapest structure that works
483
+ and never loops:
484
+
485
+ 1. the document's own `outline` (PDF bookmarks), when you pass one;
486
+ 2. section titles/levels (`sections` input) or headings found in the text — markdown headers and the
487
+ keyword/numbered headings of regulations and filings (`PART 52`, `Subpart 19.8`, `Item 7.`,
488
+ `52.236–5 Material and Workmanship.`), with running page headers and table-of-contents listings
489
+ dropped;
490
+ 3. only when there is no usable structure, the LLM proposes the sections window by window — and only
491
+ if the whole document fits `maxStructureCalls`;
492
+ 4. otherwise, or on any failure, page groups (`Pages 21–25: <first line>`), grouped again when wide.
493
+
494
+ Summaries go top-down (top levels matter most for navigation) until the **hard per-document budget**
495
+ runs out (`budget: { maxCalls, maxInputTokens, maxOutputTokens, timeoutMs, callTimeoutMs }`, default 60
496
+ calls / 400k input tokens / 5 min); the rest summarize themselves with their opening text. Every call
497
+ is checked against the budget before it is made and nothing is retried, so a document the LLM cannot
498
+ structure costs a bounded amount and still gets a usable tree (`tree.stats` records calls, tokens and
499
+ `fallbackReason`). Builds are deterministic for a deterministic model and fingerprinted: re-indexing an
500
+ unchanged document costs one store read and no LLM call, a changed one re-summarizes only the sections
501
+ whose text changed (`reusedSummaries`), and `cachedTreeLlm(llm, cache)` makes repeated prompts free.
502
+
503
+ **Navigating** (`TreeNavigationRetriever`): a tree whose outline fits `singlePassMaxChars` (default
504
+ 40k chars) is navigated in one call that sees the whole outline; a larger one is walked level by level
505
+ (a beam of `beamWidth` nodes, at most `maxSteps` levels), the model choosing to `expand` or `read` each
506
+ candidate. Each navigation has its own hard budget (default 6 calls / 60k input tokens / 60 s); running
507
+ out returns what was chosen so far. `navigate()` / `search()` return the audit trail with the passages
508
+ — every step's candidates, choices and stated reasoning, and each node's path — and every passage
509
+ carries `documentId`, `nodeId`, `title`, `pageStart`/`pageEnd` and `path` in its metadata. As a plain
510
+ `Retriever` it navigates every tree passing `filter` (the LLM picks `maxDocuments` of them from their
511
+ descriptions when there are more).
512
+
513
+ **Access control.** `DocumentTreeStore` filters (`get`, `getMany`, `list`) have exactly the vector
514
+ stores' semantics — scalar equality, array match-any, empty-array deny — and the navigator, the
515
+ two-stage retriever and the tool pass the caller's filter to every tree read.
516
+
517
+ **LLM.** `TreeLlm` is one prompt in, one text reply out: `openAiChatTreeLlm` (any OpenAI-compatible
518
+ `/chat/completions`; pass `body: { enable_thinking: false }` or similar to keep a reasoning model from
519
+ spending the reply budget on thoughts), `treeLlmFromModelProvider` (the agent's `ModelProvider`), or
520
+ your own function. Each request also carries its `task` as data, which is what `keywordTreeLlm()` — a
521
+ deterministic, model-free stand-in for tests — answers from.
522
+
523
+ PageIndex (© 2025 Vectify AI, MIT) is the method and the prompts' origin; this is an independent
524
+ TypeScript implementation. See [NOTICE](./NOTICE).
525
+
442
526
  ## API
443
527
 
444
528
  - `chunkText(text, { chunkSize?, overlap?, separator? })` — overlapping, boundary-aware chunks;
@@ -464,7 +548,24 @@ opt-in; the index is a migration you run and watch (`schemaStatements()` prints
464
548
  - `instrumentRetriever(retriever, { describe?, collection? })` — wrap any `Retriever` so each
465
549
  retrieval publishes on `aviary:rag:retrieval` (`RAG_RETRIEVAL_CHANNEL`).
466
550
  - `openAiEmbeddings({ model, baseUrl?, apiKey?, dimensions?, batchSize?, maxInputChars?, headers?,
467
- timeoutMs?, onUsage?, fetch? })` — an `EmbeddingProvider` over any OpenAI-compatible `/embeddings`.
551
+ timeoutMs?, onUsage?, onWarn?, fetch? })` — an `EmbeddingProvider` over any OpenAI-compatible
552
+ `/embeddings`. A batch the server refuses as too large (413, or TEI's `batch size N > maximum
553
+ allowed batch size M`; see `isBatchTooLarge`) is split in halves and retried, and later requests to
554
+ that server and model use the size that worked; each split is reported through `onWarn`.
555
+ - `buildDocumentTree(input, { documentId, llm?, maxDepth?, budget?, summaries?, describe?,
556
+ detectHeadings?, llmStructure?, maxStructureCalls?, groupSize?, maxLeafUnits?, previous?, metadata?,
557
+ source? })` — a document's navigation tree (`{ tree, units, unchanged }`). `indexDocumentTree(input,
558
+ { store, minUnits?, minChars?, …build options })` builds, stores and skips short/unchanged documents.
559
+ - `DocumentTreeStore` (SPI), `MemoryDocumentTreeStore`, `PgDocumentTreeStore(client, { table?,
560
+ unitsTable? })` with `schemaStatements()` / `ensureSchema()`.
561
+ - `TreeNavigationRetriever({ store, llm, singlePassMaxChars?, beamWidth?, maxSteps?, maxNodes?, budget?,
562
+ maxPassageChars?, maxContextChars?, maxDocuments?, concurrency? })` — `retrieve`, `search` (with the
563
+ trail), `navigate(query, documentIds, { filter?, budget? })`.
564
+ - `TwoStageRetriever(firstStage, navigator, { fetchTopK?, considerDocuments?, navigateDocuments?,
565
+ budget?, keepFirstStageChunks?, documentIdOf?, onNavigation? })`.
566
+ - `createNavigateDocumentTool(navigator, { name?, description?, filter?, budget? })` — the
567
+ `navigate_document({ documentId, question })` tool.
568
+ - `openAiChatTreeLlm`, `treeLlmFromModelProvider`, `cachedTreeLlm`, `keywordTreeLlm` — `TreeLlm`s.
468
569
  - `HttpReranker({ url, model?, apiKey?, format?, headers?, timeoutMs?, fetch? })` — a `Reranker` over
469
570
  a Cohere/Jina/Voyage/TEI-style `/rerank` endpoint.
470
571