npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-native-qdrant-edge

v0.3.1

Published

Embedded vector search for React Native powered by Qdrant Edge

Readme

react-native-qdrant-edge

npm license platforms

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 0.7 (Rust) with Nitro Modules for near-zero JS↔native overhead.

Features

  • Dense, sparse, and multi-vector HNSW search
  • On-device BM25 sparse embedding (no embedding model to ship)
  • Hybrid search via prefetch + fusion (RRF, DBSF)
  • Advanced query modes: recommend, discover, context, MMR (diversity), order-by, sample
  • Faceting — count points per unique value of a payload key
  • Snapshot interop with cloud Qdrant
  • Structured payload filtering (must / should / must_not / min_should)
  • Filter-based payload updates (set / overwrite / delete / clear)
  • Dynamic vector slots (add / remove named vectors at runtime)
  • Runtime HNSW + optimizer config tuning
  • Mobile-tuned WAL options
  • UUID and u64 point IDs
  • Persistent storage — survives app restarts
  • Multiple independent shards
  • React hooks API
  • iOS and Android (Expo + bare RN)

Install

npm install react-native-qdrant-edge react-native-nitro-modules

Prebuilt native binaries for iOS (arm64 + simulator) and Android (arm64 + x86_64) are included — no Rust toolchain required.

Expo

{ "plugins": ["react-native-qdrant-edge"] }
npx expo run:ios
npx expo run:android

Bare React Native

cd ios && pod install

Quick start

import { 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,
})

// Persist + reload
shard.flush()
shard.close()

const loaded = loadShard('/path/to/shard')
console.log(loaded.count())  // 2

API

createShard(path, config)

Create a new shard at the given filesystem path. The full config:

const shard = createShard(path, {
  vectors: {
    '':       { size: 384, distance: 'Cosine' },        // default vector
    'title':  { size: 128, distance: 'Dot' },           // additional named dense
    'image':  { size: 512, distance: 'Cosine', on_disk: true },
  },
  sparse_vectors: {
    'bm25': { modifier: 'idf' },                         // BM25 sparse slot
  },
  on_disk_payload: true,
  hnsw_config:    { m: 16, ef_construct: 100 },
  optimizers:     { default_segment_number: 2, indexing_threshold: 20_000 },
  wal_options:    { segment_capacity: 4 * 1024 * 1024 }, // mobile-friendly 4 MiB
})

Distance metrics: Cosine | Euclid | Dot | Manhattan

For a mobile-tuned WAL preset, see mobileWalDefaults().

loadShard(path, config?)

Load an existing shard. Config is optional — if omitted, the stored config is used.

Shard data

// Mixed dense + sparse + multi vectors per point are supported
shard.upsert([
  {
    id: 'a3f1-...-uuid',
    vector: {
      dense: [0.1, 0.2, /* … */],
      bm25:  { indices: [42, 7, 1003], values: [1.0, 1.0, 1.0] },
    },
    payload: { title: 'Mixed', category: 'docs' },
  },
])

shard.deletePoints([1, 2, 'a3f1-...-uuid'])

// Point IDs are u64 numbers OR UUID strings
shard.updateVectors  // (single-point updates use upsert)

// Payload — single point convenience
shard.setPayload(1, { tag: 'new' })
shard.overwritePayload(1, { tag: 'final' })
shard.deletePayload(1, ['old_key'])

// Payload — full power (filter / batch / nested key)
shard.setPayloadOp({
  payload: { archived: true },
  filter:  { must: [{ key: 'created_at', range: { lt: 1_700_000_000 } }] },
})
shard.overwritePayloadOp({ payload, points: [1, 2, 3] })
shard.deletePayloadOp({ keys: ['stale_key'], filter: { /* … */ } })
shard.clearPayload({ filter: { must: [{ key: 'archived', match: { value: true } }] } })

// Field indexes — required for filtering at scale
shard.createFieldIndex('category', 'keyword')
shard.createFieldIndex('price', 'float')
shard.deleteFieldIndex('category')

Field index types: keyword | integer | float | geo | text | bool | datetime

Search

const results = shard.search({
  vector: [0.1, 0.2, /* … */],         // dense | { indices, values } | [[…]] (multi)
  using: 'dense',                       // omit for the default vector
  limit: 10,
  offset: 0,
  with_payload: true,
  with_vector: false,
  score_threshold: 0.5,
  filter: {
    must: [{ key: 'category', match: { value: 'electronics' } }],
  },
})
// [{ id: '1', score: 0.98, payload: { category: '…' } }, …]

