こうこく
作 ▸

SQLiteのsqlite-vecでベクトル検索してみる (Node.js)

最近、RAGの話をよく聞くが、そもそもベクトル検索をやったことなかったのでSQLiteでやってみた。

sqlite-vssではなく、sqlite-vssの後継プロジェクトというsqlite-vecを使う。公式のドキュメントがまだ整っていないみたいなので、sqlite-vssのほうも参考にしつつ、サンプルをもとにカンでやっている。

※ベクトル検索のために何が必要で、どうやればいいのかを知りたかっただけなので、仕組みとかは説明しない。コードも最小限ではなく、適当に動かせる程度にいろいろ書いている。

Ubuntu (WSL) 24.04.1 LTSNode.js v22.14.0sqlite-vec 0.1.7-alpha.2better-sqlite3 11.9.1
もくじ

概要

ここではSQLiteの sqlite-vec (vec0) という仮想テーブルモジュールを使って、簡単なベクトル検索を行うTypeScript製のCLIアプリを作成する。

ベクトル検索のためには文字列の意味をベクトルに変換 (埋め込み = embedding) する必要があるらしいのだが、日本語をキレイにembeddingするには、日本語のわかる埋め込みモデルが必要である。ここでは何回やり直しても財布を気にしなくていいように、OllamaBGE-M3 (容量1.2GB、次元数1024) というモデルを動かしてAPIを叩いてベクトル化を行う。

BGE-M3がOllamaでインストールできてマルチリンガルであることと、そもそもOllamaのAPIでembeddingができるということは、ネットで検索してて以下の記事を見て知った。導入方法も参考にさせていただいた。

llama.cpp/ollamaでembeddingsを試す

全文検索のネタとしては、フロントマターつきMarkdownで書かれた自作小説を使う。Markdownの記法は使っていないが、いくらかHTMLタグを含む想定なので、クレンジングする必要がある。

やることとしては以下の通り。

  1. SQLiteにメタデータ用のテーブルとベクトル用の仮想テーブルを作成する。
  2. フロントマターつきMarkdownから抽出した小説の本文をチャンク化して、ベクトル化して、そのベクトルをSQLiteに格納する。(novel_search.db としてカレントディレクトリに永続化)
  3. 検索キーワードを指定して、SQLiteに格納された小説の本文からベクトル検索を行い、該当チャンクをJSON形式で標準出力する。

では順番に見ていこう。

(GPUを使う場合) NVIDIAのドライバーをインストール

以前に一度入れたことがあったが、PCを買い換えたのでまたインストールした。せっかくなので書いておく。

まず以下のサイトに行き、ドライバをダウンロードする。

Download The Official NVIDIA Drivers | NVIDIA

選択肢は手元の製品や環境に合わせて設定する。筆者の場合は以下の通り。

  • Product Type … GeForce
  • Product Series … GeForce RTX 40 Series (Notebooks)
  • Product … GeForce RTX 4060
  • Operating System … Windows 11
  • Language … Japanese

これで『Find』ボタンをクリックすると『NVIDIA Studio ドライバー』と『GeForce Game Ready ドライバー』のふたつが出てきたが、以前インストールしたときは『Studio』という字を見た気がしたので、『Studio』の方の『View』ボタンをクリックして、ダウンロードページに遷移した。

ダウンロードしたら、普通にWindows上で実行してインストールする。全て初期設定でよいと思うが、Nvidiaアプリというのは要らない気がしたので、NVIDIAグラフィックスドライバーのみをインストールした。

インストール完了したらWSL上で以下コマンドを実行して、WSL側からGPUが認識できているか確認する。

コマンド
nvidia-smi
結果
Sun Apr 13 19:01:59 2025       
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 570.133.07             Driver Version: 572.83         CUDA Version: N/A      |
|-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA GeForce RTX 4060 ...    On  |   00000000:01:00.0  On |                  N/A |
| N/A   43C    P8              4W /  115W |     422MiB /   8188MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
                                                                                         
+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|  No running processes found                                                             |
+-----------------------------------------------------------------------------------------+

