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

@rahul_vendure/vendure-meilli-search

v1.0.1

Published

Vendure plugin for Meilisearch-powered product search with AI hybrid search support

Readme

@rahul_vendure/vendure-meilli-search

A Vendure plugin that replaces the default search with Meilisearch — a fast, typo-tolerant search engine with optional AI-powered hybrid search (semantic + full-text).

Features

  • Full-text search with typo tolerance, synonyms, and stop words
  • AI-powered hybrid search (semantic + keyword) via OpenAI, HuggingFace, Ollama, or any REST embedder
  • Faceted search, collection filtering, price range filtering
  • Price range buckets for building filter UIs
  • Similar documents / product recommendations (when AI is enabled)
  • Configurable matching strategy, highlighting, cropping, and ranking
  • Custom product & variant field mappings
  • Buffered index updates
  • Health check endpoint

Requirements

Installation

# npm
npm install @rahul_vendure/vendure-meilli-search

# yarn
yarn add @rahul_vendure/vendure-meilli-search

# pnpm
pnpm add @rahul_vendure/vendure-meilli-search

Quick Start

Minimal Setup (Full-Text Search Only)

import { MeilisearchPlugin } from '@rahul_vendure/vendure-meilli-search';

export const config: VendureConfig = {
  plugins: [
    MeilisearchPlugin.init({
      host: 'http://localhost:7700',
      apiKey: 'your-master-key',
    }),
  ],
};

With AI Hybrid Search

MeilisearchPlugin.init({
  host: 'http://localhost:7700',
  apiKey: 'your-master-key',
  ai: {
    embedders: {
      default: {
        source: 'openAi',
        model: 'text-embedding-3-small',
        apiKey: process.env.OPENAI_API_KEY,
        documentTemplate:
          "A product called '{{doc.productName}}' - {{doc.description | truncatewords: 20}}",
      },
    },
    semanticRatio: 0.5, // 0 = keyword only, 1 = semantic only
  },
})

After startup, run the reindex mutation from the Admin API to populate the search index.

Full Configuration Reference

