惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Threatpost
aimingoo的专栏
aimingoo的专栏
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
Tailwind CSS Blog
J
Java Code Geeks
博客园_首页
Google Online Security Blog
Google Online Security Blog
Hugging Face - Blog
Hugging Face - Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
I
Intezer
P
Palo Alto Networks Blog
V
Vulnerabilities – Threatpost
雷峰网
雷峰网
O
OpenAI News
SecWiki News
SecWiki News
小众软件
小众软件
酷 壳 – CoolShell
酷 壳 – CoolShell
美团技术团队
N
News | PayPal Newsroom
Project Zero
Project Zero
Forbes - Security
Forbes - Security
IT之家
IT之家
A
Arctic Wolf
WordPress大学
WordPress大学
Jina AI
Jina AI
T
Tor Project blog
博客园 - 三生石上(FineUI控件)
S
Secure Thoughts
Google DeepMind News
Google DeepMind News
Attack and Defense Labs
Attack and Defense Labs
博客园 - 聂微东
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
P
Privacy International News Feed
Cloudbric
Cloudbric
G
GRAHAM CLULEY
博客园 - 叶小钗
H
Hacker News: Front Page
腾讯CDC
量子位
Help Net Security
Help Net Security
人人都是产品经理
人人都是产品经理
C
Cyber Attacks, Cyber Crime and Cyber Security
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
宝玉的分享
宝玉的分享
爱范儿
爱范儿
L
Lohrmann on Cybersecurity
Hacker News - Newest:
Hacker News - Newest: "LLM"
Recorded Future
Recorded Future
C
CERT Recently Published Vulnerability Notes

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Building Typo-Tolerant Multi-Language Video Search with OpenSearch and PHP
ahmet gedik · 2026-06-03 · via DEV Community

When you run a video aggregator that pulls trending content from nine Asia-Pacific regions every four hours, search becomes the place where users either find what they want or leave for YouTube directly. At TopVideoHub we aggregate clips across Japanese, Korean, Mandarin, Vietnamese, Thai, and English titles in the same index, and our original SQLite FTS5 setup with a CJK tokenizer worked well for exact substring matches but collapsed the moment a user typed blakpink instead of blackpink, or searched for Aimyon when our database stored あいみょん.

This post walks through the migration we did from SQLite FTS5 to OpenSearch for typo-tolerant search, while keeping FTS5 as a fallback. Running on a budget LiteSpeed host means we could not just throw a five-node Elasticsearch cluster at the problem — we had to be careful about heap pressure, indexing latency, and how Cloudflare caches the search responses.

Why FTS5 was not enough for fuzzy CJK queries

SQLite FTS5 with the unicode61 tokenizer plus a CJK bigram tokenizer handles tokenization across scripts well enough. Our migration looked like this:

CREATE VIRTUAL TABLE videos_fts USING fts5(
  title,
  channel_name,
  description,
  tokenize = 'unicode61 remove_diacritics 2',
  content = 'videos',
  content_rowid = 'id'
);

Enter fullscreen mode Exit fullscreen mode

This gave us fast prefix and substring matching. The problem is that FTS5 does not implement fuzziness in the Damerau-Levenshtein sense. If a user searched for blakpink, FTS5 returned zero results because the trigram split (bla, lak, akp, kpi, pin, ink) shared almost no overlap with the indexed blackpink tokens.

We tried compensating with edge n-grams stored as a denormalized column, then with manual misspelling synonym dictionaries. Both approaches worked for narrow cases but exploded index size and required hand-maintained mappings for every new artist or trending phrase. For CJK content, n-grams of length 1-2 produced massive recall but garbage precision — searching for returned 40,000 plus videos because the character appears in roughly every fourth Chinese title.

The decision point was clear. We needed an engine that supports proper fuzziness with edit distance, language-aware analyzers, and the ability to weight matches across multiple fields. OpenSearch fit the bill, especially because we could run a single-node deployment on a small VPS we already had provisioned for blog ingestion. Self-hosted, no managed-service bill, and the OpenSearch 2.x line ships with the analyzers we needed out of the box.

Index design for multi-language content

