[教程一] 写一个搜索:使用 Laravel Scout,Elasticsearch,ik 分词

文字太长,你可以直接看代码:

https://github.com/lijinma/laravel-scout-e...

过年的时候,我在家写了一个小网站,名字叫“笑来搜”,整个过程是这样的:

  1. 开始使用 tntsearch ,非常小巧,依赖也少,很喜欢。
  2. 不过用了一下发现 tntsearch 没有配套的中文分词,有一个小伙子写了一个,但是很不完善。
  3. 最终还是选择了 ElasticSearch,虽然相对 tntsearch 更重一点。
  4. ElasticSearch 中的 ik 分词插件简单好用,而且非常容易扩展词库。

笑来搜 上线后,好几个朋友询问如何可以简单的实现一个类似的搜索网站,所以我就抽时间做了一个类似的 Demo,代码在 https://github.com/lijinma/laravel-scout-e... ,对你有帮助的请 Star,这个 Demo 至少有这两个优点:

  1. 尽可能写清楚安装中的每一个步骤,我假设你是一名新手。
  2. 这个 Demo 直接跑在了我的服务器上,你可以直观的玩起来。http://scout.lijinma.com/search

下面是整个教程:

首先:我们要做一个什么?

我们要做的东西比较简单,就是把一个公众账号的文章拉下来,然后实现所有文章的“标题”和“内容”的搜索,在项目中我选择了李笑来老师的”学习学习再学习“中的50篇文章。

先看看要做的东西的样子: http://scout.lijinma.com/search

第一步:安装好 Laravel 5.4

不管你是使用 homestead,还是 valet,还是 docker ,还是直接自己本地环境搭建,反正第一步你要把 Laravel 5.4 项目跑起来,可以看到 welcome 的页面。

这里分享一下我是如何开发的,一般来说,只有我一个人开发的简单的 Laravel 项目,我都不使用 homestead 或者 valet 或者 docker 跑的,我直接在 Mac 本地跑,Mac 上只需要装一个 mysql,然后开发调试的时候直接使用 php artisan serve,总体来说效率比较高,配置快。

第二步:配置

配置数据库

create database laravel_scout_elastic_demo;

安装 ElasticSearch Scout Engine 包

$ composer require tamayo/laravel-scout-elastic

安装这个包的时候,顺便就会装好 Laravel Scout,我们 publish 一下 config

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

添加对应的 ServiceProvider:

//app.php
                ...
        Laravel\Scout\ScoutServiceProvider::class,
        ScoutEngines\Elasticsearch\ElasticsearchProvider::class,
                ...

安装 Goutte Client

我们需要通过公众号文章的 url 爬到文章的标题和内容,所以需要安装这个 库:

composer require fabpot/goutte

第三步:安装 ElasticSearch

因为我们要使用 ik 插件,在安装这个插件的时候,如果自己想办法安装这个插件会浪费你很多精力。

所以我们直接使用项目: https://github.com/medcl/elasticsearch-rtf

当前的版本是 Elasticsearch 5.1.1,ik 插件也是直接自带了。

安装好 ElasticSearch,跑起来服务,测试服务安装是否正确:

$ curl http://localhost:9200

{
  "name" : "Rkx3vzo",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "Ww9KIfqSRA-9qnmj1TcnHQ",
  "version" : {
    "number" : "5.1.1",
    "build_hash" : "5395e21",
    "build_date" : "2016-12-06T12:36:15.409Z",
    "build_snapshot" : false,
    "lucene_version" : "6.3.0"
  },
  "tagline" : "You Know, for Search"
}

如果正确的打印以上信息,证明 ElasticSearch 已经安装好了。

接着你需要查看一下 ik 插件是否安装(请在你的 ElasticSearch 文件夹中执行):

$ ./bin/elasticsearch-plugin list
analysis-ik

如果出现 analysis-ik,证明 ik 已经安装。

第四步,开始写代码:

添加 InitEs 命令,初始化 ES 的一些数据

$ php artisan make:command InitEs

InitEs.php 代码如下,主要做了两件事情:

  1. 创建对应的 index
  2. 创建一个 template,你可以通过下面的链接了解一下什么是 Index template
    https://www.elastic.co/guide/en/elasticsea...
<?php

namespace App\Console\Commands;

use GuzzleHttp\Client;
use Illuminate\Console\Command;

