সরাসরি পর্বে যান
পর্ব ৫৯ · ৬৭-এর মধ্যে · মডিউল ৯

Graph, search আর time-series ডেটাবেস

Graph, Search & Time-Series Databases

Taught in Bangla, this lesson covers three specialised database families: property graphs, index-free adjacency and Cypher with variable-length paths and shortestPath, graphs in SQLite with an edges table and recursive CTEs, search engines with Lucene, analyzers, BM25, shards and replicas, live full-text search with SQLite FTS4, and time-series data with compression, downsampling, retention, bucketing, moving averages and gap detection.

এই পর্বে যা শিখবেন

  • Property graph মডেল, index-free adjacency আর Cypher-এর MATCH, variable-length path ও shortestPath; RDF/SPARQL আর নতুন GQL ও SQL/PGQ মানদণ্ড
  • SQLite-এ edges টেবিল আর recursive CTE দিয়ে বন্ধুর বন্ধু, সবচেয়ে কম hop আর fraud ring খোঁজা — আর depth বাড়লে খরচ কীভাবে লাফিয়ে বাড়ে
  • Elasticsearch/OpenSearch-এর ভেতর: Lucene, analyzer, BM25, shard, replica আর near-real-time; মূল ডেটাবেস থেকে index-কে CDC বা outbox দিয়ে হালনাগাদ রাখা
  • Time-series ডেটার আকার, compression, retention, downsampling আর continuous aggregate; InfluxDB, TimescaleDB আর Prometheus
  • কোন সমস্যায় কোন পরিবার — আর কখন সাধারণ SQL-ই যথেষ্ট

১ · এক ডেটাবেস সব কাজে নয় — তিনটি বিশেষ পরিবার

পর্ব ৫৬-এ NoSQL-এর চারটি বড় পরিবার দেখেছেন। Relational ডেটাবেস যেন হাসপাতালের সাধারণ চিকিৎসক — বেশিরভাগ কাজ সে-ই সবচেয়ে ভালো করে; কিন্তু তিন ধরনের প্রশ্নে বিশেষজ্ঞ লাগে:

Graph

প্রশ্নটা সম্পর্কের শিকল নিয়ে: “এই অ্যাকাউন্টের সাথে তিন ধাপের মধ্যে কারা যুক্ত?” ধাপের সংখ্যা আগে থেকে জানা নেই, তাই কয়টি JOIN লাগবে তাও জানা নেই।

Neo4j, Neptune

Search

প্রশ্নটা লেখার অর্থ ও প্রাসঙ্গিকতা নিয়ে: “fast charging phone” — কোন পণ্য সবচেয়ে মানানসই, বানান একটু ভুল হলেও? উত্তর হ্যাঁ-না নয়, একটি ক্রম।

Elasticsearch, OpenSearch

Time-series

প্রশ্নটা সময়ের ধারা নিয়ে: “গত ২৪ ঘণ্টায় প্রতি ঘণ্টার গড় বায়ুদূষণ?” প্রতি সেকেন্ডে হাজারো নতুন মাপ, পুরোনো সারি প্রায় কখনো বদলায় না।

InfluxDB, TimescaleDB, Prometheus

প্রতিটি পরিবারের জন্য এই পর্বে একই পাঁচটি প্রশ্নের উত্তর খুঁজবেন: কোন সমস্যা মেটায়, ডেটা মডেল কী, কোয়েরির ভাষা কেমন, SQLite দিয়ে কাছাকাছি কী করা যায় (আর কোথায় থামতে হয়), আর কখন সত্যিই বিশেষ সিস্টেম আনবেন। বাস্তবের বড় অ্যাপ প্রায়ই কয়েকটি একসাথে ব্যবহার করে — একে বলে polyglot persistence. একটি ই-কমার্স সাইটে অর্ডার আর টাকা থাকে relational ডেটাবেসে, পণ্য খোঁজা চলে search engine-এ, সার্ভারের CPU ও latency মাপা হয় time-series সিস্টেমে, আর “যারা এটা কিনেছেন তারা আরও কিনেছেন” আসতে পারে graph থেকে।

মূল ধারণা

বিশেষ ডেটাবেস নতুন কোনো জাদু নয় — প্রত্যেকে একটি নির্দিষ্ট প্রবেশ-পথ (access pattern)-কে প্রধান ধরে storage আর index সাজায়। Graph সাজায় “পাশের node”-এর জন্য, search সাজায় “শব্দ থেকে ডকুমেন্ট”-এর জন্য, time-series সাজায় “সময়ের পরিসর”-এর জন্য। যে প্রশ্ন সেই পথে পড়ে না, সেখানে তারা relational ডেটাবেসের চেয়ে দুর্বল।

২ · Graph ডেটাবেস — property graph আর index-free adjacency

Facebook-এর “People you may know”, ব্যাংকের জালিয়াতি ধরার দল, কিংবা Google Maps-এর পথ — সবখানে ডেটা আসলে একটি graph: কিছু বিন্দু আর তাদের সংযোগ। Relational ডেটাবেসে আমরা সংযোগ রাখি foreign key বা junction টেবিলে, আর প্রতিটি ধাপ পেরোতে একটি JOIN লিখি। ধাপ যদি দুটি হয়, দুটি JOIN; কিন্তু “যত ধাপ লাগে” হলে কয়টি? এই প্রশ্নের জন্যই graph database.

আজকের সবচেয়ে প্রচলিত মডেল property graph. এর চারটি উপাদান:

  • Node — একটি বস্তু: একজন মানুষ, একটি অ্যাকাউন্ট, একটি শহর।
  • Label — node-এর ধরন: :Person, :Account, :Device. একটি node-এর একাধিক label থাকতে পারে।
  • Relationship — দুই node-এর মধ্যে নাম দেওয়া, দিকওয়ালা সংযোগ: (Rahim)-[:FRIEND]->(Karima). ER মডেলের relationship-এর মতো, তবে এখানে প্রতিটি সংযোগ নিজেই একটি সংরক্ষিত বস্তু।
  • Property — node বা relationship-এর গায়ে key-value: name: 'Rahim', since: 2019.

পাড়ার মুরুব্বির মাথায় থাকা মানচিত্রটা ভাবুন। তিনি “রহিম” শুনলেই জানেন রহিমের বন্ধু কারা, তাদের বন্ধু কারা — খাতা খুলে নাম খুঁজতে হয় না, একজন থেকে পরের জনে আঙুল ধরে চলে যান। Relational ডেটাবেস যেন রেজিস্টার খাতা: প্রতিবার “রহিমের বন্ধু কারা” জানতে খাতার সূচিতে (index) গিয়ে খুঁজতে হয়। দুটিই উত্তর দেয়; পার্থক্য প্রতিটি ধাপের খরচে।

এই “আঙুল ধরে চলা”-র কারিগরি নাম index-free adjacency. Neo4j-এর মতো native graph ডেটাবেসে প্রতিটি node-এর record সরাসরি তার relationship-গুলোর ঠিকানা (pointer) রাখে, আর প্রতিটি relationship রাখে দুই প্রান্তের node-এর ঠিকানা। ফলে একটি node থেকে পাশের node-এ যেতে কোনো index খুঁজতে হয় না — খরচ প্রতি relationship-এ মোটামুটি ধ্রুবক, O(1). Relational ডেটাবেসে একই ধাপ মানে friend(src)-এর B+ tree index-এ একটি খোঁজ: গাছের উচ্চতা h হলে প্রায় h-টি page পড়া, অর্থাৎ O(log E), যেখানে E = মোট সংযোগ।

খরচটা সৎভাবে মাপা দরকার। গাছের উচ্চতা সাধারণত ৩–৪, আর ওপরের page-গুলো buffer pool-এ থাকে (পর্ব ৩৯), তাই একটি ধাপে পার্থক্য সামান্য। পার্থক্য বড় হয় গভীর, বহু ধাপের traversal-এ: প্রতিটি node-এর গড়ে d জন প্রতিবেশী হলে k ধাপে পথের সংখ্যা প্রায় dk, আর প্রতিটি পথে log-গুণক খরচ জমতে থাকে। তবে “graph ডেটাবেস সবসময় দ্রুত” — এমন দাবি ঠিক নয়: “সব অ্যাকাউন্টের মোট লেনদেন” ধরনের বিশাল aggregate-এ relational বা column store প্রায়ই ভালো।

Graph-এর আরেকটি পুরোনো ধারা RDF (Resource Description Framework) — W3C-র মানদণ্ড, যেখানে সব তথ্য তিন অংশের বাক্য বা triple: subject – predicate – object, যেমন “Rahim — friend — Karima”. প্রতিটি জিনিসের নাম একটি URI, তাই বিভিন্ন প্রতিষ্ঠানের ডেটা জোড়া লাগানো সহজ। এর কোয়েরি ভাষা SPARQL; Property graph অ্যাপ বানানোর জন্য বেশি জনপ্রিয়, আর RDF জ্ঞান-আদান-প্রদান ও ontology-র জন্য।

PREFIX ex: <http://example.org/>
# triple:  ex:rahim  ex:friend  ex:karima .
SELECT DISTINCT ?name WHERE {
  ex:rahim ex:friend/ex:friend ?p .     # দুই ধাপের property path
  ?p ex:name ?name .
  FILTER (?p != ex:rahim)
}

৩ · Cypher — প্যাটার্ন এঁকে প্রশ্ন, variable-length path আর shortestPath

Neo4j সবচেয়ে পরিচিত graph ডেটাবেস, আর তার ভাষা Cypher. Cypher-এর মূল চিন্তা: প্রশ্নটা ছবির মতো আঁকা। গোল বন্ধনী ( ) = node, তীর -[ ]-> = relationship. আপনি একটি প্যাটার্ন আঁকেন, আর ডেটাবেস graph-এ সেই আকারের সব টুকরো খুঁজে আনে। নিচের উদাহরণগুলো এই পাতায় চলবে না — শুধু পড়ার জন্য; graph-টি পরের অংশের SQL এডিটরের মতোই (৮ জন মানুষ, ৯টি বন্ধুত্ব)।

// node, label, property আর relationship বানানো
CREATE (r:Person {name: 'Rahim', city: 'Dhaka'}),
       (k:Person {name: 'Karima', city: 'Chattogram'}),
       (r)-[:FRIEND {since: 2019}]->(k);

// বন্ধুর বন্ধু, যিনি এখনো সরাসরি বন্ধু নন — "আপনি হয়তো চেনেন"
MATCH (me:Person {name: 'Rahim'})-[:FRIEND]-(f)-[:FRIEND]-(fof)
WHERE fof <> me AND NOT EXISTS { (me)-[:FRIEND]-(fof) }
RETURN fof.name AS suggestion, count(*) AS mutual
ORDER BY mutual DESC;
// suggestion: Nusrat (mutual 2), Tanvir (mutual 1)

লক্ষ করুন, -[:FRIEND]--এ তীরের মাথা নেই — মানে দিক যা-ই হোক। বন্ধুত্ব দুই দিকের, তাই ডেটায় একবার -> রাখলেই চলে, কোয়েরিতে দিক উপেক্ষা করা যায়। এবার আসল শক্তি: variable-length path. [:FRIEND*1..3] মানে “১ থেকে ৩টি FRIEND সম্পর্কের শিকল”। SQL-এ এর জন্য recursive CTE লাগে (পর্ব ২৫), Cypher-এ শুধু একটি তারকাচিহ্ন। আর shortestPath দুই node-এর মধ্যে সবচেয়ে কম ধাপের পথ দেয়:

// Rahim থেকে ৩ ধাপের মধ্যে কারা আছেন
MATCH (me:Person {name: 'Rahim'})-[:FRIEND*1..3]-(x)
WHERE x <> me
RETURN DISTINCT x.name;

// Rahim থেকে Mitu — সবচেয়ে কম ধাপের পথ (৬ ধাপের সীমা)
MATCH p = shortestPath(
  (a:Person {name: 'Rahim'})-[:FRIEND*..6]-(b:Person {name: 'Mitu'}))
RETURN [n IN nodes(p) | n.name] AS route, length(p) AS hops;
// route: ["Rahim","Sabbir","Tanvir","Imran","Mitu"], hops: 4

Variable-length প্যাটার্নটি ভেতরে কীভাবে ছড়ায়, ধাপে ধাপে দেখুন। একটি নিয়ম মনে রাখবেন: একটি MATCH-এর ভেতরে Cypher একই relationship দুবার ব্যবহার করে না (relationship uniqueness), তবে একই node-এ আবার পৌঁছানো নিষেধ নয়।