真ん中あたりに『NVIDIA GeForce ...』と表示されており、『8188MiB』とVRAMが8GBであることも表示されているのでOK。

完了したら、コマンドプロンプトから以下コマンドで、一応WSLを再起動しておく。

コマンド
wsl --shutdown

Ollama + BGE-M3 のインストール

筆者はOllamaを使ったことがなかったので、使えるようにするまでにやったことを書いておく。

ここではWSL2のUbuntuで、DockerにてOllamaを立てた(?)。と言っても、やったことは完全に公式ドキュメント通り。

ollama/ollama - Docker Image | Docker Hub

コマンド
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
    | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
    | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
    | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update

sudo apt-get install -y nvidia-container-toolkit

sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

ここで Failed to restart docker.service: Unit docker.service not found. エラーが出たが、調べるのが面倒なので無視した。

以下コマンドでOllamaのコンテナを起動。GPUがついてるPCなので、GPUありの方法で起動してる。無い人は上記ドキュメントの「CPU only」を見てほしい。

コマンド
docker run -d --gpus=all -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama

これでポート11434でOllamaのAPIが叩けるようになるが、まだモデルが無い。

以下コマンドで、OllamaでBGE-M3を起動。このとき、ローカルにモデルが無ければダウンロードしてくれるけど、前述の通り1.2GBあることを留意。

コマンド
docker exec -it ollama ollama run bge-m3

そしたら、以下コマンドで試しに Generate Embeddings エンドポイントを叩いてみる。

コマンド
curl -X POST http://localhost:11434/api/embed -d '{"model":"bge-m3","input":"キリウ君"}' | jq
結果
{
  "model": "bge-m3",
  "embeddings": [[-0.039980777, 0.031594016, -0.018671291, "(省略)", -0.015887583, 0.003698323, 0.01148181]],
  "total_duration": 63926502,
  "load_duration": 21940501,
  "prompt_eval_count": 5
}

input は配列でも指定できるらしいが、ここでは単一の文字列を指定したのでレスポンスの embeddings の要素数は1。その中にある数値の配列が、embeddingされたベクトルらしい。ここの長さはモデルごとに異なっていて、BGE-M3は前述の通り1024で、これを次元数と呼ぶそうだ。

どんな文字列を指定しても出てくる次元数は同じなので、こんな単語よりももっと具体的な文章を指定した方が、意味的にしっかりしたベクトルができる……という理解を勝手にしている。

準備

ようやく本題。以下コマンドで必要なパッケージをインストール。

コマンド
npm install better-sqlite3 sqlite-vec langchain commander yaml typescript @types/node @types/better-sqlite3

SQLiteへのアクセスは better-sqlite3 を使う。ただこれ、pnpmでインストールすると実行時に Error: Could not locate the bindings file. エラーが出るみたいなので、このプロジェクトではnpmを使っている。

langchain は、テキストのチャンク化を手軽に行えるということで使用。

なお、今回の package.json とかはこんな感じ。CLIと言いつつ、どうせtsxで実行するので bin は設定してない。

package.json
{
  "name": "my-vector-similarity-search",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "tsc -p tsconfig.build.json"
  },
  "dependencies": {
    "@types/better-sqlite3": "^7.6.13",
    "@types/node": "^22.14.1",
    "better-sqlite3": "^11.9.1",
    "commander": "^13.1.0",
    "langchain": "^0.3.21",
    "sqlite-vec": "^0.1.7-alpha.2",
    "typescript": "^5.8.3",
    "yaml": "^2.7.1"
  },
  "devDependencies": {
    "@eslint/js": "^9.24.0",
    "eslint": "^9.24.0",
    "prettier": "^3.5.3",
    "tsx": "^4.19.3",
    "typescript-eslint": "^8.29.1"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES2022",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "downlevelIteration": true,
    "moduleResolution": "node10",
    "allowSyntheticDefaultImports": true,
    "declaration": true,
    "sourceMap": true
  }
}
eslint.config.mjs
import js from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  { ignores: ['dist'] },
  {
    extends: [js.configs.recommended],
    rules: {
      'no-irregular-whitespace': 'off',
    },
  },
  ...tseslint.configs.recommended,
  ...tseslint.configs.stylistic,
);