class InitEs extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'es:init';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Init es to create index';

    /**
     * Create a new command instance.
     *
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $client = new Client();
        $this->createTemplate($client);
        $this->createIndex($client);
    }

    protected function createIndex(Client $client)
    {
        $url = config('scout.elasticsearch.hosts')[0] . ':9200/' . config('scout.elasticsearch.index');
        $client->put($url, [
            'json' => [
                'settings' => [
                    'refresh_interval' => '5s',
                    'number_of_shards' => 1,
                    'number_of_replicas' => 0,
                ],
                'mappings' => [
                    '_default_' => [
                        '_all' => [
                            'enabled' => false
                        ]
                    ]
                ]
            ]
        ]);
    }

    protected function createTemplate(Client $client)
    {
        $url = config('scout.elasticsearch.hosts')[0] . ':9200/' . '_template/rtf';
        $client->put($url, [
            'json' => [
                'template' => '*',
                'settings' => [
                    'number_of_shards' => 1
                ],
                'mappings' => [
                    '_default_' => [
                        '_all' => [
                            'enabled' => true
                        ],
                        'dynamic_templates' => [
                            [
                                'strings' => [
                                    'match_mapping_type' => 'string',
                                    'mapping' => [
                                        'type' => 'text',
                                        'analyzer' => 'ik_smart',
                                        'ignore_above' => 256,
                                        'fields' => [
                                            'keyword' => [
                                                'type' => 'keyword'
                                            ]
                                        ]
                                    ]
                                ]
                            ]
                        ]
                    ]
                ]
            ]
        ]);

    }
}

创建 Post 表,存放公众号的文章

php artisan make:migration create_posts_table

代码:

<?php

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

class CreatePostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->increments('id');
            $table->text('url');
            $table->string('author', 64)->nullable()->default(null);
            $table->text('title');
            $table->longText('content');
            $table->dateTime('post_date')->nullable()->default(null);
            $table->timestamps();
        });
    }

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

在数据库中创建表:

$ php artisan migrate

添加 Post Model:

$ php artisan make:model Post

代码:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;

/**
 * Class Post
 * @package App
 * @property string $url
 * @property string $author
 * @property string $content
 * @property string $title
 * @property string $post_date
 * @property string $created_at
 * @property string $updated_at
 */
class Post extends Model
{
    use Searchable;
    protected $table = 'posts';

    protected $fillable = [
        'url',
        'author',
        'title',
        'content',
        'post_date'
    ];

    public function toSearchableArray()
    {
        return [
            'title' => $this->title,
            'content' => $this->content
        ];
    }
}

添加一个命令 ImportPosts,通过此命令去爬去数据,并导入到 Post 表中。

$ php artisan make:command ImportPosts

代码:

<?php

namespace App\Console\Commands;

use App\Libraries\WechatPostSpider;
use App\Post;
use Goutte\Client;
use Illuminate\Console\Command;

class ImportPosts extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'posts:import';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Import posts!';

    /**
     * Create a new command instance.
     *
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $client = new Client();
        foreach (config('post-urls') as $url) {
            /**
             * 这里 url 可能需要索引,但是用 url 做唯一标示不太好,索引太大
             */
            if (Post::where('url', $url)->exists()) {
                continue;
            }
            $wechatPostSpider = new WechatPostSpider($client, $url);
            $this->savePost($wechatPostSpider);
            $this->info('create one post!');
        }
    }

    protected function savePost(WechatPostSpider $wechatPostSpider)
    {
        Post::create([
            'url' => $wechatPostSpider->getUrl(),
            'author' => $wechatPostSpider->getAuthor(),
            'title' => $wechatPostSpider->getTitle(),
            'content' => $wechatPostSpider->getContent(),
            'post_date' => $wechatPostSpider->getPostDate(),
        ]);
    }
}

此时,需要依赖两个文件,一个是 app/Libraries/WechatPostSpider.php,一个是 config/post-urls.php 配置文件。

WechatPostSpider.php 负责爬去数据

<?php namespace App\Libraries;

use Goutte\Client;
use Symfony\Component\DomCrawler\Crawler;

/**
 * Created by PhpStorm.
 * User: lijinma
 * Date: 04/03/2017
 * Time: 9:05 PM
 */
class WechatPostSpider
{

    /**
     * @var Crawler|null
     */
    protected $crawler;

    /**
     * @var string
     */
    protected $url;