এ পর্যন্ত পাওয়া পথসবুজ = এই ধাপে নতুন

            
প্রতিটি ধাপের পর
ধাপ
নতুন পথ
মোট পথ
আলাদা মানুষ
  1. Graph আর শুরুর বিন্দু। আটজন মানুষ, নয়টি বন্ধুত্ব। প্যাটার্নের বাঁ দিক (me {name: 'Rahim'}) প্রথমে একটি node খুঁজে বের করে — সাধারণত :Person(name)-এর ওপর একটি index দিয়ে। শুরুর node খুঁজতে graph ডেটাবেসও index ব্যবহার করে; index-free শুধু পরের ধাপগুলো।
  2. ১ hop. Rahim-এর relationship-তালিকা থেকে সরাসরি দুটি পথ: R─K আর R─S. কোনো টেবিল খোঁজা নেই, শুধু pointer ধরে চলা।
  3. ২ hop. প্রতিটি পথের শেষ node থেকে আরও এক ধাপ। K থেকে N; S থেকে N আর T. K থেকে আবার R-এ ফেরা বাদ, কারণ সেটি একই R─K সম্পর্ক দুবার ব্যবহার করত। N দুটি আলাদা পথে এসেছে — পথ আলাদা, node একই।
  4. ৩ hop. পাঁচটি নতুন পথ। R─K─N─S আর R─S─N─K-এ S ও K আবার এসেছে, কিন্তু নতুন সম্পর্ক দিয়ে, তাই বৈধ। মোট পথ ২ → ৫ → ১০ — প্রতিটি স্তরে পথ বাড়ে প্রায় গড় প্রতিবেশী-সংখ্যার গুণে।
  5. RETURN DISTINCT. দশটি পথের শেষ node-এ ডুপ্লিকেট আছে; DISTINCT করলে ছয়জন: K, S, N, T, F, I. Mitu ৪ hop দূরে, তাই *1..3-এ আসেনি। বড় graph-এ এই “আগে সব পথ, পরে DISTINCT” কৌশলই খরচের মূল উৎস।
  6. shortestPath. এখানে কৌশল আলাদা — সব পথ নয়, স্তরে স্তরে breadth-first search, আর প্রতিটি node একবারই দেখা। যে স্তরে Mitu প্রথম আসে, সেখানেই থামা: ৪ hop, পথ R─S─T─I─M. BFS-এর খরচ O(V + E) — পথের সংখ্যার ওপর নয়।

দূরত্ব বা খরচসহ পথ (যেমন কিলোমিটারে সবচেয়ে ছোট রাস্তা) চাইলে hop গোনা যথেষ্ট নয় — Dijkstra-র মতো অ্যালগরিদম লাগে। Neo4j-এ এর জন্য আলাদা Graph Data Science library আছে। Neo4j-এর নতুন সংস্করণগুলোতে GQL-ধাঁচের SHORTEST আর quantified path pattern-এর মতো নতুন সিনট্যাক্সও যোগ হয়েছে — নিজের সংস্করণের ডকুমেন্টেশন দেখে নিন।

মানদণ্ড — GQL আর SQL/PGQ

বহু বছর graph-এর কোনো ISO মানদণ্ড ছিল না: Neo4j-এর Cypher (পরে openCypher নামে উন্মুক্ত), Apache TinkerPop-এর Gremlin, Oracle-এর PGQL — প্রত্যেকের নিজের ভাষা। এখন দুটি মানদণ্ড এসেছে। SQL:2023-এর নতুন অংশ SQL/PGQ সাধারণ টেবিলকেই একটি property graph হিসেবে ঘোষণা করে, আর GRAPH_TABLE-এর ভেতরে Cypher-ধাঁচের MATCH লিখতে দেয়। আর ২০২৪ সালে প্রকাশিত হয় GQL (ISO/IEC 39075) — SQL-এর পর ISO-র দ্বিতীয় ডেটাবেস কোয়েরি ভাষা, যার বড় অংশ Cypher থেকে অনুপ্রাণিত। কোন ডেটাবেস কতটুকু সমর্থন করে, তা দ্রুত বদলাচ্ছে; ব্যবহারের আগে নিজের সিস্টেমের সংস্করণ যাচাই করুন। নিচের সিনট্যাক্সও সিস্টেমভেদে সামান্য আলাদা হতে পারে।

CREATE PROPERTY GRAPH social
  VERTEX TABLES (person KEY (id) LABEL person PROPERTIES (id, name))
  EDGE TABLES (friend KEY (src, dst)
    SOURCE KEY (src) REFERENCES person (id)
    DESTINATION KEY (dst) REFERENCES person (id)
    LABEL friend);

SELECT DISTINCT fof
FROM GRAPH_TABLE (social
  MATCH (a IS person WHERE a.name = 'Rahim')
        -[IS friend]-> (b IS person) -[IS friend]-> (c IS person)
  COLUMNS (c.name AS fof));

৪ · SQLite-এ graph — edges টেবিল, recursive CTE আর তার দাম

Graph রাখার জন্য আলাদা ডেটাবেস না থাকলেও চলে: দুটি টেবিলই যথেষ্ট — node-এর টেবিল আর সংযোগের টেবিল, অর্থাৎ একটি adjacency list. প্রথম তিনটি এডিটরে আছে person (id, name) — আটজন: 1 Rahim, 2 Karima, 3 Sabbir, 4 Nusrat, 5 Tanvir, 6 Farhana, 7 Imran, 8 Mitu — আর friend (src, dst, since), যেখানে প্রতিটি বন্ধুত্ব দুই দিকেই একটি করে সারি (৯টি বন্ধুত্ব, ১৮টি সারি), যাতে “src-এর বন্ধু” খুঁজতে শুধু src কলাম দেখলেই হয়। Primary key (src, dst)-এর index-ই প্রতিটি ধাপের খোঁজ দ্রুত করে।

প্রথম প্রশ্ন দুই ধাপের, তাই recursion লাগে না — একটি self join যথেষ্ট। “Rahim হয়তো চেনেন” — বন্ধুর বন্ধু, যিনি নিজে Rahim নন এবং এখনো সরাসরি বন্ধু নন, কতজন পারস্পরিক বন্ধুসহ:

SELECT p.name AS suggestion, COUNT(*) AS mutual_friends,
       group_concat(m.name, ', ') AS via
FROM friend f1
JOIN friend f2 ON f2.src = f1.dst          -- বন্ধুর বন্ধু
JOIN person p  ON p.id = f2.dst
JOIN person m  ON m.id = f1.dst            -- মাঝের বন্ধু
WHERE f1.src = 1                           -- Rahim
  AND f2.dst <> 1
  AND f2.dst NOT IN (SELECT dst FROM friend WHERE src = 1)
GROUP BY p.id
ORDER BY mutual_friends DESC, p.name;
suggestion │ mutual_friends │ via
Nusrat     │ 2              │ Karima, Sabbir
Tanvir     │ 1              │ Sabbir

Cypher-এর ফলের সাথে হুবহু মিলেছে। Nusrat দুজন পারস্পরিক বন্ধুর মাধ্যমে আসে (Karima, Sabbir), তাই তালিকায় ওপরে — সামাজিক মাধ্যমের পরামর্শ ঠিক এই চিন্তায় সাজানো হয়। f2.dst <> 1 না দিলে Rahim নিজেই নিজের পরামর্শে আসতেন (Rahim → Karima → Rahim), আর NOT IN না দিলে আগের বন্ধুরাও আসতেন।

“যত ধাপ লাগে” প্রশ্নের জন্য recursive CTE. পর্ব ২৫-এ রাস্তার মানচিত্রে যে কৌশল দেখেছেন, এখানেও তাই: প্রতিটি সারি দেখা id-গুলোর একটি স্ট্রিং (seen) বয়ে নেয়, যাতে চক্রে ঘুরপাক না খায় (cycle detection), আর সাথে নামের পথ (route). প্রতিটি মানুষের সবচেয়ে কম hop:

WITH RECURSIVE walk(id, hops, seen, route) AS (
  SELECT 1, 0, '/1/', 'Rahim'
  UNION ALL
  SELECT f.dst, w.hops + 1, w.seen || f.dst || '/', w.route || ' > ' || p.name
  FROM walk w
  JOIN friend f ON f.src = w.id
  JOIN person p ON p.id = f.dst
  WHERE instr(w.seen, '/' || f.dst || '/') = 0     -- একই মানুষ দুবার নয়
    AND w.hops < 6                                  -- নিরাপত্তার সীমা
)
SELECT p.name, MIN(w.hops) AS min_hops, w.route, COUNT(*) AS paths
FROM walk w JOIN person p ON p.id = w.id
GROUP BY p.id ORDER BY min_hops, p.name;
name    │ min_hops │ route                                  │ paths
Rahim   │ 0        │ Rahim                                  │ 1
Karima  │ 1        │ Rahim > Karima                         │ 3
Sabbir  │ 1        │ Rahim > Sabbir                         │ 3
Nusrat  │ 2        │ Rahim > Karima > Nusrat                │ 3
Tanvir  │ 2        │ Rahim > Sabbir > Tanvir                │ 4
Farhana │ 3        │ Rahim > Karima > Nusrat > Farhana      │ 4
Imran   │ 3        │ Rahim > Sabbir > Tanvir > Imran        │ 4
Mitu    │ 4        │ Rahim > Sabbir > Tanvir > Imran > Mitu │ 4

Mitu ৪ hop দূরে, পথ Cypher-এর shortestPath-এর মতোই। কিন্তু paths কলাম দেখুন: Mitu পর্যন্ত চক্রহীন ৪টি আলাদা পথ, আর CTE সবগুলো বানিয়েছে — মোট ২৬টি সারি — তারপর MIN নিয়েছে। BFS ৮ জনকে একবার করে দেখলেই উত্তর পেত। Recursive CTE সব পথ গোনে, BFS গোনে node; এটিই দুই পদ্ধতির মূল পার্থক্য। আরেকটি সতর্কতা: route এখানে একটি bare column — SQLite-এর বিশেষ নিয়মে MIN()-এর সারি থেকে আসে। PostgreSQL-এ এভাবে লেখা error দেয়; সেখানে ROW_NUMBER() বা DISTINCT ON লাগবে।

Fraud ring — একই ডিভাইস ব্যবহার করা অ্যাকাউন্টের দল

মোবাইল ফাইন্যান্সিয়াল সার্ভিসের ঝুঁকি-বিশ্লেষণ দলের একটি পরিচিত সতর্কসংকেত: অনেকগুলো “আলাদা” অ্যাকাউন্ট আসলে একই ফোন সেট বা একই ডিভাইস থেকে চলছে। এটি নিজে অপরাধের প্রমাণ নয় — পরিবারের সবাই একটি ফোন ব্যবহার করতেই পারেন — কিন্তু মানুষের হাতে যাচাইয়ের (manual review) জন্য চিহ্নিত করার মতো। অ্যাকাউন্ট আর ডিভাইস দুই ধরনের node; A101 আর A103 সরাসরি কোনো ডিভাইস ভাগ না করলেও A102-এর মাধ্যমে যুক্ত হতে পারে। প্রশ্নটা তাই graph-এর connected component খোঁজা।

এডিটরের টেবিল account_device (acc_id, device_id) — দশটি অ্যাকাউন্ট (A101–A110) আর সাতটি ডিভাইস (D1–D7)-এর ১৩টি সংযোগ, বানানো ডেটা। কৌশল: প্রতিটি অ্যাকাউন্ট থেকে অ্যাকাউন্ট → ডিভাইস → অ্যাকাউন্ট ধরে যতদূর যাওয়া যায়, আর দলের নাম হবে দলের সবচেয়ে ছোট আইডি।

WITH RECURSIVE reach(start, acc) AS (
  SELECT DISTINCT acc_id, acc_id FROM account_device
  UNION                                   -- ডুপ্লিকেট বাদ, তাই চক্রেও থামে
  SELECT r.start, d2.acc_id
  FROM reach r
  JOIN account_device d1 ON d1.acc_id = r.acc
  JOIN account_device d2 ON d2.device_id = d1.device_id
),
ring AS (SELECT start, MIN(acc) AS ring_id FROM reach GROUP BY start)
SELECT ring_id, COUNT(*) AS accounts, group_concat(start, ', ') AS members
FROM ring GROUP BY ring_id
HAVING COUNT(*) >= 2
ORDER BY accounts DESC, ring_id;
ring_id │ accounts │ members
A105    │ 4        │ A105, A106, A107, A110
A101    │ 3        │ A101, A102, A103
A104    │ 2        │ A104, A108

A109 একা একটি ডিভাইস ব্যবহার করে, তাই HAVING-এ বাদ। সবচেয়ে বড় দলটি দেখুন: A105 আর A110 কোনো ডিভাইস ভাগ করে না, তবু D4 → A106 → D5 → A107 → D7 শিকলে যুক্ত। এক ধাপের JOIN এটি কখনো ধরত না। এখানে UNION ALL-এর বদলে UNION ইচ্ছা করে — প্রতিটি (start, acc) জোড়া একবারই থাকে, তাই পথ-স্ট্রিং ছাড়াই চক্র থেমে যায়, আর ফল হয় transitive closure.

Depth বাড়লে খরচ — সংখ্যায় দেখুন

এবার একটি বড় graph: ১০০০টি node (0–999), প্রতিটি থেকে ৩টি করে সংযোগ, অর্থাৎ ৩০০০টি edge, গাণিতিক সূত্রে বানানো (edge টেবিল, src-এ index). Node 0 থেকে কোনো পাহারা ছাড়া হাঁটলে প্রতিটি depth-এ কয়টি সারি তৈরি হয়, আর তার মধ্যে আলাদা node কয়টি?

WITH RECURSIVE walk(node, depth) AS (
  SELECT 0, 0
  UNION ALL
  SELECT e.dst, w.depth + 1
  FROM walk w JOIN edge e ON e.src = w.node
  WHERE w.depth < 8
)
SELECT depth, COUNT(*) AS rows_made,
       COUNT(DISTINCT node) AS distinct_nodes
FROM walk GROUP BY depth ORDER BY depth;
depth │ rows_made │ distinct_nodes
0     │ 1         │ 1
1     │ 3         │ 3
2     │ 9         │ 9
3     │ 27        │ 25
4     │ 81        │ 73
5     │ 243       │ 193
6     │ 729       │ 388
7     │ 2187      │ 492
8     │ 6561      │ 500

