@voyantjs/catalog-rag 0.19.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.
Files changed (41) hide show
  1. package/README.md +48 -0
  2. package/dist/embeddings/contract.d.ts +85 -0
  3. package/dist/embeddings/contract.d.ts.map +1 -0
  4. package/dist/embeddings/contract.js +42 -0
  5. package/dist/embeddings/contract.test.d.ts +2 -0
  6. package/dist/embeddings/contract.test.d.ts.map +1 -0
  7. package/dist/embeddings/contract.test.js +30 -0
  8. package/dist/embeddings/gemini.d.ts +110 -0
  9. package/dist/embeddings/gemini.d.ts.map +1 -0
  10. package/dist/embeddings/gemini.js +118 -0
  11. package/dist/embeddings/gemini.test.d.ts +2 -0
  12. package/dist/embeddings/gemini.test.d.ts.map +1 -0
  13. package/dist/embeddings/gemini.test.js +126 -0
  14. package/dist/embeddings/model-registry.d.ts +62 -0
  15. package/dist/embeddings/model-registry.d.ts.map +1 -0
  16. package/dist/embeddings/model-registry.js +78 -0
  17. package/dist/embeddings/model-registry.test.d.ts +2 -0
  18. package/dist/embeddings/model-registry.test.d.ts.map +1 -0
  19. package/dist/embeddings/model-registry.test.js +81 -0
  20. package/dist/embeddings/openai.d.ts +81 -0
  21. package/dist/embeddings/openai.d.ts.map +1 -0
  22. package/dist/embeddings/openai.js +123 -0
  23. package/dist/embeddings/openai.test.d.ts +2 -0
  24. package/dist/embeddings/openai.test.d.ts.map +1 -0
  25. package/dist/embeddings/openai.test.js +157 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +11 -0
  29. package/dist/search/federate.d.ts +57 -0
  30. package/dist/search/federate.d.ts.map +1 -0
  31. package/dist/search/federate.js +103 -0
  32. package/dist/search/federate.test.d.ts +2 -0
  33. package/dist/search/federate.test.d.ts.map +1 -0
  34. package/dist/search/federate.test.js +146 -0
  35. package/dist/search/semantic.d.ts +58 -0
  36. package/dist/search/semantic.d.ts.map +1 -0
  37. package/dist/search/semantic.js +71 -0
  38. package/dist/search/semantic.test.d.ts +2 -0
  39. package/dist/search/semantic.test.d.ts.map +1 -0
  40. package/dist/search/semantic.test.js +143 -0
  41. package/package.json +75 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Cross-audience federated search.