    /**
     * WechatPostSpider constructor.
     * @param Client $client
     * @param $url
     */
    public function __construct(Client $client, $url)
    {
        $this->url = $url;
        $this->crawler = $client->request('GET', $url);
    }

    /**
     * @return string
     */
    public function getTitle()
    {
        return trim($this->crawler->filter('title')->text());
    }

    /**
     * @return string
     */
    public function getContent()
    {
        return trim($this->crawler->filter('.rich_media_content')->text());
    }

    /**
     * @return string
     */
    public function getAuthor()
    {
        return trim($this->crawler->filter('#post-date')->nextAll()->text());
    }

    /**
     * @return string
     */
    public function getPostDate()
    {
        return $this->crawler->filter('#post-date')->text();
    }

    /**
     * @return string
     */
    public function getUrl()
    {
        return $this->url;
    }
}

post-urls.php 存储需要爬取的公众号文章 urls,这里只列了一条

<?php

return [
    "http://mp.weixin.qq.com/s?__biz=MzAxNzI4MTMwMw==&mid=2651630953&idx=1&sn=9c4d8f2b4df2605fdaa1338303acc908&chksm=801ff511b7687c07303220a0c105d979f1a4a5db45689c95111a6c6ec2f5a6c0c6cecea88ba0&scene=4#wechat_redirect",
];

添加 PostController

$ php artisan make:controller PostController

PostController.php 代码:

<?php

namespace App\Http\Controllers;

use App\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function search(Request $request)
    {
        $q = $request->get('q');
        $paginator = [];
        if ($q) {
            $paginator = Post::search($q)->paginate();
        }

        return view('search', compact('paginator', 'q'));
    }
}

PostController.php 需要依赖 view 文件,我们创建一个 resources/views/layouts/main.blade.php,一个 resources/views/search.blade.php

resources/views/layouts/main.blade.php 代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" id="viewport"
          content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"/>

    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ config('app.name', 'Laravel') }}</title>

    <!-- Styles -->
    <link href="https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
    <link href="/css/main.css" rel="stylesheet">

    <!-- Scripts -->
    <script>
        window.Laravel = {!! json_encode([
            'csrfToken' => csrf_token(),
        ]) !!};
    </script>
</head>
<body>
<div id="app">
    <div class="container">
        <div class="row">
            <div class="col-md-12">
                <nav class="navbar navbar-default">
                    <div class="container-fluid">
                        <!-- Brand and toggle get grouped for better mobile display -->
                        <div class="navbar-header">
                            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
                                <span class="sr-only">Toggle navigation</span>
                                <span class="icon-bar"></span>
                                <span class="icon-bar"></span>
                                <span class="icon-bar"></span>
                            </button>
                            <a class="navbar-brand" href="/">Laravel Scout Elastic Demo</a>
                        </div>
                    </div><!-- /.container-fluid -->
                </nav>
            </div>
        </div>
        @yield('content')
    </div>
</div>
<!-- Scripts -->
<script src="http://cdn.bootcss.com/jquery/1.12.4/jquery.min.js"></script>
<script src="http://cdn.bootcss.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
</body>
</html>

resources/views/search.blade.php 代码:

@extends('layouts.main')
@section('content')
    <div class="row">
        <div class="col-md-12">
            <form action="/search">
                <div class="input-group">
                    <input type="text" class="form-control h50" name="q" placeholder="关键字..." value="{{ $q }}">
                    <span class="input-group-btn"><button class="btn btn-default h50" type="submit" type="button"><span class="glyphicon glyphicon-search"></span></button></span>
                </div>
            </form>
        </div>
    </div>
    @if($q)
        <div class="row">
            <div class="col-md-12">
                <div class="panel panel-default list-panel search-results">
                    <div class="panel-heading">
                        <h3 class="panel-title ">
                            <i class="fa fa-search"></i> 关于 “<span class="highlight">{{ $q }}</span>” 的搜索结果, 共 {{ $paginator->total() }} 条
                        </h3>
                    </div>

                    <div class="panel-body ">
                        @foreach($paginator as $post)
                            <div class="result">
                                <h2 class="title">
                                    <a href="{{ $post->url }}" target="_blank">
                                            {{ $post->title }}
                                    </a>
                                </h2>
                                <div class="info">
                                </div>
                                <div class="desc">
                                        {{ mb_substr($post->content, 0, 150) }}......
                                </div>
                                <hr>
                            </div>
                        @endforeach
                    </div>
                    {{ $paginator->links() }}
                </div>
            </div>
        </div>
    @else
        <div class="row text-center">
            <div class="col-md-12">
                <br>
                <h2>你会搜索到什么?</h2>
                <br>
                <p>学习学习再学习公众号所有文章</p>
            </div>
        </div>
    @endif
