এই পর্বে যা শিখবেন
- কখন relational টেবিলের ভেতরে semi-structured ডেটা রাখা যুক্তিসংগত, আর কখন সেটি ডিজাইনের দুর্গন্ধ
- SQLite-এর JSON ফাংশন — পড়া, মেলে দেওয়া, বদলানো, বানানো আর index করা; PostgreSQL jsonb ও MySQL JSON-এর সাথে তুলনা
- Inverted index কীভাবে তৈরি হয় আর খোঁজে — ধাপে ধাপে; SQLite FTS4/FTS5, PostgreSQL tsvector আর MySQL FULLTEXT
- বাংলা লেখা খোঁজার বাস্তব সমস্যা — Unicode normalization, tokenizer আর stemming
- Array, enum, UUID, range আর geospatial টাইপ — কোনটি কী সমস্যা মেটায়
১ · Relational ডেটাবেসে semi-structured ডেটা কেন
একটি অনলাইন দোকানের কথা ভাবুন, যেখানে মোবাইল ফোন, বই, পাঞ্জাবি আর শাড়ি সবই বিক্রি হয়। সব পণ্যের কিছু তথ্য এক — নাম, দাম, স্টক, ক্যাটাগরি। কিন্তু বাকিটা একেবারে আলাদা। ফোনের আছে RAM, storage আর ক্যামেরা। বইয়ের আছে পৃষ্ঠাসংখ্যা আর ভাষা। শাড়ির আছে কাপড়, দৈর্ঘ্য আর কোথায় বোনা। পাওয়ার ব্যাংকের আছে mAh. কাল যদি দোকানে ওষুধ আসে, আসবে মেয়াদের তারিখ আর ডোজ।
দর্জির দোকানের মাপের খাতা ভাবুন। প্রতিটি পাতায় ওপরে একই ঘর — নাম, ফোন, অর্ডারের তারিখ। কিন্তু নিচের মাপ পোশাকভেদে আলাদা: পাঞ্জাবির জন্য লম্বা, বুক, হাতা; সালোয়ারের জন্য কোমর আর মুহরি; ব্লাউজের জন্য আরও অন্য মাপ। দর্জি প্রতিটি পোশাকের জন্য আলাদা ছাপানো ঘর রাখেন না — নিচে একটি খোলা জায়গায় “লম্বা ৪২, বুক ৪০, হাতা ২৪” লিখে রাখেন। ওপরের নির্দিষ্ট ঘর = সাধারণ কলাম; নিচের খোলা জায়গা = JSON কলাম।
এই ধরনের তথ্যকে বলে semi-structured data — ডেটার ভেতরেই ঘরের নাম লেখা থাকে, কিন্তু সব রেকর্ডের ঘর এক হওয়ার বাধ্যবাধকতা নেই (পর্ব ৪-এ প্রথম দেখেছেন)। প্রশ্ন হলো, relational টেবিলে একে কোথায় রাখবেন? প্রচলিত তিনটি পথ আছে, আর প্রতিটির দাম আছে।
১. চওড়া টেবিল
সব গুণের জন্য আলাদা কলাম: ram_gb, fabric, pages… টাইপ আর CHECK দেওয়া যায়। কিন্তু ক্যাটাগরি বাড়লে কয়েকশো কলাম, বেশিরভাগ ঘর NULL, আর প্রতিটি নতুন গুণে ALTER TABLE.
২. EAV টেবিল
EAV (entity-attribute-value): (product_id, attribute, value) — প্রতিটি গুণ একটি সারি। খুব নমনীয়, কিন্তু সব মান একই টাইপে (সাধারণত text), constraint দুর্বল, আর একটি পণ্যের সব গুণ এক সারিতে আনতে pivot লাগে (পর্ব ২৭)।
৩. JSON কলাম
একটি কলামে পুরো ডকুমেন্ট: {"ram_gb": 8, "storage_gb": 128}. পড়া-লেখা সহজ, অ্যাপের JSON-এর সাথে মেলে। কিন্তু ভেতরের মানে foreign key নেই, বানান ভুল কেউ ধরে না, দ্রুত খোঁজার জন্য আলাদা index লাগে।
আজ প্রায় সব বড় relational DBMS-এ JSON রাখার ব্যবস্থা আছে — PostgreSQL, MySQL, SQL Server, Oracle, SQLite — আর SQL:2016 মানদণ্ডে JSON ফাংশন যোগ হয়েছে।
এই পর্বের এডিটরগুলো চলবে কোর্সের সাধারণ db-shop ডেটাবেসে — customers (customer_id, name, city, phone, joined_on), categories (category_id, name), products (product_id, name, category_id, price, stock), orders (order_id, customer_id, order_date, status) আর order_items (order_id, product_id, quantity, unit_price). এর সাথে যোগ করা আছে একটি নতুন টেবিল:
product_attributes ( product_id INTEGER PRIMARY KEY REFERENCES products(product_id), attrs TEXT NOT NULL CHECK (json_valid(attrs)) -- প্রতিটি পণ্যের JSON )
আটটি পণ্যের attrs আট রকম। Mobile Phone (101)-এর RAM, storage, ক্যামেরা (ভেতরে আরেকটি object), dual SIM আর রঙের তালিকা; Headphone (102)-এর wireless আর ব্যাটারির ঘণ্টা; Power Bank (103)-এর mAh আর port-এর তালিকা; দুটি বইয়ের (104, 105) পৃষ্ঠা আর ভাষা; Panjabi (106)-র কাপড়, মাপের তালিকা আর রং; Saree (107)-র কাপড়, দৈর্ঘ্য (মিটার), উৎস আর রং; আর Smart Watch (110)-এর RAM, storage, ব্যাটারি আর পানিরোধী কি না। চাল আর তেলের (108, 109) কোনো সারি নেই — সব পণ্যের বাড়তি গুণ থাকতেই হবে, এমন নয়। প্রতিটি এডিটরের নিচের “নমুনা ডেটাবেস” বাক্সে পুরো ডেটা দেখতে পাবেন।
২ · SQLite-এ JSON পড়া — json_extract, -> আর ->>
SQLite-এ আলাদা কোনো JSON টাইপ নেই। JSON থাকে সাধারণ TEXT কলামে, আর ফাংশনগুলো সেই লেখাকে পার্স করে ভেতরে ঢোকে। কোন মান চাই, তা বলা হয় একটি JSON path দিয়ে: $ মানে পুরো ডকুমেন্ট, .camera মানে camera নামের ঘর, [0] মানে array-র প্রথম মান (সূচি 0 থেকে), আর [#-1] মানে শেষ মান।
মান বের করার তিনটি উপায়: পুরোনো ফাংশন json_extract(doc, path), আর SQLite ৩.৩৮ (২০২২) থেকে দুটি অপারেটর — -> আর ->>. এই পাতার ইঞ্জিন SQLite 3.49, তাই দুটিই চলে। অপারেটর দুটির পার্থক্য ছোট শোনায়, কিন্তু এটিই এই অংশের সবচেয়ে গুরুত্বপূর্ণ কথা:
doc -> pathফেরত দেয় JSON লেখা — string হলে উদ্ধৃতিচিহ্নসহ ("cotton"), সংখ্যা হলে সংখ্যার লেখা ('8', TEXT হিসেবে)।doc ->> pathফেরত দেয় SQL মান — TEXT, INTEGER, REAL বা NULL, উদ্ধৃতিচিহ্ন ছাড়া। হিসাব আর তুলনার জন্য এটিই চাই। ফলাফলjson_extract-এর প্রায় সমান।
SELECT p.name,
a.attrs -> '$.ram_gb' AS ram_json,
a.attrs ->> '$.fabric' AS fabric,
a.attrs -> '$.fabric' AS fabric_json,
json_extract(a.attrs, '$.camera.rear_mp[0]') AS rear_mp
FROM products p
JOIN product_attributes a ON a.product_id = p.product_id
WHERE p.category_id IN (1, 3) -- Electronics আর Clothing
ORDER BY p.product_id;ফল ছয়টি সারি:
name │ ram_json │ fabric │ fabric_json │ rear_mp Mobile Phone │ 8 │ NULL │ NULL │ 50 Headphone │ NULL │ NULL │ NULL │ NULL Power Bank │ NULL │ NULL │ NULL │ NULL Panjabi │ NULL │ cotton │ "cotton" │ NULL Saree │ NULL │ silk │ "silk" │ NULL Smart Watch │ 1 │ NULL │ NULL │ NULL
fabric আর fabric_json কলাম পাশাপাশি দেখুন: ->> দিয়েছে cotton, আর -> দিয়েছে "cotton" — উদ্ধৃতিচিহ্নসহ, কারণ এটি JSON-এর একটি string-এর লেখা। যে ডকুমেন্টে ঘরটি নেই (যেমন Headphone-এ fabric), সেখানে কোনো error নয়, শুধু NULL. '$.camera.rear_mp[0]' দুই ধাপ নেমে array-র প্রথম মান 50 এনেছে — ফোনের পেছনের প্রধান ক্যামেরা 50 MP. Panjabi বা Saree-র কোনো ক্যামেরা নেই, তাই NULL.
একটি path ধাপে ধাপে কীভাবে ডকুমেন্টের গাছে নামে, আর প্রতিটি ধাপে দুই অপারেটর কী দেয় — Mobile Phone (101)-এর ডকুমেন্টে দেখুন:
| path | |
| -> (JSON) | |
| ->> (SQL মান) | |
| typeof(->>) |
$.camera— একটি object. path মূল থেকে camera ঘরে ঢুকল, যার মান নিজেই একটি object. object বা array চাইলে দুই অপারেটরই একই JSON লেখা (কোনো ফাঁকা জায়গা ছাড়া) ফেরত দেয়, কারণ SQL-এ object রাখার আলাদা টাইপ নেই।$.camera.rear_mp— আরও এক ধাপ নিচে, একটি array. এখানেও ফল JSON লেখা[50,2]. এর ভেতরের প্রতিটি মান আলাদা সারিতে চাইলেjson_eachলাগবে (অংশ ৩)।[0]— পাতায় পৌঁছানো। এবার মানটি একটি সংখ্যা।->>দিল INTEGER 50, যা দিয়ে হিসাব বা তুলনা করা যায়। কিন্তু->দিল TEXT'50'— দেখতে এক, টাইপ আলাদা। পরের এডিটরে এই পার্থক্যই একটি ভুল ফল তৈরি করবে।[#-1]— শেষ মান।#মানে array-র দৈর্ঘ্য, তাই#-1শেষ অবস্থান: "blue". string-এ পার্থক্য সবচেয়ে স্পষ্ট —->উদ্ধৃতিচিহ্নসহ"blue",->>শুধুblue.WHERE color = 'blue'লিখলে প্রথমটি কখনো মিলবে না।$.dual_sim— JSON-এর boolean. SQLite-এ boolean টাইপ নেই, তাই->>true-কে 1 বানাল (আরfalse-কে 0).->রেখে দিল JSON-এর লেখাtrue.json_type(attrs, '$.dual_sim')চালালে আসবে'true'.$.warranty— নেই এমন ঘর। দুই অপারেটরই NULL দিল, কোনো error নয়। এটিই schema-on-read-এর ঝুঁকি:'$.waranty'-এর মতো বানান ভুলও নীরবে NULL দেবে, আর আপনি ভাববেন “ডেটা নেই”।
এবার সেই পার্থক্যের ফাঁদ। প্রশ্ন: “কোন কোন পণ্যের RAM অন্তত 4 GB?” একই কোয়েরি দুবার লেখা হলো — প্রথমবার -> দিয়ে, দ্বিতীয়বার ->> দিয়ে:
-- ভুল: -> দেয় TEXT '8' আর '1'
SELECT p.name, a.attrs -> '$.ram_gb' AS ram
FROM products p JOIN product_attributes a USING (product_id)
WHERE a.attrs -> '$.ram_gb' >= 4;
-- ঠিক: ->> দেয় INTEGER 8 আর 1
SELECT p.name, a.attrs ->> '$.ram_gb' AS ram
FROM products p JOIN product_attributes a USING (product_id)
WHERE a.attrs ->> '$.ram_gb' >= 4;-- প্রথম কোয়েরি -- দ্বিতীয় কোয়েরি name │ ram name │ ram Mobile Phone │ 8 Mobile Phone │ 8 Smart Watch │ 1
প্রথম কোয়েরিতে 1 GB RAM-এর Smart Watch-ও এসেছে! কারণ SQLite-এর তুলনার নিয়ম: টাইপ-affinity ছাড়া একটি TEXT আর একটি সংখ্যা তুলনা করলে TEXT সবসময় বড় ধরা হয় (পর্ব ১৩-এর type affinity). ->-এর ফল TEXT '1', তাই '1' >= 4 সত্য। কোয়েরি নির্বিঘ্নে চলল, কোনো সতর্কবার্তা নেই — শুধু ফল ভুল। নিয়মটি মনে রাখুন: দেখানো বা আবার JSON-এ বসানোর জন্য ->, তুলনা-হিসাব-JOIN-এর জন্য ->>. PostgreSQL-এ -> দেয় jsonb আর ->> দেয় text, তাই সেখানে সংখ্যার তুলনায় আবার cast লাগে — যেমন (attrs ->> 'ram_gb')::int.
SQLite-এ -> ও ->>-এর ডান পাশে পুরো path ('$.ram_gb') বা শুধু ঘরের নাম ('ram_gb') দুটিই চলে; সংখ্যা দিলে (-> 0) তা array-র সূচি ধরা হয়। MySQL-এ path অবশ্যই $ দিয়ে শুরু করতে হয়, আর PostgreSQL-এ ডান পাশে শুধু ঘরের নাম বা সূচি — path নয়। একই চিহ্ন, তিন সিস্টেমে তিন রকম নিয়ম।
৩ · JSON মেলে দেওয়া, বদলানো আর বানানো
json_each আর json_tree — ডকুমেন্ট থেকে সারি
অনেক সময় প্রশ্নটা একটি মান নিয়ে নয়, ডকুমেন্টের ভেতরের সবকিছু নিয়ে — “কোন কোন গুণ কতগুলো পণ্যে আছে?” বা “এই ফোনের সব মান একটি তালিকায় দেখাও”। এর জন্য SQLite-এর দুটি table-valued function — FROM-এ টেবিলের মতো বসে, আর প্রতিটি মানের জন্য একটি সারি দেয়:
json_each(doc)— শুধু প্রথম স্তরের ঘর (বা array-র মান)। কলাম:key,value,type,atom,fullkey,pathইত্যাদি।json_tree(doc)— পুরো গাছ, প্রতিটি স্তরে নেমে (DSA-র ভাষায় depth-first traversal). একই কলাম, সাথে কোন সারি কার সন্তান তা বোঝারparent.
-- কোন গুণ অন্তত দুটি পণ্যে আছে?
SELECT j.key, COUNT(*) AS products, group_concat(a.product_id) AS ids
FROM product_attributes a, json_each(a.attrs) j
GROUP BY j.key
HAVING COUNT(*) >= 2
ORDER BY products DESC, j.key;
-- ফোনের ডকুমেন্টের সব পাতা (object/array বাদে)
SELECT t.fullkey, t.type, t.atom
FROM product_attributes a, json_tree(a.attrs) t
WHERE a.product_id = 101 AND t.type NOT IN ('object', 'array');key │ products │ ids battery_hours │ 2 │ 102,110 color │ 2 │ 106,107 colors │ 2 │ 101,102 fabric │ 2 │ 106,107 language │ 2 │ 104,105 pages │ 2 │ 104,105 ram_gb │ 2 │ 101,110 storage_gb │ 2 │ 101,110 fullkey │ type │ atom $."ram_gb" │ integer │ 8 $."storage_gb" │ integer │ 128 $.camera."rear_mp"[0] │ integer │ 50 $.camera."rear_mp"[1] │ integer │ 2 $.camera."front_mp" │ integer │ 8 $."dual_sim" │ true │ 1 $.colors[0] │ text │ black $.colors[1] │ text │ blue
প্রথম ফলটি আসলে একটি ছোট “স্কিমা জরিপ”: JSON-এ কোন ঘর কতবার ব্যবহার হয়েছে। একটি জিনিস চোখে পড়ার মতো — color (Panjabi, Saree) আর colors (Phone, Headphone) দুটি আলাদা নামে একই ধারণা! সাধারণ কলাম হলে এমন অসংগতি টেবিল বানানোর দিনই ধরা পড়ত; JSON-এ তা নীরবে জমে। বাস্তব প্রকল্পে মাঝেমধ্যে এই কোয়েরি চালিয়ে বানান-বিভ্রাট খোঁজা ভালো অভ্যাস।
দ্বিতীয় ফলে json_tree গাছের প্রতিটি পাতা এনেছে, পুরো path (fullkey) সহ — ক্যামেরার array-র দুটি মানও আলাদা সারিতে। atom কলামে SQL মান: boolean true এখানেও 1. FROM-এ কমা দিয়ে লেখা json_each(a.attrs) আসলে একটি lateral join (পর্ব ২০): প্রতিটি বাঁ-দিকের সারির জন্য ফাংশনটি নতুন করে চলে।
বদলানো, বানানো আর বৈধতা যাচাই
ডকুমেন্টের একটি ঘর বদলাতে পুরো JSON লেখা অ্যাপে এনে, বদলে, আবার পাঠানোর দরকার নেই। SQLite-এর চারটি ফাংশন নতুন একটি ডকুমেন্ট ফেরত দেয় (মূলটি বদলায় না — তাই UPDATE … SET attrs = … দিয়ে বসাতে হয়):
| ফাংশন | ঘর আগে থেকে আছে | ঘর নেই |
|---|---|---|
json_set(doc, path, value, …) | মান বদলায় | নতুন ঘর যোগ করে |
json_insert(doc, path, value, …) | কিছু করে না | নতুন ঘর যোগ করে |
json_replace(doc, path, value, …) | মান বদলায় | কিছু করে না |
json_remove(doc, path, …) | ঘর মুছে দেয় | কিছু করে না |
UPDATE product_attributes
SET attrs = json_set(attrs, '$.ram_gb', 12, '$.warranty_months', 12)
WHERE product_id = 101;
UPDATE product_attributes
SET attrs = json_insert(attrs, '$.ram_gb', 99, '$.charger', 'included')
WHERE product_id = 101;
UPDATE product_attributes
SET attrs = json_remove(attrs, '$.dual_sim', '$.colors[0]')
WHERE product_id = 101;
SELECT attrs FROM product_attributes WHERE product_id = 101;প্রতিটি UPDATE-এ ১টি সারি বদলেছে, শেষে ডকুমেন্টটি:
{"ram_gb":12,"storage_gb":128,"camera":{"rear_mp":[50,2],"front_mp":8},
"colors":["blue"],"warranty_months":12,"charger":"included"}
ধাপে ধাপে মিলিয়ে নিন। json_set RAM 8 থেকে 12 করল (ঘর ছিল) আর warranty_months যোগ করল (ঘর ছিল না)। json_insert RAM-কে 99 করেনি — ঘর আগে থেকেই ছিল, আর insert কখনো পুরোনো মান মোছে না; শুধু নতুন charger যোগ হলো। json_remove dual_sim মুছল আর রঙের তালিকা থেকে প্রথম মান black সরাল, তাই এখন শুধু ["blue"]. “না থাকলে যোগ করো, থাকলে ছুঁয়ো না” — এই সূক্ষ্ম পার্থক্য AI-এর লেখা কোডে প্রায়ই গুলিয়ে যায়।
উল্টো দিকের কাজও সমান জরুরি: সাধারণ টেবিল থেকে JSON বানানো। ধরুন মোবাইল অ্যাপের API চায় প্রতিটি অর্ডার একটি JSON হিসেবে — গ্রাহকের নাম, অবস্থা আর জিনিসের তালিকা। json_object একটি object বানায়, আর দুটি aggregate function — json_group_array আর json_group_object — GROUP BY-এর প্রতিটি দলের সারিগুলোকে একটি JSON array বা object-এ গুছিয়ে দেয়:
SELECT o.order_id,
json_object('customer', c.name, 'status', o.status,
'items', json_group_array(
json_object('product', p.name, 'qty', oi.quantity))) AS doc
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
WHERE o.order_id IN (1001, 1003)
GROUP BY o.order_id;
SELECT json_group_object(name, stock) AS stock_map
FROM products WHERE category_id = 1;order_id │ doc
1001 │ {"customer":"Rahim Uddin","status":"delivered","items":[{"product":"Mobile Phone","qty":1},{"product":"Headphone","qty":2}]}
1003 │ {"customer":"Rahim Uddin","status":"shipped","items":[{"product":"Rice 5kg","qty":3},{"product":"Mustard Oil 1L","qty":2}]}
stock_map
{"Mobile Phone":12,"Headphone":40,"Power Bank":0,"Smart Watch":5}
চারটি টেবিলের JOIN থেকে প্রতিটি অর্ডারের দুটি সারি এসেছিল; json_group_array সেগুলোকে একটি array-তে গুছিয়েছে, ঠিক যেমন COUNT সারি গুনে একটি সংখ্যা দেয় (পর্ব ১৮)। দ্বিতীয় কোয়েরি Electronics-এর চারটি পণ্যকে নাম → স্টক object বানাল। এভাবে ডেটা থাকে normalised টেবিলে, আর JSON তৈরি হয় শুধু বাইরে পাঠানোর মুহূর্তে — অনেক ক্ষেত্রে এটিই সবচেয়ে পরিচ্ছন্ন নকশা। (array-র ভেতরের ক্রম নিশ্চিত নয়; নির্দিষ্ট ক্রম চাইলে subquery-তে আগে সাজিয়ে নিতে হয়।) PostgreSQL-এ একই কাজ jsonb_build_object আর jsonb_agg, MySQL-এ JSON_OBJECT আর JSON_ARRAYAGG.
শেষ প্রশ্ন: কেউ যদি ভাঙা JSON লিখে দেয়? json_valid(x) বলে লেখাটি বৈধ JSON কি না (1 বা 0), আর json(x) বৈধ JSON-কে ফাঁকা জায়গা ছাড়া ছোট (minified) রূপে দেয়। আমাদের টেবিলে CHECK (json_valid(attrs)) আছে — একটি CHECK constraint. নিচের শেষ বাক্যটিতে ইচ্ছা করে একটি বাড়তি কমা রাখা হয়েছে:
SELECT json_valid('{"ram_gb": 8}') AS ok,
json_valid('{ram_gb: 8}') AS bad,
json('{ "a" : [1, 2] }') AS minified;
-- শেষে বাড়তি কমা — JSON-এ এটি নিষেধ
INSERT INTO product_attributes VALUES (108, '{"weight_kg": 5,}');প্রথম কোয়েরির ফল 1 │ 0 │ {"a":[1,2]} — ঘরের নাম উদ্ধৃতিচিহ্ন ছাড়া লিখলে (JavaScript-এ যা চলে) JSON হিসেবে তা অবৈধ। তারপর INSERT থামল: CHECK constraint failed: json_valid(attrs). ভাঙা ডকুমেন্ট টেবিলে ঢোকেনি। এই CHECK না থাকলে ভাঙা লেখাটি জমা হতো, আর পরে প্রতিটি JSON ফাংশন সেই সারিতে malformed JSON error দিত — একটি খারাপ সারি পুরো রিপোর্ট আটকে দিত। PostgreSQL-এর jsonb আর MySQL-এর JSON টাইপ নিজেই লেখার সময় বৈধতা যাচাই করে; SQLite-এ এই দায়িত্ব আপনার CHECK-এর।
বৈধ JSON মানেই সঠিক ডেটা নয়। {"ram_gb": "eight"} বা {"ram": 8} দুটিই বৈধ JSON, কিন্তু আপনার কোয়েরি $.ram_gb-কে সংখ্যা ধরে নেয়। দরকারি ঘরের জন্য আরও কড়া CHECK দেওয়া যায় — যেমন CHECK (attrs ->> '$.ram_gb' IS NULL OR json_type(attrs, '$.ram_gb') = 'integer'). PostgreSQL-এ একই চিন্তায় CHECK বা JSON Schema যাচাইয়ের extension ব্যবহার হয়।
৪ · JSON-এ index — SQLite, PostgreSQL আর MySQL
এখন পর্যন্ত প্রতিটি JSON কোয়েরিতে SQLite প্রতিটি সারির ডকুমেন্ট পার্স করেছে — একটি full table scan. আটটি সারিতে টের পাওয়া যায় না, কিন্তু দশ লাখ পণ্যে “৮ GB RAM-এর ফোন” খুঁজতে দশ লাখ JSON পার্স করা মানে প্রতিটি খোঁজে কয়েক সেকেন্ড। সমাধান সাধারণ index-এর মতোই, শুধু কলামের বদলে একটি expression-এর ওপর — expression index (পর্ব ১৭-এ প্রথম দেখেছেন)। অথবা JSON-এর মানটিকে একটি generated column বানিয়ে তার ওপর সাধারণ index.
CREATE INDEX idx_attr_ram
ON product_attributes (json_extract(attrs, '$.ram_gb'));
EXPLAIN QUERY PLAN SELECT product_id FROM product_attributes
WHERE json_extract(attrs, '$.ram_gb') >= 4;
EXPLAIN QUERY PLAN SELECT product_id FROM product_attributes
WHERE attrs ->> '$.ram_gb' >= 4;
ALTER TABLE product_attributes ADD COLUMN ram_gb INTEGER
GENERATED ALWAYS AS (attrs ->> '$.ram_gb') VIRTUAL;
CREATE INDEX idx_ram_col ON product_attributes (ram_gb);
EXPLAIN QUERY PLAN SELECT product_id, ram_gb FROM product_attributes
WHERE ram_gb >= 4;
SELECT product_id, ram_gb FROM product_attributes WHERE ram_gb >= 4;SEARCH product_attributes USING COVERING INDEX idx_attr_ram (<expr>>?) SCAN product_attributes SEARCH product_attributes USING INDEX idx_ram_col (ram_gb>?) product_id │ ram_gb 101 │ 8
তিনটি plan থেকে তিনটি শিক্ষা (EXPLAIN QUERY PLAN-এর detail কলাম দেখুন)। এক: json_extract(attrs, '$.ram_gb') লেখা কোয়েরি index পেয়েছে — SEARCH … USING COVERING INDEX. দুই: একই অর্থের attrs ->> '$.ram_gb' লেখা কোয়েরি পেয়েছে SCAN — পুরো টেবিল! SQLite expression index ব্যবহার করে শুধু তখন, যখন কোয়েরির expression index-এর expression-এর সাথে হুবহু মেলে; অর্থে সমান হলেও লেখা আলাদা হলে optimizer তা চেনে না। তিন: generated column সেই সমস্যা মেটায় — expression একবারই, টেবিলের সংজ্ঞায় লেখা; কোয়েরি শুধু ram_gb বলে, আর index নির্ভরযোগ্যভাবে কাজে লাগে। VIRTUAL মানে মানটি ডিস্কে জমা থাকে না, পড়ার সময় হিসাব হয় (index-এ অবশ্য জমা থাকে); SQLite-এ ALTER TABLE দিয়ে শুধু VIRTUAL generated column যোগ করা যায়।
খরচের হিসাবটা স্পষ্ট করে নিই। n সারির টেবিলে index ছাড়া প্রতিটি খোঁজে n-টি ডকুমেন্ট পার্স হয় — প্রতিটি ডকুমেন্টের দৈর্ঘ্য L হলে মোট কাজ মোটামুটি n × L-এর সমানুপাতিক। B-tree index (পর্ব ৪০) থাকলে মিলে যাওয়া প্রথম মানে পৌঁছাতে লাগে O(log n) ধাপ, তারপর প্রতিটি মিলে যাওয়া সারির জন্য একটি করে ধাপ — মোট O(log n + k), যেখানে k = ফলের সারির সংখ্যা। বিনিময়ে প্রতিটি INSERT/UPDATE-এ expression-টি একবার হিসাব করে index হালনাগাদ করতে হয়।
অন্য দুই প্রধান সিস্টেমে একই চিন্তা, ভিন্ন হাতিয়ার (এই কোডগুলো এখানে চলবে না — শুধু পড়ার জন্য):
PostgreSQL-এ দুটি টাইপ: json (লেখাটি হুবহু রাখে) আর jsonb (পার্স করা binary রূপ — পড়া দ্রুত, index করা যায়, তবে ঘরের ক্রম, ফাঁকা জায়গা আর ডুপ্লিকেট ঘর রাখে না)। প্রায় সব ক্ষেত্রে jsonb-ই বাছা হয়। এর বিশেষ অপারেটর @> (“বাঁ দিকের ডকুমেন্টে কি ডান দিকেরটি আছে?” — containment) আর ? (“এই ঘর আছে কি?”), যাদের দ্রুত করে GIN index.
CREATE TABLE product_attributes (
product_id INT PRIMARY KEY REFERENCES products(product_id),
attrs JSONB NOT NULL -- বৈধতা টাইপ নিজেই যাচাই করে
);
SELECT product_id,
attrs -> 'camera' -> 'rear_mp' -> 0 AS rear_json, -- jsonb
attrs ->> 'fabric' AS fabric -- text
FROM product_attributes;
SELECT product_id FROM product_attributes
WHERE attrs @> '{"fabric": "silk"}'; -- containment
SELECT product_id FROM product_attributes
WHERE attrs ? 'ram_gb'; -- ঘরটি আছে কি না
-- @>, ?, ?|, ?& দ্রুত করে (jsonb_path_ops: ছোট, শুধু @> ও path)
CREATE INDEX idx_attrs_gin ON product_attributes USING GIN (attrs);
-- সংখ্যার তুলনা GIN দিয়ে হয় না: B-tree expression index
CREATE INDEX idx_ram ON product_attributes (((attrs ->> 'ram_gb')::int));
SELECT product_id FROM product_attributes
WHERE (attrs ->> 'ram_gb')::int >= 4;
-- SQL/JSON path (PostgreSQL 12 থেকে): 10 MP-এর বড় ক্যামেরাগুলো
SELECT product_id, jsonb_path_query(attrs, '$.camera.rear_mp[*] ? (@ > 10)')
FROM product_attributes;GIN (Generalized Inverted Index) আসলে একটি inverted index — ডকুমেন্টের প্রতিটি key ও মান থেকে “কোন কোন সারিতে আছে” তালিকা। অংশ ৬-এ ঠিক এই ধারণাটিই full-text search-এর জন্য ধাপে ধাপে বানাব।
MySQL-এ JSON টাইপ (৫.৭ থেকে) লেখার সময় বৈধতা যাচাই করে binary রূপে রাখে। JSON কলামে সরাসরি index দেওয়া যায় না; পথ হলো generated column, অথবা ৮.০.১৩ থেকে functional index. ->> হলো JSON_UNQUOTE(JSON_EXTRACT(…))-এর সংক্ষিপ্ত রূপ।
CREATE TABLE product_attributes (
product_id INT PRIMARY KEY,
attrs JSON NOT NULL,
ram_gb INT GENERATED ALWAYS AS (CAST(attrs ->> '$.ram_gb' AS UNSIGNED)) VIRTUAL,
INDEX idx_ram (ram_gb),
FOREIGN KEY (product_id) REFERENCES products(product_id)
);
SELECT product_id,
JSON_EXTRACT(attrs, '$.camera.rear_mp[0]') AS rear_mp,
attrs ->> '$.fabric' AS fabric
FROM product_attributes
WHERE ram_gb >= 4; -- idx_ram কাজে লাগে
-- ৮.০.১৩+: generated column ছাড়াই functional index
CREATE INDEX idx_ram2 ON product_attributes
((CAST(attrs ->> '$.ram_gb' AS UNSIGNED)));
-- ৮.০.১৭+: array-র জন্য multi-valued index, খোঁজা MEMBER OF দিয়ে
SELECT product_id FROM product_attributes
WHERE 'blue' MEMBER OF (attrs -> '$.colors');| বিষয় | SQLite | PostgreSQL | MySQL |
|---|---|---|---|
| টাইপ | TEXT (৩.৪৫ থেকে binary JSONB-ও) | json, jsonb | JSON (binary) |
| লেখার সময় যাচাই | নিজে CHECK দিতে হয় | হ্যাঁ | হ্যাঁ |
| মান বের করা | ->, ->>, json_extract | ->, ->>, #>, jsonb_path_query | ->, ->>, JSON_EXTRACT |
| সারিতে মেলে দেওয়া | json_each, json_tree | jsonb_array_elements, jsonb_each, JSON_TABLE (১৭) | JSON_TABLE |
| Index | expression index, generated column | GIN (পুরো ডকুমেন্ট), B-tree expression | generated column, functional, multi-valued |
৫ · JSON কখন ঠিক, আর কখন ডিজাইনের দুর্গন্ধ
প্রোগ্রামাররা “code smell” বলেন এমন লক্ষণকে, যা নিজে ভুল নয় কিন্তু গভীর সমস্যার ইঙ্গিত দেয়। JSON কলামের সবচেয়ে বড় smell: আপনি প্রতিনিয়ত JSON-এর ভেতরে খুঁজছেন, ফিল্টার করছেন, JOIN করছেন। তখন বুঝতে হবে তথ্যটি আসলে কাঠামোবদ্ধ — তার জায়গা সাধারণ কলাম বা আলাদা টেবিল (normalization, পর্ব ৩৬)। নিচের stepper-এ একই চারটি প্রশ্ন দিয়ে ছয়টি গুণের জায়গা ঠিক করা হলো:
- সবার আছে?
প্রায় সব সারিতেনা থাকলে NULL-এর ভিড় - নিয়ম লাগবে?
FK, টাকা, NOT NULLDBMS-কে রক্ষা করতে হবে - প্রায়ই খোঁজা?
WHERE, ORDER BYপ্রতিদিনের ফিল্টার - তালিকা, আলাদা খোঁজ?
একাধিক মানপ্রতিটি মান ধরে প্রশ্ন
| গুণ | |
| ① সবার আছে? | |
| ② নিয়ম লাগবে? | |
| ③ প্রায়ই খোঁজা? | |
| সিদ্ধান্ত |
- price — সবার, নিয়মসহ, প্রতিদিন খোঁজা। প্রতিটি পণ্যের দাম আছে, দাম ধনাত্মক হতেই হবে, আর তালিকা প্রায়ই দাম ধরে সাজানো হয়। প্রথম প্রশ্নেই উত্তর স্পষ্ট: সাধারণ কলাম। db-shop-এ ঠিক তাই আছে। দাম JSON-এ রাখা মানে CHECK, টাইপ আর সহজ index — সব হারানো।
- seller_id — অন্য টেবিলের দিকে নির্দেশ। ধরুন দোকানে একাধিক বিক্রেতা এল। বিক্রেতার আইডি JSON-এ রাখলে foreign key দেওয়া অসম্ভব — মুছে যাওয়া বিক্রেতার আইডিও থেকে যাবে, আর প্রতিটি JOIN-এ JSON পার্স হবে। নিয়ম যেখানে লাগে, সেখানে কলাম:
seller_id INTEGER REFERENCES sellers. - fabric, length_m — শুধু এক ক্যাটাগরির, মূলত দেখানোর জন্য। ফোন বা বইয়ের এগুলো নেই; চওড়া টেবিলে এগুলো মানে হাজার হাজার NULL ঘর। খোঁজা হয় কদাচিৎ, কোনো কঠোর নিয়ম নেই। JSON-এর আদর্শ জায়গা — দর্জির খাতার নিচের খোলা অংশ।
- ram_gb — কিছু পণ্যের, কিন্তু প্রতিদিন ফিল্টার। “৮ GB বা তার বেশি” ফিল্টারটি সাইটে সবচেয়ে জনপ্রিয় হতে পারে। মাঝের পথ: JSON-এই থাকুক, কিন্তু generated column বানিয়ে index দিন (অংশ ৪)। চাহিদা আরও বাড়লে — যেমন ফোনের জন্য আলাদা ফিল্টার পাতা —
phone_specsনামের আলাদা টেবিল বিবেচনা করুন। - colors — একাধিক মান, আর প্রতিটি মান ধরে প্রশ্ন। “লাল রঙের সব পণ্য” — এখানে প্রশ্ন array-র ভেতরের একটি মান নিয়ে। প্রতিবার json_each দিয়ে সব ডকুমেন্ট মেলে দেওয়া ধীর, আর
colorবনামcolors-এর মতো অসংগতি (অংশ ৩) জমতেই থাকে। প্রশ্ন ④-এর উত্তর হ্যাঁ, তাই normalise করুন:product_colors(product_id, color), primary key দুটি মিলে — এটি 1NF-এর মূল কথাও। - warranty_note — একবার-দুবারের মন্তব্য। “ওয়ারেন্টি কার্ড বাক্সের ভেতরে” — কেউ এ দিয়ে খোঁজে না, কোনো নিয়ম নেই। JSON-ই যথেষ্ট। সারসংক্ষেপ: যত বেশি “হ্যাঁ”, তত বেশি কাঠামো। JSON হলো ব্যতিক্রমের জায়গা, ডিজাইন এড়ানোর অজুহাত নয়।
| লক্ষণ | অর্থ | কী করবেন |
|---|---|---|
| প্রায় প্রতিটি কোয়েরির WHERE-এ একই JSON ঘর | ঘরটি আসলে কাঠামোর অংশ | কলামে তুলে আনুন, বা generated column + index |
| JSON-এর ভেতরের আইডি দিয়ে JOIN | লুকানো foreign key, যা কেউ রক্ষা করছে না | আলাদা কলাম বা junction টেবিল |
| JSON-এ অন্য টেবিলের তথ্যের কপি (বিক্রেতার নাম) | Redundancy — এক জায়গায় বদলালে অন্যটি পুরোনো | শুধু আইডি রাখুন, নাম JOIN করে আনুন |
| একই ধারণার একাধিক বানান (color/colors) | কেউ স্কিমা দেখভাল করছে না | CHECK, অ্যাপে validation, নিয়মিত json_each জরিপ |
পুরো টেবিলে মাত্র একটি data কলাম | Relational ডেটাবেসকে key-value দোকান বানানো | আসল ডিজাইন করুন; সত্যিই দরকার হলে document DB (পর্ব ৫৬–৫৭) |
৬ · Full-text search কেন — LIKE '%শব্দ%'-এর সীমা আর inverted index
দোকানের সার্চ বক্সে একজন ক্রেতা লিখলেন “phone”. সবচেয়ে সহজ SQL হলো WHERE name LIKE '%phone%'. দেখা যাক এটি কী করে — নামের ওপর একটি index বানিয়ে, তারপর খুঁজে, তারপর plan দেখে:
CREATE INDEX idx_products_name ON products (name);
SELECT product_id, name FROM products WHERE name LIKE '%phone%';
EXPLAIN QUERY PLAN
SELECT product_id FROM products WHERE name LIKE '%phone%';
EXPLAIN QUERY PLAN
SELECT product_id FROM products WHERE name GLOB 'Mobile*';product_id │ name 102 │ Headphone 101 │ Mobile Phone SCAN products USING COVERING INDEX idx_products_name SEARCH products USING COVERING INDEX idx_products_name (name>? AND name<?)
তিনটি সমস্যা একসাথে দেখা গেল। (১) শব্দের সীমা বোঝে না: ক্রেতা ফোন চেয়েছিলেন, এসেছে Headphone-ও — কারণ “Headphone”-এর ভেতরে “phone” অক্ষরগুলো আছে। LIKE অক্ষর মেলায়, শব্দ নয়। (২) প্রাসঙ্গিকতা নেই: কোনটি বেশি মানানসই, LIKE জানে না; ক্রম এসেছে index-এর বর্ণানুক্রম থেকে (H আগে, M পরে)। (৩) ধীর: plan বলছে SCAN — index থাকলেও প্রতিটি নাম শুরু থেকে শেষ পর্যন্ত পড়তে হলো, কারণ শুরুতে % থাকলে B-tree-র সাজানো ক্রম কাজে লাগে না (অভিধানে “শেষে ‘ফোন’ আছে এমন সব শব্দ” খোঁজার মতো)। শেষের GLOB 'Mobile*' উপসর্গ (prefix) দিয়ে খোঁজে, তাই একটি সীমার ভেতরে SEARCH করতে পেরেছে। (SQLite-এ LIKE 'Mobile%'-ও index পায় কেবল বিশেষ শর্তে, কারণ ডিফল্টে LIKE ইংরেজি অক্ষরে case-insensitive; GLOB case-sensitive.)
আরও আছে: “charging” লিখলে “charges” মেলে না (শব্দের মূল বোঝে না), আর n সারি ও গড় দৈর্ঘ্য L হলে প্রতিটি খোঁজে মোটামুটি n × L অক্ষর পড়তে হয়।
সমাধানটি বহু শতাব্দী পুরোনো। বইয়ের শেষে যে নির্ঘণ্ট (index) থাকে — “ডেটাবেস: পৃষ্ঠা ১২, ৪৫, ৮০” — সেটিই inverted index. স্বাভাবিক দিকটা হলো “এই ডকুমেন্টে কোন কোন শব্দ”; উল্টো (inverted) দিকটা হলো “এই শব্দ কোন কোন ডকুমেন্টে”। প্রতিটি শব্দের ডকুমেন্ট-তালিকাকে বলে posting list. লেখাকে শব্দে ভাঙার কাজটি করে tokenizer. নিচে চারটি ছোট রিভিউ (ছোট করে লেখা, ছোট হাতের অক্ষরে, যতিচিহ্ন বাদ দিয়ে) থেকে ধাপে ধাপে index বানানো আর খোঁজা দেখুন:
d1: Battery lasts two days. Camera is good. d2: Fast phone, good camera. d3: Battery became weak. d4: Battery drains fast.
| কাজ | |
| আলাদা শব্দ | |
| posting মোট | |
| ফল |
- d1 ভাঙা। tokenizer লেখাটিকে সাতটি শব্দে ভাঙল: battery, lasts, two, days, camera, is, good. প্রতিটি শব্দ index-এ নতুন, তাই সাতটি নতুন posting list, প্রতিটিতে শুধু d1. শব্দগুলো বর্ণানুক্রমে সাজানো রাখা হয় — বইয়ের নির্ঘণ্টের মতো — যাতে কোনো শব্দ দ্রুত খুঁজে পাওয়া যায় (সাধারণত একটি B-tree-তে)।
- d2 যোগ। fast আর phone নতুন শব্দ, তাদের তালিকায় d2. good আর camera আগে থেকেই ছিল — তাদের তালিকার শেষে d2 যোগ হলো: camera → d1, d2. আলাদা শব্দ এখন ৯টি, তালিকাগুলোয় মোট ১১টি entry. লক্ষ করুন, “phone” এখন একটি পুরো শব্দ; “headphone” লেখা থাকলে সেটি হতো আলাদা শব্দ, phone-এর তালিকায় ঢুকত না।
- d3 যোগ। battery-র তালিকা বড় হলো: d1, d3. became আর weak নতুন। প্রতিটি নতুন ডকুমেন্ট শুধু নিজের শব্দগুলোর তালিকা ছোঁয় — পুরো index নতুন করে বানাতে হয় না।
- d4 যোগ। battery → d1, d3, d4; fast → d2, d4; drains নতুন। Index তৈরি শেষ: ১২টি শব্দ, ১৭টি entry. বাস্তবে প্রতিটি entry-তে শব্দটি ডকুমেন্টের কোন অবস্থানে আছে (position), তাও রাখা হয় — বাক্যাংশ (“good camera”) আর কাছাকাছি শব্দ (NEAR) খোঁজার জন্য।
- খোঁজ: battery. কোনো ডকুমেন্ট পড়তে হলো না! সাজানো শব্দ-তালিকায় battery খুঁজে (B-tree-তে O(log V), V = আলাদা শব্দের সংখ্যা) তার posting list পড়লেই উত্তর: d1, d3, d4. LIKE হলে চারটি ডকুমেন্টের প্রতিটি অক্ষর পড়তে হতো।
- খোঁজ: battery AND fast. দুটি তালিকা আনা হলো: [d1, d3, d4] আর [d2, d4]. দুটিই সাজানো, তাই merge join-এর মতো দুই আঙুলে একসাথে হাঁটা যায়: d1 < d2 → বাঁ আঙুল এগোও; d3 > d2 → ডান আঙুল এগোও; d3 < d4 → বাঁ এগোও; d4 = d4 → মিল! ফল d4. খরচ দুই তালিকার দৈর্ঘ্যের যোগফলের সমানুপাতিক, O(p₁ + p₂) — ডকুমেন্টের মোট লেখার দৈর্ঘ্য নয়। OR হলে একই হাঁটায় union.
বাস্তব full-text engine এর সাথে আরও তিনটি কাজ করে। Normalization — ছোট হাতের অক্ষর, accent চিহ্ন সরানো (café → cafe). Stemming — শব্দকে মূলে নামানো: charges, charging → charg (Porter-এর ১৯৮০ সালের অ্যালগরিদম ইংরেজির জন্য সবচেয়ে পরিচিত)। Stop word বাদ — the, is, a-র মতো অতি সাধারণ শব্দ, যা প্রায় সব ডকুমেন্টে আছে (PostgreSQL-এর english কনফিগারেশন এগুলো বাদ দেয়; SQLite FTS ডিফল্টে দেয় না)। তারপর relevance ranking — কোন ডকুমেন্ট বেশি মানানসই, তা হিসাব করে সাজানো।
B-tree index উত্তর দেয় “এই পুরো মান বা এই উপসর্গ কোন সারিতে?” Inverted index উত্তর দেয় “এই শব্দ কোন ডকুমেন্টে?” প্রথমটি একটি সারিকে একটি key দেয়; দ্বিতীয়টি একটি ডকুমেন্টকে বহু key-তে ছড়িয়ে দেয়। তাই full-text, jsonb-র ভেতরের key আর array-র মান — তিনটির জন্যই PostgreSQL একই ধরনের GIN index ব্যবহার করে।
৭ · SQLite FTS, PostgreSQL tsvector আর MySQL FULLTEXT
SQLite-এ full-text search আসে একটি virtual table হিসেবে — দেখতে টেবিল, কিন্তু ভেতরে আসলে inverted index. কয়েকটি সংস্করণের (FTS1 থেকে FTS5) মধ্যে আজ প্রচলিত FTS4 আর FTS5 (FTS5 এসেছে SQLite ৩.৯, ২০১৫ সালে)।
এই পাতার ইঞ্জিন সম্পর্কে সৎ তথ্য: লাইভ এডিটরগুলো চলে sql.js 1.14 (SQLite 3.49)-এর সাধারণ build-এ। আমরা এটি যাচাই করেছি — CREATE VIRTUAL TABLE … USING fts5 চালালে আসে no such module: fts5, আর PRAGMA compile_options-এ আছে শুধু ENABLE_FTS3 ও ENABLE_FTS3_PARENTHESIS. তাই এখানে লাইভ চলবে FTS4 (FTS3-এর উন্নত রূপ, একই module). FTS5-এর উদাহরণ নিচে আলাদা করে, Python-এর sqlite3 দিয়ে চালানো।
এডিটরে db-shop-এর সাথে একটি নতুন সাধারণ টেবিল আছে — reviews (review_id, product_id, body) — সাতটি ইংরেজি রিভিউ: ১ ও ২ Mobile Phone-এর, ৩ Headphone-এর, ৪ Saree-র, ৫ Panjabi-র, ৬ Power Bank-এর, ৭ Smart Watch-এর। এডিটর থেকে একটি FTS4 টেবিল বানিয়ে রিভিউগুলো তাতে ঢোকানো হচ্ছে। tokenize=porter মানে ইংরেজি Porter stemmer চালু।
CREATE VIRTUAL TABLE review_fts USING fts4(body, tokenize=porter);
INSERT INTO review_fts (docid, body) SELECT review_id, body FROM reviews;
SELECT docid, body FROM review_fts WHERE review_fts MATCH 'battery';
SELECT docid FROM review_fts WHERE review_fts MATCH 'battery camera';
SELECT docid, body FROM review_fts WHERE review_fts MATCH 'charging';
SELECT docid, snippet(review_fts, '[', ']', '…', -1, 6) AS hit
FROM review_fts WHERE review_fts MATCH 'batter* OR camera';docid │ body 1 │ Battery lasts two days. Camera is good in daylight. 3 │ Great sound. The battery became weak after six months. 7 │ Battery drains fast when GPS is on. docid 1 docid │ body 6 │ Charges my phone three times. Heavy but reliable. docid │ hit 1 │ [Battery] lasts two days. [Camera] is… 2 │ …and a good [camera], but the… 3 │ Great sound. The [battery] became weak… 7 │ [Battery] drains fast when GPS is…
MATCH 'battery' শব্দ ধরে খুঁজেছে, বড়-ছোট হাতের অক্ষর নির্বিশেষে। 'battery camera' মানে দুটি শব্দই থাকতে হবে (ফাঁকা জায়গা = AND), যেকোনো জায়গায় — তাই শুধু 1. 'charging' Porter stemmer-এ হলো charg, তাই “Charges” (6) মিলেছে — কিন্তু “charger” (2) মেলেনি, কারণ Porter “charger”-কে বদলায় না। Stemming একটি নিয়মভিত্তিক অনুমান, অভিধান নয়। শেষ কোয়েরিতে batter* একটি উপসর্গ খোঁজ (prefix query), আর snippet() মিলে যাওয়া অংশের চারপাশ থেকে ছোট টুকরো কেটে শব্দগুলো বন্ধনীতে দেখিয়েছে — সার্চ ফলের পাতায় যেমন হাইলাইট দেখেন। নিজে চেষ্টা করুন: '"good camera"' (হুবহু বাক্যাংশ), 'battery NOT fast', 'battery NEAR/3 weak'.
লক্ষ করুন, ফলগুলো docid-এর ক্রমে এসেছে, প্রাসঙ্গিকতার ক্রমে নয়। FTS4-এ কোনো তৈরি ranking ফাংশন নেই (matchinfo() দিয়ে নিজে লিখতে হয়)। FTS5-এ আছে bm25() — তথ্য-অনুসন্ধানের (information retrieval) একটি সুপরিচিত সূত্র, যা TF-IDF-এর চিন্তা থেকে এসেছে: শব্দটি ডকুমেন্টে যত বেশিবার (term frequency), আর পুরো সংগ্রহে যত বিরল (inverse document frequency), স্কোর তত ভালো; ছোট ডকুমেন্টে একবার থাকা বড় ডকুমেন্টে একবার থাকার চেয়ে বেশি অর্থবহ। নিচের Python প্রোগ্রামটি FTS5 চালায় (এটি অনলাইন কম্পাইলারে CPython-এর sqlite3-এ চলে, এই পাতার sql.js-এ নয়); এতে একটি “স্প্যাম” রিভিউ (৮) রাখা হয়েছে, যেখানে battery শব্দটি চারবার:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE VIRTUAL TABLE review_fts USING fts5(body, tokenize='porter')")
db.executemany("INSERT INTO review_fts (rowid, body) VALUES (?, ?)", [
(1, "Battery lasts two days. Camera is good in daylight."), (2, "Fast phone and a good camera, but the charger gets hot."),
(3, "Great sound. The battery became weak after six months."), (4, "Beautiful silk saree, the colour is exactly as shown."),
(5, "Soft cotton panjabi, comfortable for summer."), (6, "Charges my phone three times. Heavy but reliable."),
(7, "Battery drains fast when GPS is on."), (8, "Battery battery battery! Best battery ever."),
(9, "Rice was clean and the packet was sealed."), (10, "Delivery was quick, the book pages are thick."),
])
sql = """SELECT rowid, round(bm25(review_fts), 3) AS score,
highlight(review_fts, 0, '[', ']') AS hit
FROM review_fts WHERE review_fts MATCH ?
ORDER BY bm25(review_fts)""" # ছোট (বেশি ঋণাত্মক) = বেশি প্রাসঙ্গিক
for q in ("battery", "battery OR camera"):
print("query:", q)
for rowid, score, hit in db.execute(sql, (q,)):
print(f" {rowid:>2} | {score:>6} | {hit}")query: battery 8 | -0.652 | [Battery] [battery] [battery]! Best [battery] ever. 7 | -0.389 | [Battery] drains fast when GPS is on. 1 | -0.352 | [Battery] lasts two days. Camera is good in daylight. 3 | -0.352 | Great sound. The [battery] became weak after six months. query: battery OR camera 1 | -1.522 | [Battery] lasts two days. [Camera] is good in daylight. 2 | -1.067 | Fast phone and a good [camera], but the charger gets hot. 8 | -0.652 | [Battery] [battery] [battery]! Best [battery] ever. 7 | -0.389 | [Battery] drains fast when GPS is on. 3 | -0.352 | Great sound. The [battery] became weak after six months.
SQLite-এর bm25() ইচ্ছা করে ঋণাত্মক সংখ্যা দেয়, যাতে ORDER BY bm25(…) ছোট থেকে বড় সাজালেই সেরা ফল আগে আসে। প্রথম খোঁজে স্প্যাম রিভিউ ৮ সবার ওপরে — শব্দ চারবার থাকায় term frequency বেশি। কিন্তু খেয়াল করুন, চারবার থাকা স্কোর একবার থাকার চার গুণ নয় (-0.652 বনাম -0.389): BM25 term frequency-কে “সম্পৃক্ত” (saturate) করে, তাই keyword ঠেসে দেওয়ার লাভ সীমিত। ৭-এর স্কোর ১ ও ৩-এর চেয়ে ভালো, কারণ ৭ ছোট ডকুমেন্ট (length normalization). দ্বিতীয় খোঁজে দুটি শব্দ মেলায় ১ সবার ওপরে উঠে এসেছে, আর camera বিরল (দুটি ডকুমেন্টে) বলে তার ওজন battery-র (চারটিতে) চেয়ে বেশি — তাই একবার camera থাকা ২-ও চারবার battery থাকা ৮-এর ওপরে, দ্বিতীয় স্থানে। highlight() পুরো লেখায় মিলে যাওয়া শব্দ চিহ্নিত করে; FTS5-এ snippet()-ও আছে। এই সংখ্যাগুলো স্থানীয় কম্পিউটারে Python-এর sqlite3 (SQLite 3.50) দিয়ে মিলিয়ে নেওয়া।
অন্য দুই সিস্টেমে full-text search টেবিলের ভেতরেই থাকে, আলাদা virtual table লাগে না (চলবে না, শুধু পড়ার জন্য):
PostgreSQL-এ লেখাকে প্রথমে tsvector-এ রূপান্তর করা হয় — stem করা শব্দ আর তাদের অবস্থানের সাজানো তালিকা। প্রশ্ন হয় tsquery, মেলানো হয় @@ অপারেটরে, আর index হয় GIN. এই ব্যবস্থা ৮.৩ সংস্করণ (২০০৮) থেকে মূল PostgreSQL-এর অংশ।
SELECT to_tsvector('english', 'Charges my phone three times');
-- 'charg':1 'phone':3 'three':4 'time':5 (my = stop word, বাদ)
-- PostgreSQL 12+: লেখা বদলালে tsv নিজে থেকে হালনাগাদ হয়
ALTER TABLE reviews ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;
CREATE INDEX idx_reviews_tsv ON reviews USING GIN (tsv);
SELECT review_id,
ts_rank(tsv, q) AS rank,
ts_headline('english', body, q) AS hit
FROM reviews, websearch_to_tsquery('english', 'battery -fast') AS q
WHERE tsv @@ q
ORDER BY rank DESC;websearch_to_tsquery (১১ থেকে) সাধারণ মানুষের সার্চ-বক্সের ভাষা বোঝে: উদ্ধৃতিচিহ্নে বাক্যাংশ, - দিয়ে বাদ, or. বাংলার জন্য PostgreSQL-এ সাধারণত কোনো তৈরি stemmer নেই; তখন 'simple' কনফিগারেশন (শুধু ছোট হাতের অক্ষর, কোনো stem নয়) ব্যবহার করা হয় — নিজের সংস্করণে \dF চালিয়ে কোন ভাষা আছে দেখে নিন।
MySQL-এর InnoDB-তে (৫.৬ থেকে) FULLTEXT index আর MATCH … AGAINST. ডিফল্টে ৩ অক্ষরের চেয়ে ছোট শব্দ index হয় না (innodb_ft_min_token_size), আর একটি stop word তালিকা আছে। চীনা-জাপানি-কোরীয়র মতো ফাঁকা জায়গাহীন ভাষার জন্য ngram parser আছে।
ALTER TABLE reviews ADD FULLTEXT INDEX ft_body (body);
-- natural language mode: স্কোরসহ
SELECT review_id, MATCH(body) AGAINST ('battery camera') AS score
FROM reviews
WHERE MATCH(body) AGAINST ('battery camera')
ORDER BY score DESC;
-- boolean mode: + থাকতেই হবে, - থাকা চলবে না, * উপসর্গ
SELECT review_id FROM reviews
WHERE MATCH(body) AGAINST ('+batter* -fast' IN BOOLEAN MODE);৮ · বাংলা লেখা খোঁজার চ্যালেঞ্জ
ইংরেজির জন্য তৈরি সার্চ বাংলায় প্রায়ই অদ্ভুত আচরণ করে। তিনটি বড় সমস্যা, আর প্রথম দুটি নিচের এডিটরেই দেখা যাবে।
১. একই অক্ষর, দুই রকম Unicode. “ড়” লেখা যায় একটি code point দিয়ে (U+09DC), আবার “ড” + নুকতা “়” (U+09A1 U+09BC) — দুটি code point দিয়েও। পর্দায় হুবহু এক, কম্পিউটারের কাছে আলাদা। কোন কিবোর্ড বা কোন অ্যাপ দিয়ে লেখা হয়েছে, তার ওপর নির্ভর করে কোন রূপ আসবে। এর সমাধান Unicode normalization — লেখা জমা রাখার আগে সবকিছু একটি নির্দিষ্ট রূপে (সাধারণত NFC) বদলে নেওয়া। Unicode-এর নিয়ম অনুযায়ী NFC-তে U+09DC ভেঙে দুই code point-এর রূপ হয়ে যায় (ড়, ঢ়, য় — এই তিনটি এমন “composition exclusion”).
২. Tokenizer বাংলা বোঝে না। SQLite FTS4-এর ডিফল্ট simple tokenizer ইংরেজি ছাড়া সব অক্ষরকে শব্দের অংশ ধরে — বাংলার দাঁড়ি “।”-কেও! আর unicode61 tokenizer (Unicode ৬.১-এর অক্ষর-শ্রেণি অনুযায়ী) কার-চিহ্ন (া, ি, ু…) আর নুকতাকে শব্দ-বিভাজক ধরে, ফলে “শাড়ির” ভেঙে হয় শ, ড, র — তিনটি অর্থহীন টুকরো।
SELECT char(2524) = char(2465, 2492) AS same_letter,
length(char(2524)) AS len1, length(char(2465, 2492)) AS len2;
CREATE VIRTUAL TABLE bn_simple USING fts4(t);
CREATE VIRTUAL TABLE bn_uni USING fts4(t, tokenize=unicode61);
INSERT INTO bn_simple (docid, t) VALUES (1, 'লাল শাড়ি'),
(2, 'শাড়ির দাম কত'), (3, 'নীল শাড়ি।'),
(4, 'সবুজ শা' || char(2524) || 'ি'); -- একক code point ড়
INSERT INTO bn_uni (docid, t) SELECT docid, t FROM bn_simple;
SELECT docid, t FROM bn_simple WHERE t MATCH 'শাড়ি';
SELECT docid, t FROM bn_uni WHERE t MATCH 'শাড়ি';same_letter │ len1 │ len2 0 │ 1 │ 2 docid │ t (simple tokenizer) 1 │ লাল শাড়ি docid │ t (unicode61 tokenizer) 1 │ লাল শাড়ি 2 │ শাড়ির দাম কত 3 │ নীল শাড়ি।
প্রথম ফল: দুই রূপের “ড়” SQL-এর চোখে সমান নয় (0), দৈর্ঘ্যও আলাদা — ১ বনাম ২ অক্ষর। (char(2524) মানে U+09DC; 2524 হলো 0x09DC-এর দশমিক রূপ।) এই পাতার সব বাংলা লেখা দুই code point-এর রূপে লেখা, তাই ডকুমেন্ট ৪ কোনো খোঁজেই আসেনি — সার্চ বক্সে ঠিক বানান লিখেও সবুজ শাড়িটি পাওয়া যাবে না।
simple tokenizer শুধু ১ পেয়েছে। ৩-এর লেখা “শাড়ি।” — দাঁড়িসহ একটি token, তাই মেলেনি; ২-এর “শাড়ির” আলাদা শব্দ। unicode61 ১, ২ আর ৩ — তিনটিই পেয়েছে, যা প্রথমে ভালো দেখায়। কিন্তু কারণটি ভুল: সে “শাড়ি” আর “শাড়ির” দুটিকেই কার-চিহ্নে ভেঙে শ, ড (আর র) টুকরো বানিয়েছে, আর “শ-এর ঠিক পরে ড” — এই টুকরো-ক্রমটি মিলিয়েছে। ফলে সম্পর্কহীন শব্দও মিলে যায়: আমরা যাচাই করে দেখেছি, এই tokenizer-এ MATCH 'শাড়ি' “হাতির শুঁড়”-কেও খুঁজে আনে! সার্চের ফলে আবর্জনা ঢুকবে। আমরা SQLite-এর fts3tokenize virtual table দিয়ে token-গুলো আলাদা করে দেখে এটি নিশ্চিত হয়েছি।
FTS5-এর unicode61 tokenizer-এ categories নামের একটি বিকল্প আছে, যা দিয়ে বলে দেওয়া যায় কোন Unicode শ্রেণির অক্ষর শব্দের অংশ। কার-চিহ্ন আর নুকতা যে শ্রেণির (M* — mark), সেটি যোগ করলে বাংলা শব্দ আস্ত থাকে (এই পাতায় চলবে না; Python sqlite3 দিয়ে যাচাই করা):
CREATE VIRTUAL TABLE bn5 USING fts5(t,
tokenize = "unicode61 categories 'L* N* Co M*'");
INSERT INTO bn5 (rowid, t) VALUES (1, 'লাল শাড়ি'), (2, 'শাড়ির দাম কত'), (3, 'নীল শাড়ি।');
SELECT rowid FROM bn5 WHERE bn5 MATCH 'শাড়ি'; -- 1, 3 (দাঁড়ি আলাদা হলো)
SELECT rowid FROM bn5 WHERE bn5 MATCH 'শাড়ি*'; -- 1, 2, 3 (উপসর্গ খোঁজ)
-- token তালিকা: কত, দাম, নীল, লাল, শাড়ি, শাড়ির৩. বিভক্তি আর stemming. বাংলায় একই শব্দের বহু রূপ: শাড়ি, শাড়ির, শাড়িতে, শাড়িগুলো, শাড়িটা। ইংরেজির Porter stemmer বাংলায় কিছুই করে না, আর বেশিরভাগ ডেটাবেস ইঞ্জিনে বাংলার তৈরি stemmer থাকে না। বাস্তবে যে কৌশলগুলো ব্যবহার হয়: উপসর্গ খোঁজ (শাড়ি*), যা শব্দের শেষে যুক্ত বিভক্তি ধরে ফেলে; trigram বা n-gram index — শব্দকে তিন অক্ষরের টুকরোয় ভেঙে আংশিক মিল (SQLite FTS5-এ trigram tokenizer, PostgreSQL-এ pg_trgm extension); আর বড় সার্চ engine-এর ভাষাভিত্তিক analyzer — Lucene-ভিত্তিক কিছু engine-এর ডকুমেন্টেশনে বাংলার analyzer-এর উল্লেখ পাওয়া যায়, তবে ব্যবহারের আগে নিজের ডেটায় মান যাচাই করুন।
বাংলাদেশের বাস্তবতায় আরও দুটি বিষয়: অনেক পুরোনো নথি Bijoy-এর মতো ASCII-ভিত্তিক font-এ লেখা — দেখতে বাংলা, ভেতরে ইংরেজি অক্ষর — যা আগে Unicode-এ রূপান্তর না করলে কোনো বাংলা সার্চ কাজ করবে না। আর ক্রেতারা প্রায়ই রোমান হরফে লেখেন (“shari”, “saree”), তাই romanised কলাম বা প্রতিশব্দের তালিকা রাখা প্রচলিত কৌশল — আসল সার্চ লগ দেখে ঠিক করুন।
৯ · অন্যান্য বিশেষ টাইপ — array, enum, UUID, range আর geospatial
JSON আর full-text ছাড়াও আধুনিক DBMS-এ কিছু বিশেষ টাইপ আছে, যা নির্দিষ্ট সমস্যা খুব পরিচ্ছন্নভাবে মেটায়। এগুলোর বেশিরভাগ PostgreSQL-এ সবচেয়ে সমৃদ্ধ; SQLite-এ নেই, তাই নিচের কোড চালানো যাবে না।
Array
PostgreSQL-এ যেকোনো টাইপের array কলাম হয়: tags TEXT[]. ছোট তালিকার জন্য সুবিধাজনক — যেমন পণ্যের ট্যাগ। তবে মানগুলো অন্য টেবিলের দিকে নির্দেশ করলে বা তাদের নিজের গুণ থাকলে আলাদা টেবিলই ঠিক (অংশ ৫-এর colors).
ALTER TABLE products ADD COLUMN tags TEXT[] NOT NULL DEFAULT '{}';
UPDATE products SET tags = ARRAY['eid', 'cotton'] WHERE product_id = 106;
SELECT name FROM products WHERE 'eid' = ANY (tags); -- কোনো একটি মান
SELECT name FROM products WHERE tags @> ARRAY['eid']; -- containment, GIN নিতে পারে
CREATE INDEX idx_products_tags ON products USING GIN (tags);Enum
Enum হলো নির্দিষ্ট কয়েকটি মানের তালিকা। db-shop-এ orders.status-এর জন্য আমরা CHECK (status IN ('pending', …)) ব্যবহার করেছি — SQLite-এ এটিই পথ। PostgreSQL-এ CREATE TYPE … AS ENUM আর MySQL-এ কলামের টাইপ ENUM(…). নতুন মান যোগ সহজ, কিন্তু মান মোছা কঠিন; মানগুলো প্রায়ই বদলালে বা তাদের নিজস্ব তথ্য (বাংলা নাম) থাকলে lookup টেবিল আর foreign key ভালো।
CREATE TYPE order_status AS ENUM ('pending', 'shipped', 'delivered', 'cancelled');
ALTER TABLE orders ALTER COLUMN status TYPE order_status USING status::order_status;
ALTER TYPE order_status ADD VALUE 'returned' AFTER 'delivered';
SELECT order_id FROM orders WHERE status > 'shipped'; -- ক্রম ঘোষণার ক্রম অনুযায়ীUUID
UUID একটি ১২৮-বিটের আইডি, যা কোনো কেন্দ্রীয় কাউন্টার ছাড়াই কার্যত অনন্য — মোবাইল অ্যাপ অফলাইনে অর্ডার বানিয়ে পরে পাঠালেও আইডি মিলে যাওয়ার ভয় নেই। অসুবিধা: আকারে বড় (১৬ byte), আর এলোমেলো version 4 B-tree-তে এলোমেলো জায়গায় ঢোকে; সময়-ক্রমিক version 7 (RFC 9562, ২০২৪) এর সমাধান — বিস্তারিত ভাবনার প্রশ্ন ৫-এ।
CREATE TABLE app_orders (
order_uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- v4, PostgreSQL 13+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- PostgreSQL 18 থেকে সময়-ক্রমিক: DEFAULT uuidv7()
-- MySQL: UUID() দেয় version 1; জায়গা বাঁচাতে UUID_TO_BIN(UUID(), 1) দিয়ে BINARY(16)
-- SQLite: নিজস্ব টাইপ নেই — TEXT বা 16-byte BLOB হিসেবে রাখুনRange আর exclusion constraint
অতিথিশালার রুম বুকিংয়ের কথা ভাবুন। একই রুম একই রাতে দুজনকে দেওয়া চলবে না। শুরু আর শেষের তারিখ দুটি কলামে রাখলে “ওভারল্যাপ নেই” নিয়মটি সাধারণ UNIQUE বা CHECK দিয়ে বলা যায় না; অ্যাপে যাচাই করলে দুজন একই মুহূর্তে বুক করলে দুটিই ঢুকে যেতে পারে (race condition, মডিউল ৭)। PostgreSQL-এর range type (daterange, tstzrange, int4range) একটি সময়সীমাকে একটি মান বানায়, আর exclusion constraint বলে: “একই রুমের দুটি সারির সময়সীমা পরস্পর ওভারল্যাপ (&&) করতে পারবে না” — DBMS নিজে রক্ষা করে, যত অনুরোধই একসাথে আসুক।
CREATE EXTENSION IF NOT EXISTS btree_gist; -- INT-এর = কে GiST-এ আনতে
CREATE TABLE room_bookings (
room_id INT NOT NULL,
guest TEXT NOT NULL,
stay DATERANGE NOT NULL, -- '[2025-12-20,2025-12-23)'
EXCLUDE USING gist (room_id WITH =, stay WITH &&)
);
INSERT INTO room_bookings VALUES (7, 'Rahim', '[2025-12-20,2025-12-23)');
INSERT INTO room_bookings VALUES (7, 'Karima', '[2025-12-22,2025-12-24)');
-- ERROR: conflicting key value violates exclusion constraint ...
INSERT INTO room_bookings VALUES (7, 'Karima', '[2025-12-23,2025-12-24)'); -- ঠিক আছে[ ) মানে শুরুর দিন অন্তর্ভুক্ত, শেষের দিন নয় — পর্ব ১৬-এর half-open interval. তাই ২৩ তারিখে চেক-আউট আর একই দিনে চেক-ইন সংঘর্ষ নয়।
Geospatial
ডেলিভারি অ্যাপের প্রশ্ন: “গুদাম থেকে ৩ কিলোমিটারের মধ্যে কোন কোন অর্ডার?” অক্ষাংশ-দ্রাঘিমাংশ দুটি REAL কলামে রেখে নিজে দূরত্বের সূত্র লিখলে ভুল আর ধীর — পৃথিবী গোল, আর সাধারণ B-tree দুই মাত্রার কাছাকাছি খোঁজা বোঝে না। Geospatial extension — PostgreSQL-এ PostGIS, SQLite-এ SpatiaLite, MySQL-এ নিজস্ব spatial টাইপ — বিন্দু, রেখা আর এলাকার টাইপ, দূরত্ব-ফাংশন আর R-tree/GiST-জাতীয় spatial index দেয়।
CREATE EXTENSION IF NOT EXISTS postgis;
ALTER TABLE orders ADD COLUMN drop_point geography(Point, 4326);
-- ST_MakePoint(দ্রাঘিমাংশ, অক্ষাংশ) — ক্রম খেয়াল করুন: আগে x = longitude
UPDATE orders SET drop_point = ST_MakePoint(90.41, 23.81)::geography WHERE order_id = 1001;
CREATE INDEX idx_orders_drop ON orders USING GIST (drop_point);
SELECT order_id FROM orders
WHERE ST_DWithin(drop_point, ST_MakePoint(90.39, 23.75)::geography, 3000); -- মিটারটাকা — DECIMAL-এর পুনরাবৃত্তি
পর্ব ১৩-এ দেখেছেন, টাকার হিসাবে floating-point (REAL, DOUBLE) বিপজ্জনক: 0.1 + 0.2 = 0.3 SQLite-এ 0 (মিথ্যা) দেয়, কারণ 0.1 binary-তে হুবহু রাখা যায় না। db-shop সরলতার জন্য price REAL ব্যবহার করে, কিন্তু আসল দোকানে PostgreSQL/MySQL-এ NUMERIC(12,2) বা DECIMAL, আর SQLite-এ পয়সায় INTEGER (৳১২৫.৫০ = 12550) রাখাই নিরাপদ। PostgreSQL-এর money টাইপ server-এর locale সেটিংয়ের ওপর নির্ভর করে, তাই অনেকে এটি এড়িয়ে চলার পরামর্শ দেন।
আরও গভীরে: PostgreSQL-এর GIN index-এ নতুন সারি ঢোকানো ব্যয়বহুল, কারণ একটি ডকুমেন্ট বহু posting list ছোঁয়; তাই GIN নতুন entry প্রথমে একটি “pending list”-এ জমায় আর পরে একসাথে মূল index-এ মেশায় (fastupdate বিকল্প) — লেখা দ্রুত হয়, কিন্তু pending list বড় হলে খোঁজ সামান্য ধীর হয়। SQLite FTS5-ও একই কারণে index-কে কয়েকটি segment-এ রাখে আর মাঝে মাঝে merge করে — log-structured merge tree-র চিন্তা। SQLite ৩.৪৫ থেকে JSON-কে binary JSONB রূপে রাখা যায় (jsonb() ফাংশন), যাতে প্রতিবার লেখা পার্স করতে না হয়; বাইরে থেকে ফাংশনগুলো একই থাকে।
পরীক্ষা ও ইন্টারভিউতে — এমন প্রশ্ন প্রায়ই আসে:
- PostgreSQL-এ json আর jsonb-এর পার্থক্য? — json লেখা হুবহু রাখে; jsonb পার্স করা binary রূপ — দ্রুত পড়া, GIN index, কিন্তু ঘরের ক্রম, ফাঁকা জায়গা ও ডুপ্লিকেট ঘর রাখে না।
LIKE '%word%'কেন index ব্যবহার করতে পারে না? — শুরুতে wildcard থাকলে B-tree-র সাজানো ক্রমে কোনো শুরুর বিন্দু নেই; তাই পুরো টেবিল বা পুরো index পড়তে হয়।- Inverted index কী? — প্রতিটি শব্দ (term) থেকে সেই শব্দ থাকা ডকুমেন্টের সাজানো তালিকা (posting list); AND খোঁজ মানে তালিকাগুলোর intersection.
- JSON কলাম কখন ব্যবহার করবেন না? — যখন তথ্যটি সবার আছে, নিয়ম (FK, CHECK) লাগে, বা প্রায় প্রতিটি কোয়েরিতে ফিল্টার/JOIN হয় — তখন সাধারণ কলাম বা আলাদা টেবিল।
- Stemming আর stop word কী? — Stemming শব্দকে মূল রূপে নামায় (charging → charg); stop word হলো অতি সাধারণ শব্দ (the, is), যা index বা খোঁজ থেকে বাদ দেওয়া হতে পারে।
১০ · AI-এর যুগে JSON আর সার্চ কোয়েরি
AI টুলকে “ফোনের RAM দিয়ে ফিল্টার করো” বা “সার্চ বক্স বানাও” বললে সে প্রায় সবসময় চলার মতো কোড দেয়। কিন্তু এই পর্বের প্রায় প্রতিটি ফাঁদে সে নিয়মিত পা দেয়:
| AI যা করে | আসলে সমস্যা |
|---|---|
সংখ্যার তুলনায় -> ব্যবহার করে | SQLite-এ TEXT বনাম সংখ্যা — চলে, কিন্তু ভুল সারি আসে (অংশ ২-এর Smart Watch) |
নেই এমন JSON ঘরের নাম বানায় ($.ram, $.memory) | error নয়, নীরবে NULL — ফাঁকা ফলকে “ডেটা নেই” ভেবে বসবেন |
PostgreSQL-এর সিনট্যাক্স (@>, ::int) SQLite-এ বা উল্টোটা লেখে | সিস্টেম না বললে AI প্রায়ই মিশিয়ে ফেলে |
সার্চের জন্য LIKE '%…%' দেয়, index-এর কথা তোলে না | ছোট ডেটায় চলে, বড় ডেটায় প্রতিটি সার্চে full scan; শব্দের সীমাও ভুল |
| expression index বানায়, কিন্তু কোয়েরিতে অন্য রূপের expression লেখে | index থাকা সত্ত্বেও SCAN — EXPLAIN না দেখলে টের পাবেন না |
| সব নতুন গুণ JSON-এ ঢোকাতে বলে | FK আর CHECK হারিয়ে যায়; JSON ডিজাইনের বিকল্প হয়ে দাঁড়ায় |
প্রশ্ন করার সময় সিস্টেম, সংস্করণ, আসল ডকুমেন্টের নমুনা আর যাচাইয়ের দাবি একসাথে দিন:
AI-এর লেখা JSON বা সার্চ কোয়েরি হাতে পেলে — যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১PostgreSQL কেন দুটি JSON টাইপ রাখে — json আর jsonb? এক টাইপে কাজ চলত না?
ইতিহাসটাই উত্তর দেয়। PostgreSQL ৯.২ (২০১২) প্রথম json টাইপ আনে — আসলে একটি text কলাম, যা শুধু লেখার সময় বৈধতা যাচাই করে। প্রতিটি কোয়েরিতে ভেতরের মান বের করতে লেখাটি নতুন করে পার্স করতে হতো, আর ভেতরের ঘর ধরে index করার ভালো উপায় ছিল না। তখন MongoDB দ্রুত জনপ্রিয় হচ্ছে, আর অনেক দল শুধু JSON রাখার সুবিধার জন্য relational ডেটাবেস ছাড়ছিল।
৯.৪ (২০১৪)-এ এল jsonb: লেখাটি একবার পার্স করে একটি binary, ভেঙে রাখা রূপে জমা হয়। ফলে পড়া দ্রুত, আর GIN index দিয়ে @> বা ?-এর মতো খোঁজ index-এর সাহায্যে চলে। দাম হলো, jsonb মূল লেখার কিছু তথ্য ফেলে দেয় — ফাঁকা জায়গা, ঘরের ক্রম, আর একই নামের ঘর দুবার থাকলে শুধু শেষটি রাখে। লেখার সময় পার্সের খরচও সামান্য বেশি।
তাহলে json টাইপ রাখা কেন? কারণ কিছু কাজে মূল লেখাটি হুবহু রাখাই জরুরি — যেমন বাইরের কোনো API-র উত্তর প্রমাণ হিসেবে জমা রাখা, বা ডিজিটাল স্বাক্ষর করা ডকুমেন্ট, যেখানে একটি ফাঁকা জায়গা বদলালেও স্বাক্ষর মিলবে না। আর পুরোনো অ্যাপের সামঞ্জস্য (backward compatibility) ভাঙতে PostgreSQL দল সবসময় খুব সতর্ক।
অন্য সিস্টেমগুলো ভিন্ন পথ নিয়েছে: MySQL-এর JSON টাইপ শুরু থেকেই binary; SQLite বছরের পর বছর text-এ রেখে ৩.৪৫ (২০২৪)-এ ঐচ্ছিক JSONB যোগ করেছে, তবে সেটিও একটি BLOB, আলাদা কলাম-টাইপ নয়। SQL:2023 মানদণ্ড অবশেষে একটি JSON টাইপ সংজ্ঞায়িত করেছে।
মূল উপলব্ধি: একই তথ্যের দুই রূপ — “হুবহু” আর “দ্রুত খোঁজার উপযোগী” — প্রায়ই পরস্পরবিরোধী। ডেটাবেস ডিজাইনে কোন গুণটি আপনার কাজে বেশি দামি, তা আগে ঠিক করুন।
প্র ০২“PostgreSQL-এ jsonb আছে, তাহলে MongoDB-র দরকার কী?” — এই যুক্তি কতটা ঠিক?
যুক্তিটির অর্ধেক ঠিক। jsonb আসার পর অনেক প্রকল্প দেখেছে, তাদের যা দরকার ছিল — নমনীয় গুণ, ভেতরের ঘর ধরে খোঁজা, index — তা relational ডেটাবেসের ভেতরেই পাওয়া যায়, আর সাথে থাকে JOIN, foreign key, শক্ত transaction আর পরিচিত SQL. এক ডেটাবেসে দুই রকম ডেটা মানে একটি backup, একটি monitoring, একটি দক্ষতা — polyglot persistence-এর খরচ ছাড়া (পর্ব ৪)। এই পর্বের db-shop-এর মতো দোকানে পণ্যের বৈচিত্র্যময় গুণের জন্য আলাদা document ডেটাবেস আনা প্রায়ই বাড়াবাড়ি।
কিন্তু document ডেটাবেসের নিজস্ব শক্তিও আছে। MongoDB-র মতো সিস্টেম শুরু থেকেই ডকুমেন্টকে প্রধান একক ধরে নকশা করা: ডকুমেন্টের ভেতরের ঘর বদলানোর অপারেটর, ডকুমেন্টভিত্তিক aggregation pipeline, আর বহু সার্ভারে ডেটা ছড়ানো (sharding) প্রথম দিন থেকেই মূল বৈশিষ্ট্য। যে অ্যাপের প্রায় সব ডেটাই গভীর, পরিবর্তনশীল ডকুমেন্ট, আর যার প্রধান প্রশ্ন “এই আইডির পুরো ডকুমেন্ট দাও”, সেখানে document মডেল স্বাভাবিক। উল্টো দিকে MongoDB নিজেও ২০১৮ সালে multi-document transaction যোগ করেছে — অর্থাৎ দুই ধারা পরস্পরের দিকে এগিয়েছে।
বাস্তব সিদ্ধান্তের জন্য প্রশ্নগুলো: ডেটার কতটা অংশ সত্যিই কাঠামোহীন? টাকা বা স্টকের মতো একাধিক সারি জুড়ে কঠোর নিয়ম কতটা? দলের কাদের SQL জানা? আর মাপ কি একটি সার্ভার ছাড়িয়ে যাবে? প্রথম প্রশ্নের উত্তর “সামান্য” আর দ্বিতীয়টির “অনেক” হলে relational + JSON কলাম প্রায় নিশ্চিত ভালো বাছাই। MongoDB নিয়ে হাতে-কলমে কাজ পর্ব ৫৭-এ, আর দুই মডেলের তুলনা পর্ব ৫৬-এ।
মূল উপলব্ধি: প্রশ্নটা “কোন পণ্য সেরা” নয়, “আমার ডেটার কতটা কাঠামোবদ্ধ, আর কোন নিশ্চয়তা আমার লাগবে”। JSON কলাম relational ডেটাবেসকে document-এর কাজে নিয়ে যায়, কিন্তু document ডেটাবেস হয়ে যায় না।
প্র ০৩অনেক ই-কমার্স প্ল্যাটফর্ম বহু বছর EAV টেবিল ব্যবহার করেছে। JSON কলাম আসার পর EAV কি অচল?
EAV-র ধারণা পুরোনো। চিকিৎসা-তথ্যের ব্যবস্থায় এটি অনেক দিন ধরে পরিচিত — একজন রোগীর সম্ভাব্য পরীক্ষা হাজার হাজার, কিন্তু প্রত্যেকের হয় অল্প কয়েকটি; প্রতিটি পরীক্ষার জন্য কলাম রাখা অসম্ভব, তাই (রোগী, পরীক্ষা, মান) সারি। ই-কমার্সেও একই যুক্তি: কিছু পরিচিত open-source দোকান-সফটওয়্যার পণ্যের গুণ EAV ধাঁচে রাখে বলে জানা যায়, যাতে দোকানের মালিক প্রোগ্রামার ছাড়াই নতুন গুণ যোগ করতে পারেন।
কিন্তু EAV-র দাম চড়া। একটি পণ্যের দশটি গুণ আনতে দশটি সারি, আর এক সারিতে দেখাতে দশবার self-join বা pivot (পর্ব ২৭)। সব মান একই কলামে, তাই টাইপ হারায় — “8” আর “eight” দুটিই বৈধ। অনেক EAV নকশা তাই প্রতিটি টাইপের জন্য আলাদা মান-টেবিল রাখে (int, decimal, text, date), ফলে কোয়েরি আরও জটিল হয়। ডেটাবেস সাহিত্যে EAV-কে প্রায়ই একটি anti-pattern বলা হয় — নিষিদ্ধ নয়, তবে দাম জেনে ব্যবহার করার জিনিস।
JSON কলাম অনেক ক্ষেত্রে EAV-র চেয়ে সহজ: একটি পণ্যের সব গুণ এক সারিতে, JSON-এর নিজস্ব টাইপ (সংখ্যা, string, boolean, array) টিকে থাকে, আর পুরো ডকুমেন্ট এক পড়াতেই আসে। তবু EAV-র একটি সুবিধা থেকে যায়: গুণের নাম নিজেই ডেটা, তাই attributes নামের একটি মেটা-টেবিলে প্রতিটি গুণের বাংলা নাম, একক (GB, মিটার), কোন ক্যাটাগরিতে প্রযোজ্য — এসব রাখা আর foreign key দিয়ে বেঁধে দেওয়া যায়। JSON-এ “কোন ঘর বৈধ” তা বলার স্বাভাবিক জায়গা নেই।
তাই বাস্তবে মিশ্র নকশাও দেখা যায়: গুণের সংজ্ঞা (নাম, টাইপ, একক) সাধারণ টেবিলে, আর প্রতিটি পণ্যের মান JSON-এ।
মূল উপলব্ধি: কোনো নকশা “অচল” হয় না; প্রতিটি একটি নির্দিষ্ট দুর্বলতার জবাব। EAV নমনীয়তা দেয় কিন্তু কোয়েরি কঠিন করে; JSON কোয়েরি সহজ করে কিন্তু স্কিমার দেখভাল আপনার ঘাড়ে চাপায়।
প্র ০৪Full-text search-এর জন্য Elasticsearch-এর মতো আলাদা engine এত জনপ্রিয় কেন, যখন PostgreSQL আর SQLite-এ নিজস্ব FTS আছে?
Inverted index-এর ধারণা কম্পিউটারের চেয়েও পুরোনো — বাইবেল বা কোরআনের শব্দ-নির্ঘণ্ট (concordance) মানুষ হাতে বানাতেন, কোন শব্দ কোন আয়াতে। কম্পিউটারে তথ্য-অনুসন্ধান (information retrieval) একটি আলাদা গবেষণা-ক্ষেত্র হয়ে ওঠে ১৯৬০-৭০-এর দশকে — Gerard Salton-এর SMART প্রকল্পের vector space মডেল আর TF-IDF এই সময়ের। BM25 সূত্রটি এসেছে ১৯৯০-এর দশকে Stephen Robertson ও সহকর্মীদের Okapi প্রকল্প থেকে।
১৯৯৯ সালে Doug Cutting লেখেন Lucene — Java-য় একটি open-source search library. পরে এর ওপর তৈরি হয় Solr আর ২০১০ সালে Elasticsearch, যা Lucene-কে বহু সার্ভারে ছড়ানো, JSON API-সহ একটি সার্ভিস বানায়। তাদের পুরো নকশা শুধু খোঁজার জন্য: বহু ভাষার analyzer, বানান-ভুল সহনশীল (fuzzy) খোঁজ, facet (ফলের ভেতরে ক্যাটাগরি অনুযায়ী গোনা), প্রাসঙ্গিকতার জটিল নিয়ন্ত্রণ, আর বিপুল মাপের log বিশ্লেষণ।
DBMS-এর FTS-এর সবচেয়ে বড় সুবিধা consistency: রিভিউ লেখা আর তার index হালনাগাদ একই transaction-এ। আলাদা engine হলে ডেটা দুই জায়গায় — ডেটাবেসে সত্যিকারের কপি, search engine-এ একটি প্রতিলিপি, যা কিছুক্ষণ পিছিয়ে থাকতে পারে। মুছে ফেলা পণ্য সার্চে দেখা যাওয়া, বা নতুন পণ্য কয়েক সেকেন্ড না দেখানো — এগুলো তখন স্বাভাবিক সমস্যা, আর দুইটিকে মেলানোর জন্য queue বা change-data-capture-এর মতো ব্যবস্থা লাগে।
তাই প্রচলিত পরামর্শ: ছোট থেকে মাঝারি মাপে, এক-দুই ভাষায় DBMS-এর FTS দিয়ে শুরু করুন; যখন সার্চই পণ্যের কেন্দ্রীয় অভিজ্ঞতা, বা ভাষা ও মাপের চাহিদা DBMS-এর সামর্থ্য ছাড়ায়, তখন আলাদা engine — আর তখনো সত্যিকারের ডেটা থাকবে relational ডেটাবেসেই। Search ডেটাবেস নিয়ে আরও কথা পর্ব ৫৯-এ।
মূল উপলব্ধি: বিশেষায়িত টুল বেশি ক্ষমতা দেয়, কিন্তু প্রতিটি নতুন কপি একটি নতুন consistency সমস্যা। “Source of truth” কোথায়, তা সবসময় স্পষ্ট রাখুন।
প্র ০৫Primary key হিসেবে auto-increment সংখ্যা নাকি UUID? বড় সিস্টেমে কেন UUID version 7-এর কথা এত শোনা যায়?
Auto-increment সংখ্যা (১, ২, ৩ …) ছোট (৪ বা ৮ byte), পড়তে সহজ, আর প্রতিটি নতুন সারি B-tree index-এর একেবারে ডান প্রান্তে বসে — ডিস্কের শেষ page-টি প্রায় সবসময় memory-তে, তাই লেখা দ্রুত। সমস্যা দুটি। এক, সংখ্যাটি দেয় একটি কেন্দ্রীয় কাউন্টার; বহু সার্ভার বা অফলাইন মোবাইল অ্যাপ নিজে নিজে আইডি বানাতে পারে না। দুই, আইডি অনুমানযোগ্য — /invoice/1005 দেখে কেউ 1006 চেষ্টা করতে পারে, আর মোট অর্ডারের সংখ্যাও আন্দাজ করা যায়। (দ্বিতীয়টির আসল সমাধান অবশ্য অনুমতি যাচাই — শুধু আইডি লুকানো নিরাপত্তা নয়।)
UUID (RFC 4122, ২০০৫) এই দুটি মেটায়: ১২৮ বিট, যেকোনো জায়গায় বানানো যায়, মিলে যাওয়ার সম্ভাবনা কার্যত শূন্য। কিন্তু সবচেয়ে প্রচলিত version 4 পুরো এলোমেলো। প্রতিটি নতুন সারি B-tree-র কোনো এলোমেলো page-এ ঢোকে — বড় টেবিলে সেই page প্রায়ই memory-তে থাকে না, ডিস্ক থেকে আনতে হয়, আর page ঘন ঘন ভাগ হয় (পর্ব ৪০-এ B+ tree split দেখবেন)। লেখার গতি কমে, index ফুলে ওঠে।
এর জবাব সময়-ক্রমিক আইডি। Twitter-এর Snowflake আইডি, ULID-এর মতো নানা উদ্যোগের পর ২০২৪ সালের মে মাসে RFC 9562 UUID version 7 মানদণ্ড করে: প্রথম ৪৮ বিট মিলিসেকেন্ডে সময়, বাকিটা এলোমেলো। নতুন আইডি মোটামুটি বাড়তি ক্রমে আসে, তাই B-tree-তে আবার ডান প্রান্তে বসে — auto-increment-এর গতি আর UUID-এর স্বাধীনতা একসাথে। PostgreSQL ১৮ (২০২৫) নিজস্ব uuidv7() ফাংশন এনেছে। দাম: আইডি থেকে তৈরির সময় জানা যায়, যা কিছু ক্ষেত্রে গোপন রাখা দরকার হতে পারে।
মূল উপলব্ধি: আইডির নকশা শুধু “অনন্য কি না” নয় — index-এর ভৌত গঠন, বহু সার্ভারে বানানোর সুবিধা আর তথ্য ফাঁসের ঝুঁকি, তিনটিই হিসাবে নিতে হয়। একটি ছোট দোকানের অ্যাপে সাধারণ INTEGER key প্রায়ই সবচেয়ে ভালো।
অনুশীলন
প্রথমটি SQL লেখার কাজ — উপরের product_attributes আছে এমন যেকোনো এডিটরে লিখে চালাতে পারেন। বাকিগুলো হাতে-কলমে ভাবার।
-
কোয়েরি লিখুন: db-shop আর product_attributes থেকে যেসব পণ্যের কাপড় (fabric) silk বা cotton, তাদের নাম, দাম, কাপড় আর রং বের করুন, দাম অনুযায়ী বাড়তি ক্রমে।
SELECT p.name, p.price, a.attrs ->> '$.fabric' AS fabric, a.attrs ->> '$.color' AS color FROM products p JOIN product_attributes a ON a.product_id = p.product_id WHERE a.attrs ->> '$.fabric' IN ('silk', 'cotton') ORDER BY p.price;ফল দুটি সারি: Panjabi │ 2200 │ cotton │ white, তারপর Saree │ 4500 │ silk │ red. এখানে
->>জরুরি:->দিলে মান হতো"silk"(উদ্ধৃতিচিহ্নসহ), আরIN ('silk', 'cotton')কিছুই মেলাত না — শূন্য সারি, কোনো error ছাড়া। এই ফিল্টার প্রায়ই চললেjson_extract(attrs, '$.fabric')-এর ওপর expression index, আর কোয়েরিতেও হুবহু সেই expression লিখুন। -
হাতে হিসাব — ফল আগে বলুন:
d = '{"a":1,"b":[10,20]}'. SQLite-এ নিচের চারটির ফল কী? (ক)json_insert(d, '$.a', 5, '$.c', 3)(খ)json_replace(d, '$.a', 5, '$.c', 3)(গ)json_set(d, '$.b[#]', 30)(ঘ)d -> '$.b[1]' = 20(ক)
{"a":1,"b":[10,20],"c":3}— insert পুরোনো a বদলায় না, শুধু নতুন c যোগ করে। (খ){"a":5,"b":[10,20]}— replace শুধু থাকা ঘর বদলায়; c ছিল না, তাই যোগ হয়নি। (গ){"a":1,"b":[10,20,30]}—[#]মানে array-র দৈর্ঘ্যের সমান সূচি, অর্থাৎ শেষের পরের জায়গা; set সেখানে নতুন মান বসায় — array-তে append করার প্রচলিত কৌশল। (ঘ) 0 (মিথ্যা) —->দেয় TEXT'20', আর SQLite-এ TEXT আর INTEGER কখনো সমান নয়।d ->> '$.b[1]' = 20লিখলে 1. -
Inverted index হাতে বানান: তিনটি ডকুমেন্ট — d1: “red silk saree”, d2: “white cotton panjabi”, d3: “red cotton saree”. ছোট হাতের অক্ষর, ফাঁকা জায়গায় ভাঙা, কোনো stemming নেই। (ক) পুরো index লিখুন। (খ)
cotton AND saree,red OR whiteআরsaree NOT silk-এর ফল কী? (গ) প্রতিটি খোঁজে কয়টি posting list পড়তে হলো?(ক) বর্ণানুক্রমে: cotton → d2, d3; panjabi → d2; red → d1, d3; saree → d1, d3; silk → d1; white → d2. মোট ৬টি শব্দ, ৯টি entry (তিন ডকুমেন্ট × তিন শব্দ)। (খ)
cotton AND saree= [d2, d3] ∩ [d1, d3] = [d3].red OR white= [d1, d3] ∪ [d2] = [d1, d2, d3].saree NOT silk= [d1, d3] − [d1] = [d3]. (গ) প্রতিটিতে দুটি posting list — কোনো ডকুমেন্টের লেখা পড়তে হয়নি। LIKE দিয়েcotton AND sareeকরলে তিনটি ডকুমেন্টের প্রতিটি অক্ষর দুবার করে খুঁজতে হতো। -
ডিজাইনের সিদ্ধান্ত: একটি অনলাইন ফার্মেসির পণ্য-টেবিলে নিচের তথ্যগুলো কোথায় রাখবেন — সাধারণ কলাম, JSON, নাকি আলাদা টেবিল? এক লাইনে কারণ দিন: (ক) মেয়াদোত্তীর্ণের তারিখ, (খ) প্রস্তুতকারক কোম্পানি, (গ) generic নাম (যেমন paracetamol), যা দিয়ে ক্রেতারা সবচেয়ে বেশি খোঁজেন, (ঘ) “খাবারের পরে খাবেন”-এর মতো ব্যবহারবিধি, (ঙ) একটি ওষুধের একাধিক সক্রিয় উপাদান আর প্রতিটির মাত্রা।
(ক) সাধারণ কলাম (DATE) — সব ওষুধের আছে, “মেয়াদ শেষ হচ্ছে এমন স্টক” প্রতিদিনের রিপোর্ট, আর ভুল হলে ক্রেতার স্বাস্থ্যঝুঁকি; NOT NULL ও index দরকার। (খ) কলাম + foreign key — প্রস্তুতকারক আলাদা entity, তার নাম-ঠিকানা এক জায়গায় থাকবে। (গ) কলাম + full-text বা trigram index — খোঁজার প্রধান পথ, বানান-ভুলও সামলাতে হবে (প্যারাসিটামল/paracetamol). (ঘ) JSON বা সাধারণ TEXT — শুধু দেখানোর জন্য, কাঠামো ওষুধভেদে আলাদা। (ঙ) আলাদা টেবিল
ingredients(product_id, substance_id, strength_mg)— একাধিক মান, প্রতিটির নিজের গুণ আছে, আর “এই উপাদান আছে এমন সব ওষুধ” খোঁজা (যেমন অ্যালার্জির জন্য) জরুরি প্রশ্ন; JSON-এ রাখলে অংশ ৫-এর colors-এর সমস্যা আরও গুরুতর রূপে ফিরবে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি full-text engine-এ ডকুমেন্ট জমা থেকে ফল দেখানো পর্যন্ত ধাপগুলো সাজান (▲▼ চেপে সরান)
- লেখাকে token-এ ভাঙা (tokenize)
- Token-গুলো normalize ও stem করা (ছোট হাতের অক্ষর, মূল রূপ)
- প্রতিটি শব্দের posting list-এ ডকুমেন্টের আইডি যোগ করা
- খোঁজের শব্দগুলোর posting list এনে intersection/union করা
- মিলে যাওয়া ডকুমেন্টকে প্রাসঙ্গিকতার স্কোরে (যেমন BM25) সাজানো
SQLite-এ attrs = '{"ram_gb": 1}'. attrs -> '$.ram_gb' >= 4-এর ফল কী?
- 0, কারণ 1 ছোট 4-এর চেয়ে
- 1, কারণ TEXT '1' যেকোনো সংখ্যার চেয়ে বড় ধরা হয়
- NULL
- একটি টাইপ-error
একটি পণ্যের টেবিলে নাম কলামে B-tree index আছে। WHERE name LIKE '%phone%' কেন index দিয়ে দ্রুত খোঁজা যায় না?
- কারণ LIKE সবসময় case-sensitive
- কারণ B-tree শুধু সংখ্যার কলামে কাজ করে
- কারণ শুরুতে wildcard থাকলে সাজানো ক্রমে কোনো শুরুর বিন্দু থাকে না
- কারণ index থাকলে LIKE চলেই না
একটি inverted index-এ battery → [1, 3, 4] আর fast → [2, 4]. “battery AND fast” খোঁজের ফল কী?
- [4]
- [1, 2, 3, 4]
- [1, 3, 4]
- [1, 3]
PostgreSQL-এ jsonb কলামে attrs @> '{"fabric": "silk"}' খোঁজ দ্রুত করতে সাধারণত কোন index ব্যবহার হয়?
- Hash index
- পুরো কলামে সাধারণ B-tree index
- BRIN index
- GIN index
একটি ডেটাবেসে “শাড়ি” শব্দটি কিছু সারিতে “ড়” একটি code point (U+09DC) দিয়ে, কিছুতে “ড” + নুকতা (U+09A1 U+09BC) দিয়ে লেখা। পর্দায় দুটি এক দেখায়, কিন্তু খোঁজে সব সারি আসে না। মূল সমাধান কী?
- একটি বাংলা stemmer যোগ করা
- লেখা আর খোঁজ দুটিকেই একই Unicode normalization রূপে (যেমন NFC) আনা
- সব লেখা ছোট হাতের অক্ষরে রূপান্তর করা
- Stop word তালিকা বড় করা
একটি হোটেলের বুকিং টেবিলে নিশ্চিত করতে হবে যে একই রুমের দুটি বুকিংয়ের তারিখ কখনো ওভারল্যাপ করবে না, যত অনুরোধই একসাথে আসুক। PostgreSQL-এ সবচেয়ে নির্ভরযোগ্য উপায় কোনটি?
- (room_id, start_date)-এ UNIQUE constraint
- INSERT-এর আগে অ্যাপ থেকে SELECT করে যাচাই
- daterange কলাম আর exclusion constraint
- start_date < end_date-এর CHECK constraint
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- JSON কলাম ব্যতিক্রমের জায়গা: পণ্যভেদে আলাদা, মূলত দেখানোর তথ্য। সবার তথ্য, নিয়ম (FK, CHECK) আর প্রতিদিনের ফিল্টার — সাধারণ কলাম বা আলাদা টেবিল.
- SQLite-এ
->= JSON লেখা,->>= SQL মান; তুলনা-হিসাবে->>. না-থাকা ঘর নীরবে NULL. - json_each/json_tree JSON-কে সারিতে মেলে দেয়; json_set/insert/replace/remove বদলায়; json_group_array/object টেবিল থেকে JSON বানায়; json_valid CHECK-এ।
- JSON-এ দ্রুত খোঁজ: expression index (কোয়েরিতে হুবহু একই expression) বা generated column + index; PostgreSQL jsonb-তে GIN.
- LIKE '%x%' = full scan, শব্দের সীমা ও ranking নেই। Inverted index = শব্দ → posting list; AND = intersection. বাংলায় Unicode normalization আর tokenizer যাচাই আবশ্যক।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩১ · SQL-এর উপভাষা — MySQL, PostgreSQL, SQLite, SQL Server আর Oracleপরবর্তী পর্বএকই কাজ, পাঁচ সিস্টেমে পাঁচ রকম সিনট্যাক্স।
- ডেটাবেস (DBMS) ও SQL · কোর্স হোম৬৭টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- Python · CSV আর JSONপাশের ট্র্যাকJSON ডকুমেন্ট প্রোগ্রাম থেকে পড়া ও লেখা।
- DSA · Trieপাশের ট্র্যাকউপসর্গ দিয়ে শব্দ খোঁজার ডেটা স্ট্রাকচার — prefix query-র পেছনের চিন্তা।
- DSA · B-treeগভীরে যানExpression index আর শব্দ-তালিকা যে গাছে থাকে।