Skip to content
Open
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
9 changes: 5 additions & 4 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ on:

jobs:
claude-review:
# Skip on fork PRs: GitHub strips id-token: write for pull_request events
# from forks, so the OIDC fetch fails regardless of the permissions block.
# The job is silently absent on fork PRs instead of always failing red.
if: github.event.pull_request.head.repo.full_name == github.repository
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'

runs-on: ubuntu-latest
permissions:
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/devoxx/genie/model/rag/RAGLogMessage.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,15 @@ public static class Hit {
private String preview;
/** Length of the original (un-truncated) chunk in characters. */
private int chunkLength;
/**
* 1-based rank of the hit in the retrieval shortlist before the reranker stage
* (null when reranking was disabled or did not run). See task-214.
*/
private Integer preRerankRank;
/**
* Score produced by the reranker for this hit (null when reranking was disabled
* or did not run). See task-214.
*/
private Double rerankerScore;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ public static void publish(@Nullable Project project,
.score(r.score())
.preview(preview)
.chunkLength(content.length())
.preRerankRank(r.preRerankRank())
.rerankerScore(r.rerankerScore())
.build());
}

Expand All @@ -66,7 +68,15 @@ public static void publish(@Nullable Project project,
log.info("RAG retrieval: query=\"{}\" hits={} duration={}ms",
summarizeQuery(query), results.size(), durationMs);
for (SearchResult r : results) {
log.info("RAG hit score={} file={}", formatScore(r.score()), r.filePath());
if (r.rerankerScore() != null) {
log.info("RAG hit score={} rerank={} preRank={} file={}",
formatScore(r.score()),
formatScore(r.rerankerScore()),
r.preRerankRank() == null ? "n/a" : r.preRerankRank(),
r.filePath());
} else {
log.info("RAG hit score={} file={}", formatScore(r.score()), r.filePath());
}
}

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ private List<Validator> createValidators() {
new DockerValidator(),
new ChromeDBValidator(),
new OllamaValidator(),
new NomicEmbedTextValidator()
new NomicEmbedTextValidator(),
new RerankerValidator()
);
}

