LlamaIndexのVectorStore(ChromaDB、Faiss、Qdrant)

LlamaIndexのVectorStore(ChromaDB、Faiss、Qdrant)

https://highreso.jp/edgehub/machinelearning/llamaindexvectorstore.html
の記事を読んでみる

スペックは
* GPU:NVIDIA A100 80GB
* GPUメモリ(VRAM):80GB
* OS :Ubuntu 22.04

Colabでなら再現できるかも

Docker部分はいらないので飛ばす

Llama-3-ELYZA-JP-8B-q4_k_m.gguf
を使用してる

LLMの実行にはOllamaを使用

日本語性能が高く、無料で使える埋め込みモデル「intfloat/multilingual-e5-large」を指定

from llama_index.embeddings.huggingface import HuggingFaceEmbedding

Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")

Wikipediaの『宮部みゆきの小説』に関するページを指定

from llama_index.readers.web import SimpleWebPageReader
documents = SimpleWebPageReader(html_to_text=True).load_data(
    ["https://ja.wikipedia.org/wiki/%E7%81%AB%E8%BB%8A_(%E5%B0%8F%E8%AA%AC)",
    "https://ja.wikipedia.org/wiki/%E7%90%86%E7%94%B1_(%E5%B0%8F%E8%AA%AC)",
    "https://ja.wikipedia.org/wiki/%E5%90%8D%E3%82%82%E3%81%AA%E3%81%8D%E6%AF%92",
    "https://ja.wikipedia.org/wiki/%E3%82%BD%E3%83%AD%E3%83%A2%E3%83%B3%E3%81%AE%E5%81%BD%E8%A8%BC"]
)

SimpleWebPageReader(html_to_text=True).load_data()で
指定したWebページから取得したHTMLデータをテキスト形式で読み込み

テキストをチャンクに分割
読み込んだデータをチャンクに分割し、各チャンクをノードとして処理します。
ノードは効率的な検索やクエリ処理をサポートするため、メタデータや関係情報を含む単位

from llama_index.core.node_parser import TokenTextSplitter

splitter = TokenTextSplitter(
    chunk_size=1000,
    chunk_overlap=100,
    separator=" ",
)
nodes = splitter.get_nodes_from_documents(documents)
nodes[10:13]

コード解説

splitter = TokenTextSplitter(…

* chunk_size=1000: 1つのチャンクに含まれるトークンの最大数を1000トークンに設定しています。これにより、テキストが1000トークンずつ分割されます。
* chunk_overlap=100: 隣接するチャンク間で100トークンの重複が設定されています。これにより、文脈の連続性を保つことができ、モデルがより意味を理解しやすくなります。
* separator=” “: トークンを分割する際の区切り文字として、スペース(” “)を使用しています。これは、一般的に単語の区切りを意味します。

nodes = splitter.get_nodes_from_documents(documents)

指定したdocumentsをトークン単位で分割し、それぞれをノードに変換します。ノードはテキストの一部分を保持し、後でモデルに入力するためのチャンクとなります

LlamaIndexで用意されているシンプルなVectorStoreの構築について

pip install llama-index llama-index-core

でインストール

VectorStoreIndexクラスを使って、ベクトルストアのインデックスを作成

from llama_index.core import VectorStoreIndex

index = VectorStoreIndex(nodes, show_progress=True)

なお

show_progress=True

だと
インデックスの構築プロセス中に進捗バー(プログレスバー)が表示される
特に大量のノードを処理する場合、インデックスの構築には時間がかかることがあります。このオプションを True にすることで、現在の処理状況や残り時間を視覚的に確認できるようになり、ユーザーエクスペリエンスが向上
なおデフォルトはfalse

次に
インデックスをもとにクエリエンジンを作成

query_engine = index.as_query_engine(similarity_top_k=3)
response = query_engine.query("火車の概要を教えて下さい")
print(response)


index.as_query_engine():

* これは、VectorStoreIndex オブジェクトが持つメソッドで、このインデックスを基にしたクエリエンジンを生成

オプションの解説

similarity_top_k=3:

* これは、クエリエンジンに渡すオプションの引数です。
* similarity_top_k は、与えられたクエリに対して、ベクトルストア内で最も類似度が高い上位 k 個のノード(チャンク)を取得するように指定します。
* この場合、similarity_top_k=3 なので、クエリのベクトルと最も近い3つのノードがインデックスから検索され、それらのノードの内容が回答生成の文脈として使用されます。
* この値を調整することで、取得する情報の量と関連性のバランスを取ることができます。値を大きくするとより多くの情報が取得されますが、必ずしも関連性の高い情報ばかりとは限りません。

インデックスのIDを設定し、指定したディレクトリに保存

index.set_index_id("miyabe_miyuki")
index.storage_context.persist("./storage/simple_vs")

解説

index.set_index_id(“miyabe_miyuki”)

* indexに対して、IDとして “miyabe_miyuki” を設定しています。インデックスIDは、インデックスを一意に識別するためのもので、後でインデックスをロードしたり、クエリを実行したりする際に役立ちます。

index.storage_context.persist(“./storage/simple_vs”)

データの保存や管理を行う部分(storage_context)を指定されたディレクトリ(”./storage/simple_vs”)に保存しています。このディレクトリにインデックスのデータが保存され、次回以降の使用時に再インデックスすることなく、同じデータをすぐに利用できるようになります。

なお

./storage/simple_vs

がないと自動で作成される
また、
index.storage_context.persist(“./my_custom_index_data”)
というように
カレントディレクトリ直下の別の名前のフォルダに保存するのも可能

次に
保存されたインデックスをストレージからロードして再利用

from llama_index.core import StorageContext, load_index_from_storage

storage_context = StorageContext.from_defaults(persist_dir="./storage/simple_vs")

simple_vc_index = load_index_from_storage(storage_context, index_id="miyabe_miyuki")

解説

storage_context = StorageContext.from_defaults(persist_dir=”./storage/simple_vs”)

* ここでは、”./storage/simple_vs” というディレクトリにある保存済みデータを扱うための StorageContext を作成しています。

simple_vc_index = load_index_from_storage(storage_context, index_id=”miyabe_miyuki”)

* storage_context と index_id=”miyabe_miyuki” を使って、ストレージからインデックスをロードしています。
• index_id=”miyabe_miyuki” は、保存したインデックスに設定された一意のIDです。このIDを指定することで、その特定のインデックスをロードしています。これにより、以前に保存したインデックスを再構築することなく、再度使用できるようになります。

つまり
先の部分で

index.set_index_id("miyabe_miyuki")
index.storage_context.persist("./storage/simple_vs")

でIDとデータ保存先を指定しているので
これを読み込んでいるということ
データ先を指定してIDを指定して読み込んでいる

ここまでがllamaindexのデータ保存

ここからがChromaDBを使用してベクトルストアを保存および再利用する方法

pip install chromadb llama-index-vector-stores-chroma

でインストール

ChromaDBを使用してベクトルストアのインデックスを作成

import chromadb
from llama_index.core import VectorStoreIndex
from llama_index.vector_stores.chroma import ChromaVectorStore
from llama_index.core import StorageContext

chroma_client = chromadb.EphemeralClient()
chroma_collection = chroma_client.create_collection("miyabe_miyuki")

vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)

chroma_index = VectorStoreIndex(nodes, storage_context=storage_context)

解説

chroma_client = chromadb.EphemeralClient()

ChromaDBのエフェメラル(短期間の)クライアントを作成します。これは、データを一時的に保存し、サーバーが終了すると削除されるメモリ内のデータベースを意味します。

chroma_collection = chroma_client.create_collection(“miyabe_miyuki”)

chroma_clientを使用して、新しいコレクション(データのコンテナ)を作成しています。ここではコレクション名を”miyabe_miyuki”として指定しています。

vector_store = ChromaVectorStore(chroma_collection=chroma_collection)

ChromaVectorStoreを使って、ChromaDB内のコレクション(chroma_collection)をベクトルストアとして扱うためのオブジェクトを作成しています。これにより、コレクション内のデータに対してベクトル検索を行えるようになります。

storage_context = StorageContext.from_defaults(vector_store=vector_store)

StorageContextは、データの保存や管理を行うためのオブジェクトです。ここでは、先に作成したvector_store(ChromaDBベースのベクトルストア)をStorageContextにセットして、データ管理を行えるようにしています。

そして
インデックスをもとにクエリエンジンを作成

query_engine = chroma_index.as_query_engine()
response = query_engine.query("『理由』で事件のあったマンション名を教えて下さい。")
print(response)

解説

query_engine = chroma_index.as_query_engine()

• as_query_engine:インデックスもとにユーザーの質問に対して回答を生成するクエリエンジンを構築します。

つまり

llamaindexの時にはquery_engine.query

だったけど
chromaなら

chroma_index.as_query_engine()

を使う

インデックスのIDを設定し、指定したディレクトリに保存

db = chromadb.PersistentClient(path="./storage/chroma")
chroma_collection = db.get_or_create_collection("miyabe_miyuki")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
chroma_index = VectorStoreIndex(nodes, storage_context=storage_context, show_progress=True)

解説

db = chromadb.PersistentClient(path=”./storage/chroma”)

* PersistentClientを作成し、path=”./storage/chroma”という指定されたディレクトリにデータを永続化します。

chroma_collection = db.get_or_create_collection(“miyabe_miyuki”)

* get_or_create_collectionメソッドは、”miyabe_miyuki”という名前のコレクションを取得するか、存在しない場合は新しく作成します。

vector_store = ChromaVectorStore(chroma_collection=chroma_collection)

* ChromaVectorStoreを作成し、先ほど作成したchroma_collectionをベクトルストアとして使用します。

storage_context = StorageContext.from_defaults(vector_store=vector_store)

* StorageContextを作成し、vector_storeをその一部として設定します。

chroma_index = VectorStoreIndex(nodes, storage_context=storage_context, show_progress=True)

* VectorStoreIndexを作成し、nodesとstorage_contextをもとにインデックスを構築します。
* VectorStoreIndexは、ベクトル検索用のインデックスを作成し、指定されたnodesをインデックス化します。
• show_progress=Trueは、インデックス作成中に進行状況を表示するオプションで、インデックスの構築が進んでいるかを確認できます。

以前のllamaindex単体の時には

index.set_index_id("miyabe_miyuki")
index.storage_context.persist("./storage/simple_vs")

chromaの時には

db = chromadb.PersistentClient(path="./storage/chroma")
chroma_collection = db.get_or_create_collection("miyabe_miyuki")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
chroma_index = VectorStoreIndex(nodes, storage_context=storage_context, show_progress=True)

というように最初に
DB保存先を指定
get_or_create_collectionでDB情報の取得、ないなら新規作成する
ChromaVectorStoreを作成してベクトルスコアを指定

from_defaults() によって設定されるデフォルトのインメモリ vector_store を、
ユーザーが事前に定義した vector_store オブジェクトで上書きしている

デフォルトでは、すべてのストア(docstore, vector_store, index_store)は、シンプルなインメモリ(RAM上)のストアとして初期化される

つまり

storage_context = StorageContext.from_defaults(vector_store=vector_store)

によって
このコード行の目的は、VectorStoreIndex を構築する際に、ベクトル埋め込みの保存先として、デフォルトのインメモリストアではなく、指定された外部のベクトルデータベース(vector_store 変数で指定されたもの)を使用するように設定

次に
保存されたインデックスをストレージからロードして再利用

db = chromadb.PersistentClient(path="./storage/chroma")
chroma_collection = db.get_or_create_collection("miyabe_miyuki")
chroma_vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
chroma_index = VectorStoreIndex.from_vector_store(vector_store=chroma_vector_store)

解説

chroma_collection = db.get_or_create_collection(“miyabe_miyuki”)

PersistentClientを使って、以前保存したChromaDBのデータベースを再利用するための接続を行います。
pathで指定したディレクトリ (./storage/chroma) に保存されたデータベースを読み込み、再利用します。

chroma_vector_store = ChromaVectorStore(chroma_collection=chroma_collection)

すでに保存されている “miyabe_miyuki” という名前のコレクションを取得します。g
et_or_create_collectionは、もしコレクションが存在しない場合に新たに作成しますが、
ここでは既存のコレクションを再利用しています。

chroma_index = VectorStoreIndex.from_vector_store(vector_store=chroma_vector_store)

取得したコレクションを元にChromaVectorStoreを読み込み、保存されたベクトルストアを再利用します。
これにより、新規にベクトルストアを構築することなく、保存済みのベクトルデータをそのまま利用することができます。

chroma_index = VectorStoreIndex.from_vector_store(vector_store=chroma_vector_store)

from_vector_storeメソッドを使って、保存されたベクトルストア (chroma_vector_store) を元にVectorStoreIndexを再利用します。

とりあえず解説はこれがわかりやすい

他の事例も調べる
ChromaDBのコレクションを作成またはアクセスする際にwatsonx.aiのEmbeddingモデルを使用する方法(watsonx.ai + LlamaIndex + Chroma DB)

なお

import ollama
from llama_index.core import Settings
from llama_index.llms.ollama import Ollama

modelfile='''
FROM ./Llama-3-ELYZA-JP-8B-q4_k_m.gguf
TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|>

{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>

{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>

{{ .Response }}<|eot_id|>"""
PARAMETER stop "<|start_header_id|>"
PARAMETER stop "<|end_header_id|>"
PARAMETER stop "<|eot_id|>"
PARAMETER stop "<|reserved_special_token"
'''

ollama.create(model='elyza8b', modelfile=modelfile)
Settings.llm = Ollama(model="elyza8b", request_timeout=360.0)

でモデルを作成しているが既にあるなら

modelfile='''
FROM ./Llama-3-ELYZA-JP-8B-q4_k_m.gguf
TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|>

{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>

{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>

{{ .Response }}<|eot_id|>"""
PARAMETER stop "<|start_header_id|>"
PARAMETER stop "<|end_header_id|>"
PARAMETER stop "<|eot_id|>"
PARAMETER stop "<|reserved_special_token"
'''

ollama.create(model='elyza8b', modelfile=modelfile)

は不要

また

embed_model = HuggingFaceEmbedding(model_name="all-MiniLM-L6-v2")

よりも

Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")

の方が日本語取扱には適するらしい

 touch save_index_test.py

でテストする

import ollama
from llama_index.core import Settings
from llama_index.llms.ollama import Ollama

Settings.llm = Ollama(model="elyza8b", request_timeout=360.0)

#日本語性能が高く、無料で使える埋め込みモデル「intfloat/multilingual-e5-large」を指定
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")

