前回までのまとめ
第1回目では、
- ベクトル検索の基本的な仕組み
- 「素の」PostgreSQLでベクトル検索実践
第2回目では、
- pgvectorでPostgreSQLをベクトルDBにする
- アプリケーションとDBを分離して高速検索
- インデックスについて
第3回めでは、
- 大量データの投入
- 文書データのチャンキング
- チャンキング方式の改良
について解説しました。
さて今回は、クエリを「ピンポイントで探す」手法(=キーワード検索)とベクトル検索で「ふわっと探す」手法をミックスした ハイブリッド検索 について解説して行きます。
まず、これまで解説してきた「ベクトル検索」と、今回から導入する「キーワード検索」の考え方はかなり異なります。
しかし、文書同士の「類似性」を何らかの方法で計算し、順位を決めるという目的は同じです。
そこに至るアプローチが異なっているというだけです。
それでは、「キーワード検索」はどのようなアプローチで「類似性」を算出しているのでしょうか?
まずは、最もシンプルな形の「キーワード検索」から、順を追ってお話をしたいと思います。
単純キーワード検索
1対の文書もしくは文書の断片が 「似ている」 かどうかを判定したいとします。
1つめの文書を 「クエリ」 (=探すための手掛かり)と呼ぶことにします。
例)
(クエリ):「猫」
(文書1):「猫が走り回っている」
(文書2):「犬が走り回っている」
何となく「文書1」の方が「クエリ」と関連性がありそうですよね?
なぜかというと、「猫」という単語が含まれているのが「文書1」だからです。
なので、
文書にクエリが含まれている
かどうかで 「類似性」 を決めるというのが、最もシンプルな判定法 と言えそうです。
※実際は「クエリ」が複数の単語で構成されている場合も考えなければいけないのですが、いったんここでは分かりやすくするために「1単語のみ」のケースとして扱います。
そうすると、判定結果は
「含まれている」
「含まれていない」
の 2択 になります。
このような検索結果は、SQLにおけるLIKE検索で簡単に得られます。
WHERE content LIKE '%猫%'
という書き方ですね。
もしくはオフィスなどでの「Ctrl + F」の検索とほぼ同じです。
このような検索方法でも全然問題がないケースもありますが、ただ「類似度」の評価が「2択」なので、
結果に順位を付けることが出来ない
という欠点があります。
つまり、
(文書3):「白猫と黒猫が走り回っている」
(文書1):「猫が走り回っている」
がヒットしたとしても、この両者の 「類似度」の大小が比較出来ない のです。
この場合、直感的には
(文書3):「白猫と黒猫が走り回っている」
の方が「猫」がいっぱい出てくるので、優先したくなりますね。
そこで、 類似度を数値化 するため登場するのが TF(Term Frequency) です。
TF(Term Frequency)という考え方
TF(Term Frequency)は、文書における 単語の出現回数 を示す指標です。
話は単純で、特定の単語がその文書に登場する回数を数えているだけです。
(クエリ):「猫」
(文書1):「猫が走り回っている」
→ 1回現れる→ TF=1
(文書2):「犬が走り回っている」
→ 0回現れる→ TF=0
(文書3):「白猫と黒猫が走り回っている」
→ 2回現れる→ TF=2
これで「文書3」が「文書1」よりも「似ている」ことを数値化でき、順位を付けることが可能です。
数式風に表すと
TF(t,d) = 「文書d」の中で「単語t」が現れる回数
※(t=単語、d=文書)
となります。
これで解決と言いたいところですが、実はこの方式も重大な欠点を持っています。
この方式だと「長い文書」がどうしても優位になってしまいます。例えば、以下のようなケースです。
(文書4):「猫が好きな田中さんは、毎朝近所の野良猫に餌をあげていた。ある日、その猫が子猫を連れてきたので、思わず「こんなに可愛い猫を放っておけない」と呟き、結局その猫たちを全員家に迎え入れることにした。」
→ 6回現れる→ TF=6
検索上位に長い文書が表示される一方、短い文書は最後尾に回され、ほぼ目に触れないというのは困ったものです。
しかも 「完全一致」 する文書(「猫」に対して「猫」など)の類似度が相対的に低いというのも感覚的に合いません。
そこで、この問題を解消するため 「正規化」 されたTF(Term Frequency)のバリエーションもあります。
※正規化 ⇨ ある一定の分布になるように変換すること
式で言うと
TF(t,d) = \frac{「文書d」の中で「単語t」が現れる回数}{その文書の単語数}
何をしているかと言うと、「単語の登場回数」を「その文書の単語数」で割っています。
※日本語の場合の「単語数」はどう求めるのか(助詞などの扱いetc)は考察を要する問題で、実際は形態素解析エンジンを使って求めることが多いです。
イメージとしては、その文書における、特定の単語の「密度」を示していると言っていいと思います。
この場合、
最大値が「1」→「完全一致」
最小値が「0」→「含まれていない」
になります。
そして、
(文書1):「猫が走り回っている」
であれば、単語数=5として
TF = 1/5 = 0.2
となります。
さらに、
(文書4):「猫が好きな田中さんは、毎朝近所の野良猫に餌をあげていた。ある日、その猫が子猫を連れてきたので、思わず「こんなに可愛い猫を放っておけない」と呟き、結局その猫たちを全員家に迎え入れることにした。」
という文書であれば、単語数=65として、
TF = 6/65 ≒ 0.09
となり、文書が長いからと言って有利にはなりません。
※単に「TF」と言えば、こちらの正規化されたバージョンを指すのが一般的です。
ただこれでも、まだ重要な欠点があります。
今回のクエリが「猫」という単独の単語なので表面化はしないのですが、クエリが
「茶トラの猫は人気があります」
のような文書である場合を考えます。
この場合、クエリを単語に分解して、合計の類似度を出すような計算をするのですが、
茶_トラ_の_猫_は_人気_が_あり_ます
のように分解されることになります。
そうすると、
「の」「は」「が」「あり」「ます」
も単語と認識され、
あっちこちの文書にヒットしてしまう
のです。
要するに
「の」「は」「が」「あり」「ます」
のような単語が 「ありふれ過ぎている」 ということが原因であり、
この方式だとユーザーの求めるものと大分違う検索結果になる恐れがあります。
そこで、この問題の対策として、 単語の「珍しさ」 を図るための指標が導入されました。
それが 「IDF」 です。
IDF(Inverse Document Frequency)は「希少性(珍しさ)」
「IDF」は、
ある単語の「文書集合全体」における「珍しさ」
を示す指標になります。
ここで重要な点は、
- 「TF」は 単独の「文書」 がベース
- 「IDF」は 「文書集合全体(すべての文書)」 がベース
ということです。
数式っぽく行くと、
IDF = \log \frac{全文書数}{その単語が出てくる文書数}
になります。
分解しましょう。
まず、
\frac{全文書数}{その単語が出てくる文書数}
ですが、なぜ「全文書数」が分母にならないのか?と思われる方がいるかも知れません。
その理由としては、IDFは 「珍しさ」 を評価するための指標なので、
出てくる文書数が少ない → スコアが高い
になっている方が扱いやすく、この関係が成立するよう分子と分母を逆転している(=逆数を取っている)のです。
logについては、数値の 「上がり方を抑える」 くらいに捉えてもらってOKです。
1->10->100 が 1->2->3
になるようなイメージです。
改めて式にすると、
IDF(t) = \log \frac{N}{df(t)}
N = 総文書数
となります。
これで「その単語の珍しさ」を評価できるようになりました。
では、次に、先に算出した TF と合成してみましょう。
TF-IDF(Term Frequency-Inverse Document Frequency)
単語の出現回数の指標である「TF」に、その単語の珍しさを示す「IDF」を掛け合わせたものが、 「TF-IDF」 になります。
こうすることにより、以下の効果が得られます。
- 「です」や「は」、「が」など⇨ 「相当ありふれた」単語
👉ペナルティがかかる - 「猫」⇨文書集合全体で 「局所的に」現れる単語
👉有利になる
つまり、より価値がありそうな(=ピンポイントで当たっていそうな)文書を拾えるよう、調整をしているということになります。
式は以下になります。
TF\text{-}IDF = TF(t,d) \times IDF(t)
この「TF-IDF」をクエリの中に含まれる単語ごとに計算し、合算したものがスコアになり、スコアの高いものから並べれば検索結果が得られます。
しかし、この方式にも課題点があり、現在ではこの「TF-IDF」を改良した「BM25」というアルゴリズムが主流になっています。
BM25
「TF-IDF」はシンプルで理解しやすいアルゴリズムですが、実際にスコアを出すと
「結果がまだ感覚値と合わない」
→ もっとシビアな「正規化」が出来ないものか
というような要望が出て来ました。
そこで、より精緻な正規化を行う「BM25」という計算法が登場し、現在の検索エンジンでは、この「BM25」が事実上の標準となっています。
「BM25」は「TF-IDF」をベースにいくつかの調整(正規化)が組み込まれており、式としては以下になります。
score(D,Q)=\sum IDF(t) \cdot \frac{TF(t,D) \cdot (k+1)}{TF(t,D)+k\cdot(1-b+b\cdot\frac{|D|}{avgdl})}
※TF(t,D)は正規化されていないバージョン(単語の登場回数そのまま)を用います
※ k, b は調整パラメータ
特にこの式を完全に理解する必要は全くなく、
「TF-IDF」に対して「調整」が入り、より直感に近い結果を算出している
くらいの理解で大丈夫です。
ただ、ひとつ注目すべき要素として
avgdl
というものが加わっています。
avgdlは「文書あたりの平均単語数」を意味します。
これに対し|D|は「その文書の単語数」を意味しているので、
\frac{|D|}{avgdl}
が1より大きければ該当文書が「平均より長い文書」、1より小さければ「平均より短い文書」ということを表します。
この数値に係数bを掛けたものが分母に来ているので、長い文章に対してペナルティがかかっている(長い文章を有利にしない)ということが理解できるかと思います。
さらに、係数の調整によってこの「ペナルティの度合い」が調節できるので、より 実務寄りの考え方 になっているということが言えると思います。
BM25の式に登場する k と b は、スコアの「効き方」を調整するためのパラメータです。
おおまかには、以下のような役割を持っています
- k(1.2〜2.0) → TF(単語の出現回数)の効き具合
- k が小さい → 1回でもかなり重要(早く飽和する)
- k が大きい → 出現回数の差がスコアに効きやすい
- b (0.75) → 文書の長さ補正の強さ
- b = 1 → 文書の長さをしっかり補正する(短文有利)
- b = 0 → 文書の長さは無視する(長文有利)
式についてのより詳しい解説と、k b の調整方法については以下の記事が詳しいのでご参照ください。
BM25検索の実践
まずは、準備です。
TF-IDFおよびBM25では「単語数」を割り出す必要があり、英語であれば単語がスペースで区切られていて比較的簡単に求められるのですが、日本語はすべてが「くっついている」ため、何らかの方法で分割する必要があります。
この作業には動詞や助詞の活用、漢字とカナの区別など考慮すべき点が多く、日本語のコンピュータ処理の足枷になっている頭の痛い問題です。
ただ現在は「形態素解析」という技術があり、これを使ってPythonなどで簡単に単語分割を行うことができるようになっています。
代表的なものとして「Sudachi」というライブラリがあり、このライブラリのPython版が「SudachiPy」になります。
今回はこの「SudachiPy」を導入しようと思います。
まずDockerfileを編集します。
FROM pgvector/pgvector:pg15
RUN apt-get update && apt-get install -y python3 python3-pip python3-venv
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install sentence-transformers numpy psycopg2-binary sudachipy sudachidict_core
コンテナを再ビルドします(数分以上かかります)
docker compose up -d --build
以下がBM25検索用のPythonコードです。bm25_search_ld.pyとてもしておきましょう。
import psycopg2
import math
from collections import defaultdict, Counter
import json
from sudachipy import tokenizer
from sudachipy import dictionary
# -----------------------------
# DB接続
# -----------------------------
conn = psycopg2.connect(
host="localhost",
database="ragdb",
user="user",
password="password"
)
cur = conn.cursor()
# -----------------------------
# Sudachi初期化
# -----------------------------
tokenizer_obj = dictionary.Dictionary().create()
mode = tokenizer.Tokenizer.SplitMode.C # 精度と自然さのバランス
# -----------------------------
# Sudachiトークナイズ
# -----------------------------
def tokenize(text):
"""
Sudachiによる日本語形態素解析
→ BM25用に「意味のある単語単位」に分割
"""
return [
m.surface()
for m in tokenizer_obj.tokenize(text, mode)
if m.surface().strip()
]
# -----------------------------
# データ取得(BM25前処理)
# -----------------------------
print("データ取得中...")
cur.execute("""
SELECT id, content
FROM documents_livedoor
""")
rows = cur.fetchall()
# -----------------------------
# インデックス構築
# TFをトークンごとに求める
# 平均文書長を求める
# -----------------------------
doc_freq = defaultdict(int)
doc_term_freq = {}
doc_len = {}
N = len(rows)
for doc_id, content in rows:
tokens = tokenize(content) # 単語(トークン)分割
tf = Counter(tokens) # 単語をカウント
doc_term_freq[doc_id] = tf # カウント結果を登録
doc_len[doc_id] = len(tokens) # 単語数を登録
for term in tf.keys(): # IDF計算のため文書全体の単語頻度を算出
doc_freq[term] += 1
avgdl = sum(doc_len.values()) / len(doc_len) # 平均文書長(単語数)を算出
# 事前計算の値を確認したい場合は以下のコメントアウトを外す
# print("インデックス構築完了")
# print("=== doc_term_freq ===")
# pretty_doc_term_freq = {str(k): dict(v) for k, v in doc_term_freq.items()}
# print(json.dumps(pretty_doc_term_freq, ensure_ascii=False, indent=2))
# print("=== doc_len ===")
# print(json.dumps({str(k): v for k, v in doc_len.items()}, ensure_ascii=False, indent=2))
# -----------------------------
# IDFの事前計算
# -----------------------------
idf_dict = {}
for term, df in doc_freq.items():
idf_dict[term] = math.log((N - df + 0.5) / (df + 0.5) + 1)
# 事前計算の値を確認したい場合は以下のコメントアウトを外す
# print("=== IDF ===")
# print(idf_dict)
#
# -----------------------------
# BM25パラメータ
# -----------------------------
k1 = 1.5
b = 0.75
# -----------------------------
# BM25スコア計算
# -----------------------------
def bm25_score(query_tokens):
scores = defaultdict(float)
# クエリ内の単語をループ
for term in query_tokens:
if term not in idf_dict:
continue
idf = idf_dict[term]
for doc_id in doc_term_freq:
tf = doc_term_freq[doc_id][term] # termが存在しない場合、Counterオブジェクトは0を返す
if tf == 0: # その文書に単語が存在しない場合はスキップ
continue
dl = doc_len[doc_id]
score = (
idf *
(tf * (k1 + 1)) /
(tf + k1 * (1 - b + b * dl / avgdl))
)
scores[doc_id] += score
return scores
# -----------------------------
# 検索ループ
# -----------------------------
while True:
query = input("検索クエリ(qで終了):")
if query == "q":
break
query_tokens = tokenize(query)
scores = bm25_score(query_tokens)
top_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:10]
print(f"\n検索クエリ: {query}\n")
for doc_id, score in top_docs:
cur.execute("""
SELECT article_id, chunk_id, title, url, published_at, source, content
FROM documents_livedoor
WHERE id = %s
""", (doc_id,))
row = cur.fetchone()
if not row:
continue
article_id, chunk_id, title, url, published_at, source, content = row
print("====================================================")
print(f"{score:.4f} : {article_id} : {title} - {chunk_id} : {source} : {published_at}")
print(url)
print(content)
cur.close()
conn.close()
もし、事前計算されたTFや文書長の値を見てみたいという場合、
・コードの中の# 事前計算の値を確認したい場合は以下のコメントアウトを外すの箇所のコメントアウトを外す
・「検索ループ」のwhile部分をコメントアウト
して走らせてみてください。
以下のようにすると、結果がresults.txtに吐き出されます。
python3 bm25_search_ld.py > results.txt
以下のような結果が確認できます。
※サイズ節約のため1000件で止めています
TF
"65535": {
"—": 4,
"日本": 2,
"の": 15,
"宇宙": 2,
"開発": 2,
"プロジェクト": 1,
"に": 8,
"今後": 1,
"期待": 1,
"する": 1,
"こと": 1,
"は": 4,
"?": 1,
"藤原": 2,
":": 2,
"技術": 1,
"アジア": 1,
"で": 4,
"1": 1,
"位": 1,
"なく": 1,
"て": 11,
"いけ": 1,
"ない": 2,
"と": 5,
"いう": 2,
"大変": 1,
"な": 5,
"状況": 1,
"中": 1,
"、": 11,
"多く": 1,
"国民": 2,
"希望": 1,
"を": 5,
(略)
IDF
=== IDF ===
{'-': 5.064841248013863, 'CHANGES': 9.821300881548181, '.': 6.014638391777861, 'txt': 9.31047525778219, 'ファイル': 8.086699826160075, 'の': 3.34484147070873, '追加': 8.722688592880072, '。': 3.401848988983213, '数値': 9.821300881548181, '文字': 9.31047525778219, '参照': 8.974003021160977, '&': 7.028092872105664, '#': 9.821300881548181, 'nnnn': 9.821300881548181, ';': 9.821300881548181, 'を': 3.372939052699764, 'リテラル': 9.821300881548181, 'に': 3.3697778277278614, '変換': 9.821300881548181, '2012': 5.790014455293217, '09': 8.354963812754754, '13': 6.988087537491965, '初版': 9.821300881548181, '公開': 4.399292042657595, '概要': 7.97547419104985, '-----------------': 8.722688592880072, '本': 5.876488053297044, 'コーパス': 9.31047525778219, 'は': 3.395351943930931, '、': 3.3603536742085907, 'NHN': 8.974003021160977, 'Japan': 8.52201789741792, '株式会社': 8.52201789741792, 'が': 3.373995019006968, '運営': 8.974003021160977, 'する': 3.951062791874343, '「': 3.7444234565920684, 'livedoor': 7.624076304211962, 'ニュース': 6.387313677063035, '」': 3.7536471960826523, 'うち': 7.5526173402298165, '下記': 8.086699826160075, 'クリエイティブ': 8.52201789741792, '・': 3.5604455319606694, 'コモンズ': 8.974003021160977, 'ライセンス': 8.722688592880072, '適用'
見づらいですね。
抜粋しますと
'数値': 9.821300881548181,
'コモンズ': 8.974003021160977,
'に': 3.3697778277278614,
'は': 3.395351943930931,
'、': 3.3603536742085907,
'NHN': 8.974003021160977,
'Japan': 8.52201789741792
普通の「単語」や「固有名詞」と、「記号」や「助詞」ではかなり数値の隔たりがあることが一目瞭然で分かりますね(上記は1000件の結果ですが、全件で算定するとさらに顕著な結果になります)。
TF-IDFやBM25では、こうした記号や助詞について低い評価をすることによってスコアを調整し、実用的な結果が出るように工夫されています。
試しにトランスフォーマーというキーワードで検索してみます。
検索クエリ(qで終了):トランスフォーマー
検索クエリ: トランスフォーマー
====================================================
12.7845 : movie-enter-6128471 : 【DVDエンター!】世界で最もセクシーな美女も登場、映画史上最高の3D - 1 : movie-enter : 2011-12-18 11:00:00
http://news.livedoor.com/article/detail/6128471/
【DVDエンター!】世界で最もセクシーな美女も登場、映画史上最高の3D。今年7月29日より劇場公開され、『アバター』を超える3D稼動率を記録し、3作目にしてシリーズ最高となる国内興行収入40億円を突破した、マイケル・ベイ監督による映画『トランスフォーマー/ダークサイド・ムーン』。当初は最終章となる予定が、続編の製作も噂される中、早くも12月16日には同作がブルーレイ、DVDとなって発売されました。
トランスフォーマー/ダークサイド・ムーン
宇宙の戦士トランスフォーマーのオプティマス・プライムやバンブルビーと固い友情を築いた若者サムに、新たに迫る危機。悪のトランスフォーマー集団ディセプティコンが、強力な秘密兵器を擁して3度彼らの前に立ちはだかる。地球を舞台にした最終決戦の行方は!?
映画史上最高の3Dをご家庭に
====================================================
12.3331 : movie-enter-6315858 : 【速報】「第84回アカデミー賞」、注目の“映画愛”対決は『アーティスト』が制す - 6 : movie-enter : 2012-02-27 04:45:00
http://news.livedoor.com/article/detail/6315858/
・ドラゴン・タトゥーの女
★ヒューゴの不思議な発明
・マネーボール
・トランスフォーマー ダークサイド・ムーン
・戦火の馬
音響編集賞
・ドライヴ
・ドラゴン・タトゥーの女
★ヒューゴの不思議な発明
・トランスフォーマー ダークサイド・ムーン
・戦火の馬
メイクアップ賞
・アルバート・ノッブス
・ハリー・ポッターと死の秘宝 PART2
★マーガレット・サッチャー 鉄の女の涙
視覚効果賞
・ハリー・ポッターと死の秘宝 PART2
★ヒューゴの不思議な発明
・リアル・スティール
・猿の惑星:創世記(ジェネシス)
・トランスフォーマー ダークサイド・ムーン
長編アニメーション賞
・パリ猫の生き方
・チコとリタ
・カンフー・パンダ2
・長ぐつをはいたネコ
★ランゴ
長編ドキュメンタリー賞
・Hell and Back Again
・もしもぼくらが木を失ったら
====================================================
10.6140 : movie-enter-6219105 : 【速報】「第84回アカデミー賞」ノミネート発表、『ヒューゴの不思議な発明』が最多11部門 - 5 : movie-enter : 2012-01-24 14:00:00
http://news.livedoor.com/article/detail/6219105/
・ヒューゴの不思議な発明
・ミッドナイト・イン・パリ
・戦火の馬
衣装デザイン賞
・Anonymous
・アーティスト
・ヒューゴの不思議な発明
・ジェーン・エア
・W.E.
作曲賞
・タンタンの冒険/ユニコーン号の秘密
・アーティスト
・ヒューゴの不思議な発明
・裏切りのサーカス
・戦火の馬
歌曲賞
・「Man or Muppet」(The Muppets)
・「Real in Rio」(ブルー 初めての空へ)
録音賞
・ドラゴン・タトゥーの女
・ヒューゴの不思議な発明
・マネーボール
・トランスフォーマー ダークサイド・ムーン
・戦火の馬
音響編集賞
・ドライヴ
・ドラゴン・タトゥーの女
・ヒューゴの不思議な発明
・トランスフォーマー ダークサイド・ムーン
・戦火の馬
メイクアップ賞
・アルバート・ノッブス
・ハリー・ポッターと死の秘宝 PART2
====================================================
10.5744 : movie-enter-6128471 : 【DVDエンター!】世界で最もセクシーな美女も登場、映画史上最高の3D - 2 : movie-enter : 2011-12-18 11:00:00
http://news.livedoor.com/article/detail/6128471/
マイケル・ベイは当初、『トランスフォーマー/ダークサイド・ムーン』を3Dで撮影する予定はありませんでしたが、ジェームズ・キャメロンの推薦により、採用を決定。『アバター』撮影スタッフの協力により、圧倒的な破壊力をもつ巨大なトランスフォーマーたちのみならず、3Dカメラをヘルメットに取り付けたスカイダイバーによって撮影された、ビル群の谷間を弾丸のごとく高速飛行する兵士のシーンなど、圧倒的な大迫力の映像が次々と観る者に襲い掛かります。
ただし、ブルーレイは3Dと2Dの両方で発売されますが、一般家庭における3Dテレビとブルーレイ3Dの普及率はまだまだ低く、2Dで観る方がほとんどかと思われます。
====================================================
(略)
この結果を見ると、
- 単語の登場回数
- 文書の長さ(短さ)
によって総合判定されていることが伺えますね。
まずまず実用的な検索結果だと思いますが、k b のパラメータの調整によってさらにチューニングも可能なのでお試しください。
もちろん、メタデータを活用し、
- 新しい記事を優先表示
- ソースごとにまとめる
などの調整や加工も思いのままです。
さて検索が出来たところで、検索プログラムの流れをさっと見ていきましょう。
となります。
すでにお気付きかも知れませんが、 「検索ループ」より前の部分(=事前計算)
は 毎回行う必要のない部分 であり、この計算を毎回行っていると効率が悪く、システムに負担がかかりますし、レスポンスも遅くなってしまいます。
とは言え、DBに変更があった場合(追加・更新・削除)の際にこれらの計算を行わないとデータに不整合が生じてしまいます。
そこで、実際の運用上は事前計算の部分につき以下のようなことを検討することになります。
- 夜間などにバッチで事前計算をしておく
- タイミングを決めてcronなどで定期更新する
- ある程度のデータ変更があった場合に随時行う
etc...
ただし、これらの方法を採用すると、データの更新とBM25検索の適用に タイムラグが発生してしまう ことも考慮が必要になります。
この場合、求められる「データの鮮度」に応じて適切な方法を選択することになるでしょう。
スコア計算の効率化
さて、今回のテストデータの場合、検索速度はそれほどストレスを感じるほどではないと思いますが、大量の文書データを扱いたい場合、スコア計算部分がネックになってくると思われます。
現在のスコア計算部分は以下のようになっていますが、
def bm25_score(query_tokens):
scores = defaultdict(float)
# クエリ内の単語をループ
for term in query_tokens:
if term not in idf_dict:
continue
idf = idf_dict[term]
for doc_id in doc_term_freq:
tf = doc_term_freq[doc_id][term] # termが存在しない場合、Counterオブジェクトは0を返す
if tf == 0: # その文書に単語が存在しない場合はスキップ
continue
dl = doc_len[doc_id]
score = (
idf *
(tf * (k1 + 1)) /
(tf + k1 * (1 - b + b * dl / avgdl))
)
scores[doc_id] += score
return scores
文書に単語が含まれていない場合の「スキップ処理」が実装されているとはいえ、ループとしては
for doc_id in doc_term_freq:
の部分で、長大な「単語頻度リスト(TF)」を「その単語と関係のない文書」を含めて全件ループしており非効率です。
本来は、「その単語が含まれている」文書だけをループすれば事足りるはずです。
そこで、「その単語が出現する文書のリスト」を事前に作っておき、そのリストに含まれる文書のみをループすれば格段に高速化できるはずです。
リストのイメージとしては以下です。
"スマホ" => [文書1、文書3、文書10、...]
"iPhone" => [文書1、文書4、文書10、...]
...
いわば 「手製インデックス」 みたいなものですね。
※このリストのことを「Posting list」と呼ぶ場合もあります
このリストにより事前計算のコストは多少増えますが、毎回の検索は高速に実行でき、システム側の総負担を軽減させることができます。
ついでに、このリストに「TF」の値を含めれば、別途TFのリストを参照しなくて良いのでさらに最適化できます。
"スマホ" => [(文書1,tf=2)、(文書3,tf=3)、(文書10,tf=1)、...]
"iPhone" => [(文書1,tf=3)、(文書4,tf=1)、(文書10,tf=3)、...]
...
のようなイメージです。
現在のコードに少しばかりの処理を付け加えれば、「Posting list」を参照した最適な処理を実現することが出来ます。
以下が最適化された実装例です。
Posting list を活用した最適化コード
import psycopg2
import math
from collections import defaultdict, Counter
import json
from sudachipy import tokenizer
from sudachipy import dictionary
# -----------------------------
# DB接続
# -----------------------------
conn = psycopg2.connect(
host="localhost",
database="ragdb",
user="user",
password="password"
)
cur = conn.cursor()
# -----------------------------
# Sudachi初期化
# -----------------------------
tokenizer_obj = dictionary.Dictionary().create()
mode = tokenizer.Tokenizer.SplitMode.C # 精度と自然さのバランス
# -----------------------------
# Sudachiトークナイズ
# -----------------------------
def tokenize(text):
"""
Sudachiによる日本語形態素解析
→ BM25用に「意味のある単語単位」に分割
"""
return [
m.surface()
for m in tokenizer_obj.tokenize(text, mode)
if m.surface().strip()
]
# -----------------------------
# データ取得(BM25前処理)
# -----------------------------
print("データ取得中...")
cur.execute("""
SELECT id, content
FROM documents_livedoor
""")
rows = cur.fetchall()
# -----------------------------
# インデックス構築
# TFをトークンごとに求める
# 平均文書長を求める
# -----------------------------
doc_freq = defaultdict(int)
doc_term_freq = {}
doc_len = {}
N = len(rows)
for doc_id, content in rows:
tokens = tokenize(content) # 単語(トークン)分割
tf = Counter(tokens) # 単語をカウント
doc_term_freq[doc_id] = tf # カウント結果を登録
doc_len[doc_id] = len(tokens) # 単語数を登録
for term in tf.keys(): # IDF計算のため文書全体の単語頻度を算出
doc_freq[term] += 1
avgdl = sum(doc_len.values()) / len(doc_len) # 平均文書長(単語数)を算出
# 事前計算の値を確認したい場合は以下のコメントアウトを外す
# print("インデックス構築完了")
# print("=== doc_term_freq ===")
# pretty_doc_term_freq = {str(k): dict(v) for k, v in doc_term_freq.items()}
# print(json.dumps(pretty_doc_term_freq, ensure_ascii=False, indent=2))
# print("=== doc_len ===")
# print(json.dumps({str(k): v for k, v in doc_len.items()}, ensure_ascii=False, indent=2))
# -----------------------------
# IDFの事前計算
# -----------------------------
idf_dict = {}
for term, df in doc_freq.items():
idf_dict[term] = math.log((N - df + 0.5) / (df + 0.5) + 1)
# 事前計算の値を確認したい場合は以下のコメントアウトを外す
# print("=== IDF ===")
# print(idf_dict)
# -----------------------------
# 逆インデックス(postings)構築
# term -> list of (doc_id, tf)
# 検索時はクエリ語ごとに出現文書だけを走査できるため高速化される
# -----------------------------
postings = defaultdict(list)
for doc_id, tf in doc_term_freq.items():
for term, freq in tf.items():
postings[term].append((doc_id, freq))
# 事前計算の値を確認したい場合は以下のコメントアウトを外す
# print("=== postings ===")
# print(postings)
# -----------------------------
# BM25パラメータ
# -----------------------------
k1 = 1.5
b = 0.75
# -----------------------------
# BM25スコア計算
# -----------------------------
def bm25_score(query_tokens):
scores = defaultdict(float)
# クエリ内の単語をループ
for term in query_tokens:
if term not in idf_dict:
continue
idf = idf_dict[term]
# postings(出現文書リスト)を使って、その語が出現する文書だけを走査
for doc_id, tf in postings.get(term, []):
dl = doc_len[doc_id]
score = (
idf *
(tf * (k1 + 1)) /
(tf + k1 * (1 - b + b * dl / avgdl))
)
scores[doc_id] += score
return scores
# -----------------------------
# 検索ループ
# -----------------------------
while True:
query = input("検索クエリ(qで終了):")
if query == "q":
break
query_tokens = tokenize(query)
scores = bm25_score(query_tokens)
top_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:10]
print(f"\n検索クエリ: {query}\n")
for doc_id, score in top_docs:
cur.execute("""
SELECT article_id, chunk_id, title, url, published_at, source, content
FROM documents_livedoor
WHERE id = %s
""", (doc_id,))
row = cur.fetchone()
if not row:
continue
article_id, chunk_id, title, url, published_at, source, content = row
print("====================================================")
print(f"{score:.4f} : {article_id} : {title} - {chunk_id} : {source} : {published_at}")
print(url)
print(content)
cur.close()
conn.close()
使い方は変更なしです。起動して検索してみると、今回のデータ量でも高速化を体感できるかと思います。
ちなみに、Posting List の中身を抜粋すると、以下のようになっています。
'スマホ': [(65825, 1), (74058, 1), (82412, 1), (71310, 4), (69205, 1), (67337, 1), (67624, 1), (67834, 2), (68464, 1), (68466, 1), (69147, 5), (69148, 5), (69149, 1), (69150, 5), (69151, 3), (69153, 1), (69154, 4),...
文書IDとTFがしっかり記録されていることが分かります。
ハイブリッド検索
ところで、ここら辺で「ベクトル検索」のことを思い出しましょう。
今回の「BM25」検索はまさに 「ピンポイント」 の検索ですが、「ベクトル検索」は表記揺れや関連キーワードも含めて拾ってくれる 「意味検索」 でした。
この両者、実はお互いの「弱点」を補完しているという点にお気づきでしょうか?
つまり、
「BM25」検索の短所
⇨ 「表記揺れ」に対応できない(別バリエーション「スマホ」「モバイル」、外来語と漢字「バッグ」「鞄」など)
⇨ 「多義語」に対応できない(例:「走る」→「肉体的動作」「実行」「実施」など)
がそのまま 「ベクトル検索」の長所でカバーできる
⇨ 「表記揺れ」に対応できる(意味が同じであればコサイン距離が近い)
⇨ 「多義語」も文脈によって判断できる(「5km走る」「コードを走らせる」など)
そして、
「ベクトル検索」の短所
⇨ 検索結果があいまい
⇨ 関係なさそうな「ノイズ」が混じることもある
がそのまま 「BM25」検索の長所でカバーできる
⇨ ピンポイントな検索(キーワードが含まれていることが保証される)
⇨ キーワードが優先的に拾われるので「ノイズ」は少ない
ということになります。
そうすると、この両者の検索結果を程よくマージ(統合)することが出来れば、 「理想に近い検索結果」 を得ることが出来そうです。
そしてこの 「程よい検索結果のマージ」 こそが今回のテーマである 「ハイブリッド検索」 なのです。
ところが、一口に「マージ」と言っても色々な考え方が出てきます。
例えば、
1~5位 ⇨ BM検索結果
6~10位 ⇨ ベクトル検索結果
というマージの仕方だってあり得ないではないです。
しかし、これだと「ベクトル検索」の結果が追いやられてしまい、ハイブリッドにした意味が薄れてしまいます。
やはり、 「何らかの根拠」 を決めて並べ替えする方が良さそうです。
その「何らかの根拠」というのが一筋縄行かなく、というのも、
「BM25」の類似スコアと、「ベクトル検索」の類似スコア(コサイン類似度)はスケールが異なっており、単純な比較が出来ない状態だからです。
つまり、
- BM25のスコア ⇨ 0~上限なし、数十になることも
- ベクトル検索のスコア ⇨ ほぼ0~1(正規化されている)
と範囲も違うし、おそらく分布も異なるでしょう。
そうすると、何らかの「正規化」によって スケールを合わせた上で 、スコア順に並べるというアプローチがありえそうです。
流れとしては以下のようになるでしょう
さて正規化の方法としては実際に以下のようなものが知られています。
① 正規化(Min-Max)
score' = \frac{score - min}{max - min}
全体範囲の中でどの辺りにいるのか、を算出するシンプルな方法
② Zスコア正規化
z = \frac{x - \mu}{\sigma}
「分布」を考慮した正規化(「偏差値」的な考え方)
参考:https://www.monodukuri.com/gihou/article/703
そして、いっそのこと割り切って 「スコア」を使わない 解法もあります。
スコアを使わないとすると何を使うか?
「順位」
を使います。
正確には、上位ほど数字が大きくなるよう、「順位の逆数」を使います。
そうすると、
1位 -> 1
2位 -> 0.5
3位 -> 0.33
のようになりますね。
これを「BM25」および「ベクトル検索」について求め、合算します。
そうすると、「BM25」「ベクトル検索」の両方のヒットしたデータは高く評価され、片方だけの場合はその下位に回される、という総合検索結果になり、ユーザのニーズにマッチした結果が得られそうです。
※単純な合算だと「同順」が発生しやすいので、実際にはそれぞれに係数を掛けて補正することが多いです。そうすると係数の大小によって「どっち寄り」の検索をするかチューニングをすることも出来ます。
以上を踏まえた、 「順位のみを考慮したシンプルなハイブリッド検索」 のコードが以下になります。
シンプルなハイブリッド検索のコード
import psycopg2
import math
import numpy as np
from collections import defaultdict, Counter
import json
from sentence_transformers import SentenceTransformer
from sudachipy import tokenizer
from sudachipy import dictionary
# -----------------------------
# ベクトルモデル
# -----------------------------
print("モデルロード中...")
model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)
print("モデルロード完了")
# -----------------------------
# DB接続
# -----------------------------
conn = psycopg2.connect(
host="localhost",
database="ragdb",
user="user",
password="password"
)
cur = conn.cursor()
# -----------------------------
# Sudachi初期化
# -----------------------------
tokenizer_obj = dictionary.Dictionary().create()
mode = tokenizer.Tokenizer.SplitMode.C # 精度と自然さのバランス
# -----------------------------
# Sudachiトークナイズ
# -----------------------------
def tokenize(text):
"""
Sudachiによる日本語形態素解析
→ BM25用に「意味のある単語単位」に分割
"""
return [
m.surface()
for m in tokenizer_obj.tokenize(text, mode)
if m.surface().strip()
]
# -----------------------------
# データ取得(BM25前処理)
# -----------------------------
print("データ取得中...")
cur.execute("""
SELECT id, content, embedding_v
FROM documents_livedoor
""")
rows = cur.fetchall()
# -----------------------------
# インデックス構築
# TFをトークンごとに求める
# 平均文書長を求める
# ベクトルを保管
# -----------------------------
doc_freq = defaultdict(int)
doc_term_freq = {}
doc_len = {}
doc_vec = {} # ベクトルのリスト
N = len(rows)
for doc_id, content, embedding in rows:
tokens = tokenize(content) # 単語(トークン)分割
tf = Counter(tokens) # 単語をカウント
doc_term_freq[doc_id] = tf # カウント結果を登録
doc_len[doc_id] = len(tokens) # 単語数を登録
for term in tf.keys(): # IDF計算のため文書全体の単語頻度を算出
doc_freq[term] += 1
# ベクトルをfloatのNumPy配列として保管(DBから文字列や特殊型が来ても耐える)
try:
doc_vec[doc_id] = np.asarray(embedding, dtype=np.float32)
except Exception:
# embedding が JSON 文字列や '[1,2,3]' のような表記で来る場合に対応
parsed = None
# bytes -> str
if isinstance(embedding, (bytes, bytearray)):
s = embedding.decode('utf-8')
else:
s = embedding
if isinstance(s, str):
s_strip = s.strip()
# まず JSON として試す
try:
parsed = json.loads(s_strip)
except Exception:
# 角括弧で囲まれたカンマ区切り文字列をパース
if s_strip.startswith('[') and s_strip.endswith(']'):
inner = s_strip[1:-1].strip()
if inner == '':
parsed = []
else:
parts = [p.strip() for p in inner.split(',') if p.strip()]
parsed = [float(p) for p in parts]
else:
# 最後の手段:空白で区切られた数字列を想定
parts = [p for p in s_strip.replace('\t',' ').split(' ') if p]
try:
parsed = [float(p) for p in parts]
except Exception:
raise
else:
# iterable だが numpy に変換できなかった場合
parsed = list(s)
doc_vec[doc_id] = np.asarray(parsed, dtype=np.float32)
avgdl = sum(doc_len.values()) / len(doc_len) # 平均文書長(単語数)を算出
# 事前計算の値を確認したい場合は以下のコメントアウトを外す
# print("インデックス構築完了")
# print("=== doc_term_freq ===")
# pretty_doc_term_freq = {str(k): dict(v) for k, v in doc_term_freq.items()}
# print(json.dumps(pretty_doc_term_freq, ensure_ascii=False, indent=2))
# print("=== doc_len ===")
# print(json.dumps({str(k): v for k, v in doc_len.items()}, ensure_ascii=False, indent=2))
# -----------------------------
# IDFの事前計算
# -----------------------------
idf_dict = {}
for term, df in doc_freq.items():
idf_dict[term] = math.log((N - df + 0.5) / (df + 0.5) + 1)
# 事前計算の値を確認したい場合は以下のコメントアウトを外す
# print("=== IDF ===")
# print(idf_dict)
# -----------------------------
# 逆インデックス(postings)構築
# term -> list of (doc_id, tf)
# 検索時はクエリ語ごとに出現文書だけを走査できるため高速化される
# -----------------------------
postings = defaultdict(list)
for doc_id, tf in doc_term_freq.items():
for term, freq in tf.items():
postings[term].append((doc_id, freq))
# 事前計算の値を確認したい場合は以下のコメントアウトを外す
# print("=== postings ===")
# print(postings)
# -----------------------------
# BM25パラメータ
# -----------------------------
k1 = 1.5
b = 0.75
# -----------------------------
# BM25スコア計算
# -----------------------------
def bm25_score(query_tokens):
scores = defaultdict(float)
# クエリ内の単語をループ
for term in query_tokens:
if term not in idf_dict:
continue
idf = idf_dict[term]
# postings(出現文書リスト)を使って、その語が出現する文書だけを走査
for doc_id, tf in postings.get(term, []):
dl = doc_len[doc_id]
score = (
idf *
(tf * (k1 + 1)) /
(tf + k1 * (1 - b + b * dl / avgdl))
)
scores[doc_id] += score
return scores
# -----------------------------
# ベクトル検索
# -----------------------------
def vector_search(query, top_k=50):
# Queryベクトルを確実にfloatの1次元配列にする
q_vec = np.asarray(model.encode(query), dtype=np.float32)
scores = []
for doc_id, vec in doc_vec.items():
# doc側も念のためfloat配列に
vec = np.asarray(vec, dtype=np.float32)
# cosine similarity(ゼロ除算に備えてチェック)
denom = (np.linalg.norm(q_vec) * np.linalg.norm(vec))
if denom == 0:
sim = 0.0
else:
sim = float(np.dot(q_vec, vec) / denom)
scores.append((doc_id, sim))
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
# -----------------------------
# ランキング統合(順位のみを考慮したシンプル版)
# -----------------------------
def merge_scores(bm25_scores, vec_scores):
final = defaultdict(float)
# BM25(スコア順位ベース)
bm25_rank = sorted(bm25_scores.items(), key=lambda x: x[1], reverse=True)
# finalに順位の逆数を格納
for rank, (doc_id, _) in enumerate(bm25_rank):
final[doc_id] += 1 / (rank + 1)
# Vector(順位ベース)
# finalに順位の逆数を加算して格納
for rank, (doc_id, _) in enumerate(vec_scores):
final[doc_id] += 1 / (rank + 1)
return final
# -----------------------------
# 検索ループ
# -----------------------------
while True:
query = input("検索クエリ(qで終了):")
if query == "q":
break
query_tokens = tokenize(query)
# BM25
bm25_scores = bm25_score(query_tokens)
# Vector
vec_scores = vector_search(query)
# ハイブリッド合成
final_scores = merge_scores(bm25_scores, vec_scores)
top_docs = sorted(final_scores.items(), key=lambda x: x[1], reverse=True)[:10]
print(f"\n検索クエリ: {query}\n")
for doc_id, score in top_docs:
cur.execute("""
SELECT article_id, chunk_id, title, url, published_at, source, content
FROM documents_livedoor
WHERE id = %s
""", (doc_id,))
row = cur.fetchone()
if not row:
continue
article_id, chunk_id, title, url, published_at, source, content = row
print("====================================================")
print(f"{score:.4f} : {article_id} : {title} - {chunk_id} : {source} : {published_at}")
print(url)
print(content)
cur.close()
conn.close()
実行して検索結果を確認してみましょう。
検索クエリ(qで終了):スマホ
検索クエリ: スマホ
====================================================
1.0000 : smax-6767253 : 女子力アップ効果が半端ない!SoftBank SELECTIONの「リボンシール」を試してみたが、かわい過ぎてマジでヤバイ【レビュー】 - 6 : smax : 2012-07-18 02:55:00
http://news.livedoor.com/article/detail/6767253/
・SoftBank SELECTION、Facebookを活用してスマホ女子の声を商品化、スマホをデコレーションできる「リボンシール」10種類を発売〜洋服や季節に合わせてコーディネートできるスマホアクセサリー「スマホウエア」シリーズ第1弾〜(ソフトバンクBB)
・SoftBank SELECTIONとスマホアクセをつくろう♪(Facebook)
・ONLINE SHOP(SoftBank SELECTION ソフトバンクセレクション) ・ソフトバンクモバイル
====================================================
1.0000 : smax-6648178 : KDDI、Xperia acro HDとMOTOROLA RAZRにAndroid 4.0 ICSを6月下旬以降から提供予定 - 3 : smax : 2012-06-11 13:55:00
http://news.livedoor.com/article/detail/6648178/
・スマートフォン・携帯電話に関するお知らせ詳細 | スマートフォン・携帯電話に関するお知らせ | au
====================================================
0.5625 : peachy-6792863 : スマホを“夏服”に衣替え! スマホカバー最新トレンド - 7 : peachy : 2012-07-30 02:36:00
http://news.livedoor.com/article/detail/6792863/
上部にあるシャッターボタンを押すと撮影ができたり、外付けレンズがあったりと、まるで本格的なカメラ。二点吊りストラップをつけて街を歩けば、これがスマホだって忘れちゃうかも?
■GIZMON iCA for iPhone4/4S 3,980円 GIZMON
スマホピアスで遊び心を演出!
「スマホカバーだけではなく、イヤホンジャックに差し込むスマホピアスも最近人気です。スマホにはストラップホルダーがない機種が多いので、その代わりにスマホピアスが大活躍。キャラモノや動物モノ、食品サンプルモノなど種類もいろいろ。探すのも楽しいですよ!」(山田さん)
スマホから芽が出た!? こちらは葉っぱ型のスマホピアス。シリコン性なので、指で触るとフルフル揺れるのがとってもキュート。スマホを見るたびに癒されそう。
■happa PLUG APLI 葉っぱプラグアプリ 480円(送料別)/ストラップヤネクスト
====================================================
0.5000 : smax-6673414 : 2012年夏モデルでおすすめのスマートフォンはこれだ!ハイスペック、らくらく、でも、ピンクが好き(新米ママちえ編) - 1 : smax : 2012-06-19 08:55:00
http://news.livedoor.com/article/detail/6673414/
2012年夏モデルでおすすめのスマートフォンはこれだ!ハイスペック、らくらく、でも、ピンクが好き(新米ママちえ編)。GALAXY SIIIはやはり本命?
こんにちは。最近赤ちゃんを出産して育児奮闘中のちえです。
子供を産んだからモバイルは少しおあずけ……とはならず、相変わらず新しいスマートフォンを触りながらはしゃぐ日々を送っております。
先月中旬から順次各携帯電話事業者より夏モデルが発表されました。以前にも増してスマートフォン中心のラインナップとなり、ますますどれにしようか悩んでしまいますよね。先日も東京で行われたNTTドコモの2012年夏モデル内覧会に行ってきて、新機種を体験してきたばかりです。
そこで、今回は、私が個人的に気になった新機種からベスト3を選んでみました。現時点で実物に触れることができたのが、ドコモ向け端末のみのため、あえてドコモのモデルから選んでみました。
====================================================
0.4000 : kaden-channel-6130802 : ソニーだいじょうぶ? PS Vitaの質問ページがちょっと変で話題になり、ソニーは慌てて修正【話題】 - 3 : kaden-channel : 2011-12-19 06:30:00
http://news.livedoor.com/article/detail/6130802/
・寒い冬もスマホが快適に使える! 今週の「週間アスキー」の付録は「超ぬくぬくスマホてぶくろ」【話題】
====================================================
0.3333 : peachy-6792863 : スマホを“夏服”に衣替え! スマホカバー最新トレンド - 1 : peachy : 2012-07-30 02:36:00
http://news.livedoor.com/article/detail/6792863/
スマホを“夏服”に衣替え! スマホカバー最新トレンド。毎日持ち歩いているスマホ。バッグから取り出すとき、メールを打つとき、実は意外と人から見られているものです。にも関わらず、最初にスマホを購入したときからずっと同じカバーのまま…という人も多いのでは? 「カバーなんてなんでもいいし…」なんて思っている人、ちょっと待って!
「スマホカバーは自分らしさを表現するファッションアイテムの1つ。最近はiPhoneだけではなくAndroidでも、かわいいものやユニークなものがたくさん登場しています。ずっと同じままなんて、もったないですよ!」
と語るのは、女子のためのスマートフォン情報サイト「スマホガール」編集長の山田明美さん。
今回は山田さんに、最近のスマホカバーのトレンドと、夏にぴったりなスマホアイテムを教えてもらいました。
とっても素敵なプレゼントもあるので、お見逃しなく!
*「スマホガール」
====================================================
0.2565 : peachy-6684605 : キーワードは「カワイク&賢く!」イマドキスマホ女子に人気のスマホグッズ紹介 - 1 : peachy : 2000-06-25 06:45:00
http://news.livedoor.com/article/detail/6684605/
キーワードは「カワイク&賢く!」イマドキスマホ女子に人気のスマホグッズ紹介。・カカオチョコレート
・ウサギケース ラビットしっぽ
・フォンピアス イヤホンジャックアクセサリー クマ ・ブラウンポンポンゴールド スマートフォンアクセ
====================================================
0.2500 : peachy-6792863 : スマホを“夏服”に衣替え! スマホカバー最新トレンド - 8 : peachy : 2012-07-30 02:36:00
http://news.livedoor.com/article/detail/6792863/
キラキラのハートがキュートなスマホピアス。スワロフスキー・ジルコニアがあしらわれた、揺れるチャームもおしゃれです。イヤホンジャックに差し込めば、シンプルなスマホもたちまちガーリーに。
■Jewelart Deco ハート型スマホピアス 3,280円(送料無料)/Softbank SELECTION
Jewelart どこまでもキュートに ブリッコイメージのスマホピアス♪
※プレゼントは終了しました。
女の子をキラキラでHAPPYにする情報満載のサイトJewelart(ジェラート)さんから
写真のスマホピアス(クラウンタイプ、またはソリッドタイプ)を2名様にプレゼント!
お気に入りのスマホカバーは見つかりましたか? スマホをオシャレに自分らしく彩って、夏を楽しみましょう!
(文:K-Writer’s Club 村上佳代)
■毎日をハッピーに生きる女子のためのニュースサイト「Peachy」
====================================================
0.2000 : it-life-hack-6528340 : 超コンパクトで充電ケーブルいらず!わずか92gのモバイルバッテリー【イケショップのレア物】 - 7 : it-life-hack : 2012-05-04 00:55:00
http://news.livedoor.com/article/detail/6528340/
・スマホやiPhoneを何度もフル充電! 最大で3台同時にスマホが充電できる大容量バッテリー
・電源不要でボリュームアップ!? エコなのに音量増幅できるiPhone用スタンド
・スマホでカーナビを実現!純正ドリンクホルダーがスマホホルダーに早変わり ・GALAXY TabもiPadも家電も充電できる!万能高容量ポータブルバッテリー
====================================================
0.2000 : smax-6834765 : ハイスペックだけじゃない!?使い勝手の良いドコモスマホ「ARROWS X F-10D」の使いやすさをチェック!【レビュー】 - 2 : smax : 2012-08-08 00:55:00
http://news.livedoor.com/article/detail/6834765/
スマートフォンを2台目として持つ人がたくさんいるとはいえ、やはりスマートフォンは「電話機」。電話そのものの性能は気になるところですよね。電話機能をチェックしてみました。
着信時の画面です。電話に出るか切るかしかできない端末もありますが、ARROWS X F-10Dでは「伝言メモ」をタップすることでスムーズに留守応答することができます。とっさの時に便利な機能です。
通話中、周囲の状態を感知して通話音声を最適な聞きやすさに自動調節する「ぴったりボイス」という機能が働きます。画面の状態では「静か」であると判断されているようです。
相手の声を聞きやすく変換する「スーパーはっきりボイス3」、、あらかじめ登録した使用者の年齢に合わせた音質に調節する「あわせるボイス2」、相手の話す速さをゆっくり聞こえるように調節する「ゆっくりボイス」に対応しており、通話中に切り替えることができます。
結果を観察すると、「BM25」と「ベクトル検索」のスコアが効いていて、程よくミックスされた結果になっていることが伺えます。
スコア値とテキストから類推すると、それぞれの順位の根拠は、
1位 -> BM25
2位 -> ベクトル検索
3位 -> BM25 + ベクトル検索
4位 -> ベクトル検索
によるものと考えられます。
ざっと検索結果を眺めて、ごく自然な「あるべき姿」の検索結果に近づいていることが感じられるかと思います。
あとは、
・BM25/ベクトル検索のスコアに係数を掛けて「調合比」のチューニング
・BM25のパラメータ調整(k1, b)
・他の正規化手法/マージ方法を試みる
などといった方法で改善を繰り返し、結果をフィードバックしながら理想的な検索結果を目指していただければ良いかと思います。
終わりに
本シリーズ、だいぶ長編になってしまいましたが、あらためてRAG検索の持つ奥深さに気付かされることになりました。(本来は1記事に押し込めようと考えていました…)
それでもごくごくミニマムなRAGの形にとどまってしまったのですが、理解の順序としてはこのような形が入りやすいのかなと思います。
もし機会があれば、さらに専用ベクトルDBや、マネージドサービス、あるいは最新のアルゴリズムの紹介などしていきたいと思っていますので、その際もご一読いただけると幸いです。
参考記事