Repository navigation
feat(embedding): make the local model configurable #1602
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * Repro: the default local embedding model is English-only, so CJK text | ||
| * tokenizes to [UNK] and unrelated records collapse to near-identical vectors. | ||
| * | ||
| * node scripts/repro-cjk-embedding.mjs # default model | ||
| * node scripts/repro-cjk-embedding.mjs Xenova/bge-small-zh-v1.5 | ||
| * | ||
| * Requires @huggingface/transformers (already a dependency for local embeddings). | ||
| */ | ||
| import { AutoTokenizer, pipeline } from "@huggingface/transformers"; | ||
|
|
||
| const model = process.argv[2] || "Xenova/all-MiniLM-L6-v2"; | ||
|
|
||
| const sentences = [ | ||
| "量化交易策略", | ||
| "记忆系统索引", | ||
| "回测框架", | ||
| "今天天气很好", | ||
| ]; | ||
|
|
||
| const tokenizer = await AutoTokenizer.from_pretrained(model); | ||
|
|
||
| console.log(`model: ${model}\n`); | ||
| for (const text of sentences) { | ||
| const ids = (await tokenizer(text)).input_ids.tolist()[0]; | ||
| const unkCount = ids.filter( | ||
| (id) => tokenizer.decode([id], { skip_special_tokens: false }) === "[UNK]", | ||
| ).length; | ||
| const unkRatio = unkCount / ids.length; | ||
| console.log(`${text.padEnd(10)} tokens=${ids.length} [UNK]=${(unkRatio * 100).toFixed(0)}%`); | ||
| } | ||
|
|
||
| const extractor = await pipeline("feature-extraction", model, { dtype: "q8" }); | ||
| const vectors = []; | ||
| for (const text of sentences) { | ||
| const out = await extractor([text], { pooling: "mean", normalize: true }); | ||
| vectors.push(out.tolist()[0]); | ||
| } | ||
|
|
||
| const cosine = (a, b) => { | ||
| let dot = 0; | ||
| let na = 0; | ||
| let nb = 0; | ||
| for (let i = 0; i < a.length; i++) { | ||
| dot += a[i] * b[i]; | ||
| na += a[i] * a[i]; | ||
| nb += b[i] * b[i]; | ||
| } | ||
| return dot / Math.sqrt(na * nb); | ||
| }; | ||
|
|
||
| console.log("\npairwise cosine (unrelated topics should be low):"); | ||
| for (let i = 0; i < vectors.length; i++) { | ||
| for (let j = i + 1; j < vectors.length; j++) { | ||
| console.log( | ||
| ` ${sentences[i]} <-> ${sentences[j]}: ${cosine(vectors[i], vectors[j]).toFixed(3)}`, | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,66 @@ | ||
| import type { EmbeddingProvider } from "../../types.js"; | ||
| import { getEnvVar } from "../../config.js"; | ||
|
|
||
| type FeatureExtractor = ( | ||
| texts: string[], | ||
| options: { pooling: string; normalize: boolean }, | ||
| ) => Promise<{ tolist: () => number[][] }>; | ||
|
|
||
| /** | ||
| * Default on-device model. English-only: its WordPiece vocabulary contains | ||
| * roughly 500 CJK characters, so most Chinese/Japanese/Korean text tokenizes | ||
| * to `[UNK]`. Set AGENTMEMORY_LOCAL_EMBEDDING_MODEL to a multilingual model | ||
| * (e.g. Xenova/bge-small-zh-v1.5, Xenova/multilingual-e5-small) for non-English | ||
| * stores. | ||
| */ | ||
| export const DEFAULT_LOCAL_MODEL = "Xenova/all-MiniLM-L6-v2"; | ||
|
|
||
| /** | ||
| * Output widths of the models we ship defaults for. The dimension guard in | ||
| * index.ts throws on a mismatch, so an unknown model must supply | ||
| * AGENTMEMORY_LOCAL_EMBEDDING_DIMENSIONS explicitly. | ||
| */ | ||
| const MODEL_DIMENSIONS: Record<string, number> = { | ||
| "Xenova/all-MiniLM-L6-v2": 384, | ||
| "Xenova/all-MiniLM-L12-v2": 384, | ||
| "Xenova/bge-small-zh-v1.5": 512, | ||
| "Xenova/bge-base-zh-v1.5": 768, | ||
| "Xenova/multilingual-e5-small": 384, | ||
| "Xenova/multilingual-e5-base": 768, | ||
| "Xenova/multilingual-e5-large": 1024, | ||
|
Comment on lines
+28
to
+30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Handle the input prefixes required by E5 models. When a user selects one of these newly listed E5 models, 🤖 Prompt for AI Agents |
||
| }; | ||
|
|
||
| const DEFAULT_DIMENSIONS = MODEL_DIMENSIONS[DEFAULT_LOCAL_MODEL]; | ||
|
|
||
| function parseDimensions(raw: string | undefined): number | undefined { | ||
| if (raw === undefined || raw.trim().length === 0) return undefined; | ||
| const parsed = parseInt(raw, 10); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Reject dimension strings that are not whole integers. If the setting is 🤖 Prompt for AI Agents |
||
| if (!Number.isFinite(parsed) || parsed <= 0) { | ||
| throw new Error( | ||
| `AGENTMEMORY_LOCAL_EMBEDDING_DIMENSIONS must be a positive integer, got: ${raw}`, | ||
| ); | ||
| } | ||
| return parsed; | ||
| } | ||
|
|
||
| export class LocalEmbeddingProvider implements EmbeddingProvider { | ||
| readonly name = "local"; | ||
| readonly dimensions = 384; | ||
| readonly dimensions: number; | ||
| readonly model: string; | ||
| private extractor: FeatureExtractor | null = null; | ||
|
|
||
| constructor(options: { model?: string; dimensions?: number } = {}) { | ||
| this.model = | ||
| options.model?.trim() || | ||
| getEnvVar("AGENTMEMORY_LOCAL_EMBEDDING_MODEL")?.trim() || | ||
| DEFAULT_LOCAL_MODEL; | ||
|
Comment on lines
+53
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Same-width model switches corrupt semantic recall Switching Learn moreThe vector index persists embeddings across restarts, but its metadata records no model identity. The startup guard rejects only vectors whose widths differ from the active provider. Switching from one 384-dimensional local model to another therefore loads the old embeddings, while all new queries and writes use the new model. Even Example: A store indexed with Recommended fix: Persist the active embedding model identity with the vector checkpoint and compare it at startup. On a change, invalidate old vectors and schedule a full re-embedding of stored records, with a safe recovery path for existing checkpoints that lack model metadata. Was this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+53
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
# Inspect model identity and re-embedding checks in vector persistence and startup paths.
rg -n -C 4 'embeddingModel|embedding_model|modelId|model_id|provider\.model|provider\.dimensions|DROP_STALE_INDEX|VECTOR_BACKFILL|cosineSimilarity' srcRepository: rohitg00/agentmemory Length of output: 14415 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- local provider ---'
nl -ba src/providers/embedding/local.ts | sed -n '1,180p'
printf '%s\n' '--- embedding contract and wrapper ---'
nl -ba src/providers/embedding/index.ts | sed -n '1,130p'
printf '%s\n' '--- startup restore path ---'
nl -ba src/index.ts | sed -n '420,530p'
printf '%s\n' '--- persistence vector types and restore ---'
rg -n -C 5 -F -e 'PersistedVector' -e 'vectors:' -e 'embedding:' -e 'load()' -e 'restore' src/state src/index.ts | sed -n '1,260p'
printf '%s\n' '--- provider construction and identity consumers ---'
rg -n -C 4 -F -e 'new LocalEmbeddingProvider' -e 'withDimensionGuard' -e 'embeddingProvider.name' -e 'embeddingProvider.model' -e 'provider.name' srcRepository: rohitg00/agentmemory Length of output: 37676 Invalidate persisted vectors when the local model changes.
Persist the model identity with the vector index. Reject, invalidate, or re-embed the index when the identity changes. 🤖 Prompt for AI Agents |
||
| this.dimensions = | ||
| options.dimensions ?? | ||
| parseDimensions(getEnvVar("AGENTMEMORY_LOCAL_EMBEDDING_DIMENSIONS")) ?? | ||
|
Comment on lines
+55
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Configuration skill omits new embedding settings Adding Learn moreThe configuration skill's environment list is generated by env, which scans Example: The reference still says 77 recognized variables and contains neither Recommended fix: Run Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| MODEL_DIMENSIONS[this.model] ?? | ||
| DEFAULT_DIMENSIONS; | ||
|
Comment on lines
+60
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Unknown models lose vector search silently An unknown Learn moreThe provider declares a fixed output width before loading its model. An unrecognized model receives the default MiniLM width, regardless of the width the model actually emits. withDimensionGuard rejects any different-width result, and HybridSearch silently falls back to keyword results. Live vector writes also skip rejected embeddings. Example: Set Recommended fix: Require a valid explicit dimensions override for any model absent from Was this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+60
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Require a dimension override for unknown models. If a selected model is absent from 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| async embed(text: string): Promise<Float32Array> { | ||
| const [result] = await this.embedBatch([text]); | ||
| return result; | ||
|
|
@@ -39,7 +90,7 @@ export class LocalEmbeddingProvider implements EmbeddingProvider { | |
| } | ||
| this.extractor = (await transformers.pipeline( | ||
| "feature-extraction", | ||
| "Xenova/all-MiniLM-L6-v2", | ||
| this.model, | ||
| { dtype: "q8" }, | ||
| )) as FeatureExtractor; | ||
| return this.extractor; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Exclude special tokens from the unknown-token ratio.
tokenizer(text)adds special tokens by default. Line 26 includes those tokens inids, but they cannot count as[UNK]. For these short sentences, the reported percentage understates the share of text tokens that the model cannot represent. Tokenize with{ add_special_tokens: false }before calculating the ratio. (huggingface.co)Proposed change
📝 Committable suggestion
🤖 Prompt for AI Agents