CLIのエントリーポイント

CLIアプリなので、 commander.js を使ってコマンドライン引数をパースする。

ここでは create-indexsearch の2つのサブコマンドを作成する。

create-index は、指定されたディレクトリに格納されたフロントマターつきMarkdownを舐めて、インデックスを作成するやつ。

search は、指定されたキーワードを用いて、作成済みのインデックスからベクトル検索を行うやつ。

src/index.ts
import { Command } from 'commander';
import { search } from './search';
import { createIndex } from './createIndex';

const program = new Command();

// "mycli" はテキトー、どうせ tsx で実行するので
program.name('mycli').description('小説のベクトル検索をするCLIアプリケーション').version('0.1.0');

// インデックス作成サブコマンド
program
  .command('create-index')
  .description('指定されたディレクトリのフロントマターつきMarkdownからインデックスを作成します')
  .argument('<source-directory>', 'インデックス作成元のファイルのディレクトリ')
  .action(async (directory) => {
    await createIndex({ dirPath: directory });
  });

// 検索サブコマンド
program
  .command('search')
  .description('指定されたキーワードの意味で小説を検索します')
  .argument('<query>', '検索キーワード')
  .action(async (query) => {
    const results = await search({ query, limit: 7, distance: 1.5 });
    if (results.length === 0) {
      console.log('結果がありませんでした。');
      return;
    }
    // 結果をJSONで標準出力
    console.log(JSON.stringify(results, null, 2));
  });

program.parse(process.argv);

テーブル作成~インデックス作成

インデックス作成してるとこ

create-index サブコマンドの実装。

これはエントリーポイントから呼ばれる関数。見どころとしては、ベクトル用の仮想テーブル novel_embeddings (次項で説明) に、ベクトル化したテキストをJSON文字列で格納してるところくらい。

ただこれ、Blobで格納したほうが容量を削減できるかも?らしい。sqlite-vss (vss0) でのやりかたは以下の記事で拝見したが、ここでは sqlite-vss ではなく sqlite-vec なので、一旦JSONのまま進めた。

sqlite-vss入門

src/createIndex.ts
import { Database } from 'better-sqlite3';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { getDatabase, initializeDB, isInitialized } from './libs/dbManager';
import { loadMarkdownWithFrontMatterFiles } from './libs/fileLoader';
import { createTextChunks, generateEmbeddings } from './libs/textProcessor';

interface CreateIndexParams {
  dirPath: string;
}

interface IndexData {
  metadata: {
    series: string;
    episode: string;
    title: string;
    content: string;
  };
  embeddings: {
    content: number[];
  };
}

/**
 * 指定されたディレクトリのフロントマターつきMarkdownからインデックスを作成する
 * @param dirPath
 */
export async function createIndex({ dirPath }: CreateIndexParams): Promise<void> {
  if (!(await fs.lstat(dirPath).catch(() => false))) {
    console.error(`指定されたディレクトリが存在しません: ${dirPath}`);
    process.exit(1);
  }

  // データベースが初期化されてなければ初期化
  const db = getDatabase();
  if (!(await isInitialized(db))) {
    initializeDB(db);
  }

  // 小説ファイル読み込み
  // 型引数のメタデータは、お手元のMarkdownのフロントマターに合わせてください
  const novelFiles = await loadMarkdownWithFrontMatterFiles<{ series: string; title: string }>(dirPath);

  console.log(`インデックス作成開始...`);
  const startTime = Date.now();

  // チャンクに分割してベクトル化してインデックスに追加
  for (const file of novelFiles) {
    const { metadata, content } = file;

    // チャンクに分割
    const chunks = await createTextChunks(content);
    for (const chunk of chunks) {
      const episode = path.basename(file.path, path.extname(file.path)); // 拡張子なしのファイル名
      const rowId = addToIndex(db, {
        metadata: {
          series: metadata.series,
          episode,
          title: metadata.title,
          content: chunk,
        },
        embeddings: {
          content: await generateEmbeddings(chunk),
        },
      });
      console.log(`インデックス追加: rowid=${rowId} ... ${metadata.series}/${episode}`);
    }
  }
  console.log(`インデックス作成完了: ${(Date.now() - startTime) / 1000}s`);
}