@endsection

现在我们的代码已经写完了,但是缺少一个功能,搜索结果如何高亮(highlight) 呢?

本作品采用《CC 协议》,转载必须注明作者和本文链接
写文字大部分时候是因为我希望能帮助到你,小部分时候是想做总结或做记录。我的微信是 lijinma,希望和你交朋友。 以下是我的公众账号,会分享我的学习和成长。
本帖由 Summer 于 7年前 加精
lijinma
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 59
lijinma

这样复制代码,好像有点略长。。

7年前 评论
Summer

好文章 :+1:

文章里面不要使用 h1 标签,就是这种:

file

SEO 对层级关系会敏感,最顶层也就是 H1 是被页面的标题占用了。所以文章里的层级关系只能是 h2 ~ h6 ,他们都隶属于此篇文章 :smile_cat:

7年前 评论
lijinma

@Summer 明白了,Summer,马上改好

7年前 评论

真的不错,好评!

7年前 评论

马上就想按着你的教程折腾一下。:kissing_heart:

7年前 评论
lijinma

@dodo 谢谢。

7年前 评论
lijinma

@JokerLinly 快折腾下,正好我看看我教程写的有没有问题。。

任何问题叫我,秒回。

7年前 评论

最近在怼 opensearch,已经出了一个内部版本的 SDK,等成熟了可以交流下

7年前 评论
lijinma

@RryLee 恩恩,期待哈

7年前 评论

我前端时间刚使用es做实时日志的收集和查询,赞一个!

7年前 评论
Artisan

之前没有听说过ElasticSearch,看过这三篇文章后去查阅了相关资料,感觉很厉害.这个软件如果用在普通的企业站做站内搜索会不会有些大材小用呢?似乎很吃内存,要2G以上是吗?

7年前 评论
lijinma
7年前 评论
lijinma

@Artisan 还好,我现在内存紧张,es 使用 512M,其实多个企业站用一个搜索服务就行。搜索不那么频繁的话。

7年前 评论
Artisan

@lijinma 您所谓的搜索服务是 Algolia 这种服务吗?还是别的什么,搜索确实不多.

7年前 评论

好厉害的东西,金马哥。

7年前 评论
lijinma

@Artisan 恩,比如 Algolia,比如阿里云,腾讯云都有提供,如果你搜索不多,数据不多,价格不贵。

如果想自己折腾,就自己搭建 ElasticSearch ,其实也快。

7年前 评论
lijinma

@张铁林 哈哈,谢谢铁林。。

7年前 评论

很详细, 准备走一遍!!

7年前 评论

报错 Type error: Too few arguments to function Illuminate\Support\Manager::createDriver()

7年前 评论
lijinma

@ohr445321
不够详细,无法判断是哪的问题

7年前 评论

@ohr445321 你config里面没有scout.php! php artisan es:init && php artisan post:import 应该没执行! 我开始也跟你一样的错误

6年前 评论

@lijinma 谢谢提供这么好的技术文章! 我在爬完公众号数据后 数据库也有了数据!! 不知道为什么 一条数据都搜索不到
关于 “北京” 的搜索结果, 共 0 条
关于 “十分钟读完《刻意练习》” 的搜索结果, 共 0 条

6年前 评论

@lijinma 是不是没有把posts表里面的title content放到索引里去?

6年前 评论
Silencewj

ElasticSearch 安装3%就动不了。搞了一下午还是不行

6年前 评论
Silencewj

@JokerLinly 金马的搜索 折腾的怎样? ElasticSearch安装上了吗

6年前 评论
Silencewj

@Artisan 金马的搜索 折腾的怎样? ElasticSearch安装上了吗

6年前 评论

@Silence9312 不带你这样批量发问的。。可以同时 @ 两个人嘛~
参考下 5 分钟配置并使用 ElasticsearchElasticsearch,为了搜索

6年前 评论
Silencewj

@JokerLinly 多谢Joker 哈哈 现在就去试试 不过下载的速度老慢了

6年前 评论
Destiny

不得不赞。完美。

6年前 评论
Destiny