#Wikipediaの『宮部みゆきの小説』に関するページを指定
from llama_index.readers.web import SimpleWebPageReader
documents = SimpleWebPageReader(html_to_text=True).load_data(
    ["https://ja.wikipedia.org/wiki/%E7%81%AB%E8%BB%8A_(%E5%B0%8F%E8%AA%AC)",
    "https://ja.wikipedia.org/wiki/%E7%90%86%E7%94%B1_(%E5%B0%8F%E8%AA%AC)",
    "https://ja.wikipedia.org/wiki/%E5%90%8D%E3%82%82%E3%81%AA%E3%81%8D%E6%AF%92",
    "https://ja.wikipedia.org/wiki/%E3%82%BD%E3%83%AD%E3%83%A2%E3%83%B3%E3%81%AE%E5%81%BD%E8%A8%BC"]
)

#読み込んだデータをチャンクに分割し、各チャンクをノードとして処理します
from llama_index.core.node_parser import TokenTextSplitter

splitter = TokenTextSplitter(
    chunk_size=1000,
    chunk_overlap=100,
    separator=" ",
)
nodes = splitter.get_nodes_from_documents(documents)
nodes[10:13]

#VectorStoreIndexクラスを使って、ベクトルストアのインデックスを作成
from llama_index.core import VectorStoreIndex

index = VectorStoreIndex(nodes, show_progress=True)

#インデックスをもとにクエリエンジンを作成
query_engine = index.as_query_engine(similarity_top_k=3)
response = query_engine.query("火車の概要を教えて下さい")
print(response)

#インデックスのIDを設定し、指定したディレクトリに保存
index.set_index_id("miyabe_miyuki")
index.storage_context.persist("./storage/simple_vs")

#保存されたインデックスをストレージからロードして再利用
from llama_index.core import StorageContext, load_index_from_storage

storage_context = StorageContext.from_defaults(persist_dir="./storage/simple_vs")

simple_vc_index = load_index_from_storage(storage_context, index_id="miyabe_miyuki")

というようにまとめたものを
インデックス作成のみにコード変更
作成は gemini を使用

import ollama
from llama_index.core import Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.readers.web import SimpleWebPageReader
from llama_index.core.node_parser import TokenTextSplitter
from llama_index.core import VectorStoreIndex

# --- 1. LLMと埋め込みモデルの設定 ---
# OllamaのLLMを設定
# 'elyza8b'モデルが既にOllamaサーバーに存在していることを前提とします。
Settings.llm = Ollama(model="elyza8b", request_timeout=360.0)

# 日本語性能の高い埋め込みモデルを設定
Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")
print("LLMと埋め込みモデルの設定が完了しました。")

# --- 2. データのロード ---
# Wikipediaの指定されたページからデータをロード
print("Wikipediaページからデータをロード中...")
urls = [
    "https://ja.wikipedia.org/wiki/%E7%81%AB%E8%BB%8A_(%E5%B0%8F%E8%AA%AC)",
    "https://ja.wikipedia.org/wiki/%E7%90%86%E7%94%B1_(%E5%B0%8F%E8%AA%AC)",
    "https://ja.wikipedia.org/wiki/%E5%90%8D%E3%82%82%E3%81%AA%E3%81%8D%E6%AF%92",
    "https://ja.wikipedia.org/wiki/%E3%82%BD%E3%83%AD%E3%83%A2%E3%83%B3%E3%81%AE%E5%81%BD%E8%A8%BC"
]
documents = SimpleWebPageReader(html_to_text=True).load_data(urls)
print(f"合計 {len(documents)} ページのドキュメントをロードしました。")

# --- 3. ドキュメントのチャンク化とノード化 ---
# 読み込んだドキュメントをチャンクに分割し、ノードとして処理
print("ドキュメントをノードに分割中...")
splitter = TokenTextSplitter(
    chunk_size=1000,
    chunk_overlap=100,
    separator=" ",
)
nodes = splitter.get_nodes_from_documents(documents)
print(f"合計 {len(nodes)} 個のノードが生成されました。")
# 確認のため、一部のノードを表示(オプション)
# print("\n一部のノードの例:")
# for i, node in enumerate(nodes[10:13]):
#     print(f"Node {i+10}: {node.text[:100]}...") # 最初の100文字を表示
# print("---")

# --- 4. VectorStoreIndexの作成 ---
# ノードからVectorStoreIndexを作成
print("VectorStoreIndexを作成中...")
index = VectorStoreIndex(nodes, show_progress=True)
print("VectorStoreIndexの作成が完了しました。")

# --- 5. インデックスの保存 ---
# インデックスにIDを設定し、指定したディレクトリに保存
# 保存先ディレクトリが存在しない場合は自動で作成されます。
index_id = "miyabe_miyuki"
persist_dir = "./storage/simple_vs"

print(f"インデックスを '{persist_dir}' に ID '{index_id}' で保存中...")
index.set_index_id(index_id)
index.storage_context.persist(persist_dir)
print("インデックスの保存が完了しました。")
print("これで、次回からはこのインデックスを再利用できます。")

実行したら

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/ollama/save_index_test.py", line 5, in <module>
    from llama_index.readers.web import SimpleWebPageReader
ModuleNotFoundError: No module named 'llama_index.readers.web'

となるので

pip install llama-index-readers-web

これを実行すると

ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
ollama 0.4.6 requires httpx<0.28.0,>=0.27.0, but you have httpx 0.28.1 which is incompatible.
Successfully installed chromedriver-autoinstaller-0.6.4 cssselect-1.3.0 feedfinder2-0.0.4 feedparser-6.0.11 httpx-0.28.1 jieba3k-0.35.1 llama-index-readers-web-0.4.1 lxml-5.4.0 lxml-html-clean-0.4.2 newspaper3k-0.2.8 oxylabs-2.0.0 requests-file-2.1.0 sgmllib3k-1.0.0 spider-client-0.0.27 tinysegmenter-0.3 tldextract-5.3.0

となる

なので

pip install httpx==0.28.0

これで再度

 python save_index_test.py     

を実行

modules.json: 100%|█████████████████████████████| 387/387 [00:00<00:00, 631kB/s]
README.md: 100%|██████████████████████████████| 160k/160k [00:00<00:00, 931kB/s]
sentence_bert_config.json: 100%|██████████████| 57.0/57.0 [00:00<00:00, 250kB/s]
config.json: 100%|█████████████████████████████| 690/690 [00:00<00:00, 2.76MB/s]
model.safetensors: 100%|███████████████████| 2.24G/2.24G [03:19<00:00, 11.2MB/s]
tokenizer_config.json: 100%|███████████████████| 418/418 [00:00<00:00, 1.71MB/s]
sentencepiece.bpe.model: 100%|█████████████| 5.07M/5.07M [00:00<00:00, 11.6MB/s]
tokenizer.json: 100%|██████████████████████| 17.1M/17.1M [00:01<00:00, 12.2MB/s]
special_tokens_map.json: 100%|█████████████████| 280/280 [00:00<00:00, 1.32MB/s]
config.json: 100%|██████████████████████████████| 201/201 [00:00<00:00, 994kB/s]
LLMと埋め込みモデルの設定が完了しました。
Wikipediaページからデータをロード中...
合計 4 ページのドキュメントをロードしました。
ドキュメントをノードに分割中...
合計 211 個のノードが生成されました。
VectorStoreIndexを作成中...
Generating embeddings: 100%|██████████████████| 211/211 [01:05<00:00,  3.20it/s]
VectorStoreIndexの作成が完了しました。
インデックスを './storage/simple_vs' に ID 'miyabe_miyuki' で保存中...
インデックスの保存が完了しました。
これで、次回からはこのインデックスを再利用できます。

次に
保存されたインデックスをロードし、キーボードからの質問に回答する

 ls storage/simple_vs/
default__vector_store.json	image__vector_store.json
docstore.json			index_store.json
graph_store.json


インデックスデータが保存されていることを確認

touch ask_index.py

import sys
from llama_index.core import StorageContext, load_index_from_storage
from llama_index.core import Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

# --- 1. LLMと埋め込みモデルの設定 ---
# インデックス作成時と同じモデルを設定する必要があります。
# これにより、クエリのベクトル化と回答生成が正しく行われます。
Settings.llm = Ollama(model="elyza8b", request_timeout=360.0)
Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")
print("LLMと埋め込みモデルの設定が完了しました。")

# --- 2. インデックスのロード ---
print("インデックスをロード中...")
try:
    # 保存先のディレクトリとインデックスIDを指定
    persist_dir = "./storage/simple_vs"
    index_id = "miyabe_miyuki"

    # StorageContext を設定し、保存先のディレクトリを指定
    storage_context = StorageContext.from_defaults(persist_dir=persist_dir)

    # 指定されたストレージコンテキストとインデックスIDでインデックスをロード
    # これにより、ディスクからデータがメモリに読み込まれ、再利用可能になります。
    loaded_index = load_index_from_storage(storage_context, index_id=index_id)
    print("インデックスのロードが完了しました。")

    # ロードしたインデックスからクエリエンジンを作成
    # similarity_top_k は、回答生成のために検索するノードの数を指定します。
    query_engine = loaded_index.as_query_engine(similarity_top_k=3)
    print("クエリエンジンの準備ができました。")

    # --- 3. ユーザーからの質問を受け付けるループ ---
    print("\n質問を入力してください(終了するには 'exit' と入力)。")
    while True:
        query = input("あなたの質問: ")

        if query.lower() == 'exit':
            print("プログラムを終了します。")
            break

        if not query.strip(): # 空の入力の場合
            print("質問を入力してください。")
            continue

        print("回答を生成中...")
        try:
            # クエリエンジンで質問を処理し、回答を取得
            response = query_engine.query(query)
            print("\n--- 回答 ---")
            print(response)
            print("------------\n")
        except Exception as e:
            print(f"質問処理中にエラーが発生しました: {e}")
            print("もう一度お試しください。")

except FileNotFoundError:
    print(f"エラー: インデックスデータが見つかりません。'{persist_dir}' ディレクトリが存在し、以前にインデックスがID '{index_id}' で保存されているか確認してください。")
    print("インデックスを保存するには、提供された「インデックス作成と保存コード」を実行する必要があります。")
    sys.exit(1) # プログラムを終了
except Exception as e:
    print(f"予期せぬエラーが発生しました: {e}")
    sys.exit(1) # プログラムを終了

で保存し

python ask_index.py 
LLMと埋め込みモデルの設定が完了しました。
インデックスをロード中...
インデックスのロードが完了しました。
クエリエンジンの準備ができました。

質問を入力してください(終了するには 'exit' と入力)。
あなたの質問: 火車の概要を教えて下さい 
回答を生成中...
質問処理中にエラーが発生しました: model "elyza8b" not found, try pulling it first
もう一度お試しください。
あなたの質問: exit
プログラムを終了します。

となるので、モデル名を修正
正しくは

Settings.llm = Ollama(model="elyza:jp8b", request_timeout=360.0)

これで実行すると

 python ask_index.py
LLMと埋め込みモデルの設定が完了しました。
インデックスをロード中...
インデックスのロードが完了しました。
クエリエンジンの準備ができました。

質問を入力してください(終了するには 'exit' と入力)。
あなたの質問: 火車の概要を教えて下さい 
回答を生成中...

--- 回答 ---
『火車』は、宮部みゆきの推理小説である。社会問題としての消費者金融のありかたをテーマとしており、サラリーマン金融やカード破産などの借財と多重債務を題材にした作品となっている。
------------

というように問題なく動作することができる
ただし挙動が少し遅い

他の質問をすると

あなたの質問: 日本の総理大臣は?
回答を生成中...

--- 回答 ---
この情報には日本の総理大臣に関する記述がありません。
------------

あなたの質問: exit
プログラムを終了します。
となってしまう

この結果を Gemini で調べてみると

