Skip to content

Commit 92f1344

Browse files
feat(rag): hybrid retrieval + multilingual refinements on top of vector RAG (#246)
> **Stacked on `feat/221-rag-sources`.** Base this PR against that branch, not `main`, and merge it after the base lands. The diff here is only the retrieval-quality layer (~16 files); everything else belongs to the base PR. Turns the vector-only baseline into hybrid retrieval and adds the language refinements. Same on-device guarantee — nothing new leaves the device; this is purely a quality upgrade to how chunks are found and ranked. ## What it adds on top of the baseline - **Keyword index** (`database/keywordIndex.ts`) — a parallel FTS5/BM25 table keyed by the same `documentId:chunkIndex` chunk id, built alongside the vector index and torn down with it. On first launch after upgrading from the vector baseline it **backfills from the existing `vectors` table**, so documents imported before this PR become keyword-searchable without a re-import. - **Hybrid retrieval** (`utils/hybridRetrieval.ts`, replacing the vanilla `retrieve`) — semantic vector search + keyword BM25, fused with Reciprocal Rank Fusion, then re-ranked (term-coverage boost → MMR diversification → adaptive-k) before neighbor expansion. - **Multilingual no-answer detection** — Polish refusal patterns added to the existing English ones, so a PL "brak informacji" reply cites nothing instead of over-citing. - **`HybridRetriever`** — the app↔library boundary: a thin wrapper binding store + embeddings into one `retrieve(query, options)` call. ## Why hybrid (the evidence) BM25 recovers exact-match recall — names, codes, rare tokens — that embeddings miss; vectors recover paraphrase. An offline eval harness (real LFM 2.5 + FTS5, frozen embeddings, ~1000 queries) confirms hybrid beats pure vector on exact and mixed queries, at parity on pure-paraphrase. RRF fuses the two rankings without tuning a per-query linear weight. The semantic floor those numbers were measured against (`0.40` + `0.25` top-keep floor) is the calibrated gate that ships in the base PR, so the measured recall is what this branch actually delivers. > Re-run the on-device eval before merging: the keyword stemming (`utils/queryTerms.ts`) and the RRF/coverage/adaptive-k knobs are exactly what the harness measures, so any tuning here should be revalidated against it rather than trusted on unit tests alone. ## Key decisions / trade-offs - **No cross-encoder re-ranker.** Re-ranking is RRF + term-coverage + MMR + adaptive-k — all cheap and on-device. A cross-encoder would improve precision but costs a second model and per-candidate inference on a phone. - **`HybridRetriever` has no interface and no generic.** One implementation, one caller. Notably *not* `implements VectorStore`: the hybrid is read-only and its `ContextChunk` output drops id/embedding, so coercing it to a `QueryResult` would change results. Extract an interface when a second retriever appears. - **Retrieval constants live in `constants/retrieval.ts`** with per-value rationale — the fusion weights, MMR lambda, coverage alpha, adaptive-k ratios and semantic floor each document where the number comes from. ## Known limitations - **FTS5 degradation is soft.** If the native build lacks FTS5, `ensureKeywordIndex` logs a warning and hybrid search silently falls back to vector-only. Open question: leave as-is / surface a signal / fail fast — depends on whether a supported build without FTS5 exists. - **Query processing is PL/EN-tuned.** `utils/queryTerms.ts` hardcodes Polish morphology (ł/Ł folding, stem-prefix) and the no-answer detection is PL/EN. Other languages work but retrieve measurably worse. Follow-up: a `LanguageAdapter` with a neutral fallback. ## How to test locally 1. **Exact-match win** — attach a doc containing a rare token (an error code, a surname) and query it verbatim; the chunk should surface even when semantic similarity alone would gate it out. 2. **PL refusal** — ask (in Polish) something the docs don't cover; expect a no-answer reply with zero citations. 3. **Diversity** — a spanning multi-doc query should not return five near-duplicate chunks from one file (MMR + per-file cap). 4. **FTS5 fallback** — on a build without FTS5, retrieval still returns results (vector-only) with a logged warning. 5. **Backfill after upgrade** — open a build that imported documents on the vector baseline, then upgrade to this branch; the pre-existing documents are keyword-searchable (verbatim rare-token query surfaces them) with no re-import. --------- Co-authored-by: Norbert Klockiewicz <Nklockiewicz12@gmail.com>
1 parent 700363f commit 92f1344

87 files changed

Lines changed: 10655 additions & 2098 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

__mocks__/@gorhom/bottom-sheet.tsx

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,53 @@
1-
import React from 'react';
2-
import { View, FlatList } from 'react-native';
1+
import React, { forwardRef, type PropsWithChildren } from 'react';
2+
import {
3+
View,
4+
FlatList,
5+
type FlatListProps,
6+
type ScrollViewProps,
7+
type ViewProps,
8+
} from 'react-native';
39

4-
export const BottomSheetModal = React.forwardRef(
5-
({ children }: any, _ref: any) => <>{children}</>
6-
);
7-
export const BottomSheetView = ({ children, style }: any) => (
10+
export interface BottomSheetModalRef {
11+
present: () => void;
12+
dismiss: () => void;
13+
}
14+
15+
export type SheetAnimationConfig = Record<string, number | boolean>;
16+
17+
export const BottomSheetModal = forwardRef<
18+
BottomSheetModalRef,
19+
PropsWithChildren
20+
>(({ children }, _ref) => <>{children}</>);
21+
BottomSheetModal.displayName = 'BottomSheetModal';
22+
23+
export const BottomSheetView = ({ children, style }: ViewProps) => (
824
<View style={style}>{children}</View>
925
);
10-
export const BottomSheetFlatList = (props: any) => <FlatList {...props} />;
26+
27+
export const BottomSheetScrollView = ({
28+
children,
29+
contentContainerStyle,
30+
testID,
31+
}: ScrollViewProps) => (
32+
<View style={contentContainerStyle} testID={testID}>
33+
{children}
34+
</View>
35+
);
36+
37+
export const BottomSheetFlatList = <ItemT,>(props: FlatListProps<ItemT>) => (
38+
<FlatList {...props} />
39+
);
40+
1141
export const BottomSheetBackdrop = () => null;
12-
export const BottomSheetModalProvider = ({ children }: any) => <>{children}</>;
13-
export const useBottomSheetTimingConfigs = (_config: any) => ({});
14-
export const useBottomSheetSpringConfigs = (_config: any) => ({});
42+
43+
export const BottomSheetModalProvider = ({ children }: PropsWithChildren) => (
44+
<>{children}</>
45+
);
46+
47+
export const useBottomSheet = () => ({ close: () => {} });
48+
49+
export const useBottomSheetTimingConfigs = (config: SheetAnimationConfig) =>
50+
config;
51+
52+
export const useBottomSheetSpringConfigs = (config: SheetAnimationConfig) =>
53+
config;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export const ExpoResourceFetcher = {
22
cancelFetching: jest.fn(),
33
deleteResources: jest.fn(),
4+
listDownloadedFiles: jest.fn(async () => [] as string[]),
5+
getFilesTotalSize: jest.fn(async () => 0),
46
};

__mocks__/react-native-reanimated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ module.exports = {
7676
inOut: (fn: any) => fn,
7777
out: (fn: any) => fn,
7878
in: (fn: any) => fn,
79+
bezier: () => (t: any) => t,
7980
},
8081
interpolate: (val: any, inputRange: any, outputRange: any) => {
8182
if (val <= inputRange[0]) return outputRange[0];

__tests__/ChatBar.test.tsx

Lines changed: 105 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import React from 'react';
22
import { render, screen, fireEvent, act } from '@testing-library/react-native';
3+
import type { LLMStore } from '../store/llmStore';
4+
import type { Attachment } from '../hooks/useAttachment';
5+
import type { PermissionStatus } from 'react-native-audio-api';
36

47
// ── mocks ─────────────────────────────────────────────────────────────────────
58

9+
const mockRunWithModelOffloaded = jest.fn(
10+
async (operation: () => Promise<unknown>) => operation()
11+
);
12+
613
jest.mock('../context/ThemeContext', () => ({
714
useTheme: () => ({
815
theme: {
@@ -13,17 +20,22 @@ jest.mock('../context/ThemeContext', () => ({
1320
}));
1421

1522
jest.mock('../store/llmStore', () => ({
16-
useLLMStore: jest.fn(() => ({
17-
isGenerating: false,
18-
isProcessingPrompt: false,
19-
interrupt: jest.fn(),
20-
loadModel: jest.fn(),
21-
model: null,
22-
})),
23+
useLLMStore: jest.fn((selector?: (state: Partial<LLMStore>) => unknown) => {
24+
const state = {
25+
isGenerating: false,
26+
isProcessingPrompt: false,
27+
interrupt: jest.fn(),
28+
loadModel: jest.fn(),
29+
model: null,
30+
runWithModelOffloaded:
31+
mockRunWithModelOffloaded as unknown as LLMStore['runWithModelOffloaded'],
32+
};
33+
return selector ? selector(state) : state;
34+
}),
2335
}));
2436

2537
const mockUseAttachment = {
26-
attachments: [] as any[],
38+
attachments: [] as Attachment[],
2739
sheetRef: { current: null },
2840
pickFromLibrary: jest.fn(),
2941
pickFromCamera: jest.fn(),
@@ -45,7 +57,12 @@ jest.mock('../components/bottomSheets/AttachmentSheet', () => {
4557
onPickFromCamera,
4658
onPickDocument,
4759
isVisionModel,
48-
}: any) => (
60+
}: {
61+
onPickFromLibrary: () => void;
62+
onPickFromCamera: () => void;
63+
onPickDocument: () => void;
64+
isVisionModel: boolean;
65+
}) => (
4966
<View testID="attachment-sheet">
5067
<Text>{`vision:${isVisionModel}`}</Text>
5168
<TouchableOpacity testID="pick-library-btn" onPress={onPickFromLibrary}>
@@ -63,7 +80,13 @@ jest.mock('../components/bottomSheets/AttachmentSheet', () => {
6380

6481
jest.mock('../components/chat-screen/AttachmentThumbnail', () => {
6582
const { View, TouchableOpacity, Text } = require('react-native');
66-
return ({ attachment, onRemove }: any) => (
83+
return ({
84+
attachment,
85+
onRemove,
86+
}: {
87+
attachment: Attachment;
88+
onRemove: () => void;
89+
}) => (
6790
<View testID={`attachment-thumb-${attachment.id}`}>
6891
<Text>{attachment.name || attachment.uri}</Text>
6992
<TouchableOpacity
@@ -77,8 +100,14 @@ jest.mock('../components/chat-screen/AttachmentThumbnail', () => {
77100
});
78101

79102
jest.mock('../components/chat-screen/ChatSpeechInput', () => {
80-
const { View, TouchableOpacity, Text } = require('react-native');
81-
return ({ onSubmit, onCancel }: any) => (
103+
const { View, TouchableOpacity } = require('react-native');
104+
return ({
105+
onSubmit,
106+
onCancel,
107+
}: {
108+
onSubmit: (transcript: string) => void;
109+
onCancel: () => void;
110+
}) => (
82111
<View testID="speech-input">
83112
<TouchableOpacity
84113
testID="speech-submit"
@@ -91,7 +120,7 @@ jest.mock('../components/chat-screen/ChatSpeechInput', () => {
91120

92121
jest.mock('../components/chat-screen/PromptSuggestions', () => {
93122
const { TouchableOpacity, Text } = require('react-native');
94-
return ({ onSelectPrompt }: any) => (
123+
return ({ onSelectPrompt }: { onSelectPrompt: (prompt: string) => void }) => (
95124
<TouchableOpacity
96125
testID="prompt-suggestion"
97126
onPress={() => onSelectPrompt('Suggested prompt')}
@@ -114,7 +143,18 @@ jest.mock('../components/chat-screen/ChatBarActions', () => {
114143
onThinkingToggle,
115144
thinkingEnabled,
116145
onAttach,
117-
}: any) => (
146+
}: {
147+
userInput: string;
148+
hasAttachments: boolean;
149+
onSend: () => void;
150+
isGenerating: boolean;
151+
isProcessingPrompt: boolean;
152+
onInterrupt: () => void;
153+
onSpeechInput: () => void;
154+
onThinkingToggle: () => void;
155+
thinkingEnabled: boolean;
156+
onAttach: () => void;
157+
}) => (
118158
<View testID="chat-bar-actions">
119159
<TouchableOpacity testID="attach-btn" onPress={onAttach}>
120160
<Text>+</Text>
@@ -175,7 +215,7 @@ const defaultProps = {
175215
onSelectModel: jest.fn(),
176216
onSelectPrompt: jest.fn(),
177217
model: downloadedModel,
178-
scrollRef: { current: null } as any,
218+
scrollRef: { current: null },
179219
isAtBottom: true,
180220
isVisionModel: false,
181221
thinkingEnabled: false,
@@ -187,23 +227,31 @@ const renderBar = (props: Partial<typeof defaultProps> = {}) =>
187227
render(<ChatBar {...defaultProps} {...props} />);
188228

189229
beforeEach(() => {
190-
mockUseLLMStore.mockReturnValue({
191-
isGenerating: false,
192-
isProcessingPrompt: false,
193-
interrupt: jest.fn(),
194-
loadModel: jest.fn(),
195-
model: null,
196-
});
230+
mockUseLLMStore.mockImplementation(
231+
(selector?: (state: Partial<LLMStore>) => unknown) => {
232+
const state = {
233+
isGenerating: false,
234+
isProcessingPrompt: false,
235+
interrupt: jest.fn(),
236+
loadModel: jest.fn(),
237+
model: null,
238+
runWithModelOffloaded:
239+
mockRunWithModelOffloaded as unknown as LLMStore['runWithModelOffloaded'],
240+
};
241+
return selector ? selector(state) : state;
242+
}
243+
);
197244
mockUseAttachment.attachments = [];
198245
mockUseAttachment.openSheet.mockClear();
199246
mockUseAttachment.clearAll.mockClear();
200247
mockUseAttachment.removeAttachment.mockClear();
248+
mockRunWithModelOffloaded.mockClear();
201249
jest.clearAllMocks();
202250
jest.spyOn(console, 'error').mockImplementation(() => {});
203251
jest.spyOn(console, 'warn').mockImplementation(() => {});
204252
// Default: permission granted
205253
mockAudioManager.requestRecordingPermissions.mockResolvedValue(
206-
'Granted' as any
254+
'Granted' as PermissionStatus
207255
);
208256
});
209257

@@ -293,26 +341,36 @@ describe('downloaded model — text input', () => {
293341

294342
describe('generating state', () => {
295343
it('shows interrupt button when isGenerating', () => {
296-
mockUseLLMStore.mockReturnValue({
297-
isGenerating: true,
298-
isProcessingPrompt: false,
299-
interrupt: jest.fn(),
300-
loadModel: jest.fn(),
301-
model: null,
302-
});
344+
mockUseLLMStore.mockImplementation(
345+
(selector?: (state: Partial<LLMStore>) => unknown) => {
346+
const state = {
347+
isGenerating: true,
348+
isProcessingPrompt: false,
349+
interrupt: jest.fn(),
350+
loadModel: jest.fn(),
351+
model: null,
352+
};
353+
return selector ? selector(state) : state;
354+
}
355+
);
303356
renderBar();
304357
expect(screen.getByTestId('interrupt-btn')).toBeTruthy();
305358
});
306359

307360
it('calls interrupt when interrupt button is pressed', () => {
308361
const interrupt = jest.fn();
309-
mockUseLLMStore.mockReturnValue({
310-
isGenerating: true,
311-
isProcessingPrompt: false,
312-
interrupt,
313-
loadModel: jest.fn(),
314-
model: null,
315-
});
362+
mockUseLLMStore.mockImplementation(
363+
(selector?: (state: Partial<LLMStore>) => unknown) => {
364+
const state = {
365+
isGenerating: true,
366+
isProcessingPrompt: false,
367+
interrupt,
368+
loadModel: jest.fn(),
369+
model: null,
370+
};
371+
return selector ? selector(state) : state;
372+
}
373+
);
316374
renderBar();
317375
fireEvent.press(screen.getByTestId('interrupt-btn'));
318376
expect(interrupt).toHaveBeenCalled();
@@ -332,7 +390,7 @@ describe('speech input', () => {
332390

333391
it('shows toast and stays in text mode when microphone permission is denied', async () => {
334392
mockAudioManager.requestRecordingPermissions.mockResolvedValue(
335-
'Denied' as any
393+
'Denied' as PermissionStatus
336394
);
337395
renderBar();
338396
await act(async () => {
@@ -405,7 +463,7 @@ describe('speech input', () => {
405463
// Override speech mock to submit empty string
406464
jest.mock('../components/chat-screen/ChatSpeechInput', () => {
407465
const { View, TouchableOpacity } = require('react-native');
408-
return ({ onSubmit }: any) => (
466+
return ({ onSubmit }: { onSubmit: (transcript: string) => void }) => (
409467
<View testID="speech-input">
410468
<TouchableOpacity
411469
testID="speech-submit"
@@ -434,9 +492,15 @@ describe('attachment', () => {
434492
expect(screen.getByTestId('attach-btn')).toBeTruthy();
435493
});
436494

437-
it('opens attachment sheet when + button is pressed', () => {
495+
it('offloads the LLM before opening the attachment sheet', async () => {
438496
renderBar();
439-
fireEvent.press(screen.getByTestId('attach-btn'));
497+
await act(async () => {
498+
fireEvent.press(screen.getByTestId('attach-btn'));
499+
});
500+
expect(mockRunWithModelOffloaded).toHaveBeenCalledWith(
501+
expect.any(Function),
502+
{ restore: false }
503+
);
440504
expect(mockUseAttachment.openSheet).toHaveBeenCalled();
441505
});
442506

0 commit comments

Comments
 (0)