rows_made ঠিক 3k: প্রতিটি ধাপে প্রতিটি সারি ৩টি নতুন সারি বানায় — মোট ১ + ৩ + … + ৬৫৬১ = ৯৮৪১টি সারি। কিন্তু depth 8-এ আলাদা node মাত্র ৫০০ — অর্থাৎ ৬৫৬১টি সারির বেশিরভাগই একই node-এ ভিন্ন পথে বারবার পৌঁছানো। depth 12-তে সারি হতো ৫ লাখের বেশি, depth 20-এ কয়েকশো কোটি; আর পুরো graph-এ node মাত্র ১০০০টি। প্রতিটি সারির জন্য একটি index খোঁজ, তাই খরচ O(dk · log E). UNION দিয়ে শুধু node মনে রাখলে (WITH RECURSIVE seen(node) AS (SELECT 0 UNION SELECT e.dst FROM seen s JOIN edge e ON e.src = s.node)) মাত্র ১০০০টি সারিতে সব reachable node পাওয়া যায় — কিন্তু তখন depth হারায়। “কত দূরে” আর “একবারই দেখো” দুটি একসাথে SQL-এ স্বাভাবিকভাবে বলা যায় না; graph ডেটাবেসের BFS ঠিক এটাই করে।

একই কাজ সাধারণ প্রোগ্রামে, visited set রেখে — প্রতিটি মানুষ একবারই queue-তে ঢোকে (DSA কোর্সের BFS পর্বে বিস্তারিত):

from collections import deque
edges = [("Rahim", "Karima"), ("Rahim", "Sabbir"), ("Karima", "Nusrat"), ("Sabbir", "Nusrat"),
         ("Sabbir", "Tanvir"), ("Nusrat", "Farhana"), ("Tanvir", "Imran"), ("Farhana", "Imran"), ("Imran", "Mitu")]
adj = {}                                   # adjacency list
for a, b in edges:
    adj.setdefault(a, []).append(b); adj.setdefault(b, []).append(a)
def bfs(start, goal):
    parent, queue, popped = {start: None}, deque([start]), 0   # parent = visited + কোথা থেকে এলাম
    while queue:
        node = queue.popleft(); popped += 1
        if node == goal: break
        for nb in adj[node]:
            if nb not in parent:           # প্রতিটি মানুষ একবারই queue-তে
                parent[nb] = node; queue.append(nb)
    route = []
    while node is not None:
        route.append(node); node = parent[node]
    return route[::-1], popped, len(parent)
route, popped, seen = bfs("Rahim", "Mitu")
print(" > ".join(route), "| hops:", len(route) - 1)
print("dequeued:", popped, "| visited:", seen, "of", len(adj))
Rahim > Sabbir > Tanvir > Imran > Mitu | hops: 4
dequeued: 8 | visited: 8 of 8

একই উত্তর, কিন্তু কাজ ৮টি node-এর — SQL-এর ২৬টি পথ-সারির জায়গায়।

পরিস্থিতিসাধারণ SQL যথেষ্ট?
১–২ ধাপের সম্পর্ক, যেমন অর্ডার → গ্রাহক, বন্ধুর বন্ধুহ্যাঁ — JOIN আর index
গাছ বা অল্প গভীর hierarchy (category, mentor-শৃঙ্খল)হ্যাঁ — recursive CTE (পর্ব ২৫)
অজানা গভীরতা, ঘন সংযোগ, প্রতিদিনের বহু-ধাপ প্রশ্ন (fraud, recommendation, network topology)graph ডেটাবেস বিবেচনা করুন
Graph অ্যালগরিদম — PageRank, community detection, weighted shortest pathgraph ডেটাবেস বা graph analytics library
টাকার লেনদেনের মূল হিসাব (ledger)relational-ই থাকুক; graph-এ কপি পাঠিয়ে বিশ্লেষণ

৫ · Search engine — Lucene, analyzer, BM25, shard আর replica

পর্ব ৩০-এ inverted index দেখেছেন: শব্দ → কোন কোন ডকুমেন্টে আছে তার তালিকা (posting list). একটি search engine এই ধারণাকে বিশাল মাপে নিয়ে যায়। আজকের সবচেয়ে প্রচলিত দুটি — Elasticsearch আর তার ২০২১ সালের open-source শাখা OpenSearch — দুটিই Lucene নামের Java library-র ওপর তৈরি। ডেটা মডেল সহজ: প্রতিটি রেকর্ড একটি JSON ডকুমেন্ট, ডকুমেন্টের সংগ্রহের নাম index (relational-এর টেবিলের কাছাকাছি), আর mapping বলে কোন ঘর কোন টাইপের।

লেখা index-এ ঢোকার আগে যায় একটি analyzer-এর ভেতর দিয়ে, যার তিনটি ধাপ: (১) character filter — যেমন HTML ট্যাগ মোছা; (২) tokenizer — লেখাকে শব্দে ভাঙা; (৩) token filter — ছোট হাতের অক্ষর, stop word বাদ, stemming. ইংরেজি analyzer-এ “Fast charging supported” হয়ে যায় fast, charg, support. খোঁজার শব্দও একই analyzer দিয়ে যায় — না হলে “Charging” আর “charg” কখনো মিলত না।

মিলে যাওয়া ডকুমেন্ট সাজানো হয় প্রাসঙ্গিকতার স্কোরে (relevance ranking). Lucene-এর ডিফল্ট সূত্র BM25 (Elasticsearch ৫.০ থেকে ডিফল্ট; তার আগে ছিল TF-IDF-ভিত্তিক সূত্র)। একটি শব্দ q আর ডকুমেন্ট D-এর জন্য:

score(q, D) = IDF(q) × tf × (k1 + 1) / (tf + k1 × (1 − b + b × |D| / avgdl))
IDF(q)      = ln(1 + (N − n + 0.5) / (n + 0.5))

এখানে tf = D-তে শব্দটি কতবার, |D| = D-তে মোট শব্দ, avgdl = গড় ডকুমেন্ট-দৈর্ঘ্য, N = মোট ডকুমেন্ট, n = কতগুলো ডকুমেন্টে শব্দটি আছে; সাধারণ মান k1 = 1.2, b = 0.75. তিনটি চিন্তা: বিরল শব্দের ওজন বেশি (IDF); বারবার থাকলে স্কোর বাড়ে, কিন্তু একটি সীমার দিকে থেমে যায় (k1-এর saturation); আর লম্বা ডকুমেন্টে একবার থাকা কম অর্থবহ (b-এর length normalization). পাঁচটি ছোট পণ্য-বিবরণে খোঁজ “battery charging” (stemming ছাড়া):

d1: fast charging phone with a big battery                    (৭ শব্দ)
d2: power bank with fast charging                             (৫)
d3: wireless headphone with long battery life                 (৬)
d4: smart watch battery saver keeps the battery going for days (১০)
d5: red silk saree                                            (৩)
index আর হিসাবসবুজ = এই ধাপে কাজে লাগল

            
প্রতিটি ধাপের পর
কাজ
প্রার্থী
সেরা
  1. Analyze. প্রতিটি বিবরণ ছোট হাতের অক্ষরে শব্দে ভাঙা হলো। মোট ৩১টি শব্দ, তাই গড় দৈর্ঘ্য avgdl = ৬.২. দৈর্ঘ্যগুলো index-এ জমা থাকে, কারণ স্কোরের সূত্রে লাগবে।
  2. Inverted index. প্রতিটি শব্দের posting list-এ শুধু ডকুমেন্ট নয়, কতবার আছে (tf) সেটিও। battery আছে d1, d3-এ একবার আর d4-এ দুবার; charging আছে d1 আর d2-তে।
  3. Posting পড়া। Elasticsearch-এর match কোয়েরির ডিফল্ট আচরণ OR: যেকোনো একটি শব্দ থাকলেই প্রার্থী। দুটি তালিকার union — d1, d2, d3, d4. d5-এর লেখা কখনো পড়া হয়নি; খরচ নির্ভর করে তালিকার দৈর্ঘ্যে, সংগ্রহের মোট আকারে নয়।
  4. IDF. battery ৫টির ৩টিতে, charging ২টিতে। তাই charging বিরল, ওজন ০.৮৭৫ — battery-র ০.৫৩৯-এর চেয়ে বেশি।
  5. tf আর length normalization. d2 ছোট (৫ শব্দ), তাই তার একটি charging পেল ০.৯৫১ — d1-এর charging (০.৮৩২)-এর চেয়ে বেশি। d4-এ battery দুবার, কিন্তু ডকুমেন্টটি লম্বা: স্কোর ০.৬৩২; একবার থাকলে হতো ০.৪৩১. দুবার থাকায় দ্বিগুণ হয়নি — এটিই saturation, keyword ঠেসে দেওয়ার লাভ সীমিত।
  6. যোগ ও সাজানো। ডকুমেন্টের স্কোর = প্রতিটি শব্দের স্কোরের যোগফল। d1-এ দুটি শব্দই আছে: ০.৫১২ + ০.৮৩২ = ১.৩৪৪, তাই সবার ওপরে। তারপর d2, d4, d3.

এই হিসাবটি একটি ছোট Python প্রোগ্রামে — inverted index বানানো থেকে স্কোর পর্যন্ত (stdlib ছাড়া কিছু লাগে না):

import math, re
from collections import Counter
docs = {"d1": "fast charging phone with a big battery",
        "d2": "power bank with fast charging",
        "d3": "wireless headphone with long battery life",
        "d4": "smart watch battery saver keeps the battery going for days",
        "d5": "red silk saree"}
tok = {d: re.findall(r"[a-z0-9]+", t.lower()) for d, t in docs.items()}
N, avgdl, k1, b = len(tok), sum(map(len, tok.values())) / len(tok), 1.2, 0.75
index = {}                                   # inverted index: term -> {doc: tf}
for d, words in tok.items():
    for w, tf in Counter(words).items():
        index.setdefault(w, {})[d] = tf
scores = Counter()
for q in "battery charging".split():
    post = index.get(q, {})
    idf = math.log(1 + (N - len(post) + 0.5) / (len(post) + 0.5))
    print(f"{q:9} n={len(post)} idf={idf:.3f} postings={post}")
    for d, tf in post.items():
        norm = 1 - b + b * len(tok[d]) / avgdl   # লম্বা ডকুমেন্টে বড়
        scores[d] += idf * tf * (k1 + 1) / (tf + k1 * norm)
for d, s in scores.most_common(): print(f"  {d}  {s:.3f}  {docs[d]}")
battery   n=3 idf=0.539 postings={'d1': 1, 'd3': 1, 'd4': 2}
charging  n=2 idf=0.875 postings={'d1': 1, 'd2': 1}
  d1  1.344  fast charging phone with a big battery
  d2  0.951  power bank with fast charging
  d4  0.632  smart watch battery saver keeps the battery going for days
  d3  0.546  wireless headphone with long battery life

stepper-এর প্রতিটি সংখ্যা এখানে মিলিয়ে নিন। d4-এর বিবরণে আরেকবার “battery” যোগ করে চালান — স্কোর বাড়বে, কিন্তু অল্প। Lucene-এর আধুনিক সংস্করণ ধ্রুবক গুণক (k1 + 1) বাদ দিয়ে হিসাব করে — সব স্কোর একই অনুপাতে বদলায়, তাই ক্রম একই থাকে।

ছড়ানো গঠন — shard, replica আর near-real-time

একটি index কয়েকটি shard-এ ভাগ হয় — প্রতিটি shard নিজেই একটি সম্পূর্ণ Lucene index, আর বিভিন্ন সার্ভারে (node) ছড়ানো থাকে, ঠিক পর্ব ৫৪-এর sharding-এর মতো। প্রতিটি primary shard-এর এক বা একাধিক replica অন্য node-এ থাকে: একটি সার্ভার নষ্ট হলে replica primary হয়ে যায়, আর খোঁজের চাপও replica-রা ভাগ করে নেয়। একটি খোঁজ সব shard-এ একসাথে যায়, প্রতিটি নিজের সেরা ফল ফেরত দেয়, আর সমন্বয়কারী node সেগুলো মিলিয়ে চূড়ান্ত ক্রম বানায় — scatter-gather. Primary shard-এর সংখ্যা index বানানোর সময়ই ঠিক করতে হয়; পরে বদলাতে reindex বা split/shrink-এর মতো বিশেষ অপারেশন লাগে।

আরেকটি বৈশিষ্ট্য near-real-time: নতুন ডকুমেন্ট পাঠানোর সাথে সাথে খোঁজে আসে না। Lucene নতুন লেখা প্রথমে মেমরিতে জমায়, আর একটি refresh-এ সেগুলো থেকে নতুন, অপরিবর্তনীয় segment বানায় — তখনই খোঁজা যায়। Elasticsearch-এ ডিফল্ট refresh প্রতি ১ সেকেন্ডে। Segment কখনো বদলায় না; মুছলে শুধু “মুছে ফেলা” চিহ্ন পড়ে, আর পটভূমিতে ছোট segment জুড়ে বড় segment হয় (merge) — পর্ব ৪০ আর ৫৬-এ উল্লেখ করা LSM-tree-এর চিন্তা।

PUT /products
{
  "settings": { "number_of_shards": 3, "number_of_replicas": 1 },
  "mappings": {
    "properties": {
      "name":        { "type": "text" },
      "description": { "type": "text", "analyzer": "english" },
      "category":    { "type": "keyword" },
      "price":       { "type": "scaled_float", "scaling_factor": 100 }
    }
  }
}

GET /products/_search
{
  "query": {
    "bool": {
      "must":   [ { "multi_match": { "query": "fast charging",
                                     "fields": ["name^2", "description"] } } ],
      "filter": [ { "term":  { "category": "Electronics" } },
                  { "range": { "price": { "lte": 5000 } } } ]
    }
  },
  "highlight": { "fields": { "description": {} } }
}