/**
 * 小説の内容をインデックスに追加する
 * @param db データベース接続
 * @param data インデックスに追加するデータ
 * @returns rowid
 */
function addToIndex(db: Database, { metadata, embeddings }: IndexData): number {
  // トランザクション
  let rowId: number;
  const transaction = db.transaction(() => {
    // ベクトル
    const insertContent = db.prepare(
      'INSERT INTO novel_embeddings (series, episode, title, content, content_embedding) VALUES (?, ?, ?, ?, ?)',
    );
    const res = insertContent.run(metadata.series, metadata.episode, metadata.title, metadata.content, JSON.stringify(embeddings.content));
    rowId = res.lastInsertRowid as number;
  });
  transaction();
  return rowId!;
}

テーブル作成してるとこ

次はテーブルを作っているところだ。DBのコネクションを取得したら、sqlite-vec のエクステンションをロードするのを忘れずに。

vec0の仮想テーブル novel_embeddings を作っているところが少し特殊で、ベクトルを格納するための属性を持つテーブルを作成している。このテーブルは自分でID列を決めることはできないみたいで、必ず rowid というINTEGER列を使うことになる。それと、ベクトルの列は次元数と合わせた配列長を指定する必要があるため、埋め込みモデルを変えた場合はおそらくテーブルを作り直す必要がある。

それと sqlite-vec はベクトルと同じテーブルにメタデータを持てる。すごい斜め読みした限りでは、これは sqlite-vss との大きな違いに見えるけど、「フルスキャンすると遅い」とドキュメントに書いてあるので、WHERE条件に指定する場合には気を付けた方がいいのかもしれない。

ところで sqlite-vss では、ベクトルのカラムの factory オプションでFaissのインデックス方式を指定できたみたいなのだが、 sqlite-vec には無いように見える。というかFaissという単語もドキュメントに出てこないような?

src/libs/dbManager.ts
import path from 'node:path';
import DatabaseConstructor, { Database } from 'better-sqlite3';
import * as sqliteVec from 'sqlite-vec';

// DBファイルのパス
const DB_PATH = path.join(process.cwd(), 'novel_search.db');

/**
 * データベース接続を取得
 */
export function getDatabase(): Database {
  const db = new DatabaseConstructor(DB_PATH);
  db.loadExtension(sqliteVec.getLoadablePath()); // sqlite-vecをロード
  return db;
}

/**
 * データベースファイルを初期化済みならtrueを返す
 * @param db データベース接続
 * @returns
 */
