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.4.0

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.8 (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
  • Query groups — group results by a payload field (top-k per group)
  • Search matrix — pairwise distances over a sample, for dedup/clustering
  • Search paramshnsw_ef, exact search, quantization overrides, ACORN, filtered IDF corpus
  • Parallel search — per-segment reads run on a configurable thread pool
  • Faceting — count points per unique value of a payload key
  • Snapshot interop with cloud Qdrant
  • Structured payload filtering (must / should / must_not / min_should), incl. full-text, phrase, and prefix match
  • 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
  max_search_threads: 2,   // per-segment reads run in parallel (default: CPU-derived)
  search_pool_core: 0,     // optionally pin search threads to one core (best-effort)
})

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' } }],
  },
  params: { hnsw_ef: 128 },             // optional search-time tuning
})
// [{ id: '1', score: 0.98, payload: { category: '…' } }, …]

Search params

params is accepted by search, query, queryGroups, and every prefetch level:

{
  hnsw_ef: 128,            // HNSW beam size — accuracy vs speed
  exact: true,             // brute-force search, no approximation
  indexed_only: true,      // skip slow un-indexed segments
  quantization: { ignore: false, rescore: true, oversampling: 2.0 },
  acorn: { enable: true, max_selectivity: 0.4 },
  idf: { corpus: { must: [{ key: 'lang', match: { value: 'en' } }] } },
}

idf selects the population that sparse-vector IDF statistics are computed over (requires a sparse vector with modifier: 'idf'): 'global' (default) or { corpus: <filter> } to score against a filtered corpus.

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.

The BM25 model is configurable (k, b, avg_len, tokenizer, stopwords, stemmer, …). Stemming can be explicitly disabled — the recommended language-neutral setup:

const bm25 = createBm25({
  stemmer: { type: 'none' },
  stopwords: { languages: [] },
})

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 })

Query groups

Group results by a payload field — each group key appears once with its own top hits, instead of one value dominating the result list.

const groups = shard.queryGroups({
  query: queryVector,
  group_by: 'document_id',   // payload key to group by
  limit: 5,                  // number of groups
  group_size: 3,             // hits per group
  with_payload: true,
})
// [{ key: 'doc-1', hits: [{ id, score, payload }, …] }, …]

Search matrix

Pairwise distances over a random sample — each sampled point's nearest neighbours within the sample. Useful for on-device dedup and clustering.

const { sample_ids, nearests } = shard.searchMatrix({
  sample: 20,     // points to sample (default 10)
  limit: 3,       // neighbours per sample (default 3)
  using: 'dense', // omit for the default vector
  filter: { must: [{ key: 'active', match: { value: true } }] },
})
// nearests[i] are the neighbours of sample_ids[i]

Retrieve & scroll

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

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

// Order by an indexed payload field (numeric / datetime index required).
// next_offset is not returned when ordering by field.
const latest = shard.scroll({
  limit: 20,
  with_payload: true,
  order_by: { key: 'created_at', direction: 'desc' },
})

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 (throws on failure)
shard.optimize()    // merge segments, build HNSW indexes
shard.info()        // { points_count, segments_count, indexed_vectors_count,
                    //   payload_schema: { category: { data_type: 'keyword', points: 42 }, … } }
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 } },
  ],
}

Text matching supports full-text (text), any-token (text_any), phrase (phrase), and prefix (prefix) modes on text-indexed fields:

{ must: [{ key: 'title', match: { prefix: 'qdra' } }] }

Additional conditions: { has_id: [...] }, { has_vector: 'name' }, { is_empty: { key } }, { is_null: { key } }, and { slice: { total, index } } — a deterministic slice of the point-id space for processing a shard in independent batches.

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, useQueryGroups, useSearchMatrix,
  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.3.x

0.4.0 upgrades the engine from qdrant-edge 0.7 to 0.8. Additive on the JS API, with three behavior notes:

  • Bm25Stemmer type corrected — the shape is { type: 'snowball', language: 'english' } or { type: 'none' }. The old { language } TS shape never deserialized at runtime, so no working call site changes.
  • flush() now throws on failure instead of aborting the process on an internal panic.
  • search() routes through the query engine (upstream deprecated the standalone search path). Results are identical.

New in 0.4.0: queryGroups, searchMatrix, params on search/query/prefetch (incl. filtered IDF corpus), payload_schema in info(), order_by scroll, prefix match, has_vector / slice conditions, and the max_search_threads / search_pool_core config for the parallel search pool.

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.8 (Rust)
        → HNSW index, WAL, segment storage, BM25 tokenizer, search thread pool

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.
  • Read-only follower shards — qdrant-edge 0.8 ships ReadOnlyEdgeShard (one writer process, N reader processes over the same directory); useful for app-extension setups, not yet exposed here.
  • gRPC client wrapper — out of scope, but could ship alongside.

License

MIT