ভাষাটির নাম query DSL — প্রশ্নটাই একটি JSON. দুটি জিনিস লক্ষ করুন। text ঘর analyzer দিয়ে ভাঙা হয় আর স্কোরে অংশ নেয়; keyword ঘর আস্ত একটি মান, ঠিক SQL-এর =-এর মতো মেলে। আর must-এর শর্ত স্কোর বানায়, কিন্তু filter-এর শর্ত শুধু হ্যাঁ-না বলে, স্কোর ছোঁয় না, আর তার ফল cache করা যায় — SQL-এর WHERE-এর মতো।

৬ · SQLite-এ সার্চ — FTS4, মূল ডেটাবেসের সাথে মিল রাখা আর আসল স্থাপত্য

এবার দোকানের নিজের পণ্যে সার্চ। এডিটরগুলো চলবে কোর্সের সাধারণ db-shop ডেটাবেসে — customers, categories, products (product_id, name, category_id, price, stock), orders আর order_items. সাথে একটি নতুন টেবিল product_text (product_id, description) — দশটি পণ্যের ছোট ইংরেজি বিবরণ, যেমন Mobile Phone-এর “Android phone with 8 GB RAM, 50 MP camera and a 5000 mAh battery. Fast charging supported.” পুরো ডেটা এডিটরের নিচের “নমুনা ডেটাবেস” বাক্সে।

পর্ব ৩০-এর মতো এখানেও লাইভ ইঞ্জিনে FTS5 নেই, তাই virtual table FTS4, Porter stemmer-সহ। নাম আর বিবরণ দুটি কলামই index হচ্ছে, আর docid = product_id, যাতে মূল টেবিলের সাথে জোড়া যায়:

CREATE VIRTUAL TABLE product_fts USING fts4(name, description, tokenize=porter);
INSERT INTO product_fts (docid, name, description)
  SELECT p.product_id, p.name, t.description
  FROM products p JOIN product_text t USING (product_id);

SELECT docid, name FROM product_fts WHERE product_fts MATCH 'battery';
SELECT docid, name FROM product_fts WHERE product_fts MATCH 'charging';
SELECT docid, snippet(product_fts, '[', ']', '…', 1, 6) AS hit
FROM product_fts WHERE product_fts MATCH '"fast charging"';
-- লেখার খোঁজ + কাঠামোবদ্ধ ফিল্টার একসাথে
SELECT name, price, stock FROM products
WHERE product_id IN (SELECT docid FROM product_fts WHERE product_fts MATCH 'battery')
  AND price < 5000 AND stock > 0;
docid │ name
101   │ Mobile Phone
102   │ Headphone
110   │ Smart Watch

docid │ name
101   │ Mobile Phone
103   │ Power Bank

docid │ hit
101   │ …5000 mAh battery. [Fast] [charging] supported.
103   │ …times, with a [fast] [charging] port.

name        │ price  │ stock
Headphone   │ 1200 │ 40
Smart Watch │ 3500 │ 5

“charging” Porter-এ হয় charg, তাই Power Bank-এর “Charges” আর “charging” দুটিই মেলে। উদ্ধৃতিচিহ্নের "fast charging" একটি phrase query — শব্দ দুটি পাশাপাশি থাকতে হবে; snippet()-এর পঞ্চম মান 1 মানে description কলাম (০ = name) থেকে টুকরো কাটো। শেষ কোয়েরিটিই বাস্তব সার্চ পাতার আসল রূপ: “battery” শব্দের খোঁজ + দাম ৫০০০-এর কম + স্টকে আছে। Elasticsearch-এ এটি ঠিক আগের অংশের must + filter. FTS4-এ তৈরি ranking নেই — ফল docid-এর ক্রমে; স্কোর চাইলে matchinfo() দিয়ে নিজে লিখতে হয়, যার পেছনের হিসাব আগের Python-এর মতোই।

Search index-কে মূল ডেটাবেসের সাথে মিলিয়ে রাখা — outbox আর CDC

Search engine সাধারণত source of truth নয়: দাম, স্টক, অর্ডার থাকে relational ডেটাবেসে, আর search index তার একটি সাজানো কপি। সমস্যা হলো কপিটাকে হালনাগাদ রাখা। সবচেয়ে সরল — অ্যাপ প্রথমে ডেটাবেসে লেখে, তারপর search engine-এ — একটি ফাঁদ (dual write): মাঝখানে অ্যাপ বন্ধ হলে বা নেটওয়ার্ক ব্যর্থ হলে দুই জায়গায় দুই রকম তথ্য, আর কেউ জানে না। দুটি প্রচলিত সমাধান:

  • Outbox: মূল পরিবর্তনের একই transaction-এ একটি search_outbox টেবিলে “এই পণ্য বদলেছে” লেখা। commit হলে দুটিই থাকে, rollback হলে কোনোটিই নয়। আলাদা একটি worker outbox পড়ে search engine-এ পাঠায়, আর সফল হলে event মুছে দেয়।
  • CDC: ডেটাবেসের transaction log (PostgreSQL-এর WAL, MySQL-এর binlog) পড়ে প্রতিটি commit হওয়া পরিবর্তন ধরা — অ্যাপের কোডে হাত না দিয়েই। Debezium-এর মতো টুল এই কাজে পরিচিত, প্রায়ই Kafka-র সাথে।

SQLite-এ outbox-এর একটি ছোট নকল: একটি trigger নাম বদলালে event লেখে (বাস্তবে অ্যাপ নিজেই একই transaction-এ লেখে, বা CDC লাগে)। এডিটরে product_fts আর ফাঁকা search_outbox (event_id, product_id, op) আগে থেকেই তৈরি:

CREATE TRIGGER trg_prod_upd AFTER UPDATE OF name ON products
BEGIN
  INSERT INTO search_outbox (product_id, op) VALUES (NEW.product_id, 'upsert');
END;
UPDATE products SET name = 'Smart Watch Pro' WHERE product_id = 110;
SELECT * FROM search_outbox;
SELECT docid, name FROM product_fts WHERE product_fts MATCH 'pro';   -- এখনো পুরোনো
-- worker: outbox পড়ে index হালনাগাদ, তারপর event মুছে ফেলে
DELETE FROM product_fts WHERE docid IN (SELECT product_id FROM search_outbox);
INSERT INTO product_fts (docid, name, description)
  SELECT p.product_id, p.name, t.description FROM products p
  JOIN product_text t USING (product_id)
  WHERE p.product_id IN (SELECT product_id FROM search_outbox);
DELETE FROM search_outbox;
SELECT docid, name FROM product_fts WHERE product_fts MATCH 'pro';
event_id │ product_id │ op
1        │ 110        │ upsert

(প্রথম MATCH 'pro': কোনো সারি নেই)

docid │ name
110   │ Smart Watch Pro

মাঝের ফাঁকা ফলটিই শিক্ষা: মূল টেবিলে নাম বদলে গেছে, কিন্তু worker চলার আগ পর্যন্ত সার্চে “Pro” নেই। এটি eventual consistency — কপি একটু পিছিয়ে থাকে, কিন্তু শেষে মেলে। worker মাঝপথে ব্যর্থ হলে আবার চালালেও ক্ষতি নেই — মুছে আবার ঢোকানো idempotent, দুবার করলেও ফল একই।

অ্যাপ ──লেখা──▶ PostgreSQL / MySQL  (source of truth)
                     │  একই transaction-এ outbox
                     │  বা WAL/binlog থেকে CDC
                     ▼
                 queue (যেমন Kafka)
                     ▼
              indexer / worker
                     ▼
অ্যাপ ◀──খোঁজ── Elasticsearch / OpenSearch
               (৩ shard × ১ replica)
ফলের id দিয়ে দাম-স্টক আবার মূল DB থেকে
প্রচলিত স্থাপত্য: লেখা যায় মূল ডেটাবেসে, খোঁজ আসে search engine থেকে; চূড়ান্ত দাম ও স্টক অনেক দল আবার মূল ডেটাবেস থেকে মিলিয়ে নেয়।

বাংলা লেখার কথা আবার মনে করুন (পর্ব ৩০): একই অক্ষরের দুই Unicode রূপ (ড় = এক বা দুই code point) — তাই index আর খোঁজ দুই দিকেই Unicode normalization; কার-চিহ্নে শব্দ ভেঙে ফেলা tokenizer; আর শাড়ি, শাড়ির, শাড়িতে-র মতো বিভক্তি। Elasticsearch-এর ডকুমেন্টেশনে bengali নামে একটি language analyzer আছে, যা normalization আর stemming করে; ICU-ভিত্তিক analysis plugin-ও আছে। তবে কোনোটিই নিখুঁত নয় — নিজের আসল সার্চ লগ থেকে শব্দ নিয়ে _analyze API-তে token দেখে যাচাই করুন।

কখন আলাদা search engine? কয়েক লাখ পণ্য, এক-দুই ভাষা, সাধারণ খোঁজ — PostgreSQL-এর tsvector বা SQLite FTS5 প্রায়ই যথেষ্ট, আর consistency বিনা খরচে। বানান-ভুল সহনশীলতা (fuzzy), facet (ফলের মধ্যে ক্যাটাগরি-ভিত্তিক গোনা), autocomplete, বহু ভাষা, শতকোটি log লাইন বা প্রাসঙ্গিকতার সূক্ষ্ম নিয়ন্ত্রণ যখন পণ্যের কেন্দ্র, তখন Elasticsearch/OpenSearch — আর সাথে স্বীকার করে নিন, এখন থেকে একটি কপি সামলাতে হবে।

৭ · Time-series ডেটা — অবিরাম মাপ, compression, downsampling আর retention

ঢাকার কোনো মোড়ে একটি বায়ুমান সেন্সর প্রতি ১০ মিনিটে PM2.5 (বাতাসে ভাসমান সূক্ষ্ম কণা, µg/m³) মাপে। একটি পোশাক কারখানার প্রতিটি সেলাই মেশিন প্রতি সেকেন্ডে গতি আর তাপমাত্রা পাঠায়। শেয়ারবাজারের প্রতিটি লেনদেন (tick) সময়সহ জমা হয়। এগুলো সবই time-series data: প্রতিটি সারি = (কখন, কোন উৎস, কী মাপ)।

এই ডেটার আকার সাধারণ ব্যবসায়িক ডেটা থেকে আলাদা, আর সেখান থেকেই time-series database-এর নকশা আসে:

  • লেখা অনেক বেশি, আর প্রায় সবই নতুন: ১০,০০০ সেন্সর × প্রতি ১০ সেকেন্ডে একটি মাপ = প্রতি সেকেন্ডে ১,০০০টি নতুন সারি, দিনে ৮ কোটি ৬৪ লাখ। পুরোনো সারি প্রায় কখনো UPDATE হয় না — append-only. তাই লেখার পথ সাজানো হয় LSM-ধাঁচে বা সময়ভিত্তিক খণ্ডে (chunk).
  • প্রশ্ন সময়ের পরিসরে: “গত ৬ ঘণ্টা”, “গত মাসের প্রতিদিনের সর্বোচ্চ”। তাই ডেটা সময় অনুযায়ী ভাগ করা থাকে (partitioning), আর পুরোনো খণ্ড ছুঁতেই হয় না।
  • Compression খুব কার্যকর: পরপর timestamp-এর ব্যবধান প্রায় সমান, মানও ধীরে বদলায়। তাই পুরো মান না রেখে শুধু পার্থক্য রাখা যায় (delta encoding), এমনকি পার্থক্যের পার্থক্য। Facebook-এর ২০১৫ সালের Gorilla পেপার timestamp-এ delta-of-delta আর দশমিক মানে আগের মানের সাথে XOR ব্যবহার করে প্রতিটি data point গড়ে প্রায় ১.৩৭ byte-এ নামানোর কথা জানিয়েছিল — সাধারণ ১৬ byte-এর জায়গায়।
  • পুরোনো ডেটার দাম কমে: গতকালের প্রতি ১০ সেকেন্ডের CPU দরকারি, গত বছরেরটা নয় — দিনের গড়ই যথেষ্ট। তাই downsampling (সূক্ষ্ম মাপ থেকে মোটা সারাংশ) আর retention (নির্দিষ্ট বয়সের পর কাঁচা ডেটা মুছে ফেলা) এদের মূল বৈশিষ্ট্য।

বিদ্যুৎ অফিসের মিটার রিডিংয়ের খাতা ভাবুন। চলতি মাসে প্রতিদিনের রিডিং লেখা থাকে। মাস শেষ হলে বিল হয় মাসের মোট ইউনিটে, আর এক বছর পর প্রতিদিনের পাতাগুলো আর কেউ খোঁজে না — শুধু মাসিক মোট থাকে। প্রতিদিনের পাতা থেকে মাসিক মোট বানানো = downsampling; পুরোনো পাতা ফেলে দেওয়া = retention. শর্ত একটাই: পাতা ফেলার আগে মোট লিখে রাখতে হবে।

নিচের stepper-এ এই পর্বের সেন্সর ডেটা নিয়েই পুরো প্রক্রিয়া: ঢাকার একটি সেন্সর dhaka-01, ১৫ জানুয়ারি ২০২৬ রাত ১২টা থেকে ভোর ৬টা পর্যন্ত প্রতি ১০ মিনিটে একটি মাপ — মোট ৩৬টি হওয়ার কথা। কিন্তু ০২:৩০ থেকে ০৩:০০ পর্যন্ত লোডশেডিংয়ে সেন্সর বন্ধ ছিল, তাই চারটি মাপ নেই (সংখ্যাগুলো শেখানোর জন্য বানানো, আসল মাপ নয়)।

