Skip to content

Commit 7fe73bb

Browse files
committed
feat: add local reranker, FTS metadata enrichment, and retrieval audit log
Add Ollama-based local rerank provider for private deployments with automatic fallback to API reranker. Support any OpenAI-compatible embedding endpoint via env config. Extend chunks_fts with language and symbols columns for enriched lexical search. Make RRF fusion weights env-configurable. Add per-query retrieval audit logging to SQLite. Add git post-commit diff parser for automatic incremental indexing. All 478 tests pass (22 new).
1 parent 300bf73 commit 7fe73bb

19 files changed

Lines changed: 2474 additions & 15 deletions

docs/plans/2026-04-14-retrieval-precision-optimization.md

Lines changed: 1393 additions & 0 deletions
Large diffs are not rendered by default.

src/api/embedding.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
/**
22
* Embedding 客户端
33
*
4+
* 支持任何 OpenAI-compatible Embedding API:
5+
* - SiliconFlow (默认): EMBEDDINGS_BASE_URL 指向 SiliconFlow 端点
6+
* - Ollama 本地: EMBEDDINGS_BASE_URL=http://localhost:11434/v1/embeddings
7+
* - vLLM / LocalAI: EMBEDDINGS_BASE_URL 指向对应 /v1/embeddings 端点
8+
*
49
* 调用 SiliconFlow Embedding API,将文本转换为向量
510
* 支持并发控制、批量处理和智能速率限制
611
*

src/api/localReranker.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* Local Reranker Client — Ollama chat-based reranking
3+
*
4+
* Uses a local Ollama model to rerank code snippets by relevance.
5+
* Falls back to equal scoring if the model output cannot be parsed.
6+
*/
7+
8+
import { logger } from '../utils/logger.js';
9+
import type { RerankedDocument, RerankDetailedResult, RerankOptions } from './reranker.js';
10+
11+
export interface LocalRerankerConfig {
12+
baseUrl: string;
13+
model: string;
14+
/** Max tokens for model response (default 512) */
15+
maxTokens?: number;
16+
/** Request timeout in ms (default 30000) */
17+
timeoutMs?: number;
18+
}
19+
20+
const RERANK_SYSTEM_PROMPT = `You are a code relevance judge. Given a query and a list of code snippets, return ONLY a JSON array of indices ordered by relevance (most relevant first). Example: [2, 0, 1]. No explanation, no markdown, just the array.`;
21+
22+
export class LocalRerankerClient {
23+
private config: Required<LocalRerankerConfig>;
24+
25+
constructor(config: LocalRerankerConfig) {
26+
this.config = {
27+
maxTokens: 512,
28+
timeoutMs: 30000,
29+
...config,
30+
};
31+
}
32+
33+
async rerankDetailed(
34+
query: string,
35+
documents: string[],
36+
): Promise<RerankDetailedResult> {
37+
if (documents.length === 0) {
38+
return { results: [] };
39+
}
40+
41+
const numberedDocs = documents
42+
.map((doc, i) => `[${i}] ${doc}`)
43+
.join('\n\n');
44+
45+
const userPrompt = `Query: ${query}\n\nSnippets:\n${numberedDocs}`;
46+
47+
const controller = new AbortController();
48+
const timeout = setTimeout(
49+
() => controller.abort(),
50+
this.config.timeoutMs,
51+
);
52+
53+
try {
54+
const response = await fetch(`${this.config.baseUrl}/api/chat`, {
55+
method: 'POST',
56+
headers: { 'Content-Type': 'application/json' },
57+
body: JSON.stringify({
58+
model: this.config.model,
59+
messages: [
60+
{ role: 'system', content: RERANK_SYSTEM_PROMPT },
61+
{ role: 'user', content: userPrompt },
62+
],
63+
stream: false,
64+
options: { num_predict: this.config.maxTokens },
65+
}),
66+
signal: controller.signal,
67+
});
68+
69+
if (!response.ok) {
70+
throw new Error(`Ollama rerank HTTP ${response.status}`);
71+
}
72+
73+
const data = (await response.json()) as {
74+
message: { content: string };
75+
};
76+
const indices = this.parseIndexArray(data.message.content, documents.length);
77+
78+
if (indices.length === 0) {
79+
// Model failed to produce valid ranking — return all with score 0
80+
logger.warn('Local rerank produced no valid indices, returning equal scores');
81+
return {
82+
results: documents.map((text, i) => ({
83+
originalIndex: i,
84+
score: 0,
85+
text,
86+
})),
87+
};
88+
}
89+
90+
const topN = indices.length;
91+
const results: RerankedDocument[] = indices.map(
92+
(originalIndex, rank) => ({
93+
originalIndex,
94+
score: 1 - rank / Math.max(topN, 1),
95+
text: documents[originalIndex],
96+
}),
97+
);
98+
99+
// Include documents not in the ranked list with score 0
100+
const rankedSet = new Set(indices);
101+
for (let i = 0; i < documents.length; i++) {
102+
if (!rankedSet.has(i)) {
103+
results.push({
104+
originalIndex: i,
105+
score: 0,
106+
text: documents[i],
107+
});
108+
}
109+
}
110+
111+
return { results };
112+
} catch (err) {
113+
logger.error({ error: err }, 'Local rerank failed, returning equal scores');
114+
return {
115+
results: documents.map((text, i) => ({
116+
originalIndex: i,
117+
score: 0,
118+
text,
119+
})),
120+
};
121+
} finally {
122+
clearTimeout(timeout);
123+
}
124+
}
125+
126+
/**
127+
* Parse model output as an index array.
128+
* Tries direct JSON parse first, then extracts first [...]+ pattern.
129+
*/
130+
/**
131+
* Rerank with attached metadata (matches RerankerClient.rerankWithDataDetailed contract)
132+
*/
133+
async rerankWithDataDetailed<T>(
134+
query: string,
135+
items: T[],
136+
textExtractor: (item: T) => string,
137+
_options?: RerankOptions,
138+
): Promise<RerankDetailedResult<T>> {
139+
if (items.length === 0) {
140+
return { results: [] };
141+
}
142+
143+
const texts = items.map(textExtractor);
144+
const textResult = await this.rerankDetailed(query, texts);
145+
146+
return {
147+
results: textResult.results.map((result) => ({
148+
...result,
149+
data: items[result.originalIndex],
150+
})),
151+
};
152+
}
153+
154+
private parseIndexArray(content: string, maxIndex: number): number[] {
155+
const cleaned = content.trim();
156+
157+
// Attempt 1: direct parse
158+
try {
159+
const arr = JSON.parse(cleaned);
160+
if (Array.isArray(arr) && arr.every((n) => typeof n === 'number')) {
161+
return this.validateIndices(arr as number[], maxIndex);
162+
}
163+
} catch {
164+
// not valid JSON
165+
}
166+
167+
// Attempt 2: extract first [..] block
168+
const match = cleaned.match(/\[[\d\s,]+\]/);
169+
if (match) {
170+
try {
171+
const arr = JSON.parse(match[0]) as number[];
172+
return this.validateIndices(arr, maxIndex);
173+
} catch {
174+
// extraction failed
175+
}
176+
}
177+
178+
logger.warn({ content: cleaned.slice(0, 200) }, 'Failed to parse rerank output');
179+
return [];
180+
}
181+
182+
private validateIndices(indices: number[], maxIndex: number): number[] {
183+
const valid = new Set<number>();
184+
const result: number[] = [];
185+
for (const idx of indices) {
186+
if (Number.isInteger(idx) && idx >= 0 && idx < maxIndex && !valid.has(idx)) {
187+
valid.add(idx);
188+
result.push(idx);
189+
}
190+
}
191+
return result;
192+
}
193+
}