3
+ *
4
+ * Per architecture §7, vectors are strictly per-audience — customer
5
+ * embedding pools only contain customer-visible text, partner pools only
6
+ * contain partner-visible text, etc. This means the most common admin AI
7
+ * use case ("find products similar to *X*" where *X* is in customer-
8
+ * facing language) needs to query a non-staff audience pool.
9
+ *
10
+ * Staff actors are authorized to query any audience pool; customer /
11
+ * partner / supplier agents are pinned to their own audience by API
12
+ * authorization. This helper takes a list of `search_audiences` and:
13
+ *
14
+ * 1. Verifies the actor is authorized for each requested audience.
15
+ * 2. Issues parallel `IndexerAdapter.search` calls — one per audience.
16
+ * 3. Deduplicates hits by entity id (same entity may rank in multiple
17
+ * pools; keep the highest-scoring instance).
18
+ * 4. Merges the per-pool result sets into a single ranked list.
19
+ *
20
+ * If the adapter declares `supportsCrossAudienceFederation`, the helper
21
+ * delegates to a single multi-collection adapter call instead of fanning
22
+ * out client-side. Either way the API contract is the same to callers.
23
+ *
24
+ * See `docs/architecture/catalog-rag-architecture.md` §7.3.
25
+ */
26
+ /**
27
+ * Federate a search across multiple audience pools. Returns a unified
28
+ * `SearchResults` with deduplicated hits ranked by score.
29
+ */
30
+ export async function federateAudienceSearch(options) {
31
+ const { adapter, actor, searchAudiences, vertical, locale, market, request } = options;
32
+ if (searchAudiences.length === 0) {
33
+ throw new Error("federateAudienceSearch requires at least one searchAudience");
34
+ }
35
+ enforceAudienceAuthorization(actor, searchAudiences);
36
+ // If a single audience is requested, no federation needed — direct search.
37
+ if (searchAudiences.length === 1) {
38
+ const audience = searchAudiences[0];
39
+ const slice = { vertical, locale, audience, market };
40
+ return adapter.search(slice, request);
41
+ }
42
+ // Engine-side federation when supported — single adapter call.
43
+ if (adapter.capabilities.supportsCrossAudienceFederation) {
44
+ // The adapter contract doesn't currently expose multi-slice search
45
+ // directly. For Phase 2 v1 we use the client-side fan-out path below
46
+ // even when the engine could do better. A follow-up adds a
47
+ // `searchFederated(slices: IndexerSlice[], request)` method to the
48
+ // contract to take advantage of native federation.
49
+ // For now, fall through to the client-side path.
50
+ }
51
+ // Client-side fan-out: one search per audience pool, parallelized.
52
+ const slices = searchAudiences.map((audience) => ({
53
+ vertical,
54
+ locale,
55
+ audience,
56
+ market,
57
+ }));
58
+ const perSliceResults = await Promise.all(slices.map((slice) => adapter.search(slice, request)));
59
+ return mergeAndDedupe(perSliceResults, request.pagination?.limit);
60
+ }
61
+ /**
62
+ * Merge several `SearchResults` into one, deduplicating by hit id and
63
+ * keeping the highest-scoring instance. Total is the count of unique ids
64
+ * across all pools (after dedupe).
65
+ */
66
+ export function mergeAndDedupe(perSlice, limit) {
67
+ const byId = new Map();
68
+ for (const result of perSlice) {
69
+ for (const hit of result.hits) {
70
+ const existing = byId.get(hit.id);
71
+ if (!existing || hit.score > existing.score) {
72
+ byId.set(hit.id, hit);
73
+ }
74
+ }
75
+ }
76
+ // Sort by score descending — federated semantics rank by best score.
77
+ const merged = Array.from(byId.values()).sort((a, b) => b.score - a.score);
78
+ const limited = limit != null ? merged.slice(0, limit) : merged;
79
+ return {
80
+ hits: limited,
81
+ total: byId.size,
82
+ // Facets aren't merged — federation across audiences with different
83
+ // facet vocabularies is ambiguous. Callers that need facets should
84
+ // search a single audience.
85
+ };
86
+ }
87
+ /**
88
+ * Enforce per-actor authorization rules: customer / partner / supplier
89
+ * agents may only search their own audience pool. Staff actors may
90
+ * federate across any pools.
91
+ */
92
+ function enforceAudienceAuthorization(actor, requested) {
93
+ if (actor === "staff") {
94
+ // Staff may federate across anything.
95
+ return;
96
+ }
97
+ if (requested.length === 1 && requested[0] === actor) {
98
+ // Non-staff actor searching their own pool only — allowed.
99
+ return;
100
+ }
101
+ throw new Error(`Actor "${actor}" is not authorized to federate across audiences ${JSON.stringify(requested)}. ` +
102
+ `Non-staff actors may only search their own audience pool. To federate across audiences, the request must come from a staff actor.`);
103
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=federate.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"federate.test.d.ts","sourceRoot":"","sources":["../../src/search/federate.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { federateAudienceSearch, mergeAndDedupe } from "./federate.js";
3
+ const capabilities = {
4
+ supportsKeywordSearch: true,
5
+ supportsHybridSearch: true,
6
+ supportsVectorFields: true,
7
+ vectorDimensions: 1536,
8
+ maxVectorsPerDocument: null,
9
+ supportsCrossAudienceFederation: false,
10
+ supportsAdminDenormalization: true,
11
+ };
12
+ function hit(id, score) {
13
+ return { id, score, document: { id, fields: {} } };
14
+ }
15
+ function makeAdapter(perAudience) {
16
+ return {
17
+ capabilities,
18
+ async ensureCollection() { },
19
+ async upsert() { },
20
+ async delete() { },
21
+ async bulkReindex() { },
22
+ async search(slice) {
23
+ const hits = perAudience[slice.audience] ?? [];
24
+ return { hits, total: hits.length };
25
+ },
26
+ };
27
+ }
28
+ describe("mergeAndDedupe", () => {
29
+ it("keeps the highest-scoring instance when an entity appears in multiple pools", () => {
30
+ const merged = mergeAndDedupe([
31
+ { hits: [hit("a", 1), hit("b", 2)], total: 2 },
32
+ { hits: [hit("a", 5), hit("c", 3)], total: 2 },
33
+ ]);
34
+ const ids = merged.hits.map((h) => h.id);
35
+ expect(ids).toEqual(["a", "c", "b"]); // sorted by score desc
36
+ const aHit = merged.hits.find((h) => h.id === "a");
37
+ expect(aHit?.score).toBe(5);
38
+ expect(merged.total).toBe(3);
39
+ });
40
+ it("respects the limit parameter", () => {
41
+ const merged = mergeAndDedupe([{ hits: [hit("a", 1), hit("b", 2), hit("c", 3)], total: 3 }], 2);
42
+ expect(merged.hits).toHaveLength(2);
43
+ expect(merged.total).toBe(3); // total is unique-id count, not limited
44
+ });
45
+ it("returns an empty result when no pools have hits", () => {
46
+ const merged = mergeAndDedupe([]);
47
+ expect(merged.hits).toEqual([]);
48
+ expect(merged.total).toBe(0);
49
+ });
50
+ });
51
+ describe("federateAudienceSearch — authorization", () => {
52
+ it("rejects non-staff actors trying to federate beyond their own pool", async () => {
53
+ const adapter = makeAdapter({});
54
+ await expect(federateAudienceSearch({
55
+ adapter,
56
+ actor: "customer",
57
+ searchAudiences: ["customer", "partner"],
58
+ vertical: "products",
59
+ locale: "en-GB",
60
+ market: "default",
61
+ request: { query: "x", mode: "keyword" },
62
+ })).rejects.toThrow(/not authorized to federate/);
63
+ });
64
+ it("allows non-staff actors to search their own audience pool only", async () => {
65
+ const adapter = makeAdapter({ customer: [hit("a", 1)] });
66
+ const result = await federateAudienceSearch({
67
+ adapter,
68
+ actor: "customer",
69
+ searchAudiences: ["customer"],
70
+ vertical: "products",
71
+ locale: "en-GB",
72
+ market: "default",
73
+ request: { query: "x", mode: "keyword" },
74
+ });
75
+ expect(result.hits).toHaveLength(1);
76
+ });
77
+ it("rejects non-staff actors trying to search a different audience pool", async () => {
78
+ const adapter = makeAdapter({});
79
+ await expect(federateAudienceSearch({
80
+ adapter,
81
+ actor: "customer",
82
+ searchAudiences: ["partner"],
83
+ vertical: "products",
84
+ locale: "en-GB",
85
+ market: "default",
86
+ request: { query: "x", mode: "keyword" },
87
+ })).rejects.toThrow(/not authorized to federate/);
88
+ });
89
+ it("allows staff actors to federate across multiple audience pools", async () => {
90
+ const adapter = makeAdapter({
91
+ customer: [hit("a", 1)],
92
+ partner: [hit("b", 2)],
93
+ });
94
+ const result = await federateAudienceSearch({
95
+ adapter,
96
+ actor: "staff",
97
+ searchAudiences: ["customer", "partner"],
98
+ vertical: "products",
99
+ locale: "en-GB",
100
+ market: "default",
101
+ request: { query: "x", mode: "keyword" },
102
+ });
103
+ expect(result.hits.map((h) => h.id).sort()).toEqual(["a", "b"]);
104
+ expect(result.total).toBe(2);
105
+ });
106
+ });
107
+ describe("federateAudienceSearch — single audience shortcut", () => {
108
+ it("issues a single adapter call when only one audience is requested", async () => {
109
+ let callCount = 0;
110
+ const wrapped = {
111
+ capabilities,
112
+ async ensureCollection() { },
113
+ async upsert() { },
114
+ async delete() { },
115
+ async bulkReindex() { },
116
+ async search(slice) {
117
+ callCount++;
118
+ return { hits: [hit(`from-${slice.audience}`, 1)], total: 1 };
119
+ },
120
+ };
121
+ await federateAudienceSearch({
122
+ adapter: wrapped,
123
+ actor: "staff",
124
+ searchAudiences: ["customer"],
125
+ vertical: "products",
126
+ locale: "en-GB",
127
+ market: "default",
128
+ request: { query: "x", mode: "keyword" },
129
+ });
130
+ expect(callCount).toBe(1);
131
+ });
132
+ });
133
+ describe("federateAudienceSearch — empty input rejected", () => {
134
+ it("requires at least one searchAudience", async () => {
135
+ const adapter = makeAdapter({});
136
+ await expect(federateAudienceSearch({
137
+ adapter,
138
+ actor: "staff",
139
+ searchAudiences: [],
140
+ vertical: "products",
141
+ locale: "en-GB",
142
+ market: "default",
143
+ request: { query: "x", mode: "keyword" },
144
+ })).rejects.toThrow(/at least one searchAudience/);
145
+ });
146
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Semantic / hybrid search orchestration.
3
+ *
4
+ * Wraps the Phase 1 `IndexerAdapter.search` with the embedding-generation
5
+ * step. When `mode: "semantic" | "hybrid"`, this helper embeds the query
6
+ * via the configured `EmbeddingProvider`, attaches the vector to the
7
+ * `SearchRequest` as `query_embedding`, and delegates to the adapter.
8
+ *
9
+ * Callers that already have a query embedding (an upstream agent that
10
+ * vectorized the user's intent) can skip embedding by passing
11
+ * `query_embedding` directly — `executeSemanticSearch` honors a
12
+ * caller-supplied vector and skips the embed call.
13
+ *
14
+ * See `docs/architecture/catalog-rag-architecture.md` §3 + §6.
15
+ */
16
+ import type { IndexerAdapter, IndexerSlice, SearchRequest, SearchResults } from "@voyantjs/catalog";
17
+ import type { EmbeddingProvider } from "../embeddings/contract.js";
18
+ export interface SemanticSearchOptions {
19
+ /** Adapter to query. */
20
+ adapter: IndexerAdapter;
21
+ /** Embedding provider — used to vectorize the query string when needed. */
22
+ embeddings: EmbeddingProvider;
23
+ /** The variant slice (vertical, locale, audience, market) to search. */
24
+ slice: IndexerSlice;
25
+ /** The search request. `mode` controls keyword/hybrid/semantic blending. */
26
+ request: SearchRequest;
27
+ }
28
+ /**
29
+ * Run a search request that may need a query embedding generated.
30
+ *
31
+ * Behavior by mode:
32
+ * - `keyword` — adapter.search called directly; no embedding work.
33
+ * - `hybrid` — query string is embedded (unless caller supplied
34
+ * `query_embedding`), adapter blends keyword + vector
35
+ * scores.
36
+ * - `semantic` — query string is embedded, adapter does pure vector
37
+ * similarity. (Engines that don't support pure-semantic
38
+ * typically fall back to hybrid with the keyword weight
39
+ * set very low.)
40
+ *
41
+ * Verifies adapter capabilities at runtime: requesting `semantic` /
42
+ * `hybrid` against an adapter without `supportsVectorFields` throws
43
+ * a clear error rather than silently degrading to keyword-only.
44
+ */
45
+ export declare function executeSemanticSearch(options: SemanticSearchOptions): Promise<SearchResults>;
46
+ /**
47
+ * Helper for callers (typically AI agents) that have already vectorized
48
+ * a query upstream and want to bypass the embedding step entirely. The
49
+ * vector is attached to the request as-is.
50
+ */
51
+ export declare function executeBYOVectorSearch(options: {
52
+ adapter: IndexerAdapter;
53
+ slice: IndexerSlice;
54
+ request: SearchRequest & {
55
+ query_embedding: number[];
56
+ };
57
+ }): Promise<SearchResults>;
58
+ //# sourceMappingURL=semantic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semantic.d.ts","sourceRoot":"","sources":["../../src/search/semantic.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EACV,cAAc,EACd,YAAY,EACZ,aAAa,EACb,aAAa,EACd,MAAM,mBAAmB,CAAA;AAE1B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAElE,MAAM,WAAW,qBAAqB;IACpC,wBAAwB;IACxB,OAAO,EAAE,cAAc,CAAA;IACvB,2EAA2E;IAC3E,UAAU,EAAE,iBAAiB,CAAA;IAC7B,wEAAwE;IACxE,KAAK,EAAE,YAAY,CAAA;IACnB,4EAA4E;IAC5E,OAAO,EAAE,aAAa,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,aAAa,CAAC,CAmCxB;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,EAAE;IACpD,OAAO,EAAE,cAAc,CAAA;IACvB,KAAK,EAAE,YAAY,CAAA;IACnB,OAAO,EAAE,aAAa,GAAG;QAAE,eAAe,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;CACvD,GAAG,OAAO,CAAC,aAAa,CAAC,CAQzB"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Semantic / hybrid search orchestration.
3
+ *
4
+ * Wraps the Phase 1 `IndexerAdapter.search` with the embedding-generation
5
+ * step. When `mode: "semantic" | "hybrid"`, this helper embeds the query
6
+ * via the configured `EmbeddingProvider`, attaches the vector to the
7
+ * `SearchRequest` as `query_embedding`, and delegates to the adapter.
8
+ *
9
+ * Callers that already have a query embedding (an upstream agent that
10
+ * vectorized the user's intent) can skip embedding by passing
11
+ * `query_embedding` directly — `executeSemanticSearch` honors a
12
+ * caller-supplied vector and skips the embed call.
13
+ *
14
+ * See `docs/architecture/catalog-rag-architecture.md` §3 + §6.
15
+ */
16
+ /**
17
+ * Run a search request that may need a query embedding generated.
18
+ *
19
+ * Behavior by mode:
20
+ * - `keyword` — adapter.search called directly; no embedding work.
21
+ * - `hybrid` — query string is embedded (unless caller supplied
22
+ * `query_embedding`), adapter blends keyword + vector
23
+ * scores.
24
+ * - `semantic` — query string is embedded, adapter does pure vector
25
+ * similarity. (Engines that don't support pure-semantic
26
+ * typically fall back to hybrid with the keyword weight
27
+ * set very low.)
28
+ *
29
+ * Verifies adapter capabilities at runtime: requesting `semantic` /
30
+ * `hybrid` against an adapter without `supportsVectorFields` throws
31
+ * a clear error rather than silently degrading to keyword-only.
32
+ */
33
+ export async function executeSemanticSearch(options) {
34
+ const { adapter, embeddings, slice, request } = options;
35
+ if (request.mode === "keyword") {
36
+ return adapter.search(slice, request);
37
+ }
38
+ if (!adapter.capabilities.supportsVectorFields) {
39
+ throw new Error(`Search mode "${request.mode}" requires an indexer that supports vector fields. ` +
40
+ `Configured adapter does not declare supportsVectorFields. Use mode: "keyword" or swap to a vector-capable adapter (e.g. Typesense).`);
41
+ }
42
+ if (request.mode === "hybrid" && !adapter.capabilities.supportsHybridSearch) {
43
+ throw new Error(`Search mode "hybrid" requires an indexer that declares supportsHybridSearch. ` +
44
+ `Configured adapter does not. Either use mode: "semantic" (pure vector) or swap to a hybrid-capable engine.`);
45
+ }
46
+ // Use caller-supplied embedding if provided; otherwise generate one.
47
+ let queryEmbedding = request.query_embedding;
48
+ if (!queryEmbedding && request.query.length > 0) {
49
+ const [vector] = await embeddings.embed([request.query]);
50
+ if (!vector) {
51
+ throw new Error("EmbeddingProvider returned no vector for the query string");
52
+ }
53
+ queryEmbedding = vector;
54
+ }
55
+ return adapter.search(slice, {
56
+ ...request,
57
+ query_embedding: queryEmbedding,
58
+ });
59
+ }
60
+ /**
61
+ * Helper for callers (typically AI agents) that have already vectorized
62
+ * a query upstream and want to bypass the embedding step entirely. The
63
+ * vector is attached to the request as-is.
64
+ */
65
+ export async function executeBYOVectorSearch(options) {
66
+ const { adapter, slice, request } = options;
67
+ if (!adapter.capabilities.supportsVectorFields) {
68
+ throw new Error("BYO-vector search requires an indexer with supportsVectorFields. The configured adapter does not.");
69
+ }
70
+ return adapter.search(slice, request);
71
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=semantic.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semantic.test.d.ts","sourceRoot":"","sources":["../../src/search/semantic.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { executeBYOVectorSearch, executeSemanticSearch } from "./semantic.js";
3
+ function makeAdapter(capabilities = {}) {
4
+ const baseCapabilities = {
5
+ supportsKeywordSearch: true,
6
+ supportsHybridSearch: true,
7
+ supportsVectorFields: true,
8
+ vectorDimensions: 1536,
9
+ maxVectorsPerDocument: null,
10
+ supportsCrossAudienceFederation: false,
11
+ supportsAdminDenormalization: true,
12
+ ...capabilities,
13
+ };
14
+ const searchSpy = vi.fn(async (_slice, _request) => ({
15
+ hits: [],
16
+ total: 0,
17
+ }));
18
+ return {
19
+ capabilities: baseCapabilities,
20
+ async ensureCollection() { },
21
+ async upsert() { },
22
+ async delete() { },
23
+ async bulkReindex() { },
24
+ search: searchSpy,
25
+ searchSpy,
26
+ };
27
+ }
28
+ function makeEmbeddings() {
29
+ const embedSpy = vi.fn(async (texts) => texts.map(() => [0.1, 0.2, 0.3]));
30
+ return {
31
+ capabilities: {
32
+ modelId: "test/v1",
33
+ dimensions: 3,
34
+ maxTokensPerInput: 1000,
35
+ maxBatchSize: 100,
36
+ },
37
+ embed: embedSpy,
38
+ embedSpy,
39
+ };
40
+ }
41
+ const slice = {
42
+ vertical: "products",
43
+ locale: "en-GB",
44
+ audience: "customer",
45
+ market: "default",
46
+ };
47
+ describe("executeSemanticSearch", () => {
48
+ it("keyword mode delegates directly without embedding", async () => {
49
+ const adapter = makeAdapter();
50
+ const embeddings = makeEmbeddings();
51
+ await executeSemanticSearch({
52
+ adapter,
53
+ embeddings,
54
+ slice,
55
+ request: { query: "wellness", mode: "keyword" },
56
+ });
57
+ expect(embeddings.embedSpy).not.toHaveBeenCalled();
58
+ expect(adapter.searchSpy).toHaveBeenCalledTimes(1);
59
+ });
60
+ it("semantic mode embeds the query and attaches the vector", async () => {
61
+ const adapter = makeAdapter();
62
+ const embeddings = makeEmbeddings();
63
+ await executeSemanticSearch({
64
+ adapter,
65
+ embeddings,
66
+ slice,
67
+ request: { query: "wellness", mode: "semantic" },
68
+ });
69
+ expect(embeddings.embedSpy).toHaveBeenCalledWith(["wellness"]);
70
+ const [, request] = adapter.searchSpy.mock.calls[0];
71
+ expect(request.query_embedding).toEqual([0.1, 0.2, 0.3]);
72
+ });
73
+ it("hybrid mode embeds the query when caller didn't supply one", async () => {
74
+ const adapter = makeAdapter();
75
+ const embeddings = makeEmbeddings();
76
+ await executeSemanticSearch({
77
+ adapter,
78
+ embeddings,
79
+ slice,
80
+ request: { query: "wellness", mode: "hybrid" },
81
+ });
82
+ expect(embeddings.embedSpy).toHaveBeenCalledTimes(1);
83
+ });
84
+ it("honors caller-supplied query_embedding without re-embedding", async () => {
85
+ const adapter = makeAdapter();
86
+ const embeddings = makeEmbeddings();
87
+ const caller_vector = [0.9, 0.8, 0.7];
88
+ await executeSemanticSearch({
89
+ adapter,
90
+ embeddings,
91
+ slice,
92
+ request: {
93
+ query: "wellness",
94
+ mode: "semantic",
95
+ query_embedding: caller_vector,
96
+ },
97
+ });
98
+ expect(embeddings.embedSpy).not.toHaveBeenCalled();
99
+ const [, request] = adapter.searchSpy.mock.calls[0];
100
+ expect(request.query_embedding).toBe(caller_vector);
101
+ });
102
+ it("throws when semantic mode is requested but adapter doesn't support vectors", async () => {
103
+ const adapter = makeAdapter({ supportsVectorFields: false });
104
+ const embeddings = makeEmbeddings();
105
+ await expect(executeSemanticSearch({
106
+ adapter,
107
+ embeddings,
108
+ slice,
109
+ request: { query: "x", mode: "semantic" },
110
+ })).rejects.toThrow(/supports vector fields/);
111
+ });
112
+ it("throws when hybrid mode is requested but adapter doesn't support hybrid", async () => {
113
+ const adapter = makeAdapter({ supportsVectorFields: true, supportsHybridSearch: false });
114
+ const embeddings = makeEmbeddings();
115
+ await expect(executeSemanticSearch({
116
+ adapter,
117
+ embeddings,
118
+ slice,
119
+ request: { query: "x", mode: "hybrid" },
120
+ })).rejects.toThrow(/supportsHybridSearch/);
121
+ });
122
+ });
123
+ describe("executeBYOVectorSearch", () => {
124
+ it("attaches the caller-supplied vector and delegates without embedding", async () => {
125
+ const adapter = makeAdapter();
126
+ const vector = [1, 2, 3];
127
+ await executeBYOVectorSearch({
128
+ adapter,
129
+ slice,
130
+ request: { query: "x", mode: "semantic", query_embedding: vector },
131
+ });
132
+ const [, request] = adapter.searchSpy.mock.calls[0];
133
+ expect(request.query_embedding).toBe(vector);
134
+ });
135
+ it("throws when adapter does not support vector fields", async () => {
136
+ const adapter = makeAdapter({ supportsVectorFields: false });
137
+ await expect(executeBYOVectorSearch({
138
+ adapter,
139
+ slice,
140
+ request: { query: "x", mode: "semantic", query_embedding: [1, 2, 3] },
141
+ })).rejects.toThrow(/supportsVectorFields/);
142
+ });
143
+ });
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@voyantjs/catalog-rag",
3
+ "version": "0.19.0",
4
+ "license": "Apache-2.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./embeddings/contract": "./src/embeddings/contract.ts",
9
+ "./embeddings/openai": "./src/embeddings/openai.ts",
10
+ "./embeddings/model-registry": "./src/embeddings/model-registry.ts",
11
+ "./search/semantic": "./src/search/semantic.ts",
12
+ "./search/federate": "./src/search/federate.ts"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit",
16
+ "lint": "biome check src/",
17
+ "test": "vitest run",
18
+ "build": "tsc -p tsconfig.json",
19
+ "clean": "rm -rf dist",
20
+ "prepack": "pnpm run build"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./embeddings/contract": {
34
+ "types": "./dist/embeddings/contract.d.ts",
35
+ "import": "./dist/embeddings/contract.js",
36
+ "default": "./dist/embeddings/contract.js"
37
+ },
38
+ "./embeddings/openai": {
39
+ "types": "./dist/embeddings/openai.d.ts",
40
+ "import": "./dist/embeddings/openai.js",
41
+ "default": "./dist/embeddings/openai.js"
42
+ },
43
+ "./embeddings/model-registry": {
44
+ "types": "./dist/embeddings/model-registry.d.ts",
45
+ "import": "./dist/embeddings/model-registry.js",
46
+ "default": "./dist/embeddings/model-registry.js"
47
+ },
48
+ "./search/semantic": {
49
+ "types": "./dist/search/semantic.d.ts",
50
+ "import": "./dist/search/semantic.js",
51
+ "default": "./dist/search/semantic.js"
52
+ },
53
+ "./search/federate": {
54
+ "types": "./dist/search/federate.d.ts",
55
+ "import": "./dist/search/federate.js",
56
+ "default": "./dist/search/federate.js"
57
+ }
58
+ },
59
+ "main": "./dist/index.js",
60
+ "types": "./dist/index.d.ts"
61
+ },
62
+ "dependencies": {
63
+ "@voyantjs/catalog": "workspace:*"
64
+ },
65
+ "devDependencies": {
66
+ "@voyantjs/voyant-typescript-config": "workspace:*",
67
+ "typescript": "^6.0.2",
68
+ "vitest": "^4.1.2"
69
+ },
70
+ "repository": {
71
+ "type": "git",
72
+ "url": "https://github.com/voyantjs/voyant.git",
73
+ "directory": "packages/catalog-rag"
74
+ }
75
+ }