kalan_db 0.1.0 copy "kalan_db: ^0.1.0" to clipboard
kalan_db: ^0.1.0 copied to clipboard

PlatformAndroid

Offline hybrid search for Flutter (Android). HNSW + BM25, RRF-fused, with on-device ONNX query embedding — no server required.

example/lib/main.dart

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),
                        ),
                      ],
                    ),
                ],
              ),
            ),
        ],
      ),
    );
  }
}
2
likes
160
points
103
downloads

Documentation

API reference

Publisher

verified publisheratmega.in

Weekly Downloads

Offline hybrid search for Flutter (Android). HNSW + BM25, RRF-fused, with on-device ONNX query embedding — no server required.

Repository (GitHub)
View/report issues

Topics

#vector-search #embeddings #rag #onnx

License

MIT (license)

Dependencies

ffi, flutter, flutter_onnxruntime, plugin_platform_interface

More

Packages that depend on kalan_db

Packages that implement kalan_db