@dudousxd/nestjs-agent-rag 0.4.0 → 0.5.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
@@ -1,8 +1,9 @@
1
1
  # @dudousxd/nestjs-agent-rag
2
2
 
3
3
  Retrieval-Augmented Generation for [`@dudousxd/nestjs-agent`](../nestjs). Chunking, ingestion, an
4
- embedding-backed `Retriever`, and two vector stores — in-memory and pgvector. Framework-agnostic:
5
- it depends only on `-core`, and you bring your own embedding model and (for pgvector) Postgres driver.
4
+ embedding-backed `Retriever`, and three vector stores — in-memory, pgvector and RediSearch.
5
+ Framework-agnostic: it depends only on `-core`, and you bring your own embedding model and (for the
6
+ pgvector / RediSearch stores) database driver.
6
7
 
7
8
  ## Install
8
9
 
@@ -69,10 +70,128 @@ const store = new PgVectorStore(
69
70
  await store.ensureSchema(); // CREATE EXTENSION vector + table + HNSW cosine index
70
71
  ```
71
72
 
73
+ ## RediSearch store
74
+
75
+ `RedisVectorStore` takes anything that can send a raw command — `node-redis`, or `ioredis` wrapped —
76
+ so this package pulls in no Redis driver either. Needs the RediSearch module (Redis Stack / Redis 8+):
77
+
78
+ ```ts
79
+ import { createClient } from 'redis';
80
+ import { RedisVectorStore } from '@dudousxd/nestjs-agent-rag';
81
+
82
+ const client = createClient({ url: process.env.REDIS_URL });
83
+ await client.connect();
84
+
85
+ const store = new RedisVectorStore(
86
+ { sendCommand: (args) => client.sendCommand(args) },
87
+ { dimensions: 1536, filterableFields: ['audience'] },
88
+ );
89
+ await store.ensureSchema(); // FT.CREATE: TEXT + TAG + HNSW cosine vector field
90
+ ```
91
+
92
+ `filterableFields` declares which metadata keys become filterable `TAG` fields — RediSearch needs them
93
+ up front, so only those keys can be used in a `filter`. This store also does lexical search; see below.
94
+
95
+ `ensureSchema()` belongs at boot. It creates the index if missing — and if the index already exists,
96
+ it compares it against your configuration rather than assuming they agree. A `filterableFields` entry
97
+ added after the index was created is repaired in place with `FT.ALTER … SCHEMA ADD` (chunks written
98
+ before the repair only become filterable on that key once re-ingested). A changed `dimensions` — a
99
+ model swap — can't be repaired without a full reindex, so it throws `RedisVectorSchemaMismatchError`
100
+ instead of silently leaving the index on the old width.
101
+
102
+ ## Hybrid search (dense + lexical)
103
+
104
+ `HybridRetriever` fuses several retrievers with Reciprocal Rank Fusion. RRF is **rank**-based, so the
105
+ two halves need no common score scale — cosine similarity and BM25 fuse as they are.
106
+
107
+ There are two ways to get the lexical half, and which one you want is decided by your deployment, not
108
+ your taste.
109
+
110
+ **Store-backed (the default for anything multi-process).** `RedisVectorStore` already declares the
111
+ chunk text as a RediSearch `TEXT` field, so the lexical index is *already there* — `LexicalRetriever`
112
+ just queries it with BM25:
113
+
114
+ ```ts
115
+ import { EmbeddingRetriever, HybridRetriever, LexicalRetriever } from '@dudousxd/nestjs-agent-rag';
116
+
117
+ const retriever = new HybridRetriever([
118
+ new EmbeddingRetriever(embedder, store),
119
+ new LexicalRetriever(store), // ← same store, no second index
120
+ ]);
121
+ ```
122
+
123
+ Nothing else changes: no chunks to hand the retriever, no state in the process, no deletes to mirror.
124
+ This is the only option that works when **ingestion and search run in different processes** — a
125
+ worker that ingests and an API pod that searches. The API pod never sees the chunks, so an in-process
126
+ index there would be permanently empty (or kept warm by scanning the corpus into JS heap on every
127
+ pod, which is the workaround this exists to delete). It also means a document is lexically findable
128
+ the moment it is upserted, with no refresh window.
129
+
130
+ `searchText` applies `filter` with exactly the same semantics as `search` — array = OR, **empty array
131
+ = deny** — so a lexical hit can never escape a filter a vector hit would have respected. That matters
132
+ when the filter is carrying capability tokens rather than a hint.
133
+
134
+ A query is reduced to letter/digit/`_` terms before it is sent, so RediSearch query syntax in user
135
+ input (`*`, `@field:{…}`, `|`, `-`, `=>[KNN …]`) is inert rather than escaped-but-live. A query with
136
+ no searchable terms left — empty, whitespace, or pure punctuation — returns `[]` rather than matching
137
+ the corpus.
138
+
139
+ **In-process (`KeywordRetriever`).** Still the right answer for `MemoryVectorStore`, for a store with
140
+ no lexical capability, and for single-process deployments where you control both halves. It is a full
141
+ BM25 implementation over chunks you feed it, so chunk once and give the SAME chunks to both halves —
142
+ their ids line up and `HybridRetriever` can fuse the rankings:
143
+
144
+ ```ts
145
+ const chunks = chunkDocuments(docs);
146
+ await ingestChunks(chunks, { embedder, store });
147
+ keyword.add(chunks); // its own copy of the corpus, in this process only
148
+
149
+ const retriever = new HybridRetriever([new EmbeddingRetriever(embedder, store), keyword]);
150
+ ```
151
+
152
+ Because that is a second index, **deletes have to hit both**, and only this process can see it.
153
+ `KeywordRetriever` keeps its own copy of each chunk's text, so a document dropped from the vector
154
+ store alone stays fully retrievable — `retrieve` will keep returning the removed passage:
155
+
156
+ ```ts
157
+ await store.remove(documentId);
158
+ keyword.remove(documentId); // ← the other half. Same document id; chunk ids collapse for you.
159
+ ```
160
+
161
+ `KeywordRetriever` also exposes `clear()` and `size` for rebuilding the index wholesale. None of this
162
+ bookkeeping exists on the store-backed path above, which is the point of it.
163
+
164
+ Ask the store which it supports rather than hard-coding:
165
+
166
+ ```ts
167
+ import { isLexicalVectorStore } from '@dudousxd/nestjs-agent-rag';
168
+
169
+ const lexical = isLexicalVectorStore(store) ? new LexicalRetriever(store) : keywordRetriever;
170
+ ```
171
+
172
+ ### Which stores implement `LexicalVectorStore`
173
+
174
+ | Store | Lexical | Why |
175
+ | --- | --- | --- |
176
+ | `RedisVectorStore` | ✅ | RediSearch indexes the chunk text as `TEXT`; BM25 is already there |
177
+ | `PgVectorStore` | ❌ | the chunks table has no `tsvector`/GIN index, and adding one is a migration |
178
+ | `MemoryVectorStore` | ❌ | single-process by definition — use `KeywordRetriever` |
179
+
180
+ Postgres is a deliberate omission. Lexical search there needs new DDL (a GIN index), and `CREATE
181
+ INDEX` in `ensureSchema` would take a write lock on an already-populated chunks table at boot; it also
182
+ forces a text-search-configuration choice that must match between index and query or Postgres silently
183
+ falls back to a sequential scan. That wants a migration you run and watch, not a side effect of a
184
+ library upgrade. The interface is open, so it can be added later without changing anything else.
185
+
72
186
  ## API
73
187
 
74
188
  - `chunkText(text, { chunkSize?, overlap? })` — overlapping, boundary-aware chunks.
75
189
  - `ingestDocuments(docs, { embedder, store, chunkSize?, overlap? })` — chunk → embed → upsert.
76
190
  - `EmbeddingRetriever(embedder, store)` — the core `Retriever` from an embedder + vector store.
77
- - `MemoryVectorStore` / `PgVectorStore` — `VectorStore` adapters.
191
+ - `LexicalRetriever(store)` — the core `Retriever` from a store's own full-text index (BM25).
192
+ - `KeywordRetriever` — in-process BM25 over chunks you feed it; the single-process lexical half:
193
+ `add(chunks)`, `remove(documentId)`, `clear()`, `size`.
194
+ - `HybridRetriever(retrievers, { k?, fetchTopK?, weights? })` — RRF fusion of the two.
195
+ - `MemoryVectorStore` / `PgVectorStore` / `RedisVectorStore` — `VectorStore` adapters.
196
+ - `isLexicalVectorStore(store)` — type guard for the optional `LexicalVectorStore` capability.
78
197
  - `createRetrievalTool(retriever, { name?, description?, topK? })` — the agentic-retrieval tool.
package/dist/index.cjs CHANGED
@@ -25,8 +25,10 @@ __export(index_exports, {
25
25
  FilteredRetriever: () => FilteredRetriever,
26
26
  HybridRetriever: () => HybridRetriever,
27
27
  KeywordRetriever: () => KeywordRetriever,
28
+ LexicalRetriever: () => LexicalRetriever,
28
29
  MemoryVectorStore: () => MemoryVectorStore,
29
30
  PgVectorStore: () => PgVectorStore,
31
+ RedisVectorSchemaMismatchError: () => RedisVectorSchemaMismatchError,
30
32
  RedisVectorStore: () => RedisVectorStore,
31
33
  RerankingRetriever: () => RerankingRetriever,
32
34
  chunkDocuments: () => chunkDocuments,
@@ -34,7 +36,8 @@ __export(index_exports, {
34
36
  createRetrievalTool: () => createRetrievalTool,
35
37
  documentIdOf: () => documentIdOf,
36
38
  ingestChunks: () => ingestChunks,
37
- ingestDocuments: () => ingestDocuments
39
+ ingestDocuments: () => ingestDocuments,
40
+ isLexicalVectorStore: () => isLexicalVectorStore
38
41
  });
39
42
  module.exports = __toCommonJS(index_exports);
40
43
 
@@ -77,6 +80,10 @@ function chunkText(text, options = {}) {
77
80
  __name(chunkText, "chunkText");
78
81
 
79
82
  // src/vector-store.ts
83
+ function isLexicalVectorStore(store) {
84
+ return typeof store.searchText === "function";
85
+ }
86
+ __name(isLexicalVectorStore, "isLexicalVectorStore");
80
87
  function documentIdOf(chunkId) {
81
88
  return chunkId.replace(/#\d+$/, "");
82
89
  }
@@ -306,6 +313,19 @@ function toVectorLiteral(embedding) {
306
313
  __name(toVectorLiteral, "toVectorLiteral");
307
314
 
308
315
  // src/redis-vector-store.ts
316
+ var RedisVectorSchemaMismatchError = class extends Error {
317
+ static {
318
+ __name(this, "RedisVectorSchemaMismatchError");
319
+ }
320
+ index;
321
+ field;
322
+ expected;
323
+ actual;
324
+ constructor(index, field, expected, actual, remedy) {
325
+ super(`RediSearch index "${index}" disagrees with this RedisVectorStore's configuration: field "${field}" is ${actual}, expected ${expected}. ${remedy}`), this.index = index, this.field = field, this.expected = expected, this.actual = actual;
326
+ this.name = "RedisVectorSchemaMismatchError";
327
+ }
328
+ };
309
329
  var RedisVectorStore = class {
310
330
  static {
311
331
  __name(this, "RedisVectorStore");
@@ -315,23 +335,83 @@ var RedisVectorStore = class {
315
335
  prefix;
316
336
  dimensions;
317
337
  filterableFields;
338
+ lexicalScorer;
318
339
  constructor(client, options = {}) {
319
340
  this.client = client;
320
341
  this.index = options.index ?? "agent_rag_idx";
321
342
  this.prefix = options.prefix ?? "agent_rag:";
322
343
  this.dimensions = options.dimensions ?? 1536;
323
344
  this.filterableFields = options.filterableFields ?? [];
345
+ this.lexicalScorer = options.lexicalScorer ?? "BM25";
324
346
  }
325
- /** Idempotent: create the HNSW/cosine index if `FT.INFO` says it doesn't exist yet. */
347
+ /**
348
+ * Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
349
+ * and if it DOES exist, compare what's there against this store's configuration instead of
350
+ * assuming they agree. An index that outlives a config change is the normal case (schemas live in
351
+ * Redis, config lives in a redeployed process), and the two failure modes it produces are both
352
+ * invisible at runtime: a `filterableFields` entry added later has no `meta_*` TAG, so every search
353
+ * filtering on it matches *nothing*; a changed `dimensions` leaves the index on the old width, so
354
+ * writes are rejected or KNN ranks garbage. Neither raises anything on its own.
355
+ *
356
+ * The two are repaired differently, because only one of them CAN be repaired:
357
+ *
358
+ * - **A missing filterable TAG is additive** → `FT.ALTER … SCHEMA ADD`. Nothing that already works
359
+ * changes, so doing it automatically is safe. Note the chunks written *before* the alter carry no
360
+ * `meta_*` hash field at all (`upsert` only writes the fields configured at the time), so they
361
+ * only become filterable on that key once they're re-ingested — the schema is repaired here, the
362
+ * backfill is the host's call.
363
+ * - **A dimension (or field-type) change is not repairable** → {@link RedisVectorSchemaMismatchError}.
364
+ * It needs a drop + full reindex, which would destroy the host's corpus; failing loudly at boot is
365
+ * the only honest option.
366
+ *
367
+ * If `FT.INFO` comes back in a shape this parser doesn't recognise, no drift is inferred — an
368
+ * unreadable reply must not be turned into a false alarm that blocks a boot.
369
+ */
326
370
  async ensureSchema() {
371
+ let info;
327
372
  try {
328
- await this.client.sendCommand([
373
+ info = await this.client.sendCommand([
329
374
  "FT.INFO",
330
375
  this.index
331
376
  ]);
332
- return;
333
377
  } catch {
378
+ await this.createIndex();
379
+ return;
380
+ }
381
+ await this.reconcileSchema(info);
382
+ }
383
+ /** Repair what can be repaired in place; throw on what can't. */
384
+ async reconcileSchema(info) {
385
+ const attributes = parseIndexAttributes(info);
386
+ if (attributes.size === 0) {
387
+ return;
388
+ }
389
+ const vector = attributes.get("embedding");
390
+ if (vector?.dimensions !== void 0 && vector.dimensions !== this.dimensions) {
391
+ throw new RedisVectorSchemaMismatchError(this.index, "embedding", `DIM ${this.dimensions}`, `DIM ${vector.dimensions}`, `A vector field's width is fixed at FT.CREATE and every stored embedding has the old one, so this cannot be altered in place: drop the index (FT.DROPINDEX ${this.index} DD) and re-ingest with the new embedding model.`);
392
+ }
393
+ const missing = [];
394
+ for (const field of this.filterableFields) {
395
+ const name = `meta_${field}`;
396
+ const existing = attributes.get(name);
397
+ if (existing === void 0) {
398
+ missing.push(name);
399
+ } else if (existing.type !== "TAG") {
400
+ throw new RedisVectorSchemaMismatchError(this.index, name, "TAG", existing.type, `A field's type cannot be changed in place: drop the index (FT.DROPINDEX ${this.index} DD) and re-ingest, or rename the metadata key.`);
401
+ }
402
+ }
403
+ for (const name of missing) {
404
+ await this.client.sendCommand([
405
+ "FT.ALTER",
406
+ this.index,
407
+ "SCHEMA",
408
+ "ADD",
409
+ name,
410
+ "TAG"
411
+ ]);
334
412
  }
413
+ }
414
+ async createIndex() {
335
415
  const schema = [
336
416
  "text",
337
417
  "TEXT",
@@ -484,7 +564,100 @@ var RedisVectorStore = class {
484
564
  ]);
485
565
  return parseSearchReply(reply, this.prefix);
486
566
  }
567
+ /**
568
+ * Lexical (BM25) search over the same index `search` uses — the {@link LexicalVectorStore} half of
569
+ * hybrid retrieval, served by RediSearch's own keyword index rather than an in-process copy of the
570
+ * corpus. `options.filter` is ANDed onto the text clause with the identical TAG semantics as
571
+ * `search` (array = OR, empty array = deny), so a lexical hit can never escape a filter a vector
572
+ * hit would have respected.
573
+ *
574
+ * A query with no searchable terms — empty, whitespace, or pure punctuation — returns `[]`. That is
575
+ * deliberate: RediSearch's `*` would match the entire corpus, which is the opposite of what a
576
+ * relevance query with nothing to be relevant to should do.
577
+ */
578
+ async searchText(query, options) {
579
+ if (filterMatchesNothing(options.filter)) {
580
+ return [];
581
+ }
582
+ const terms = tokenizeQuery(query);
583
+ if (terms.length === 0) {
584
+ return [];
585
+ }
586
+ const filterQuery = buildFilter(options.filter);
587
+ const textQuery = `@text:(${terms.join("|")})`;
588
+ const reply = await this.client.sendCommand([
589
+ "FT.SEARCH",
590
+ this.index,
591
+ filterQuery === "*" ? textQuery : `(${filterQuery} ${textQuery})`,
592
+ "SCORER",
593
+ this.lexicalScorer,
594
+ "WITHSCORES",
595
+ "RETURN",
596
+ "3",
597
+ "text",
598
+ "source",
599
+ "metadata_json",
600
+ "DIALECT",
601
+ "2",
602
+ "LIMIT",
603
+ "0",
604
+ String(options.topK)
605
+ ]);
606
+ return parseSearchReply(reply, this.prefix, "lexical");
607
+ }
487
608
  };
609
+ function parseIndexAttributes(reply) {
610
+ const attributes = /* @__PURE__ */ new Map();
611
+ for (const entry of readInfoField(reply, "attributes")) {
612
+ const fields = readKeyValues(entry);
613
+ const name = fields.attribute ?? fields.identifier;
614
+ if (name === void 0) {
615
+ continue;
616
+ }
617
+ const dim = Number(fields.dim);
618
+ attributes.set(name, {
619
+ type: (fields.type ?? "").toUpperCase(),
620
+ ...fields.dim !== void 0 && Number.isFinite(dim) ? {
621
+ dimensions: dim
622
+ } : {}
623
+ });
624
+ }
625
+ return attributes;
626
+ }
627
+ __name(parseIndexAttributes, "parseIndexAttributes");
628
+ function readInfoField(reply, key) {
629
+ if (Array.isArray(reply)) {
630
+ for (let index = 0; index + 1 < reply.length; index += 2) {
631
+ if (toStr(reply[index]) === key) {
632
+ const value = reply[index + 1];
633
+ return Array.isArray(value) ? value : [];
634
+ }
635
+ }
636
+ return [];
637
+ }
638
+ if (typeof reply === "object" && reply !== null && key in reply) {
639
+ const value = reply[key];
640
+ return Array.isArray(value) ? value : [];
641
+ }
642
+ return [];
643
+ }
644
+ __name(readInfoField, "readInfoField");
645
+ function readKeyValues(entry) {
646
+ const fields = {};
647
+ if (Array.isArray(entry)) {
648
+ for (let index = 0; index + 1 < entry.length; index += 2) {
649
+ fields[toStr(entry[index]).toLowerCase()] = toStr(entry[index + 1]);
650
+ }
651
+ return fields;
652
+ }
653
+ if (typeof entry === "object" && entry !== null) {
654
+ for (const [key, value] of Object.entries(entry)) {
655
+ fields[key.toLowerCase()] = toStr(value);
656
+ }
657
+ }
658
+ return fields;
659
+ }
660
+ __name(readKeyValues, "readKeyValues");
488
661
  function encodeVector(embedding) {
489
662
  return Buffer.from(new Float32Array(embedding).buffer);
490
663
  }
@@ -510,6 +683,10 @@ function filterMatchesNothing(filter) {
510
683
  return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
511
684
  }
512
685
  __name(filterMatchesNothing, "filterMatchesNothing");
686
+ function tokenizeQuery(query) {
687
+ return query.match(/[\p{L}\p{N}_]+/gu) ?? [];
688
+ }
689
+ __name(tokenizeQuery, "tokenizeQuery");
513
690
  function escapeTag(value) {
514
691
  return value.replace(/[^a-zA-Z0-9_]/g, (char) => `\\${char}`);
515
692
  }
@@ -546,17 +723,19 @@ function toStr(value) {
546
723
  return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
547
724
  }
548
725
  __name(toStr, "toStr");
549
- function parseSearchReply(reply, prefix) {
726
+ function parseSearchReply(reply, prefix, scoreSource = "vector") {
727
+ const stride = scoreSource === "lexical" ? 3 : 2;
550
728
  if (Array.isArray(reply)) {
551
729
  const passages = [];
552
- for (let index = 1; index + 1 < reply.length; index += 2) {
553
- const rawFields = reply[index + 1];
730
+ for (let index = 1; index + stride - 1 < reply.length; index += stride) {
731
+ const rawFields = reply[index + stride - 1];
554
732
  if (Array.isArray(rawFields)) {
555
733
  const attrs = {};
556
734
  for (let field = 0; field + 1 < rawFields.length; field += 2) {
557
735
  attrs[toStr(rawFields[field])] = toStr(rawFields[field + 1]);
558
736
  }
559
- passages.push(toPassage(toStr(reply[index]), attrs, prefix));
737
+ const inlineScore = scoreSource === "lexical" ? toStr(reply[index + 1]) : void 0;
738
+ passages.push(toPassage(toStr(reply[index]), attrs, prefix, scoreSource, inlineScore));
560
739
  }
561
740
  }
562
741
  return passages;
@@ -564,12 +743,19 @@ function parseSearchReply(reply, prefix) {
564
743
  if (typeof reply === "object" && reply !== null && "results" in reply) {
565
744
  const results = reply.results;
566
745
  if (Array.isArray(results)) {
567
- return results.map((result) => toPassage(readId(result), readAttributes(result), prefix));
746
+ return results.map((result) => toPassage(readId(result), readAttributes(result), prefix, scoreSource, scoreSource === "lexical" ? readScore(result) : void 0));
568
747
  }
569
748
  }
570
749
  return [];
571
750
  }
572
751
  __name(parseSearchReply, "parseSearchReply");
752
+ function readScore(result) {
753
+ if (typeof result === "object" && result !== null && "score" in result) {
754
+ return toStr(result.score);
755
+ }
756
+ return void 0;
757
+ }
758
+ __name(readScore, "readScore");
573
759
  function readId(result) {
574
760
  if (typeof result === "object" && result !== null && "id" in result) {
575
761
  return toStr(result.id);
@@ -590,13 +776,13 @@ function readAttributes(result) {
590
776
  return attrs;
591
777
  }
592
778
  __name(readAttributes, "readAttributes");
593
- function toPassage(rawId, attrs, prefix) {
594
- const distance = Number(attrs.vector_score ?? "1");
779
+ function toPassage(rawId, attrs, prefix, scoreSource = "vector", inlineScore) {
780
+ const score = scoreSource === "lexical" ? Number(inlineScore ?? "0") : 1 - Number(attrs.vector_score ?? "1");
595
781
  const source = attrs.source;
596
782
  return {
597
783
  id: rawId.startsWith(prefix) ? rawId.slice(prefix.length) : rawId,
598
784
  text: attrs.text ?? "",
599
- score: 1 - distance,
785
+ score: Number.isFinite(score) ? score : 0,
600
786
  ...source !== void 0 ? {
601
787
  source
602
788
  } : {},
@@ -662,10 +848,14 @@ var KeywordRetriever = class {
662
848
  this.k1 = options.k1 ?? 1.5;
663
849
  this.b = options.b ?? 0.75;
664
850
  }
851
+ /** How many chunks are currently indexed. */
852
+ get size() {
853
+ return this.docs.size;
854
+ }
665
855
  /** Index (or re-index) records. Re-adding an existing id replaces its posting and updates stats. */
666
856
  add(records) {
667
857
  for (const record of records) {
668
- this.remove(record.id);
858
+ this.removeChunk(record.id);
669
859
  const tokens = tokenize(record.text);
670
860
  const frequencies = /* @__PURE__ */ new Map();
671
861
  for (const token of tokens) {
@@ -682,7 +872,30 @@ var KeywordRetriever = class {
682
872
  this.totalLength += tokens.length;
683
873
  }
684
874
  }
685
- remove(id) {
875
+ /**
876
+ * Forget a source document — every chunk whose id is `${documentId}` or `${documentId}#<n>`, the
877
+ * same id scheme (and the same {@link documentIdOf} collapse) the vector stores use for their
878
+ * `remove`. The keyword half of a hybrid index keeps its own copy of the chunk text, so a document
879
+ * dropped from the vector store stays fully retrievable here until this is called. Unknown ids are
880
+ * a no-op.
881
+ */
882
+ remove(documentId) {
883
+ for (const id of [
884
+ ...this.docs.keys()
885
+ ]) {
886
+ if (id === documentId || documentIdOf(id) === documentId) {
887
+ this.removeChunk(id);
888
+ }
889
+ }
890
+ }
891
+ /** Drop every indexed chunk, resetting the corpus statistics with it. */
892
+ clear() {
893
+ this.docs.clear();
894
+ this.documentFrequency.clear();
895
+ this.totalLength = 0;
896
+ }
897
+ /** Drop ONE chunk by its exact id, decrementing the corpus statistics it contributed. */
898
+ removeChunk(id) {
686
899
  const existing = this.docs.get(id);
687
900
  if (existing === void 0) {
688
901
  return;
@@ -743,6 +956,25 @@ function tokenize(text) {
743
956
  }
744
957
  __name(tokenize, "tokenize");
745
958
 
959
+ // src/lexical-retriever.ts
960
+ var LexicalRetriever = class {
961
+ static {
962
+ __name(this, "LexicalRetriever");
963
+ }
964
+ store;
965
+ constructor(store) {
966
+ this.store = store;
967
+ }
968
+ async retrieve(query, options = {}) {
969
+ return this.store.searchText(query, {
970
+ topK: options.topK ?? 5,
971
+ ...options.filter !== void 0 ? {
972
+ filter: options.filter
973
+ } : {}
974
+ });
975
+ }
976
+ };
977
+
746
978
  // src/hybrid-retriever.ts
747
979
  var HybridRetriever = class {
748
980
  static {
@@ -930,8 +1162,10 @@ __name(createRetrievalTool, "createRetrievalTool");
930
1162
  FilteredRetriever,
931
1163
  HybridRetriever,
932
1164
  KeywordRetriever,
1165
+ LexicalRetriever,
933
1166
  MemoryVectorStore,
934
1167
  PgVectorStore,
1168
+ RedisVectorSchemaMismatchError,
935
1169
  RedisVectorStore,
936
1170
  RerankingRetriever,
937
1171
  chunkDocuments,
@@ -939,6 +1173,7 @@ __name(createRetrievalTool, "createRetrievalTool");
939
1173
  createRetrievalTool,
940
1174
  documentIdOf,
941
1175
  ingestChunks,
942
- ingestDocuments
1176
+ ingestDocuments,
1177
+ isLexicalVectorStore
943
1178
  });
944
1179
  //# sourceMappingURL=index.cjs.map