readings (কাঁচা) আর readings_1hসবুজ = এই ধাপে বদলেছে

            
সঞ্চয়ের হিসাব
ধাপ
readings
readings_1h
মোট সারি
  1. কাঁচা মাপ। ছয় ঘণ্টায় ৩৬টির জায়গায় ৩২টি সারি — ০২:৩০, ০২:৪০, ০২:৫০, ০৩:০০ নেই।
  2. Time bucket. প্রতিটি timestamp-কে তার ঘণ্টার শুরুতে নামানো হলো: ০০:১০ আর ০০:৫০ দুটিই ০০:০০-এর bucket-এ। SQLite-এ এর জন্য strftime, TimescaleDB-তে time_bucket, InfluxDB 3-এর SQL-এ date_bin — নাম আলাদা, কাজ এক।
  3. Aggregate. প্রতিটি bucket-এ গোনা, গড়, সর্বনিম্ন আর সর্বোচ্চ। ০২:০০-এ মাত্র ৩টি আর ০৩:০০-এ ৫টি মাপ — n না দেখলে কেউ বুঝবে না যে ০২:০০-এর গড় অর্ধেক ঘণ্টার ডেটায় তৈরি।
  4. Rollup জমা। ফলটি একটি আলাদা টেবিল readings_1h-এ রাখা হলো — ৬টি সারি। এটি একটি materialized view-এর মতো; TimescaleDB-র continuous aggregate একই কাজ নিজে নিজে, শুধু নতুন ডেটার অংশ হিসাব করে করে।
  5. Retention. নিয়ম: কাঁচা ডেটা শুধু শেষ দুই ঘণ্টার (বাস্তবে হয়তো ৩০ দিন)। ০৪:০০-এর আগের ২০টি সারি মুছল, থাকল ১২টি। মোট সারি ৩৮ থেকে ১৮.
  6. প্রশ্ন। পুরোনো ঘণ্টার উত্তর আসে rollup থেকে, নতুনগুলোর কাঁচা ডেটা থেকে। ঘণ্টার গড় ও সর্বোচ্চ সবই আছে, কিন্তু ০১:২০-এর কাঁচা মান 194 চিরতরে গেছে। Downsampling-এর আগে ঠিক করুন কোন সারাংশ লাগবে — গড় থেকে median বা percentile আর বানানো যায় না।

৮ · SQLite-এ time-series — ডেটা বানানো, bucket, moving average, ফাঁক খোঁজা আর rollup

প্রথম এডিটরে সেন্সরের ডেটা নিজেই বানাবেন — একটি recursive CTE ০ থেকে ৩৫ পর্যন্ত গোনে, আর প্রতিটি i থেকে সময় হয় ০০:০০ + i × ১০ মিনিট। PM2.5-এর মান একটি সহজ সূত্রে বানানো (ধীরে বাড়ে, মাঝে ওঠানামা করে)। তারপর লোডশেডিংয়ের চারটি মাপ মুছে ফেলা হয়, আর ঘণ্টাভিত্তিক bucket. টেবিল readings (sensor, ts, pm25), primary key (sensor, ts) — time-series-এ প্রায় সবসময় এমন “উৎস + সময়” key.

CREATE TABLE readings (sensor TEXT NOT NULL, ts TEXT NOT NULL,
  pm25 REAL NOT NULL, PRIMARY KEY (sensor, ts));
INSERT INTO readings
WITH RECURSIVE t(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM t WHERE i < 35)
SELECT 'dhaka-01',
       strftime('%Y-%m-%d %H:%M', '2026-01-15 00:00', '+' || (i * 10) || ' minutes'),
       140 + (i * 37) % 50 + (i / 6) * 8
FROM t;
DELETE FROM readings WHERE ts BETWEEN '2026-01-15 02:30' AND '2026-01-15 03:00';

SELECT strftime('%H:00', ts) AS hour, COUNT(*) AS n,
       ROUND(AVG(pm25), 1) AS avg_pm25, MIN(pm25) AS min_pm25, MAX(pm25) AS max_pm25
FROM readings GROUP BY hour ORDER BY hour;

INSERT-এ ৩৬টি সারি, DELETE-এ ৪টি মুছল। তারপর:

hour  │ n │ avg_pm25 │ min_pm25 │ max_pm25
00:00 │ 6 │ 165.8    │ 140    │ 188
01:00 │ 6 │ 170.8    │ 155    │ 194
02:00 │ 3 │ 187    │ 174    │ 200
03:00 │ 5 │ 181    │ 165    │ 204
04:00 │ 6 │ 202.5    │ 184    │ 221
05:00 │ 6 │ 207.5    │ 188    │ 227

stepper-এর তৃতীয় ধাপের টেবিলটি এখানে আসলেই তৈরি হলো। ৩০ মিনিট বা ৫ মিনিটের মতো যেকোনো মাপের bucket-এর সাধারণ সূত্র: datetime((unixepoch(ts) / 1800) * 1800, 'unixepoch') — epoch সেকেন্ডকে bucket-এর দৈর্ঘ্য দিয়ে ভাগ করে নিচে নামানো (Unix epoch). বাস্তবে timestamp UTC-তে রাখুন; এখানে সরলতার জন্য ঢাকার স্থানীয় সময়।

এরপরের তিনটি এডিটরে একই ৩২টি সারি আগে থেকেই তৈরি। এবার moving average — বায়ুমানের সূচক প্রায়ই “শেষ এক ঘণ্টার গড়” হিসেবে দেখানো হয়। Window function-এ (পর্ব ২৬) দুই রকমভাবে লেখা যায়, আর ফাঁক থাকলে দুটির উত্তর আলাদা:

SELECT * FROM (
  SELECT ts, pm25,
    ROUND(AVG(pm25) OVER (ORDER BY ts
          ROWS BETWEEN 5 PRECEDING AND CURRENT ROW), 1) AS avg_6rows,
    ROUND(AVG(pm25) OVER (ORDER BY unixepoch(ts)
          RANGE BETWEEN 3000 PRECEDING AND CURRENT ROW), 1) AS avg_last_hour,
    COUNT(*) OVER (ORDER BY unixepoch(ts)
          RANGE BETWEEN 3000 PRECEDING AND CURRENT ROW) AS n_hour
  FROM readings)
WHERE ts BETWEEN '2026-01-15 02:00' AND '2026-01-15 04:00';
ts               │ pm25  │ avg_6rows │ avg_last_hour │ n_hour
2026-01-15 02:00 │ 200 │ 175.8     │ 175.8         │ 6
2026-01-15 02:10 │ 187 │ 180.8     │ 180.8         │ 6
2026-01-15 02:20 │ 174 │ 177.5     │ 177.5         │ 6
2026-01-15 03:10 │ 167 │ 175.2     │ 170.5         │ 2
2026-01-15 03:20 │ 204 │ 181.2     │ 185.5         │ 2
2026-01-15 03:30 │ 191 │ 187.2     │ 187.3         │ 3
2026-01-15 03:40 │ 178 │ 183.5     │ 185         │ 4
2026-01-15 03:50 │ 165 │ 179.8     │ 181         │ 5
2026-01-15 04:00 │ 210 │ 185.8     │ 185.8         │ 6

ফাঁকের আগে দুই কলাম হুবহু এক — প্রতি ১০ মিনিটে একটি মাপ, তাই “শেষ ৬টি সারি” = “শেষ এক ঘণ্টা”। ০৩:১০-এ পার্থক্য: ROWS অন্ধভাবে আগের ৬টি সারি নিয়েছে, যার মধ্যে ০১:৪০ আর ০১:৫০-ও আছে — দেড় ঘণ্টা আগের মাপ “শেষ ঘণ্টার গড়”-এ ঢুকে গেছে। RANGE BETWEEN 3000 PRECEDING সময় ধরে frame বানায় (৩০০০ সেকেন্ড = ৫০ মিনিট আগে থেকে এখন পর্যন্ত, অর্থাৎ ৬টি ১০-মিনিটের ঘর): শুধু ০২:২০ আর ০৩:১০ — n_hour = 2. নিয়ম: সময়ের window সময় দিয়ে লিখুন, সারি দিয়ে নয়. Subquery-র কারণ: WHERE আগে চললে window শুধু ছাঁকা সারি দেখত, আর ০২:০০-এর গড় ভুল হতো (পর্ব ২৬-এর logical processing order).

এবার ফাঁক খোঁজা (পর্ব ২৭-এর gaps-and-islands-এর আত্মীয়)। প্রতিটি মাপের আগের মাপ LAG দিয়ে এনে ব্যবধান মাপা — ১০ মিনিটের বেশি হলে ফাঁক:

SELECT prev_ts AS last_ok, ts AS back_at,
       (unixepoch(ts) - unixepoch(prev_ts)) / 60 AS gap_min,
       (unixepoch(ts) - unixepoch(prev_ts)) / 600 - 1 AS missing
FROM (SELECT ts, LAG(ts) OVER (PARTITION BY sensor ORDER BY ts) AS prev_ts
      FROM readings)
WHERE unixepoch(ts) - unixepoch(prev_ts) > 600;

-- কোন ৩০ মিনিটের bucket-এ ৩টির কম মাপ?
SELECT datetime((unixepoch(ts) / 1800) * 1800, 'unixepoch') AS bucket_30m,
       COUNT(*) AS n, ROUND(AVG(pm25), 1) AS avg_pm25
FROM readings GROUP BY bucket_30m HAVING n < 3;
last_ok          │ back_at          │ gap_min │ missing
2026-01-15 02:20 │ 2026-01-15 03:10 │ 50      │ 4

bucket_30m          │ n │ avg_pm25
2026-01-15 03:00:00 │ 2 │ 185.5

০২:২০-এর পর সেন্সর ফিরেছে ০৩:১০-এ — ৫০ মিনিট, চারটি মাপ নেই। PARTITION BY sensor জরুরি: বহু সেন্সর একই টেবিলে থাকলে একটির শেষ মাপকে আরেকটির আগের মাপ ধরা চলবে না। দ্বিতীয় কোয়েরিতে খেয়াল করুন: ০২:৩০-এর bucket-এ কোনো মাপ নেই, তাই সেটি ফলেই নেই — GROUP BY ফাঁকা bucket বানায় না। সম্পূর্ণ সময়রেখা চাইলে recursive CTE দিয়ে সব bucket বানিয়ে LEFT JOIN করতে হয় (পর্ব ২৫-এর তারিখের ধারা)। TimescaleDB-র time_bucket_gapfill আর InfluxDB 3-এর date_bin_gapfill এই কাজটাই সহজ করে।

শেষে stepper-এর downsampling আর retention, সত্যিকারের SQL-এ:

CREATE TABLE readings_1h AS
SELECT sensor, strftime('%Y-%m-%d %H:00', ts) AS bucket, COUNT(*) AS n,
       ROUND(AVG(pm25), 1) AS avg_pm25, MAX(pm25) AS max_pm25
FROM readings GROUP BY sensor, bucket;

DELETE FROM readings WHERE ts < '2026-01-15 04:00';     -- retention

SELECT (SELECT COUNT(*) FROM readings)    AS raw_rows,
       (SELECT COUNT(*) FROM readings_1h) AS rollup_rows;
-- ইতিহাস: পুরোনো অংশ rollup থেকে, নতুন অংশ কাঁচা ডেটা থেকে
SELECT bucket, n, avg_pm25 FROM readings_1h WHERE bucket < '2026-01-15 04:00'
UNION ALL
SELECT strftime('%Y-%m-%d %H:00', ts), COUNT(*), ROUND(AVG(pm25), 1)
FROM readings GROUP BY 1 ORDER BY 1;
raw_rows │ rollup_rows
12       │ 6

bucket           │ n │ avg_pm25
2026-01-15 00:00 │ 6 │ 165.8
2026-01-15 01:00 │ 6 │ 170.8
2026-01-15 02:00 │ 3 │ 187
2026-01-15 03:00 │ 5 │ 181
2026-01-15 04:00 │ 6 │ 202.5
2026-01-15 05:00 │ 6 │ 207.5

DELETE-এ ২০টি সারি গেছে; ৩২টি কাঁচা সারির জায়গায় এখন ১২ + ৬. শেষ কোয়েরিটি TimescaleDB-র “real-time aggregate”-এর হাতে-বানানো রূপ: যে অংশ rollup-এ হিসাব হয়ে গেছে তা সেখান থেকে, বাকিটা তাৎক্ষণিক হিসাব। দুটি সতর্কতা। এক, ক্রম জরুরি — rollup আগে, DELETE পরে, আর দুটি একটি transaction-এ (পর্ব ৪৪)। দুই, ঘণ্টার গড় থেকে দিনের গড় বানাতে n দিয়ে ভারিত গড় নিন: SUM(avg_pm25 * n) / SUM(n) — এখানে 185.8, যা ৩২টি কাঁচা মাপের আসল গড়ের (185.81) সাথে মেলে; সরল গড়ের গড় দেয় 185.77.

বিশেষ সিস্টেম — InfluxDB, TimescaleDB আর Prometheus

তিনটির পথ তিন রকম (কোডগুলো এখানে চলবে না, শুধু পড়ার জন্য):