MeilisearchPlugin.init({

  // ─── Connection ────────────────────────────────────────────
  host: 'http://localhost:7700',          // Meilisearch server URL
  apiKey: 'your-master-key',             // Master/admin API key
  connectionAttempts: 10,                 // Retry attempts on startup
  connectionAttemptInterval: 5000,        // ms between retries

  // ─── Indexing ──────────────────────────────────────────────
  indexPrefix: 'vendure-',                // Index name prefix (useful for multi-project)
  reindexProductsChunkSize: 2500,         // Products loaded per DB query during reindex
  reindexBatchSize: 1000,                 // Documents sent to Meilisearch per batch
  bufferUpdates: false,                   // Buffer updates instead of immediate indexing

  // ─── Search Query Config ───────────────────────────────────
  searchConfig: {
    // Matching
    matchingStrategy: 'frequency',        // 'last' | 'all' | 'frequency'
    attributesToSearchOn: ['productName', 'description', 'sku'],
    rankingScoreThreshold: 0.15,          // 0.0-1.0, filter out weak results

    // Highlighting
    attributesToHighlight: ['productName', 'description'],
    highlightPreTag: '<mark>',
    highlightPostTag: '</mark>',

    // Cropping
    attributesToCrop: ['description'],
    cropLength: 30,
    cropMarker: '...',

    // Debug / scoring
    showRankingScore: true,
    showRankingScoreDetails: false,
    showMatchesPosition: false,

    // Response
    attributesToRetrieve: ['*'],          // Fields to return

    // Internal limits
    facetValueMaxSize: 50,
    collectionMaxSize: 50,
    totalItemsMaxSize: 10000,
    priceRangeBucketInterval: 1000,       // Price bucket width (in currency subunits)

    // Hooks
    mapQuery: (query, input, searchConfig, channelId, enabledOnly, ctx) => {
      // Modify the raw Meilisearch query before it's sent
      return query;
    },
    mapSort: (sort, input) => sort,
  },

  // ─── Typo Tolerance ────────────────────────────────────────
  typoTolerance: {
    enabled: true,
    minWordSizeForOneTypo: 4,             // Default: 5
    minWordSizeForTwoTypos: 8,            // Default: 9
    disableOnWords: ['iPhone', 'Samsung'],
    disableOnAttributes: ['sku'],
  },

  // ─── Synonyms ──────────────────────────────────────────────
  synonyms: {
    phone: ['mobile', 'smartphone', 'cellphone'],
    laptop: ['notebook'],
    tv: ['television', 'monitor'],
  },

  // ─── Stop Words ────────────────────────────────────────────
  stopWords: ['the', 'a', 'an', 'is', 'for', 'and', 'of'],

  // ─── Ranking Rules ─────────────────────────────────────────
  rankingRules: [
    'words', 'typo', 'proximity', 'attribute',
    'sort', 'exactness', 'productInStock:desc',
  ],

  // ─── AI / Hybrid Search (optional) ─────────────────────────
  ai: {
    embedders: {
      default: {
        source: 'openAi',                // 'openAi' | 'huggingFace' | 'ollama' | 'rest' | 'userProvided'
        model: 'text-embedding-3-small',
        apiKey: process.env.OPENAI_API_KEY,
        documentTemplate: "A product called '{{doc.productName}}' - {{doc.description | truncatewords: 20}}",
        documentTemplateMaxBytes: 400,
        // For 'rest' source:
        // url: 'https://api.example.com/embed',
        // request: { ... },
        // response: { ... },
        // headers: { ... },
        // For 'userProvided' source:
        // dimensions: 1536,
      },
    },
    defaultEmbedder: 'default',           // Which embedder to use by default
    semanticRatio: 0.5,                   // 0.0 = keyword only, 1.0 = semantic only
  },

  // ─── Custom Mappings ───────────────────────────────────────
  customProductMappings: {
    reviewRating: {
      graphQlType: 'Float',
      valueFn: (product, variants, languageCode, injector, ctx) => {
        return product.customFields?.reviewRating ?? 0;
      },
    },
  },
  customProductVariantMappings: {
    warehouse: {
      graphQlType: 'String',
      valueFn: (variant, languageCode, injector, ctx) => {
        return variant.customFields?.warehouse ?? '';
      },
    },
  },

  // ─── Hydration (extra DB relations for custom mappings) ────
  hydrateProductRelations: ['customFields'],
  hydrateProductVariantRelations: ['customFields'],

  // ─── Extend GraphQL Input ──────────────────────────────────
  extendSearchInputType: {
    reviewRating: 'Float',
  },
  extendSearchSortType: ['reviewRating'],
})

Matching Strategy

Controls how Meilisearch matches multi-word queries:

| Strategy | Behavior | Use When | |---|---|---| | 'last' (default) | Returns results even if not all terms match. Drops least important terms progressively. | You want maximum results / fuzzy matching | | 'frequency' | Prioritizes rare/meaningful terms, drops common ones. | Balanced — good default for e-commerce | | 'all' | Only returns documents matching every query term. | You want strict/exact matching |

Typo Tolerance

Meilisearch has built-in typo tolerance. The typoTolerance config lets you tune it:

typoTolerance: {
  enabled: true,
  minWordSizeForOneTypo: 4,   // "shrt" matches "shirt" (4+ chars = 1 typo allowed)
  minWordSizeForTwoTypos: 8,  // "smartphne" matches "smartphone" (8+ chars = 2 typos)
  disableOnWords: ['iPhone'],  // Brand names must be exact
  disableOnAttributes: ['sku'], // SKU must be exact
}

Lower values = more fuzzy. Higher values = more strict.

| Strictness | minWordSizeForOneTypo | minWordSizeForTwoTypos | |---|---|---| | Loose | 3 | 6 | | Balanced | 4 | 8 | | Default | 5 | 9 | | Strict | 6 | 10 |

AI Hybrid Search

When the ai option is configured, searches automatically combine keyword matching with semantic similarity. The Meilisearch server handles all embedding generation — the plugin just configures the embedder and sends plain documents.

Supported Embedder Sources

| Source | Description | |---|---| | 'openAi' | OpenAI API (recommended, works best for most use cases) | | 'huggingFace' | HuggingFace models running on the Meilisearch server | | 'ollama' | Self-hosted Ollama models | | 'rest' | Any REST API embedder (Mistral, Cloudflare, Voyage, etc.) | | 'userProvided' | You compute and supply your own embeddings |

Semantic Ratio

Controls the balance between keyword and semantic results:

0.0  ──────────── 0.5 ──────────── 1.0
pure keyword     balanced      pure semantic

Embeddings & Reindex

  • Embeddings are generated by the Meilisearch server, not this plugin.
  • A full reindex creates a fresh index — all embeddings are regenerated (this incurs API costs with external providers like OpenAI).
  • Incremental updates (product edits) only re-embed affected documents.
  • Removing the ai config and restarting switches to keyword-only search immediately. Reindex to clean up old embeddings from the server.

Similar Documents

When AI is enabled, you can query for similar products via GraphQL:

query {
  similarDocuments(input: {
    id: "1_42_en"
    limit: 10
  }) {
    items {
      productName
      productId
    }
    totalItems
  }
}

Price Range Buckets

The priceRangeBucketInterval controls how search results are grouped into price bands in the response. This data powers price filter UIs:

searchConfig: {
  priceRangeBucketInterval: 2000, // Each bucket spans $20 (2000 cents)
}

Response includes:

{
  "prices": {
    "range": { "min": 500, "max": 15000 },
    "buckets": [
      { "to": 2000, "count": 23 },
      { "to": 4000, "count": 45 },
      { "to": 6000, "count": 12 }
    ]
  }
}

This is separate from the priceRange input filter, which lets users filter results by price.

Multi-Project Setup

If multiple Vendure projects share the same Meilisearch instance, use different indexPrefix values:

// Project A
MeilisearchPlugin.init({ indexPrefix: 'shop-a-', ... })

// Project B
MeilisearchPlugin.init({ indexPrefix: 'shop-b-', ... })

This creates separate indexes (shop-a-variants, shop-b-variants) so reindexing one doesn't affect the other.

Admin API

The plugin extends the Admin API with:

# Rebuild the entire search index
mutation { reindex { ... } }

# Run buffered updates (when bufferUpdates: true)
mutation { runPendingSearchIndexUpdates { ... } }

Custom Mappings

Add extra data to the search index:

customProductMappings: {
  brand: {
    graphQlType: 'String',
    public: true, // Exposed in GraphQL (default: true)
    valueFn: (product, variants, languageCode, injector, ctx) => {
      return product.customFields?.brand ?? '';
    },
  },
},

Access in GraphQL:

query {
  search(input: { term: "shoes" }) {
    items {
      customProductMappings {
        brand
      }
    }
  }
}

Hooks

mapQuery

Intercept and modify the raw Meilisearch query before it's sent:

searchConfig: {
  mapQuery: (query, input, searchConfig, channelId, enabledOnly, ctx) => {
    // Example: boost in-stock products for logged-in users
    if (ctx.activeUser) {
      query.sort = ['inStock:desc', ...(query.sort || [])];
    }
    return query;
  },
}

mapSort

Modify the sort parameters:

searchConfig: {
  mapSort: (sort, input) => {
    if (input.sort?.myCustomField) {
      sort.push(`variant-myCustomField:${input.sort.myCustomField === 'ASC' ? 'asc' : 'desc'}`);
    }
    return sort;
  },
}

Exported Types

import {
  MeilisearchPlugin,
  MeilisearchOptions,
  SearchConfig,
  MatchingStrategy,
  EmbedderConfig,
  AiSearchConfig,
  TypoToleranceConfig,
} from '@rahul_vendure/vendure-meilli-search';

License

MIT