@ahkxhyl 初始化 ES init 的配置没运行吧。

6年前 评论

按照步骤走了一遍, 但是搜索数字和英文有结果, 搜索汉字没有结果, 不支持中文?? 但是单独执行elasticsearch 搜索接口是能够获取到数据的,请问十什么原因呢??

6年前 评论

@lijinma 按照步骤走了一遍, 但是搜索数字和英文有结果, 搜索汉字没有结果, 不支持中文?? 但是单独执行elasticsearch 搜索接口是能够获取到数据的,请问十什么原因呢??

file

file

6年前 评论
lijinma

@珠珠 - 。- debug 下。。。

6年前 评论

@Destiny 都是按教程一步一步来的 代码都没测试就放上来 反馈也不回信息~

6年前 评论

提示这个报错:
[GuzzleHttp\Exception\ConnectException]
cURL error 6: Couldn't resolve host 'localhost:9200' (see http://curl.haxx.se/libcurl/c/libcurl-erro...)
谷歌了一遍还是没找到解决方案

6年前 评论

mac下一切正常 部署到服务器之后英文数字有结果...中文没结果啊 是编码问题?

6年前 评论

@珠珠 同样问题啊 你解决了吗

6年前 评论

Call to undefined method GuzzleHttp\Psr7\Response::filter(), 执行抓取数据的命令的时候报这个错。

6年前 评论

@珠珠 我也有碰到这个问题,这个需要先执行es:init 配置,然后再使用scout:import 就可以进行中文的搜索了

6年前 评论

@珠珠 我也是中文搜不到和你一样 你的问题解决了吗

6年前 评论

ES和Mysql之间的数据同步你是怎么解决的;做到增量同步好像有点困难

6年前 评论

你好,根据你的github源码 运行 php artisan es:init 报错如下

file

6年前 评论

添加一个配置::文中漏了的坑scout.php中

'driver' => env('SCOUT_DRIVER', 'elasticsearch'), agloin 改成elasticsearch 驱动
并且加入
'elasticsearch' => [
'index' => env('ELASTICSEARCH_INDEX', 'laravel_search'),
'hosts' => [
env('ELASTICSEARCH_HOST', 'http://127.0.0.1:9200'),
],
],
6年前 评论

金老师。求助问题,刚刚跑通你的demo,
但是发现问题,
我把posts表的数据全部删掉后去搜索,发现搜不到,而且报错,但是报错信息里存在ES的数据,
怎么把报错的信息变为正常信息返回
当我把ES里的索引全删后,搜索不会报错。
发现搜索的驱动还是MYSQL,跟ES没什么关系
疑惑中,求解
file

file

6年前 评论
易水 3年前

{"_index":"localhost","_type":"course","_id":"2","found":false}
导入模型数据成功了 而且显示出来表的ID数 但是 浏览器输入http://127.0.0.1:9200/localhost/course/2 查询时是为空

已解决

5年前 评论

@zbc123 请问你是怎么解决的 ?我也刚遇到这个问题

5年前 评论

前辈,您好,看了您的文章,已经基本入门elasticsearch,但是现在遇到一个问题,烦请指点一下。我有两个表,分别为:posts [文章表] comments[评论表 ] ,一篇文章有多条评论。当我在输入框输入搜索关键词时,我希望,也能够将comments 表中的评论内容也加入搜索的范围,我在 App\Models\Post 中的 toSearchableArray() 方法如何设置呢?烦请指点一下。

5年前 评论

@ittiro 我的好像是在模型里指定对应的表就好了 。

5年前 评论

elasticsearch 6.x不再支持一个索引下多个type,(Multiple types in the same index really shouldn't be used all that often and one of the few use cases for types is parent child relationships.)那如何对多表进行搜索呢?@lijinma

5年前 评论

@zbc123 你的es是哪个版本?是对单个模型进行了导入吧,多个模型搜索如何解决的?

5年前 评论

@lijinma 问一下楼主我这为啥中英文啥也搜不出来呢?
file

file

4年前 评论

请问使用paginate时报错:说不支持types ,如何解决啊 是因为我es版本为7.2,版本高的原因吗?

4年前 评论
suisai337 4年前

我想问一下,这个搜索是不是只支持搜索词全文模糊匹配,我的搜索词不会自动分词,比如说,搜“中华人民共和国”,他就搜不出只带“中华”或者“共和国”的内容。

2年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!