fix(MULTIVERSION-0001): fix version isolation, 404 on unknown version, commit-hash lookup, and searchModeUsed
Bug 1: Thread version tag from run() into crawl() via getVersionTag() helper so
LocalCrawler and GithubCrawler receive the correct ref when indexing a named
version instead of always crawling HEAD.
Bug 2: Return HTTP 404 with code VERSION_NOT_FOUND when a requested version tag
is not found in repository_versions, instead of silently falling back to a
cross-version mixed result set.
Bug 4: Before returning 404, attempt a commit_hash prefix match (min 7 chars)
so callers can request a version by full or short SHA.
Bug 3: Change HybridSearchService.search() to return
{ results, searchModeUsed } and propagate searchModeUsed through
ContextResponseMetadata and ContextJsonResponseDto so callers can see which
strategy (keyword / semantic / hybrid / keyword_fallback) was actually used.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -101,9 +101,12 @@ export class HybridSearchService {
|
||||
*
|
||||
* @param query - Raw search string (preprocessing handled by SearchService).
|
||||
* @param options - Search parameters including repositoryId and alpha blend.
|
||||
* @returns Ranked array of SnippetSearchResult, deduplicated by snippet ID.
|
||||
* @returns Object with ranked results array and the search mode actually used.
|
||||
*/
|
||||
async search(query: string, options: HybridSearchOptions): Promise<SnippetSearchResult[]> {
|
||||
async search(
|
||||
query: string,
|
||||
options: HybridSearchOptions
|
||||
): Promise<{ results: SnippetSearchResult[]; searchModeUsed: string }> {
|
||||
const limit = options.limit ?? 20;
|
||||
const mode = options.searchMode ?? 'auto';
|
||||
|
||||
@@ -127,12 +130,12 @@ export class HybridSearchService {
|
||||
// Semantic mode: skip FTS entirely and use vector search only.
|
||||
if (mode === 'semantic') {
|
||||
if (!this.embeddingProvider || !query.trim()) {
|
||||
return [];
|
||||
return { results: [], searchModeUsed: 'semantic' };
|
||||
}
|
||||
|
||||
const embeddings = await this.embeddingProvider.embed([query]);
|
||||
if (embeddings.length === 0) {
|
||||
return [];
|
||||
return { results: [], searchModeUsed: 'semantic' };
|
||||
}
|
||||
|
||||
const queryEmbedding = embeddings[0].values;
|
||||
@@ -144,7 +147,10 @@ export class HybridSearchService {
|
||||
});
|
||||
|
||||
const topIds = vectorResults.slice(0, limit).map((r) => r.snippetId);
|
||||
return this.fetchSnippetsByIds(topIds, options.repositoryId, options.type);
|
||||
return {
|
||||
results: this.fetchSnippetsByIds(topIds, options.repositoryId, options.type),
|
||||
searchModeUsed: 'semantic'
|
||||
};
|
||||
}
|
||||
|
||||
// FTS5 mode (keyword) or hybrid/auto modes: try FTS first.
|
||||
@@ -157,7 +163,7 @@ export class HybridSearchService {
|
||||
|
||||
// Degenerate cases: no provider or pure FTS5 mode.
|
||||
if (!this.embeddingProvider || alpha === 0) {
|
||||
return ftsResults.slice(0, limit);
|
||||
return { results: ftsResults.slice(0, limit), searchModeUsed: 'keyword' };
|
||||
}
|
||||
|
||||
// For auto/hybrid modes: if FTS yielded results, use them; otherwise try vector.
|
||||
@@ -168,14 +174,14 @@ export class HybridSearchService {
|
||||
// No FTS results: try vector search as a fallback in auto/hybrid modes.
|
||||
if (!query.trim()) {
|
||||
// Query is empty; no point embedding it.
|
||||
return [];
|
||||
return { results: [], searchModeUsed: 'keyword_fallback' };
|
||||
}
|
||||
|
||||
const embeddings = await this.embeddingProvider.embed([query]);
|
||||
|
||||
// If provider fails (Noop returns empty array), we're done.
|
||||
if (embeddings.length === 0) {
|
||||
return [];
|
||||
return { results: [], searchModeUsed: 'keyword_fallback' };
|
||||
}
|
||||
|
||||
const queryEmbedding = embeddings[0].values;
|
||||
@@ -187,7 +193,10 @@ export class HybridSearchService {
|
||||
});
|
||||
|
||||
const topIds = vectorResults.slice(0, limit).map((r) => r.snippetId);
|
||||
return this.fetchSnippetsByIds(topIds, options.repositoryId, options.type);
|
||||
return {
|
||||
results: this.fetchSnippetsByIds(topIds, options.repositoryId, options.type),
|
||||
searchModeUsed: 'keyword_fallback'
|
||||
};
|
||||
}
|
||||
|
||||
// FTS has results: use RRF to blend with vector search (if alpha < 1).
|
||||
@@ -195,7 +204,7 @@ export class HybridSearchService {
|
||||
|
||||
// Provider may be a Noop (returns empty array) — fall back to FTS gracefully.
|
||||
if (embeddings.length === 0) {
|
||||
return ftsResults.slice(0, limit);
|
||||
return { results: ftsResults.slice(0, limit), searchModeUsed: 'keyword' };
|
||||
}
|
||||
|
||||
const queryEmbedding = embeddings[0].values;
|
||||
@@ -210,7 +219,10 @@ export class HybridSearchService {
|
||||
// Pure vector mode: skip RRF and return vector results directly.
|
||||
if (alpha === 1) {
|
||||
const topIds = vectorResults.slice(0, limit).map((r) => r.snippetId);
|
||||
return this.fetchSnippetsByIds(topIds, options.repositoryId, options.type);
|
||||
return {
|
||||
results: this.fetchSnippetsByIds(topIds, options.repositoryId, options.type),
|
||||
searchModeUsed: 'semantic'
|
||||
};
|
||||
}
|
||||
|
||||
// Build ranked lists for RRF. Score field is unused by RRF — only
|
||||
@@ -221,7 +233,10 @@ export class HybridSearchService {
|
||||
const fused = reciprocalRankFusion(ftsRanked, vecRanked);
|
||||
|
||||
const topIds = fused.slice(0, limit).map((r) => r.id);
|
||||
return this.fetchSnippetsByIds(topIds, options.repositoryId, options.type);
|
||||
return {
|
||||
results: this.fetchSnippetsByIds(topIds, options.repositoryId, options.type),
|
||||
searchModeUsed: 'hybrid'
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user