Let's Go Further فیلتر کردن، مرتب سازی و صفحه بندی › جستجوی تمام متن
قبلی · فهرست مطالب · بعدی
فصل ۹.۵.

در این فصل، ما فیلتر عنوان فیلم خود را با پشتیبانی از تطابق جزئی به جای تطابق کامل، کاربرپسندتر خواهیم کرد. به عنوان مثال، اگر یک مشتری بخواهد فیلم The Breakfast Club را پیدا کند، می‌تواند فقط با رشته پرس و جوی title=breakfast آن را پیدا کند.

روش‌های مختلفی برای پیاده‌سازی این ویژگی در کد ما وجود دارد، اما یک روش مؤثر و شهودی (از دیدگاه مشتری) استفاده از قابلیت full-text search PostgreSQL است که به شما امکان می‌دهد جستجوهای زبان طبیعی را روی فیلدهای متنی پایگاه داده خود انجام دهید.

جستجوی تمام متن PostgreSQL یک ابزار قدرتمند و بسیار قابل تنظیم است و توضیح نحوه عملکرد آن و گزینه‌های موجود به طور کامل به راحتی می‌تواند یک کتاب کامل را پر کند. بنابراین توضیحات این فصل را در سطح بالا نگه می‌داریم و بر پیاده‌سازی عملی تمرکز خواهیم کرد.

برای پیاده‌سازی یک جستجوی تمام متن پایه روی فیلد title خود، کوئری SQL خود را به شکل زیر به روز می‌کنیم:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') 
AND (genres @> $2 OR $2 = '{}')     
ORDER BY id

این در نگاه اول کمی پیچیده به نظر می‌رسد، بنابراین بیایید آن را تجزیه کرده و توضیح دهیم که چه اتفاقی می‌افتد.

تابع to_tsvector('simple', title) عنوان یک فیلم را گرفته و آن را به lexeme‌ها (واژه‌های پایه) تقسیم می‌کند. ما پیکربندی simple را مشخص می‌کنیم، به این معنی که lexeme‌ها فقط نسخه‌های حروف کوچک کلمات در عنوان هستند. به عنوان مثال، عنوان فیلم "The Breakfast Club" به lexeme‌های 'breakfast' 'club' 'the' تقسیم می‌شود.

تابع plainto_tsquery('simple', $1) مقدار جستجو را گرفته و آن را به یک query term قالب‌بندی شده تبدیل می‌کند که جستجوی تمام متن PostgreSQL می‌تواند آن را درک کند. این مقدار جستجو را عادی‌سازی می‌کند (دوباره با استفاده از پیکربندی simple)، کاراکترهای خاص را حذف می‌کند و عملگر and & را بین کلمات درج می‌کند. به عنوان مثال، مقدار جستجوی "The Club" منجر به query term 'the' & 'club' می‌شود.

عملگر @@ عملگر تطابق (matches) است. در دستور ما از آن برای بررسی اینکه آیا query term تولید شده با lexeme‌های عنوان مطابقت دارد یا خیر استفاده می‌کنیم. برای ادامه مثال، query term 'the' & 'club' با ردیف‌هایی که هر دو lexeme 'the' و 'club' را دارند مطابقت می‌کند.

کلمات تخصصی زیادی در پاراگراف‌های بالا وجود دارد، اما اگر آن را با چند مثال نشان دهیم، در واقع بسیار شهودی است:

// Return all movies where the title includes the case-insensitive word 'panther'.
/v1/movies?title=panther

// Return all movies where the title includes the case-insensitive words 'the' and 
// 'club'.
/v1/movies?title=the+club

بیایید این را اجرا کنیم. فایل internal/data/movies.go خود را باز کنید و متد GetAll() را به روز کنید تا از کوئری SQL جدید مانند زیر استفاده کند:

فایل: internal/data/movies.go
package data

...

func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, error) {
    // Use full-text search for the title filter.
    query := `
        SELECT id, created_at, title, year, runtime, genres, version
        FROM movies
        WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') 
        AND (genres @> $2 OR $2 = '{}')     
        ORDER BY id`

    // Nothing else below needs to change.
    ...
}

اگر در حال دنبال کردن هستید، برنامه را مجدداً راه اندازی کنید و سعی کنید با مقادیر مختلف برای عنوان فیلم درخواست‌هایی ارسال کنید. باید متوجه شوید که جستجوهای جزئی اکنون مطابق آنچه در بالا توضیح دادیم کار می‌کنند.

به عنوان مثال:

