feat(ts-sdk): add FastEmbed embedding provider (#5862)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
@@ -3,16 +3,22 @@ import { DEFAULT_MEMORY_CONFIG } from "./defaults";
|
||||
|
||||
export class ConfigManager {
|
||||
static mergeConfig(userConfig: Partial<MemoryConfig> = {}): MemoryConfig {
|
||||
const embedderProvider =
|
||||
userConfig.embedder?.provider || DEFAULT_MEMORY_CONFIG.embedder.provider;
|
||||
const embedderProviderKey = embedderProvider.toLowerCase();
|
||||
const mergedConfig = {
|
||||
version: userConfig.version || DEFAULT_MEMORY_CONFIG.version,
|
||||
embedder: {
|
||||
provider:
|
||||
userConfig.embedder?.provider ||
|
||||
DEFAULT_MEMORY_CONFIG.embedder.provider,
|
||||
provider: embedderProvider,
|
||||
config: (() => {
|
||||
const defaultConf = DEFAULT_MEMORY_CONFIG.embedder.config;
|
||||
const userConf = userConfig.embedder?.config;
|
||||
let finalModel: string | any = defaultConf.model;
|
||||
// The default embedder model (OpenAI's text-embedding-3-small) only
|
||||
// makes sense for API-based providers. FastEmbed has its own fixed
|
||||
// model set and default, so leave the model unset here and let
|
||||
// FastEmbedEmbedder fall back to its own default.
|
||||
let finalModel: string | any =
|
||||
embedderProviderKey === "fastembed" ? undefined : defaultConf.model;
|
||||
|
||||
if (userConf?.model && typeof userConf.model === "object") {
|
||||
finalModel = userConf.model;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { EmbeddingModel, FlagEmbedding } from "fastembed";
|
||||
import { Embedder } from "./base";
|
||||
import { EmbeddingConfig } from "../types";
|
||||
|
||||
const DEFAULT_MODEL = EmbeddingModel.BGESmallENV15;
|
||||
type FastEmbedModel = Exclude<EmbeddingModel, EmbeddingModel.CUSTOM>;
|
||||
|
||||
// FastEmbed only ships a fixed set of ONNX models. Keep the list handy so we can
|
||||
// reject unknown model names up front with a clear message instead of letting
|
||||
// FlagEmbedding.init fail later with an opaque download error.
|
||||
const SUPPORTED_MODELS = Object.values(EmbeddingModel).filter(
|
||||
(model) => model !== EmbeddingModel.CUSTOM,
|
||||
) as FastEmbedModel[];
|
||||
|
||||
export class FastEmbedEmbedder implements Embedder {
|
||||
private modelName: FastEmbedModel;
|
||||
private embeddingModel?: Promise<FlagEmbedding>;
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
if (typeof config.model === "string" && config.model.length > 0) {
|
||||
if (!SUPPORTED_MODELS.includes(config.model as FastEmbedModel)) {
|
||||
throw new Error(
|
||||
`Unsupported FastEmbed model "${config.model}". ` +
|
||||
`Supported models: ${SUPPORTED_MODELS.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
this.modelName = config.model as FastEmbedModel;
|
||||
} else {
|
||||
this.modelName = DEFAULT_MODEL;
|
||||
}
|
||||
}
|
||||
|
||||
private getEmbeddingModel(): Promise<FlagEmbedding> {
|
||||
if (!this.embeddingModel) {
|
||||
this.embeddingModel = FlagEmbedding.init({
|
||||
model: this.modelName,
|
||||
}).catch((error) => {
|
||||
this.embeddingModel = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return this.embeddingModel;
|
||||
}
|
||||
|
||||
private normalizeInput(text: string): string {
|
||||
return text.replace(/\n/g, " ");
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const normalizedText = this.normalizeInput(text);
|
||||
const model = await this.getEmbeddingModel();
|
||||
|
||||
for await (const batch of model.embed([normalizedText])) {
|
||||
const embedding = batch[0];
|
||||
if (embedding !== undefined) {
|
||||
return embedding;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("FastEmbed embed() returned no embeddings");
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
const normalizedTexts = texts.map((text) => this.normalizeInput(text));
|
||||
const model = await this.getEmbeddingModel();
|
||||
const embeddings: number[][] = [];
|
||||
|
||||
for await (const batch of model.embed(normalizedTexts)) {
|
||||
embeddings.push(...batch);
|
||||
}
|
||||
|
||||
return embeddings;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export * from "./embeddings/together";
|
||||
export * from "./embeddings/google";
|
||||
export * from "./embeddings/azure";
|
||||
export * from "./embeddings/langchain";
|
||||
export * from "./embeddings/fastembed";
|
||||
export * from "./llms/base";
|
||||
export * from "./llms/openai";
|
||||
export * from "./llms/google";
|
||||
|
||||
@@ -36,6 +36,7 @@ import { GoogleEmbedder } from "../embeddings/google";
|
||||
import { GoogleLLM } from "../llms/google";
|
||||
import { AzureOpenAILLM } from "../llms/azure";
|
||||
import { AzureOpenAIEmbedder } from "../embeddings/azure";
|
||||
import { FastEmbedEmbedder } from "../embeddings/fastembed";
|
||||
import { LangchainLLM } from "../llms/langchain";
|
||||
import { LangchainEmbedder } from "../embeddings/langchain";
|
||||
import { LangchainVectorStore } from "../vector_stores/langchain";
|
||||
@@ -60,6 +61,8 @@ export class EmbedderFactory {
|
||||
return new GoogleEmbedder(config);
|
||||
case "azure_openai":
|
||||
return new AzureOpenAIEmbedder(config);
|
||||
case "fastembed":
|
||||
return new FastEmbedEmbedder(config);
|
||||
case "langchain":
|
||||
return new LangchainEmbedder(config);
|
||||
default:
|
||||
|
||||
@@ -298,6 +298,38 @@ describe("ConfigManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - FastEmbed defaults", () => {
|
||||
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
it("does not inject the OpenAI default embedder model into fastembed", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "fastembed",
|
||||
config: {},
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.provider).toBe("fastembed");
|
||||
expect(cfg.embedder.config.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats FastEmbed provider casing the same way as the factory", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "FastEmbed",
|
||||
config: {},
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.provider).toBe("FastEmbed");
|
||||
expect(cfg.embedder.config.model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - LM Studio LLM config", () => {
|
||||
const baseEmbedder = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
@@ -603,6 +635,30 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
expect(result.results[0].memory).toBe("User likes hiking");
|
||||
});
|
||||
|
||||
it("preserves the FastEmbed provider default model through the Memory config path", async () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "fastembed",
|
||||
config: {},
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key" },
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ filters: { user_id: "u1" } });
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"fastembed",
|
||||
expect.objectContaining({
|
||||
model: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("add flow works with lmstudio LLM for fact extraction", async () => {
|
||||
mockLlm.generateResponse.mockResolvedValueOnce(
|
||||
'{"facts":["User loves sushi"]}',
|
||||
|
||||
@@ -20,6 +20,11 @@ jest.mock("../src/embeddings/google", () => ({
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/fastembed", () => ({
|
||||
FastEmbedEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "fastembed-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/azure", () => ({
|
||||
AzureOpenAIEmbedder: jest
|
||||
.fn()
|
||||
@@ -193,6 +198,7 @@ describe("EmbedderFactory", () => {
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["fastembed"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
["together"],
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* FastEmbed Embedder — unit tests (mocked FastEmbed client).
|
||||
* Verifies async generator handling for embed and embedBatch.
|
||||
*/
|
||||
|
||||
const mockInit = jest.fn();
|
||||
const mockEmbed = jest.fn();
|
||||
|
||||
jest.mock("fastembed", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
EmbeddingModel: {
|
||||
BGESmallENV15: "fast-bge-small-en-v1.5",
|
||||
},
|
||||
FlagEmbedding: {
|
||||
init: mockInit,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { FastEmbedEmbedder } from "../src/embeddings/fastembed";
|
||||
|
||||
const mockVectorBatch = [
|
||||
[0.1, 0.2],
|
||||
[0.3, 0.4],
|
||||
];
|
||||
const mockEmbedding = [0.1, 0.2, 0.3];
|
||||
|
||||
const createEmbeddingGenerator = async function* (batches: number[][][]) {
|
||||
for (const batch of batches) {
|
||||
yield batch;
|
||||
}
|
||||
};
|
||||
|
||||
describe("FastEmbedEmbedder (unit)", () => {
|
||||
beforeEach(() => {
|
||||
mockInit.mockReset();
|
||||
mockEmbed.mockReset();
|
||||
|
||||
mockInit.mockResolvedValue({
|
||||
embed: mockEmbed,
|
||||
});
|
||||
});
|
||||
|
||||
it("embed() initializes lazily, normalizes newlines, and returns the first vector", async () => {
|
||||
mockEmbed.mockReturnValue(
|
||||
createEmbeddingGenerator([[mockEmbedding], mockVectorBatch]),
|
||||
);
|
||||
const embedder = new FastEmbedEmbedder({
|
||||
model: "fast-bge-small-en-v1.5",
|
||||
});
|
||||
|
||||
expect(mockInit).not.toHaveBeenCalled();
|
||||
|
||||
const result = await embedder.embed("Example\ntext");
|
||||
|
||||
expect(mockInit).toHaveBeenCalledTimes(1);
|
||||
expect(mockInit).toHaveBeenCalledWith({
|
||||
model: "fast-bge-small-en-v1.5",
|
||||
});
|
||||
expect(mockEmbed).toHaveBeenCalledTimes(1);
|
||||
expect(mockEmbed.mock.calls[0][0]).toEqual(["Example text"]);
|
||||
expect(result).toEqual(mockEmbedding);
|
||||
});
|
||||
|
||||
it("embedBatch() consumes all async embedding batches and reuses the initialized model", async () => {
|
||||
mockEmbed.mockReturnValueOnce(createEmbeddingGenerator([[mockEmbedding]]));
|
||||
mockEmbed.mockReturnValueOnce(
|
||||
createEmbeddingGenerator([[mockEmbedding], mockVectorBatch]),
|
||||
);
|
||||
const embedder = new FastEmbedEmbedder({});
|
||||
|
||||
await embedder.embed("warmup");
|
||||
const result = await embedder.embedBatch([
|
||||
"first text",
|
||||
"second text",
|
||||
"third text",
|
||||
]);
|
||||
|
||||
expect(mockEmbed).toHaveBeenCalledWith([
|
||||
"first text",
|
||||
"second text",
|
||||
"third text",
|
||||
]);
|
||||
expect(mockInit).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([mockEmbedding, ...mockVectorBatch]);
|
||||
});
|
||||
|
||||
it("embed() throws when FastEmbed yields no embeddings", async () => {
|
||||
mockEmbed.mockReturnValue(createEmbeddingGenerator([]));
|
||||
const embedder = new FastEmbedEmbedder({});
|
||||
|
||||
await expect(embedder.embed("missing")).rejects.toThrow(
|
||||
"FastEmbed embed() returned no embeddings",
|
||||
);
|
||||
});
|
||||
|
||||
it("embed() retries initialization after a transient init failure", async () => {
|
||||
mockInit.mockRejectedValueOnce(new Error("transient init failure"));
|
||||
mockInit.mockResolvedValueOnce({
|
||||
embed: mockEmbed,
|
||||
});
|
||||
mockEmbed.mockReturnValue(createEmbeddingGenerator([[mockEmbedding]]));
|
||||
const embedder = new FastEmbedEmbedder({});
|
||||
|
||||
await expect(embedder.embed("first attempt")).rejects.toThrow(
|
||||
"transient init failure",
|
||||
);
|
||||
|
||||
const result = await embedder.embed("second attempt");
|
||||
|
||||
expect(mockInit).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual(mockEmbedding);
|
||||
});
|
||||
|
||||
it("throws a clear error for an unsupported model instead of failing at init", () => {
|
||||
expect(
|
||||
() => new FastEmbedEmbedder({ model: "text-embedding-3-small" }),
|
||||
).toThrow(/Unsupported FastEmbed model "text-embedding-3-small"/);
|
||||
expect(mockInit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user