How to build 'did you mean' functionality with Laravel Scout

Bài này được dịch từ bài gốc http://tnt.studio/blog/did-you-mean-functionality-with-laravel-scout?utm_source=learninglaravel.net

Đầu tiên bạn hãy xem Demo

Bây giờ chúng ta hãy cùng nghiên cứu cách xây dựng chức năng 'did you mean' này

Giới thiệu

Với chức năng này chúng ta sẽ sử dụng lượng dữ liệu rất lớn, hơn 3 triệu record thông tin các thành phố. Với ý tưởng chức năng là hiển thị tên thành phố đúng trong trường hợp người dùng đánh sai. Danh sách các thành phố chúng ta sẽ lấy ở đây https://www.maxmind.com/en/free-world-cities-database. Chúng ta sẽ sử dụng Laravel Scout và TNT search để cài đặt chức năng này.

Cài đặt laravel-scout-tntsearch-driver

Từ thư mục project bạn chạy composer để cài đặt driver

composer require teamtnt/laravel-scout-tntsearch-driver

Sau đó, khai báo provider cho việc sử dụng driver trên bằng cách thêm vào file config/app.php

'providers' => [
    /*
     * Package Service Providers...
     */
    Laravel\Scout\ScoutServiceProvider::class,
    TeamTNT\Scout\TNTSearchScoutServiceProvider::class,
]

Tiếp theo, để config publish cho Scount bạn chạy tiếp

php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"

Và thiết lập TNTSearch với giá trị defaul trong file .env

SCOUT_DRIVER=tntsearch

Trong config/scount.php thiết lập tiếp giá trị storage_path

'tntsearch' => [
    'storage'  => storage_path(),
],

Xây dựng function

Tạo Migration

Tạo migration file

php artisan make:model City --migration

Với nội dung file migration

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateCitiesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('cities', function (Blueprint $table) {
            $table->increments('id');
            $table->string('country');
            $table->string('city');
            $table->string('region');
            $table->float('population');
            $table->double('latitude', 15, 8);
            $table->double('longitude', 15, 8);
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('cities');
    }
}

Trong model City.php chúng ta khai báo

public $timestamps = false;

Tạo Command

Tạo command ImportCities

php artisan make:command ImportCities

Sau khi chạy xong, file ImportCities sẽ được tạo ra. Chúng ta ghi nội dung file:

<?php
namespace App\Console\Commands;
use App\City;
use Illuminate\Console\Command;
use TeamTNT\TNTSearch\Indexer\TNTIndexer;
class ImportCities extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'import:cities';
    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Imports cities from MaxMind';
    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->tnt = new TNTIndexer;
        parent::__construct();
    }
    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $this->info("Downloading worldcitiespop.txt.gz from MaxMind");
        $gzipedFile  = storage_path().'/worldcitiespop.txt.gz';
        $unZipedFile = storage_path().'/worldcitiespop.txt';
        if (!file_exists($gzipedFile)) {
            file_put_contents($gzipedFile, fopen("http://download.maxmind.com/download/worldcities/worldcitiespop.txt.gz", 'r'));
        }
        $this->info("Unziping worldcitiespop.txt.gz to worldcitiespop.txt");
        $this->line("\n\nInserting cities to database");
        if (!file_exists($unZipedFile)) {
            $this->unzipFile($gzipedFile);
        }
        $cities = fopen(storage_path().'/worldcitiespop.txt', "r");
        $lineNumber = 0;
        $bar        = $this->output->createProgressBar(3173959);
        if ($cities) {
            while (!feof($cities)) {
                $line = fgets($cities, 4096);
                if ($lineNumber == 0) {
                    $lineNumber++;
                    continue;
                }
                
                $line = explode(',', $line);
                $this->insertCity($line);
                $lineNumber++;
                $bar->advance();
            }
            fclose($cities);
        }
        $bar->finish();
    }
    public function insertCity($cityArray)
    {
        
        if ($cityArray[4] < 1) {
            return;
        }
        $city             = new City;
        $city->country    = $cityArray[0];
        $city->city       = utf8_encode($cityArray[2]);
        $city->region     = $cityArray[3];
        $city->population = $cityArray[4];
        $city->latitude   = trim($cityArray[5]);
        $city->longitude  = trim($cityArray[6]);
        $city->n_grams    = $this->createNGrams($city->city);
        $city->save();
    }
    public function unzipFile($from)
    {
        
        $buffer_size   = 4096; 
        $out_file_name = str_replace('.gz', '', $from);
        
        $file     = gzopen($from, 'rb');
        $out_file = fopen($out_file_name, 'wb');
        
        while (!gzeof($file)) {
            
            
            fwrite($out_file, gzread($file, $buffer_size));
        }
        
        fclose($out_file);
        gzclose($file);
    }
    public function createNGrams($word)
    {
        return utf8_encode($this->tnt->buildTrigrams($word));
    }
}