$ curl "localhost:4000/v1/movies?title=panther"
{
    "movies": [
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}

$ curl "localhost:4000/v1/movies?title=the+club"
{
    "movies": [
        {
            "id": 4,
            "title": "The Breakfast Club",
            "year": 1985,
            "runtime": "97 mins",
            "genres": [
                "comedy"
            ],
            "version": 5
        }
    ]
}

افزودن ایندکس‌ها

برای اینکه کوئری SQL ما با رشد مجموعه داده‌ها به سرعت اجرا شود، منطقی است از ایندکس‌ها برای کمک به جلوگیری از اسکن کامل جدول و جلوگیری از تولید lexeme‌ها برای فیلد title در هر بار اجرای کوئری استفاده کنیم.

در مورد ما، ایجاد ایندکس‌های GIN هم روی فیلد genres و هم روی lexeme‌های تولید شده توسط to_tsvector() منطقی است، که هر دو در کلاUSE WHERE کوئری SQL ما استفاده می‌شوند.

اگر در حال دنبال کردن هستید، یک جفت فایل migration جدید ایجاد کنید:

$ migrate create -seq -ext .sql -dir ./migrations add_movies_indexes

سپس دستورات زیر را به فایل‌های migration 'up' و 'down' اضافه کنید تا ایندکس‌های لازم ایجاد و حذف شوند:

فایل: migrations/000003_add_movies_indexes.up.sql
CREATE INDEX IF NOT EXISTS movies_title_idx ON movies USING GIN (to_tsvector('simple', title));
CREATE INDEX IF NOT EXISTS movies_genres_idx ON movies USING GIN (genres);
فایل: migrations/000003_add_movies_indexes.down.sql
DROP INDEX IF EXISTS movies_title_idx;
DROP INDEX IF EXISTS movies_genres_idx;

پس از اتمام کار، باید بتوانید مایگریشن 'up' را برای اضافه کردن ایندکس‌ها به پایگاه داده خود اجرا کنید:

$ migrate -path ./migrations -database $GREENLIGHT_DB_DSN up
3/u add_movies_indexes (38.638777ms)

اطلاعات تکمیلی

پیکربندی غیر ساده و اطلاعات بیشتر

همانطور که در بالا ذکر شد، می‌توانید به جای پیکربندی simple که در حال حاضر استفاده می‌کنیم، از یک پیکربندی خاص زبان برای جستجوهای تمام متن استفاده کنید. وقتی lexeme‌ها یا query term‌ها را با یک پیکربندی خاص زبان ایجاد می‌کنید، کلمات رایج زبان را حذف کرده و stemming (ریشه‌یابی) کلمات را انجام می‌دهد.

بنابراین، به عنوان مثال، اگر از پیکربندی english استفاده کنید، lexeme‌های تولید شده برای "One Flew Over the Cuckoo's Nest" عبارتند از 'cuckoo' 'flew' 'nest' 'one'. یا با پیکربندی spanish، lexeme‌های "Los lunes al sol" عبارتند از 'lun' 'sol'.

می‌توانید با اجرای دستور \dF در PostgreSQL لیستی از تمام پیکربندهای موجود را بازیابی کنید:

postgres=# \dF
              List of text search configurations
   Schema   |    Name    |              Description              
------------+------------+---------------------------------------
 pg_catalog | arabic     | configuration for arabic language
 pg_catalog | danish     | configuration for danish language
 pg_catalog | dutch      | configuration for dutch language
 pg_catalog | english    | configuration for english language
 ...

و اگر می‌خواهید از پیکربندی english برای جستجوی فیلم‌های ما استفاده کنید، می‌توانید کوئری SQL را به شکل زیر به روز کنید:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('english', title) @@ plainto_tsquery('english', $1) OR $1 = '') 
AND (genres @> $2 OR $2 = '{}')     
ORDER BY id

اگر می‌خواهید در مورد جستجوی تمام متن PostgreSQL بیشتر بدانید، مستندات رسمی منبع عالی از اطلاعات و مثال‌ها است.

استفاده از STRPOS و ILIKE

اگر نمی‌خواهید از جستجوی تمام متن برای جستجوی جزئی عنوان فیلم استفاده کنید، برخی جایگزین‌ها تابع STRPOS() و عملگر ILIKE PostgreSQL هستند.

تابع STRPOS() PostgreSQL به شما امکان می‌دهد وجود یک رشته فرعی در یک فیلد خاص پایگاه داده را بررسی کنید. می‌توانیم آن را در کوئری SQL خود مانند زیر استفاده کنیم:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (STRPOS(LOWER(title), LOWER($1)) > 0 OR $1 = '') 
AND (genres @> $2 OR $2 = '{}')     
ORDER BY id

از دیدگاه مشتری، عیب این روش این است که ممکن است نتایج غیر شهودی برگرداند. به عنوان مثال، جستجو برای title=the هم The Breakfast Club و هم Black Panther را در مجموعه داده‌های ما برمی‌گرداند.

از دیدگاه سرور نیز برای مجموعه داده‌های بزرگ ایده‌آل نیست. زیرا روش مؤثری برای ایندکس‌گذاری فیلد title برای بررسی شرط STRPOS() وجود ندارد، این بدان معناست که کوئری ممکن است هر بار نیاز به اسکن کامل جدول داشته باشد.

گزینه دیگر عملگر ILIKE است که به شما امکان می‌دهد ردیف‌هایی را که با یک الگوی خاص (بدون حساسیت به بزرگی و کوچکی حروف) مطابقت دارند پیدا کنید. می‌توانیم آن را در کوئری SQL خود مانند زیر استفاده کنیم:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (title ILIKE $1 OR $1 = '') 
AND (genres @> $2 OR $2 = '{}')     
ORDER BY id

این رویکرد از دیدگاه سرور بهتر خواهد بود زیرا می‌توان با استفاده از افزونه pg_trgm و یک ایندکس GIN روی فیلد title ایندکس ایجاد کرد (برای جزئیات لطفاً این پست را ببینید).

از طرف کلاینت نیز بهتر از رویکرد STRPOS() است، زیرا آنها می‌توانند رفتار تطبیق را با پیشوند/پسوند کردن عبارت جستجو با کاراکتر wildcard % کنترل کنند (که در رشته پرس و جوی URL باید به %25 تبدیل شود). به عنوان مثال، برای جستجوی فیلم‌هایی با عنوانی که با “the” شروع می‌شود، یک مشتری می‌تواند پارامتر title=the%25 را ارسال کند.