InfluxDB শুরু থেকেই time-series-এর জন্য বানানো (প্রথম প্রকাশ ২০১৩)। ডেটা লেখা হয় line protocol-এ: measurement, তারপর কমা দিয়ে tag (index হওয়া বর্ণনা, যেমন সেন্সর আর এলাকা), ফাঁকা জায়গার পর field (মাপা মান), শেষে nanosecond-এ timestamp. প্রশ্নের ভাষা সংস্করণভেদে বদলেছে: ১.x-এ SQL-ধাঁচের InfluxQL, ২.x-এ Flux, আর InfluxDB 3 (Apache Arrow ও Parquet-ভিত্তিক নতুন ইঞ্জিন)-এ SQL ও InfluxQL; Flux সেখানে সমর্থিত নয় বলে জানানো হয়েছে। কোন সংস্করণ ব্যবহার করছেন, তা আগে নিশ্চিত হোন।

# line protocol: measurement,tags fields timestamp(ns, UTC)
air_quality,sensor=dhaka-01,area=Farmgate pm25=140,temp_c=18.5 1768413600000000000

-- InfluxQL (1.x, 3.x)
SELECT MEAN("pm25") FROM "air_quality"
WHERE "sensor" = 'dhaka-01' AND time >= now() - 6h
GROUP BY time(1h) fill(none)

// Flux (2.x)
from(bucket: "air")
  |> range(start: -6h)
  |> filter(fn: (r) => r._measurement == "air_quality" and r._field == "pm25")
  |> aggregateWindow(every: 1h, fn: mean, createEmpty: false)

-- SQL (InfluxDB 3)
SELECT date_bin(INTERVAL '1 hour', time) AS hour, avg(pm25) AS avg_pm25
FROM air_quality
WHERE sensor = 'dhaka-01' AND time >= now() - INTERVAL '6 hours'
GROUP BY 1 ORDER BY 1;

TimescaleDB আলাদা ডেটাবেস নয় — PostgreSQL-এর একটি extension (২০১৭ থেকে)। সাধারণ টেবিলকে hypertable বানালে সেটি ভেতরে সময় অনুযায়ী বহু chunk-এ ভাগ হয়, কিন্তু বাইরে একটি টেবিলই দেখায়। time_bucket হলো আমাদের strftime-কৌশলের পরিচ্ছন্ন রূপ।

CREATE TABLE readings (ts TIMESTAMPTZ NOT NULL, sensor TEXT NOT NULL,
                       pm25 DOUBLE PRECISION);
SELECT create_hypertable('readings', 'ts', chunk_time_interval => INTERVAL '1 day');

SELECT time_bucket('1 hour', ts) AS bucket, sensor, avg(pm25)
FROM readings WHERE ts > now() - INTERVAL '1 day'
GROUP BY bucket, sensor ORDER BY bucket;

-- continuous aggregate: নতুন ডেটা এলে শুধু সেই অংশ আবার হিসাব
CREATE MATERIALIZED VIEW readings_1h WITH (timescaledb.continuous) AS
SELECT time_bucket('1 hour', ts) AS bucket, sensor,
       avg(pm25) AS avg_pm25, max(pm25) AS max_pm25, count(*) AS n
FROM readings GROUP BY bucket, sensor;
SELECT add_continuous_aggregate_policy('readings_1h',
  start_offset => INTERVAL '3 days', end_offset => INTERVAL '1 hour',
  schedule_interval => INTERVAL '30 minutes');

-- compression আর retention: নীতি হিসেবে
ALTER TABLE readings SET (timescaledb.compress, timescaledb.compress_segmentby = 'sensor');
SELECT add_compression_policy('readings', INTERVAL '7 days');
SELECT add_retention_policy('readings', INTERVAL '30 days');   -- পুরো chunk ফেলে দেয়

নতুন সংস্করণে এই ফাংশনগুলোর কিছু নতুন নাম বা বিকল্প সিনট্যাক্স এসেছে (যেমন compression-কে columnstore বলা); ধারণা একই, নাম মিলিয়ে নিন।

Prometheus (SoundCloud-এ শুরু, পরে CNCF প্রকল্প) সার্ভার আর অ্যাপের metric-এর জন্য — সাধারণ ব্যবসায়িক ডেটার জন্য নয়। এটি নিজে গিয়ে প্রতিটি সার্ভার থেকে metric টেনে আনে (pull, “scrape”), নিজস্ব স্থানীয় TSDB-তে রাখে (ডিফল্ট retention ১৫ দিন), আর PromQL ভাষায় প্রশ্ন ও alert-এর নিয়ম লেখা হয়।

# প্রতিটি সেন্সরের শেষ ১ ঘণ্টার গড় PM2.5
avg_over_time(pm25_ugm3{city="dhaka"}[1h])

# দোকানের API-তে শেষ ৫ মিনিটে প্রতি সেকেন্ডে অনুরোধের হার
sum by (status) (rate(http_requests_total{job="shop-api"}[5m]))

# alert-এর শর্ত: এক ঘণ্টার গড় 150-এর বেশি
avg_over_time(pm25_ugm3[1h]) > 150

প্রতিটি আলাদা label-মানের সমন্বয় একটি আলাদা series. sensor আর city label ঠিক আছে; কিন্তু কেউ যদি label-এ ব্যবহারকারীর ফোন নম্বর বা order_id বসায়, series-এর সংখ্যা লাখ-কোটিতে লাফায় — একে বলে high cardinality, আর এটি time-series সিস্টেমকে ধীর করে দেওয়ার সবচেয়ে পরিচিত ভুল।

কখন time-series ডেটাবেস? কয়েকশো সেন্সর, মিনিটে একটি মাপ — PostgreSQL-এ সময় ধরে partition আর একটি BRIN index (সময়ের ক্রমে আসা ডেটার জন্য ছোট index) প্রায়ই যথেষ্ট। প্রতি সেকেন্ডে হাজার হাজার লেখা, মাসের পর মাস ইতিহাস, স্বয়ংক্রিয় downsampling-retention-compression দরকার হলে TimescaleDB বা InfluxDB; আর অবকাঠামো পর্যবেক্ষণ ও alert-এর জন্য Prometheus প্রায় মানদণ্ডে পরিণত হয়েছে।

৯ · তিন পরিবার পাশাপাশি — তুলনা আর সিদ্ধান্ত

দিকGraphSearchTime-series
মূল প্রশ্নবহু ধাপের সম্পর্ক, পথলেখার প্রাসঙ্গিকতা, ক্রমসময়ের পরিসরে সারাংশ
ডেটা মডেলnode, relationship, label, property (বা RDF triple)JSON ডকুমেন্ট + mapping(সময়, tag/উৎস, মান)
ভাষাCypher, GQL, SQL/PGQ, SPARQL, Gremlinquery DSL (JSON), কিছু SQLInfluxQL/SQL, PromQL, সাধারণ SQL (Timescale)
প্রধান কাঠামোindex-free adjacencyinverted index, segmentসময়-খণ্ড (chunk), columnar compression
লেখার ধরনমাঝারি, সংযোগ বদলায়ব্যাচে বা স্রোতে, near-real-timeখুব বেশি, append-only
দুর্বলতাবিশাল aggregate, sharding কঠিনconsistency (কপি), transaction নেইযেকোনো সারি UPDATE, জটিল JOIN
উদাহরণNeo4j, Amazon Neptune, MemgraphElasticsearch, OpenSearch, SolrInfluxDB, TimescaleDB, Prometheus
SQLite/SQL-এ কাছাকাছিedges টেবিল + recursive CTEFTS4/FTS5, PostgreSQL tsvectorGROUP BY time bucket, window, partition
SQL-এর সীমাসব পথ গোনে — dk বিস্ফোরণranking, fuzzy, বহু ভাষা দুর্বললেখার হার, স্বয়ংক্রিয় rollup নেই

সিদ্ধান্তের একটি সৎ ক্রম: প্রথমে relational ডেটাবেসের ভেতরের সমাধান (recursive CTE, FTS, partition, BRIN) দিয়ে শুরু করুন আর মাপুন (পর্ব ৫৩-এর মতো EXPLAIN, latency, লেখার হার)। সমস্যা যখন সংখ্যায় প্রমাণিত, তখনই বিশেষ সিস্টেম — আর সেটি সাধারণত মূল ডেটাবেসের পাশে বসে, জায়গায় নয়।

আরও গভীরে: Elasticsearch-এর ডিফল্ট খোঁজে (query_then_fetch) প্রতিটি shard নিজের ডকুমেন্ট থেকেই IDF হিসাব করে, তাই ছোট index-এ একই শব্দের স্কোর shard-ভেদে আলাদা হতে পারে; dfs_query_then_fetch আগে সব shard-এর পরিসংখ্যান জোগাড় করে, বাড়তি একটি round trip-এর দামে। Graph ডেটাবেসকে বহু সার্ভারে ভাগ করা (sharding) মৌলিকভাবে কঠিন — যেকোনো ভাগে কিছু relationship দুই সার্ভারের মাঝে পড়বেই, আর সেটি পেরোনো মানে নেটওয়ার্ক hop; ন্যূনতম কাটা সংযোগে graph ভাগ করার সমস্যাটি সাধারণ রূপে NP-hard. Time-series ইঞ্জিনগুলো প্রায়ই ডেটাকে কলাম ধরে রাখে (column store), কারণ একই কলামের পাশাপাশি মান সবচেয়ে ভালো compress হয়।

পরীক্ষা ও ইন্টারভিউতে — এমন প্রশ্ন প্রায়ই আসে:

  • Property graph-এর উপাদান কী কী? — Node, relationship (দিক ও ধরনসহ), label আর property (node ও relationship দুটিতেই key-value).
  • Index-free adjacency কী? — প্রতিটি node সরাসরি নিজের relationship-এর ঠিকানা রাখে, তাই পাশের node-এ যেতে index খুঁজতে হয় না; প্রতি ধাপ মোটামুটি O(1).
  • BM25-এ IDF আর length normalization কেন? — বিরল শব্দকে বেশি ওজন দিতে, আর লম্বা ডকুমেন্টে শব্দের উপস্থিতিকে কম অর্থবহ ধরতে; k1 বারবার থাকার লাভকে সীমিত করে।
  • Search index আর মূল ডেটাবেস কীভাবে মিলিয়ে রাখবেন? — dual write নয়; transactional outbox বা CDC দিয়ে, idempotent worker আর মাঝে মাঝে reconciliation.
  • Downsampling আর retention-এর পার্থক্য? — Downsampling সূক্ষ্ম ডেটা থেকে মোটা সারাংশ বানায়; retention নির্দিষ্ট বয়সের পর ডেটা মুছে ফেলে। সাধারণত আগে downsample, পরে কাঁচা ডেটার retention.

১০ · AI-এর যুগে graph, search আর time-series কোয়েরি

AI টুল এই তিন জগতে দ্রুত, নিখুঁত-দেখতে উত্তর দেয়, কিন্তু এখানে সংস্করণভেদে সিনট্যাক্স বদলেছে অনেক, তাই ভুলের ধরনও নির্দিষ্ট:

AI যা করেআসলে সমস্যা
Recursive CTE-তে চক্রের পাহারা বা depth-সীমা দেয় নাচক্রওয়ালা graph-এ কোয়েরি থামে না, বা dk সারিতে ডুবে যায়
সংস্করণ মিশিয়ে ফেলে — Flux-এর কোড InfluxDB 3-এর জন্য, পুরোনো Cypher ফাংশন নতুন Neo4j-এচলেই না, বা deprecated সতর্কবার্তা; সংস্করণ না বললে AI অনুমান করে
Elasticsearch-এ সব শর্ত must-এ রাখেদাম-ক্যাটাগরির শর্তও স্কোর বদলায়, cache হয় না; সেগুলোর জায়গা filter
keyword আর text গুলিয়ে ফেলেterm কোয়েরি analyzer-এ ভাঙা text ঘরে প্রায়ই কিছুই মেলায় না
Moving average-এ ROWS দেয়ফাঁক থাকলে “শেষ এক ঘণ্টা” আসলে দেড়-দুই ঘণ্টা হয়ে যায়
ঘণ্টার গড়ের সরল গড়কে দিনের গড় বলেমাপের সংখ্যা অসমান হলে ভুল; ভারিত গড় লাগে
Search engine-কে একমাত্র ডেটাবেস বানাতে বলে, বা dual write দেয়কপি অমিল, transaction নেই — outbox/CDC লাগে

প্রশ্ন করার সময় সিস্টেম, সংস্করণ, ডেটার আকার আর যাচাইয়ের দাবি একসাথে দিন:

আমি SQLite 3.49 ব্যবহার করছি। টেবিল: friend(src, dst) — প্রতিটি বন্ধুত্ব দুই দিকে দুটি সারি, graph-এ চক্র আছে, প্রায় ২ লাখ মানুষ, গড়ে ৫০ জন বন্ধু। ১. একজন মানুষ থেকে ৩ ধাপের মধ্যে সবার সবচেয়ে কম hop বের করার recursive CTE লেখো। চক্র কীভাবে আটকাচ্ছ আর depth-সীমা কোথায়, এক লাইনে বলো। ২. depth ৩-এ মোটামুটি কয়টি মধ্যবর্তী সারি তৈরি হতে পারে, হিসাব করে দেখাও। এটা কি বাস্তবে চলবে? না চললে বিকল্প (BFS অ্যাপে, বা graph ডেটাবেস) বলো। ৩. যাচাইয়ের জন্য ৬–৮ জনের একটি ছোট পরীক্ষার ডেটাসেট দাও, যাতে চক্র আর একজন বিচ্ছিন্ন মানুষ আছে, আর প্রত্যাশিত ফল হাতে লিখে দাও। ৪. কোনো ফাংশন বা সিনট্যাক্স SQLite-এ আছে কি না নিশ্চিত না হলে স্পষ্ট করে বলো।

