Skip to content

Commit c866f06

Browse files
kfaracikclaude
andcommitted
refactor(rag): add HybridRetriever wrapper as app↔lib boundary
Wraps hybridRetrieve in a HybridRetriever class that binds the vector store and embeddings, exposing a single retrieve(query, options) call as the explicit app↔react-native-rag boundary. hybridRetrieve is unchanged and still the production path (messageSources.ts untouched); the class is the boundary/test seam. No generic and no interface — one impl, one caller; extract an interface only if a second retriever appears. Deliberately not `implements VectorStore`: the hybrid is read-only and its ContextChunk output drops id/embedding, so coercing to QueryResult would change retrieval results. Test proves 1:1 forwarding with load-bearing attachmentSourceIds and sourceNamesById, so a spread that dropped either would fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b95e43b commit c866f06

2 files changed

Lines changed: 89 additions & 1 deletion

File tree

__tests__/hybridRetrieval.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { hybridRetrieve } from '../utils/hybridRetrieval';
1+
import { hybridRetrieve, HybridRetriever } from '../utils/hybridRetrieval';
22
import * as keywordIndex from '../database/keywordIndex';
33
import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite';
44

@@ -483,3 +483,60 @@ describe('hybridRetrieve', () => {
483483
expect(result.map((c) => c.similarity)).toEqual([0, 0.9, 0]);
484484
});
485485
});
486+
487+
describe('HybridRetriever', () => {
488+
beforeEach(() => {
489+
mockKeywordSearch.mockReset();
490+
});
491+
492+
// The cases above already exercise the hybrid logic; this proves the wrapper
493+
// forwards 1:1 — query→prompt, store/embeddings from the constructor, and
494+
// every option spread through. Inputs are chosen so the two options a naive
495+
// spread could silently drop are load-bearing: sourceNamesById resolves doc
496+
// 1's missing name, and attachmentSourceIds keeps doc 2's otherwise-gated
497+
// low-similarity chunk and orders it first. A wrapper that dropped either
498+
// would diverge from the raw call and fail the toEqual.
499+
it('forwards to hybridRetrieve 1:1, including attachmentSourceIds and sourceNamesById', async () => {
500+
const vectorResults = [
501+
{
502+
id: '1:0',
503+
document: 'a semantic passage about felines',
504+
embedding: [1, 0],
505+
similarity: 0.8,
506+
metadata: { documentId: 1 }, // no name → resolved via sourceNamesById
507+
},
508+
{
509+
id: '2:0',
510+
document: 'freshly attached, low semantic overlap',
511+
embedding: [0, 1],
512+
similarity: 0.05, // gated out unless treated as an attachment
513+
metadata: { documentId: 2, name: 'Attachment' },
514+
},
515+
];
516+
mockKeywordSearch.mockResolvedValue([]);
517+
518+
const store = makeVectorStore(vectorResults, {});
519+
const options = {
520+
enabledSourceIds: [1, 2],
521+
sourceNamesById: new Map<number, string>([[1, 'ResolvedName']]),
522+
attachmentSourceIds: [2],
523+
};
524+
525+
const viaWrapper = await new HybridRetriever(store, null).retrieve(
526+
'felines',
527+
options
528+
);
529+
const viaFunction = await hybridRetrieve({
530+
prompt: 'felines',
531+
vectorStore: store,
532+
embeddings: null,
533+
...options,
534+
});
535+
536+
const names = viaWrapper.map((c) => c.metadata?.name);
537+
expect(names).toContain('ResolvedName'); // sourceNamesById forwarded
538+
expect(names).toContain('Attachment'); // attachmentSourceIds forwarded
539+
expect(viaWrapper[0]?.metadata?.name).toBe('Attachment'); // attachment ordered first
540+
expect(viaWrapper).toEqual(viaFunction); // and identical to the raw call
541+
});
542+
});

utils/hybridRetrieval.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,3 +408,34 @@ export const hybridRetrieve = async ({
408408
sourceNamesById
409409
);
410410
};
411+
412+
// Thin app↔library boundary. Binds the store + embeddings so retrieval is a
413+
// single retrieve(query, options) call, making the hybrid testable and portable
414+
// in isolation. It forwards to hybridRetrieve unchanged — no interface, because
415+
// there is one implementation and one caller; extract a Retriever interface only
416+
// if a second retriever ever appears. Deliberately NOT `implements VectorStore`:
417+
// the hybrid is read-only and its ContextChunk output drops id/embedding, so
418+
// coercing to QueryResult would change retrieval results.
419+
export type HybridRetrieveOptions = Omit<
420+
HybridRetrieveParams,
421+
'prompt' | 'vectorStore' | 'embeddings'
422+
>;
423+
424+
export class HybridRetriever {
425+
constructor(
426+
private vectorStore: OPSQLiteVectorStore,
427+
private embeddings?: LFMEmbeddings | null
428+
) {}
429+
430+
retrieve(
431+
query: string,
432+
options: HybridRetrieveOptions
433+
): Promise<ContextChunk[]> {
434+
return hybridRetrieve({
435+
prompt: query,
436+
vectorStore: this.vectorStore,
437+
embeddings: this.embeddings,
438+
...options,
439+
});
440+
}
441+
}

0 commit comments

Comments
 (0)