export function isInitialized(db: Database): boolean {
  // テーブルの存在を確認
  const result = db
    .prepare<[], { name: string }>(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'novel_embeddings'`)
    .get();
  return !!result;
}

/**
 * 必要なテーブルを作成する (あれば作り直す)
 * @param db データベース接続
 */
export function initializeDB(db: Database) {
  // ベクトル検索用の仮想テーブル
  // https://alexgarcia.xyz/sqlite-vec/features/vec0.html
  db.exec(`DROP TABLE IF EXISTS novel_embeddings`);
  db.exec(`
    CREATE VIRTUAL TABLE novel_embeddings USING vec0 (
      -- sqlite-vec はメタデータをベクトルと同じテーブルに持てる
      series TEXT,
      episode TEXT,
      title TEXT,
      content TEXT,  -- ベクトル化前のテキスト
      content_embedding float[1024]  -- ベクトル化されたテキスト
    );
  `);
}

フロントマターつきMarkdownの読み込み

次は、フロントマターつきMarkdownを読み込んでるところ。

しきりに『フロントマターつきMarkdown』と書いているが、具体的には AstroのContent Collections で管理している本家サイトの小説を想定してるので、*.md だけでなく *.mdx も読み込むようにしてたりする。

ここではまだテキストをそのまま抜き出してるだけで、加工などは行っていない。

src/libs/fileLoader.ts
import { promises as fs } from 'node:fs';
import path from 'node:path';
import YAML from 'yaml';

export interface ParsedFile<TMetadata> {
  path: string;
  metadata: TMetadata;
  content: string;
}

/**
 * 指定されたディレクトリから全てのフロントマターつきMarkdownファイルを読み込み、メタデータと本文に分離
 * @param dirPath 対象ディレクトリパス
 * @returns
 */
export async function loadMarkdownWithFrontMatterFiles<TMetadata>(dirPath: string): Promise<ParsedFile<TMetadata>[]> {
  // ディレクトリ内のすべての .md or .mdx ファイルを取得
  const mdFiles = (await fs.readdir(dirPath)).filter((file) => {
    const ext = path.extname(file);
    return ext === '.md' || ext === '.mdx';
  });

  // 各ファイルをパース
  const result: ParsedFile<TMetadata>[] = [];
  for (const fileName of mdFiles) {
    const filePath = path.join(dirPath, fileName);
    const mdxStr = await fs.readFile(filePath, 'utf-8');

    // フロントマターと本文を分離
    const parts = mdxStr.split('---');
    if (parts.length < 3) {
      console.warn(`フロントマターの形式が不正です: ${filePath}`);
      continue;
    }

    // parts[0]は通常カラ、parts[1]がフロントマター、parts[2]以降が本文
    const frontMatter = parts[1].trim();
    const metadata: TMetadata = YAML.parse(frontMatter);
    const content = parts.slice(2).join('---').trim();

    result.push({ path: filePath, metadata, content });
  }

  return result;
}

テキストのベクトル化

次は、テキストをベクトル化しているところだ。ここでベクトル化したものを、上記のSQLiteの novel_embeddings テーブルに格納することになる。

テキストをベクトル化する前に、検索にヒットさせたい意味の単位でテキストを分割 (チャンク化) する必要があるので、 createTextChunks 関数を用意している。ここでは小説の本文全体でベクトル検索を行うのが目的なので、LangChainのText splittersを使って500文字くらいで機械的に分割しているだけ。

generateEmbeddings 関数は、パラメータのテキストをOllamaのAPIでベクトル化してるだけ。

src/libs/textProcessor.ts
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';

interface OllamaEmbedRequest {
  model: string;
  input: string | string[];
}

interface OllamaEmbedResponse {
  model: string;
  embeddings: number[][];
  total_duration: number;
  load_duration: number;
  prompt_eval_count: number;
}

/**
 * テキストを前処理してチャンクに分割する
 * @param text 処理するテキスト
 * @returns
 */
export async function createTextChunks(text: string): Promise<string[]> {
  // HTMLタグを除去、改行と全角スペースを半角スペースに置換、連続するスペースを1つにまとめる
  const cleanedText = text
    .replace(/<[^>]*>/g, '')
    .replace(/\r?\n/g, ' ')
    .replace(/ /g, ' ')
    .replace(/\s+/g, ' ')
    .trim();

  // テキストをチャンクに分割
  // RecursiveCharacterTextSplitter は、意味の一貫性を維持するために段落などをあまり割らないでいてくれるらしい
  // https://js.langchain.com/docs/concepts/text_splitters/#text-structured-based
  const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500, // 文字数
    chunkOverlap: 50, // 先頭と末尾の重複文字数
  });
  const chunks = await textSplitter.splitText(cleanedText);
  return chunks;
}

/**
 * テキストをOllamaのAPIでベクトル化する
 * @param text 処理するテキスト
 * @returns
 */
export async function generateEmbeddings(text: string): Promise<number[]> {
  // https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings
  const response = await fetch('http://localhost:11434/api/embed', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ model: 'bge-m3', input: text } satisfies OllamaEmbedRequest),
  });

  if (!response.ok) {
    throw new Error(`Ollama APIエラー: ${response.statusText}`);
  }

  const data: OllamaEmbedResponse = await response.json();
  return data.embeddings[0]; // 最初のチャンクのベクトルを返す
}

全文検索するとこ

最後に検索の実装。指定されたキーワードをベクトル化してクエリを実行することで、意味が近いチャンクを検索し、メタデータとともに返す。

検索結果のソートに使用している distance 列は、vec0が自動で追加してくれる列で、デフォルトだとL2 distanceがセットされるらしい。単純にクエリとチャンクの距離ということでよいか? これを昇順で並べることで、一致度の高い順に並ぶ。ここでは取得結果にそのまま distance 列を含めているので、次項のサンプルにて実際の値を載せる。

src/search.tsimport { getDatabase } from './libs/dbManager';
import { generateEmbeddings } from './libs/textProcessor';

interface SearchResult {
  rowid: number;
  distance: number;
  title: string;
  series: string;
  episode: string;
  content: string;
}

interface SearchParams {
  query: string;
  limit: number;
  distance: number;
}

/**
 * 指定されたキーワードで小説を検索して、結果を返す
 * @param query 検索クエリ
 * @returns 検索結果の配列
 */
export async function search({ query, limit, distance }: SearchParams): Promise<SearchResult[]> {
  // クエリをベクトルに変換
  const queryEmbedding = await generateEmbeddings(query);
  console.log(`「${query}」で検索...`);

  const db = getDatabase();

  // 検索SQL
  // https://alexgarcia.xyz/sqlite-vec/features/knn.html
  const stmt = db.prepare<unknown[], SearchResult>(`
      SELECT 
        rowid,
        distance,
        title,
        series,
        episode,
        content
      FROM 
        novel_embeddings
      WHERE
        content_embedding MATCH ?
        AND k = ?  -- SQLite 3.41 未満ではLIMITの代わりにkを使うらしい https://github.com/asg017/sqlite-vec/issues/116
        AND distance <= ?
      ORDER BY
        distance
    `);

  // 検索実行
  const rows = stmt.all(JSON.stringify(queryEmbedding), limit, distance);

  // 結果を返却
  return rows;
}

実行してみる

以下のような感じで実行する。(横着してtsxで実行してるが、ビルドしてnodeコマンドで実行してもよい。)

# インデックス作成
npx tsx src/index.ts create-index /path/to/novels

# 検索
npx tsx src/index.ts search "空を飛ぶキリウ君"

ちなみにインデックスの作成にかかった時間は、ここでは計207チャンク (チャンクあたり500文字, オーバーラップ50文字) で、以下の通りだった。

  • CPUのみ: 121秒 ... 13th Gen Intel(R) Core(TM) i7-13650HX
  • GPUあり: 21秒 ... NVIDIA GeForce RTX 4060 Laptop GPU

で、検索結果は以下の通り。

ちょっと文章が長いのと、自作小説なので他人には何がなんだかだと思うが、筆者的にはこれはけっこう面白い。ちゃんとキリウ君なる生物が飛行しているシーンが抽出されている。そして最後のチャンク (rowid=64) については、空は飛んでないけど石段から跳び下りるシーンが抽出されている。

[
  {
    "rowid": 140,
    "distance": 0.8354104161262512,
    "title": "27.アッシーの憂鬱",
    "series": "issho",
    "episode": "27",
    "content": "キリウ君は地面に足を着けずに車体の上からそのままふわりと浮かび上がり、オレンジと藍のグラデーションを背に男を見下ろして、外したヘルメットを投げ渡してきた。そして地べたを這いずり回る生き物にも届くようにか、少し張り上げた声で言った。 「手伝ってくれてありがとう。楽しかったよ」 「オレはつまんなかったけどな」 最後まで子供みたいな罵倒を浴びせられた後、少しだけ子供みたいに笑ったキリウ君は、ヘルメットでぐしゃっとした髪を沈みかけの夕日にきらめかせて、凄い勢いで空の向こうへと飛んで行った。キリウ君が飛び去って行った空を前に立ち尽くしたまま、男はタバコに火を灯したくなったが、駐車場に着いてからにしようと思い直し再びバイクのエンジンをかけた。 あんなシンプルで速いものがあるくせに、足なんて必要の無いものを求めてわざわざ他人に絡んでくること自体が冷やかし以外の何物でもないのだと、彼は本気で気付いていないのだろうか?"
  },
  {
    "rowid": 117,
    "distance": 0.8464230298995972,
    "title": "24.非実在、少年",
    "series": "issho",
    "episode": "24",
    "content": "最近、キリウ君が夜中にめっちゃくちゃ空飛んでる気がする。 その姿を見たわけじゃないけれど、飛んできた後のキリウ君はなんとなく雰囲気が違うので判るのだった。光の翅の気配が残っているかのように、人間の形をした人間ではない何かのような雰囲気。それに、あの薄ぼんやりした瞳。程度は段階的だが、キリウ君は時々そういう目になる。微かな変化にも関わらず、覗き込まなくても見分けがつく気がするのは、ジュンが彼の弟をやっているからかもしれない。 もう十二月なのに夜空は寒くないのだろうか、とジュンは思った。ジュンはあまり夜更かしをしないので、彼の深夜徘徊癖がどのくらい深刻なのかが分からなかった。少なくともご近所さんから苦情は来ていないので、ベランダ以外の場所から飛び立っているのだろう。 その予感はすぐに裏が取れた。少し夜更かしして注意深く見ていると、どうやらキリウ君は、毎晩へとへとになるまで外を飛び回ってから帰宅しているようだった。気がかりなのでジュンが直接本人に訊いてみたところ、彼は「海行きたいから」と答えた。 「空飛んでたら、キリウ君じゃなくなれる気がする」"
  },
  {
    "rowid": 182,
    "distance": 0.885509192943573,
    "title": "6.壊れかけのキリウ君",
    "series": "issho",
    "episode": "6",
    "content": "「キリウ君だろ。探してたんだ」 にわかに血の気が戻りだした彼の顔は、見れば見るほど見慣れた造形だった。そうも凝視しながら話しかけたジュンに向かって、なぜか彼はへらっと微笑んだ。 その直後、どこからかプラスチックが砕けるような音がするとともに、彼の背中に、身長ほどもある銀色の光の翅のようなものが浮かび上がっていた。 暴風を伴って一瞬で飛び立った彼は、唖然としているジュンを残して、もの凄い勢いで夜空を飛び回っていた。あひゃひゃひゃひゃと高らかな彼の笑い声が暗黒の空に響き渡り、しかし彼自身のスピードにかき乱されたそれは、たいぶ不思議な風合いとなって深夜の街に降り注いでいた。 逃げられてしまう、と急にジュンは現実的な不安に捕われて声を上げそうになった。しかしミーちゃんに袖を捕まれたことで口を閉じた。ミーちゃんは空に描かれたぐちゃぐちゃな光の軌道を指で示して、「もどってくると思うよ」と言った。"
  },
  {
    "rowid": 183,
    "distance": 0.8855963945388794,
    "title": "6.壊れかけのキリウ君",
    "series": "issho",
    "episode": "6",
    "content": "後で彼女が話してくれたところによると、べつに明確な根拠があったわけではなく、単に彼女はジュンを落ち着かせるためにそう言ったのだそうだ。とはいえそれはミーちゃんのふんわりとした言語処理エンジンの言い分であり、実際のところミーちゃんは、彼の飛行軌道からなんとなくキリウ君が意思を持って飛んでいそうなことを判断していた。(人形というのは人工物であるぶん、そういうところはわりとしっかりしており、ジュンもその点に信頼を置いている。) そして実際に――ぐるっと辺り一帯を飛び回ったあと、覚醒したキリウ君はジュンの前に戻ってきた。 キリウ君は四枚の翅を引っ込めずに、地面から八十センチ浮いたままじっとジュンを見ていた。ジュンは気圧されながらも、今度は彼の手首を掴んで再び彼を地面に引っ張り下ろした。そしてもう逃がしてはならないと、手を離さずに彼の目を見て言った。 「うちに来なよ」 しかしこの時ジュンは、自分で決めたことだけどこのキリウ君は兄よりイカれてるところがあるし気が合わないかもしれないな~~、などと無責任に思ってもいた。 「どうして?」"
  },
  {
    "rowid": 118,
    "distance": 0.8931155204772949,
    "title": "24.非実在、少年",
    "series": "issho",
    "episode": "24",
    "content": "「空飛んでたら、キリウ君じゃなくなれる気がする」 それは彼なりの努力なのだろうか。それとも逃避なのだろうか。 ジュンは、よくわからないが特に止めなくてもいいかと思った自分に少し驚いた。全てキリウ君がしたいようにすればいいのだ。 「事故には気を付けて」 ジュンがそう言うと、キリウ君は無言で頷いた。もう一つ、ジュンは付け足した。 「それと、ちゃんと帰って家で寝るように」 最近、キリウ君は困ったような顔でジュンを見ることがある。 * * * 「こないだのあいつさー。みなしごなんだって」 台所の床にあれやこれやを広げての換気扇掃除中に、唐突にキリウ君が言った。 その『あいつ』が指すものは、包丁を持って玄関にいた例のキリウ君のことだと言外にジュンは理解した。ジュンはレンジフードをブラシで擦る手を止めて尋ねた。 「生産所産じゃなくて?」 「捨て子」 キリウ君は、拭き掃除中のコンロに目を落としたままだった。 「同情なんかしないよ。っていうか、ぼくたちが会ったキリウ君って、みんなそんな感じじゃん」 気乗りのしない話題に、ジュンは明け透けというより投げやりな気持ちになっていた。"
  },
  {
    "rowid": 16,
    "distance": 0.8978223204612732,
    "title": "11.シャボン玉飛んだ",
    "series": "issho",
    "episode": "11",
    "content": "しかしどういうわけか最もジュンの頭に焼き付いて離れなかったのは、あの運転席の男の暗い目だった。 ことが終わったあと、キリウ君(B)に奪われていたジュンのスマホをキリウ君(A)が回収しようとしたとき、いつの間にかキリウ君(B)のそばにあの男が立っていたのだ。彼は放心した様子で何も言わず、頭をかち割られて血の海に沈んだキリウ君(B)をただ暗い目で見下ろしていた。キリウ君(A)はそれを気にも留めずキリウ君(B)のポッケを探っていたが、目的を果たして離れようとした時、ふとキリウ君(A)が男の顔を一瞥した。おそらく、目が合っていたようジュンには見えた。それからキリウ君は小走りでジュンの元に戻ってきて、背中に銀色の翅を出現させると、ジュンを担いでふわりと宙に浮き上がった。"
  },
  {
    "rowid": 64,
    "distance": 0.9032438397407532,
    "title": "17.海に行こう",
    "series": "issho",
    "episode": "17",
    "content": "けれど、最近のキリウ君はそうでもないみたいだった。確かに当初は距離感を測りかねている素振りがあったが、いつの間にかそこそこ馴染んでおり、時にはジュンとは別の方向性で適当に彼女と遊んでいるようだった。ミーちゃんもキリウ君がかまってくれるから嬉しそうで、以前にも増してキリウちゃんキリウちゃんと纏わりついているのだった。 そういう光景を見ているとジュンは、これは有り得た未来なのかな、と不思議に思う。 そうだったらいいな……。 白い石畳がまぶしくなって、ジュンは俯くのをやめて前を見た。だだっ広い道路に沿って生えるまばらな街並みは、今日は息づく音が聴こえるかのように有機的に感じられた。太陽の光の具合がそうさせるのかもしれない。 「海いこー、海」 気の抜けた声と共に海浜公園の方へと続く石段を示したミーちゃんが、砂浜を歩くために選んだ靴のカカトを鳴らして一目散にそちらへ降りて行った。それを見送ったキリウ君は、ふいにジュンを振り返って不敵に笑った。ジュンが何も言わずに見ていると、彼は数メートル離れたところから唐突に走り出して、十段以上あるそれを一気に跳び下りた。"
  }
]

他にも『悲しいシーン』『暴力的なシーン』なんてワードで検索すると、ちゃんとそれに合ったチャンクが抽出されてくる。いずれにせよ、『意味で検索する』ということがよくわかる結果だった。

以上。

この記事に何かあればこちらまで (非公開)