Cần phải đăng ký command này trong app/Console/Kernel.php

<?php

    protected $commands = [
        \App\Console\Commands\ImportCities::class
    ];

Vậy là chúng ta đã xây dựng xong chức năng tự động download file từ http://download.maxmind.com/download/worldcities/worldcitiespop.txt.gz và giải né nó rồi import dữ liệu vào bảng cities.

Tuy nhiên do dữ liệu được import vào là rất lớn, nên chúng ta cần phải có 1 giải pháp nào đó cho việc đánh index cho những record này. Vậy ta cần viết tiếp 1 command CreateCityTrigrams

php artisan make:command CreateCityTrigrams

Với nội dung của CreateCityTrigrams.php

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use TeamTNT\TNTSearch\TNTSearch;
class CreateCityTrigrams extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'city:trigrams';
    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Creates an index of city trigrams';
    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }
    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $this->info("Creating index of city trigrams");
        $tnt = new TNTSearch;
        $driver = config('database.default');
        $config = config('scout.tntsearch') + config("database.connections.$driver");
        $tnt->loadConfig($config);
        $tnt->setDatabaseHandle(app('db')->connection()->getPdo());
        $indexer = $tnt->createIndex('cityngrams.index');
        $indexer->query('SELECT id, n_grams FROM cities;');
        $indexer->setLanguage('no');
        $indexer->run();
    }
}

Vậy là xong, bạn chỉ cần chạy command line cho 2 phần

php artisan import:cities

cho import data và

php artisan cities.index

cho đánh index

Tạo Controller

Tạo file CityController.php bằng command line

php artisan make:controllers CityController

Với nội dung

<?php

namespace App\Http\Controllers;

use App\City;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use TeamTNT\TNTSearch\Indexer\TNTIndexer;
use TeamTNT\TNTSearch\TNTSearch;

class CityController extends Controller
{
    public function search(Request $request)
    {
        $res = City::search($request->get('city'))->get();
        if (isset($res[0]) && $this->isExactMatch($request, $res[0])) {
            return [
                'didyoumean' => false,
                'data'       => $res[0]
            ];
        }

        
        return [
            'didyoumean' => true,
            'data'       => $this->getSuggestions($request)
        ];

    }

    public function isExactMatch($request, $result)
    {
        return strtolower($request->get('city')) == strtolower($result->city);
    }

    public function getSuggestions(Request $request)
    {
        $TNTIndexer = new TNTIndexer;
        $trigrams   = $TNTIndexer->buildTrigrams($request->get('city'));

        $tnt = new TNTSearch;

        $driver = config('database.default');
        $config = config('scout.tntsearch') + config("database.connections.$driver");

        $tnt->loadConfig($config);
        $tnt->setDatabaseHandle(app('db')->connection()->getPdo());

        $tnt->selectIndex("cityngrams.index");
        $res  = $tnt->search($trigrams, 10);
        $keys = collect($res['ids'])->values()->all();

        $suggestions = City::whereIn('id', $keys)->get();

        $suggestions->map(function ($city) use ($request) {
            $city->distance = levenshtein($request->get('city'), $city->city);
        });

        $sorted = $suggestions->sort(function ($a, $b) {
            if ($a->distance === $b->distance) {
                if ($a->population === $b->population) {
                    return 0;
                }
                return $a->population > $b->population ? -1 : 1;
            }
            return $a->distance < $b->distance ? -1 : 1;
        });

        return $sorted->values()->all();
    }
}

Vậy là xong, bạn chỉ việc đặt route cho function search trong CityController để hiển thấy được kết quả. Demo

How dose it work?

Nếu nhìn vào mã code ở trên chắc hẳn sẽ rất khó để hiểu ý tưởng của chức năng này chạy như nào. Ta sẽ cùng phân tích theo hướng logic như thế này.

Ở đây, lấy ví dụ là "Berlin". Trigrams sẽ chia nhỏ và phân tích theo từ và cụm từ thì nó sẽ trở có dạng như __b _be ber erl rli lin in_ n__

Và giải sử bạn đánh sai từ Berlin đó thành "Berln". Trigrams sẽ chia nhỏ và phân tích thành __b _be ber erl rln ln_ n__

Và bạn có thể thấy, việc match những Trigrams giữa 2 phần này trong hình dưới

All Rights Reserved