社内ドキュメントを検索できる『検索君』というシステムを、MFT 高速 Index × Elasticsearch にアップグレードしている話

以前、新人の研修担当をしていた時に、その新人が『社内ドキュメントを検索できるツールあったら便利じゃないか』という提案したことがある。

完成イメージ画像まで作っていて、名前は **『検索君』** だった。

弊社では、一部クラウド化はされているものの、様々な事情で未だ NAS に大量のドキュメントが置かれている。
管理はしっかりされている一方、とにかく量が多く階層も深く、探すのが辛かった。

新人が、しっかりと新人の目でその不満点を見ていた事に感心し、作るのも簡単そうだったのでサクッと作ってみた。

# 初代 検索君

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/642338dc-4c4e-d77c-1cc2-3b2e38067671.png)

初代検索くんは、数時間で作られてこともあり、便利さは限定的だった。

## 実行環境

- Ruby
    - Sinatra
    - groonga
- Frontend
    - riot.js
    - Bulma.css

## できる事

- NAS 内とクラウドストレージのシームレス検索
    - NAS
    - Google Drive
- 検索エンジンは groonga
- ファイルパスでの部分一致検索

## できない事

- コンテンツでの検索
- あいまい検索
- Index は手動で数時間 ~ 数日かかる
    - `Find.find` で検索が遅かった
    - 一旦作ると、再 Index 化が面倒でやらなくなる
        - 現実と乖離していく Index

## 直したい所

とにかく、**現実との乖離** はサービスの価値としては致命的なので、これをまず何とかしたい。

# 二代目 J Soul 検索君

二代目でやりたい事は、

- MFT による Index の高速化
- 検索エンジンを Elasticsearch に
- Web API を Elixir 化
- Docker 化

