Build a RAG knowledge base
Build an “ask my docs” assistant in your app, on any client. User types a question, app returns the most semantically relevant FAQ articles ranked by similarity. Zero embedding code on the client — Koolbase embeds records and queries server-side via your configured AI provider.
What you'll build
A small FAQ assistant backed by a collection of question/answer pairs. When the user asks something like “how do I cancel my subscription?”, the app calls searchSemantic and gets back the top 3 FAQs ranked by how close they are in meaning — not keyword match. The query “end my membership” would find the same answer.
This is the bottom half of RAG (Retrieval-Augmented Generation): you retrieve the relevant context with vector search, then optionally pass it to an LLM that grounds its answer in the retrieved docs. We'll cover the retrieval pipeline end-to-end and show the LLM hand-off pattern at the end.
Why semantic search beats keyword search here
Prerequisites
- A Koolbase project — sign up at app.koolbase.com
- An app with a Koolbase SDK installed — Flutter, React Native or the browser — see Installation
- A Gemini API key (free tier, no card required) — aistudio.google.com/apikey
Step 1 — Configure the AI provider
Open the dashboard, go to AI → Providers, click Add Gemini, paste your key, save. The dashboard runs a one-token validation roundtrip and flips the status to valid if the key works.
Why Gemini for the tutorial
openai in place of gemini in the vector field config below. Your key is encrypted at rest; only the worker that does embeddings can decrypt it.Step 2 — Create the FAQ collection
From the dashboard's Database page, create a collection called faqs. Access rules can stay at the defaults for the tutorial — adjust later if you want FAQs to be public-readable in production.
Records will hold a question field (the FAQ's question text, which is what we'll embed) and an answer field (the answer the app will show).
Why embed the question, not the answer
Step 3 — Declare the vector field with auto-embed
On the faqs collection, open Vector Fields and add a field named question_embedding with these settings:
- Dimensions:
1536 - Distance metric:
cosine - Auto-embedding provider:
gemini - Model:
gemini-embedding-001 - Source field:
question
Or, equivalently, from the CLI:
koolbase vector-fields create faqs question_embedding \
--dimensions 1536 \
--provider gemini \
--model gemini-embedding-001 \
--source-field question \
--project <your-project-id>From this point, every record inserted or updated with a non-empty question field is automatically embedded via Gemini and the vector lands in question_embedding on the record. No client embedding code — the worker handles it server-side with retries, deduplication by content hash, and a dead-letter queue for failures.
Step 4 — Seed the FAQs from your app
Insert a handful of FAQs from your app code. The vector for each record is generated asynchronously by the embedding worker — usually a second or two end-to-end. Records are readable immediately; the vector becomes searchable as soon as it's persisted.
Flutter
import 'package:koolbase/koolbase.dart';
final faqs = [
{
'question': 'How do I reset my password?',
'answer': 'Tap "Forgot password" on the login screen and follow the email link.',
},
{
'question': 'How do I cancel my subscription?',
'answer': 'Open Settings → Billing → Cancel subscription. Access continues until period end.',
},
{
'question': 'Can I export my data?',
'answer': 'Yes — Settings → Privacy → Export all data. We email you a download link within 24h.',
},
{
'question': 'How do I change my email address?',
'answer': 'Settings → Account → Change email. We send a confirmation link to the new address.',
},
{
'question': 'Is my data encrypted?',
'answer': 'All data is encrypted in transit (TLS 1.3) and at rest (AES-256).',
},
];
for (final faq in faqs) {
await Koolbase.db.collection('faqs').create(faq);
}React Native
import { Koolbase } from '@koolbase/react-native';
const faqs = [
{
question: 'How do I reset my password?',
answer: 'Tap "Forgot password" on the login screen and follow the email link.',
},
{
question: 'How do I cancel my subscription?',
answer: 'Open Settings → Billing → Cancel subscription. Access continues until period end.',
},
{
question: 'Can I export my data?',
answer: 'Yes — Settings → Privacy → Export all data. We email you a download link within 24h.',
},
{
question: 'How do I change my email address?',
answer: 'Settings → Account → Change email. We send a confirmation link to the new address.',
},
{
question: 'Is my data encrypted?',
answer: 'All data is encrypted in transit (TLS 1.3) and at rest (AES-256).',
},
];
for (const faq of faqs) {
await Koolbase.db.insert('faqs', faq);
}Watch the worker process these
Backfilling existing data
koolbase vector-fields embed-all faqs question_embedding from the CLI, or click Backfill in the dashboard's Vector Fields panel. Idempotent — safe to re-run.Step 5 — Search semantically
Now the magic. When the user types a question, call searchSemantic with their text. Koolbase embeds the query through the same provider/model the field was configured with, then runs a cosine-nearest-neighbor search through the HNSW index. Result is ranked by distance — lowest first, most similar first.
Flutter
import 'package:koolbase/koolbase.dart';
Future<List<Map<String, dynamic>>> findRelevantFaqs(String userQuestion) async {
final result = await Koolbase.db.collection('faqs').searchSemantic(
field: 'question_embedding',
queryText: userQuestion,
limit: 3,
);
return result.hits.map((hit) {
return {
'question': hit.record['question'],
'answer': hit.record['answer'],
// Cosine distance: 0 = identical, ≤2 in practice. Convert to a
// 0–1 similarity score for display if useful.
'similarity': (1 - hit.distance).clamp(0, 1),
};
}).toList();
}React Native
import { Koolbase } from '@koolbase/react-native';
interface Faq {
question: string;
answer: string;
similarity: number;
}
export async function findRelevantFaqs(userQuestion: string): Promise<Faq[]> {
const result = await Koolbase.db.searchSemantic({
collection: 'faqs',
field: 'question_embedding',
queryText: userQuestion,
limit: 3,
});
return result.hits.map((hit) => ({
question: hit.record.question as string,
answer: hit.record.answer as string,
// Cosine distance: 0 = identical, ≤2 in practice.
similarity: Math.max(0, Math.min(1, 1 - hit.distance)),
}));
}No client embedding code
Step 6 — Render results in your UI
Once you have the ranked list, drop it into your existing widget tree. A simple list view works fine for v1; a polished UX would show the question as the headline and the answer as expandable detail.
Flutter
class FaqScreen extends StatefulWidget {
@override
State<FaqScreen> createState() => _FaqScreenState();
}
class _FaqScreenState extends State<FaqScreen> {
final _controller = TextEditingController();
List<Map<String, dynamic>> _results = [];
bool _loading = false;
Future<void> _search() async {
setState(() => _loading = true);
final hits = await findRelevantFaqs(_controller.text);
setState(() {
_results = hits;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Ask a question')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'How can we help?',
suffixIcon: IconButton(
icon: const Icon(Icons.search),
onPressed: _search,
),
),
onSubmitted: (_) => _search(),
),
),
if (_loading) const LinearProgressIndicator(),
Expanded(
child: ListView.builder(
itemCount: _results.length,
itemBuilder: (_, i) => ExpansionTile(
title: Text(_results[i]['question']),
subtitle: Text(
'${(_results[i]['similarity'] * 100).toStringAsFixed(0)}% match',
),
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(_results[i]['answer']),
),
],
),
),
),
],
),
);
}
}React Native
import { useState } from 'react';
import {
export const metadata = {
title: 'Rag Knowledge Base — Examples',
description: 'Build an "ask my docs" assistant in your Flutter or React Native app. User types a question, app returns the most semantically relevant FAQ articles ranked b...',
}
View,
TextInput,
FlatList,
Text,
ActivityIndicator,
TouchableOpacity,
} from 'react-native';
export function FaqScreen() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Faq[]>([]);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState<number | null>(null);
const search = async () => {
setLoading(true);
const hits = await findRelevantFaqs(query);
setResults(hits);
setLoading(false);
};
return (
<View style={{ flex: 1, padding: 16 }}>
<TextInput
value={query}
onChangeText={setQuery}
onSubmitEditing={search}
placeholder="How can we help?"
style={{
borderWidth: 1,
borderColor: '#ccc',
padding: 12,
borderRadius: 8,
}}
/>
{loading && <ActivityIndicator style={{ marginTop: 12 }} />}
<FlatList
data={results}
keyExtractor={(_, i) => String(i)}
renderItem={({ item, index }) => (
<TouchableOpacity
onPress={() => setExpanded(expanded === index ? null : index)}
style={{ padding: 16, borderBottomWidth: 1, borderColor: '#eee' }}
>
<Text style={{ fontWeight: 'bold' }}>{item.question}</Text>
<Text style={{ color: '#666', fontSize: 12 }}>
{Math.round(item.similarity * 100)}% match
</Text>
{expanded === index && (
<Text style={{ marginTop: 8 }}>{item.answer}</Text>
)}
</TouchableOpacity>
)}
/>
</View>
);
}Step 7 — (Optional) Pipe results into an LLM
The retrieval half of RAG is done. To turn this into a chatbot that answers in natural language using your FAQs as ground truth, feed the top hits into an LLM as context. Keep this server-side in a Koolbase Function so your LLM API key never ships to clients.
// Koolbase Function — deploy with: koolbase deploy ask
// Functions export: async function handler(ctx)
// ctx.env carries your encrypted secrets (set with: koolbase secrets set)
export async function handler(ctx) {
const { question } = ctx.request.body ?? {};
// Retrieve top FAQs via the search endpoint (server-side; the API key
// lives in ctx.env and never ships to clients)
const searchRes = await fetch('https://api.koolbase.com/v1/sdk/db/search-semantic', {
method: 'POST',
headers: {
'x-api-key': ctx.env.KOOLBASE_PUBLIC_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
collection: 'faqs',
field: 'question_embedding',
query_text: question,
limit: 3,
}),
});
const result = await searchRes.json();
const context = result.hits
.map((h, i) => `${i + 1}. Q: ${h.record.question}\n A: ${h.record.answer}`)
.join('\n\n');
// Call your LLM of choice — OpenAI, Anthropic, Gemini, etc.
const llmResponse = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${ctx.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content:
'Answer the user\'s question using ONLY the FAQs below. ' +
'If they don\'t cover it, say so honestly.\n\n' + context,
},
{ role: 'user', content: question },
],
}),
});
const data = await llmResponse.json();
return Response.json({ answer: data.choices[0].message.content });
}Call it from your app the same way as any other function:
final result = await Koolbase.functions.invoke('ask', body: {
'question': userQuestion,
});
final answer = result.data?['answer'] as String?;Why server-side for the LLM call
koolbase secrets keeps the secret server-side and lets you add rate limiting, caching, or user-quota logic in one place.When things break
Three dashboards exist specifically for diagnosing RAG pipelines.
- AI → Embeddings — Pending job count, 24h embed throughput, and the dead-letter queue for jobs that failed past their retry budget. Each DLQ row carries the final error message; click Replay after fixing the underlying issue, or Drop to remove permanently.
- AI → Search Playground — Run queries against your vector fields without writing app code. Useful when you're tuning what to embed or want to feel how similarity ranks before integrating.
- AI → Vector Explorer — Inspect the actual stored vector for a record. Useful when a record “doesn't come up in search” — Vector Explorer tells you whether it's missing a vector entirely (auto-embed never ran), the vector is stale (source field changed after embedding), or the vector exists but the query is just genuinely distant.
Common gotchas:
- Empty source field, no vector. Auto-embed silently skips records where the source field is null or empty. The record is still written, just unindexed. Vector Explorer shows
Not storedfor these. - Provider key revoked. If a stored API key gets revoked at the provider, the worker fails on the next embedding attempt and the periodic revalidator flips the provider status to
invalid. Jobs DLQ until you re-enter a valid key. - Source field renamed. Existing vectors don't auto-regenerate when you change the source field on the vector-field config. Run
embed-allto backfill against the new source.
What you shipped
An end-to-end retrieval pipeline: server-side embedding on insert, semantic query at runtime, ranked results in your app, and an optional LLM step that grounds answers in your FAQs. The same shape extends to product search (embed product names + descriptions), customer support (embed past tickets), document retrieval (embed paragraphs), and recommendations (embed item content, search by user history).
Next: Vectors reference for the full API, Access rules for controlling who can search what, and Functions for shipping the LLM hand-off.