Hybrid query (dense + BM25 sparse)

The query API mirrors the upstream qdrant-client REST shape. A prefetch tree fans out one search per vector type, then a fusion clause merges the rankings.

import { createBm25 } from 'react-native-qdrant-edge'

const bm25 = createBm25({ language: 'english' })
const dense = await embedDense('quick brown fox')             // your dense model
const sparse = bm25.embedQuery('quick brown fox')             // on-device BM25

const results = shard.query({
  prefetch: [
    { query: dense,  using: 'dense', limit: 100 },
    { query: sparse, using: 'bm25',  limit: 100 },
  ],
  query:  { fusion: 'rrf' },          // or 'dbsf'
  limit:  10,
  with_payload: true,
})

bm25.close()

fusion: { fusion: 'rrf', k: 60, weights: [2.0, 1.0] } accepts an optional RRF k and per-source weights. Prefetches nest arbitrarily.

Advanced query modes

All of these go in the query slot at the root or within a prefetch:

// Recommend (positive + negative examples)
shard.query({ query: { recommend: { positive: [v1, v2], negative: [v3], strategy: 'best_score' } } })

// Discover (target + positive/negative context pairs)
shard.query({ query: { discover: { target: v, context: [{ positive: p1, negative: n1 }] } } })

// Context (no target, just preference pairs)
shard.query({ query: { context: [{ positive: p1, negative: n1 }] } })

// Order by payload field
shard.query({ query: { order_by: { key: 'created_at', direction: 'desc' } }, limit: 20 })

// Random sample
shard.query({ query: { sample: 'random' }, limit: 50 })

// MMR — diversity-aware rerank (lambda 0 = full diversity, 1 = full relevance)
shard.query({ query: { mmr: { vector: v, lambda: 0.5, candidates_limit: 100 } }, limit: 10 })

Retrieve & scroll

const points = shard.retrieve([1, 2, 'uuid-…'], { withPayload: true, withVector: false })

const { points, next_offset } = shard.scroll({ limit: 100, with_payload: true })

const total = shard.count()
const active = shard.count({ must: [{ key: 'active', match: { value: true } }] })

Facet

Count points per unique value of a payload key.

const { hits } = shard.facet({
  key: 'category',
  limit: 20,
  filter: { must: [{ key: 'in_stock', match: { value: true } }] },
  exact: true,
})
// [{ value: 'electronics', count: 42 }, { value: 'books', count: 17 }, …]

Snapshot interop

Treat the snapshot manifest as opaque — pass it back through recoverPartialSnapshot verbatim.

import { unpackSnapshot, recoverPartialSnapshot } from 'react-native-qdrant-edge'

// Apply an external snapshot to an existing local shard
unpackSnapshot('/downloads/snapshot.tar', '/tmp/snapshot-unpacked')

const current  = shard.snapshotManifest()
const incoming = JSON.parse(await fs.readTextFile('/tmp/snapshot-unpacked/manifest.json'))

const merged = recoverPartialSnapshot(shard.path, current, '/tmp/snapshot-unpacked', incoming)

Lifecycle

shard.flush()       // persist to disk
shard.optimize()    // merge segments, build HNSW indexes
shard.info()        // { points_count, segments_count, indexed_vectors_count }
shard.close()       // flush + release

Runtime config

shard.setHnswConfig({ m: 32, ef_construct: 200 })
shard.setVectorHnswConfig('bm25', { full_scan_threshold: 5000 })
shard.setOptimizersConfig({ indexing_threshold: 10_000, prevent_unoptimized: true })

shard.createVectorName('caption', { dense: { size: 768, distance: 'Cosine' } })
shard.deleteVectorName('legacy')

Mobile WAL preset

The upstream default WAL segment capacity is 32 MiB — wasteful on phones. Use the helper:

import { createShard, mobileWalDefaults } from 'react-native-qdrant-edge'

const shard = createShard(path, {
  vectors: { '': { size: 384, distance: 'Cosine' } },
  wal_options: mobileWalDefaults(),     // 4 MiB segments, retain_closed: 1
})

Filtering

Filters follow the Qdrant filter syntax:

