@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,78 @@
1
+ /**
2
+ * Embedding model registry helpers.
3
+ *
4
+ * Each search-index document carries an `embedding_model_id` field that
5
+ * identifies which model produced its vector. The registry's job is to:
6
+ *
7
+ * 1. Validate at deployment startup that the configured embedding
8
+ * provider's `dimensions` matches the configured `IndexerAdapter`'s
9
+ * `vectorDimensions`. Mismatch → fail loudly.
10
+ * 2. Track the active model id so search queries can scope vector
11
+ * lookups to documents using a compatible model. During a model
12
+ * migration window the index can hold mixed-model documents — old
13
+ * ones get skipped on vector queries and re-embedded by the
14
+ * `bulkReindex(forceReembed: true)` job.
15
+ *
16
+ * See `docs/architecture/catalog-rag-architecture.md` §8.
17
+ */
18
+ /**
19
+ * Validate that an embedding provider's capabilities are compatible with
20
+ * the search engine's vector configuration. Call this at deployment
21
+ * startup; throw if incompatible.
22
+ */
23
+ export function validateEmbeddingCompatibility(providerCapabilities, indexerCapabilities) {
24
+ if (!indexerCapabilities.supportsVectorFields) {
25
+ throw new Error(`IndexerAdapter does not support vector fields, but an embedding provider is configured (model: ${providerCapabilities.modelId}). ` +
26
+ `Disable embeddings or swap to an indexer that supports them (e.g. Typesense).`);
27
+ }
28
+ if (indexerCapabilities.vectorDimensions != null &&
29
+ indexerCapabilities.vectorDimensions !== providerCapabilities.dimensions) {
30
+ throw new Error(`Embedding model ${providerCapabilities.modelId} produces ${providerCapabilities.dimensions}-d vectors, ` +
31
+ `but IndexerAdapter is configured for ${indexerCapabilities.vectorDimensions}-d. ` +
32
+ `Either reconfigure the indexer's vectorDimensions, or swap to a compatible embedding model.`);
33
+ }
34
+ if (indexerCapabilities.maxVectorsPerDocument != null &&
35
+ indexerCapabilities.maxVectorsPerDocument < 1) {
36
+ throw new Error(`IndexerAdapter declares maxVectorsPerDocument=${indexerCapabilities.maxVectorsPerDocument} but Phase 2 requires at least 1 vector per document.`);
37
+ }
38
+ }
39
+ /**
40
+ * Returns true if a given document's `embedding_model_id` matches the
41
+ * deployment's active model. Vector queries should filter to active-model
42
+ * documents; non-matching documents fall through to keyword-only
43
+ * scoring until `bulkReindex(forceReembed: true)` re-embeds them.
44
+ */
45
+ export function isActiveEmbeddingModel(documentModelId, activeModelId) {
46
+ return documentModelId === activeModelId;
47
+ }
48
+ /**
49
+ * Convenience: stamp an `IndexerDocument`'s `embedding_model_id` from a
50
+ * provider's capabilities. Use this when constructing documents in the
51
+ * embedding pipeline so the active model id propagates to the index.
52
+ */
53
+ export function stampEmbeddingModelId(providerCapabilities) {
54
+ return { embedding_model_id: providerCapabilities.modelId };
55
+ }
56
+ export function planEmbeddingMigration(documents, activeModelId) {
57
+ const embedded = [];
58
+ const pending = [];
59
+ const migrating = [];
60
+ for (const doc of documents) {
61
+ if (!doc.embedding_model_id) {
62
+ pending.push(doc.id);
63
+ }
64
+ else if (doc.embedding_model_id === activeModelId) {
65
+ embedded.push(doc.id);
66
+ }
67
+ else {
68
+ migrating.push(doc.id);
69
+ }
70
+ }
71
+ return {
72
+ embedded,
73
+ pending,
74
+ migrating,
75
+ totalDocuments: documents.length,
76
+ activeModelId,
77
+ };
78
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=model-registry.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-registry.test.d.ts","sourceRoot":"","sources":["../../src/embeddings/model-registry.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isActiveEmbeddingModel, planEmbeddingMigration, stampEmbeddingModelId, validateEmbeddingCompatibility, } from "./model-registry.js";
3
+ const provider = {
4
+ modelId: "openai/text-embedding-3-small/v1",
5
+ dimensions: 1536,
6
+ maxTokensPerInput: 8191,
7
+ maxBatchSize: 2048,
8
+ supportedLanguages: null,
9
+ };
10
+ const indexerCompatible = {
11
+ supportsKeywordSearch: true,
12
+ supportsHybridSearch: true,
13
+ supportsVectorFields: true,
14
+ vectorDimensions: 1536,
15
+ maxVectorsPerDocument: null,
16
+ supportsCrossAudienceFederation: true,
17
+ supportsAdminDenormalization: true,
18
+ };
19
+ describe("validateEmbeddingCompatibility", () => {
20
+ it("succeeds when dimensions match", () => {
21
+ expect(() => validateEmbeddingCompatibility(provider, indexerCompatible)).not.toThrow();
22
+ });
23
+ it("throws when the indexer does not support vector fields", () => {
24
+ expect(() => validateEmbeddingCompatibility(provider, {
25
+ ...indexerCompatible,
26
+ supportsVectorFields: false,
27
+ })).toThrow(/does not support vector fields/);
28
+ });
29
+ it("throws when dimensions mismatch", () => {
30
+ expect(() => validateEmbeddingCompatibility(provider, { ...indexerCompatible, vectorDimensions: 768 })).toThrow(/1536-d/);
31
+ });
32
+ it("accepts null vectorDimensions on the indexer (deferred config)", () => {
33
+ expect(() => validateEmbeddingCompatibility(provider, { ...indexerCompatible, vectorDimensions: null })).not.toThrow();
34
+ });
35
+ it("throws when maxVectorsPerDocument is < 1", () => {
36
+ expect(() => validateEmbeddingCompatibility(provider, {
37
+ ...indexerCompatible,
38
+ maxVectorsPerDocument: 0,
39
+ })).toThrow(/at least 1 vector/);
40
+ });
41
+ });
42
+ describe("isActiveEmbeddingModel", () => {
43
+ it("returns true when ids match", () => {
44
+ expect(isActiveEmbeddingModel(provider.modelId, provider.modelId)).toBe(true);
45
+ });
46
+ it("returns false for missing or mismatched ids", () => {
47
+ expect(isActiveEmbeddingModel(undefined, provider.modelId)).toBe(false);
48
+ expect(isActiveEmbeddingModel("openai/text-embedding-3-large/v1", provider.modelId)).toBe(false);
49
+ });
50
+ });
51
+ describe("stampEmbeddingModelId", () => {
52
+ it("returns an object with the model id from capabilities", () => {
53
+ expect(stampEmbeddingModelId(provider)).toEqual({
54
+ embedding_model_id: provider.modelId,
55
+ });
56
+ });
57
+ });
58
+ describe("planEmbeddingMigration", () => {
59
+ it("partitions documents into embedded / pending / migrating", () => {
60
+ const docs = [
61
+ { id: "a", embedding_model_id: "openai/text-embedding-3-small/v1" },
62
+ { id: "b", embedding_model_id: undefined },
63
+ { id: "c", embedding_model_id: null },
64
+ { id: "d", embedding_model_id: "openai/text-embedding-ada-002/v1" },
65
+ { id: "e", embedding_model_id: "openai/text-embedding-3-small/v1" },
66
+ ];
67
+ const plan = planEmbeddingMigration(docs, "openai/text-embedding-3-small/v1");
68
+ expect(plan.embedded.sort()).toEqual(["a", "e"]);
69
+ expect(plan.pending.sort()).toEqual(["b", "c"]);
70
+ expect(plan.migrating).toEqual(["d"]);
71
+ expect(plan.totalDocuments).toBe(5);
72
+ expect(plan.activeModelId).toBe("openai/text-embedding-3-small/v1");
73
+ });
74
+ it("handles an empty document list", () => {
75
+ const plan = planEmbeddingMigration([], "x");
76
+ expect(plan.totalDocuments).toBe(0);
77
+ expect(plan.embedded).toEqual([]);
78
+ expect(plan.pending).toEqual([]);
79
+ expect(plan.migrating).toEqual([]);
80
+ });
81
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Default EmbeddingProvider implementation backed by OpenAI's embeddings API.
3
+ *
4
+ * Uses native `fetch` so it works in Cloudflare Workers + Node + browsers
5
+ * without an SDK dependency. Templates pass in the API key (and optionally
6
+ * a custom `baseUrl` for proxies / Azure OpenAI / OpenRouter etc.).
7
+ *
8
+ * Models supported by default:
9
+ * - `text-embedding-3-small` — 1536d, multilingual, cheapest. **Default.**
10
+ * - `text-embedding-3-large` — 3072d, multilingual, higher quality.
11
+ * - `text-embedding-ada-002` — 1536d, legacy (kept for migration paths).
12
+ *
13
+ * See `docs/architecture/catalog-rag-architecture.md` §6 for the design.
14
+ */
15
+ import { type EmbeddingProvider } from "./contract.js";
16
+ /**
17
+ * Known OpenAI embedding models. Adding a new entry here is the only place
18
+ * to touch when OpenAI ships a new model — `createOpenAIEmbeddingProvider`
19
+ * picks up the dimensions / batch limits automatically.
20
+ */
21
+ declare const OPENAI_MODELS: {
22
+ readonly "text-embedding-3-small": {
23
+ readonly dimensions: 1536;
24
+ readonly maxTokensPerInput: 8191;
25
+ readonly maxBatchSize: 2048;
26
+ readonly multilingual: true;
27
+ };
28
+ readonly "text-embedding-3-large": {
29
+ readonly dimensions: 3072;
30
+ readonly maxTokensPerInput: 8191;
31
+ readonly maxBatchSize: 2048;
32
+ readonly multilingual: true;
33
+ };
34
+ readonly "text-embedding-ada-002": {
35
+ readonly dimensions: 1536;
36
+ readonly maxTokensPerInput: 8191;
37
+ readonly maxBatchSize: 2048;
38
+ readonly multilingual: true;
39
+ };
40
+ };
41
+ export type OpenAIEmbeddingModel = keyof typeof OPENAI_MODELS;
42
+ export interface OpenAIEmbeddingProviderOptions {
43
+ /** OpenAI API key. */
44
+ apiKey: string;
45
+ /**
46
+ * Embedding model to use. Default: `text-embedding-3-small`.
47
+ * Switching models is a deliberate `bulkReindex` operation — the catalog
48
+ * plane scopes vector queries to documents matching the active
49
+ * `embedding_model_id`, so mid-migration mixes are handled cleanly.
50
+ */
51
+ model?: OpenAIEmbeddingModel;
52
+ /**
53
+ * Override the API base URL — useful for Azure OpenAI, OpenRouter,
54
+ * a corporate proxy, or any OpenAI-API-compatible service. Default:
55
+ * `https://api.openai.com/v1`.
56
+ */
57
+ baseUrl?: string;
58
+ /**
59
+ * Optional `fetch` override for testing or custom transport. Default:
60
+ * the global `fetch`. Must follow the standard Fetch API contract.
61
+ */
62
+ fetchImpl?: typeof fetch;
63
+ /**
64
+ * Override the model id stamped onto search-index documents. Defaults
65
+ * to `openai/<model>/v1` — keep this stable across deployments so
66
+ * documents stay queryable across instances.
67
+ */
68
+ modelId?: string;
69
+ }
70
+ /**
71
+ * Build the default OpenAI EmbeddingProvider.
72
+ */
73
+ export declare function createOpenAIEmbeddingProvider(options: OpenAIEmbeddingProviderOptions): EmbeddingProvider;
74
+ /**
75
+ * Helper that chunks a large input array into batches sized to the model's
76
+ * `maxBatchSize` and concatenates the per-batch results. Use this when
77
+ * embedding more than `maxBatchSize` texts at once.
78
+ */
79
+ export declare function embedBatched(provider: EmbeddingProvider, texts: string[]): Promise<number[][]>;
80
+ export { OPENAI_MODELS };
81
+ //# sourceMappingURL=openai.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openai.d.ts","sourceRoot":"","sources":["../../src/embeddings/openai.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAKL,KAAK,iBAAiB,EAGvB,MAAM,eAAe,CAAA;AAEtB;;;;GAIG;AACH,QAAA,MAAM,aAAa;;;;;;;;;;;;;;;;;;;CAmBT,CAAA;AAEV,MAAM,MAAM,oBAAoB,GAAG,MAAM,OAAO,aAAa,CAAA;AAE7D,MAAM,WAAW,8BAA8B;IAC7C,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAA;IACd;;;;;OAKG;IACH,KAAK,CAAC,EAAE,oBAAoB,CAAA;IAC5B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAaD;;GAEG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,8BAA8B,GACtC,iBAAiB,CAyEnB;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,QAAQ,EAAE,iBAAiB,EAC3B,KAAK,EAAE,MAAM,EAAE,GACd,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAWrB;AAED,OAAO,EAAE,aAAa,EAAE,CAAA"}
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Default EmbeddingProvider implementation backed by OpenAI's embeddings API.
3
+ *
4
+ * Uses native `fetch` so it works in Cloudflare Workers + Node + browsers
5
+ * without an SDK dependency. Templates pass in the API key (and optionally
6
+ * a custom `baseUrl` for proxies / Azure OpenAI / OpenRouter etc.).
7
+ *
8
+ * Models supported by default:
9
+ * - `text-embedding-3-small` — 1536d, multilingual, cheapest. **Default.**
10
+ * - `text-embedding-3-large` — 3072d, multilingual, higher quality.
11
+ * - `text-embedding-ada-002` — 1536d, legacy (kept for migration paths).
12
+ *
13
+ * See `docs/architecture/catalog-rag-architecture.md` §6 for the design.
14
+ */
15
+ import { chunkForBatch, EMBEDDING_BATCH_TOO_LARGE, EMBEDDING_INPUT_TOO_LONG, EMBEDDING_PROVIDER_ERROR, EmbeddingProviderError, } from "./contract.js";
16
+ /**
17
+ * Known OpenAI embedding models. Adding a new entry here is the only place
18
+ * to touch when OpenAI ships a new model — `createOpenAIEmbeddingProvider`
19
+ * picks up the dimensions / batch limits automatically.
20
+ */
21
+ const OPENAI_MODELS = {
22
+ "text-embedding-3-small": {
23
+ dimensions: 1536,
24
+ maxTokensPerInput: 8191,
25
+ maxBatchSize: 2048,
26
+ multilingual: true,
27
+ },
28
+ "text-embedding-3-large": {
29
+ dimensions: 3072,
30
+ maxTokensPerInput: 8191,
31
+ maxBatchSize: 2048,
32
+ multilingual: true,
33
+ },
34
+ "text-embedding-ada-002": {
35
+ dimensions: 1536,
36
+ maxTokensPerInput: 8191,
37
+ maxBatchSize: 2048,
38
+ multilingual: true,
39
+ },
40
+ };
41
+ /**
42
+ * Build the default OpenAI EmbeddingProvider.
43
+ */
44
+ export function createOpenAIEmbeddingProvider(options) {
45
+ const model = options.model ?? "text-embedding-3-small";
46
+ const modelInfo = OPENAI_MODELS[model];
47
+ const baseUrl = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
48
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
49
+ const capabilities = {
50
+ modelId: options.modelId ?? `openai/${model}/v1`,
51
+ dimensions: modelInfo.dimensions,
52
+ maxTokensPerInput: modelInfo.maxTokensPerInput,
53
+ maxBatchSize: modelInfo.maxBatchSize,
54
+ supportedLanguages: modelInfo.multilingual ? null : undefined,
55
+ };
56
+ return {
57
+ capabilities,
58
+ async embed(texts) {
59
+ if (texts.length === 0)
60
+ return [];
61
+ if (texts.length > capabilities.maxBatchSize) {
62
+ throw new EmbeddingProviderError(EMBEDDING_BATCH_TOO_LARGE, `OpenAI embedding batch size ${texts.length} exceeds max ${capabilities.maxBatchSize}; chunk inputs via chunkForBatch() first`);
63
+ }
64
+ // Rough byte-length sanity check — actual token limits enforced by API.
65
+ // We pass through and let OpenAI return its specific error if too long.
66
+ const url = `${baseUrl}/embeddings`;
67
+ const body = JSON.stringify({ input: texts, model });
68
+ let response;
69
+ try {
70
+ response = await fetchImpl(url, {
71
+ method: "POST",
72
+ headers: {
73
+ "Content-Type": "application/json",
74
+ Authorization: `Bearer ${options.apiKey}`,
75
+ },
76
+ body,
77
+ });
78
+ }
79
+ catch (cause) {
80
+ throw new EmbeddingProviderError(EMBEDDING_PROVIDER_ERROR, "OpenAI embeddings request failed at the network layer", cause);
81
+ }
82
+ if (!response.ok) {
83
+ const text = await response.text().catch(() => "");
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(text);
87
+ }
88
+ catch {
89
+ // ignore parse failure; surface the raw text
90
+ }
91
+ const message = parsed?.error?.message ?? text ?? `HTTP ${response.status}`;
92
+ const code = parsed?.error?.code === "context_length_exceeded"
93
+ ? EMBEDDING_INPUT_TOO_LONG
94
+ : EMBEDDING_PROVIDER_ERROR;
95
+ throw new EmbeddingProviderError(code, `OpenAI embeddings request failed (${response.status}): ${message}`);
96
+ }
97
+ const json = (await response.json());
98
+ // OpenAI returns vectors in `data` with explicit `index` — sort to
99
+ // guarantee output order matches input order regardless of API
100
+ // implementation detail.
101
+ const sorted = [...json.data].sort((a, b) => a.index - b.index);
102
+ return sorted.map((entry) => entry.embedding);
103
+ },
104
+ };
105
+ }
106
+ /**
107
+ * Helper that chunks a large input array into batches sized to the model's
108
+ * `maxBatchSize` and concatenates the per-batch results. Use this when
109
+ * embedding more than `maxBatchSize` texts at once.
110
+ */
111
+ export async function embedBatched(provider, texts) {
112
+ if (texts.length <= provider.capabilities.maxBatchSize) {
113
+ return provider.embed(texts);
114
+ }
115
+ const batches = chunkForBatch(texts, provider.capabilities.maxBatchSize);
116
+ const results = [];
117
+ for (const batch of batches) {
118
+ const vectors = await provider.embed(batch);
119
+ results.push(...vectors);
120
+ }
121
+ return results;
122
+ }
123
+ export { OPENAI_MODELS };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=openai.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openai.test.d.ts","sourceRoot":"","sources":["../../src/embeddings/openai.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { EMBEDDING_BATCH_TOO_LARGE, EmbeddingProviderError } from "./contract.js";
3
+ import { createOpenAIEmbeddingProvider, embedBatched, OPENAI_MODELS } from "./openai.js";
4
+ function mockFetch(response) {
5
+ return vi.fn(async () => {
6
+ return new Response(typeof response.json === "function" ? JSON.stringify(await response.json()) : "", {
7
+ status: response.status ?? (response.ok ? 200 : 400),
8
+ });
9
+ });
10
+ }
11
+ describe("createOpenAIEmbeddingProvider", () => {
12
+ it("declares correct capabilities for the default model", () => {
13
+ const provider = createOpenAIEmbeddingProvider({
14
+ apiKey: "sk-test",
15
+ fetchImpl: mockFetch({ ok: true, json: async () => ({ data: [] }) }),
16
+ });
17
+ expect(provider.capabilities.modelId).toBe("openai/text-embedding-3-small/v1");
18
+ expect(provider.capabilities.dimensions).toBe(1536);
19
+ expect(provider.capabilities.maxBatchSize).toBe(2048);
20
+ });
21
+ it("uses the configured model and stamps a matching modelId", () => {
22
+ const provider = createOpenAIEmbeddingProvider({
23
+ apiKey: "sk-test",
24
+ model: "text-embedding-3-large",
25
+ fetchImpl: mockFetch({ ok: true, json: async () => ({ data: [] }) }),
26
+ });
27
+ expect(provider.capabilities.modelId).toBe("openai/text-embedding-3-large/v1");
28
+ expect(provider.capabilities.dimensions).toBe(OPENAI_MODELS["text-embedding-3-large"].dimensions);
29
+ });
30
+ it("returns vectors in input order even when API returns shuffled indices", async () => {
31
+ const provider = createOpenAIEmbeddingProvider({
32
+ apiKey: "sk-test",
33
+ fetchImpl: mockFetch({
34
+ ok: true,
35
+ json: async () => ({
36
+ object: "list",
37
+ model: "text-embedding-3-small",
38
+ usage: { prompt_tokens: 5, total_tokens: 5 },
39
+ data: [
40
+ { object: "embedding", index: 2, embedding: [0.3] },
41
+ { object: "embedding", index: 0, embedding: [0.1] },
42
+ { object: "embedding", index: 1, embedding: [0.2] },
43
+ ],
44
+ }),
45
+ }),
46
+ });
47
+ const vectors = await provider.embed(["a", "b", "c"]);
48
+ expect(vectors).toEqual([[0.1], [0.2], [0.3]]);
49
+ });
50
+ it("returns an empty array for an empty input without hitting the API", async () => {
51
+ const fetchSpy = vi.fn();
52
+ const provider = createOpenAIEmbeddingProvider({ apiKey: "sk-test", fetchImpl: fetchSpy });
53
+ const vectors = await provider.embed([]);
54
+ expect(vectors).toEqual([]);
55
+ expect(fetchSpy).not.toHaveBeenCalled();
56
+ });
57
+ it("throws EMBEDDING_BATCH_TOO_LARGE when input exceeds maxBatchSize", async () => {
58
+ const provider = createOpenAIEmbeddingProvider({
59
+ apiKey: "sk-test",
60
+ fetchImpl: mockFetch({ ok: true, json: async () => ({ data: [] }) }),
61
+ });
62
+ const tooMany = Array.from({ length: 2049 }, (_, i) => `text-${i}`);
63
+ await expect(provider.embed(tooMany)).rejects.toMatchObject({
64
+ code: EMBEDDING_BATCH_TOO_LARGE,
65
+ });
66
+ });
67
+ it("wraps non-2xx responses as EmbeddingProviderError", async () => {
68
+ const provider = createOpenAIEmbeddingProvider({
69
+ apiKey: "sk-test",
70
+ fetchImpl: mockFetch({
71
+ ok: false,
72
+ status: 401,
73
+ json: async () => ({ error: { message: "invalid api key", code: "invalid_api_key" } }),
74
+ }),
75
+ });
76
+ await expect(provider.embed(["x"])).rejects.toBeInstanceOf(EmbeddingProviderError);
77
+ });
78
+ it("respects a custom baseUrl (Azure / proxy / OpenRouter)", async () => {
79
+ const fetchSpy = vi.fn(async () => {
80
+ return new Response(JSON.stringify({ data: [] }), { status: 200 });
81
+ });
82
+ const provider = createOpenAIEmbeddingProvider({
83
+ apiKey: "sk-test",
84
+ baseUrl: "https://my-proxy.example.com/openai/v1/",
85
+ fetchImpl: fetchSpy,
86
+ });
87
+ await provider.embed(["x"]);
88
+ // biome-ignore lint/suspicious/noExplicitAny: vi.fn return type
89
+ const calledWith = fetchSpy.mock.calls[0]?.[0];
90
+ // Trailing slash stripped; path appended.
91
+ expect(calledWith).toBe("https://my-proxy.example.com/openai/v1/embeddings");
92
+ });
93
+ it("sends the api key as a Bearer header", async () => {
94
+ const fetchSpy = vi.fn(async () => {
95
+ return new Response(JSON.stringify({ data: [] }), { status: 200 });
96
+ });
97
+ const provider = createOpenAIEmbeddingProvider({
98
+ apiKey: "sk-test-12345",
99
+ fetchImpl: fetchSpy,
100
+ });
101
+ await provider.embed(["x"]);
102
+ // biome-ignore lint/suspicious/noExplicitAny: vi.fn return type
103
+ const init = fetchSpy.mock.calls[0]?.[1];
104
+ const headers = init.headers;
105
+ expect(headers.Authorization).toBe("Bearer sk-test-12345");
106
+ });
107
+ });
108
+ describe("embedBatched", () => {
109
+ it("returns results unchanged when input fits in a single batch", async () => {
110
+ const provider = createOpenAIEmbeddingProvider({
111
+ apiKey: "sk-test",
112
+ fetchImpl: mockFetch({
113
+ ok: true,
114
+ json: async () => ({
115
+ data: [
116
+ { index: 0, embedding: [0.1] },
117
+ { index: 1, embedding: [0.2] },
118
+ ],
119
+ }),
120
+ }),
121
+ });
122
+ const result = await embedBatched(provider, ["a", "b"]);
123
+ expect(result).toEqual([[0.1], [0.2]]);
124
+ });
125
+ it("chunks oversized inputs into batches and concatenates the results", async () => {
126
+ let callCount = 0;
127
+ const fetchImpl = vi.fn(async () => {
128
+ callCount++;
129
+ // Return one fake vector per input for whatever batch came in.
130
+ return new Response(JSON.stringify({
131
+ data: [
132
+ { index: 0, embedding: [callCount * 0.1] },
133
+ { index: 1, embedding: [callCount * 0.1 + 0.01] },
134
+ ],
135
+ }), { status: 200 });
136
+ });
137
+ const provider = createOpenAIEmbeddingProvider({
138
+ apiKey: "sk-test",
139
+ fetchImpl,
140
+ // Override capabilities by using a baseUrl trick? Easier: use the
141
+ // default model and pass exactly maxBatchSize+ items. But that's
142
+ // 2048+ which is unwieldy. Instead, since we control `embedBatched`'s
143
+ // chunking via `provider.capabilities.maxBatchSize`, fake a smaller
144
+ // batch size by constructing a minimal stub provider.
145
+ });
146
+ // Tweak capabilities for the test: replace with a tiny-batch-size proxy.
147
+ const tinyBatchProvider = {
148
+ capabilities: { ...provider.capabilities, maxBatchSize: 2 },
149
+ embed: provider.embed.bind(provider),
150
+ };
151
+ const result = await embedBatched(tinyBatchProvider, ["a", "b", "c", "d"]);
152
+ // Two batches × 2 vectors each = 4 vectors total.
153
+ expect(result).toHaveLength(4);
154
+ // biome-ignore lint/suspicious/noExplicitAny: vi.fn type
155
+ expect(fetchImpl.mock.calls.length).toBe(2);
156
+ });
157
+ });
@@ -0,0 +1,7 @@
1
+ export { chunkForBatch, EMBEDDING_BATCH_TOO_LARGE, EMBEDDING_INPUT_TOO_LONG, EMBEDDING_PROVIDER_ERROR, type EmbeddingProvider, type EmbeddingProviderCapabilities, EmbeddingProviderError, } from "./embeddings/contract.js";
2
+ export { createGeminiEmbeddingProvider, GEMINI_MODELS, type GeminiEmbeddingModel, type GeminiEmbeddingProviderOptions, type GeminiTaskType, } from "./embeddings/gemini.js";
3
+ export { type EmbeddingMigrationPlan, isActiveEmbeddingModel, planEmbeddingMigration, stampEmbeddingModelId, validateEmbeddingCompatibility, } from "./embeddings/model-registry.js";
4
+ export { createOpenAIEmbeddingProvider, embedBatched, OPENAI_MODELS, type OpenAIEmbeddingModel, type OpenAIEmbeddingProviderOptions, } from "./embeddings/openai.js";
5
+ export { type FederatedSearchOptions, federateAudienceSearch, mergeAndDedupe, } from "./search/federate.js";
6
+ export { executeBYOVectorSearch, executeSemanticSearch, type SemanticSearchOptions, } from "./search/semantic.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,aAAa,EACb,yBAAyB,EACzB,wBAAwB,EACxB,wBAAwB,EACxB,KAAK,iBAAiB,EACtB,KAAK,6BAA6B,EAClC,sBAAsB,GACvB,MAAM,0BAA0B,CAAA;AAEjC,OAAO,EACL,6BAA6B,EAC7B,aAAa,EACb,KAAK,oBAAoB,EACzB,KAAK,8BAA8B,EACnC,KAAK,cAAc,GACpB,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EACL,KAAK,sBAAsB,EAC3B,sBAAsB,EACtB,sBAAsB,EACtB,qBAAqB,EACrB,8BAA8B,GAC/B,MAAM,gCAAgC,CAAA;AAEvC,OAAO,EACL,6BAA6B,EAC7B,YAAY,EACZ,aAAa,EACb,KAAK,oBAAoB,EACzB,KAAK,8BAA8B,GACpC,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACL,KAAK,sBAAsB,EAC3B,sBAAsB,EACtB,cAAc,GACf,MAAM,sBAAsB,CAAA;AAE7B,OAAO,EACL,sBAAsB,EACtB,qBAAqB,EACrB,KAAK,qBAAqB,GAC3B,MAAM,sBAAsB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // Embedding contract + standard error codes.
2
+ export { chunkForBatch, EMBEDDING_BATCH_TOO_LARGE, EMBEDDING_INPUT_TOO_LONG, EMBEDDING_PROVIDER_ERROR, EmbeddingProviderError, } from "./embeddings/contract.js";
3
+ // Gemini provider (Google AI Studio).
4
+ export { createGeminiEmbeddingProvider, GEMINI_MODELS, } from "./embeddings/gemini.js";
5
+ // Model registry helpers — validation + migration planning.
6
+ export { isActiveEmbeddingModel, planEmbeddingMigration, stampEmbeddingModelId, validateEmbeddingCompatibility, } from "./embeddings/model-registry.js";
7
+ // OpenAI provider.
8
+ export { createOpenAIEmbeddingProvider, embedBatched, OPENAI_MODELS, } from "./embeddings/openai.js";
9
+ export { federateAudienceSearch, mergeAndDedupe, } from "./search/federate.js";
10
+ // Search orchestration — semantic / hybrid / BYO-vector + federated.
11
+ export { executeBYOVectorSearch, executeSemanticSearch, } from "./search/semantic.js";
@@ -0,0 +1,57 @@
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
+ import type { IndexerAdapter, SearchRequest, SearchResults, Visibility } from "@voyantjs/catalog";
27
+ export interface FederatedSearchOptions {
28
+ adapter: IndexerAdapter;
29
+ /**
30
+ * The actor making the request. The federation helper enforces:
31
+ * - `customer` / `partner` / `supplier` actors → may search only
32
+ * their own audience pool (no federation).
33
+ * - `staff` actors → may search any combination of audience pools.
34
+ */
35
+ actor: Visibility;
36
+ /** The audience pools to federate across. Must be a subset of allowed pools per actor. */
37
+ searchAudiences: Visibility[];
38
+ /** The vertical (entity_module) to search. */
39
+ vertical: string;
40
+ /** Locale + market for every slice. */
41
+ locale: string;
42
+ market: string;
43
+ /** The base search request — same shape passed to a single-slice search. */
44
+ request: SearchRequest;
45
+ }
46
+ /**
47
+ * Federate a search across multiple audience pools. Returns a unified
48
+ * `SearchResults` with deduplicated hits ranked by score.
49
+ */
50
+ export declare function federateAudienceSearch(options: FederatedSearchOptions): Promise<SearchResults>;
51
+ /**
52
+ * Merge several `SearchResults` into one, deduplicating by hit id and
53
+ * keeping the highest-scoring instance. Total is the count of unique ids
54
+ * across all pools (after dedupe).
55
+ */
56
+ export declare function mergeAndDedupe(perSlice: ReadonlyArray<SearchResults>, limit?: number): SearchResults;
57
+ //# sourceMappingURL=federate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"federate.d.ts","sourceRoot":"","sources":["../../src/search/federate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EACV,cAAc,EAGd,aAAa,EACb,aAAa,EACb,UAAU,EACX,MAAM,mBAAmB,CAAA;AAE1B,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,cAAc,CAAA;IACvB;;;;;OAKG;IACH,KAAK,EAAE,UAAU,CAAA;IACjB,0FAA0F;IAC1F,eAAe,EAAE,UAAU,EAAE,CAAA;IAC7B,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAA;IAChB,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,4EAA4E;IAC5E,OAAO,EAAE,aAAa,CAAA;CACvB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,aAAa,CAAC,CAqCxB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,aAAa,CAAC,aAAa,CAAC,EACtC,KAAK,CAAC,EAAE,MAAM,GACb,aAAa,CAsBf"}