The first real engineering decision was the index mapping. CJK languages do not use whitespace as a token boundary, so the standard analyzer is useless for Japanese, Chinese, and Korean. OpenSearch ships with the ICU analysis plugin, and there are well-maintained kuromoji (Japanese), nori (Korean), and smartcn (Chinese) plugins.

Rather than maintain one analyzer per language and a router on the application side, we used the multi-fields pattern. The same source text is indexed under several analyzers and we search them with multi_match. The query engine picks the best match per shard, and we score-weight them on the application side.

Here is the mapping we settled on after about two weeks of tuning:

{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "analysis": {
      "analyzer": {
        "title_standard": {
          "type": "custom",
          "tokenizer": "icu_tokenizer",
          "filter": ["icu_folding", "lowercase"]
        },
        "title_edge_ngram": {
          "type": "custom",
          "tokenizer": "icu_tokenizer",
          "filter": ["icu_folding", "lowercase", "edge_ngram_filter"]
        },
        "title_cjk_bigram": {
          "type": "custom",
          "tokenizer": "icu_tokenizer",
          "filter": ["cjk_bigram", "lowercase"]
        }
      },
      "filter": {
        "edge_ngram_filter": {
          "type": "edge_ngram",
          "min_gram": 2,
          "max_gram": 15
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "video_id": { "type": "keyword" },
      "title": {
        "type": "text",
        "analyzer": "title_standard",
        "fields": {
          "edge": {
            "type": "text",
            "analyzer": "title_edge_ngram",
            "search_analyzer": "title_standard"
          },
          "cjk": {
            "type": "text",
            "analyzer": "title_cjk_bigram"
          },
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "channel_name": { "type": "text", "analyzer": "title_standard" },
      "region":       { "type": "keyword" },
      "category_id":  { "type": "integer" },
      "published_at": { "type": "date" },
      "view_count":   { "type": "long" },
      "duration_seconds": { "type": "integer" }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

A few design choices worth explaining:

  • We dropped number_of_replicas to zero because a single-node deployment cannot replicate, and the default of one leaves the cluster in yellow status forever and breaks health-check scripts.
  • icu_folding handles diacritic stripping for Vietnamese (hóa becomes hoa) and width normalization for fullwidth Latin (HELLO becomes hello).
  • The cjk_bigram filter splits CJK characters into overlapping pairs, so 日本語 indexes as 日本, 本語. This is the standard approach when you do not want to ship and version a heavy morphological dictionary.
  • search_analyzer differs from analyzer on the edge subfield. We index with edge n-grams but search without them. Otherwise every search term would also explode into n-grams on the query side, and the relevance scoring would be unusable.

Indexing pipeline from PHP 8.4

Our existing fetch cron pulls from the YouTube Data API every 2-7 hours depending on the site, normalizes the payload, and writes to SQLite. The OpenSearch indexing hooks into the same path as a fire-and-forget bulk step at the end of each fetch cycle. If OpenSearch is unavailable, we log and move on — SQLite remains the source of truth.

<?php

declare(strict_types=1);

namespace App\Search;

final class OpenSearchIndexer
{
    private const ENDPOINT = 'http://127.0.0.1:9200';
    private const INDEX = 'videos_v3';
    private const BATCH_SIZE = 200;

    public function __construct(
        private readonly \PDO $db,
        private readonly \App\Logger $log,
    ) {}

    public function indexBatch(array $videoIds): int
    {
        if ($videoIds === []) {
            return 0;
        }

        $placeholders = implode(',', array_fill(0, count($videoIds), '?'));
        $stmt = $this->db->prepare(
            "SELECT id, video_id, title, channel_name, region,
                    category_id, published_at, view_count, duration_seconds
             FROM videos WHERE id IN ($placeholders)"
        );
        $stmt->execute($videoIds);

        $bulk = '';
        $count = 0;
        while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
            $bulk .= json_encode([
                'index' => [
                    '_index' => self::INDEX,
                    '_id'    => $row['video_id'],
                ],
            ], JSON_THROW_ON_ERROR) . "\n";

            $bulk .= json_encode([
                'video_id'         => $row['video_id'],
                'title'            => $row['title'],
                'channel_name'     => $row['channel_name'],
                'region'           => $row['region'],
                'category_id'      => (int)$row['category_id'],
                'published_at'     => $row['published_at'],
                'view_count'       => (int)$row['view_count'],
                'duration_seconds' => (int)$row['duration_seconds'],
            ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) . "\n";

            $count++;
        }

        $response = $this->postBulk($bulk);
        if ($response === null) {
            $this->log->warn('opensearch.bulk.failed', ['count' => $count]);
            return 0;
        }

        if (!empty($response['errors'])) {
            $this->logBulkErrors($response['items']);
        }

        return $count;
    }

    private function postBulk(string $payload): ?array
    {
        $ch = curl_init(self::ENDPOINT . '/_bulk');
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $payload,
            CURLOPT_HTTPHEADER     => ['Content-Type: application/x-ndjson'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 8,
            CURLOPT_CONNECTTIMEOUT => 2,
        ]);
        $body = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($code !== 200 || $body === false) {
            return null;
        }

        return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    }

    private function logBulkErrors(array $items): void
    {
        $failures = [];
        foreach ($items as $item) {
            $op = $item['index'] ?? $item['create'] ?? null;
            if ($op === null || ($op['status'] ?? 200) < 400) {
                continue;
            }
            $failures[] = [
                'id'     => $op['_id'] ?? null,
                'status' => $op['status'],
                'error'  => $op['error']['reason'] ?? 'unknown',
            ];
        }
        if ($failures !== []) {
            $this->log->warn('opensearch.bulk.items_failed', $failures);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

The eight-second timeout matters. PHP-FPM under LiteSpeed has a 180-second ceiling, but if OpenSearch is overloaded we want to surface failures fast, not hold the worker. We batch in 200-document chunks because smaller batches give us better backpressure when JVM heap is tight, and the bulk endpoint copes well at this size.

One thing we learned the hard way: do not use the external versioning mode unless you have a real monotonic version. We tried using published_at as the version field, but YouTube backfills publishedAt sometimes when a video gets re-uploaded under the same ID, and the new value was occasionally smaller than the old one. Documents stopped updating silently. Default internal versioning is the safe choice for a feed-style index.

Building the search query

The interesting part is the query side. A user types something — possibly with typos, possibly in CJK, possibly mixed (BTS dynamite live). We want fuzzy matching for Latin terms, exact bigram matching for CJK, and edge n-gram matching for prefix-as-you-type behavior. All three need to score together so the best result wins.

We use a single multi_match with best_fields type, plus a fuzziness parameter that only kicks in for Latin scripts.

<?php

declare(strict_types=1);

namespace App\Search;

final class OpenSearchQuery
{
    private const ENDPOINT = 'http://127.0.0.1:9200';
    private const INDEX = 'videos_v3';

    public function search(string $term, int $limit = 24, ?string $region = null): array
    {
        $term = trim($term);
        if ($term === '') {
            return [];
        }

        $isCjk = preg_match('/[\x{3000}-\x{9FFF}\x{AC00}-\x{D7AF}]/u', $term) === 1;
        $fuzziness = $isCjk ? '0' : 'AUTO:4,7';

        $query = [
            'size' => $limit,
            'query' => [
                'function_score' => [
                    'query' => [
                        'bool' => [
                            'should' => [
                                [
                                    'multi_match' => [
                                        'query'          => $term,
                                        'fields'         => ['title^3', 'channel_name^1.5'],
                                        'type'           => 'best_fields',
                                        'fuzziness'      => $fuzziness,
                                        'prefix_length'  => 1,
                                        'max_expansions' => 30,
                                    ],
                                ],
                                [
                                    'match' => [
                                        'title.edge' => [
                                            'query' => $term,
                                            'boost' => 2.0,
                                        ],
                                    ],
                                ],
                                [
                                    'match' => [
                                        'title.cjk' => [
                                            'query' => $term,
                                            'boost' => $isCjk ? 4.0 : 0.5,
                                        ],
                                    ],
                                ],
                            ],
                            'minimum_should_match' => 1,
                            'filter' => $this->buildFilters($region),
                        ],
                    ],
                    'functions' => [
                        [
                            'gauss' => [
                                'published_at' => [
                                    'origin' => 'now',
                                    'scale'  => '30d',
                                    'decay'  => 0.5,
                                ],
                            ],
                        ],
                        [
                            'field_value_factor' => [
                                'field'    => 'view_count',
                                'modifier' => 'log1p',
                                'factor'   => 0.2,
                                'missing'  => 0,
                            ],
                        ],
                    ],
                    'score_mode' => 'sum',
                    'boost_mode' => 'multiply',
                ],
            ],
        ];

        return $this->execute($query);
    }

    private function buildFilters(?string $region): array
    {
        $filters = [
            ['range' => ['duration_seconds' => ['gte' => 30, 'lte' => 1800]]],
        ];
        if ($region !== null) {
            $filters[] = ['term' => ['region' => $region]];
        }
        return $filters;
    }

    private function execute(array $query): array
    {
        $ch = curl_init(self::ENDPOINT . '/' . self::INDEX . '/_search');
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => json_encode($query, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
            CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 2,
            CURLOPT_CONNECTTIMEOUT => 1,
        ]);
        $body = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($code !== 200 || $body === false) {
            throw new SearchUnavailable('OpenSearch returned ' . $code);
        }

        $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        return array_map(
            fn(array $hit) => ['id' => $hit['_id'], 'score' => $hit['_score']] + $hit['_source'],
            $decoded['hits']['hits'] ?? []
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

The key decisions here:

  • fuzziness: AUTO:4,7 means terms of length 1-3 match exactly, length 4-6 allow one edit, length 7 and up allow two edits. For CJK we disable fuzziness entirely because edit distance on logographic characters returns nonsense — 日本 and 本日 are one transposition apart but mean different things.
  • prefix_length: 1 forces the first character to match, which dramatically reduces the candidate set for short queries that would otherwise expand into thousands of fuzzy matches.
  • max_expansions: 30 caps how many terms each fuzzy query can expand to, protecting us from a pathological single-letter search melting the heap.
  • function_score decays results by publish date with a 30-day Gaussian half-life and boosts by view count (log1p keeps mega-viral videos from completely dominating).
  • The CJK subfield is boosted to 4.0 for CJK queries and 0.5 otherwise. This stops k-pop from accidentally matching every Korean clip through romanization noise.
  • The filter on duration_seconds excludes Shorts (under 30s) and full episodes (over 30min), which is a TopVideoHub-specific UX choice.

Circuit breaker and FTS5 fallback

OpenSearch processes crash. The JVM does long GC pauses. The VPS reboots. In a search UX, going from fuzzy results to no results because the daemon died for 90 seconds is unacceptable. We wrapped the OpenSearch query in a circuit breaker that falls back to SQLite FTS5 on repeated failure.

The pattern is straightforward, but state has to be process-local because we are on PHP — no shared memory unless we want to involve APCu or Redis. A tiny JSON file in /tmp is enough because LiteSpeed pins the PHP worker pool to a single host.

<?php

declare(strict_types=1);

namespace App\Search;

final class SearchService
{
    private const CIRCUIT_FILE = '/tmp/opensearch_circuit.json';
    private const FAILURE_THRESHOLD = 3;
    private const COOLDOWN_SECONDS = 60;

    public function __construct(
        private readonly OpenSearchQuery $primary,
        private readonly Fts5Search $fallback,
        private readonly \App\Logger $log,
    ) {}

    public function search(string $term, int $limit = 24, ?string $region = null): array
    {
        if ($this->circuitOpen()) {
            return $this->fallback->search($term, $limit, $region);
        }

        try {
            $results = $this->primary->search($term, $limit, $region);
            $this->recordSuccess();
            return $results;
        } catch (SearchUnavailable $e) {
            $this->recordFailure();
            $this->log->warn('search.fallback', ['error' => $e->getMessage()]);
            return $this->fallback->search($term, $limit, $region);
        }
    }

    private function circuitOpen(): bool
    {
        $state = $this->readState();
        if ($state['failures'] < self::FAILURE_THRESHOLD) {
            return false;
        }
        return (time() - $state['last_failure']) < self::COOLDOWN_SECONDS;
    }

    private function recordSuccess(): void
    {
        $this->writeState(['failures' => 0, 'last_failure' => 0]);
    }

    private function recordFailure(): void
    {
        $state = $this->readState();
        $this->writeState([
            'failures'     => $state['failures'] + 1,
            'last_failure' => time(),
        ]);
    }

    private function readState(): array
    {
        if (!is_file(self::CIRCUIT_FILE)) {
            return ['failures' => 0, 'last_failure' => 0];
        }
        $raw = @file_get_contents(self::CIRCUIT_FILE);
        if ($raw === false) {
            return ['failures' => 0, 'last_failure' => 0];
        }
        return json_decode($raw, true) ?: ['failures' => 0, 'last_failure' => 0];
    }

    private function writeState(array $state): void
    {
        @file_put_contents(self::CIRCUIT_FILE, json_encode($state), LOCK_EX);
    }
}

Enter fullscreen mode Exit fullscreen mode

After three consecutive failures we stop hitting OpenSearch for 60 seconds and route everything to FTS5. After the cooldown we let one request through; if it succeeds, the counter resets. The fallback FTS5 path returns visibly fewer results for typo-heavy queries, but it always returns something, which is the important property.

Cloudflare and LiteSpeed caching of search responses

The search endpoint gets hammered — roughly 8% of pageviews. We could not let every search query reach origin. But search results are user-typed input, so caching the wrong response is a real risk if Vary is wrong.

The approach was three layers:

  • Set Cache-Control: public, max-age=600, stale-while-revalidate=1800 on search responses with a non-empty query string.
  • Cache key includes the normalized query (lowercased, trimmed, NFKC) plus the region cookie.
  • LiteSpeed <IfModule LiteSpeed> block adds CacheLookup public on for /search, so LSCache kicks in before PHP even boots.

Cloudflare Free does not cache HTML by default. We added a Cache Rule for URI Path starts with "/search" to set Cache Level to Cache Everything with TTL 600. Combined with Vary: Cookie scoped to only the region cookie, this works without leaking sessions across users.

The gotcha was personalization: logged-in users see watch-later markers on result cards, and those would poison the cache. We solved this by rendering the user-specific overlay client-side from a tiny /api/watchlater.json endpoint that the cache layer skips. Nothing about the cached HTML body is user-specific.

Numbers that mattered

After two weeks of running OpenSearch alongside FTS5 with traffic split 90/10 to OpenSearch, the results were:

  • Search result CTR went from 31% to 48%, mostly driven by previously-empty result pages now returning content.
  • Median search latency: SQLite FTS5 14ms, OpenSearch 22ms, with the Cloudflare layer absorbing roughly 73% of traffic at near-zero ms.
  • Zero-result rate dropped from 19% to 4%. The remaining 4% is genuinely unindexable input (single characters, pasted URLs).
  • OpenSearch steady-state RAM: 1.4 GB heap, 600 MB OS cache. Indexing about 12,000 new videos per day across nine regions.

The fuzzy queries that drove the most improvement, in rough order of impact:

  • Misspellings of artist names (blakpink, newgens, itzy variants).
  • Diacritic-stripped Vietnamese (hoa minzy matching Hòa Minzy).
  • Hyphen and space normalization (k pop matching k-pop, bts v matching BTS-V).
  • Romanized CJK once we added a romaji subfield (aimyon matching あいみょん), which is a separate post.

Conclusion

OpenSearch was the right tool for the typo-tolerance problem, but the migration was as much about the surrounding pieces as the engine itself. The circuit breaker that protects PHP workers from a sick OpenSearch process, the LSCache plus Cloudflare cache layer that absorbs most of the search traffic before queries even hit Lucene, and the per-field analyzer strategy for CJK content are what made it production-viable on a budget LiteSpeed host. SQLite FTS5 still has a job as the fallback and as the source of truth for exact-substring features like channel-scoped search, but for free-form user queries with typos, edit-distance fuzziness is non-negotiable. If you are running a similar multi-language aggregator and your search box still goes straight to FTS5 or LIKE queries, the migration is doable in a couple of weeks with one engineer. Start with the index mapping, build the indexing pipeline, then layer the circuit breaker before cutting traffic over.