Expand Down
36 changes: 32 additions & 4 deletions src/main/java/com/devoxx/genie/service/rag/SearchResult.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
package com.devoxx.genie.service.rag;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* A single semantic-search hit: which chunk matched the query, where it came from, and how well.
*
* @param filePath absolute path of the source file the chunk was extracted from
* @param score similarity score (0.0–1.0) returned by the vector store
* @param content the chunk text as it was embedded and stored (NOT the full file contents)
* @param filePath absolute path of the source file the chunk was extracted from
* @param score similarity score (0.0–1.0) returned by the vector store
* @param content the chunk text as it was embedded and stored (NOT the full file contents)
* @param preRerankRank 1-based rank of this hit in the retrieval shortlist before the reranker
* ran ({@code null} when reranking was disabled or not applied)
* @param rerankerScore relevance score produced by the reranker (typically 0.0–1.0 after
* normalization; {@code null} when reranking was disabled or not applied)
*/
public record SearchResult(String filePath, Double score, String content) {
public record SearchResult(@Nullable String filePath,
@Nullable Double score,
@Nullable String content,
@Nullable Integer preRerankRank,
@Nullable Double rerankerScore) {

/**
* Backward-compatible 3-arg constructor used by retrieval before any reranking has happened.
* Sets {@code preRerankRank} and {@code rerankerScore} to {@code null}.
*/
public SearchResult(@Nullable String filePath, @Nullable Double score, @Nullable String content) {
this(filePath, score, content, null, null);
}

/**
* Return a copy of this result carrying the supplied reranker annotations.
* Used by reranker implementations to record where a hit sat before reranking
* and what score the reranker assigned, without mutating the original instance.
*/
public @NotNull SearchResult withRerankerAnnotations(int preRerankRank, double rerankerScore) {
return new SearchResult(filePath, score, content, preRerankRank, rerankerScore);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.devoxx.genie.service.rag;

import com.devoxx.genie.service.chromadb.ChromaEmbeddingService;
import com.devoxx.genie.service.rag.rerank.NoOpReranker;
import com.devoxx.genie.service.rag.rerank.OllamaReranker;
import com.devoxx.genie.service.rag.rerank.Reranker;
import com.devoxx.genie.ui.settings.DevoxxGenieStateService;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.components.Service;
Expand All @@ -13,6 +16,7 @@
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;

import java.util.ArrayList;
import java.util.Collection;
Expand All @@ -27,6 +31,14 @@ public final class SemanticSearchService {

private final ChromaEmbeddingService embeddingService;
private final DevoxxGenieStateService stateService;
/**
* Reranker used when the "Rerank results" setting is enabled. Defaults to
* {@link OllamaReranker} in production; tests use {@link #setReranker(Reranker)}
* to substitute a deterministic stand-in. {@code volatile} because the field can be
* swapped from one thread (a test {@code @BeforeEach}) and read from another (the
* thread running {@link #search(Project, String, ChatModel)}).
*/
private volatile @NotNull Reranker reranker = new OllamaReranker();

@NotNull
public static SemanticSearchService getInstance() {
Expand All @@ -38,6 +50,15 @@ public SemanticSearchService() {
this.stateService = DevoxxGenieStateService.getInstance();
}

/**
* Test seam — allows swapping in a {@link NoOpReranker} / mocked reranker without
* monkey-patching static factories. Not used in production code.
*/
@TestOnly
public void setReranker(@NotNull Reranker reranker) {
this.reranker = reranker;
}

/**
* Single-query search — kept for callers that don't have a chat model available
* (find-command, integration tests). Equivalent to
Expand All @@ -60,19 +81,58 @@ public SemanticSearchService() {
* <p>When expansion is disabled or unavailable, falls back to single-query embedding +
* store lookup — identical to the previous behavior.
*
* <p>When the "Rerank results" toggle is enabled, the retrieval shortlist (sized by
* {@code rerankerShortlistSize}) is passed to the configured {@link Reranker} which
* returns the top {@code indexerMaxResults} entries. When the toggle is OFF, retrieval
* returns {@code indexerMaxResults} directly and the reranker is bypassed.
*
* <p>Returns one {@link SearchResult} per matching chunk (so multiple chunks from the
* same file are preserved). Results are ordered by descending score.
*/
public @NotNull List<SearchResult> search(Project project, String query, @Nullable ChatModel chatModel) {
embeddingService.init(project);

boolean rerank = Boolean.TRUE.equals(stateService.getRerankResults());
int finalTopN = stateService.getIndexerMaxResults();
// When rerank is on, retrieval returns the wider shortlist so the reranker has room
// to reorder; otherwise retrieval returns exactly what the prompt will see.
int retrievalMax = rerank
? Math.max(finalTopN, safeShortlistSize())
: finalTopN;

List<SearchResult> retrieved;
if (chatModel != null && Boolean.TRUE.equals(stateService.getRagQueryExpansionEnabled())) {
return searchWithExpansion(query, chatModel);
retrieved = searchWithExpansion(query, chatModel, retrievalMax);
} else {
retrieved = singleQuerySearch(query, retrievalMax);
}
return singleQuerySearch(query);

if (!rerank || retrieved.isEmpty()) {
return retrieved;
}

long timeoutMs = stateService.getRerankerTimeoutMs() != null
? stateService.getRerankerTimeoutMs()
: 2000L;
try {
return reranker.rerank(query, retrieved, finalTopN, timeoutMs);
} catch (Exception e) {
// Reranker contract is best-effort, but defend against impls that throw rather
// than falling back internally — we still need to return *something* useful.
log.warn("Reranker threw ({}); falling back to retrieval order", e.getMessage());
int n = Math.min(finalTopN, retrieved.size());
return new ArrayList<>(retrieved.subList(0, n));
}
}

private int safeShortlistSize() {
Integer cfg = stateService.getRerankerShortlistSize();
return (cfg == null || cfg <= 0) ? 30 : cfg;
}

private @NotNull List<SearchResult> searchWithExpansion(String query, @NotNull ChatModel chatModel) {
private @NotNull List<SearchResult> searchWithExpansion(String query,
@NotNull ChatModel chatModel,
int maxResults) {
int n = stateService.getRagQueryExpansionN() == null ? 3 : stateService.getRagQueryExpansionN();
LinkedHashSet<String> variants = new LinkedHashSet<>();
variants.add(query); // keep the original as the unexpanded baseline
Expand All @@ -87,16 +147,16 @@ public SemanticSearchService() {
}
return QueryExpansionFuser.expandAndFuse(
variants,
this::singleQuerySearch,
stateService.getIndexerMaxResults());
v -> singleQuerySearch(v, maxResults),
maxResults);
}

private @NotNull List<SearchResult> singleQuerySearch(@NotNull String query) {
private @NotNull List<SearchResult> singleQuerySearch(@NotNull String query, int maxResults) {
Embedding queryEmbedding = embeddingService.getEmbeddingModel().embed(query).content();
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.minScore(stateService.getIndexerMinScore())
.maxResults(stateService.getIndexerMaxResults())
.maxResults(maxResults)
.build();

List<SearchResult> results = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.devoxx.genie.service.rag.rerank;

import com.devoxx.genie.service.rag.SearchResult;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;

/**
* No-op reranker — returns the supplied candidates unchanged, truncated to {@code topN}.
* Used when the "Rerank results" toggle is OFF, when wiring tests, and as the fallback
* inside {@link OllamaReranker} when the model call cannot complete in time.
*/
public final class NoOpReranker implements Reranker {

@Override
public @NotNull List<SearchResult> rerank(@NotNull String query,
@NotNull List<SearchResult> candidates,
int topN,
long timeoutMs) {
if (candidates.isEmpty() || topN <= 0) {
return List.of();
}
int n = Math.min(topN, candidates.size());
return new ArrayList<>(candidates.subList(0, n));
}
}
Loading
Loading