Embedded vector search for React Native. Runs the Qdrant search engine in-process on the device -- no server, no network, fully offline.
Built on qdrant-edge (Rust) with Nitro Modules for near-zero JS-native overhead.
- HNSW-indexed vector search (dense, sparse, multi-vector)
- Structured payload filtering (
must/should/must_not) - Persistent storage -- survives app restarts
- Snapshot interop with server Qdrant
- Multiple independent shards
- React hooks API
- iOS and Android (Expo + bare RN)
npm install react-native-qdrant-edge react-native-nitro-modulesPrebuilt native binaries for iOS (arm64 + simulator) and Android (arm64 + x86_64) are included in the npm package -- no Rust toolchain required.
Add the plugin to app.json:
{
"plugins": ["react-native-qdrant-edge"]
}Then run:
npx expo run:ios
npx expo run:androidcd ios && pod installimport { createShard, loadShard } from 'react-native-qdrant-edge'
// Create a new shard
const shard = createShard('/path/to/shard', {
vectors: { '': { size: 384, distance: 'Cosine' } },
})
// Insert points
shard.upsert([
{ id: 1, vector: [0.1, 0.2, ...], payload: { title: 'Hello' } },
{ id: 2, vector: [0.3, 0.4, ...], payload: { title: 'World' } },
])
// Search
const results = shard.search({
vector: [0.1, 0.2, ...],
limit: 10,
with_payload: true,
})
// [{ id: '1', score: 0.98, payload: { title: 'Hello' } }, ...]
// Persist to disk
shard.flush()
shard.close()
// Reload from disk on next app launch
const loaded = loadShard('/path/to/shard')
const count = loaded.count() // 2
const info = loaded.info() // { points_count: 2, segments_count: 1, ... }Create a new shard at the given filesystem path.
const shard = createShard(path, {
vectors: {
'': { size: 384, distance: 'Cosine' }, // default vector
'title': { size: 128, distance: 'Dot' }, // named vector
},
sparse_vectors: {
'keywords': { modifier: 'Idf' }, // sparse vector
},
})Distance metrics: Cosine | Euclid | Dot | Manhattan
Load an existing shard from disk. Config is optional -- if omitted, uses the stored config.
shard.upsert([{ id: 1, vector: [...], payload: { ... } }])
shard.deletePoints([1, 2, 3])
shard.setPayload(1, { key: 'value' })
shard.deletePayload(1, ['key'])
shard.createFieldIndex('category', 'keyword')
shard.deleteFieldIndex('category')const results = shard.search({
vector: [0.1, 0.2, ...],
limit: 10,
offset: 0,
with_payload: true,
with_vector: false,
score_threshold: 0.5,
filter: {
must: [{ key: 'category', match: { value: 'electronics' } }],
},
})const results = shard.query({
vector: [0.1, 0.2, ...],
limit: 10,
filter: { ... },
fusion: 'rrf', // reciprocal rank fusion
})const points = shard.retrieve([1, 2, 3], {
withPayload: true,
withVector: false,
})
const { points, next_offset } = shard.scroll({
limit: 100,
with_payload: true,
})
const count = shard.count({
must: [{ key: 'active', match: { value: true } }],
})shard.flush() // persist to disk
shard.optimize() // merge segments, build HNSW index
shard.info() // { points_count, segments_count, indexed_vectors_count }
shard.close() // flush and release resourcesFilters follow the Qdrant filter syntax:
{
must: [
{ key: 'price', range: { gte: 10, lte: 100 } },
{ key: 'category', match: { value: 'shoes' } },
],
must_not: [
{ key: 'brand', match: { any: ['Nike', 'Adidas'] } },
],
}Field index types: keyword | integer | float | geo | text | bool | datetime
Create an index before filtering on a field for best performance:
shard.createFieldIndex('price', 'float')
shard.createFieldIndex('category', 'keyword')import { useShard } from 'react-native-qdrant-edge'
function NotesScreen() {
const { shard, isOpen, error, open, close } = useShard({
path: `${documentDir}/notes`,
config: { vectors: { '': { size: 384, distance: 'Cosine' } } },
create: true, // create new shard, or use false / omit to load existing
})
useEffect(() => { open() }, [])
// Automatically closes the shard on unmount
if (!isOpen) return <Text>Loading...</Text>
return <NotesList shard={shard} />
}import { useUpsert } from 'react-native-qdrant-edge'
function AddNote({ shard }) {
const { upsert, error } = useUpsert(shard)
const handleSave = (embedding: number[], text: string) => {
upsert([{ id: Date.now(), vector: embedding, payload: { text } }])
}
return <Button onPress={() => handleSave(embedding, 'My note')} title="Save" />
}import { useDelete } from 'react-native-qdrant-edge'
function NoteItem({ shard, noteId }) {
const { deletePoints, error } = useDelete(shard)
return <Button onPress={() => deletePoints([noteId])} title="Delete" />
}import { useSearch } from 'react-native-qdrant-edge'
function SearchView({ shard, queryEmbedding }) {
const { results, error, search } = useSearch({
shard,
request: { vector: queryEmbedding, limit: 10, with_payload: true },
enabled: true, // auto-search when request changes
})
// Or trigger manually:
const handleRefresh = () => search({ vector: newEmbedding, limit: 5 })
return results.map(r => <ResultCard key={r.id} point={r} />)
}Same as useSearch but uses the advanced query API with fusion support.
import { useQuery } from 'react-native-qdrant-edge'
const { results, query } = useQuery({
shard,
request: { vector: embedding, limit: 10, fusion: 'rrf' },
})import { useRetrieve } from 'react-native-qdrant-edge'
function NoteDetail({ shard, noteIds }) {
const { points, retrieve } = useRetrieve(shard)
useEffect(() => {
retrieve(noteIds, { withPayload: true, withVector: false })
}, [noteIds])
return points.map(p => <Text key={p.id}>{p.payload?.text}</Text>)
}import { useScroll } from 'react-native-qdrant-edge'
function AllNotes({ shard }) {
const { points, nextOffset, scroll } = useScroll(shard)
useEffect(() => { scroll({ limit: 50, with_payload: true }) }, [])
const loadMore = () => {
if (nextOffset) scroll({ offset: nextOffset, limit: 50, with_payload: true })
}
return (
<FlatList
data={points}
onEndReached={loadMore}
renderItem={({ item }) => <Text>{item.payload?.text}</Text>}
/>
)
}import { useCount } from 'react-native-qdrant-edge'
function Stats({ shard }) {
const { count, refresh } = useCount(shard)
// count auto-refreshes when shard changes
// Count with filter:
const activeCount = () => refresh({ must: [{ key: 'active', match: { value: true } }] })
return <Text>{count} points</Text>
}import { useShardInfo } from 'react-native-qdrant-edge'
function ShardStats({ shard }) {
const { info, refresh } = useShardInfo(shard)
return (
<Text>
{info?.points_count} points, {info?.segments_count} segments
</Text>
)
}function App() {
const notes = useShard({
path: `${dataDir}/notes`,
config: { vectors: { '': { size: 384, distance: 'Cosine' } } },
})
const photos = useShard({
path: `${dataDir}/photos`,
config: { vectors: { '': { size: 512, distance: 'Dot' } } },
})
useEffect(() => {
notes.open()
photos.open()
}, [])
// Search across both
const noteResults = useSearch({
shard: notes.shard,
request: { vector: queryVec384, limit: 5, with_payload: true },
})
const photoResults = useSearch({
shard: photos.shard,
request: { vector: queryVec512, limit: 10, with_payload: true },
})
return (
<>
<Section title="Notes" results={noteResults.results} />
<Section title="Photos" results={photoResults.results} />
</>
)
}Each shard is independent with its own storage, index, and config:
import { createShard, loadShard } from 'react-native-qdrant-edge'
// Separate shards for different data types
const documents = createShard(`${dataDir}/documents`, {
vectors: { '': { size: 768, distance: 'Cosine' } },
})
const images = createShard(`${dataDir}/images`, {
vectors: { '': { size: 512, distance: 'Dot' } },
})
// Insert into each independently
documents.upsert([
{ id: 1, vector: docEmbedding, payload: { title: 'Getting started', category: 'docs' } },
{ id: 2, vector: docEmbedding2, payload: { title: 'API reference', category: 'docs' } },
])
images.upsert([
{ id: 1, vector: imgEmbedding, payload: { filename: 'photo.jpg', album: 'vacation' } },
])
// Search each shard separately
const docResults = documents.search({ vector: queryVec768, limit: 5, with_payload: true })
const imgResults = images.search({ vector: queryVec512, limit: 10, with_payload: true })
// Persist both
documents.flush()
images.flush()
// Later, reload from disk
const docs = loadShard(`${dataDir}/documents`)
const imgs = loadShard(`${dataDir}/images`)Only needed if you're contributing or the prebuilt binaries don't cover your target.
- Rust
- Xcode (iOS)
- Android NDK (Android)
# iOS (device + simulator xcframework)
npm run rust:build:ios
# Android (arm64 + x86_64)
npm run rust:build:android
# Both
npm run rust:buildTypeScript API
-> Nitro HybridObject (C++, near-zero overhead)
-> extern "C" FFI
-> qdrant-edge (Rust)
-> HNSW index, WAL, segment storage
All search operations are synchronous and run on the JS thread via JSI -- no bridge, no serialization overhead for the call itself. Vector data is passed as JSON strings across the FFI boundary and deserialized in Rust.
- ArrayBuffer for vectors -- pass vector data as raw
Float32Array/ArrayBufferinstead of JSON strings across the FFI boundary. JSON parse overhead is negligible for search (HNSW lookup dominates), but matters for bulk upsert (1000+ points). Hybrid approach: ArrayBuffer for vectors, JSON for metadata (filter, payload, config). - Async operations -- offload heavy operations (optimize, bulk upsert) to a background thread via Nitro async methods.
- Snapshot import/export -- expose
EdgeShardsnapshot API for syncing with server Qdrant. - Named vector search helpers -- typed API for multi-vector search (e.g. search text vectors and image vectors separately).
MIT