![kensaku (1).png](https://qiita-image-store.s3.amazonaws.com/0/20198/b82caa2b-1218-ce2d-367e-1f2a9ea9bbb7.png)


実際のソースコードは全てGithub にある。

[github.com/kentork/kensakukun](https://github.com/kentork/kensakukun)
[github.com/kentork/tansakukun](https://github.com/kentork/tansakukun)


## MFT による Index の高速化

NTFS ファイルシステムには、 MFT ( Master File Table ) という全てのファイルエントリを管理する親玉ファイルが存在する。

どうやら、 Everything 等の高速 Index 検索ツールもこれを使って高速化しているらしいと聞き、検索君もこれを使って高速化できないだろうかと挑戦してみた。

以下、参考に

[Wikipedia - マスター ファイル テーブル](https://ja.wikipedia.org/wiki/%E3%83%9E%E3%82%B9%E3%82%BF%E3%83%BC_%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB_%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB)
[NTFSの読み方](https://qiita.com/kusano_k/items/45b0a86649aabb8040ff)

### 情報がない

全然無い。
ソースコードはチラチラと見つかるが、古かったりよく分からなかったりと、とにかく無い。

取り敢えず、良さそうなサンプルソースをいくつか読み解きながら、主にこのコードを参考に進めた。

[CCS LABS C#: Accessing the Master File Table](https://code.msdn.microsoft.com/windowsdesktop/CCS-LABS-C-Accessing-the-d317805c/sourcecode)

● 参考

[stackoverflow - How do we access MFT through C#
](https://stackoverflow.com/questions/21661798/how-do-we-access-mft-through-c-sharp)
[How to read file attributes using Master File Table data
](https://stackoverflow.com/questions/31938722/how-to-read-file-attributes-using-master-file-table-data)
[Get file info from NTFS-MFT reference number
](https://stackoverflow.com/questions/24724343/get-file-info-from-ntfs-mft-reference-number)

### 開発

役割としては検索君とは独立しているため、プロジェクトを切りだした。

[github.com/kentork/tansakukun](https://github.com/kentork/tansakukun)

#### ◆ 開発環境

- Windows 10
- VS Code
- DotNet SDK 2.3.1 ( x86 )

#### ◆ C# プロジェクト作成

C# を書くのは久しぶりなので、まずは C# の環境を作るところから始める。
以下、 [scoop は使えるものとして進める](https://qiita.com/kikuchi_kentaro/items/77793e4a21db6ffdb7cd)

まずは、DotNet SDK を入れる。

```ps1
PS> scoop install -a x86 dotnet-sdk
```

<font color="silver">※ 今回は、x86向けにビルドする必要があるので、アーキテクチャを指定している。x64 を使っている人は何とか頑張ってください</font>

次に、[gh コマンド](https://qiita.com/kikuchi_kentaro/items/f500f261d1292ebe2941) で Github プロジェクトを作り、C# プロジェクトとして初期化する。

```ps1
PS> gh create

# create kentork/tansakukun project

PS> gh cd kentork/tansakukun
PS> dotnet new console -o .
```

```bash
├── ./Program.cs
├── ./tansakukun.csproj
└── ./obj
    ├── ./obj/tansakukun.csproj.nuget.cache
    ├── ./obj/tansakukun.csproj.nuget.g.props
    ├── ./obj/tansakukun.csproj.nuget.g.targets
    └── ./obj/project.assets.json
```

#### ◆ サンプル実行

[サンプルソース](https://code.msdn.microsoft.com/windowsdesktop/CCS-LABS-C-Accessing-the-d317805c/sourcecode?fileId=60931&pathId=477876996) を実行してみたが、以下でエラーが出たので、ちょっと直した。

```diff
-      [DllImport("kernel32.dll")]
-      public static extern void ZeroMemory(IntPtr ptr, Int32 size);
+      [DllImport("Kernel32.dll", EntryPoint = "RtlZeroMemory", SetLastError = false)]
+      public static extern void ZeroMemory(IntPtr ptr, Int32 size);
```

実行

```ps1
PS> dotnet restore
PS> dotnet run
```

これで、一応動いたんだけど、取れるのはファイル名だけだった。
以降、カスタマイズしていく。

#### ◆ フルパス取得

まずは、これをフルパスにする機能を追加した。

```csharp
  public class FileNameAndParentFrn
  {
    private string _name;
    public string Name
    {
      get { return _name; }
      set { _name = value; }
    }

    private string _path = "";
    public string Path
    {
      get { return _path; }
      set { _path = value; }
    }

    private UInt64 _parentFrn;
    public UInt64 ParentFrn
    {
      get { return _parentFrn; }
      set { _parentFrn = value; }
    }

    public FileNameAndParentFrn(string name, UInt64 parentFrn)
    {
      if (name != null && name.Length > 0)
      {
        _name = name;
      }
      else
      {
        throw new ArgumentException("Invalid argument: null or Length = zero", "name");
      }
      if (!(parentFrn < 0))
      {
        _parentFrn = parentFrn;
      }
      else
      {
        throw new ArgumentException("Invalid argument: less than zero", "parentFrn");
      }
    }
  }

...

    private void ResolvePath(string drive, ref Dictionary<ulong, FileNameAndParentFrn> files)
    {
      foreach (KeyValuePair<ulong, FileNameAndParentFrn> entry in files)
      {
        FileNameAndParentFrn file = (FileNameAndParentFrn)entry.Value;
        file.Path = string.Concat(FrnToParentDirectory(drive, file.ParentFrn), Path.DirectorySeparatorChar, file.Name);
      }
    }
    private string FrnToParentDirectory(string drive, ulong frn)
    {
      if (!_directories.ContainsKey(frn)) return "";

      var parent = _directories[frn];
      if (parent.ParentFrn == 0) return drive;
      if (parent.Path != "") return parent.Path;

      parent.Path = string.Concat(FrnToParentDirectory(drive, parent.ParentFrn), Path.DirectorySeparatorChar, parent.Name);
      return parent.Path;
    }
```

ファイルをリストしている間に、実はフォルダも全部リストアップしていたのでそれを使った。
やっていることは、親への Reference を辿りながら、パスを結合していくというシンプルなもの。

C# 的に再帰ってどうなの？とは思ったけど、眠くてこれ以外にアルゴリズムが思いつかなかった。

これで、フルパスが取れるようになった。
ローカルマシンで確認した所、『C:』から350万ぐらいのファイルを読み込んで、2分弱 ( CPU Core i5, Mem 16GB )。早い。

#### ◆ 巨大な壁

ここで、試しに社内の NAS に繋いでみようと思ったが、１つ疑問が。

**あれ、共有フォルダって Drive Letter 無いよね？**

とは言え、ホスト名とか入れれば動くんでしょ、と思ってやってみたが、駄目だった。
ネットワークドライブの割当も駄目。

え、NAS 繋がらないの？ **意味ないじゃん！！**

しかし、MFT をつかっているであろう高速 Index ツールにも、共有フォルダに対応する物はあるので、できないことは無いのでは、と思っている。

今の所、対策は無い。

[追記]
さっき、出張から戻った同僚に相談したら、なんだかんだで MFT を取るのは難しんじゃないかと言う感じになった。
さて、次の方法を考えないと。。。

## 検索エンジンを Elasticsearch に

次に、検索エンジンを groonga から Elasticsearch に変更。
安定感、アクセスのしやすさ、情報の多さ、これまでの経験を含めて、Elasticsearch がベストと判断。

### 開発

Elasticsearch は Docker として立ち上げる。
これは、検索君のプロジェクトで管理している。

```yaml:docker-compose.yaml
version: '3'

services:
  es:
    container_name: es
    image: docker.elastic.co/elasticsearch/elasticsearch:6.1.1
    ports:
      - "9200:9200"
    volumes:
      - es_data:/usr/share/elasticsearch/data
    environment:
      - ELASTIC_PASSWORD=MagicWord
      - ES_JAVA_OPTS=-Xms512m -Xmx512m
      - transport.host=0.0.0.0
      - bootstrap.memory_lock=true
      - discovery.type=single-node
      # - cluster.name=full-house
      # - node.name=Jesse
    ulimits:
      memlock:
        soft: -1
        hard: -1
    networks:
      - default

...

volumes:
  es_data:
    driver: 'local'

```

Elasticsearch のイメージは、`library/elasticsearch ` ではなく `docker.elastic.co/elasticsearch/elasticsearch` が公式サポート版。

今の所、クラスタを組む気は無いため、`single-node` で動作している。

#### ◆ Elasticsearch に Bulk Insert

上記で Elasticsearch を立ち上げたら、まずは tansakukun の方から入れ込む。
C# から Elasticsearch を操作をする場合、以下クライアントが公式にサポートされている。

- [NEST](https://github.com/elastic/elasticsearch-net)
    - Higl Level Client と呼ばれている
    - LINQ っぽい Query DSL で操作可能
    - [NEST - High level client](https://www.elastic.co/guide/en/elasticsearch/client/net-api/current/nest.html)
- [Elasticsearch.Net](https://github.com/elastic/elasticsearch-net)
    - Low Level Client と呼ばれている
    - [Elasticsearch.Net - Low level client](https://www.elastic.co/guide/en/elasticsearch/client/net-api/current/elasticsearch-net.html)

**Bulk API が提供されているのは Elasticsearch.Net だけ** なので、コッチを使う。

```csharp
  public class BulkInsert
  {
    public static void execute(string host, int port, Dictionary<ulong, FileNameAndParentFrn> files, int chunkSize = 10000)
    {
      var settings = new ConnectionConfiguration(new Uri("http://" + host + ":" + port))
                          .RequestTimeout(TimeSpan.FromMinutes(2));
      var lowlevelClient = new ElasticLowLevelClient(settings);

      var index = DateTime.Now.ToString("yyyyMMddHHmmss");
      var type = "pathes";

      long id = 1;
      foreach (var chank in files.Values.Chunks(chunkSize))
      {
        var json = new List<object>();
        foreach (var file in chank)
        {
          json.Add(new Index(id, index, type));
          json.Add(new FileEntry(file.Path, ""));

          id++;
        }

        var indexResponse = lowlevelClient.Bulk<StreamResponse>(PostData.MultiJson(json));
        using (var responseStream = indexResponse.Body)
        {
          Console.Write(".");
        }
      }
      Console.WriteLine("");
    }
  }

  // from https://webbibouroku.com/Blog/Article/chunk-linq
  public static class Extensions
  {
    public static IEnumerable<IEnumerable<T>> Chunks<T>(this IEnumerable<T> list, int size)
    {
      while (list.Any())
      {
        yield return list.Take(size);
        list = list.Skip(size);
      }
    }
  }
```

```csharp:Index.cs
  public class Index
  {
    public Metadata index { get; set; }

    public Index(long id, string _index, string type)
    {
      index = new Metadata(_index, type, id.ToString());
    }

    public class Metadata
    {
      public Metadata(string index, string type, string id)
      {
        _index = index;
        _type = type;
        _id = id;
      }
      public string _index { get; set; }
      public string _type { get; set; }
      public string _id { get; set; }
    }
  }
```

```csharp:FileEntry.cs
  public class FileEntry
  {
    public string path { get; set; }
    public string date { get; set; }

    public FileEntry(string path, string date)
    {
      this.path = path;
      this.date = date;
    }
  }
```

ポイントとしては、

- 起動時間毎に新しい Index をつくる
- Index のオブジェクトと Data のオブジェクトを交互に渡すと、勝手に Serialize してくれる
- 10000 ファイルずつチャンクして Bulk

10000 ファイルでチャンクしたのは、HTTPリクエストと大量データ送信の程よい均衡点を探しての事。
設定ファイルで変更可能であり、現在は 2~50000 ファイルあたりでもパフォーマンス調査しているが、失敗もなく時間増加も線形的なので、もっと増やしてみたい。

先程の350万レコードを挿入して、大体 15 分程度だった。まぁまぁ早い。

※ 後で気付いたけど、**Elasticsearch に日本語検索のプラグイン入れてなかった**。パフォーマンス確実に変わるので、上記は参考程度に。

## Web サーバを Elixir 化

これは、以前の Ruby + Sinatra のままでも良かったのだが、クエリ検索の機能があまり優秀ではなかったりして、もう少し本格的にしていきたいなぁと。

で、そうなると Ruby + Sinatra だと少し不安だなぁ。ということで、 Elixir にした。
速度や安定感もさることながら、**データ加工** が素晴らしい。

#### ◆ Elixir

パイプラインやパターンマッチ等で流れるようにデータが加工できるのがとても良く、データを少し加工して返すシンプルな API サーバとしてとても優秀だと実感した。
以下の資料に、その素晴らしさがまとめられている。

[やや関数型を意識した風Elixir／Phoenixご紹介](https://www.slideshare.net/piacere_ex/elixirphoenix)

辛かったのは、まとまった良い情報が Web 上にあまり無いこと。
同時期に Rust も書いていたが、あっちは公式も充実していたし記事も多かった。
仕方なく、昔買った [プログラミングElixir](https://www.amazon.co.jp/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0Elixir-Dave-Thomas/dp/4274219151) を見ながら頑張った。

### 開発
メインプロジェクトとして、Elasticsearch 等クラスタ管理も仕切る。

[github.com/kentork/kensakukun](https://github.com/kentork/kensakukun)

#### ◆ 開発環境

- Windows 10
- VS Code
- Erlang OTP 20
- Elixir 1.6.0-dev

#### ◆ Elixir プロジェクト作成

Elixir を書くのも久しぶりなので、まずは Elixir の環境を作るところから始める。
以下、 [scoop は使えるものとして進める](https://qiita.com/kikuchi_kentaro/items/77793e4a21db6ffdb7cd)

本来は、Docker で動かしたかったが、変更監視 → Recompile の手法が見つけられなかったので、今回はローカルでの動作を前提とする。

```ps1
PS> scoop install elixir
```

次に、[gh コマンド](https://qiita.com/kikuchi_kentaro/items/f500f261d1292ebe2941) で Github プロジェクトを作り、Elixir プロジェクトとして初期化する。

```ps1
PS> gh create

# create kentork/kensakukun project

PS> gh cd kentork/kensakukun 
PS> cd ..
PS> mix new kensakukun 
# The directory "hoge" already exists. Are you sure you want to continue? [Yn] y
```

```bash
├── ./LICENSE.txt
├── ./README.md
├── ./config
│   └── ./config/config.exs
├── ./lib
│   └── ./lib/kensakukun.ex
├── ./mix.exs
└── ./test
    ├── ./test/kensakukun_test.exs
    └── ./test/test_helper.exs
```

#### ◆ 依存パッケージを追加

今回は、Phoenix 等のフルスタックフレームワークは要らないので、シンプルに plug だけの web サーバを考える。

```elixir:mix.exs
...
  defp deps do
    [
      {:cowboy, "~> 1.1"},
      {:plug, "~> 1.5.0-rc.0"},
      {:poison, "~> 3.1.0"},
      {:httpotion, "~> 3.0.3"}
    ]
  end
...
```

Webサーバは **cowboy** で、Rack 的な Web インターフェイスに **plug**。
JSON パーサとして **poison** を利用。
Elasticsearch へのクエリは、クライアントを使わずに **httpotion** でリクエストする。

#### ◆ Application

```elixir:kensakukun.ex
defmodule Kensakukun do
  use Application
  require Logger

  def start(_type, _args) do
    children = [
      Plug.Adapters.Cowboy.child_spec(:http, Kensakukun.Plug.Router, [], port: 8080)
    ]

    Logger.info("Started application")

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end
```

#### ◆ Routing

```elixir:plug/router.ex
defmodule Kensakukun.Plug.Router do
  use Plug.Router
  require Logger

  if Mix.env() == :dev do
    use Plug.Debugger
  end

  plug(Plug.Logger)
  plug(Plug.Parsers, parsers: [:json], json_decoder: Poison)

  plug(:match)
  plug(:dispatch)

  get "/api/oauth/google/client_id" do
    client_id = Application.fetch_env!(:kensakukun, :api_token)
    resp(conn, 200, client_id)
  end

  get "/api/search" do
    index =
      Kensakukun.Elasticsearch.get_indices()
      |> Enum.max_by(&Integer.parse(&1["index"]))

    result = Kensakukun.Elasticsearch.search_path(index["index"], conn.params["query"])

    pathes =
      Poison.encode(result)
      |> (fn {:ok, result} -> result end).()

    resp(conn, 200, pathes)
  end

  match _ do
    resp(conn, 404, "Not Found")
  end
end
```

クライアントで使う Google API 用の Client ID は、クライアントに埋め込見たくない。
ので、エンドポイントを 1つ設けてある。

#### ◆ Elasticsearch の操作

これは、通常の HTTP を用いる普通のやり方。特に無し。

350 万件から部分一致件検索で 1000件取得して 150 ms 前後。
以前に比べて数倍~数十倍早いんじゃなかろうか。

インクリメンタル検索も夢じゃない？

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/b2bd7015-cba2-e2f9-807f-72418a006496.png)

※ 後で気付いたけど、**Elasticsearch に日本語検索のプラグイン入れてなかった**。パフォーマンス変わるので参考程度に。

#### ◆ Docker 化

本番 ( と言っても社内だが ) に向けて、Docker 化はしておきたい。
公式のパッケージが Hex や Rebar を含んでおらず、入れていないと毎度聞かれるのが面倒なので、あらかじめ入れたイメージを作っておく。

```dockerfile:Dockerfile
FROM elixir:1.5.3-alpine
RUN mix local.hex --force && mix local.rebar --force
```

サービス定義はこんな感じ

```yaml:docker-compose.yaml
...
  api-init:
    container_name: api-init
    build: .
    volumes:
      - ./:/usr/src/app
      - elixir_cache:/usr/src/app/deps
    working_dir: /usr/src/app
    entrypoint: sh -c "mix deps.get && mix compile"
    networks:
      - default
  api:
    container_name: api
    build: .
    ports:
      - "8080:8080"
    environment:
      - MIX_ENV=prod
      - ES_HOST=es
      - ES_PORT=9200
    env_file: .env
    volumes:
      - ./:/usr/src/app
      - elixir_cache:/usr/src/app/deps
    working_dir: /usr/src/app
    entrypoint: mix run --no-halt
    networks:
      - default
...
```

渡した環境変数は、`config/prod.exs` で参照している。
`API_TOKEN` だけは、便宜上 `.env` から取得している。

```elixir:config/prod.exs
use Mix.Config

config :kensakukun,
  es_host: System.get_env("ES_HOST"),
  es_port: System.get_env("ES_PORT"),
  api_token: System.get_env("API_TOKEN")

```

動かす時は、一度初期化が必要。

```bash
docker-compose run --rm api-init
docker-compose up -d api
```

## フロントエンド

- Riot.js v3.7.4
- Bulma.css v0.6.1

ここは、ほぼ初代からの流用である。バージョンだけ上げている。
やはり、Riotjs + Bulma.css の組み合わせは、ぱっと作るのに向いている。

配信は、[Caddy](https://caddyserver.com/) が担当している。

```yaml:docker-compose.yaml
...
  web:
    container_name: web
    image: abiosoft/caddy:latest
    ports:
      - "443:2015"
    volumes:
      - ./Site/Caddyfile:/etc/Caddyfile
      - ./Site/public:/srv
    networks:
      - default
...
```

```
Site
├── Caddyfile
└── public
    ├── 404.html
    ├── component
    │   └── app.tag
    ├── css
    │   ├── bulma.min.css
    │   └── font-awesome.min.css
    ├── favicons
    │   ├── android-icon-144x144.png
    │   ├── ...
    │   └── ms-icon-70x70.png
    ├── fonts
    │   ├── FontAwesome.otf
    │   ├── ...
    │   └── fontawesome-webfont.woff2
    ├── images
    │   ├── drive.png
    │   ├── folder.png
    │   └── kensaku.png
    ├── index.html
    └── js
        ├── clipboard.min.js
        └── riot+compiler.min.js
```

```:Caddyfile
0.0.0.0 {
  root /srv
  gzip
  tls self_signed

  index index.html
  errors {
    404 404.html
  }

  proxy /api http://api:8080/ {
    transparent
  }
}
```

これだけで、 HTTPS + HTTP2 (+ quic) + Reverse Proxy。素晴らしい。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/2c870e9b-5b34-f656-0324-360ea622306a.png)

# Docker 化

ここまでで Docker 化終わってるので。

現在、ローカルでは一応動いている。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/811bafba-c033-6ecd-f66e-f36c663296c4.png)

# まとめ

検索は確実に早くなるでしょう。

しかし、Index 化が早くならなくては余り意味がない。
最新の情報があるという保証がなければ、サービスの価値は低い。
少なくとも、1日1回は走らせたい。その為には、せいぜい **30分~1時間以内** は欲しい。

とにかく、ネットワークの向こうの NAS をいかに高速に Index するか考えなくては。
MFT が取れるのか、それとも取れないのか。
MFT から取らないのなら、他に方法は無いのか。