AI-এর লেখা graph, search বা time-series কোয়েরি হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১১৯৬০-৭০-এর দশকের network model-ও তো pointer দিয়ে রেকর্ড জুড়ত, আর Codd সেটির সমালোচনা করেই relational model এনেছিলেন। তাহলে graph ডেটাবেস কি পুরোনো ভুলের ফিরে আসা?

মিলটা সত্যি, আর প্রশ্নটা ন্যায্য। CODASYL-ধাঁচের network model-এ (পর্ব ৪) রেকর্ডগুলো pointer-এর শিকলে বাঁধা থাকত, আর প্রোগ্রামারকে হাতে লিখতে হতো “এই রেকর্ড থেকে এই set ধরে পরের রেকর্ডে যাও” — একে বলে navigational access. Charles Bachman ১৯৭৩ সালের Turing Award বক্তৃতার নামই দিয়েছিলেন “The Programmer as Navigator”. Codd-এর ১৯৭০ সালের পেপারের মূল আপত্তি ছিল এখানেই: ডেটা কীভাবে জমা আছে, প্রোগ্রাম তার ওপর নির্ভর করলে কাঠামো বদলালেই প্রোগ্রাম ভাঙে — data independence হারায় (পর্ব ৩)।

আধুনিক graph ডেটাবেস দুটি জিনিস আলাদা করেছে, যা পুরোনো মডেলে জড়ানো ছিল। Storage-এর স্তরে তারা pointer ব্যবহার করে (index-free adjacency), কারণ তাতে traversal দ্রুত হয়। কিন্তু ভাষার স্তরে Cypher বা GQL ঘোষণামূলক (declarative): আপনি আঁকেন কোন আকারের প্যাটার্ন চান, কোন পথে হাঁটতে হবে তা ঠিক করে query planner — ঠিক SQL-এর optimizer-এর মতো (পর্ব ৪৩)। অর্থাৎ Codd-এর মূল শিক্ষা — “কী চাই” আর “কীভাবে আনব” আলাদা রাখা — গ্রহণ করা হয়েছে; শুধু ভৌত সংগঠনটি সম্পর্ক-কেন্দ্রিক।

তবু কিছু পুরোনো দুর্বলতা ফিরে আসে। Property graph-এ schema প্রায়ই ঢিলেঢালা — কোন label-এর node-এ কোন property থাকবে, তা ডেটাবেস নিজে থেকে প্রায়ই রক্ষা করে না, আর relational-এর normalization-তত্ত্বের মতো পরিণত ডিজাইন-তত্ত্বও নেই। Codd-এর relational algebra-র মতো গাণিতিক ভিত্তিও graph ভাষাগুলোর জন্য তুলনামূলক নতুন; GQL আর SQL/PGQ মানদণ্ড সেই ঘাটতি পূরণের চেষ্টা।

মূল উপলব্ধি: প্রযুক্তির ইতিহাসে ধারণা প্রায়ই ফিরে আসে, কিন্তু আগের শিক্ষা নিয়ে। প্রশ্নটা “pointer ভালো না খারাপ” নয় — pointer কি প্রোগ্রামারের ঘাড়ে, নাকি ইঞ্জিনের ভেতরে লুকানো।

প্র ০২বিশ্বের সবচেয়ে বড় সামাজিক graph-গুলোর একটি — Facebook-এর — চালানো হয়েছে MySQL-এর ওপর। তাহলে graph ডেটাবেস কখন সত্যিই দরকার?

Facebook ২০১৩ সালে USENIX ATC-তে TAO নামের সিস্টেমের বিবরণ প্রকাশ করে: বন্ধুত্ব, লাইক, মন্তব্য — সবকিছু object আর association হিসেবে মডেল করা, কিন্তু স্থায়ীভাবে জমা থাকে বহু ভাগে ভাগ করা MySQL ডেটাবেসে, আর ওপরে বিশাল একটি cache স্তর। প্রধান প্রশ্নগুলো ছিল এক ধাপের: “এই পোস্টের সাম্প্রতিক মন্তব্য”, “এই ব্যবহারকারীর বন্ধুর তালিকা”, “কতজন লাইক দিয়েছে”। এমন প্রশ্ন একটি index খোঁজ — relational ডেটাবেস আর cache এতে অসাধারণ। প্রতি সেকেন্ডে বিপুল পড়ার চাপ সামলাতে cache-ই ছিল নকশার কেন্দ্র, আর একই সাথে পরিচিত, পরীক্ষিত MySQL-এর নির্ভরযোগ্যতা।

শিক্ষাটা হলো: ডেটা graph-আকৃতির হলেই graph ডেটাবেস লাগে না; লাগে যখন প্রশ্নগুলো বহু ধাপের আর অনির্দিষ্ট গভীরতার। ব্যাংক বা মোবাইল ওয়ালেটের জালিয়াতি-বিশ্লেষণে প্রশ্ন হয় “এই অ্যাকাউন্টের সাথে ৪–৫ ধাপে কোন সন্দেহজনক অ্যাকাউন্ট, ডিভাইস বা ঠিকানা যুক্ত?” — এখানে এই পর্বের depth-বিস্ফোরণ সরাসরি সমস্যা। Supply chain-এ “এই কাঁচামাল দেরিতে এলে কোন কোন অর্ডার প্রভাবিত”, IT নেটওয়ার্কে “এই সার্ভার বন্ধ হলে কোন সার্ভিস ভাঙবে”, বা জ্ঞানভান্ডারে (knowledge graph) জটিল সম্পর্ক — এগুলো graph ডেটাবেসের পরিচিত ক্ষেত্র।

আরেকটি বাস্তব প্যাটার্ন: লেনদেনের মূল হিসাব relational-এ, আর একটি কপি (CDC দিয়ে) graph ডেটাবেসে বিশ্লেষণের জন্য। এতে টাকার হিসাবে ACID থাকে, আর তদন্তকারীরা graph-এর সুবিধা পান। খরচ: আরেকটি সিস্টেম, আরেকটি কপি, আর graph-এর ভালো দক্ষতা বাজারে তুলনামূলক কম।

মূল উপলব্ধি: ডেটার আকার নয়, প্রশ্নের আকার সিদ্ধান্ত নেয়। এক ধাপের প্রশ্নে বিশাল graph-ও relational-এ চলে; বহু ধাপের প্রশ্নে ছোট graph-ও SQL-কে কষ্ট দেয়।

প্র ০৩২০২১ সালে Elasticsearch-এর লাইসেন্স বদলানো আর OpenSearch-এর জন্ম — একটি ডেটাবেস প্রযুক্তি বাছাইয়ে লাইসেন্স কেন এত গুরুত্বপূর্ণ?

Elasticsearch শুরু থেকে Apache 2.0 লাইসেন্সে ছিল — যে কেউ ব্যবহার, বদল, এমনকি বিক্রিও করতে পারত। বড় cloud প্রতিষ্ঠানগুলো একে managed সার্ভিস হিসেবে বিক্রি করতে থাকে। Elastic কোম্পানির যুক্তি ছিল, তারা উন্নয়নের খরচ বহন করছে, আর লাভ যাচ্ছে অন্যের কাছে। ২০২১ সালের শুরুতে তারা নতুন সংস্করণ (৭.১১ থেকে) SSPL আর Elastic License-এর অধীনে আনে — এগুলো open source-এর প্রচলিত সংজ্ঞায় পড়ে না বলে অনেকে মনে করেন। জবাবে AWS শেষ Apache-লাইসেন্সের সংস্করণ থেকে শাখা (fork) তৈরি করে, নাম OpenSearch. পরে ২০২৪ সালে Elastic AGPL-কে আরেকটি বিকল্প হিসেবে যোগ করে, আর OpenSearch-এর দায়িত্ব যায় Linux Foundation-এর অধীনের একটি ফাউন্ডেশনে।

ডেটাবেস জগতে এটি একা ঘটনা নয় — MongoDB (SSPL, ২০১৮), Redis (২০২৪) প্রমুখের লাইসেন্স বদল আর পাল্টা fork-ও আলোচিত। একটি প্রতিষ্ঠানের জন্য প্রশ্নগুলো বাস্তব: আমরা কি এই সফটওয়্যার নিজেদের সার্ভিসের অংশ হিসেবে বাইরে দিচ্ছি? ভবিষ্যতে লাইসেন্স বদলালে আমাদের কী করার থাকবে? দুটি fork আলাদা পথে গেলে কোনটির API-তে আমাদের কোড বাঁধা পড়বে?

প্রকৌশলের দিক থেকে সুরক্ষা হলো নির্ভরতা পাতলা রাখা — vendor lock-in কমানো। সার্চের কোড একটি ছোট স্তরের পেছনে রাখুন, source of truth নিজের relational ডেটাবেসে রাখুন, আর index যেন যেকোনো সময় মূল ডেটা থেকে নতুন করে বানানো যায়। তাহলে engine বদলানো কষ্টকর হলেও অসম্ভব হয় না।

মূল উপলব্ধি: প্রযুক্তি বাছাই শুধু গতি আর সুবিধার বিচার নয়; লাইসেন্স, শাসন-কাঠামো (governance) আর বের হওয়ার পথও নকশার অংশ।

প্র ০৪PostgreSQL-এ সময় ধরে partition আর BRIN index দিয়েই যদি time-series চলে, তাহলে আলাদা time-series ডেটাবেসের এত বাজার কেন?

ছোট থেকে মাঝারি মাপে সত্যিই চলে, আর অনেক দলের জন্য সেটিই সেরা বাছাই। কিন্তু মাপ বাড়লে চারটি চাপ জমে। প্রথম, লেখার হার: প্রতি সেকেন্ডে লাখো মাপ এলে সাধারণ B-tree index-এর প্রতিটি নতুন সারি index হালনাগাদ করে, WAL লেখে, আর MVCC-র বাড়তি তথ্য রাখে (পর্ব ৪৭) — প্রতি সারিতে কয়েক ডজন byte-এর বাড়তি খরচ। দ্বিতীয়, জায়গা: সারিভিত্তিক storage-এ পরপর মাপের মিল কাজে লাগে না; কলামভিত্তিক delta ও XOR compression (Gorilla পেপারের ধারা) একই ডেটাকে কয়েক গুণ ছোট করে। তৃতীয়, রক্ষণাবেক্ষণ: retention মানে বিশাল DELETE আর VACUUM, যদি না পুরো partition একবারে ফেলা হয়; downsampling মানে নিজের লেখা cron job. চতুর্থ, প্রশ্নের ভাষা: gap-fill, rate, “শেষ মান” — এগুলো সাধারণ SQL-এ লেখা যায়, কিন্তু কষ্টে।

দুটি দর্শন দাঁড়িয়েছে। TimescaleDB বলেছে: PostgreSQL-ই রাখা, ভেতরে hypertable, compression আর continuous aggregate যোগ করা — SQL, JOIN আর পরিচিত টুল অক্ষুণ্ণ। InfluxDB আর Prometheus বলেছে: শুরু থেকে নিজস্ব ইঞ্জিন বানানো, শুধু এই কাজের জন্য। Prometheus আরও এক ধাপ এগিয়ে metric সংগ্রহ (scrape), সংরক্ষণ, প্রশ্ন আর alert — সব এক প্যাকেজে দিয়েছে, যা অবকাঠামো পর্যবেক্ষণে তাকে প্রায় মানদণ্ড বানিয়েছে।

একটি বিপদ সব time-series সিস্টেমে এক: series-এর cardinality. প্রতিটি আলাদা tag/label-সমন্বয় একটি আলাদা series, আর অনেক ইঞ্জিন প্রতিটি series-এর জন্য মেমরিতে index রাখে। label-এ ব্যবহারকারীর আইডি বসানোর মতো একটি ভুলে সিস্টেম বসে পড়তে পারে।

মূল উপলব্ধি: বিশেষ ডেটাবেসের মূল্য প্রায়ই একটি অলৌকিক অ্যালগরিদমে নয়, বরং বহু ছোট সুবিধার যোগফলে — compression, স্বয়ংক্রিয় নীতি, উপযুক্ত ভাষা। নিজের মাপ জেনে তবে সেই দাম দিন।

প্র ০৫অনেকে Elasticsearch-কেই প্রধান ডেটাবেস বানিয়ে ফেলেন — সব ডেটা সেখানে, আর কিছু নেই। এতে ঝুঁকি কোথায়?

লোভটা বোঝা যায়: সব ডকুমেন্ট এক জায়গায়, খোঁজ দ্রুত, JSON-এ সহজ। কিন্তু search engine-এর নকশার অগ্রাধিকার আলাদা। এতে একাধিক ডকুমেন্ট জুড়ে transaction নেই — “স্টক এক কমাও আর অর্ডার বানাও” দুটি আলাদা লেখা, মাঝে ব্যর্থ হলে অর্ধেক কাজ থেকে যায়। Foreign key বা UNIQUE-এর মতো constraint নেই। Near-real-time মানে লেখার পরপরই পড়লে নতুন মান না-ও দেখা যেতে পারে। আর mapping বদলাতে প্রায়ই পুরো index নতুন করে বানাতে হয় — সেটি করতে লাগে মূল ডেটা, যা তখন আর অন্য কোথাও নেই।

ঐতিহাসিকভাবে Elasticsearch-এর বিতরণ-ব্যবস্থার নির্ভরযোগ্যতা নিয়ে প্রশ্নও উঠেছে: Kyle Kingsbury-র Jepsen পরীক্ষায় (২০১৪-১৫) নেটওয়ার্ক বিভাজনের সময় নিশ্চিত হওয়া লেখা হারানোর ঘটনা দেখা গিয়েছিল। পরের সংস্করণগুলোতে cluster coordination অনেক বদলেছে আর উন্নত হয়েছে, কিন্তু শিক্ষাটি টিকে আছে — যে সিস্টেম মূলত খোঁজার জন্য, তার কাছে টাকার হিসাবের নিশ্চয়তা আশা করবেন না।

