88731a50e3
Heartbeat: - HeartbeatMonitor with 5 checks: gateway, model, channels, memory, disk - Configurable interval, failure threshold, notification channel - Recovery notifications when health restores - 25 new tests Vector Memory Search: - EmbeddingProvider interface with OpenAI, Gemini, Ollama, LlamaCpp backends - SQLite-backed VectorStore with cosine similarity search - Text chunker with paragraph-aware splitting and overlap - HybridSearch merging keyword + vector results with configurable weight - Background indexer with dirty-namespace tracking - Graceful fallback to keyword search when embeddings unavailable - 51 new tests Config: automation.heartbeat + memory.embedding schema sections Total: 950 tests passing, all types clean
214 lines
6.8 KiB
TypeScript
214 lines
6.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { HybridSearch } from './hybrid-search.js';
|
|
import type { HybridSearchResult } from './hybrid-search.js';
|
|
import type { MemoryStore, SearchResult } from './store.js';
|
|
import type { VectorStore, VectorSearchResult } from './vector-store.js';
|
|
import type { EmbeddingProvider } from './embeddings.js';
|
|
|
|
/**
|
|
* Create a mock MemoryStore with keyword search results.
|
|
*/
|
|
function mockMemoryStore(results: SearchResult[]): MemoryStore {
|
|
return {
|
|
search: vi.fn(() => results),
|
|
read: vi.fn(() => ''),
|
|
write: vi.fn(),
|
|
listNamespaces: vi.fn(() => []),
|
|
getContextForPrompt: vi.fn(() => ''),
|
|
getDirtyNamespaces: vi.fn(() => []),
|
|
markAllDirty: vi.fn(),
|
|
} as unknown as MemoryStore;
|
|
}
|
|
|
|
/**
|
|
* Create a mock VectorStore that returns given results.
|
|
*/
|
|
function mockVectorStore(results: VectorSearchResult[]): VectorStore {
|
|
return {
|
|
search: vi.fn(() => results),
|
|
upsertChunks: vi.fn(),
|
|
deleteNamespace: vi.fn(),
|
|
hasContentHash: vi.fn(() => false),
|
|
count: vi.fn(() => 0),
|
|
close: vi.fn(),
|
|
} as unknown as VectorStore;
|
|
}
|
|
|
|
/**
|
|
* Create a mock embedding provider that returns fixed embeddings.
|
|
*/
|
|
function mockEmbeddingProvider(dims: number = 4): EmbeddingProvider {
|
|
return {
|
|
dimensions: dims,
|
|
embed: vi.fn(async (texts: string[]) =>
|
|
texts.map(() => new Array(dims).fill(0.1)),
|
|
),
|
|
};
|
|
}
|
|
|
|
describe('HybridSearch', () => {
|
|
describe('search', () => {
|
|
it('returns keyword results when no vector results exist', async () => {
|
|
const keywordResults: SearchResult[] = [
|
|
{ namespace: 'notes', line: 5, content: 'fox jumped', context: 'the fox jumped over' },
|
|
];
|
|
|
|
const hybrid = new HybridSearch(
|
|
mockMemoryStore(keywordResults),
|
|
mockVectorStore([]),
|
|
mockEmbeddingProvider(),
|
|
0.7,
|
|
);
|
|
|
|
const results = await hybrid.search('fox');
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].namespace).toBe('notes');
|
|
expect(results[0].source).toBe('keyword');
|
|
});
|
|
|
|
it('returns vector results when no keyword results exist', async () => {
|
|
const vectorResults: VectorSearchResult[] = [
|
|
{ namespace: 'journal', chunkText: 'semantic match', startLine: 10, endLine: 20, score: 0.9 },
|
|
];
|
|
|
|
const hybrid = new HybridSearch(
|
|
mockMemoryStore([]),
|
|
mockVectorStore(vectorResults),
|
|
mockEmbeddingProvider(),
|
|
0.7,
|
|
);
|
|
|
|
const results = await hybrid.search('meaning');
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].namespace).toBe('journal');
|
|
expect(results[0].source).toBe('vector');
|
|
});
|
|
|
|
it('merges keyword and vector results', async () => {
|
|
const keywordResults: SearchResult[] = [
|
|
{ namespace: 'notes', line: 5, content: 'fox keyword', context: 'the fox keyword hit' },
|
|
];
|
|
const vectorResults: VectorSearchResult[] = [
|
|
{ namespace: 'journal', chunkText: 'fox semantic', startLine: 10, endLine: 20, score: 0.85 },
|
|
];
|
|
|
|
const hybrid = new HybridSearch(
|
|
mockMemoryStore(keywordResults),
|
|
mockVectorStore(vectorResults),
|
|
mockEmbeddingProvider(),
|
|
0.7,
|
|
);
|
|
|
|
const results = await hybrid.search('fox');
|
|
expect(results.length).toBe(2);
|
|
|
|
const namespaces = results.map((r) => r.namespace);
|
|
expect(namespaces).toContain('notes');
|
|
expect(namespaces).toContain('journal');
|
|
});
|
|
|
|
it('deduplicates results from same namespace and nearby lines', async () => {
|
|
// Both keyword and vector find something at the same location
|
|
const keywordResults: SearchResult[] = [
|
|
{ namespace: 'notes', line: 5, content: 'fox hit', context: 'context' },
|
|
];
|
|
const vectorResults: VectorSearchResult[] = [
|
|
{ namespace: 'notes', chunkText: 'fox hit too', startLine: 4, endLine: 8, score: 0.9 },
|
|
];
|
|
|
|
const hybrid = new HybridSearch(
|
|
mockMemoryStore(keywordResults),
|
|
mockVectorStore(vectorResults),
|
|
mockEmbeddingProvider(),
|
|
0.7,
|
|
);
|
|
|
|
const results = await hybrid.search('fox');
|
|
// Should be deduplicated to a single "both" result
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].source).toBe('both');
|
|
});
|
|
|
|
it('applies hybrid weight to scoring', async () => {
|
|
const keywordResults: SearchResult[] = [
|
|
{ namespace: 'notes', line: 100, content: 'keyword only', context: 'ctx' },
|
|
];
|
|
const vectorResults: VectorSearchResult[] = [
|
|
{ namespace: 'journal', chunkText: 'vector only', startLine: 200, endLine: 210, score: 0.95 },
|
|
];
|
|
|
|
// High vector weight (0.9)
|
|
const hybrid = new HybridSearch(
|
|
mockMemoryStore(keywordResults),
|
|
mockVectorStore(vectorResults),
|
|
mockEmbeddingProvider(),
|
|
0.9,
|
|
);
|
|
|
|
const results = await hybrid.search('query');
|
|
expect(results.length).toBe(2);
|
|
|
|
// Vector result should rank higher with high vector weight
|
|
const vectorResult = results.find((r) => r.source === 'vector');
|
|
const keywordResult = results.find((r) => r.source === 'keyword');
|
|
expect(vectorResult).toBeDefined();
|
|
expect(keywordResult).toBeDefined();
|
|
expect(vectorResult!.score).toBeGreaterThan(keywordResult!.score);
|
|
});
|
|
|
|
it('falls back to keyword search when vector search fails', async () => {
|
|
const keywordResults: SearchResult[] = [
|
|
{ namespace: 'notes', line: 1, content: 'fallback', context: 'ctx' },
|
|
];
|
|
|
|
const failingProvider: EmbeddingProvider = {
|
|
dimensions: 4,
|
|
embed: vi.fn(async () => { throw new Error('API error'); }),
|
|
};
|
|
|
|
const hybrid = new HybridSearch(
|
|
mockMemoryStore(keywordResults),
|
|
mockVectorStore([]),
|
|
failingProvider,
|
|
0.7,
|
|
);
|
|
|
|
// Should not throw — should fall back to keyword results
|
|
const results = await hybrid.search('test');
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].source).toBe('keyword');
|
|
});
|
|
|
|
it('respects topK limit', async () => {
|
|
const keywordResults: SearchResult[] = Array.from({ length: 10 }, (_, i) => ({
|
|
namespace: `ns${i}`,
|
|
line: i + 1,
|
|
content: `result ${i}`,
|
|
context: `ctx ${i}`,
|
|
}));
|
|
|
|
const hybrid = new HybridSearch(
|
|
mockMemoryStore(keywordResults),
|
|
mockVectorStore([]),
|
|
mockEmbeddingProvider(),
|
|
0.5,
|
|
);
|
|
|
|
const results = await hybrid.search('query', 3);
|
|
expect(results.length).toBe(3);
|
|
});
|
|
|
|
it('returns empty array when both searches find nothing', async () => {
|
|
const hybrid = new HybridSearch(
|
|
mockMemoryStore([]),
|
|
mockVectorStore([]),
|
|
mockEmbeddingProvider(),
|
|
0.7,
|
|
);
|
|
|
|
const results = await hybrid.search('nonexistent');
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|
|
});
|