Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/__tests__/embeddings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,26 @@ describe("embeddingConfigHash", () => {
);
});
});

describe("prepareEmbeddingText task prefixes", () => {
const item = { title: "Fix login bug", body: "resolves the redirect loop", type: "pr" };

it("prefixes the clustering task for a model trained to expect one", () => {
const result = prepareEmbeddingText(item, "embeddinggemma");

expect(result.startsWith("task: clustering | query: ")).toBe(true);
expect(result).toContain("Fix login bug");
});

it("leaves output byte-identical for a model with no task prompt", () => {
expect(prepareEmbeddingText(item, "nomic-embed-text-v2-moe")).toBe(prepareEmbeddingText(item));
});

it("adds no prefix for an unrecognised model, so a new model cannot be silently mangled", () => {
expect(prepareEmbeddingText(item, "some-future-model")).toBe(prepareEmbeddingText(item));
});

it("matches a tagged model variant, since ollama slugs carry a :tag", () => {
expect(prepareEmbeddingText(item, "embeddinggemma:300m").startsWith("task: clustering | query: ")).toBe(true);
});
});
2 changes: 1 addition & 1 deletion src/benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ export async function runBenchmarkForModel(

for (let i = 0; i < allItems.length; i += batchSize) {
const batch = allItems.slice(i, i + batchSize);
const texts = batch.map((item) => prepareEmbeddingText(item));
const texts = batch.map((item) => prepareEmbeddingText(item, model));

let embeddings: number[][];
try {
Expand Down
36 changes: 33 additions & 3 deletions src/embeddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,35 @@ export async function createEmbeddingProvider(config: ProviderConfig): Promise<E
// the embedding config hash so a format change invalidates cached embeddings and
// the scan warns to re-embed, instead of silently mixing old and new text vectors.
// v1: "Pull Request:"/"Issue:" type prefix. v2: no prefix.
export const EMBEDDING_TEXT_VERSION = 2;
// v3: per-model task prefix (see TASK_PREFIXES). Models with no entry emit
// byte-identical text to v2, but the version still bumps: the model name alone
// cannot distinguish vectors cached before a prefix existed for that model.
export const EMBEDDING_TEXT_VERSION = 3;

/**
* Task instructions some models are trained to receive. Omitting the prompt on
* a model that expects one measurably degrades it, so benchmarking such a model
* without this would under-report it and wrongly favour the incumbent.
*
* Keyed by ollama slug with the `:tag` stripped. Deliberately an allowlist: an
* unknown model gets no prefix, because inventing an instruction format a model
* was not trained on is worse than sending none.
*
* Clustering rather than retrieval: this pipeline compares items to each other
* to find duplicates. It has no query/document asymmetry.
*/
const TASK_PREFIXES: Readonly<Record<string, string>> = Object.freeze({
// https://ai.google.dev/gemma/docs/embeddinggemma — prompt set includes
// "task: clustering | query: " for grouping semantically similar text.
embeddinggemma: "task: clustering | query: ",
});

/** Ollama slugs carry a `:tag` (embeddinggemma:300m); the prompt is a property
* of the model family, not the quantisation. */
export function taskPrefixFor(model?: string): string {
if (!model) return "";
return TASK_PREFIXES[model.split(":")[0].trim().toLowerCase()] ?? "";
}

export type EmbeddingVectorGeneration = "native" | "provider-selected-v1" | "local-truncation-v1";

Expand Down Expand Up @@ -441,11 +469,13 @@ export function effectiveEmbeddingConfigHash(
);
}

export function prepareEmbeddingText(item: { title: string; body: string; type: string }): string {
export function prepareEmbeddingText(item: { title: string; body: string; type: string }, model?: string): string {
// No type prefix. A leading "Pull Request:" / "Issue:" token systematically
// pushes an issue away from its own fix PR in embedding space, which fragments
// a single bug across separate clusters. Only takes effect after a re-embed.
const title = (item.title || "Untitled").trim();
const body = (item.body || "").trim().slice(0, 2000);
return body ? `${title}\n\n${body}` : title;
const text = body ? `${title}\n\n${body}` : title;
// Model-specific task instruction, empty for models that do not use one.
return `${taskPrefixFor(model)}${text}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep vision embeddings in the same task space

When EMBEDDING_MODEL=embeddinggemma, scanned PR/issue vectors now include the clustering prefix, but loadAndEmbedVisionDoc still embeds raw document chunks at src/vision.ts:46-61; scoreVisionAlignment then compares these differently prompted vectors against fixed cosine thresholds. This can change alignment scores and misclassify items whenever the vision command runs with this model, so the vision chunks need an appropriate EmbeddingGemma task prompt (or both sides must otherwise use a consistent task format).

Useful? React with 👍 / 👎.

}
15 changes: 9 additions & 6 deletions src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,14 @@ export async function reEmbedStoredItems(
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const texts = batch.map((item) =>
prepareEmbeddingText({
title: item.title,
body: item.bodySnippet,
type: item.type,
}),
prepareEmbeddingText(
{
title: item.title,
body: item.bodySnippet,
type: item.type,
},
providerConfig.model,
),
);
const embeddings = await embedder.embedBatch(texts);
for (let j = 0; j < batch.length; j++) {
Expand Down Expand Up @@ -263,7 +266,7 @@ export async function runScan(

for (let i = 0; i < newItems.length; i += BATCH_SIZE) {
const batch = newItems.slice(i, i + BATCH_SIZE);
const texts = batch.map((item) => prepareEmbeddingText(item));
const texts = batch.map((item) => prepareEmbeddingText(item, env.EMBEDDING_MODEL));
const embedWithRetry = async (input: string[]): Promise<number[][]> => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
Expand Down
Loading