তাই প্রচলিত নকশা: relational ডেটাবেস source of truth; outbox বা CDC দিয়ে search index হালনাগাদ; সার্চের ফলের id দিয়ে দাম-স্টক মূল ডেটাবেস থেকে আবার মিলিয়ে দেখানো; আর index যেকোনো সময় মুছে আবার বানানো যায় — এটি নিয়মিত অনুশীলন করা। Log বিশ্লেষণের মতো ক্ষেত্রে, যেখানে কিছু লাইন হারালে বড় ক্ষতি নেই আর ডেটা শুধু যোগ হয়, সেখানে Elasticsearch-কে একমাত্র ভান্ডার রাখা বহু দল সচেতনভাবেই করে।

মূল উপলব্ধি: প্রতিটি সিস্টেমকে জিজ্ঞেস করুন — “এর সব ডেটা হারালে আমি কি আবার বানাতে পারব?” Search index-এর উত্তর “হ্যাঁ” হওয়া উচিত; source of truth-এর উত্তর হতে হবে “এটা হারাবেই না”।

অনুশীলন

প্রথমটি SQL লেখার কাজ — অংশ ৪-এর প্রথম দুটি এডিটরের যেকোনোটিতে লিখে চালাতে পারেন। বাকিগুলো হাতে-কলমে ভাবার।

  1. কোয়েরি লিখুন: friend(src, dst) টেবিলে (প্রতিটি বন্ধুত্ব দুই দিকে) Rahim (id 1) আর Nusrat (id 4)-এর পারস্পরিক বন্ধুদের নাম বের করুন। তারপর ভাবুন: Cypher-এ একই প্রশ্ন কেমন হবে?
    SELECT p.name
    FROM person p
    WHERE p.id IN (SELECT dst FROM friend WHERE src = 1
                   INTERSECT
                   SELECT dst FROM friend WHERE src = 4)
    ORDER BY p.name;

    ফল: Karima, Sabbir. Rahim-এর বন্ধু {Karima, Sabbir}, Nusrat-এর বন্ধু {Karima, Sabbir, Farhana} — intersection দুজন (পর্ব ২২)। বিকল্প: friend f1 JOIN friend f2 ON f1.dst = f2.dst WHERE f1.src = 1 AND f2.src = 4. Cypher-এ প্যাটার্নটিই উত্তর: MATCH (:Person {name: 'Rahim'})-[:FRIEND]-(m)-[:FRIEND]-(:Person {name: 'Nusrat'}) RETURN m.name.

  2. ফল আগে বলুন: একটি সেন্সরের মাপের সময় 10:00, 10:10, 10:40, 10:50, 11:30 (প্রতি ১০ মিনিটে মাপ নেওয়ার কথা)। (ক) LAG দিয়ে ১০ মিনিটের বেশি ব্যবধান খোঁজার কোয়েরি কোন কোন সারি দেবে, gap_min আর missing সহ? (খ) ঘণ্টাভিত্তিক GROUP BY-তে প্রতিটি bucket-এর n কত? (গ) 10:50-এ ROWS BETWEEN 2 PRECEDING AND CURRENT ROW কোন কোন মাপ নেবে, আর RANGE BETWEEN 1200 PRECEDING (সময় epoch সেকেন্ডে)?

    (ক) দুটি সারি: 10:10 → 10:40 (gap_min 30, missing 2: 10:20, 10:30) আর 10:50 → 11:30 (gap_min 40, missing 3: 11:00, 11:10, 11:20). প্রথম সারি 10:00-এর LAG হলো NULL, তাই শর্তে বাদ। (খ) 10:00 → n = 4 (10:00, 10:10, 10:40, 10:50); 11:00 → n = 1 (11:30). 11:00-এর গড় একটি মাত্র মাপে — n না দেখালে বিভ্রান্তিকর। (গ) ROWS নেয় শেষ তিনটি সারি: 10:10, 10:40, 10:50 — অর্থাৎ ৪০ মিনিটের পরিসর। RANGE 1200 সেকেন্ড = ২০ মিনিট: 10:30 থেকে 10:50 — শুধু 10:40 আর 10:50. ফাঁক থাকলে দুটির উত্তর আলাদা, যেমন অংশ ৮-এ দেখেছেন।

  3. সঠিক ডেটাবেস বাছুন: প্রতিটির জন্য একটি পরিবার (relational, graph, search, time-series) বলুন, এক লাইনের কারণসহ। (ক) একটি মোবাইল ওয়ালেটের প্রতিটি লেনদেনের মূল হিসাব। (খ) অনলাইন বইয়ের দোকানে “humayun ahmed” লিখলে বানান একটু ভুল হলেও বই খুঁজে দেওয়া। (গ) একটি পোশাক কারখানার ৫০০ মেশিনের প্রতি সেকেন্ডের তাপমাত্রা, ৯০ দিন রাখা। (ঘ) একটি সন্দেহজনক অ্যাকাউন্টের সাথে ৪ ধাপের মধ্যে যুক্ত সব অ্যাকাউন্ট, ডিভাইস আর ঠিকানা। (ঙ) দোকানের সার্ভারের CPU আর latency, সীমা পেরোলে সতর্কবার্তা।

    (ক) Relational — ACID transaction, constraint আর audit অপরিহার্য; এটিই source of truth. (খ) Search — fuzzy খোঁজ আর প্রাসঙ্গিকতার ক্রম; ছোট সংগ্রহে PostgreSQL-এর pg_trgm-ও কাজ করতে পারে। (গ) Time-series — ৫০০ × ৮৬,৪০০ = দিনে ৪ কোটি ৩২ লাখ মাপ, ৯০ দিনে প্রায় ৩৯০ কোটি; compression, chunk-ভিত্তিক retention আর downsampling দরকার। (ঘ) Graph — অনির্দিষ্ট বহু ধাপ; মূল লেনদেন relational-এ রেখে CDC দিয়ে graph-এ কপি। (ঙ) Time-series, বিশেষভাবে Prometheus — metric সংগ্রহ, PromQL আর alert এক জায়গায়।

  4. হিসাব করে দেখুন — cardinality: একটি শহরে ২০০টি বায়ুমান সেন্সর, প্রতিটি প্রতি ১০ সেকেন্ডে ৪টি metric পাঠায় (pm25, pm10, temp, humidity), আর tag হিসেবে আছে sensor_id আর firmware (৩টি সংস্করণ, কিন্তু একটি সেন্সরে একসময়ে একটিই)। (ক) মোট series কত? (খ) কেউ প্রস্তাব দিল প্রতিটি মাপে reading_id (প্রতিবার নতুন) tag হিসেবে রাখতে — কী হবে? (গ) সঠিক জায়গা কোথায়?

    (ক) প্রতিটি সেন্সরে firmware একটিই, তাই series = ২০০ × ৪ = ৮০০ (firmware বদলালে পুরোনো আর নতুন মিলিয়ে সাময়িকভাবে কিছু বেশি)। tag-গুলো স্বাধীন হলে গুণফল নিতে হতো, কিন্তু এখানে firmware sensor-এর ওপর নির্ভরশীল — একটি functional dependency-র মতো (পর্ব ৩৫)। (খ) প্রতিটি মাপ একটি নতুন series হবে — প্রতি ১০ সেকেন্ডে ৮০০টি নতুন series, দিনে প্রায় ৭০ লাখ; series-এর index মেমরি খেয়ে ফেলবে, আর compression-এর সুবিধাও (একই series-এর পরপর মান) হারাবে। (গ) অনন্য বা বহুমানের তথ্য field হিসেবে রাখুন (index হয় না), অথবা আদৌ time-series-এ নয় — আলাদা relational টেবিলে। নিয়ম: tag = যা দিয়ে ভাগ করে প্রশ্ন করবেন আর যার মান সীমিত।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমTransactional outbox দিয়ে একটি পণ্যের নাম বদল search engine পর্যন্ত পৌঁছানোর ধাপগুলো সাজান (▲▼ চেপে সরান)

  1. অ্যাপ একই transaction-এ products টেবিল বদলায় আর outbox-এ একটি event লেখে
  2. Transaction commit হয় — দুটি পরিবর্তনই একসাথে স্থায়ী
  3. Worker outbox থেকে নতুন event পড়ে
  4. Worker পণ্যের নতুন ডকুমেন্ট search engine-এর index API-তে পাঠায়
  5. পরের refresh-এর পর ডকুমেন্টটি খোঁজে দেখা যায়
  6. Worker event-টিকে সম্পন্ন চিহ্নিত করে বা মুছে দেয়

Native graph ডেটাবেসে “index-free adjacency”-র মূল অর্থ কোনটি?

  • Graph ডেটাবেসে কোনো index-ই থাকে না
  • প্রতিটি node সরাসরি তার relationship-এর ঠিকানা রাখে, তাই পাশের node-এ যেতে index খুঁজতে হয় না
  • প্রতিটি ধাপে সংযোগ-টেবিলের B+ tree index খোঁজা হয়
  • পুরো graph একটি adjacency matrix হিসেবে মেমরিতে থাকে

Cypher-এ MATCH (a:Person {name: 'Rahim'})-[:FRIEND*1..3]-(x) RETURN DISTINCT x.name কী ফেরত দেয়?

  • শুধু ঠিক ৩ ধাপ দূরের মানুষ
  • Rahim-এর সাথে যেকোনো দূরত্বে যুক্ত সবাই
  • ১ থেকে ৩টি FRIEND সম্পর্কের শিকলে পৌঁছানো যায় এমন সবাই, প্রত্যেকে একবার
  • Rahim-এর বন্ধুর সংখ্যা, সর্বোচ্চ ৩ পর্যন্ত

একটি search engine-এ BM25 স্কোরিংয়ে একটি শব্দ একটি ডকুমেন্টে ১ বারের জায়গায় ৪ বার থাকলে (দৈর্ঘ্য একই) স্কোর কেমন বদলায়?

  • বাড়ে, কিন্তু চার গুণের অনেক কম — tf-এর প্রভাব সম্পৃক্ত (saturate) হয়
  • ঠিক চার গুণ হয়
  • একটুও বদলায় না, কারণ BM25 শুধু IDF দেখে
  • কমে যায়, কারণ BM25 স্প্যাম শাস্তি দেয়

Elasticsearch-এ একটি পণ্য index করার সাথে সাথে খোঁজ চালালে সেটি না-ও আসতে পারে, কিন্তু প্রায় এক সেকেন্ড পরে আসে। এর কারণ কী?

  • ডকুমেন্টটি আগে সব replica-তে কপি হতে হয়
  • BM25 স্কোর আগে থেকে হিসাব করতে সময় লাগে
  • প্রতিটি নতুন ডকুমেন্টে shard-এর সংখ্যা নতুন করে ঠিক হয়
  • নতুন লেখা refresh-এ নতুন segment তৈরি হলে তবেই খোঁজে আসে (near-real-time)

একটি সেন্সর প্রতি ১০ মিনিটে মাপ পাঠায়, কিন্তু মাঝে ৪০ মিনিট বন্ধ ছিল। “শেষ এক ঘণ্টার গড়” সঠিকভাবে পেতে SQL window-এর কোন frame ঠিক?

  • ROWS BETWEEN 5 PRECEDING AND CURRENT ROW
  • ORDER BY epoch সেকেন্ড দিয়ে RANGE BETWEEN 3000 PRECEDING AND CURRENT ROW
  • ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  • window নয়, GROUP BY strftime('%H', ts)

একটি time-series সিস্টেমে প্রতি ১০ সেকেন্ডের কাঁচা মাপ ৭ দিন রাখা হয়, তার আগে ঘণ্টার গড়-সর্বোচ্চ আলাদা টেবিলে জমিয়ে কাঁচা ডেটা মুছে ফেলা হয়। এই দুই কাজের নাম কী?

  • Sharding আর replication
  • Compression আর normalization
  • Downsampling (rollup) আর retention
  • Indexing আর vacuum

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Property graph = node, relationship, label, property; index-free adjacency-তে প্রতি ধাপ ≈ O(1). Cypher-এ [:R*1..3] আর shortestPath; মানদণ্ড GQL (২০২৪) আর SQL/PGQ (SQL:2023).
  2. SQL-এ graph = edges টেবিল + recursive CTE, চক্রের পাহারা আর depth-সীমাসহ। এটি সব পথ গোনে — dk; BFS গোনে node — O(V + E).
  3. Search engine = analyzer → inverted index → BM25 (IDF, saturation, length normalization); shard ও replica, near-real-time refresh. ফিল্টার filter-এ, স্কোর must-এ।
  4. Search index একটি কপি: dual write নয়, outbox বা CDC; idempotent worker আর reconciliation. Source of truth relational-এ।
  5. Time-series: append-only, সময়-খণ্ড, compression; downsampling আগে, retention পরে; সময়ের window সময় দিয়ে (RANGE), গড়ের গড় ভারিত, label-এ cardinality-র সাবধানতা।

পরের পর্বে: এই পর্বের সার্চ মিলিয়েছে শব্দ। কিন্তু কেউ যদি লেখেন “গরমে আরামের পোশাক”, আর পণ্যের বিবরণে আছে “soft cotton panjabi for summer” — একটি শব্দও মেলে না, অথচ অর্থ মেলে। পরের পর্বে vector database: লেখাকে সংখ্যার ভেক্টরে রূপান্তর করে অর্থ দিয়ে খোঁজা — AI যুগের খোঁজ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান