Meilisearch
সেল্ফ-হোস্টিং, ডকুমেন্ট indexing, typo tolerance, faceted search, এবং Meilisearch-কে আপনার ডেটাবেসের সাথে সিঙ্ক রাখা।
বাস্তব জীবনের উদাহরণ
লাইব্রেরির নিয়োগ করা একজন বিশেষজ্ঞ search কনসালট্যান্ট: তিনি একটা আলাদা, বিশেষভাবে বানানো কার্ড সিস্টেম সেট আপ করেন যা শুধুমাত্র দ্রুত জিনিস খুঁজে পাওয়ার জন্য অপ্টিমাইজড — typo-tolerant, genre আর year অনুযায়ী faceted, relevance tuning-সহ। মূল ক্যাটালগ (Postgres) authoritative থাকে; কনসালট্যান্টের সিস্টেমটা তার উপর বসানো search ইন্টারফেস।
গল্পে বুঝি
সমরকন্দের এক ব্যস্ত দোকানে খোয়ারিজমি নামে এক আশ্চর্য চটপটে সেলসম্যান আছেন। আপনি মুখ খুলে “চিনি…” বলতে না বলতেই তিনি কাউন্টারের সামনে চিনিগুঁড়া চাল আর চিনির প্যাকেট দুটোই নামিয়ে ফেলেছেন — পুরো শব্দটা শেষ হওয়ার অপেক্ষাও করেননি। যত টাইপ করছেন, মানে যত বলছেন, ঠিক সেই মুহূর্তেই তাক থেকে সম্ভাব্য জিনিসগুলো সামনে চলে আসছে।
আরও মজার ব্যাপার — আপনি যদি একটু ভুল করে বলেন “chinigura” কিংবা উচ্চারণ এদিক-ওদিক হয়ে যায়, খোয়ারিজমি বিরক্ত হন না, “বুঝলাম না” বলেও ফিরিয়ে দেন না; তিনি ঠিক ধরে ফেলেন আপনি চিনিগুঁড়া চালই খুঁজছেন আর সেটাই এগিয়ে দেন। আর এসব করতে তাঁকে মাস মাস ট্রেনিং দিতে হয়নি — দোকানের নতুন ছেলেটাও প্রথম দিন থেকেই এভাবে কাজ করতে পারে, কারণ পদ্ধতিটা সহজ। সিনা কিংবা ফাতিমা যে-ই আসুক, সবাই সেকেন্ডের মধ্যে জিনিস পেয়ে যায়।
এই দোকানটাই আসলে Meilisearch। কথা বলা শুরু করতেই ফলাফল সামনে চলে আসা — এটাই instant / as-you-type search (প্রতিটা কি-স্ট্রোকে সাথে সাথে রেজাল্ট)। ভুল বলা শব্দও বুঝে ফেলা — এটাই typo tolerance। আর নতুন ছেলেও প্রথম দিনেই পারা — এটাই সহজ setup আর দারুণ ডেভেলপার এক্সপেরিয়েন্স, বেশি কনফিগ ছাড়াই কাজ। সেলসম্যানের গতি হলো Meilisearch-এর হালকা, ঝটপট search। বাস্তবে আপনার অ্যাপ বা সাইটের সার্চ বক্সে ইউজার যখন টাইপ করে আর সাথে সাথে (বানান ভুল হলেও) মিলিয়ে-যাওয়া ফলাফল দেখতে পায়, তখন নেপথ্যে ঠিক এই কাজটাই একটা Meilisearch index করছে।
কেন Meilisearch
Meilisearch একটা Rust-ভিত্তিক search engine যা ডেভেলপার এক্সপেরিয়েন্সের জন্য অপ্টিমাইজড:
- আউট-অফ-দ্য-বক্স typo tolerance (ডিফল্টে 5+ char শব্দের জন্য 1 typo)
- লক্ষ লক্ষ ডকুমেন্টেও sub-100ms search
- count, filter আর sort-সহ facet — সব index অনুযায়ী configure করা
- সিম্পল JSON API — শেখার জন্য কোনো query DSL নেই
- সিঙ্গেল বাইনারি, সহজে সেল্ফ-হোস্ট করা যায়
Elasticsearch-এর তুলনায়: Meilisearch সহজ, সেট আপ করা দ্রুত, কিন্তু কম configurable। Elasticsearch petabyte-scale আর জটিল aggregation হ্যান্ডল করে; Meilisearch Lucene-এ PhD ছাড়াই “আমার প্রোডাক্ট ক্যাটালগ সার্চ করো” হ্যান্ডল করে।
Meilisearch চালানো
# Docker
docker run -d \
--name meilisearch \
-p 7700:7700 \
-e MEILI_MASTER_KEY="your-master-key" \
-v $(pwd)/meili_data:/meili_data \
getmeili/meilisearch:latest
# Docker Compose
services:
meilisearch:
image: getmeili/meilisearch:latest
ports: ["7700:7700"]
environment:
MEILI_MASTER_KEY: "your-master-key"
MEILI_ENV: production
MEILI_DB_PATH: /meili_data
volumes:
- meili_data:/meili_data
restart: unless-stopped # Verify
curl http://localhost:7700/health
# {"status":"available"} একটা Index বানানো এবং Settings Configure করা
import MeiliSearch from 'meilisearch';
const client = new MeiliSearch({
host: 'http://localhost:7700',
apiKey: 'your-master-key'
});
// Create index
const index = client.index('products');
// Configure settings (do this before indexing)
await index.updateSettings({
// Which fields are searchable and their relative importance
searchableAttributes: [
'name', // highest priority
'brand',
'description',
'tags'
],
// Which fields can be used to filter/facet
filterableAttributes: ['category', 'brand', 'price_cents', 'in_stock', 'tags'],
// Which fields can be sorted
sortableAttributes: ['price_cents', 'created_at', 'popularity'],
// Relevance ranking (order matters)
rankingRules: [
'words', // documents with more query words rank higher
'typo', // fewer typos = higher rank
'proximity', // closer query words = higher rank
'attribute', // match in searchableAttributes[0] > [1] > [2]
'sort', // custom sort fields
'exactness', // exact match > prefix match
'popularity:desc' // custom ranking field (inject popularity score)
],
// Typo tolerance configuration
typoTolerance: {
enabled: true,
minWordSizeForTypos: {
oneTypo: 5, // allow 1 typo for words >= 5 chars
twoTypos: 9 // allow 2 typos for words >= 9 chars
}
},
// Stop words (don't index/search these)
stopWords: ['the', 'a', 'an', 'and', 'or', 'but'],
// Synonyms
synonyms: {
tv: ['television', 'screen'],
laptop: ['notebook', 'portable computer']
}
}); Document Indexing করা
// Index documents (upsert — safe to re-run)
await index.addDocuments(
[
{
id: 'prod-123', // required — Meilisearch's primary key
name: 'MacBook Pro 14"',
brand: 'Apple',
category: 'laptops',
description: 'M3 chip, 18GB RAM, 512GB SSD',
price_cents: 199900,
in_stock: true,
tags: ['laptop', 'apple', 'pro'],
popularity: 9500,
created_at: '2024-01-01T00:00:00Z'
}
// ...
],
{ primaryKey: 'id' }
);
// Check indexing status
const task = await index.addDocuments(documents);
const status = await client.waitForTask(task.taskUid);
console.log(status.status); // 'succeeded'
// Update single document
await index.updateDocuments([{ id: 'prod-123', price_cents: 189900 }]);
// Delete
await index.deleteDocument('prod-123'); Searching
// Basic search
const results = await index.search('macbook pro');
console.log(results.hits);
// [{ id: 'prod-123', name: 'MacBook Pro 14"', ... }]
console.log(results.processingTimeMs); // e.g., 3ms
// With filters and facets
const results = await index.search('laptop', {
filter: ['category = "laptops"', 'price_cents < 200000', 'in_stock = true'],
sort: ['price_cents:asc'],
limit: 20,
offset: 0,
facets: ['brand', 'category', 'tags'],
attributesToHighlight: ['name', 'description'],
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
attributesToCrop: ['description'],
cropLength: 100
});
console.log(results.facetDistribution);
// {
// brand: { Apple: 45, Dell: 23, HP: 18 },
// category: { laptops: 86 },
// tags: { laptop: 86, apple: 45, pro: 32 }
// }
// Typo tolerance in action
const results2 = await index.search('macbok pro'); // "macbok" typo
// Still finds MacBook Pro — typo tolerance at work // API route
app.get('/search', async (req, res) => {
const { q = '', category, minPrice, maxPrice, sort = 'popularity:desc', page = 1 } = req.query;
const filters: string[] = [];
if (category) filters.push(`category = "${category}"`);
if (minPrice) filters.push(`price_cents >= ${Number(minPrice) * 100}`);
if (maxPrice) filters.push(`price_cents <= ${Number(maxPrice) * 100}`);
filters.push('in_stock = true');
const results = await index.search(q as string, {
filter: filters.join(' AND '),
sort: [sort as string],
facets: ['brand', 'category'],
limit: 20,
offset: (Number(page) - 1) * 20
});
res.json({
hits: results.hits,
total: results.estimatedTotalHits,
facets: results.facetDistribution,
processingTimeMs: results.processingTimeMs
});
}); Meilisearch-কে সিঙ্কে রাখা
Meilisearch একটা read-optimized secondary index। Postgres authoritative। সিঙ্ক স্ট্র্যাটেজি:
1. Write-through (প্রতিটা write-এ সিঙ্ক):
async function createProduct(product: Product): Promise<Product> {
const saved = await db.create(product);
// Index in Meilisearch (fire-and-forget — ok if it fails, background job retries)
index
.addDocuments([productToSearchDoc(saved)])
.catch((err) => log.error({ err, productId: saved.id }, 'Meilisearch sync failed'));
return saved;
} সহজ কিন্তু ভঙ্গুর — Meilisearch down থাকলে সিঙ্কটা হারিয়ে যায়।
2. Outbox pattern (নির্ভরযোগ্য সিঙ্ক):
// On write: record intent to sync
await db.transaction(async (tx) => {
const product = await tx.create(product);
await tx.query(
'INSERT INTO search_sync_queue (entity_type, entity_id, operation) VALUES ($1, $2, $3)',
['product', product.id, 'upsert']
);
});
// Background worker: process sync queue
async function processSyncQueue() {
const batch = await db.query(
`SELECT * FROM search_sync_queue
WHERE processed_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED`
);
if (batch.rows.length === 0) return;
const toUpsert = batch.rows.filter((r) => r.operation === 'upsert');
const toDelete = batch.rows.filter((r) => r.operation === 'delete');
if (toUpsert.length > 0) {
const products = await db.query('SELECT * FROM products WHERE id = ANY($1)', [
toUpsert.map((r) => r.entity_id)
]);
await index.addDocuments(products.rows.map(productToSearchDoc));
}
if (toDelete.length > 0) {
await index.deleteDocuments(toDelete.map((r) => r.entity_id));
}
await db.query('UPDATE search_sync_queue SET processed_at = NOW() WHERE id = ANY($1)', [
batch.rows.map((r) => r.id)
]);
}
setInterval(processSyncQueue, 5000); 3. Full reindex (নির্ধারিত সময়ে):
async function fullReindex() {
let offset = 0;
const batchSize = 1000;
while (true) {
const products = await db.query('SELECT * FROM products ORDER BY id LIMIT $1 OFFSET $2', [
batchSize,
offset
]);
if (products.rows.length === 0) break;
await index.addDocuments(products.rows.map(productToSearchDoc));
offset += batchSize;
}
log.info({ offset }, 'Full reindex complete');
}
// Run nightly as a safety net
new CronJob('0 2 * * *', fullReindex).start(); Multi-Tenant Search
প্রতি tenant-এ আলাদা index (ছোট tenant) অথবা tenant ID দিয়ে filter (বড় tenant):
// Small SaaS: one index per tenant
function getTenantIndex(tenantId: string) {
return client.index(`products_${tenantId}`);
}
// Large SaaS: shared index with tenant filter
const results = await index.search(query, {
filter: [`tenant_id = "${tenantId}"`]
// tenant_id must be in filterableAttributes
}); আলাদা index পারফেক্ট আইসোলেশন দেয় কিন্তু অপারেশনাল ওভারহেড বাড়ায়। filter-সহ shared index সহজ কিন্তু সতর্ক access control দরকার (সার্ভার-সাইড filter injection, ক্লায়েন্ট-প্রদত্ত filter কখনো বিশ্বাস করবেন না)।
Frontend-এর জন্য Meilisearch API Key
master key কখনো ক্লায়েন্টের কাছে এক্সপোজ করবেন না। scoped API key বানান:
const searchKey = await client.createKey({
description: 'Frontend search — read only',
actions: ['search'],
indexes: ['products'],
expiresAt: null // no expiry for production search key
});
console.log(searchKey.key); // give this to the frontend Frontend সরাসরি Meilisearch কল করতে পারে — আপনার backend বাইপাস করায় শূন্য latency — আর master key সার্ভার-সাইডে থাকে।