{
  must: [
    { key: 'price', range: { gte: 10, lte: 100 } },
    { key: 'category', match: { value: 'shoes' } },
  ],
  should: [
    { key: 'brand', match: { any: ['Nike', 'Adidas'] } },
  ],
  must_not: [
    { key: 'archived', match: { value: true } },
  ],
}

Error handling

Every Shard / Bm25 method that fails throws a JS Error with a message of the form "<operation> failed: <cause>". For structured access:

import { asQdrantError } from 'react-native-qdrant-edge'

try {
  shard.upsert(points)
} catch (err) {
  const qe = asQdrantError(err)
  console.log(qe.operation, qe.cause)   // e.g. 'upsert', 'invalid JSON path: …'
}

React hooks

import {
  useShard, useUpsert, useDelete,
  useSearch, useQuery,
  useRetrieve, useScroll, useCount, useShardInfo,
  useBm25, useFacet, useSnapshotManifest,
} from 'react-native-qdrant-edge'

function NotesScreen() {
  const { shard, open, close } = useShard({
    path: `${documentDir}/notes`,
    config: { vectors: { '': { size: 384, distance: 'Cosine' } } },
    create: true,
  })

  const { bm25 } = useBm25({ language: 'english' })

  const { results, search } = useSearch({
    shard,
    request: { vector: queryEmbedding, limit: 10, with_payload: true },
    enabled: true,
  })

  useEffect(() => { open() }, [])
  // shard + bm25 are auto-closed/disposed on unmount

  return <NotesList shard={shard} bm25={bm25} results={results} />
}

Hybrid search via useQuery:

const { results } = useQuery({
  shard,
  request: {
    prefetch: [
      { query: denseVec,  using: 'dense', limit: 100 },
      { query: sparseVec, using: 'bm25',  limit: 100 },
    ],
    query: { fusion: 'rrf' },
    limit: 10,
    with_payload: true,
  },
})

Multiple shards

Each shard is independent — separate storage, config, and indexes.

const docs = createShard(`${dir}/docs`,   { vectors: { '': { size: 768, distance: 'Cosine' } } })
const imgs = createShard(`${dir}/photos`, { vectors: { '': { size: 512, distance: 'Dot' } } })

Migration from 0.2.x

Mostly additive. The only TS-visible widening is:

  • Point.id and IDs in deletePoints / retrieve: number → number | string. Existing numeric IDs still work.
  • Point.vector and SearchRequest.vector: accept sparse { indices, values } and multi [[…]] in addition to dense.
  • Shard.setPayload(pointId, payload) gains an optional 3rd argument key?: string and accepts string IDs. Existing call sites are unchanged.

Note one upstream behavior change: with optimizers.prevent_unoptimized: true, points written to unoptimized segments above indexing_threshold are persisted as deferred — they are excluded from reads/search until you call shard.optimize(). Previously this option blocked the write entirely.

Architecture

TypeScript API
  → Nitro HybridObject (C++, near-zero JS overhead)
    → extern "C" FFI
      → qdrant-edge 0.7 (Rust)
        → HNSW index, WAL, segment storage, BM25 tokenizer

All operations are synchronous and run on the JS thread via JSI — no bridge, no serialization between JS and the C++ object. Vector and metadata payloads cross the FFI boundary as JSON; the JSON-parse overhead is negligible vs HNSW lookup for search, and measurable but acceptable for bulk upsert (raw Float32Array marshaling is on the roadmap — see Future work).

Building from source

Only needed if you contribute, or the prebuilt binaries don't cover your target.

Requirements

  • Rust
  • Xcode (iOS)
  • Android NDK (Android)
  • cbindgen for header regen: cargo install cbindgen

Build

npm run rust:build:ios          # xcframework: device arm64 + simulator
npm run rust:build:android      # arm64 + x86_64
npm run rust:build              # both

After modifying any .nitro.ts, regenerate the bindings:

npm run specs

Future work

  • ArrayBuffer / Float32Array for vectors — skip JSON encoding for bulk upsert (HNSW search itself is already JSON-light).
  • Async / background-thread operations — offload optimize, bulk upsert, and snapshotManifest via Nitro async methods.
  • Formula rescoring — the upstream AST does not impl Deserialize; will need a typed expression builder API.
  • gRPC client wrapper — out of scope, but could ship alongside.

License

MIT