src/api/rerankerRouter.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Reranker Router — selects API or local reranker based on config
3+
*/
4+
5+
import { getRerankProvider, getOllamaRerankerConfig } from '../config.js';
6+
import { LocalRerankerClient } from './localReranker.js';
7+
import { RerankerClient } from './reranker.js';
8+
import type { RerankDetailedResult, RerankOptions } from './reranker.js';
9+
10+
export type RerankerBackend = {
11+
rerankDetailed(
12+
query: string,
13+
documents: string[],
14+
options?: RerankOptions,
15+
): Promise<RerankDetailedResult>;
16+
17+
rerankWithDataDetailed<T>(
18+
query: string,
19+
items: T[],
20+
textExtractor: (item: T) => string,
21+
options?: RerankOptions,
22+
): Promise<RerankDetailedResult<T>>;
23+
};
24+
25+
let cachedBackend: RerankerBackend | null = null;
26+
27+
export function getRerankerBackend(): RerankerBackend {
28+
if (cachedBackend) return cachedBackend;
29+
30+
const provider = getRerankProvider();
31+
if (provider === 'ollama') {
32+
cachedBackend = new LocalRerankerClient(getOllamaRerankerConfig()) as RerankerBackend;
33+
} else {
34+
cachedBackend = new RerankerClient() as RerankerBackend;
35+
}
36+
return cachedBackend;
37+
}
38+
39+
/**
40+
* Reset cached backend (for testing)
41+
*/
42+
export function resetRerankerBackend(): void {
43+
cachedBackend = null;
44+
}

src/config.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,30 @@ export interface RerankerConfig {
8282
topN: number;
8383
}
8484

85+
export type RerankProvider = 'api' | 'ollama';
86+
87+
export interface OllamaRerankerConfig {
88+
baseUrl: string;
89+
model: string;
90+
maxTokens: number;
91+
timeoutMs: number;
92+
}
93+
94+
export function getRerankProvider(): RerankProvider {
95+
const provider = process.env.RERANK_PROVIDER;
96+
if (provider === 'ollama') return 'ollama';
97+
return 'api';
98+
}
99+
100+
export function getOllamaRerankerConfig(): OllamaRerankerConfig {
101+
return {
102+
baseUrl: process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
103+
model: process.env.OLLAMA_RERANK_MODEL || 'qwen2.5-coder:7b',
104+
maxTokens: parseInt(process.env.OLLAMA_RERANK_MAX_TOKENS || '512', 10),
105+
timeoutMs: parseInt(process.env.OLLAMA_RERANK_TIMEOUT_MS || '30000', 10),
106+
};
107+
}
108+
85109
export interface IndexUpdateStrategyConfig {
86110
churnThreshold: number;
87111
costThresholdRatio: number;
@@ -176,7 +200,7 @@ export function getEmbeddingConfig(): EmbeddingConfig {
176200
const globalMinIntervalMs = parseInt(process.env.EMBEDDINGS_GLOBAL_MIN_INTERVAL_MS || '200', 10);
177201

178202
if (!apiKey) {
179-
throw new Error('EMBEDDINGS_API_KEY 环境变量未设置');
203+
throw new Error('EMBEDDINGS_API_KEY 环境变量未设置(本地 Ollama 可设置为 "ollama")');
180204
}
181205
if (!baseUrl) {
182206
throw new Error('EMBEDDINGS_BASE_URL 环境变量未设置');

0 commit comments

Comments
 (0)