feat(embeddings): compact, description-forward embedding text (#2333) (#2334)

This commit is contained in:
Gergő Magyar 2026-07-01 07:37:16 +01:00 • committed by GitHub
parent f5a2e6a248
commit 905b7dfa21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 459 additions and 123 deletions

View file

@ -26,7 +26,6 @@ import {
type EmbeddableNode,
type SemanticSearchResult,
type ModelProgress,
type EmbeddingContext,
EMBEDDABLE_LABELS,
isShortLabel,
LABEL_METHOD,
@ -80,7 +79,7 @@ const ensureVectorExtensionAvailable = async (): Promise<boolean> => {
* invalidate existing vectors, such as metadata/header shape changes,
* structural container context changes, or preceding-context formatting rules.
*/
export const EMBEDDING_TEXT_VERSION = 'v2';
export const EMBEDDING_TEXT_VERSION = 'v4';
/**
* Compute a stable content fingerprint for an embeddable node.
@ -255,6 +254,42 @@ export interface EmbeddingPipelineResult {
semanticMode: 'vector-index' | 'exact-scan';
}
/**
* DELETE stale embedding rows for the given nodeIds so they can be re-inserted.
*
* Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the
* sanctioned pattern. A `"does not exist"` error means the rows are already gone
* (safe to proceed); any other error risks vector-index corruption, so it
* propagates and aborts the pipeline.
*
* Called per-batch (just before each batch's INSERT), not once up front — see
* the caller comment / KTD7: an up-front bulk delete of every stale row leaves
* the whole index deleted-not-reinserted if the re-embed is interrupted. Per-batch
* interleaving bounds that window to a single batch.
*/
const deleteStaleEmbeddingRows = async (
executeWithReusedStatement: (
cypher: string,
paramsList: Array<Record<string, any>>,
) => Promise<void>,
nodeIds: string[],
): Promise<void> => {
if (nodeIds.length === 0) return;
try {
await executeWithReusedStatement(
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`,
nodeIds.map((nodeId) => ({ nodeId })),
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('does not exist')) {
throw new Error(
`[embed] Failed to delete stale embedding rows — aborting to prevent vector-index corruption: ${msg}`,
);
}
}
};
/**
* Run the embedding pipeline
*
@ -263,11 +298,9 @@ export interface EmbeddingPipelineResult {
* @param onProgress - Callback for progress updates
* @param config - Optional configuration override
* @param skipNodeIds - Optional set of node IDs that already have embeddings (incremental mode)
* @param context - Optional repo/server context for metadata enrichment
* @param existingEmbeddings - Optional map of nodeId → contentHash for incremental mode.
* Nodes whose hash matches are skipped; nodes with a changed hash are DELETE'd
* and re-embedded; nodes not in the map are embedded fresh.
*/
export const runEmbeddingPipeline = async (
executeQuery: (cypher: string) => Promise<any[]>,
@ -278,7 +311,6 @@ export const runEmbeddingPipeline = async (
onProgress: EmbeddingProgressCallback,
config: Partial<EmbeddingConfig> = {},
skipNodeIds?: Set<string>,
context?: EmbeddingContext,
existingEmbeddings?: Map<string, string>,
): Promise<EmbeddingPipelineResult> => {
const finalConfig = resolveEmbeddingConfig(config);
@ -321,21 +353,16 @@ export const runEmbeddingPipeline = async (
// Phase 2: Query embeddable nodes
let nodes = await queryEmbeddableNodes(executeQuery);
// Apply context metadata
if (context?.repoName) {
for (const node of nodes) {
node.repoName = context.repoName;
node.serverName = context.serverName;
}
}
// Incremental mode: compare content hashes, delete stale rows, skip fresh ones.
// Computed hashes for stale nodes are cached so batchInsertEmbeddings can reuse them
// (avoids double computation).
const computedStaleHashes = new Map<string, string>();
// Stale rows are DELETE'd per-batch (just before each batch's INSERT) rather
// than all up front — see U6 / KTD7. `staleNodeIds` is consulted inside the
// batch loop; it stays empty in full (non-incremental) mode so no deletes fire.
const staleNodeIds = new Set<string>();
if (existingEmbeddings && existingEmbeddings.size > 0) {
const beforeCount = nodes.length;
const staleNodeIds: string[] = [];
nodes = nodes.filter((n) => {
const existingHash = existingEmbeddings.get(n.id);
if (existingHash === undefined) {
@ -346,40 +373,16 @@ export const runEmbeddingPipeline = async (
if (currentHash !== existingHash) {
// Content changed — cache hash for reuse during insert, mark for DELETE + re-embed
computedStaleHashes.set(n.id, currentHash);
staleNodeIds.push(n.id);
staleNodeIds.add(n.id);
return true;
}
// Hash matches — skip (fresh); no need to cache hash for skipped nodes
return false;
});
// DELETE stale embedding rows so they can be re-inserted
// (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern)
if (staleNodeIds.length > 0) {
if (isDev) {
logger.info(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
}
try {
await executeWithReusedStatement(
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`,
staleNodeIds.map((nodeId) => ({ nodeId })),
);
} catch (err) {
// "does not exist" = rows already gone — safe to proceed.
// All other errors risk vector-index corruption (Kuzu requires DELETE-before-INSERT
// for vector-indexed properties) — propagate so the pipeline aborts cleanly.
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('does not exist')) {
throw new Error(
`[embed] Failed to delete stale embedding rows — aborting to prevent vector-index corruption: ${msg}`,
);
}
}
}
if (isDev) {
logger.info(
`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`,
`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.size} stale, ${nodes.length} to embed`,
);
}
}
@ -504,6 +507,12 @@ export const runEmbeddingPipeline = async (
}
}
// U6 / KTD7: delete this batch's stale rows immediately before its inserts,
// so an interrupted re-embed loses at most one batch (not the whole index).
// Preserves Kuzu's required DELETE-before-INSERT for vector-indexed rows.
const batchStaleIds = batch.filter((n) => staleNodeIds.has(n.id)).map((n) => n.id);
await deleteStaleEmbeddingRows(executeWithReusedStatement, batchStaleIds);
// Embed chunk texts in sub-batches to control memory
const EMBED_SUB_BATCH = finalConfig.subBatchSize;
for (let si = 0; si < allTexts.length; si += EMBED_SUB_BATCH) {

View file

@ -1,7 +1,7 @@
/**
* Text Generator Module
*
* Generates enriched embedding text from code nodes with metadata.
* Generates compact, description-forward embedding text from code nodes.
* Supports chunkable labels (Function/Method with AST chunking),
* Class-specific structural text, and short-node direct embed.
*
@ -58,33 +58,51 @@ const cleanContent = (content: string): string => {
};
/**
* Build metadata header for a node
* Compact location signal for the embedding header: the last 1-2 path segments
* (immediate parent dir + basename), never the full deep path.
*
* #2333 / PR #2334 tri-review: U1 dropped the location entirely, which regressed
* path/service-qualified semantic search (e.g. `billing/handler` vs
* `identity/handler` in a monorepo) — and FTS indexes only name/content/description,
* never `filePath`, so there is no keyword backfill. The bounded form restores the
* discriminating tokens (service dir + filename-concept) at a fraction of the
* dilution the full path caused.
*/
const buildMetadataHeader = (node: EmbeddableNode, config: Partial<EmbeddingConfig>): string => {
const boundedLocation = (filePath: string): string => {
const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean);
return segments.slice(-2).join('/');
};
/**
* Build a compact, description-forward header for embedding text.
*
* Issue #2333 (sub-issue of #2326), Option A: lead the embedding text with the
* symbol name + doc-comment description and drop the low-signal metadata lines
* (`Repo`/`Server`/`Export` and the verbose full `Path`). For short doc comments
* those lines used to be ~25-30% of the embedding text, diluting the description's
* semantic weight in the vector and weakening description-shaped search — worst
* for CJK, where a complete concept is often 4-20 characters.
*
* A *bounded* location signal (last 1-2 path segments) is kept after the
* description — see `boundedLocation` for why the full path drop was reversed.
*
* Full metadata is unaffected: it lives on the graph node properties, which is
* what display/context tools read. Only the embedding text changes here.
*
* Option B (reorder only, keep metadata) was rejected — mean-pooled embeddings
* weight by token proportion, not position, so reordering alone barely moves the
* signal. Option C (a separate description-only embedding + hybrid merge) is
* deferred to follow-up; build it only if Option A proves insufficient against
* real measurement. Any change to this template MUST bump EMBEDDING_TEXT_VERSION.
*/
const buildEmbeddingHeader = (node: EmbeddableNode, config: Partial<EmbeddingConfig>): string => {
const parts: string[] = [];
// Label + name
parts.push(`${node.label}: ${node.name}`);
// Repo name
if (node.repoName) {
parts.push(`Repo: ${node.repoName}`);
}
// Server name (optional)
if (node.serverName) {
parts.push(`Server: ${node.serverName}`);
}
// Full file path
parts.push(`Path: ${node.filePath}`);
// Export status
if (node.isExported !== undefined) {
parts.push(`Export: ${node.isExported}`);
}
// Description (truncated)
// Description hoisted above everything else so its semantic signal dominates
// the embedding vector and is never the part lost to token-limit truncation.
if (node.description) {
const maxLen = config.maxDescriptionLength ?? DEFAULT_EMBEDDING_CONFIG.maxDescriptionLength;
const truncated = truncateDescription(node.description, maxLen);
@ -93,6 +111,16 @@ const buildMetadataHeader = (node: EmbeddableNode, config: Partial<EmbeddingConf
}
}
// Bounded location signal — placed after the description so the description
// still leads the vector. Restores path/service disambiguation lost when the
// full Path line was dropped (FTS does not index filePath to backfill it).
if (node.filePath) {
const loc = boundedLocation(node.filePath);
if (loc) {
parts.push(`Loc: ${loc}`);
}
}
return parts.join('\n');
};
@ -102,7 +130,7 @@ const generateCodeBodyText = (
config: Partial<EmbeddingConfig>,
prevTail?: string,
): string => {
const header = buildMetadataHeader(node, config);
const header = buildEmbeddingHeader(node, config);
const parts = [header];
if (prevTail) {
parts.push(`[preceding context]: ...${cleanContent(prevTail)}`);
@ -128,7 +156,7 @@ const generateStructuralTypeText = (
chunkIndex?: number,
prevTail?: string,
): string => {
const header = buildMetadataHeader(node, config);
const header = buildEmbeddingHeader(node, config);
const parts: string[] = [header];
const isFirstChunk = chunkIndex === undefined || chunkIndex === 0;
const cleanedContent = cleanContent(node.content);
@ -253,7 +281,7 @@ export const generateEmbeddingText = (
prevTail?: string,
): string => {
if (isShortLabel(node.label)) {
const header = buildMetadataHeader(node, config);
const header = buildEmbeddingHeader(node, config);
const cleaned = cleanContent(node.content);
return `${header}\n\n${cleaned}`;
}

View file

@ -289,14 +289,6 @@ export interface CachedEmbedding {
contentHash?: string;
}
/**
* Context info for embedding pipeline (repo/server metadata enrichment)
*/
export interface EmbeddingContext {
repoName?: string;
serverName?: string;
}
/**
* Model download progress from transformers.js
*/

View file

@ -1369,21 +1369,6 @@ export async function runFullAnalysis(
}
}
const { readServerMapping } = await import('./embeddings/server-mapping.js');
// Mirror the registry's name-resolution chain so the server-mapping
// lookup key stays aligned with the final registry name (#1259):
// --name → remote-derived → canonical-root basename
// (preserved-alias is intentionally NOT consulted here — server
// mappings are addressed by the operationally-meaningful name the
// user configures, not by a sticky registry-only alias they may not
// know about. The previous canonical-only logic ignored both --name
// and remote-derived names, silently breaking server-mapping for
// anyone with a `--name` alias or remote-named repo.)
const projectName =
options.registryName ??
getInferredRepoName(repoPath) ??
path.basename(resolveRepoIdentityRoot(repoPath));
const serverName = await readServerMapping(projectName);
const embeddingResult = await runEmbeddingPipeline(
executeQuery,
executeWithReusedStatement,
@ -1399,7 +1384,6 @@ export async function runFullAnalysis(
},
{},
cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined,
{ repoName: projectName, serverName },
existingEmbeddings,
);
if (embeddingResult.semanticMode === 'exact-scan') {

View file

@ -1767,7 +1767,6 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
},
{}, // config: use defaults
undefined, // skipNodeIds
undefined, // context
existingEmbeddings,
);

View file

@ -105,10 +105,11 @@ describe('embedding-chunking integration', () => {
const text = generateEmbeddingText(node, chunks[0].text);
expect(text).toContain('Function: test');
expect(text).toContain('Repo: my-project');
expect(text).toContain('Server: my-service');
expect(text).toContain('Export: true');
expect(text).toContain('function hello()');
// #2333: verbose metadata is no longer part of embedding text.
expect(text).not.toContain('Repo: my-project');
expect(text).not.toContain('Server: my-service');
expect(text).not.toContain('Export: true');
});
it('long function produces multiple chunks', () => {
@ -282,7 +283,7 @@ describe('embedding-chunking integration', () => {
expect(secondText).toContain('age: u32,');
});
it('metadata is present in every chunk', () => {
it('header is present in every chunk', () => {
const longContent = 'x'.repeat(3000);
const node = makeNode({
content: longContent,
@ -294,9 +295,11 @@ describe('embedding-chunking integration', () => {
for (const chunk of chunks) {
const text = generateEmbeddingText(node, chunk.text);
// The compact header (name, + description when present) repeats on every
// chunk so each chunk keeps its identity; #2333 dropped the metadata lines.
expect(text).toContain('Function: test');
expect(text).toContain('Repo: test-repo');
expect(text).toContain('Path: src/test.ts');
expect(text).not.toContain('Repo: test-repo');
expect(text).not.toContain('Path: src/test.ts');
}
});
});

View file

@ -101,11 +101,29 @@ describe('contentHashForNode', () => {
expect(contentHashForNode(original)).not.toBe(contentHashForNode(edited));
});
it('changes when filePath differs', () => {
const a = makeNode({ filePath: 'src/a.ts' });
const b = makeNode({ filePath: 'src/b.ts' });
// Different filePaths lead to different embedding text ⇒ different hashes
expect(contentHashForNode(a)).not.toBe(contentHashForNode(b));
it('depends on the bounded location (last 1-2 segments) but not the deep path prefix (#2333 U3)', () => {
// U3 reinstated a BOUNDED location signal (last 1-2 path segments) in the
// embedding header, so the hash now tracks that signal — but only it, not the
// full deep prefix. Same last-2-segments ⇒ identical embedding text ⇒ identical
// hash, even with a totally different prefix.
const samePrefixA = makeNode({ filePath: 'src/very/deep/nested/svc/Impl.ts' });
const samePrefixB = makeNode({ filePath: 'other/svc/Impl.ts' });
expect(contentHashForNode(samePrefixA)).toBe(contentHashForNode(samePrefixB));
// Different last segments (e.g. a real service-folder move) ⇒ different bounded
// location ⇒ different hash, so the re-embed correctly picks up the new location.
const billing = makeNode({ filePath: 'billing/handler.ts' });
const identity = makeNode({ filePath: 'identity/handler.ts' });
expect(contentHashForNode(billing)).not.toBe(contentHashForNode(identity));
});
it('is independent of repoName/serverName/isExported (#2333 — dropped from header)', () => {
// #2333 dropped these three (alongside filePath) from the embedding header.
// The hash must not depend on them; if any were re-added to the header, this
// assertion flips and flags the silent re-coupling before it ships.
const a = makeNode({ repoName: 'repo-a', serverName: 'svc-a', isExported: true });
const b = makeNode({ repoName: 'repo-b', serverName: 'svc-b', isExported: false });
expect(contentHashForNode(a)).toBe(contentHashForNode(b));
});
it('produces identical hash regardless of config vs finalConfig when config is empty', () => {
@ -116,7 +134,7 @@ describe('contentHashForNode', () => {
});
it('exports a text template version marker', () => {
expect(EMBEDDING_TEXT_VERSION).toBe('v2');
expect(EMBEDDING_TEXT_VERSION).toBe('v4');
});
});
@ -290,7 +308,6 @@ describe('runEmbeddingPipeline incremental filter', () => {
onProgress,
{},
undefined, // skipNodeIds
undefined, // context
existingEmbeddings,
);
@ -326,7 +343,6 @@ describe('runEmbeddingPipeline incremental filter', () => {
onProgress,
{},
undefined, // skipNodeIds
undefined, // context
existingEmbeddings,
);
@ -402,7 +418,6 @@ describe('runEmbeddingPipeline incremental filter', () => {
onProgress,
{},
undefined,
undefined,
new Map(),
);
@ -410,9 +425,19 @@ describe('runEmbeddingPipeline incremental filter', () => {
const classText = embeddedTexts.find((text) => text.includes('Class: Parser'));
const enumText = embeddedTexts.find((text) => text.includes('Enum: Status'));
expect(classText).toContain('Export: true');
// #2333 dropped Export/metadata from embedding text, but the description
// assertions still prove the positional column mapping is correct. The Class
// row carries isExported at index 7 and description at index 8; the Enum row
// has no isExported column (description at index 7), exercising the other
// mapping branch. The toContain checks below are the primary guard: an
// off-by-one would put the boolean from index 7 into description, so the real
// text would be absent, failing here.
expect(classText).toContain('Parses typed payloads.');
expect(enumText).not.toContain('Export:');
// Header-integrity guard (#2333 U5): the embedding text must start with the
// `Label: name` header. A positional mis-map that corrupted the header line
// (e.g. the name column shifting) is caught here directly, instead of via the
// old narrow `not.toContain('\ntrue')` coincidence.
expect(classText).toMatch(/^Class: Parser\n/);
expect(enumText).toContain('Represents user status.');
});
@ -435,7 +460,6 @@ describe('runEmbeddingPipeline incremental filter', () => {
onProgress,
{},
undefined, // skipNodeIds
undefined, // context
existingEmbeddings,
);
@ -468,7 +492,6 @@ describe('runEmbeddingPipeline incremental filter', () => {
onProgress,
{},
undefined, // skipNodeIds
undefined, // context
existingEmbeddings,
);
@ -481,6 +504,87 @@ describe('runEmbeddingPipeline incremental filter', () => {
expect(createCalls.length).toBeGreaterThanOrEqual(1);
});
it('deletes each batch stale rows interleaved with its insert, not all up front (#2333 U6)', async () => {
mockEmbedderSetup();
const n1 = makeNode({ id: 'Function:a:src/a.ts', name: 'a', filePath: 'src/a.ts' });
const n2 = makeNode({ id: 'Function:b:src/b.ts', name: 'b', filePath: 'src/b.ts' });
// Both stale (hash mismatch) → both re-embed.
const existingEmbeddings = new Map<string, string>([
[n1.id, 'wronghash1'],
[n2.id, 'wronghash2'],
]);
const executeQuery = mockExecuteQuery([n1, n2]);
const executeWithReusedStatement = mockExecuteWithReusedStatement();
const { runEmbeddingPipeline } =
await import('../../src/core/embeddings/embedding-pipeline.js');
await runEmbeddingPipeline(
executeQuery,
executeWithReusedStatement,
onProgress,
{ batchSize: 1 }, // one node per batch → two batches
undefined, // skipNodeIds
existingEmbeddings,
);
// U6 / KTD7: per-batch interleaving means TWO separate DELETE calls (one per
// batch), not one up-front bulk delete of both stale rows.
const deleteCalls = stmtCalls.filter((c) => c.cypher.includes('DELETE'));
expect(deleteCalls.length).toBe(2);
// Ordering proof: batch 1's INSERT lands BEFORE batch 2's DELETE. An up-front
// bulk delete would put both DELETEs before any INSERT, failing this — so an
// interrupted re-embed can lose at most one batch, never the whole index.
const insertN1 = stmtCalls.findIndex(
(c) => c.cypher.includes('CREATE') && c.params.some((p) => p.nodeId === n1.id),
);
const deleteN2 = stmtCalls.findIndex(
(c) => c.cypher.includes('DELETE') && c.params.some((p) => p.nodeId === n2.id),
);
expect(insertN1).toBeGreaterThanOrEqual(0);
expect(deleteN2).toBeGreaterThanOrEqual(0);
expect(insertN1).toBeLessThan(deleteN2);
});
it('deletes only stale nodes — new and unchanged nodes are never deleted (#2333 U6)', async () => {
mockEmbedderSetup();
const unchanged = makeNode({ id: 'Function:u:src/u.ts', name: 'u', filePath: 'src/u.ts' });
const stale = makeNode({ id: 'Function:s:src/s.ts', name: 's', filePath: 'src/s.ts' });
const brandNew = makeNode({ id: 'Function:n:src/n.ts', name: 'n', filePath: 'src/n.ts' });
const unchangedHash = contentHashForNode(unchanged, DEFAULT_EMBEDDING_CONFIG);
const existingEmbeddings = new Map<string, string>([
[unchanged.id, unchangedHash], // hash matches → skipped, no delete
[stale.id, 'wronghash'], // hash mismatch → deleted + re-embed
// brandNew absent from the map → new → embedded, no delete
]);
const executeQuery = mockExecuteQuery([unchanged, stale, brandNew]);
const executeWithReusedStatement = mockExecuteWithReusedStatement();
const { runEmbeddingPipeline } =
await import('../../src/core/embeddings/embedding-pipeline.js');
await runEmbeddingPipeline(
executeQuery,
executeWithReusedStatement,
onProgress,
{ batchSize: 1 },
undefined, // skipNodeIds
existingEmbeddings,
);
const deletedIds = stmtCalls
.filter((c) => c.cypher.includes('DELETE'))
.flatMap((c) => c.params.map((p) => p.nodeId));
expect(deletedIds).toContain(stale.id);
expect(deletedIds).not.toContain(brandNew.id);
expect(deletedIds).not.toContain(unchanged.id);
});
it('calls createVectorIndex even when zero nodes need embedding after filter', async () => {
mockEmbedderSetup();
@ -501,7 +605,6 @@ describe('runEmbeddingPipeline incremental filter', () => {
onProgress,
{},
undefined, // skipNodeIds
undefined, // context
existingEmbeddings,
);
@ -623,7 +726,6 @@ describe('runEmbeddingPipeline incremental filter', () => {
onProgress,
{ chunkSize: 90, overlap: 0 },
undefined,
undefined,
new Map(),
);
@ -678,7 +780,6 @@ describe('runEmbeddingPipeline incremental filter', () => {
onProgress,
{ chunkSize: CLASS_CHUNK_SIZE, overlap: CLASS_OVERLAP },
undefined,
undefined,
new Map(),
);
@ -714,7 +815,6 @@ describe('runEmbeddingPipeline incremental filter', () => {
onProgress,
{},
undefined, // skipNodeIds
undefined, // context
existingEmbeddings,
),
).rejects.toThrow('vector-index corruption');

View file

@ -19,32 +19,30 @@ const baseNode: EmbeddableNode = {
describe('text-generator', () => {
describe('generateEmbeddingText', () => {
it('includes metadata header for Function', () => {
it('leads with name and code, dropping verbose metadata lines (#2333)', () => {
const node: EmbeddableNode = {
...baseNode,
isExported: true,
repoName: 'backend-user-ms',
};
const text = generateEmbeddingText(node, node.content);
// Compact embedding header: name + code remain.
expect(text).toContain('Function: parseJSON');
expect(text).toContain('Repo: backend-user-ms');
expect(text).toContain('Path: src/utils/parser.ts');
expect(text).toContain('Export: true');
expect(text).toContain('function parseJSON');
// Low-signal metadata lines are intentionally excluded from embedding text.
expect(text).not.toContain('Repo: backend-user-ms');
expect(text).not.toContain('Path: src/utils/parser.ts');
expect(text).not.toContain('Export: true');
});
it('includes Server line when serverName is set', () => {
it('excludes the Server line from embedding text even when serverName is set (#2333)', () => {
const node: EmbeddableNode = {
...baseNode,
repoName: 'backend-user-ms',
serverName: 'user-service',
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Server: user-service');
});
it('omits Server line when serverName is undefined', () => {
const text = generateEmbeddingText(baseNode, baseNode.content);
expect(text).not.toContain('Server: user-service');
expect(text).not.toContain('Server:');
});
@ -57,6 +55,179 @@ describe('text-generator', () => {
expect(text).toContain('This function parses JSON text');
});
// #2333: short doc comments must not be diluted by metadata. The description
// is hoisted directly under the name, ahead of the code body, and the
// low-signal metadata lines are dropped from embedding text entirely.
it('hoists a short English description above the code body (#2333)', () => {
const node: EmbeddableNode = {
...baseNode,
label: 'Method',
name: 'updateMaterialExpiryDate',
description: 'validate user',
isExported: false,
repoName: 'my-project',
content:
'function updateMaterialExpiryDate(paramMap) {\n // ... a long method body ...\n return doWork(paramMap);\n}',
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('validate user');
// Description appears before the code body.
expect(text.indexOf('validate user')).toBeLessThan(text.indexOf('return doWork'));
// Metadata noise removed.
expect(text).not.toContain('Repo: my-project');
expect(text).not.toContain('Path:');
expect(text).not.toContain('Export:');
});
it('hoists a short CJK description above the code body (#2333)', () => {
const node: EmbeddableNode = {
...baseNode,
label: 'Method',
name: 'updateMaterialExpiryDate',
description: '更新物料有效期',
isExported: false,
repoName: 'my-project',
content:
'function updateMaterialExpiryDate(paramMap) {\n // ... a long method body ...\n return doWork(paramMap);\n}',
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('更新物料有效期');
expect(text.indexOf('更新物料有效期')).toBeLessThan(text.indexOf('return doWork'));
expect(text).not.toContain('Repo: my-project');
expect(text).not.toContain('Path:');
expect(text).not.toContain('Export:');
});
it('hoists description in short-label nodes too (#2333)', () => {
const node: EmbeddableNode = {
...baseNode,
label: 'Const',
name: 'MAX_RETRIES',
description: 'retry ceiling',
content: 'const MAX_RETRIES = 5;',
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Const: MAX_RETRIES');
expect(text).toContain('retry ceiling');
expect(text.indexOf('retry ceiling')).toBeLessThan(text.indexOf('const MAX_RETRIES = 5;'));
expect(text).not.toContain('Path:');
});
it('keeps structural Methods/Properties lines under the compact header (#2333)', () => {
const node: EmbeddableNode = {
...baseNode,
label: 'Class',
name: 'Parser',
description: 'JSON parser',
repoName: 'my-project',
methodNames: ['parseJSON', 'validate'],
fieldNames: ['options', 'cache'],
content: `class Parser {
options: ParserOptions;
private cache: Map<string, any>;
parseJSON(text: string) { return JSON.parse(text); }
validate() { return true; }
}`,
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Class: Parser');
expect(text).toContain('JSON parser');
// Structural signal must survive the compact-header change.
expect(text).toContain('Methods: parseJSON, validate');
expect(text).toContain('Properties: options, cache');
// Description is hoisted ahead of the structural lines (ordering guard for
// the structural path, mirroring the function/method ordering checks).
expect(text.indexOf('JSON parser')).toBeLessThan(text.indexOf('Container:'));
expect(text.indexOf('JSON parser')).toBeLessThan(text.indexOf('Methods:'));
// Metadata noise still dropped.
expect(text).not.toContain('Repo: my-project');
});
it('emits no description line and no metadata when description is absent (#2333)', () => {
const node: EmbeddableNode = {
...baseNode,
isExported: true,
repoName: 'my-project',
description: undefined,
};
const text = generateEmbeddingText(node, node.content);
// Header is the name line, then the bounded location line, then a blank
// line, then the code body — no stray empty description line, no verbose
// metadata.
expect(
text.startsWith('Function: parseJSON\nLoc: utils/parser.ts\n\nfunction parseJSON'),
).toBe(true);
expect(text).not.toContain('Repo:');
expect(text).not.toContain('Export:');
// Only the bounded last-1-2 segments — never the verbose deep path.
expect(text).not.toContain('Path:');
expect(text).not.toContain('src/utils/parser.ts');
});
// U3 (#2333 PR #2334 tri-review): a BOUNDED location signal (last 1-2 path
// segments) is reinstated so path/service-qualified semantic search keeps a
// discriminator, since FTS does not index filePath.
it('emits a bounded location (last 2 segments), not the full deep path (#2333 U3)', () => {
const node: EmbeddableNode = {
...baseNode,
label: 'Method',
name: 'updateMaterialExpiryDate',
filePath: 'src/main/java/com/example/service/MaterialServiceImpl.java',
content: 'function updateMaterialExpiryDate() { return doWork(); }',
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Loc: service/MaterialServiceImpl.java');
// The deep prefix is dropped entirely.
expect(text).not.toContain('src/main/java/com/example');
});
it('emits just the basename for a root-level file (#2333 U3)', () => {
const node: EmbeddableNode = { ...baseNode, filePath: 'index.ts' };
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Loc: index.ts');
// No leading slash and no stray "undefined/" prefix from slicing one segment.
expect(text).not.toContain('Loc: /index.ts');
expect(text).not.toContain('undefined');
});
it('disambiguates same-named symbols in different service folders (#2333 U3)', () => {
const billing = generateEmbeddingText(
{ ...baseNode, name: 'handler', filePath: 'billing/handler.ts' },
'function handler() {}',
);
const identity = generateEmbeddingText(
{ ...baseNode, name: 'handler', filePath: 'identity/handler.ts' },
'function handler() {}',
);
expect(billing).toContain('Loc: billing/handler.ts');
expect(identity).toContain('Loc: identity/handler.ts');
// The two embedding texts differ — the regression the tri-review flagged
// (both collapsing to identical vectors) is fixed.
expect(billing).not.toBe(identity);
});
it('keeps the description ahead of the location signal (#2333 U3)', () => {
const node: EmbeddableNode = {
...baseNode,
label: 'Method',
name: 'doThing',
description: 'batch import rows',
filePath: 'svc/importer.ts',
content: 'function doThing() { return run(); }',
};
const text = generateEmbeddingText(node, node.content);
// description leads, then the location line, then the code body.
expect(text.indexOf('batch import rows')).toBeLessThan(text.indexOf('Loc: svc/importer.ts'));
expect(text.indexOf('Loc: svc/importer.ts')).toBeLessThan(text.indexOf('return run()'));
});
it('normalizes Windows path separators in the location signal (#2333 U3)', () => {
const node: EmbeddableNode = { ...baseNode, filePath: 'src\\svc\\Foo.ts' };
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Loc: svc/Foo.ts');
expect(text).not.toContain('\\');
});
it('generates short node text for TypeAlias without chunking', () => {
const node: EmbeddableNode = {
...baseNode,
@ -167,6 +338,56 @@ describe('text-generator', () => {
expect(text).toContain('struct User {');
});
// U5 (#2333 PR #2334): Interface and Struct route through the same
// generateStructuralTypeText path as Class, so the description-forward
// ordering must hold for them too — guards against a future per-label
// specialization silently reordering the header.
it('keeps an Interface description ahead of its structural lines (#2333 U5)', () => {
const node: EmbeddableNode = {
...baseNode,
label: 'Interface',
name: 'Handler',
description: 'event handler contract',
methodNames: ['handle', 'validate'],
fieldNames: ['name'],
content: `interface Handler {
handle(event: Event): void;
readonly name: string;
}`,
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Interface: Handler');
expect(text).toContain('event handler contract');
expect(text).toContain('Methods: handle, validate');
expect(text).toContain('Properties: name');
expect(text.indexOf('event handler contract')).toBeLessThan(text.indexOf('Container:'));
expect(text.indexOf('event handler contract')).toBeLessThan(text.indexOf('Methods:'));
expect(text).toContain('Loc: utils/parser.ts');
expect(text).not.toContain('Repo:');
});
it('keeps a Struct description ahead of its structural lines (#2333 U5)', () => {
const node: EmbeddableNode = {
...baseNode,
label: 'Struct',
name: 'User',
description: 'user record',
fieldNames: ['name', 'age'],
content: `struct User {
name: String,
age: u32,
}`,
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Struct: User');
expect(text).toContain('user record');
expect(text).toContain('Properties: name, age');
expect(text).toContain('Container: struct User {');
expect(text.indexOf('user record')).toBeLessThan(text.indexOf('Container:'));
expect(text.indexOf('user record')).toBeLessThan(text.indexOf('Properties:'));
expect(text).toContain('Loc: utils/parser.ts');
});
it('keeps compact container context on later structural chunks', () => {
const node: EmbeddableNode = {
...baseNode,