うまく動作していますね! LlamaIndex を使った RAG (Retrieval-Augmented Generation) システムが意図通りに機能していることを示しています。
動作の分析
あなたの実行結果は、以下のように解釈できます。
1. 火車の概要を教えて下さい:
* 期待通りの動作: 『火車』に関する情報は、インデックスの作成時に指定したWikipediaのURL(https://ja.wikipedia.org/wiki/%E7%81%AB%E8%BB%8A_(%E5%B0%8F%E8%AA%AC))に含まれています。
* RAGの成功: LlamaIndex が質問をベクトル化し、関連するノードをインデックスから検索し、その情報に基づいて Ollama の elyza8b モデルが正確な概要を生成しました。

2. 陰陽五行について教えて:
* 「記述がありません。」: これは、インデックス作成時に与えたWikipediaのURL(宮部みゆきさんの小説に関するページ)に陰陽五行に関する情報が含まれていないため、LlamaIndex が関連するノードを見つけられなかったことを意味します。
* LLMの正直な回答: 大規模言語モデル(LLM)は、検索された情報に基づいて回答を生成します。関連情報が見つからない場合、このように「記述がありません」といった回答を返すのは、ハルシネーション(でっち上げ)を防ぐための正しい挙動です。

3. 日本の総理大臣は?:
* 「この情報には日本の総理大臣に関する記述がありません。」: 陰陽五行 の場合と同様に、インデックスのソースデータには日本の総理大臣に関する情報が含まれていません。
* LLMの正しい挙動: LLMは与えられた知識ベース(今回RAGで検索されたノード)の範囲内で回答を生成しようとします。その範囲外の知識については、このように情報がないことを伝えます。

まとめ
あなたの LlamaIndex 環境は、指定した知識ベース(今回の場合、宮部みゆきさんのWikipediaページ)に基づいて質問に回答できる状態になっています。これは、RAG システムが意図通りに機能している証拠です。
* インデックスに含まれる情報: 質問に正確に回答。
* インデックスに含まれない情報: 情報がないことを正直に回答。
この挙動は、限定された専門知識を持つAIシステムを構築する上で非常に重要です。もし汎用的な知識も必要とする場合は、インデックスに追加するドキュメントの範囲を広げたり、RAGと通常のLLMの推論を組み合わせたりするなどの工夫が必要になります。

つまり動作は正常だが
RAGと通常のLLMの推論を組み合わせが必要になる

import sys
from llama_index.core import StorageContext, load_index_from_storage, Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

# --- 1. LLMと埋め込みモデルの設定 ---
Settings.llm = Ollama(model="elyza:jp8b", request_timeout=360.0)
Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")
print("LLMと埋め込みモデルの設定が完了しました。")

# --- 2. インデックスのロード ---
print("インデックスをロード中...")
try:
    persist_dir = "./storage/simple_vs"
    index_id = "miyabe_miyuki"

    storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
    loaded_index = load_index_from_storage(storage_context, index_id=index_id)
    print("インデックスのロードが完了しました。")

    query_engine = loaded_index.as_query_engine(similarity_top_k=3)
    print("クエリエンジンの準備ができました。")

    direct_llm = Settings.llm  # 直接LLM回答用のLLM

    print("\n質問を入力してください(終了するには 'exit' と入力)。")
    while True:
        query = input("あなたの質問: ")

        if query.lower() == 'exit':
            print("プログラムを終了します。"); break

        if not query.strip():
            print("質問を入力してください。"); continue

        print("回答を生成中...")
        try:
            response = query_engine.query(query)
            
            # RAGの回答が空または不十分な場合
            if not response or "情報がありません" in response.response or len(response.response.strip()) == 0:
                print("RAGに該当情報がないため、直接LLMで回答します。");
                direct_response = direct_llm.complete(query)
                final_response = direct_response.text
            else:
                final_response = response.response

            print("\n--- 回答 ---")
            print(final_response)
            print("------------\n")

        except Exception as e:
            print(f"質問処理中にエラーが発生しました: {e}")

except FileNotFoundError:
    print(f"エラー: インデックスデータが見つかりません。'{persist_dir}' を確認してください。");
    sys.exit(1)
except Exception as e:
    print(f"予期せぬエラーが発生しました: {e}");
    sys.exit(1)

としたが

実行すると

LLMと埋め込みモデルの設定が完了しました。 インデックスをロード中... インデックスのロードが完了しました。 クエリエンジンの準備ができました。 質問を入力してください(終了するには 'exit' と入力)。 あなたの質問: 日本の総理大臣は 回答を生成中... --- 回答 --- 提供された文書には、日本の総理大臣に関する情報は含まれていません。 ------------ 

となる

elyza:jp8b単体では答えが出る

これは、現在のコードでRAGの回答が「情報がない」と認識できるように明示的な処理ができていないために起きています。
具体的には、LlamaIndexのquery_engine.query()メソッドは、情報がないときでも通常「情報がありません」というような明確なテキストを返さず、「提供された文書には〜情報は含まれていません」という形式のメッセージを返します。このため、現在の条件式では不十分

解決方法:
RAGの回答に「含まれていません」「情報は含まれていません」などの特定のフレーズが含まれているかで判定し、直接LLMを使って回答を得るよう調整

response = query_engine.query(query)

# RAGの回答が不十分な場合の判断を改善
insufficient_phrases = ["含まれていません", "情報は含まれていません", "情報がありません", "該当情報がありません"]

if any(phrase in response.response for phrase in insufficient_phrases) or not response.response.strip():
    print("RAGに該当情報がないため、直接LLMで回答します。")
    direct_response = direct_llm.complete(query)
    final_response = direct_response.text
else:
    final_response = response.response

print("\n--- 回答 ---")
print(final_response)
print("------------\n")

しかし

質問を入力してください(終了するには 'exit' と入力)。
あなたの質問: 日本の総理大臣
回答を生成中...

--- 回答 ---
提供された文書には、特定のページに関する情報やリンクが含まれていますが、日本の総理大臣に関する記述はありません。
------------

となる

RAGの回答判定がうまく働いていないようです。この問題の原因は、返ってくる回答オブジェクトの型や内容が期待と異なるためです。
以下のように、明確に response.response の内容をチェックし、不十分な回答だと判断したらLLMへフォールバックするようコードを厳密に書き直す

import sys
from llama_index.core import StorageContext, load_index_from_storage, Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

# --- 1. LLMと埋め込みモデルの設定 ---
Settings.llm = Ollama(model="elyza:jp8b", request_timeout=360.0)
Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")
print("LLMと埋め込みモデルの設定が完了しました。")

# --- 2. インデックスのロード ---
print("インデックスをロード中...")
try:
    persist_dir = "./storage/simple_vs"
    index_id = "miyabe_miyuki"

    storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
    loaded_index = load_index_from_storage(storage_context, index_id=index_id)
    print("インデックスのロードが完了しました。")

    query_engine = loaded_index.as_query_engine(similarity_top_k=3)
    print("クエリエンジンの準備ができました。")

    direct_llm = Settings.llm  # 直接LLM回答用のLLM

    print("\n質問を入力してください(終了するには 'exit' と入力)。")
    while True:
        query = input("あなたの質問: ")

        if query.lower() == 'exit':
            print("プログラムを終了します。"); break

        if not query.strip():
            print("質問を入力してください。"); continue

        print("回答を生成中...")
        try:
            response = query_engine.query(query)
            
            # RAGの回答が不十分な場合の判断を改善
            insufficient_phrases = [
                "含まれていません",
                "情報は含まれていません",
                "情報がありません",
                "該当情報がありません",
                "記述はありません",
                "見つかりません"
            ]


            # 判定をresponse.responseのテキストで明示的に行う
            if (not response.response.strip()) or any(phrase in response.response for phrase in insufficient_phrases):
                print("RAGに該当情報がないため、直接LLMで回答します。")
                direct_response = direct_llm.complete(query)
                final_response = direct_response.text
            else:
                final_response = response.response

            print("\n--- 回答 ---")
            print(final_response)
            print("------------\n")

        except Exception as e:
            print(f"質問処理中にエラーが発生しました: {e}")

except FileNotFoundError:
    print(f"エラー: インデックスデータが見つかりません。'{persist_dir}' を確認してください。");
    sys.exit(1)
except Exception as e:
    print(f"予期せぬエラーが発生しました: {e}");
    sys.exit(1)

これで
* insufficient_phrases に『記述はありません』や『見つかりません』といった、今回表示された具体的なフレーズを追加しました。
* これにより、今回のような回答が出た場合でも、確実にLLM(elyza:jp8b)にフォールバックします。
この修正版のコードを適用すると、質問『日本の総理大臣』のようにRAGに情報がない場合でも、elyza:jp8bを使用して正しく回答を生成できるようになります

実行すると


LLMと埋め込みモデルの設定が完了しました。
インデックスをロード中...
インデックスのロードが完了しました。
クエリエンジンの準備ができました。

質問を入力してください(終了するには 'exit' と入力)。
あなたの質問: 日本の総理
回答を生成中...
RAGに該当情報がないため、直接LLMで回答します。

--- 回答 ---
日本の歴代総理大臣の一覧です。

1. 伊藤博文 (1878年12月22日 - 1885年12月24日)
2. 黒田清隆 (1885年12月24日 - 1889年4月10日)
3. 山縣有朋 (1889年4月10日 - 1891年11月30日)
4. 松方正義 (1891年11月30日 - 1896年9月18日)
5. 伊藤博文 (1896年9月18日 - 1898年11月30日)
6. 小松原英太郎 (1898年11月30日 - 1900年10月31日)
7. 加藤高明 (1900年10月31日 - 1901年6月13日)
8. 松方正義 (1901年6月13日 - 1902年9月15日)
9. 尾崎行雄 (1902年9月15日 - 1903年7月30日)
10. 上原勇作 (1903年7月30日 - 1904年11月29日)
11. 福島県知事・大隈重信 (1905年8月31日 - 1906年12月25日)
12. 桂太郎 (1907年1月18日 - 1911年10月28日)
13. 小村寿太郎 (1912年4月30日 - 1913年12月21日)
14. 内閣総理大臣・清浦奎吾 (1914年5月15日 - 1916年8月10日)
15. 加藤友三郎 (1916年8...
------------

あなたの質問: 今の日本の総理大臣
回答を生成中...

--- 回答 ---
この情報には、特定の人物や役職の名前が多数記載されています。ただし、現在の日本の総理大臣は明記されていません。
------------

あなたの質問: 今年の日本の総理大臣
回答を生成中...
RAGに該当情報がないため、直接LLMで回答します。

--- 回答 ---
2023年現在の日本の内閣総理大臣は、岸田文雄です。
------------

あなたの質問: 日本の首都は?
回答を生成中...

--- 回答 ---
東京
------------

あなたの質問: 現在のアメリカの大統領
回答を生成中...
RAGに該当情報がないため、直接LLMで回答します。

--- 回答 ---
2021年現在、アメリカ合衆国の大統領はジョー・バイデンです。
------------

となる

この状況が起きている原因は、LLMの回答においてモデルが「現在の年(2023年や2021年)」を正しく認識しておらず、古い情報や曖昧な回答を出してしまっているためです。
これはモデルが持つ知識が訓練データ(2023年10月まで)に依存しているため、現在(2025年)の最新情報とずれが生じていることが原因

これは
推論時に「現在の日付」をプロンプトに含めることで、回答を現在の日付に基づいた正確なものに誘導することができます

from datetime import datetime

response = query_engine.query(query)

insufficient_phrases = [
    "含まれていません",
    "情報は含まれていません",
    "情報がありません",
    "該当情報がありません",
    "記述はありません",
    "見つかりません"
]

current_date = datetime.now().strftime("%Y年%m月%d日")

if (not response.response.strip()) or any(phrase in response.response for phrase in insufficient_phrases):
    print("RAGに該当情報がないため、直接LLMで回答します。")
    
    prompt_with_date = f"今日は{current_date}です。この日付を基準として次の質問に答えてください。\n質問:{query}"
    direct_response = direct_llm.complete(prompt_with_date)
    final_response = direct_response.text
else:
    final_response = response.response

print("\n--- 回答 ---")
print(final_response)
print("------------\n")

というようにコード変更する

これで実験する

LLMと埋め込みモデルの設定が完了しました。
インデックスをロード中...
インデックスのロードが完了しました。
クエリエンジンの準備ができました。

質問を入力してください(終了するには 'exit' と入力)。
あなたの質問: 現在のアメリカの大統領
回答を生成中...

--- 回答 ---
この質問は、ウィキペディアやウィキデータに該当する情報が含まれています。
------------

あなたの質問: 今の日本の総理大臣
回答を生成中...

--- 回答 ---
この情報は、Wikipediaのページ編集用のメニューであり、政治や人物に関する情報が含まれています。したがって、今の日本の総理大臣を特定することができません。
------------

という結果になる

このため
RAGの応答が新たなパターンで返ってきているため、現在の条件文では対応できていないからです。
今回返ってきた文言:
* 「ウィキペディアやウィキデータに該当する情報が含まれています。」
* 「Wikipediaのページ編集用のメニューであり〜特定することができません。」
を追加の判定条件として組み込み対処

            insufficient_phrases = [
                "含まれていません",
                "情報は含まれていません",
                "情報がありません",
                "該当情報がありません",
                "記述はありません",
                "見つかりません",
                "特定することができません",
                "Wikipediaのページ編集用",
                "ウィキペディアやウィキデータ"
            ]

というように対応ワードを追加

実行結果で気になるのが

import sys
from llama_index.core import StorageContext, load_index_from_storage, Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from datetime import datetime

# --- 1. LLMと埋め込みモデルの設定 ---
Settings.llm = Ollama(model="elyza:jp8b", request_timeout=360.0)
Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")
print("LLMと埋め込みモデルの設定が完了しました。")

# --- 2. インデックスのロード ---
print("インデックスをロード中...")
try:
    persist_dir = "./storage/simple_vs"
    index_id = "miyabe_miyuki"

    storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
    loaded_index = load_index_from_storage(storage_context, index_id=index_id)
    print("インデックスのロードが完了しました。")

    query_engine = loaded_index.as_query_engine(similarity_top_k=3)
    print("クエリエンジンの準備ができました。")

    direct_llm = Settings.llm  # 直接LLM回答用のLLM

    print("\n質問を入力してください(終了するには 'exit' と入力)。")
    while True:
        query = input("あなたの質問: ")

        if query.lower() == 'exit':
            print("プログラムを終了します。"); break

        if not query.strip():
            print("質問を入力してください。"); continue

        print("回答を生成中...")
        try:
            response = query_engine.query(query)

            insufficient_phrases = [
                "含まれていません",
                "情報は含まれていません",
                "情報がありません",
                "該当情報がありません",
                "記述はありません",
                "見つかりません",
                "特定することができません",
                "Wikipediaのページ編集用",
                "ウィキペディアやウィキデータ",
                "記載がありません",
                "MediaWiki",
                "閲覧したとしても"
            ]

            current_date = datetime.now().strftime("%Y年%m月%d日")

            if (not response.response.strip()) or any(phrase in response.response for phrase in insufficient_phrases):
                print("RAGに該当情報がないため、直接LLMで回答します。")
                
                prompt_with_date = f"今日は{current_date}です。この日付を基準として次の質問に答えてください。\n質問:{query}"
                direct_response = direct_llm.complete(prompt_with_date)
                final_response = direct_response.text
            else:
                final_response = response.response

            print("\n--- 回答 ---")
            print(final_response)
            print("------------\n")


        except Exception as e:
            print(f"質問処理中にエラーが発生しました: {e}")

except FileNotFoundError:
    print(f"エラー: インデックスデータが見つかりません。'{persist_dir}' を確認してください。");
    sys.exit(1)
except Exception as e:
    print(f"予期せぬエラーが発生しました: {e}");
    sys.exit(1)

を実行すると

あなたの質問: 現在時刻を教えて
回答を生成中...

--- 回答 ---
2025年5月11日(日)10:52
------------

となる
現在の日付とあっていない
しかし

あなたの質問: 今日は何曜日?
回答を生成中...
RAGに該当情報がないため、直接LLMで回答します。

--- 回答 ---
2025年05月29日は、木曜日にあたります。
------------

では正確な答えになる

これは
質問に日付を含めてモデルに渡してはいるものの、「現在時刻を教えて」の質問に対して、モデルが自分の持つ内部情報(訓練時の情報)を参照して回答を返してしまっているから
具体的に言うと、質問に『今日は2025年05月29日です』と日付は含めていますが、『時刻』までは渡していません。そのため、LLMが「時刻」に関しては独自の推測(誤った日時)を返しています

日付だけでなく、現在時刻も明示的にプロンプトに含めることで正確な回答を得られるようになります
ということなので

            current_date = datetime.now().strftime("%Y年%m月%d日")

            current_datetime = datetime.now().strftime("%Y年%m月%d日 %H:%M")

            prompt_with_datetime = f"現在の日時は {current_datetime} です。この日時を基準として次の質問に答えてください。\n質問:{query}"

と変更

全体コードとしては

import sys
from llama_index.core import StorageContext, load_index_from_storage, Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from datetime import datetime

# --- 1. LLMと埋め込みモデルの設定 ---
Settings.llm = Ollama(model="elyza:jp8b", request_timeout=360.0)
Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")
print("LLMと埋め込みモデルの設定が完了しました。")

# --- 2. インデックスのロード ---
print("インデックスをロード中...")
try:
    persist_dir = "./storage/simple_vs"
    index_id = "miyabe_miyuki"

    storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
    loaded_index = load_index_from_storage(storage_context, index_id=index_id)
    print("インデックスのロードが完了しました。")

    query_engine = loaded_index.as_query_engine(similarity_top_k=3)
    print("クエリエンジンの準備ができました。")

    direct_llm = Settings.llm  # 直接LLM回答用のLLM

    print("\n質問を入力してください(終了するには 'exit' と入力)。")
    while True:
        query = input("あなたの質問: ")

        if query.lower() == 'exit':
            print("プログラムを終了します。"); break

        if not query.strip():
            print("質問を入力してください。"); continue

        print("回答を生成中...")
        try:
            response = query_engine.query(query)

            insufficient_phrases = [
                "含まれていません",
                "情報は含まれていません",
                "情報がありません",
                "該当情報がありません",
                "記述はありません",
                "見つかりません",
                "特定することができません",
                "Wikipediaのページ編集用",
                "ウィキペディアやウィキデータ",
                "記載がありません",
                "MediaWiki",
                "閲覧したとしても"
            ]
            current_datetime = datetime.now().strftime("%Y年%m月%d日 %H:%M")
            prompt_with_datetime = f"現在の日時は {current_datetime} です。この日時を基準として次の質問に答えてください。\n質問:{query}"

            if (not response.response.strip()) or any(phrase in response.response for phrase in insufficient_phrases):
                print("RAGに該当情報がないため、直接LLMで回答します。")
                direct_response = direct_llm.complete(prompt_with_datetime)
                final_response = direct_response.text
            else:
                final_response = response.response

            print("\n--- 回答 ---")
            print(final_response)
            print("------------\n")

        except Exception as e:
            print(f"質問処理中にエラーが発生しました: {e}")

except FileNotFoundError:
    print(f"エラー: インデックスデータが見つかりません。'{persist_dir}' を確認してください。");
    sys.exit(1)
except Exception as e:
    print(f"予期せぬエラーが発生しました: {e}");
    sys.exit(1)

しかし結果は

LLMと埋め込みモデルの設定が完了しました。
インデックスをロード中...
インデックスのロードが完了しました。
クエリエンジンの準備ができました。

質問を入力してください(終了するには 'exit' と入力)。
あなたの質問: 現在の時刻は?
回答を生成中...
RAGに該当情報がないため、直接LLMで回答します。

--- 回答 ---
現在の時刻は、00:46です。
------------

あなたの質問: 現在時刻を教えて
回答を生成中...

--- 回答 ---
2025年5月11日 (日) 10:52
------------

あなたの質問: 今の時間は?
回答を生成中...
RAGに該当情報がないため、直接LLMで回答します。

--- 回答 ---
現在の時間は00:47です。
------------

あなたの質問: 今日は何日?
回答を生成中...

--- 回答 ---
この情報には、日付に関する記述は見当たりません。
------------

あなたの質問: 

というようにプロンプトにより結果が変わってしまう

前提条件を変更するため
Gemini で原因を聞いてみる

ご提示の実行結果を見ると、同じ「現在の時刻」に関する質問でも、RAG(Retrieval-Augmented Generation)の有無やLLMの回答内容に違いが生じていますね。この現象の主な原因は、プロンプトがLLMの回答に与える影響、特にRAGが機能しなかった場合のフォールバックメカニズムにあります。

プロンプトによる回答の正確さの変動要因
1. RAGの回答の有無と品質
RAGは、事前に用意された知識ベース(この場合はmiyabe_miyukiインデックス)から関連情報を検索し、その情報を基にLLMが回答を生成する仕組みです。
* RAGが機能した場合:
* あなたの質問「現在時刻を教えて」に対しては、RAGが何らかの情報を取得し、その情報をLLMが処理して「2025年5月11日 (日) 10:52」という回答を生成しています。これは、RAGが参照した情報源に、たまたま古い日付と時刻が含まれていた可能性を示唆しています。RAGは、その情報源に「現在時刻」に関する情報が含まれていない限り、リアルタイムの時刻を知ることはできません。 今回のインデックスが「宮部みゆき」氏に関するものであれば、時刻情報が含まれている可能性は極めて低いでしょう。
* RAGが機能しなかった場合(フォールバック):
* 「現在の時刻は?」や「今の時間は?」のように、RAGの検索結果に「不十分なフレーズ」が含まれていると判断された場合、スクリプトは直接LLMに質問を投げ直すフォールバック処理を行っています。この時、プロンプトには”現在の日時は {current_datetime} です。この日時を基準として次の質問に答えてください。\n質問:{query}”という形で、スクリプト実行時のリアルタイムの日時情報が付与されています。これにより、LLMは自身の持つ一般的な知識に加えて、明示的に与えられた最新の日時情報を参照して回答を生成します。そのため、「00:46」や「00:47」といった、スクリプト実行時に近い時刻を回答できています。
2. LLMの持つ一般的な知識とバイアス
LLMは学習データに基づいた一般的な知識を持っています。しかし、その知識はリアルタイムの情報ではありません。
* 「今日は何日?」への回答:
* この質問に対しては、RAGが「この情報には、日付に関する記述は見当たりません。」と回答しています。これはRAGの情報源に日付情報がなかったためと考えられます。そして、フォールバックが行われず、LLMに現在の日時情報を含むプロンプトが渡されなかったため、LLMはRAGの回答をそのまま出力してしまっています。もし、この質問もフォールバックの対象となっていれば、現在の正確な日付を回答できたはずです。
3. 「不十分なフレーズ」によるRAGの判断基準
スクリプト内で定義されているinsufficient_phrasesのリストは、RAGが生成した回答が「役に立たない」と判断するためのキーワードです。
* RAGの回答にこれらのフレーズが含まれていた場合、スクリプトはRAGの回答を破棄し、直接LLMに最新の日時情報を含むプロンプトを渡しています。これにより、LLMはより正確なリアルタイムの時刻情報を提供できています。

まとめ
プロンプトによって回答の正確さが変わる主な原因は、以下の2点に集約されます。
1. RAGが参照する情報源の特性: RAGは事前にインデックス化された情報しか利用できないため、リアルタイムの情報(現在の時刻や日付など)は提供できません。
2. フォールバック時のプロンプトの変化: RAGが役に立たないと判断された場合、スクリプトがLLMに渡すプロンプトにリアルタイムの日時情報が明示的に追加されるため、LLMはその情報に基づいてより正確な回答を生成できます。
したがって、今回のケースでは、スクリプトがフォールバック時に動的にプロンプトを調整していることが、時刻の回答の正確さに大きく寄与していると言えます。RAGが適切にリアルタイム情報を扱えない場合、このようなフォールバック戦略は非常に有効です。

この問題を解決するためにコードを変更

RAGの検索結果に関わらず、日付と時刻に関する質問に対しては、常に最新の日時情報をLLMに伝えるようにコードを変更

変更の概要
主な変更点は以下の通りです。
1. 日付・時刻関連のキーワードの検出: 質問内容が日付や時刻に関連するかどうかを判断するためのキーワードリストを定義します。
2. プロンプトへの日時情報追加の条件変更: RAGの回答が不十分な場合だけでなく、質問が日付や時刻に関連すると判断された場合にも、現在のリアルタイムの日時情報をLLMへのプロンプトに含めるようにします。
これにより、ユーザーが「現在の時刻は?」や「今日の日付は?」と質問した場合、RAGの検索結果に関わらず、スクリプトが取得した最新の日時情報がLLMに渡され、LLMはその情報に基づいて正確な回答を生成できるようになります

import sys
from llama_index.core import StorageContext, load_index_from_storage, Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from datetime import datetime

# --- 1. LLMと埋め込みモデルの設定 ---
Settings.llm = Ollama(model="elyza:jp8b", request_timeout=360.0)
Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")
print("LLMと埋め込みモデルの設定が完了しました。")

# --- 2. インデックスのロード ---
print("インデックスをロード中...")
try:
    persist_dir = "./storage/simple_vs"
    index_id = "miyabe_miyuki"

    storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
    loaded_index = load_index_from_storage(storage_context, index_id=index_id)
    print("インデックスのロードが完了しました。")

    query_engine = loaded_index.as_query_engine(similarity_top_k=3)
    print("クエリエンジンの準備ができました。")

    direct_llm = Settings.llm  # 直接LLM回答用のLLM

    # 日時関連の質問を検出するためのキーワード
    datetime_keywords = ["時刻", "時間", "日付", "何日", "今日", "今"]

    print("\n質問を入力してください(終了するには 'exit' と入力)。")
    while True:
        query = input("あなたの質問: ")

        if query.lower() == 'exit':
            print("プログラムを終了します。"); break

        if not query.strip():
            print("質問を入力してください。"); continue

        print("回答を生成中...")
        try:
            # RAGで情報を検索
            response = query_engine.query(query)

            insufficient_phrases = [
                "含まれていません", "情報は含まれていません", "情報がありません",
                "該当情報がありません", "記述はありません", "見つかりません",
                "特定することができません", "Wikipediaのページ編集用", "ウィキペディアやウィキデータ",
                "記載がありません", "MediaWiki", "閲覧したとしても"
            ]

            # 日時関連の質問かどうかを判定
            is_datetime_query = any(keyword in query for keyword in datetime_keywords)

            # 現在の日時情報を取得
            current_datetime = datetime.now().strftime("%Y年%m月%d日 %H:%M")
            prompt_with_datetime = f"現在の日時は {current_datetime} です。この日時を基準として次の質問に答えてください。\n質問:{query}"

            final_response = ""

            # RAGの回答が不十分な場合、または日時関連の質問の場合に直接LLMで回答
            if (not response.response.strip()) or any(phrase in response.response for phrase in insufficient_phrases) or is_datetime_query:
                print("RAGに該当情報がないか、日時関連の質問のため、直接LLMで回答します。")
                direct_response = direct_llm.complete(prompt_with_datetime)
                final_response = direct_response.text
            else:
                final_response = response.response

            print("\n--- 回答 ---")
            print(final_response)
            print("------------\n")

        except Exception as e:
            print(f"質問処理中にエラーが発生しました: {e}")

except FileNotFoundError:
    print(f"エラー: インデックスデータが見つかりません。'{persist_dir}' を確認してください。");
    sys.exit(1)
except Exception as e:
    print(f"予期せぬエラーが発生しました: {e}");
    sys.exit(1)

とコード変更

現在の課題は、RAGがリアルタイム情報(特に日付と時刻)を提供できないために、LLMが常に正確な「現在」の情報を答えることができない点ですね。これを解決するために、RAGの検索結果に関わらず、日付と時刻に関する質問に対しては、常に最新の日時情報をLLMに伝えるようにコードを変更します。

変更の概要
主な変更点は以下の通りです。
1. 日付・時刻関連のキーワードの検出: 質問内容が日付や時刻に関連するかどうかを判断するためのキーワードリストを定義します。
2. プロンプトへの日時情報追加の条件変更: RAGの回答が不十分な場合だけでなく、質問が日付や時刻に関連すると判断された場合にも、現在のリアルタイムの日時情報をLLMへのプロンプトに含めるようにします。
これにより、ユーザーが「現在の時刻は?」や「今日の日付は?」と質問した場合、RAGの検索結果に関わらず、スクリプトが取得した最新の日時情報がLLMに渡され、LLMはその情報に基づいて正確な回答を生成できるようになります。

変更後のコード


import sys
from llama_index.core import StorageContext, load_index_from_storage, Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from datetime import datetime

# --- 1. LLMと埋め込みモデルの設定 ---
Settings.llm = Ollama(model="elyza:jp8b", request_timeout=360.0)
Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")
print("LLMと埋め込みモデルの設定が完了しました。")

# --- 2. インデックスのロード ---
print("インデックスをロード中...")
try:
    persist_dir = "./storage/simple_vs"
    index_id = "miyabe_miyuki"

    storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
    loaded_index = load_index_from_storage(storage_context, index_id=index_id)
    print("インデックスのロードが完了しました。")

    query_engine = loaded_index.as_query_engine(similarity_top_k=3)
    print("クエリエンジンの準備ができました。")

    direct_llm = Settings.llm  # 直接LLM回答用のLLM

    # 日時関連の質問を検出するためのキーワード
    datetime_keywords = ["時刻", "時間", "日付", "何日", "今日", "今"]

    print("\n質問を入力してください(終了するには 'exit' と入力)。")
    while True:
        query = input("あなたの質問: ")

        if query.lower() == 'exit':
            print("プログラムを終了します。"); break

        if not query.strip():
            print("質問を入力してください。"); continue

        print("回答を生成中...")
        try:
            # RAGで情報を検索
            response = query_engine.query(query)

            insufficient_phrases = [
                "含まれていません", "情報は含まれていません", "情報がありません",
                "該当情報がありません", "記述はありません", "見つかりません",
                "特定することができません", "Wikipediaのページ編集用", "ウィキペディアやウィキデータ",
                "記載がありません", "MediaWiki", "閲覧したとしても"
            ]

            # 日時関連の質問かどうかを判定
            is_datetime_query = any(keyword in query for keyword in datetime_keywords)

            # 現在の日時情報を取得
            current_datetime = datetime.now().strftime("%Y年%m月%d日 %H:%M")
            prompt_with_datetime = f"現在の日時は {current_datetime} です。この日時を基準として次の質問に答えてください。\n質問:{query}"

            final_response = ""

            # RAGの回答が不十分な場合、または日時関連の質問の場合に直接LLMで回答
            if (not response.response.strip()) or any(phrase in response.response for phrase in insufficient_phrases) or is_datetime_query:
                print("RAGに該当情報がないか、日時関連の質問のため、直接LLMで回答します。")
                direct_response = direct_llm.complete(prompt_with_datetime)
                final_response = direct_response.text
            else:
                final_response = response.response

            print("\n--- 回答 ---")
            print(final_response)
            print("------------\n")

        except Exception as e:
            print(f"質問処理中にエラーが発生しました: {e}")

except FileNotFoundError:
    print(f"エラー: インデックスデータが見つかりません。'{persist_dir}' を確認してください。");
    sys.exit(1)
except Exception as e:
    print(f"予期せぬエラーが発生しました: {e}");
    sys.exit(1)

変更点の解説
1. datetime_keywordsリストの追加:

datetime_keywords = ["時刻", "時間", "日付", "何日", "今日", "今"]

このリストは、ユーザーの質問にこれらのキーワードが含まれている場合に、その質問を日時関連の質問として識別するために使用

is_datetime_query変数の追加:

is_datetime_query = any(keyword in query for keyword in datetime_keywords)

ユーザーのqueryの中にdatetime_keywordsリスト内のいずれかのキーワードが含まれているかをチェックし、結果をブール値で保持

直接LLMで回答する条件の変更:

if (not response.response.strip()) or any(phrase in response.response for phrase in insufficient_phrases) or is_datetime_query:
    print("RAGに該当情報がないか、日時関連の質問のため、直接LLMで回答します。")
    direct_response = direct_llm.complete(prompt_with_datetime)
    final_response = direct_response.text
else:
    final_response = response.response

ここが最も重要な変更点です。これまでの条件 (RAGの回答が空、または不十分なフレーズを含む) に加えて、or is_datetime_queryを追加しました。これにより、質問が日時関連であると判断された場合も、RAGの回答内容に関わらず、直接LLMに最新の日時情報付きのプロンプトが送られます

実行すると

LLMと埋め込みモデルの設定が完了しました。
インデックスをロード中...
インデックスのロードが完了しました。
クエリエンジンの準備ができました。

質問を入力してください(終了するには 'exit' と入力)。
あなたの質問: 今日は何日?
回答を生成中...
RAGに該当情報がないか、日時関連の質問のため、直接LLMで回答します。

--- 回答 ---
現在の日付は2025年05月29日なので、今日は29日です。
------------

あなたの質問: 今 今の時間は?
回答を生成中...
RAGに該当情報がないか、日時関連の質問のため、直接LLMで回答します。

--- 回答 ---
現在の日時は2025年05月29日00:59なので、00:59です。
------------

あなたの質問: 今のアメリカの大統領は?
回答を生成中...
RAGに該当情報がないか、日時関連の質問のため、直接LLMで回答します。

--- 回答 ---
2025年05月29日現在、米国の大統領はジョー・バイデン氏です。
------------

というように大統領の名前が違う

これは学習済みデータにもRAGにもないのが原因なので正常動作になる
次は取説と約款のRAG作成

その前に gemma3 4b + faster-whusoer のコードを公開する

chromaについて

chromaについて

「Chroma」は、軽量かつ高速な**ベクトルストア(Vector Store)**で、主にRAG(Retrieval-Augmented Generation)や類似文書検索に使われる

■ Chromaとは
**Chroma(正式名称:ChromaDB)は、テキストや画像などのデータをベクトル(数値の集合)として保存し、後から類似検索(類似性の高いものを検索)**するためのデータベースです。
* 開発元:Chromadb Inc.
* ライセンス:Apache 2.0(オープンソース)
* 特徴:
* 自己完結型(埋め込みデータの保存から検索まで完結)
* 高速・軽量(ローカルでも動作)
* RAGなどのLLMとの相性が良い

■ 主な用途
1. RAG(LLM × ベクトル検索)
* PDFや文書を分割してベクトル化し、Chromaに保存
* クエリと類似する文書をChromaで検索してLLMに渡す
2. 類似文章検索
* 「この文章に似ているものは?」という検索が可能
3. 画像や音声の類似性検索(拡張可能)

■ 保存形式(Chroma + SQLite + Parquet)
Chromaはデフォルトで以下を使います:
* SQLite:メタデータや設定情報を保存
* Parquet:ベクトルそのもの(高次元な数値)を効率的に保存
この構成により、
* ローカルファイルでの軽量運用が可能
* クラッシュしてもデータが残りやすい
* 他ツールと連携しやすい(Parquet形式はPandasやSparkとも互換あり)

pip install chromadb

でインストール

次にテスト

import chromadb
from chromadb.utils.embedding_functions import OpenAIEmbeddingFunction

client = chromadb.Client()
collection = client.create_collection(name="my_collection")

collection.add(
    documents=["東京は日本の首都です", "大阪は関西の中心都市です"],
    ids=["doc1", "doc2"]
)

results = collection.query(
    query_texts=["日本の都市について教えて"],
    n_results=1
)
print(results)

これを
touch db_test.py
でファイル作成し

 python db_test.py

で実行

最初にモデルのダウンロードが行われるため時間がかかる

Llama index RAGのメタデータ保存

Llama index RAGのメタデータ保存

[RAGカスタマイズ] LlamaIndexで情報元のPDFのメタデータを含めた回答を生成する
を参考に行う

社内資料の検索をスムーズにするRAGシステムをLlamaIndexを使用して開発している際にある問題に当たった.
「情報の出所がどのPDFの何ページ目かを知りたい」
営業やカスタマーセンターの方が顧客から質問され,RAGを使用して回答を得て,顧客に回答するという使用方法を想定する場合,間違った情報を伝えてはならない.従って,情報源となるPDFのページを示すことで,簡単にファクトチェックを行えるようになる.
今回の記事では,このRAGシステムを構築する際によく遭遇する問題をLlamaIndexで解決していく
というコンセプトらしい

この中で

from llama_index.core import (
    StorageContext,
    load_index_from_storage,
    get_response_synthesizer
)
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import SimilarityPostprocessor

# 保存済みのindexを読み込み
index_path = "path/to/storage"
storage_context = StorageContext.from_defaults(persist_dir=index_path)

index = load_index_from_storage(storage_context=storage_context)

# Retrieverを準備
retriever = VectorIndexRetriever(
    index=index,
    similarity_top_k=5
)

# NodePostProcessorの準備
# スコアが0.75以下のNodeを削除
score_filtter_processor = SimilarityPostprocessor(similarity_cutoff=0.75)
node_postprocessors=[score_filtter_processor]

# Synthesizerの作成
response_synthesizer = get_response_synthesizer(
    output_cls=ResponseModel
)

# QueryEngineの作成
query_engine = RetrieverQueryEngine(
    retriever=retriever,
    response_synthesizer=response_synthesizer,
    node_postprocessors=[AddMetadataNodePostprocessor()]
)

output = query_engine.query("質問を入力")


「保存されたインデックス(index)を読み込んでクエリを実行する流れ」を記述したものであり、インデックスを保存する処理(=永続化のための保存処理)は含まれていません

RAGの回答にメタデータを含める
それでは,これらの方法を応用して,RAGで生成する回答に出所となるPDFファイルとそのページを含める方法についてまとめる.大きく3つの手順が必要になる.
1. PDFからDocumentオブジェクトを生成する際に所望のメタデータを含める
2. Retrieverで検索をする際にPostNodeProcessorによりNodeのテキストにメタデータを追加する
3. Synthesizerの出力形式をpydanticで指定し,メタデータが含まれるようにする

Nodeオブジェクトに所望のメタデータを含める
LlamaIndexでは,PDF → Documentオブジェクト → Nodeオブジェクト の順でデータが変換されていく.
DocumentオブジェクトのメタデータはNodeオブジェクトにも継承される.
つまり,PDFのファイルパスやページ数をメタデータとして含める場合は,
PDF → Documentの部分でそれぞれのDocumentオブジェクトにメタデータを持たせる.

LlamaIndexが提供しているSimpleDirectoryReaderでは,PDFをDocumentに変換する際,デフォルトでメタデータを付与してくれる.従って,メタデータを付与するための処理は以下の処理だけで良い.

Chromaについて調べた

llamaindex の保存先でChroma → SQLite + Parquet を勧められたが どのようなものか知りたい

🔷 1. Chroma とは?
Chroma は 高速なベクトルストア(類似検索エンジン) です。以下の特徴があります:
* ベクトル(埋め込み)とメタデータの保存・検索が可能
* シンプルなPython APIで操作可能
* ローカルストレージにも対応(クラウド不要)
* LlamaIndexと統合しやすい
LlamaIndex の ChromaVectorStore 経由で使います。

🔷 2. SQLite とは?
SQLite は 軽量な組み込み型データベース です。特徴:
* ファイル1つで完結(.db)
* インストール不要でPythonに組み込み済み
* 小中規模のアプリに最適
👉 Chromaはメタデータやインデックス情報の保存先としてSQLiteを使えます。

🔷 3. Parquet とは?
Parquet は 列指向のファイルフォーマット(Apache Arrowベース)で、以下の特徴があります:
* 高圧縮&高速読み書き
* PandasやPyArrowなどと連携しやすい
* 機械学習や分析によく使われる形式
👉 Chromaでは、埋め込みベクトル自体をParquetに保存するオプションがあります。

🧩 まとめると:この構成の利点
コンポーネント 役割 保存先
Chroma ベクトル検索・管理 –
SQLite メタデータ・インデックス情報 chroma.sqlite3
Parquet 埋め込みベクトルの保存 chroma.parquet
この構成では、以下のような利点があります:
* ✅ クラウド不要・ローカルで完結
* ✅ 速くて軽い
* ✅ LlamaIndexと連携しやすい
* ✅ データ構造が明確(バックアップ・移行しやすい)

必要なマシンスペック

🖥️ 必要なマシンスペックの目安
✅ 最小スペック(数十〜数百ページのPDFを家庭内検索用途で扱う場合)
項目 推奨値
CPU Intel Core i5 / Apple M1 / Ryzen 5 以上
メモリ(RAM) 8GB以上(できれば16GB)
ストレージ SSD(空き容量10GB程度〜)
OS macOS / Windows / Linux(どれでも可)
Python環境 Python 3.9〜3.11 + pipでパッケージ管理可能なこと
✔ 対応可能なこと
* 家電マニュアル、契約書、学校通知PDFを読み込み
* 自然言語で「何ページに書いてある?」などと検索
* 毎日新しいお知らせを追加

⚙ 中規模スペック(数千ページ、複数人で共有など)
項目 推奨値
CPU Intel Core i7 / Apple M1 Pro / Ryzen 7以上
メモリ(RAM) 16GB〜32GB
ストレージ SSD(空き容量50GB以上推奨)
その他 常時稼働できるようファン静音 or ラズパイサーバなど
✔ 対応可能なこと
* 数千ページ以上のマニュアルや書籍、PDFを対象にした検索
* 高速な応答(数秒以内)で快適な対話
* 音声やチャットでの自然なやり取り

💡 小型構成としてRaspberry Piは使える?
* Raspberry Pi 4(4GB〜8GB RAM)でも動作可能ですが、ベクトル埋め込みの生成が非常に遅くなるため、最初のインデックス構築はPCで行い、完成済みのindexだけをRaspberry Piで読み込むといった構成が理想です。

⚠️ 注意点:Chroma+Parquetの重さについて
* Chroma自体は軽量ですが、埋め込みベクトルの生成(特にHuggingFaceの大きなモデル)にはメモリとCPUを多く使います。
* ベクトルの保存(Parquet)は効率的ですが、読み込み時に全体をメモリにロードする動作があるため、数万文書以上扱うなら32GB RAMが望ましいです。

とりあえずm1macbookAIr 16GBでも動作は可能みたい

事例があるかgpt で検索

ローカルでEmbeddingしてローカルLLMでIndex検索するデモ
が近いけど
これは

構成 モジュール
Embedding(埋め込み) 「intfloat/multilingual-e5-large」をHuggingFaceEmbeddingで利用
LLM 「elyza/Llama-3-ELYZA-JP-8B-q4_k_m.gguf」をLlambaCppで利用
ベクトル検索と類似性検索 Faiss
という構成なのでちょっと違った

続・LlamaIndexを使って独自データをQ&AするだけならOpenAI API使わなくてもいいでない?
これは
* LlamaIndex
llama.cpp
の構成
こっちはデータのロード方法が載っているので参考になるかも

あと

Ruri: 日本語に特化した汎用テキスト埋め込みモデル

これも組み合わせてみる

[RAGカスタマイズ] LlamaIndexで情報元のPDFのメタデータを含めた回答を生成する
が多分一番近い

LlamaIndexのデータ形式に関するまとめ
も面白い

GPTによれば

[RAGカスタマイズ] LlamaIndexで情報元のPDFのメタデータを含めた回答を生成する
にあるように
RAGの回答にメタデータを含める
それでは,これらの方法を応用して,RAGで生成する回答に出所となるPDFファイルとそのページを含める方法についてまとめる.大きく3つの手順が必要になる.
1. PDFからDocumentオブジェクトを生成する際に所望のメタデータを含める
2. Retrieverで検索をする際にPostNodeProcessorによりNodeのテキストにメタデータを追加する
3 Synthesizerの出力形式をpydanticで指定し,メタデータが含まれるようにする

で必要なメタデータの保存も可能

Chroma(ChromaDB)でもメタデータは保存・検索可能です

✅ Chroma におけるメタデータの扱い
ChromaDB では、各ドキュメント(レコード)に対して以下の3種類の情報を保存できます:
項目 内容
ids ドキュメントの一意なID
documents 実際のテキスト内容
metadatas 任意のキー・バリューペア
この metadatas に、PDFファイルのファイルパスやページ番号などを含めることができます。

🔧 例:Chroma にメタデータ付きでデータを追加

collection.add(
    documents=["これは1ページ目です", "これは2ページ目です"],
    ids=["doc1", "doc2"],
    metadatas=[
        {"file_path": "sample.pdf", "page": 1},
        {"file_path": "sample.pdf", "page": 2}
    ]
)

このようにして、LlamaIndex の Document.metadata に相当する情報を Chroma にも明示的に渡せます。

✅ LlamaIndex × Chroma でのメタデータの流れ
LlamaIndex 側で SimpleDirectoryReader を使うと、読み込まれた Document に自動的に以下のようなメタデータが付きます:

print(documents[0].metadata)
# 出力例: {'file_path': './docs/sample.pdf', 'page_label': '1', 'page_number': 1}

このメタデータは、VectorStoreIndex を ChromaVectorStore 経由で作る際に、内部的にNodeオブジェクトにコピーされ、Chromaにも反映されます。
構成例:

from llama_index.core import SimpleDirectoryReader, VectorStoreIndex, StorageContext
from llama_index.vector_stores.chroma import ChromaVectorStore

reader = SimpleDirectoryReader(input_dir="./docs")
documents = reader.load_data()

# Chromaの初期化(永続化あり)
from chromadb.config import Settings
from chromadb import PersistentClient

client = PersistentClient(path="./chroma_db")
chroma_store = ChromaVectorStore(chroma_collection=client.get_or_create_collection("my_collection"))

storage_context = StorageContext.from_defaults(vector_store=chroma_store)
index = VectorStoreIndex.from_documents(documents, storage_context=storage_context)

index.storage_context.persist()

🔍 検索時にメタデータも取得できる?
はい。クエリ結果(Node)から、以下のようにメタデータが取得可能です:

response = index.as_query_engine().query("これは何ページの情報ですか?")
for node in response.source_nodes:
    print(node.metadata)

✅ まとめ
項目 Chroma対応 備考
ファイルパスの保存 ✅ metadatas={“file_path”: …}
ページ番号の保存 ✅ metadatas={“page”: …}
LlamaIndex連携時の自動反映 ✅ Document.metadata → Node.metadata → Chroma に自動伝搬

さらに、「特定のPDFファイル+ページに限定して検索したい」といった用途がある場合、Chromaのフィルター検索も可能です
(ただし query() ではなく query_where() 相当を使う必要あり)。希望があればその例も紹介します。

まずはインストールして進める

RAG

RAG

LlamaIndex」はツール/ライブラリの名前、
「RAG(Retrieval-Augmented Generation)」は手法やアプローチの名前

1. RAGとは
* 正式には Retrieval-Augmented Generation(検索拡張生成)。
* LLM(例:GPT、Llamaなど)に直接すべての情報を記憶させるのではなく、
必要に応じて外部の知識(ドキュメント、データベースなど)を検索し、その結果を使って回答を作成する仕組み。

* 一般的なRAGの流れ:
1. ユーザーから質問を受け取る
2. 質問に関連しそうな情報を検索(情報検索フェーズ)
3. 見つかった情報をもとにLLMが回答を生成(生成フェーズ)
→ RAGは「仕組み」や「考え方」

2. LlamaIndexとは
* RAGの「仕組み」を簡単に構築できるようにするためのPythonライブラリ。

* 役割:
* 文書やデータを「Index」(インデックス化=検索しやすく加工)
* 質問に応じてそのIndexから「検索」
* 検索結果をもとに「LLMにプロンプトして回答生成」
* つまり、LlamaIndexは
* 「データを取り込みやすくする」
* 「賢く検索できるようにする」
* 「LLMにうまく渡して回答させる」
を手伝ってくれるツールです。
→ LlamaIndexは「RAGを実装するためのツール」

| 項目 | 内容 |
| :——— | :———————– |
| RAG | 検索+生成を組み合わせた仕組み・手法 |
| LlamaIndex | RAGを作るためのPythonライブラリ・ツール |

コンセプトは
取扱説明書や契約書を電子化し、音声での質問に答えて、該当するページを表示したい。 また内容をわかりやすく説明する機能も欲しい

【あなたのゴール】
1. 取扱説明書や契約書を電子データ化しておく
2. 音声入力で質問できる
3. 質問に対して
* 該当するページやセクションを画面に表示
* 分かりやすく要約や解説もする

これは技術的には、
「RAG+音声入出力+画面UI」 を組み合わせることで実現できます!

なお
プロトタイプはmacbookairで動作し ページ表示もmacbookairだが 本番環境は画面にページ表示は指定したIPアドレスのラズパイディスプレイを想定。

なお以前動かしたものが
Ollama index チュートリアルを参考に
のメモに残っているので調べた

PDFファイルに関しては

 touch test.py

でファイルを作成

PDFファイルに関しては
Dataフォルダに格納

wget https://www.mhlw.go.jp/content/001018385.pdf -O data/10k/001018385.pdf

でファイルを取得し保存している

なおパッケージインストールは

pip install llama-index-core llama-index-readers-file llama-index-llms-ollama llama-index-embeddings-huggingface
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
from transformers import AutoTokenizer

# 使用モデルを選択します。
llm = Ollama(model="elyza:jp8b", request_timeout=40.0)

# HuggingFace Embedding モデルの初期化
embed_model = HuggingFaceEmbedding(model_name="all-MiniLM-L6-v2")

# Transformers tokenizerの設定
tokenizer = AutoTokenizer.from_pretrained("sentence-transformers/all-MiniLM-L6-v2", clean_up_tokenization_spaces=False)

# PDFファイルの読み込み
reader = SimpleDirectoryReader(input_files=["./data/001018385.pdf"])
data = reader.load_data()
index = VectorStoreIndex.from_documents(data, embed_model=embed_model)

# クエリエンジンの初期化
query_engine = index.as_query_engine(llm=llm, streaming=True, similarity_top_k=3)

# クエリの実行
response = query_engine.query("就業規則では休みは何日もらえますか?日本語で答えて。")
response.print_response_stream()

# ソースノードの確認
for node in response.source_nodes:
    print(f"Text: {node.node.text[:100]}...")
    print(f"Similarity score: {node.score}")  # 'score'を使用

これで動いているのが確認できている

またテキストファイルの場合は

test_txt.py

として

from llama_index.llms.ollama import Ollama
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
from transformers import AutoTokenizer

# 使用モデルを選択します。
llm = Ollama(model="elyza:jp8b", request_timeout=40.0)

# HuggingFace Embedding モデルの初期化
embed_model = HuggingFaceEmbedding(model_name="all-MiniLM-L6-v2")

# Transformers tokenizerの設定
tokenizer = AutoTokenizer.from_pretrained("sentence-transformers/all-MiniLM-L6-v2", clean_up_tokenization_spaces=True)

reader = SimpleDirectoryReader(input_files=["./data/10k/bocchi.txt"])
data = reader.load_data()
index = VectorStoreIndex.from_documents(data, embed_model=embed_model)


# クエリエンジンの初期化
query_engine = index.as_query_engine(llm=llm, streaming=True, similarity_top_k=3)

# ストリーミングでレスポンスを取得
response = query_engine.query(
    "文化祭ライブはどうだったのか**日本語**で簡潔に説明してください。 "
    " page reference after each statement."
)
response.print_response_stream()

で動作確認できている

あとはデータ保存について調べる

GPT、geminiともにデフォルトではメモリにあるため
プログラム終了するとデータは消えてしまう

このため保存先が必要になる

RAGのデータについて調べたが
Gemini GPTともに
メモリがデフォルトでプログラム終了後は内容は消えるため
自分で保存しないとだめ

保存しなければ → 一時的なRAM(セッション内のみ有効)
明示的に保存した場合 → index.save_to_disk() で指定したパス(通常ローカル)
外部ベクトルDB使用時 → 各サービスのストレージ領域(クラウド or DB)

用途により保存方法は異なるため
コンセプトの再確認

家庭内での利用。 妻や子供、ITに詳しくない両親たちが、家電の説明書や契約書の内容をわかりやすく説明するのに使う。
別の用途として、学校からのお知らせのPDFを随時読み込んで緊急時の対応などに対処できるようにしたい

これに対し
家庭内での用途(非技術ユーザーの利用、家電マニュアルや学校のお知らせPDFなどの理解補助)という条件であれば、
保存先は**「ローカルで簡単に使えるが、永続化されており、安定していて高速」**であることが重要
とのこと

GPTとgeminiによる提案はどちらも
家庭内利用に最適な保存先:Chroma(ローカル)

この場合必要なマシンスペックを調べることにする

ウェイクワードエンジンモジュールテスト(失敗)

ウェイクワードエンジンモジュールテスト

import pyaudio
import numpy as np
from openwakeword.model import Model

class SimpleWakeWordDetector:
    def __init__(self, model_path=&quot;alexa_v0.1.onnx&quot;, threshold=0.5):
        self.model_path = model_path
        self.threshold = threshold
        self.format = pyaudio.paInt16
        self.channels = 1
        self.rate = 16000
        self.chunk = 1024

        self.model_name = model_path  # モデル名 = ファイル名
        self.model = Model(
            wakeword_models=[self.model_path],
            inference_framework=&quot;onnx&quot;
        )

        self.audio = pyaudio.PyAudio()
        self.stream = self.audio.open(
            format=self.format,
            channels=self.channels,
            rate=self.rate,
            input=True,
            frames_per_buffer=self.chunk
        )

    def listen_for_wakeword(self):
        print(f&quot;ウェイクワード待機中...({self.model_path})&quot;)
        prev_detect = False

        while True:
            data = self.stream.read(self.chunk, exception_on_overflow=False)
            audio = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0

            self.model.predict(audio)
            score = self.model.get_last_prediction(self.model_name)

            print(f&quot;score: {score:.3f}&quot;, end='\r')

            detect = score &gt; self.threshold
            if detect and not prev_detect:
                print(f&quot;\nWakeword Detected! (score: {score:.3f})&quot;)
                return True

            prev_detect = detect

 touch simple_wakeword.py

で作成

touch wordtest.py

from simple_wakeword import SimpleWakeWordDetector

# モジュール初期化(Alexa用モデルとしきい値指定)
wakeword_detector = SimpleWakeWordDetector(
    model_path=&quot;alexa_v0.1.onnx&quot;,
    threshold=0.5
)

# 検知まで待機
detected = wakeword_detector.listen_for_wakeword()

if detected:
    print(&quot;こんにちは&quot;)

しかし反応がない

とりあえずウェイクワードは保留とし
先にRAGと llama index を行う

ウェイクワードエンジンと ollama gemma3 4b の組み合わせ(失敗)

ウェイクワードエンジンと ollama gemma3 4b の組み合わせ

とりあえずopenwakeword で alexa なら動作するので
次に音声入力が起動するように組み合わせる

これならAlexa と言ったら
音声入力を開始
という

常にマイクを監視
「ねえラマ」などのウェイクワードが話されたら録音開始
認識→質問→読み上げを実行
終了後、またウェイクワード待ちに戻る

ができるはず

import pyaudio
import numpy as np
from openwakeword.model import Model
import sys

FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000
CHUNK = 1024
audio = pyaudio.PyAudio()
mic_stream = audio.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK)

model_name = "alexa_v0.1.onnx"

model = Model(
  wakeword_models=[model_name],
  inference_framework="onnx"
)

print("Listening for wakeword \"Alexa\"...")
print()

prev_detect=False

while True:
    audio = np.frombuffer(mic_stream.read(CHUNK), dtype=np.int16)

    prediction = model.predict(audio)

    scores = model.prediction_buffer[model_name]
    curr_score = format(scores[-1], '.20f')
    detect = True if float(curr_score) > 0.5 else False

    if detect:
        if detect != prev_detect:
            print(f"Detected!({curr_score[:5]})")
            prev_detect=True
    else:
        prev_detect=False
を元にGPTの提案したコードを書き換える

touch module/module_wakeword.py
で

import pyaudio
import numpy as np
from openwakeword.model import Model

class WakeWordDetector:
    def __init__(self, model_path="alexa_v0.1.onnx", threshold=0.5):
        self.model_name = model_path
        self.threshold = threshold

        # モデル初期化
        self.model = Model(
            wakeword_models=[self.model_name],
            inference_framework="onnx"
        )

        # PyAudio設定
        self.format = pyaudio.paInt16
        self.channels = 1
        self.rate = 16000
        self.chunk = 1024

        self.audio = pyaudio.PyAudio()
        self.stream = self.audio.open(
            format=self.format,
            channels=self.channels,
            rate=self.rate,
            input=True,
            frames_per_buffer=self.chunk
        )

    def listen_for_wakeword(self):
        print(f"ウェイクワード待機中...(モデル: {self.model_name})")

        prev_detect = False

        while True:
            audio_chunk = self.stream.read(self.chunk, exception_on_overflow=False)
            audio_np = np.frombuffer(audio_chunk, dtype=np.int16)

            _ = self.model.predict(audio_np)

            score = self.model.prediction_buffer[self.model_name][-1]
            detect = score > self.threshold

            if detect and not prev_detect:
                print(f"ウェイクワード検出!(スコア: {score:.3f})")
                return True  # 検出されたら終了

            prev_detect = detect

✅ 特徴と使い方
* alexa_v0.1.onnx をモデルとして使う(変更可能)
* listen_for_wakeword() を呼び出すと、検出されるまでループし、検出されたら return True

✅ モデルファイルが models/ にある場合の使い方

wakeword_detector = WakeWordDetector(model_path="models/alexa_v0.1.onnx", threshold=0.5)

✅ 使い方例(main側)

from module.module_wakeword import WakeWordDetector

wakeword_detector = WakeWordDetector("models/alexa_v0.1.onnx", threshold=0.5)
wakeword_detector.listen_for_wakeword()

これらを元に

touch main6.py

を作成し

from module.module_audio_to_text import AudioToTextCorrector
from module.module_speaker import Speaker
from module.module_wakeword import WakeWordDetector
from ollama import chat, ChatResponse

# モデル名(Ollama用)
OLLAMA_MODEL = 'gemma3:4b'

# 各モジュールの初期化
speaker = Speaker()
audio_to_text = AudioToTextCorrector("config.json")
wakeword_detector = WakeWordDetector(model_path="models/alexa_v0.1.onnx", threshold=0.5)

def ask_ollama(prompt: str) -> str:
    try:
        response: ChatResponse = chat(model=OLLAMA_MODEL, messages=[
            {'role': 'user', 'content': prompt}
        ])
        return response.message.content.strip()
    except Exception as e:
        print(f"Ollamaエラー: {e}")
        return "エラーが発生しました。"

def main():
    while True:
        # ① ウェイクワードを検出するまで待機
        wakeword_detector.listen_for_wakeword()

        # ② 音声を認識してテキスト化(+日本語補正)
        corrected_text = audio_to_text.record_and_correct(timeout_seconds=10)

        if corrected_text is None:
            print("無音またはタイムアウトで中断。再びウェイクワード待ちに戻ります。")
            continue

        print("\n【認識・補正したテキスト】")
        print(corrected_text)

        # ③ Ollama(gemma3:4b)へ質問
        ollama_reply = ask_ollama(corrected_text)

        print("\n【gemma3:4bの返答】")
        print(ollama_reply)

        # ④ 読み上げ
        speaker.speak(ollama_reply)

if __name__ == "__main__":
    main()

でウェイクワード対応させる

実行すると

[2025-05-14 06:23:54.568] [ctranslate2] [thread 311016] [warning] The compute type inferred from the saved model is float16, but the target device or backend do not support efficient float16 computation. The model weights have been automatically converted to use the float32 compute type instead.
Traceback (most recent call last):
  File "/Users/snowpool/aw10s/gemma/main6.py", line 12, in <module>
    wakeword_detector = WakeWordDetector(model_path="models/alexa_v0.1.onnx", threshold=0.5)
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/aw10s/gemma/module/module_wakeword.py", line 11, in __init__
    self.model = Model(
                 ^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/utils.py", line 686, in wrapped
    return func(*args, **new_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/model.py", line 97, in __init__
    raise ValueError("Could not find pretrained model for model name '{}'".format(i))
ValueError: Could not find pretrained model for model name 'models/alexa_v0.1.onnx'

これは
openWakeWord の Model(…) に渡されたモデルパスが正しく認識されていない

openwakeword.Model(…) に渡す wakeword_models は、**ファイル名ではなく「モデル名 or 辞書形式」**で渡す必要がある

なので

self.model = Model(
    wakeword_models=[self.model_name],  # これはNG
    inference_framework="onnx"
)

self.model = Model(
    wakeword_models={ "alexa": self.model_name },
    inference_framework="onnx"
)

にする

import pyaudio
import numpy as np
from openwakeword.model import Model

class WakeWordDetector:
    def __init__(self, model_path="models/alexa_v0.1.onnx", threshold=0.5):
        self.model_name = "alexa"
        self.threshold = threshold

        # モデル名とファイルパスを辞書で渡す
        self.model = Model(
            wakeword_models={self.model_name: model_path},
            inference_framework="onnx"
        )

        # PyAudio設定
        self.format = pyaudio.paInt16
        self.channels = 1
        self.rate = 16000
        self.chunk = 1024

        self.audio = pyaudio.PyAudio()
        self.stream = self.audio.open(
            format=self.format,
            channels=self.channels,
            rate=self.rate,
            input=True,
            frames_per_buffer=self.chunk
        )

    def listen_for_wakeword(self):
        print(f"ウェイクワード待機中...(アレクサ)")

        prev_detect = False

        while True:
            audio_chunk = self.stream.read(self.chunk, exception_on_overflow=False)
            audio_np = np.frombuffer(audio_chunk, dtype=np.int16)

            _ = self.model.predict(audio_np)

            score = self.model.prediction_buffer[self.model_name][-1]
            detect = score > self.threshold

            if detect and not prev_detect:
                print(f"「アレクサ」検出!(スコア: {score:.3f})")
                return True

            prev_detect = detect

が全体コード

今度は

[2025-05-14 06:28:28.142] [ctranslate2] [thread 314783] [warning] The compute type inferred from the saved model is float16, but the target device or backend do not support efficient float16 computation. The model weights have been automatically converted to use the float32 compute type instead.
Traceback (most recent call last):
  File "/Users/snowpool/aw10s/gemma/main6.py", line 12, in <module>
    wakeword_detector = WakeWordDetector(model_path="models/alexa_v0.1.onnx", threshold=0.5)
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/aw10s/gemma/module/module_wakeword.py", line 11, in __init__
    self.model = Model(
                 ^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/utils.py", line 686, in wrapped
    return func(*args, **new_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/model.py", line 90, in __init__
    for ndx, i in enumerate(wakeword_models):
RuntimeError: dictionary changed size during iteration

コード生成がうまくいかないため

 test_openwakeword.py 

のコードをモジュール化する

module/module_wakeword_simple.py

内容は

import pyaudio
import numpy as np
from openwakeword.model import Model

class SimpleWakeWordDetector:
    def __init__(self, model_path="alexa_v0.1.onnx", threshold=0.5):
        self.model_path = model_path
        self.threshold = threshold
        self.format = pyaudio.paInt16
        self.channels = 1
        self.rate = 16000
        self.chunk = 1024

        # モデル名(ファイル名)でアクセスするためのキー
        self.model_name = model_path

        # OpenWakeWord モデルの読み込み
        self.model = Model(
            wakeword_models=[self.model_path],
            inference_framework="onnx"
        )

        # マイク初期化
        self.audio = pyaudio.PyAudio()
        self.stream = self.audio.open(
            format=self.format,
            channels=self.channels,
            rate=self.rate,
            input=True,
            frames_per_buffer=self.chunk
        )

    def listen_for_wakeword(self):
        print(f"Listening for wakeword \"{self.model_name}\"...")

        prev_detect = False

        while True:
            data = self.stream.read(self.chunk, exception_on_overflow=False)
            audio = np.frombuffer(data, dtype=np.int16)

            self.model.predict(audio)
            scores = self.model.prediction_buffer[self.model_name]
            curr_score = scores[-1]
            detect = curr_score > self.threshold

            if detect and not prev_detect:
                print(f"Wakeword Detected! (score: {curr_score:.3f})")
                return True

            prev_detect = detect

使い方は

from module.module_wakeword_simple import SimpleWakeWordDetector

wake_detector = SimpleWakeWordDetector(model_path="models/alexa_v0.1.onnx", threshold=0.5)

while True:
    wake_detector.listen_for_wakeword()
    print("処理を実行します...")
    # → 音声認識など次の処理へ

しかし、そもそもの前提として
Modelディレクトリにはモデルが存在していない

touch download.py  

内容は

import openwakeword

openwakeword.utils.download_models()


実行すると

python download.py 
embedding_model.tflite: 100%|█████████████████| 1.33M/1.33M [00:00<00:00, 9.92MiB/s]
embedding_model.onnx: 100%|███████████████████| 1.33M/1.33M [00:00<00:00, 7.13MiB/s]
melspectrogram.tflite: 100%|██████████████████| 1.09M/1.09M [00:00<00:00, 6.30MiB/s]
melspectrogram.onnx: 100%|████████████████████| 1.09M/1.09M [00:00<00:00, 6.54MiB/s]
silero_vad.onnx: 100%|████████████████████████| 1.81M/1.81M [00:00<00:00, 7.82MiB/s]
alexa_v0.1.tflite: 100%|████████████████████████| 855k/855k [00:00<00:00, 5.67MiB/s]
alexa_v0.1.onnx: 100%|██████████████████████████| 854k/854k [00:00<00:00, 5.36MiB/s]
hey_mycroft_v0.1.tflite: 100%|██████████████████| 860k/860k [00:00<00:00, 6.84MiB/s]
hey_mycroft_v0.1.onnx: 100%|████████████████████| 858k/858k [00:00<00:00, 6.52MiB/s]
hey_jarvis_v0.1.tflite: 100%|█████████████████| 1.28M/1.28M [00:00<00:00, 6.96MiB/s]
hey_jarvis_v0.1.onnx: 100%|███████████████████| 1.27M/1.27M [00:00<00:00, 6.26MiB/s]
hey_rhasspy_v0.1.tflite: 100%|██████████████████| 416k/416k [00:00<00:00, 4.76MiB/s]
hey_rhasspy_v0.1.onnx: 100%|████████████████████| 204k/204k [00:00<00:00, 3.06MiB/s]
timer_v0.1.tflite: 100%|██████████████████████| 1.74M/1.74M [00:00<00:00, 7.98MiB/s]
timer_v0.1.onnx: 100%|████████████████████████| 1.74M/1.74M [00:00<00:00, 8.96MiB/s]
weather_v0.1.tflite: 100%|████████████████████| 1.15M/1.15M [00:00<00:00, 6.99MiB/s]
weather_v0.1.onnx: 100%|██████████████████████| 1.15M/1.15M [00:00<00:00, 6.44MiB/s]

となってダウンロードされているがパスが不明

GPTで

ls ~/.cache/openwakeword/models/

で存在するというが

ls: /Users/snowpool/.cache/openwakeword/models/: No such file or directory

となるのでこれではない

 touch show_model_path.py

from openwakeword.utils import default_cache_dir
import os

print("モデル保存先:")
print(os.path.join(default_cache_dir(), "models"))

で実行する

しかし

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/gemma/show_model_path.py", line 1, in <module>
    from openwakeword.utils import default_cache_dir
ImportError: cannot import name 'default_cache_dir' from 'openwakeword.utils' (/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/utils.py)

となる

参考サイトとして
https://zenn.dev/kun432/scraps/1a987de4943c65
によれば

 pip show openwakeword | grep Location

で調べることができる

Location: /Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages

モデルはsite-packagesの下にダウンロードされる

Treeコマンドはインストールされていないので

 brew install tree

でインストール

しかしエラーになるので

arch -arm64 brew install tree

でインストールする

tree /Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword
/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword
├── __init__.py
├── __pycache__
│   ├── __init__.cpython-311.pyc
│   ├── custom_verifier_model.cpython-311.pyc
│   ├── data.cpython-311.pyc
│   ├── metrics.cpython-311.pyc
│   ├── model.cpython-311.pyc
│   ├── train.cpython-311.pyc
│   ├── utils.cpython-311.pyc
│   └── vad.cpython-311.pyc
├── custom_verifier_model.py
├── data.py
├── metrics.py
├── model.py
├── resources
│   └── models
│       ├── alexa_v0.1.onnx
│       ├── alexa_v0.1.tflite
│       ├── embedding_model.onnx
│       ├── embedding_model.tflite
│       ├── hey_jarvis_v0.1.onnx
│       ├── hey_jarvis_v0.1.tflite
│       ├── hey_mycroft_v0.1.onnx
│       ├── hey_mycroft_v0.1.tflite
│       ├── hey_rhasspy_v0.1.onnx
│       ├── hey_rhasspy_v0.1.tflite
│       ├── melspectrogram.onnx
│       ├── melspectrogram.tflite
│       ├── silero_vad.onnx
│       ├── timer_v0.1.onnx
│       ├── timer_v0.1.tflite
│       ├── weather_v0.1.onnx
│       └── weather_v0.1.tflite
├── train.py
├── utils.py
└── vad.py

これで場所が把握できたのでモデルをコピーする

mkdir -p models
cp /Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/resources/models/alexa_v0.1.onnx models/

これで実行したけど
アレクサ
と言っても検知しない

試しに

python test_openwakeword.py

を実行してもダメだった

openwakeWordと EffecientWord-Net について

ウェイクワードエンジンの導入

openwakeWordと EffecientWord-Net について調べる

1. OpenWakeWord
開発元: Picovoice(またはオープンソースコミュニティ)
特徴
* オープンソースで利用可能
* Pythonで簡単に組み込める
* 自作ウェイクワードの訓練が比較的簡単(マイク音声から自作ワードを学習可能)
* 低スペックなデバイス(例:Raspberry Pi)でも動作可能
* 英語中心だが、日本語でも利用できるようカスタムワードを収録可
メリット
* 手軽に組み込みやすい
* 小型音声アシスタントやプロトタイプ向けに最適
* 小型モデルなのでリアルタイム処理に強い
利用例
* 「ねえラマ!」など任意のフレーズで起動する家庭用AIスピーカー
* 子供の声でも誤検知しにくいように調整可能

2. EfficientWord-Net
開発元: ByteDance Research(論文あり)
特徴
* 学習済みモデルを使用した高精度ウェイクワード検出モデル
* EfficientNetアーキテクチャベースで構築されており、精度と軽量性を両立
* 公開されている論文ベースでは、LibriSpeechやGoogle Speech Commandsなどのベンチマークで優秀な精度を記録
メリット
* 音声ノイズ環境でも精度が高い
* 高速かつ高精度な推論が可能
* 多数のウェイクワードを同時に検出するシナリオに強い
注意点
* 実装がやや高度で、デバイスへの組み込みにはある程度の知識が必要
* ソースコードが論文公開のみで、実装例が少ないこともある

| 比較項目 | OpenWakeWord | EfficientWord-Net |
| ——— | ——————- | ——————– |
| オープンソース | はい | 一部のみ(論文あり) |
| カスタムワード対応 | あり(簡易に追加可能) | 理論上可能だが要学習 |
| リアルタイム性 | 高い(軽量) | 高いがややGPU依存 |
| 対応言語 | 任意(自作可能、日本語対応も工夫で可) | 主に英語想定 |
| 実装のしやすさ | 非常に簡単(Pythonで完結) | やや難(自前でモデル導入や音響処理必要) |
| 小型デバイス対応 | Raspberry Pi等でOK | 要スペック(モバイルでも可能) |

EfficientWord-Netはリアルタイム性
高いがややGPU依存だと
OpenWakeWordの方が今後ラズパイとかで動く方が良いかも

OpenWakeWordは自分の声でトリガーワードを録音し、モデルを訓練して追加可能
なお自分の声でなくても voicevox などで生成した音声でもいけるらしい

ただし要求するマシンスペックがメモリ8GBのため
Google Colab を使うことにする

ウェイクワード検出ライブラリ openWakeWord を使ってみた
での動作環境は ubuntu

オープンソースのウェイクワード検出ライブラリ「openWakeword」を試す
だと
Apple Silicon
だと問題があるらしい
自力でビルドすればいけるらしいが

GPTだと
Zennのkun432氏による記事「オープンソースのウェイクワード検出ライブラリ『openWakeword』を試す」で報告されていた問題は、現在では解決されています。
当初、Apple Silicon(M1/M2)搭載のMacでOpenWakeWordを使用する際、TensorFlow Liteランタイム(tflite-runtime)が提供されておらず、エラーが発生していました。
しかし、OpenWakeWordはONNX形式のモデルもサポートしており、onnxruntimeを使用することで、Apple Silicon環境でも問題なく動作します。

ってあるけど本当かは試さないと不明

pip install openwakeword onnxruntime sounddevice

でインストール

M1環境では tflite-runtime は使わず、onnxruntime を使うらしい

次に起動テスト

touch test_openwakeword.py

内容を

import sounddevice as sd
from openwakeword.model import Model

# ONNXモードを明示
oww_model = Model(
    wakeword_models=["hey_jarvis"],
    inference_framework="onnx"
)

sample_rate = 16000
duration = 0.5  # 秒

print("ウェイクワード検出開始(Ctrl+Cで終了)")

try:
    while True:
        audio = sd.rec(int(sample_rate * duration), samplerate=sample_rate, channels=1, dtype='float32')
        sd.wait()
        prediction = oww_model.predict(audio.flatten())

        score = prediction.get("hey_jarvis", 0)
        if score > 0.5:
            print(f"検出!スコア: {score:.2f}")
except KeyboardInterrupt:
    print("終了しました")

これを実行すると

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/gemma/test_openwakeword.py", line 5, in <module>
    oww_model = Model(
                ^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/utils.py", line 686, in wrapped
    return func(*args, **new_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/model.py", line 153, in __init__
    self.models[mdl_name] = ort.InferenceSession(mdl_path, sess_options=sessionOptions,
                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/onnxruntime/capi/onnxruntime_inference_collection.py", line 469, in __init__
    self._create_inference_session(providers, provider_options, disabled_optimizers)
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/onnxruntime/capi/onnxruntime_inference_collection.py", line 530, in _create_inference_session
    sess = C.InferenceSession(session_options, self._model_path, True, self._read_config_from_model)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
onnxruntime.capi.onnxruntime_pybind11_state.NoSuchFile: [ONNXRuntimeError] : 3 : NO_SUCHFILE : Load model from /Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/resources/models/hey_jarvis_v0.1.onnx failed:Load model /Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/resources/models/hey_jarvis_v0.1.onnx failed. File doesn't exist

となる

OpenWakeWord が使用しようとしているモデルファイル:

/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/openwakeword/resources/models/hey_jarvis_v0.1.onnx

が存在せず、onnx モデルの自動ダウンロードに失敗していることが原因

https://zenn.dev/kun432/scraps/1a987de4943c65
にも書いてあるけど
どうやらモデルファイルをダウンロードする必要がある

GPTでモデルダウンロードURLを表示されるが
存在しないURLだったので解決策を調べる

https://zenn.dev/kun432/scraps/1a987de4943c65
を参考に

touch download.py  

内容は

import openwakeword

openwakeword.utils.download_models()


実行すると

python download.py 
embedding_model.tflite: 100%|█████████████████| 1.33M/1.33M [00:00<00:00, 9.92MiB/s]
embedding_model.onnx: 100%|███████████████████| 1.33M/1.33M [00:00<00:00, 7.13MiB/s]
melspectrogram.tflite: 100%|██████████████████| 1.09M/1.09M [00:00<00:00, 6.30MiB/s]
melspectrogram.onnx: 100%|████████████████████| 1.09M/1.09M [00:00<00:00, 6.54MiB/s]
silero_vad.onnx: 100%|████████████████████████| 1.81M/1.81M [00:00<00:00, 7.82MiB/s]
alexa_v0.1.tflite: 100%|████████████████████████| 855k/855k [00:00<00:00, 5.67MiB/s]
alexa_v0.1.onnx: 100%|██████████████████████████| 854k/854k [00:00<00:00, 5.36MiB/s]
hey_mycroft_v0.1.tflite: 100%|██████████████████| 860k/860k [00:00<00:00, 6.84MiB/s]
hey_mycroft_v0.1.onnx: 100%|████████████████████| 858k/858k [00:00<00:00, 6.52MiB/s]
hey_jarvis_v0.1.tflite: 100%|█████████████████| 1.28M/1.28M [00:00<00:00, 6.96MiB/s]
hey_jarvis_v0.1.onnx: 100%|███████████████████| 1.27M/1.27M [00:00<00:00, 6.26MiB/s]
hey_rhasspy_v0.1.tflite: 100%|██████████████████| 416k/416k [00:00<00:00, 4.76MiB/s]
hey_rhasspy_v0.1.onnx: 100%|████████████████████| 204k/204k [00:00<00:00, 3.06MiB/s]
timer_v0.1.tflite: 100%|██████████████████████| 1.74M/1.74M [00:00<00:00, 7.98MiB/s]
timer_v0.1.onnx: 100%|████████████████████████| 1.74M/1.74M [00:00<00:00, 8.96MiB/s]
weather_v0.1.tflite: 100%|████████████████████| 1.15M/1.15M [00:00<00:00, 6.99MiB/s]
weather_v0.1.onnx: 100%|██████████████████████| 1.15M/1.15M [00:00<00:00, 6.44MiB/s]

となってダウンロードされる

再度

python test_openwakeword.py
ウェイクワード検出開始(Ctrl+Cで終了)
^C終了しました

と起動はするけど
ジャービス
ヘイ ジャービス
でも反応がない

https://zenn.dev/kun432/scraps/1a987de4943c65
のコードを参考に実験する

import pyaudio
import numpy as np
from openwakeword.model import Model
import sys

FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000
CHUNK = 1024
audio = pyaudio.PyAudio()
mic_stream = audio.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK)

model_name = "alexa_v0.1.onnx"

model = Model(
  wakeword_models=[model_name],
  inference_framework="onnx"
)

print("Listening for wakeword \"Alexa\"...")
print()

prev_detect=False

while True:
    audio = np.frombuffer(mic_stream.read(CHUNK), dtype=np.int16)

    prediction = model.predict(audio)

    scores = model.prediction_buffer[model_name]
    curr_score = format(scores[-1], '.20f')
    detect = True if float(curr_score) > 0.5 else False

    if detect:
        if detect != prev_detect:
            print(f"Detected!({curr_score[:5]})")
            prev_detect=True
    else:
        prev_detect=False


Alexaから変更する
これは家の中にあるアレクサと被るため
hey jarvisで動作確認する

とりあえず GPTでコード解説

マイク設定と音声入力の初期化

import pyaudio
import numpy as np
from openwakeword.model import Model
import sys

FORMAT = pyaudio.paInt16       # 16bit PCM
CHANNELS = 1                   # モノラル
RATE = 16000                   # サンプリング周波数 16kHz
CHUNK = 1024                   # 一度に読み取るサンプル数

audio = pyaudio.PyAudio()
mic_stream = audio.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK)

これで
マイクから音声をリアルタイムで取得するためのストリームを開始

次にOpenWakeWord モデルの初期化
model_name = "alexa_v0.1.onnx"
model = Model(
  wakeword_models=[model_name],
  inference_framework="onnx"
)

今回は”alexa_v0.1.onnx” モデルをONNXランタイムで使用
macOS (M1など) では onnx が推奨

そして
メインループで音声解析

print("Listening for wakeword \"Alexa\"...")

while True:
    audio = np.frombuffer(mic_stream.read(CHUNK), dtype=np.int16)

この部分で
マイクから CHUNK = 1024 サンプル(= 約64ms)分を読み込み
int16 に変換してNumPy配列にします(OpenWakeWordの入力形式)

そしてウェイクワードの検出

    prediction = model.predict(audio)

現在のフレームに対してウェイクワードスコアを計算

    scores = model.prediction_buffer[model_name]
    curr_score = format(scores[-1], '.20f')
    detect = True if float(curr_score) > 0.5 else False

過去のスコアをバッファから取得し、最新のスコア([-1])を確認
0.5 を超えていたら検出と判定

一度だけ表示する仕組みとして

    if detect:
        if detect != prev_detect:
            print(f"Detected!({curr_score[:5]})")
            prev_detect=True
    else:
        prev_detect=False

前回検出と今回の結果が異なるときだけ表示することで、連続検出を防ぐ
例:「アレクサ」と言う → スコアが上がって「Detected!」が一度表示

これを元に音声モデルを変更

import pyaudio
import numpy as np
from openwakeword.model import Model
import sys

FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000
CHUNK = 1024
audio = pyaudio.PyAudio()
mic_stream = audio.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK)

model_name = "hey_jarvis_v0.1.onnx"

model = Model(
  wakeword_models=[model_name],
  inference_framework="onnx"
)

print("Listening for wakeword \"hey_jarvis\"...")
print()

prev_detect=False

while True:
    audio = np.frombuffer(mic_stream.read(CHUNK), dtype=np.int16)

    prediction = model.predict(audio)

    scores = model.prediction_buffer[model_name]
    curr_score = format(scores[-1], '.20f')
    detect = True if float(curr_score) > 0.5 else False

    if detect:
        if detect != prev_detect:
            print(f"Detected!({curr_score[:5]})")
            prev_detect=True
    else:
        prev_detect=False

実行したら

python test_openwakeword.py
Traceback (most recent call last):
  File "/Users/snowpool/aw10s/gemma/test_openwakeword.py", line 1, in <module>
    import pyaudio
ModuleNotFoundError: No module named 'pyaudio'

となったので

pip install pyaudio
Collecting pyaudio
  Downloading PyAudio-0.2.14.tar.gz (47 kB)
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... done
Building wheels for collected packages: pyaudio
  Building wheel for pyaudio (pyproject.toml) ... done
  Created wheel for pyaudio: filename=pyaudio-0.2.14-cp311-cp311-macosx_15_0_arm64.whl size=25773 sha256=85950d898df05ebcce820f2f6d30b030db44951656c7decef10274215dca529b
  Stored in directory: /Users/snowpool/Library/Caches/pip/wheels/80/b1/c1/67e4ef443de2665d86031d4760508094eab5de37d5d64d9c27
Successfully built pyaudio
Installing collected packages: pyaudio
Successfully installed pyaudio-0.2.14

[notice] A new release of pip is available: 24.3.1 -> 25.1.1
[notice] To update, run: pip install --upgrade pip

で結局反応しないのでアレクサで試したら動いた…

単純に私の発音が悪いらしい

GPTによれば発音がダメとのこと

なお最初にGPTで提案されたコードは動作しないので
今回のコードを使うことにする

pyttsx3 音声読み上げ機能をモジュール化

pyttsx3 音声読み上げ機能をモジュール化

module/module_speaker.py

import pyttsx3
import emoji
import re

class Speaker:
    def __init__(self):
        self.engine = pyttsx3.init()
        # 音量やスピード、声の設定があればここで行える
        self.engine.setProperty("rate", 170)  # 話すスピード(デフォルト=200)
        self.engine.setProperty("volume", 1.0)  # 音量(0.0~1.0)

    def remove_emoji(self, text: str) -> str:
        return emoji.replace_emoji(text, replace='')

    def speak(self, text: str):
        clean_text = self.remove_emoji(text)
        clean_text = re.sub(r"[*_`~^]", "", clean_text)  # マークアップ記号を除去
        print("\n【読み上げるテキスト】")
        print(clean_text)

        # 1文ずつ話すことで安定性を向上
        sentences = re.split(r"(?<=[。!?\n])", clean_text)
        for sentence in sentences:
            if sentence.strip():
                self.engine.say(sentence.strip())
        self.engine.runAndWait()

として保存

pyttsx3 関連の処理をモジュール化した module_speaker.py を使うように

touch main5.py

from module.module_audio_to_text import AudioToTextCorrector
from module.module_speaker import Speaker
from ollama import chat, ChatResponse

# モデル名
OLLAMA_MODEL = 'gemma3:4b'

# 音声読み上げクラスを初期化
speaker = Speaker()

def ask_ollama(prompt: str) -> str:
    try:
        response: ChatResponse = chat(model=OLLAMA_MODEL, messages=[
            {
                'role': 'user',
                'content': prompt,
            }
        ])
        return response.message.content.strip()
    except Exception as e:
        print(f"Ollamaエラー: {e}")
        return "エラーが発生しました。"

def main():
    audio_to_text = AudioToTextCorrector("config.json")

    while True:
        corrected_text = audio_to_text.record_and_correct(timeout_seconds=10)

        if corrected_text is None:
            print("終了条件に達したため、ループを抜けます。")
            break

        print("\n【認識・補正したテキスト】")
        print(corrected_text)

        # Ollamaへ質問
        ollama_reply = ask_ollama(corrected_text)

        print("\n【gemma3:4bの返答】")
        print(ollama_reply)

        # 読み上げ
        speaker.speak(ollama_reply)

if __name__ == "__main__":
    main()

として検証

[2025-05-10 06:23:46.009] [ctranslate2] [thread 64614] [warning] The compute type inferred from the saved model is float16, but the target device or backend do not support efficient float16 computation. The model weights have been automatically converted to use the float32 compute type instead.
stand by ready OK
recording...
finished

【認識・補正したテキスト】
こんにちは。

【gemma3:4bの返答】
こんにちは!何かお手伝いできることはありますか?😊

【読み上げるテキスト】
こんにちは!何かお手伝いできることはありますか?
stand by ready OK
recording...
finished
10秒間音声が入力されなかったため、処理を終了します。
終了条件に達したため、ループを抜けます。

となるので無事に動作しているのが確認できる

次はウェイクワードエンジンの導入

gemma3:4bの返答を pyttsx3 を使って 自然にリアルタイム読み上げる

gemma3:4bの返答を
pyttsx3 を使って
自然にリアルタイム読み上げる

読み上げは

touch main4.py

でファイルを作成し問題が起きた時にわかりやすくする

from module.module_audio_to_text import AudioToTextCorrector
from ollama import chat, ChatResponse
import pyttsx3
import re
import emoji

# モデル名
OLLAMA_MODEL = 'gemma3:4b'

# pyttsx3初期化
engine = pyttsx3.init()

def ask_ollama(prompt: str) -> str:
    try:
        response: ChatResponse = chat(model=OLLAMA_MODEL, messages=[
            {
                'role': 'user',
                'content': prompt,
            }
        ])
        return response.message.content.strip()
    except Exception as e:
        print(f"Ollamaエラー: {e}")
        return "エラーが発生しました。"

def remove_emoji(text: str) -> str:
    return emoji.replace_emoji(text, replace='')

def speak(text: str):
    clean_text = remove_emoji(text)
    print("\n【読み上げるテキスト】")
    print(clean_text)
    engine.say(clean_text)
    engine.runAndWait()

def main():
    audio_to_text = AudioToTextCorrector("config.json")

    while True:
        corrected_text = audio_to_text.record_and_correct(timeout_seconds=10)

        if corrected_text is None:
            print("終了条件に達したため、ループを抜けます。")
            break

        print("\n【認識・補正したテキスト】")
        print(corrected_text)

        # Ollamaへ質問
        ollama_reply = ask_ollama(corrected_text)

        print("\n【gemma3:4bの返答】")
        print(ollama_reply)

        # gemma3:4bの返答を音声で読み上げ
        speak(ollama_reply)

if __name__ == "__main__":
    main()

実行したけど

[2025-05-09 19:01:12.451] [ctranslate2] [thread 8719969] [warning] The compute type inferred from the saved model is float16, but the target device or backend do not support efficient float16 computation. The model weights have been automatically converted to use the float32 compute type instead. stand by ready OK recording... finished Ollamaエラー: llama runner process has terminated: exit status 2 終了条件に達したため、ループを抜けます。

となる

エラー内容を調べると

Ollamaエラー: llama runner process has terminated: exit status 2
は、Ollamaの内部プロセス(llama-runner)がクラッシュして終了

このため chrome を一度終了してから再度実行

しかし音声が出ない

engine = pyttsx3.init('nsss')  # macOSならこれで確実に動く

と明示的にしてもだめ

touch tts_test.py

でファイルを作成

import pyttsx3

engine = pyttsx3.init('nsss')
text = "こんにちは。今日はどんな気分ですか?映画でも見ませんか?"
engine.say(text)
engine.runAndWait()

として実行

しかし音声が再生されない
色々試した結果、再起動したら音声が再生された

どうやら再起動することで解決したらしい
メモリが足りなくてエラーだったので
もう少しマシンスペックを上げないと厳しいかもしれない

とりあえず動くので
次は音声再生部分をモジュールにする
その後ウェイクワードエンジンをやってみる