kalan_db 0.1.0
kalan_db: ^0.1.0 copied to clipboard
Offline hybrid search for Flutter (Android). HNSW + BM25, RRF-fused, with on-device ONNX query embedding — no server required.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:path_provider/path_provider.dart';
import 'package:kalan_db/kalan_db.dart';
import 'rag_agent.dart';
/// Gemini key is injected at build/run time, never committed:
/// flutter run --dart-define=GEMINI_API_KEY=...
const String kGeminiApiKey = String.fromEnvironment('GEMINI_API_KEY');
const String kModelAsset =
'assets/models/bge-small-en-v1.5/model_quantized.onnx';
const String kVocabAsset = 'assets/models/bge-small-en-v1.5/vocab.txt';
void main() => runApp(const DemoApp());
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Kalan DB Demo',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
home: const HomePage(),
);
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
EmbeddedKalanDB? _edb;
RagAgent? _rag;
String _status = 'Loading model + database…';
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
final dbPath = await _prepareAsset(
'assets/textbooks.vdb',
'textbooks.vdb',
);
final modelPath = await _prepareAsset(
kModelAsset,
'model_quantized.onnx',
);
final vocabPath = await _prepareAsset(kVocabAsset, 'vocab.txt');
final edb = await EmbeddedKalanDB.open(
dbPath: dbPath,
modelPath: modelPath,
vocabPath: vocabPath,
);
setState(() {
_edb = edb;
_rag = RagAgent(edb: edb, apiKey: kGeminiApiKey);
_status = 'Ready — ${edb.db.stats.chunkCount} chunks';
});
} catch (e) {
setState(() => _status = 'Init failed: $e');
}
}
Future<String> _prepareAsset(String asset, String filename) async {
final dir = await getApplicationDocumentsDirectory();
final path = '${dir.path}/$filename';
final file = File(path);
if (!file.existsSync()) {
final data = await rootBundle.load(asset);
await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
}
return path;
}
@override
void dispose() {
_edb?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final edb = _edb;
if (edb == null) {
return Scaffold(
appBar: AppBar(title: const Text('Kalan DB Demo')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(_status),
],
),
),
);
}
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Kalan DB'),
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.search), text: 'Search'),
Tab(icon: Icon(Icons.chat_bubble_outline), text: 'Ask (RAG)'),
],
),
),
body: TabBarView(
children: [
SearchTab(edb: edb, status: _status),
AskTab(rag: _rag!),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Search tab — embeds the query ON-DEVICE, then hybrid/vector/keyword search.
// ---------------------------------------------------------------------------
class SearchTab extends StatefulWidget {
const SearchTab({super.key, required this.edb, required this.status});
final EmbeddedKalanDB edb;
final String status;
@override
State<SearchTab> createState() => _SearchTabState();
}
class _SearchTabState extends State<SearchTab> {
final _textCtrl = TextEditingController();
List<String> _demoQueries = const [];
SearchMode _mode = SearchMode.hybrid;
String _subject = 'All';
int? _grade;
List<SearchResult> _results = const [];
double _embedMs = 0;
double _searchMs = 0;
String? _hint;
bool _busy = false;
static const _subjects = [
'All',
'MATHS',
'SCIENCE',
'ENGLISH',
'TAMIL',
'SOCIAL',
];
static const _grades = [null, 8, 9, 10, 11, 12];
@override
void initState() {
super.initState();
_loadDemoQueries();
}
Future<void> _loadDemoQueries() async {
final raw = await rootBundle.loadString('assets/textbooks_queries.json');
final parsed = jsonDecode(raw) as Map<String, dynamic>;
setState(
() => _demoQueries = (parsed['queries'] as List)
.map((e) => e as String)
.toList(),
);
}
Future<void> _runSearch() async {
final text = _textCtrl.text.trim();
setState(() => _hint = null);
if (text.isEmpty) {
setState(() => _hint = 'Pick a demo query or type some text.');
return;
}
final filter = (_subject == 'All' && _grade == null)
? null
: MetadataFilter(
subject: _subject == 'All' ? null : _subject,
grade: _grade,
);
setState(() => _busy = true);
try {
final sw = Stopwatch()..start();
List<double>? vec;
if (_mode != SearchMode.keywordOnly) {
vec = await widget.edb.embedder.embed(text, isQuery: true);
}
final embedMs = sw.elapsedMicroseconds / 1000.0;
sw.reset();
final results = await widget.edb.db.search(
vec,
queryText: text,
topK: 5,
mode: _mode,
alpha: 0.7,
filter: filter,
);
final searchMs = sw.elapsedMicroseconds / 1000.0;
setState(() {
_results = results;
_embedMs = embedMs;
_searchMs = searchMs;
});
} catch (e) {
setState(() => _hint = 'Search error: $e');
} finally {
setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(widget.status, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 8),
DropdownButtonFormField<int>(
initialValue: null,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Demo query (embedded on-device)',
border: OutlineInputBorder(),
),
items: [
for (int i = 0; i < _demoQueries.length; i++)
DropdownMenuItem(
value: i,
child: Text(_demoQueries[i], overflow: TextOverflow.ellipsis),
),
],
onChanged: (i) {
if (i == null) return;
setState(() => _textCtrl.text = _demoQueries[i]);
},
),
const SizedBox(height: 8),
TextField(
controller: _textCtrl,
decoration: const InputDecoration(
labelText: 'Query text',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _runSearch(),
),
const SizedBox(height: 8),
SegmentedButton<SearchMode>(
segments: const [
ButtonSegment(value: SearchMode.hybrid, label: Text('Hybrid')),
ButtonSegment(
value: SearchMode.vectorOnly,
label: Text('Vector'),
),
ButtonSegment(
value: SearchMode.keywordOnly,
label: Text('Keyword'),
),
],
selected: {_mode},
onSelectionChanged: (s) => setState(() => _mode = s.first),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _subject,
decoration: const InputDecoration(
labelText: 'Subject',
border: OutlineInputBorder(),
),
items: [
for (final s in _subjects)
DropdownMenuItem(value: s, child: Text(s)),
],
onChanged: (v) => setState(() => _subject = v ?? 'All'),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<int>(
initialValue: _grade,
decoration: const InputDecoration(
labelText: 'Grade',
border: OutlineInputBorder(),
),
items: [
for (final g in _grades)
DropdownMenuItem(
value: g,
child: Text(g == null ? 'All' : '$g'),
),
],
onChanged: (v) => setState(() => _grade = v),
),
),
],
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _busy ? null : _runSearch,
icon: _busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.search),
label: const Text('Search'),
),
if (_hint != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_hint!,
style: const TextStyle(color: Colors.deepOrange),
),
),
if (_results.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'${_results.length} results • embed ${_embedMs.toStringAsFixed(1)} ms '
'• search ${_searchMs.toStringAsFixed(1)} ms',
style: Theme.of(context).textTheme.titleSmall,
),
),
Expanded(child: _resultsList()),
],
),
);
}
Widget _resultsList() {
return ListView.separated(
itemCount: _results.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) {
final r = _results[i];
final c = r.chunk;
return ListTile(
title: Text(c.text, maxLines: 3, overflow: TextOverflow.ellipsis),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'chunk ${r.chunkId} • ${c.subject} • grade ${c.grade} '
'• ch ${c.chapter} • p ${c.page}\n'
'score ${r.score.toStringAsFixed(4)} '
'vec ${r.vectorScore.toStringAsFixed(3)} '
'bm25 ${r.bm25Score.toStringAsFixed(2)}',
),
),
isThreeLine: true,
);
},
);
}
}
// ---------------------------------------------------------------------------
// Ask tab — on-device retrieval + Gemini 2.5 Flash Lite grounded answer.
// ---------------------------------------------------------------------------
class AskTab extends StatefulWidget {
const AskTab({super.key, required this.rag});
final RagAgent rag;
@override
State<AskTab> createState() => _AskTabState();
}
class _AskTabState extends State<AskTab> {
final _ctrl = TextEditingController();
bool _busy = false;
String? _error;
RagAnswer? _answer;
Future<void> _ask() async {
final q = _ctrl.text.trim();
if (q.isEmpty) return;
setState(() {
_busy = true;
_error = null;
_answer = null;
});
try {
final ans = await widget.rag.ask(q);
setState(() => _answer = ans);
} on RagException catch (e) {
// Still show retrieved sources so the demo is useful without a key.
try {
final sources = await widget.rag.retrieve(q);
setState(() {
_error = e.message;
_answer = RagAnswer(
answer: '',
sources: sources,
embedMs: 0,
retrieveMs: 0,
generateMs: 0,
);
});
} catch (_) {
setState(() => _error = e.message);
}
} catch (e) {
setState(() => _error = '$e');
} finally {
setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final ans = _answer;
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!widget.rag.hasKey)
Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Colors.amber.shade100,
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'No GEMINI_API_KEY — showing retrieval only. Run with '
'--dart-define=GEMINI_API_KEY=… to enable answers.',
style: TextStyle(fontSize: 12),
),
),
TextField(
controller: _ctrl,
decoration: const InputDecoration(
labelText: 'Ask a question about the textbook',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _ask(),
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _busy ? null : _ask,
icon: _busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome),
label: const Text('Ask'),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_error!,
style: const TextStyle(color: Colors.deepOrange),
),
),
const SizedBox(height: 8),
if (ans != null)
Expanded(
child: ListView(
children: [
if (ans.answer.isNotEmpty) ...[
Card(
child: Padding(
padding: const EdgeInsets.all(12),
// Render the RAG answer as rich text (Markdown + LaTeX).
child: GptMarkdown(ans.answer),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'embed ${ans.embedMs.toStringAsFixed(1)} ms • '
'retrieve ${ans.retrieveMs.toStringAsFixed(1)} ms • '
'generate ${ans.generateMs.toStringAsFixed(0)} ms • '
'total ${ans.totalMs.toStringAsFixed(0)} ms',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
Text(
'Sources (${ans.sources.length})',
style: Theme.of(context).textTheme.titleSmall,
),
for (final s in ans.sources)
ExpansionTile(
title: Text(
'chunk ${s.chunkId} • ch ${s.chunk.chapter} '
'• p ${s.chunk.page}',
),
subtitle: Text(
'score ${s.score.toStringAsFixed(3)} • '
'vec ${s.vectorScore.toStringAsFixed(3)} • '
'bm25 ${s.bm25Score.toStringAsFixed(2)}',
),
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text(s.chunk.text),
),
],
),
],
),
),
],
),
);
}
}