購入頻度と消費間隔の予測

購入頻度と消費間隔の予測

まず過去の購入データを全てcsvにする
これをSqlite にインポート
その後に
SQLite の購入データを Neo4j に同期させる

ここまでできたら情報が整うので
購入頻度
消費感覚
価格の推移
最安値店舗の把握と最高値店舗の把握
ができるので在庫がどれくらいの期間で消費されているかを予測できる

店舗での購入物を管理できるので
家計の把握とコントロールが可能

家で作成しているレシピを登録することで
1品ごとのコストが把握できる

これなら惣菜を買った方が得という意見の反論の証拠にもなる

あと購入物をカテゴライズし
生鮮食品なら3日
調味料1日
というようにすると良いかもしれないがバターなどはカテゴリが面倒

購入管理なら
日用品ならまとめ買いで頻繁に購入する必要がないので使えそう
あとは調味料関連
とりあえず年間の購入の履歴から購入頻度を割り出す
その後にカテゴリ分けと期間の設定を割り出した方が良さそう

あとはレシピの登録

mkdir recipes
ここにレシピを登録して neo4jに一括登録する

購入管理のCypherクエリ

購入管理のCypherクエリ

購入頻度(日付順)と数量確認

MATCH (:Store)-[r:PURCHASED_ON]->(i:Item {name: "たい"})
RETURN r.date AS date, r.quantity AS quantity
ORDER BY r.date

Pythonで r.date を日付に変換して、間隔(差分)を出せば
「何日おきに買っているか」がわかる

店舗別の購入回数

MATCH (s:Store)-[r:PURCHASED_ON]->(i:Item {name: "たい"})
RETURN s.name AS store, COUNT(*) AS times
ORDER BY times DESC

店舗依存度が見える(1店舗に集中?複数利用?)

価格と日付の傾向(最安値・平均)

MATCH (:Store)-[r:PURCHASED_ON]->(i:Item {name: "たい"})
RETURN r.date AS date, r.unit_price AS unit_price
ORDER BY r.date

折れ線グラフにして価格のトレンドを可視化

Neo4jから価格履歴をクエリで取得

MATCH (s:Store {name: "COOP ユーコープ 袋井町店"})-[r:PURCHASED_ON]->(i:Item {name: "たい"})
RETURN r.date AS date, r.unit_price AS unit_price
ORDER BY r.date

現在のNeo4jへの登録処理を確認するには
「現在の登録処理(ノード・リレーション構造)」を確認するには、Neo4j Browser または Cypher シェルでグラフ全体や構造を可視化するクエリを実行するのが基本

MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 100

これで
ノード(n, m)とリレーション(r)を100件まで表示
実際のノードの種類(Store、Item)と関係(PURCHASED_ON)が図で確認できる

次に
✅ 目的
「同じ商品を複数店舗で購入した履歴がある場合に、最も安かった店舗と価格を一覧表示」する。

MATCH (s:Store)-[r:PURCHASED_ON]->(i:Item)
WITH i.name AS item, r.unit_price AS price, s.name AS store
ORDER BY item, price ASC
WITH item, collect({store: store, price: price}) AS store_prices
RETURN item, store_prices[0].store AS cheapest_store, store_prices[0].price AS min_price
ORDER BY item

商品ごとの最安値店舗一覧
が表示可能

🧠 解説
* MATCH で Store → PURCHASED_ON → Item の関係を取得
* WITH + ORDER BY で商品ごとに価格の安い順に並べる
* collect(…)[0] で最も安かった店舗と価格を取得

✅ 特定の商品だけ調べたい場合
例えば “たい” という商品に絞りたい場合

MATCH (s:Store)-[r:PURCHASED_ON]->(i:Item {name: "たい"})
RETURN s.name AS store, r.unit_price AS price
ORDER BY price ASC
LIMIT 1

これを pythonコードにすると

 vim cheapest_store.py

from neo4j import GraphDatabase
import pandas as pd

# Neo4jの接続情報を入力
uri = "bolt://localhost:7687"
auth = ("neo4j", "あなたのパスワード")  # ここを実際のパスワードに書き換えてください

driver = GraphDatabase.driver(uri, auth=auth)

def fetch_cheapest_store_per_item(tx):
    query = """
    MATCH (s:Store)-[r:PURCHASED_ON]->(i:Item)
    WITH i.name AS item, r.unit_price AS price, s.name AS store
    ORDER BY item, price ASC
    WITH item, collect({store: store, price: price}) AS store_prices
    RETURN item, store_prices[0].store AS cheapest_store, store_prices[0].price AS min_price
    ORDER BY item
    """
    result = tx.run(query)
    return [record.data() for record in result]

with driver.session() as session:
    records = session.execute_read(fetch_cheapest_store_per_item)

driver.close()

# 表形式で出力
df = pd.DataFrame(records)
print(df.to_string(index=False))

で実行

            item      cheapest_store  min_price
  COジョウエラミックスナッツ     COOP ユーコープ 袋井町店        298
      COゼリーエネルギー     COOP ユーコープ 袋井町店        118
   COゼリーエネルギー60F     COOP ユーコープ 袋井町店        178
          CO冷凍茶豆     COOP ユーコープ 袋井町店        158
   おかめ納豆 まろやか旨味ミ             ピアゴ 袋井店         97
          かつおたたき     COOP ユーコープ 袋井町店        398
       きゅうり/丸数農園             とれたて食楽部        150
      きゅうり/河井 恵美             とれたて食楽部        100
     ごぼう/とれたて食楽部             とれたて食楽部        280
            ししゃも     COOP ユーコープ 袋井町店        298
       じゃが芋/澤口正則             とれたて食楽部        250
      その他切り花/竹原秀             とれたて食楽部        210
              たい     COOP ユーコープ 袋井町店        398
    だしのきいたなめらか玉子             ピアゴ 袋井店        128
        なす/大石真智子             とれたて食楽部        260
            なす/袋     COOP ユーコープ 袋井町店        138
    はごろも シーチキンLフ           遠鉄ストア 浅羽店        338
           ぶなしめじ ドラッグストア クリエイト 袋井山名店         98
            まいたけ     COOP ユーコープ 袋井町店         88
         アーリーレッド             とれたて食楽部        230
        イチゴ/鈴木康友             とれたて食楽部        230
          イチジク習慣           遠鉄ストア 浅羽店        398
       コーヒー/濱小路徳             とれたて食楽部       1090
      コーヒー/満り路仁徳             とれたて食楽部       1090
      サンテビオ 15mL           杏林堂 袋井旭町店        968
         シュガーコーン     COOP ユーコープ 袋井町店        278
     タイレノールA 30錠             ピアゴ 袋井店       1790
      バジルMIXピッツァ     COOP ユーコープ 袋井町店        428
        バラ/大場安輝子             とれたて食楽部        300
  ビオヨーグルト プレーン加糖 ドラッグストア クリエイト 袋井山名店        178
       ピーマン/小林宗作             とれたて食楽部        140
        フィリピンバナナ     COOP ユーコープ 袋井町店        138
    フルーツフェスタアップル ドラッグストア クリエイト 袋井山名店        198
    フルーツフェスタフルーツ ドラッグストア クリエイト 袋井山名店        198
フルーツフェスタフルーツミックス ドラッグストア クリエイト 袋井山名店        198
  ブルボン デザートマルシェ白 ドラッグストア クリエイト 袋井山名店         93
     ベビーリーフ/南グロー             とれたて食楽部        120
    ボスコEVオリーブオイル     COOP ユーコープ 袋井町店        898
      ルッコラ/Gファーム             とれたて食楽部        180
       レタス/山田さち子             とれたて食楽部        140
       レタス/山田さち子             とれたて食楽部        150
   ロイヤルブレッド山型(5) ドラッグストア クリエイト 袋井山名店        139
     ロイヤルブレッド(6) ドラッグストア クリエイト 袋井山名店        139
           塩銀鮭切身     COOP ユーコープ 袋井町店        398
         大根/石原義浩             とれたて食楽部        150
          天ぷら用えび     COOP ユーコープ 袋井町店        398
      家族の潤いマスカット ドラッグストア クリエイト 袋井山名店        108
     家族の潤い白桃ミックス ドラッグストア クリエイト 袋井山名店        108
    日興フーズ 王様のチーズ ドラッグストア クリエイト 袋井山名店        298
    日興フーズ 王様のデーツ ドラッグストア クリエイト 袋井山名店        298
      明和水産 切身・丸魚             ピアゴ 袋井店        478
   明治おいしい牛乳900ml             ピアゴ 袋井店        247
      森永バター 200g ドラッグストア クリエイト 袋井山名店        458
            生豚挽肉     COOP ユーコープ 袋井町店        522
        白ねぎ/鈴木正孝             とれたて食楽部        170
           真あじ開き     COOP ユーコープ 袋井町店        398
           若鶏ムネ肉     COOP ユーコープ 袋井町店        594
       菌床椎茸/永井晃太             とれたて食楽部        260
      豚ヒレかたまりカナダ     COOP ユーコープ 袋井町店        630
             豚小間     COOP ユーコープ 袋井町店        535
            豚小間切             とれたて食楽部        336
           豚小間切れ     COOP ユーコープ 袋井町店        190
           赤魚粕漬け     COOP ユーコープ 袋井町店        398
         雑貨/小路仁徳             とれたて食楽部        390
  雪印メグミルク 牧場の朝ヨー ドラッグストア クリエイト 袋井山名店         99
     静岡そだち牛肉入りコロ             とれたて食楽部        390
           鮭・くず桜     COOP ユーコープ 袋井町店        238
    鶏卵パック MS 10個 ドラッグストア クリエイト 袋井山名店        228
   鶏卵卵パック MS 10個 ドラッグストア クリエイト 袋井山名店        228
          黄金しょうが     COOP ユーコープ 袋井町店        178

となる

SQLite と Neo4j を組み合わせgraphDB作成

Neo4j

GraphDB はノード(商品・店舗)とエッジ(購入履歴)で関係を管理できるため、例えば「特定の商品をどの店舗で買うべきか」の分析が容易になります。

データの構造(ノードとリレーション)
* ノード
* (:Item {name: “牛乳”})
* (:Store {name: “スーパーA”})
* リレーション
* (:Item)-[:SOLD_AT {price: 198, date: “2025-02-01”}]->(:Store)

Neo4j でのデータ登録

from neo4j import GraphDatabase

uri = "bolt://localhost:7687"
driver = GraphDatabase.driver(uri, auth=("neo4j", "password"))

def add_purchase(tx, item, store, price, date):
    query = """
    MERGE (i:Item {name: $item})
    MERGE (s:Store {name: $store})
    CREATE (i)-[:SOLD_AT {price: $price, date: $date}]->(s)
    """
    tx.run(query, item=item, store=store, price=price, date=date)

with driver.session() as session:
    session.write_transaction(add_purchase, "牛乳", "スーパーA", 198, "2025-02-01")

購入履歴から最適な店舗を検索

MATCH (i:Item {name: "牛乳"})-[r:SOLD_AT]->(s:Store)
RETURN s.name AS store, MIN(r.price) AS lowest_price

SQLite と GraphDB の使い分け

| 機能          | SQLite          | GraphDB              |
| ----------- | --------------- | -------------------- |
| データの保存      | 高速・軽量           | 遅め(構造化に向いている)        |
| 最安値検索       | シンプルな SQL で実装可能 | 関係性を活かした分析が得意        |
| データの可視化     | 表形式が得意          | ネットワーク分析が可能          |
| セールや価格変動の分析 | やや複雑            | 履歴をノード間リレーションで管理しやすい |

* SQLite: 基本的な購入データ管理(最安値検索・履歴保存)に向いている。

* GraphDB: 「どの店舗でどの商品を買うべきか?」を関係性で分析するのに適している。
まずは SQLite をメインに使用し、分析が必要な部分を GraphDB に移行するのが良さそう

SQLite と Neo4j を組み合わせることは可能
それぞれの役割を明確にし、データの流れを整理すれば、最適な購買管理システムを構築できる

| データ               | SQLite | Neo4j          |
| ----------------- | ------ | -------------- |
| 購入履歴(商品・価格・店舗・日付) | ✅      | ✅              |
| 最安値の検索            | ✅(SQL) | ✅(Graph Query) |
| 価格の履歴管理           | ✅      | ✅(関係性を活用)      |
| 商品と店舗の関係分析        | ❌      | ✅(ネットワーク解析)    |
| セール・特売情報との連携      | ❌      | ✅(リレーション活用)    |

SQLite は「履歴管理」「最安値検索」に適している
Neo4j は「店舗と商品の関係分析」「価格変動の視覚化」に向いている

SQLite のデータを Neo4j に同期させることで、購入履歴と分析機能を両方活用できる

現在のディレクトリは
ls
の結果

csv_files				receipts.db
import_receipts_with_dedup_and_log.py	skipped.csv
medication_summary.py

となっている

次に SqliteDBに
purchases テーブルがあることを確認

sqlite3 receipts.db 

を実行し

.tables

を実行

import_log  purchases 

とテーブルが表示されればOK

さらに構造を確認するには

 .schema purchases

とすれば

CREATE TABLE purchases (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        store TEXT,
        date TEXT,
        item TEXT,
        unit_price INTEGER,
        total_price INTEGER,
        quantity INTEGER,
        self_med_amount INTEGER,
        UNIQUE(date, store, item)
    );

というように表示される

次に
SQLite のデータを Neo4j に同期させる

現在SQLite DBで

Enter ".help" for usage hints.
sqlite> .tables
import_log  purchases 
sqlite> .schema purchases
CREATE TABLE purchases (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        store TEXT,
        date TEXT,
        item TEXT,
        unit_price INTEGER,
        total_price INTEGER,
        quantity INTEGER,
        self_med_amount INTEGER,
        UNIQUE(date, store, item)
    );

という構造になっている

これは

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
杏林堂 袋井旭町店,2025/06/18,サンテビオ 15mL,968,871,1,871
ピアゴ 袋井店,2025/04/18,タイレノールA 30錠,1790,1772,1,1772
COOP ユーコープ 袋井町店,2025/06/19,COゼリーエネルギー,118,354,3,0
COOP ユーコープ 袋井町店,2025/06/19,鮭・くず桜,238,190,1,0
COOP ユーコープ 袋井町店,2025/06/19,天ぷら用えび,398,238,1,0
COOP ユーコープ 袋井町店,2025/06/19,たい,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/19,塩銀鮭切身,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/19,真あじ開き,398,796,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,日興フーズ 王様のチーズ,298,298,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,ロイヤルブレッド山型(5),139,139,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,家族の潤い白桃ミックス,108,216,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,鶏卵卵パック MS 10個,228,228,1,0

という購入履歴をDBにしたもの

これを
SQLite と Neo4j を組み合わせ
それぞれの役割を明確にし、データの流れを整理すれば、最適な購買管理システムを構築する

なお元になるcsvファイルは毎日の買い物なので毎日更新される

目的が「毎日追加されるCSVの購入データを元に、SQLiteとNeo4jを連携して購買管理を最適化すること」

✅ システム構成の概要
項目 内容
SQLiteの役割 生データの保存。CSVから読み込んだ構造化データを記録。
→ 変更・削除・追加が簡単なローカル台帳的役割。
Neo4jの役割 関係性を活用した分析(例:購入頻度・店舗との関係・カテゴリ分析)
→ 推薦、店舗の傾向把握、消費パターン予測
CSVの流れ 日次で新しいCSVが追加 → SQLiteに取り込み → Neo4jに同期(新規 or 更新)

SQLiteスキーマ(そのままでOK)
UNIQUE(date, store, item) により同じ日・店舗・商品は1件のみ保存される(重複防止)。

CREATE TABLE purchases (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    store TEXT,
    date TEXT,
    item TEXT,
    unit_price INTEGER,
    total_price INTEGER,
    quantity INTEGER,
    self_med_amount INTEGER,
    UNIQUE(date, store, item)
);

Neo4jの構造例
以下のようにグラフ構造で格納

(:Store {name: "杏林堂 袋井旭町店"})
    -[:PURCHASED_ON {date: "2025-06-18", unit_price: 968, ...}]->
(:Item {name: "サンテビオ 15mL"})

Store ノード:店舗
Item ノード:商品
:PURCHASED_ON リレーション:購入関係と属性(日付・価格・数量など)

🔁 データフロー(毎日更新の流れ)
1. CSV読み込み → SQLiteにINSERT
2. SQLiteから新規・更新データ抽出
3. Neo4jに同期(MERGEでノードとリレーションを作成)

次に実践

まずは docker を起動する
Mac の場合

docker compose up -d

を実行しても

Cannot connect to the Docker daemon at unix:///Users/snowpool/.docker/run/docker.sock. Is the docker daemon running?

となるため
あらかじめ docker desktop を立ち上げておく必要がある

 docker compose up -d

を再度実行

[+] Running 1/1
 ✔ Container ne4j_pg-neo4j-1  Started                                      

次に

vim link_sql_in_ne04jdb.py

from neo4j import GraphDatabase
import sqlite3

# SQLiteに接続
conn = sqlite3.connect('receipts.db')
cursor = conn.cursor()

# Neo4jに接続(適宜、ユーザー名・パスワードを置き換えてください)
neo4j_driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "your_password"))

# データ取得
cursor.execute("SELECT store, date, item, unit_price, total_price, quantity, self_med_amount FROM purchases")
rows = cursor.fetchall()

def insert_to_neo4j(tx, store, date, item, unit_price, total_price, quantity, self_med_amount):
    tx.run("""
    MERGE (s:Store {name: $store})
    MERGE (i:Item {name: $item})
    MERGE (s)-[r:PURCHASED_ON {date: $date}]->(i)
    SET r.unit_price = $unit_price,
        r.total_price = $total_price,
        r.quantity = $quantity,
        r.self_med_amount = $self_med_amount
    """, store=store, date=date, item=item,
         unit_price=unit_price, total_price=total_price,
         quantity=quantity, self_med_amount=self_med_amount)

# Neo4jに書き込み
with neo4j_driver.session() as session:
    for row in rows:
        session.write_transaction(insert_to_neo4j, *row)

# 接続終了
conn.close()
neo4j_driver.close()

これを実行すると

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/purcharge_history/link_sql_in_ne04jdb.py", line 1, in <module>
    from neo4j import GraphDatabase
ModuleNotFoundError: No module named 'neo4j'

となる

Pythonバージョンをアップしたのが原因

pip install neo4j

でインストール

Collecting neo4j
  Downloading neo4j-5.28.1-py3-none-any.whl.metadata (5.9 kB)
Requirement already satisfied: pytz in /Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages (from neo4j) (2024.2)
Downloading neo4j-5.28.1-py3-none-any.whl (312 kB)
Installing collected packages: neo4j
Successfully installed neo4j-5.28.1

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

今度は

/Users/snowpool/aw10s/purcharge_history/link_sql_in_ne04jdb.py:31: DeprecationWarning: write_transaction has been renamed to execute_write
  session.write_transaction(insert_to_neo4j, *row)

となる

これは
write_transaction() は 非推奨(Deprecated) になり、代わりに execute_write() を使うように変更されているため

このためコード変更

# 旧(警告が出る)
session.write_transaction(insert_to_neo4j, *row)

# 新(推奨される書き方)
session.execute_write(insert_to_neo4j, *row)

このため

with neo4j_driver.session() as session:
    for row in rows:
        session.execute_write(insert_to_neo4j, *row)

と変更

| 状態     | メソッド名                 | 備考            |
| ------ | --------------------- | ------------- |
| ✅ 推奨   | `execute_write()`     | Neo4j 5.x以降向け |
| ⚠️ 非推奨 | `write_transaction()` | 古いコードとの互換性用   |

これで再度

python link_sql_in_ne04jdb.py

を実行すると
graphDBが作成される

sqliteと neo4jでやること

sqliteと neo4jでやること

最適な店舗の提案
価格比較+購入パターン分析
* 過去の購入データを分析し、最安値の店舗を提案
* 店舗ごとのセールパターンを学習
* 例:「〇〇スーパーは毎週火曜に卵が安い」「ドラッグストアでシャンプーがポイント還元率が高い」

最適な店舗の提案
① データ分析
* 過去の購入履歴を集計
* 商品ごとの平均価格
* 最安値の店舗ランキング
* 曜日ごとの傾向分析
* 「火曜にスーパーAが安い」
* 「週末にまとめ買いするとお得」
② 店舗の最適化提案
* 次回の最適な購入場所をLINE通知
* 「牛乳はスーパーB(178円)、卵はスーパーC(99円)がお得」
* 「来週火曜にドラッグストアDで洗剤のセールあり」
* Webスクレイピングで最新セール情報取得
* 各スーパー・ドラッグストアのWebチラシをチェック
* 「チラシプラス」「Shufoo!」API活用

など
SQLite では商品ごとの最安値の店舗を検索するのも簡単

GraphDB での管理
GraphDB はノード(商品・店舗)とエッジ(購入履歴)で関係を管理できるため、例えば「特定の商品をどの店舗で買うべきか」の分析が容易になります。
① GraphDB の選択肢
* Neo4j(Python なら neo4j ライブラリを利用)

データの構造(ノードとリレーション)
* ノード
* (:Item {name: “牛乳”})
* (:Store {name: “スーパーA”})
* リレーション
* (:Item)-[:SOLD_AT {price: 198, date: “2025-02-01”}]->(:Store)

購入履歴の管理と最適な店舗の提案を行う

import sqlite3

conn = sqlite3.connect("shopping.db")
cur = conn.cursor()

cur.execute("INSERT INTO purchases (date, store, item, price) VALUES (?, ?, ?, ?)",
            ("2025-02-01", "スーパーA", "牛乳", 198))

conn.commit()
conn.close()


データの追加

購入データの検索

cur.execute("SELECT * FROM purchases WHERE item = ?", ("牛乳",))
print(cur.fetchall())

また
SQLite で商品ごとの最安値の店舗を検索

cur.execute("""
    SELECT item, store, MIN(price) as min_price
    FROM purchases
    GROUP BY item
""")
print(cur.fetchall())

Neo4j でのデータ登録なら

from neo4j import GraphDatabase

uri = "bolt://localhost:7687"
driver = GraphDatabase.driver(uri, auth=("neo4j", "password"))

def add_purchase(tx, item, store, price, date):
    query = """
    MERGE (i:Item {name: $item})
    MERGE (s:Store {name: $store})
    CREATE (i)-[:SOLD_AT {price: $price, date: $date}]->(s)
    """
    tx.run(query, item=item, store=store, price=price, date=date)

with driver.session() as session:
    session.write_transaction(add_purchase, "牛乳", "スーパーA", 198, "2025-02-01")

購入履歴から最適な店舗を検索なら

MATCH (i:Item {name: "牛乳"})-[r:SOLD_AT]->(s:Store)
RETURN s.name AS store, MIN(r.price) AS lowest_price

SQLite と GraphDB の使い分け

| 機能          | SQLite          | GraphDB              |
| ----------- | --------------- | -------------------- |
| データの保存      | 高速・軽量           | 遅め(構造化に向いている)        |
| 最安値検索       | シンプルな SQL で実装可能 | 関係性を活かした分析が得意        |
| データの可視化     | 表形式が得意          | ネットワーク分析が可能          |
| セールや価格変動の分析 | やや複雑            | 履歴をノード間リレーションで管理しやすい |

結論
* SQLite: 基本的な購入データ管理(最安値検索・履歴保存)に向いている。
* GraphDB: 「どの店舗でどの商品を買うべきか?」を関係性で分析するのに適している。
まずは SQLite をメインに使用し、分析が必要な部分を GraphDB に移行するのが良さそう

sqlite と neo4jを組み合わせることはできるか?
SQLite と Neo4j を組み合わせることは可能
れぞれの役割を明確にし、データの流れを整理すれば、最適な購買管理システムを構築できる

SQLite と Neo4j の役割分担

| データ               | SQLite | Neo4j          |
| ----------------- | ------ | -------------- |
| 購入履歴(商品・価格・店舗・日付) | ✅      | ✅              |
| 最安値の検索            | ✅(SQL) | ✅(Graph Query) |
| 価格の履歴管理           | ✅      | ✅(関係性を活用)      |
| 商品と店舗の関係分析        | ❌      | ✅(ネットワーク解析)    |
| セール・特売情報との連携      | ❌      | ✅(リレーション活用)    |

SQLite は「履歴管理」「最安値検索」に適している
Neo4j は「店舗と商品の関係分析」「価格変動の視覚化」に向いている

SQLite のデータを Neo4j に同期させることで、購入履歴と分析機能を両方活用できる

SQLite から Neo4j にデータを移行
Neo4j は APOC(ライブラリ)を使って外部データをインポートできる

まずSQLite のデータを取得

import sqlite3
import pandas as pd

# SQLiteに接続
conn = sqlite3.connect("shopping.db")
df = pd.read_sql("SELECT date, store, item, price FROM purchases", conn)
conn.close()


次にNeo4j にデータを挿入
from neo4j import GraphDatabase

# Neo4jの接続情報
uri = "bolt://localhost:7687"
driver = GraphDatabase.driver(uri, auth=("neo4j", "password"))

# データ挿入関数
def add_purchase(tx, date, store, item, price):
    query = """
    MERGE (i:Item {name: $item})
    MERGE (s:Store {name: $store})
    CREATE (i)-[:SOLD_AT {price: $price, date: $date}]->(s)
    """
    tx.run(query, item=item, store=store, price=price, date=date)

# SQLite のデータを Neo4j に登録
with driver.session() as session:
    for _, row in df.iterrows():
        session.write_transaction(add_purchase, row["date"], row["store"], row["item"], row["price"])

ここまでできたら
Neo4j でのデータ分析
Neo4j を使うことで、「この商品はどの店舗が安いか?」 などの分析が容易になる

商品ごとの最安値店舗を検索

MATCH (i:Item)-[r:SOLD_AT]->(s:Store)
WITH i, s, r
ORDER BY r.price ASC
RETURN i.name AS 商品, s.name AS 最安店舗, r.price AS 価格

ある店舗の価格変動を可視化

MATCH (i:Item)-[r:SOLD_AT]->(s:Store)
RETURN i.name AS 商品, COUNT(s) AS 店舗数, AVG(r.price) AS 平均価格
ORDER BY 平均価格 ASC

特定の商品の購入パターン分析

MATCH (i:Item)-[r:SOLD_AT]->(s:Store)
RETURN i.name AS 商品, COUNT(s) AS 店舗数, AVG(r.price) AS 平均価格
ORDER BY 平均価格 ASC

次に
SQLite と Neo4j の同期方法

CSVファイルをSqlite にインポートする

CSVファイルをSqlite にインポートする

最初に

import sqlite3
import csv

# CSVとDBファイルのパス
csv_file = 'receipts.csv'
db_file = 'receipts.db'

# データベース接続とテーブル作成
conn = sqlite3.connect(db_file)
cursor = conn.cursor()

cursor.execute('''
    CREATE TABLE IF NOT EXISTS purchases (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        date TEXT,
        store TEXT,
        item TEXT,
        price INTEGER,
        quantity INTEGER
    )
''')

# CSV読み込み&データ挿入
with open(csv_file, newline='', encoding='utf-8') as f:
    reader = csv.DictReader(f)
    for row in reader:
        cursor.execute('''
            INSERT INTO purchases (date, store, item, price, quantity)
            VALUES (?, ?, ?, ?, ?)
        ''', (row['日付'], row['店舗'], row['商品名'], int(row['単価']), int(row['数量'])))

conn.commit()
conn.close()

print("CSVファイルからSQLiteにデータを取り込みました。")

としたけど
毎日買い物するためCSVファイルは 20250618_create.csv のように ファイル名は 日付_店舗名.csv となる このファイルが複数になる

✅ 目的
すべての *.csv をスキャンし、SQLiteに取り込む。

なので

./csv_files/

から取り込むようにするので

mkdir csv_files
mv *.csv csv_files 

で移動しておく

そしてこれを取り込むようにコード変更

import sqlite3
import csv
import os
import re

# フォルダとDBファイルのパス
csv_folder = './csv_files'
db_file = 'receipts.db'

# データベース接続とテーブル作成
conn = sqlite3.connect(db_file)
cursor = conn.cursor()

cursor.execute('''
    CREATE TABLE IF NOT EXISTS purchases (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        date TEXT,
        store TEXT,
        item TEXT,
        price INTEGER,
        quantity INTEGER
    )
''')

# ファイル名のパターン: 20250618_イオン.csv
pattern = re.compile(r"(\d{8})_(.+)\.csv")

# フォルダ内のCSVファイルをループ処理
for filename in os.listdir(csv_folder):
    match = pattern.match(filename)
    if not match:
        continue  # 無効なファイル名はスキップ

    date = match.group(1)
    store = match.group(2)
    filepath = os.path.join(csv_folder, filename)

    with open(filepath, newline='', encoding='utf-8') as f:
        reader = csv.DictReader(f)
        for row in reader:
            try:
                cursor.execute('''
                    INSERT INTO purchases (date, store, item, price, quantity)
                    VALUES (?, ?, ?, ?, ?)
                ''', (
                    date,
                    store,
                    row['商品名'],
                    int(row['単価']),
                    int(row['数量'])
                ))
            except Exception as e:
                print(f"[エラー] {filename} の {row} に問題があります: {e}")

conn.commit()
conn.close()
print("すべてのCSVファイルをSQLiteに取り込みました。")

✅ 今後追加可能な機能
* すでに取り込んだCSVファイルを記録してスキップ(import_log.dbなどで管理)
* 重複チェック(同じ日付・店舗・商品・価格が存在すればスキップ)

すでに取り込んだCSVファイルを記録してスキップ(import_log.dbなどで管理) 重複チェック(同じ日付・店舗・商品・価格が存在すればスキップ) を追加する

✅ 追加機能の詳細
1. 取り込み済みCSVファイルを記録してスキップ
 → 別テーブル import_log を用意し、同じファイルは2回処理しないようにする。
2. 同一商品の重複データ(同じ日付+店舗+商品+単価)をスキップ
 → purchases テーブルに一意制約(UNIQUE)を追加。

これで

import sqlite3
import csv
import os
import re

# フォルダとDBファイルのパス
csv_folder = './csv_files'
db_file = 'receipts.db'

# データベース接続とテーブル作成
conn = sqlite3.connect(db_file)
cursor = conn.cursor()

# 商品データ用テーブル(重複防止のためUNIQUE制約付き)
cursor.execute('''
    CREATE TABLE IF NOT EXISTS purchases (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        date TEXT,
        store TEXT,
        item TEXT,
        price INTEGER,
        quantity INTEGER,
        UNIQUE(date, store, item, price)
    )
''')

# CSVインポート済みファイルの記録テーブル
cursor.execute('''
    CREATE TABLE IF NOT EXISTS import_log (
        filename TEXT PRIMARY KEY
    )
''')

# ファイル名のパターン: 20250618_イオン.csv
pattern = re.compile(r"(\d{8})_(.+)\.csv")

# フォルダ内のCSVファイルをループ処理
for filename in os.listdir(csv_folder):
    match = pattern.match(filename)
    if not match:
        continue  # 無効なファイル名はスキップ

    # すでに取り込み済みかチェック
    cursor.execute('SELECT 1 FROM import_log WHERE filename = ?', (filename,))
    if cursor.fetchone():
        print(f"[スキップ] すでに取り込み済み: {filename}")
        continue

    date = match.group(1)
    store = match.group(2)
    filepath = os.path.join(csv_folder, filename)

    with open(filepath, newline='', encoding='utf-8') as f:
        reader = csv.DictReader(f)
        inserted = 0
        for row in reader:
            try:
                cursor.execute('''
                    INSERT OR IGNORE INTO purchases (date, store, item, price, quantity)
                    VALUES (?, ?, ?, ?, ?)
                ''', (
                    date,
                    store,
                    row['商品名'],
                    int(row['単価']),
                    int(row['数量'])
                ))
                if cursor.rowcount > 0:
                    inserted += 1
            except Exception as e:
                print(f"[エラー] {filename} の {row} に問題があります: {e}")

    # 成功したらimport_logに記録
    cursor.execute('INSERT OR IGNORE INTO import_log (filename) VALUES (?)', (filename,))
    print(f"[完了] {filename} を取り込みました(新規 {inserted} 件)")

conn.commit()
conn.close()
print("すべてのCSVファイルの取り込みが完了しました。")

とする

そして
セルフメディケーション対応商品の商品名と金額、今年購入した合計金額を表示

✅ やりたいことまとめ
* 商品名に「セルフメディケーション対象」とわかるものをリストアップ(例:風邪薬、胃腸薬など)
* 購入履歴のうち、対象商品の2025年の購入金額合計を商品別に表示
* 合計金額も出す

DBで購入管理したい そのために 購入店舗名 購入年月日 商品名 価格 個数 をCSVファイルにしたい また食品なども同時に購入することがあるため セルフメディケーション対象商品を識別できるようにしたい

最初にGPTで提案してきたのは
対象商品のリスト(キーワードまたは手動リスト)
 たとえば以下のように事前定義します:

self_medication_keywords = [
    "風邪薬", "解熱剤", "鎮痛剤", "胃薬", "整腸剤", "花粉症", "鼻炎薬", "目薬", "かゆみ止め", "湿布", "トローチ"
]

これだとリストを手動で追加する必要がある

DBで購入管理したい そのために 購入店舗名 購入年月日 商品名 価格 個数 をCSVファイルにしたい また食品なども同時に購入することがあるため セルフメディケーション対象商品を識別できるようにしたい 現在購入している店舗ては ◆ もしくは セ] が商品名の先頭についている

この場合、◆をCSVにいれる以外の方法で何かあるか?
に対して

方法1:**商品名とは別に「分類」カラムを持つ(推奨)
方法2:対象商品のリストを別ファイル or 別テーブルで管理する
方法3:商品名はそのまま、別フラグ列 is_self_med を追加

✅ 結論:どの方法がいい?
方法 長所 短所 おすすめ度
方法1: 分類列 視認性と拡張性◎、CSVで完結 手入力ミスの可能性 ⭐⭐⭐⭐☆
方法2: マスタ マスタ更新で一括制御できる(大量商品向け) 初期セットアップが必要 ⭐⭐⭐⭐☆
方法3: フラグ 分類が2値なら簡単 他カテゴリ分類に弱い ⭐⭐⭐☆☆
方法0: 記号 手軽・一目でわかる データが汚れ、分類が混在する可能性 ⭐⭐☆☆☆

が回答

結論として

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
ドラッグストア クリエイト 袋井山名店,2025/06/21,ロイヤルブレッド(6),139,139,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,日興フーズ 王様のデーツ,298,298,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,家族の潤いマスカット,108,108,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,家族の潤い白桃ミックス,108,216,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,ビオヨーグルト プレーン加糖,178,356,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,雪印メグミルク 牧場の朝ヨー,99,198,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,ブルボン デザートマルシェ白,93,278,3,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,フルーツフェスタフルーツミックス,198,396,2,0

というように
カラムの追加とその値に金額を入れることにした

CSVデータ作成を依頼すると重複データを送られることがある。
実際のデータレシートは1枚のため
同一のレシート内容がある場合、削除する機能が欲しい

ハッシュで重複検出
をGPTは提案してくるが
CSVファイル全体の内容が完全一致した場合だけ「重複」とみなしてスキップ
つまり差分のチェックはこれだとできない

基本的に購入管理なので商品名と日付の判定で十分。 同じ日付で同一店舗で複数購入することはまず無いため
これを元に再度作成する

✔ 重複判定キー:
sql
コピーする編集する
UNIQUE(date, store, item)
これにより、
* 同じ日付
* 同じ店舗
* 同じ商品名
が1回しか登録されないようになる

✅ 補足:複数買い(同じ商品を同日に2回買う)を防ぎたい場合
万が一「同日に同じ商品を複数回買う」ことが発生してしまうと、2回目は登録されません(意図どおり)。
でもその場合も quantity や total_price を工夫すれば、「まとめて登録したことにできる」ので問題ありません。

ということ

この制約で動くインポートスクリプト全体を提供し
重複スキップされた商品名を CSV記録する

✅ 要件まとめ
* csv_files/ フォルダに複数のCSVがあり、すべて処理対象
* CSV形式:

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額

* UNIQUE制約:購入店舗名, 購入年月日, 商品名 の3つで一意
* 重複データは登録せずスキップ
* スキップされた商品情報をCSVに記録(例:skipped.csv)

 vim import_receipts_with_dedup_and_log.py

でファイルを作成

import sqlite3
import csv
import os
from datetime import datetime
import re

# 設定
csv_folder = './csv_files'
db_file = 'receipts.db'
skipped_log = 'skipped.csv'

# SQLite接続
conn = sqlite3.connect(db_file)
cursor = conn.cursor()

# purchasesテーブル(UNIQUE制約付き)
cursor.execute('''
    CREATE TABLE IF NOT EXISTS purchases (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        store TEXT,
        date TEXT,
        item TEXT,
        unit_price INTEGER,
        total_price INTEGER,
        quantity INTEGER,
        self_med_amount INTEGER,
        UNIQUE(date, store, item)
    )
''')

# import_logテーブル
cursor.execute('''
    CREATE TABLE IF NOT EXISTS import_log (
        filename TEXT PRIMARY KEY
    )
''')

# skipped.csv が存在していたら上書き、なければ新規作成
with open(skipped_log, 'w', newline='', encoding='utf-8') as skipfile:
    skip_writer = csv.writer(skipfile)
    skip_writer.writerow(['購入店舗名', '購入年月日', '商品名', '単品税抜価格', '価格', '個数', 'セルフメディケーション対象金額'])

    for filename in os.listdir(csv_folder):
        if not filename.endswith('.csv'):
            continue

        filepath = os.path.join(csv_folder, filename)

        # ファイルの重複チェック
        cursor.execute('SELECT 1 FROM import_log WHERE filename = ?', (filename,))
        if cursor.fetchone():
            print(f"[スキップ] {filename}(すでにインポート済み)")
            continue

        print(f"[処理中] {filename}")
        try:
            with open(filepath, newline='', encoding='utf-8') as f:
                reader = csv.DictReader(f)
                for row in reader:
                    store = row['購入店舗名'].strip()
                    date = datetime.strptime(row['購入年月日'], '%Y/%m/%d').strftime('%Y%m%d')
                    item = row['商品名'].strip()
                    unit_price = int(row['単品税抜価格'])
                    total_price = int(row['価格'])
                    quantity = int(row['個数'])
                    self_med_amount = int(row['セルフメディケーション対象金額'])

                    try:
                        cursor.execute('''
                            INSERT INTO purchases
                            (store, date, item, unit_price, total_price, quantity, self_med_amount)
                            VALUES (?, ?, ?, ?, ?, ?, ?)
                        ''', (store, date, item, unit_price, total_price, quantity, self_med_amount))
                    except sqlite3.IntegrityError:
                        # 重複データをskipped.csvに記録
                        skip_writer.writerow([
                            store, row['購入年月日'], item,
                            unit_price, total_price, quantity, self_med_amount
                        ])

            # ファイル名をインポート済みとして記録
            cursor.execute('INSERT INTO import_log (filename) VALUES (?)', (filename,))
            print(f"[完了] {filename} をインポートしました。")

        except Exception as e:
            print(f"[エラー] {filename}: {e}")

conn.commit()
conn.close()
print("✅ すべてのCSVの処理が完了しました。スキップされた商品は skipped.csv に保存されています。")

これを実行したら

 python import_receipts_with_dedup_and_log.py
[処理中] 20250621.csv
[エラー] 20250621.csv: time data '購入年月日' does not match format '%Y/%m/%d'
[処理中] 20240618-19.csv
[完了] 20240618-19.csv をインポートしました。
[処理中] 20250612.csv
[完了] 20250612.csv をインポートしました。
[処理中] 20250613.csv
[完了] 20250613.csv をインポートしました。
[処理中] 20250614-19.csv
[完了] 20250614-19.csv をインポートしました。
[処理中] 20250605.csv
[完了] 20250605.csv をインポートしました。
[処理中] 20250611.csv
[完了] 20250611.csv をインポートしました。
✅ すべてのCSVの処理が完了しました。スキップされた商品は skipped.csv に保存されています。

となる

cat skipped.csv 

で中身を確認

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
杏林堂 袋井旭町店,2025/06/18,サンテビオ 15mL,968,871,1,871
ピアゴ 袋井店,2025/04/18,タイレノールA 30錠,1790,1772,1,1772
COOP ユーコープ 袋井町店,2025/06/19,COゼリーエネルギー,118,354,3,0
COOP ユーコープ 袋井町店,2025/06/19,鮭・くず桜,238,190,1,0
COOP ユーコープ 袋井町店,2025/06/19,天ぷら用えび,398,238,1,0
COOP ユーコープ 袋井町店,2025/06/19,たい,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/19,塩銀鮭切身,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/19,真あじ開き,398,796,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,日興フーズ 王様のチーズ,298,298,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,ロイヤルブレッド山型(5),139,139,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,家族の潤い白桃ミックス,108,216,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,鶏卵卵パック MS 10個,228,228,1,0

で調べるとスキップされている

 cat csv_files/20240618-19.csv 
で中身を確認
購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
杏林堂 袋井旭町店,2025/06/18,サンテビオ 15mL,968,871,1,871
ピアゴ 袋井店,2025/04/18,タイレノールA 30錠,1790,1772,1,1772
COOP ユーコープ 袋井町店,2025/06/19,COゼリーエネルギー,118,354,3,0
COOP ユーコープ 袋井町店,2025/06/19,鮭・くず桜,238,190,1,0
COOP ユーコープ 袋井町店,2025/06/19,天ぷら用えび,398,238,1,0
COOP ユーコープ 袋井町店,2025/06/19,たい,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/19,塩銀鮭切身,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/19,真あじ開き,398,796,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,日興フーズ 王様のチーズ,298,298,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,ロイヤルブレッド山型(5),139,139,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,家族の潤い白桃ミックス,108,216,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,鶏卵卵パック MS 10個,228,228,1,0

で調べると

これは row[‘購入年月日’] の値が本来 “2025/06/18” のような日付になるはずのところ、
誤って “購入年月日”(つまりヘッダーの文字列)が入っているため、datetime.strptime() が失敗しています。
とのこと

CSVファイル内に ヘッダー行がもう一度繰り返されている可能性が非常に高いです(コピペミス、マージ時の事故など)。

✅ 解決策:誤って混入したヘッダー行をスキップ
以下の1行を for row in reader: の直後に追加

# ヘッダー行がデータ行に混入していたらスキップ
if row['購入年月日'] == '購入年月日':
    continue

これを処理に追加して

with open(filepath, newline='', encoding='utf-8') as f:
    reader = csv.DictReader(f)
    for row in reader:
        if row['購入年月日'] == '購入年月日':
            continue  # ← ここで2回目以降のヘッダー行を除外

        store = row['購入店舗名'].strip()
        date = datetime.strptime(row['購入年月日'], '%Y/%m/%d').strftime('%Y%m%d')
        ...

とすることで
✅ 結果
* ✔ ヘッダーが再登場しても自動でスキップ
* ✔ 異常な日付データがあってもクラッシュせず処理継続

保存して再度実行

 python import_receipts_with_dedup_and_log.py
[処理中] 20250621.csv
[完了] 20250621.csv をインポートしました。
[スキップ] 20240618-19.csv(すでにインポート済み)
[スキップ] 20250612.csv(すでにインポート済み)
[スキップ] 20250613.csv(すでにインポート済み)
[スキップ] 20250614-19.csv(すでにインポート済み)
[スキップ] 20250605.csv(すでにインポート済み)
[スキップ] 20250611.csv(すでにインポート済み)
✅ すべてのCSVの処理が完了しました。スキップされた商品は skipped.csv に保存されています。

となって無事に成功

次に
セルフメディケーション対象商品の合計金額(2025年) 商品別セルフメディケーション金額ランキング を表示したい

SQLiteに保存された購入データ(テーブル名:purchases)から、以下の2つを表示

セルフメディケーション対象商品の合計金額(2025年)

SELECT SUM(self_med_amount) AS total_amount
FROM purchases
WHERE date BETWEEN '20250101' AND '20251231'
  AND self_med_amount > 0;

商品別セルフメディケーション金額ランキング(2025年)

SELECT item, SUM(self_med_amount) AS total_amount
FROM purchases
WHERE date BETWEEN '20250101' AND '20251231'
  AND self_med_amount > 0
GROUP BY item
ORDER BY total_amount DESC;

これがSQLになる

 vim medication_summary.py
import sqlite3

# SQLiteに接続
conn = sqlite3.connect("receipts.db")
cursor = conn.cursor()

print("📋 セルフメディケーション対象商品の合計金額(2025年)")
cursor.execute('''
    SELECT SUM(self_med_amount)
    FROM purchases
    WHERE date BETWEEN '20250101' AND '20251231'
      AND self_med_amount > 0
''')
total = cursor.fetchone()[0] or 0
print(f"▶ 合計金額:¥{total:,}")
print()

print("📊 商品別セルフメディケーション対象金額ランキング(2025年)")
cursor.execute('''
    SELECT item, SUM(self_med_amount) AS total
    FROM purchases
    WHERE date BETWEEN '20250101' AND '20251231'
      AND self_med_amount > 0
    GROUP BY item
    ORDER BY total DESC
''')

rows = cursor.fetchall()
if not rows:
    print("(対象商品はありません)")
else:
    print("商品名\t\t金額")
    print("-" * 30)
    for item, amount in rows:
        print(f"{item[:14]:<14} ¥{amount:,}")

conn.close()

これを実行すると

python medication_summary.py 
📋 セルフメディケーション対象商品の合計金額(2025年)
▶ 合計金額:¥2,643

📊 商品別セルフメディケーション対象金額ランキング(2025年)
商品名		金額
------------------------------
タイレノールA 30錠    ¥1,772
サンテビオ 15mL     ¥871

となる

レシートからセルフメディケーションの抽出

セルフメディケーションの抽出

レシートの識別で

ピアゴのレシートはできた
なるべく拡大撮影した方がうまく識別できる

Coopも問題ない

ただし向きが縦向きになっていないと正しく認識しない

杏林堂は横向きでもできた

他のCreate やココカラファインでも
画像を拡大すればできる

つまり解像度も重要だが文字の大きさが重要
画像のズームなどが現時点では自動でできないし
向きの修正もできないため
まずは面倒だがレシートを人力で行い
その後に作成されたCSVファイルをDBに取り込むようにする

次に問題となるのがCSVファイルをどんどん追加でsqliteDBに後で追加できるかということ

GPTでのチャットが乱立しているので
SQLに関してのものを探す

レシート分析システム
レシート情報最適化提案
Graph RAG活用法
日用品食料品管理法

でプロンプトも重要

レシート画像から 購入した店舗名 購入日時 商品名 単価 を取り出しDBへ保存。
このデータを毎日蓄積していき 購入した店舗、商品価格から最適な店舗での購入を知らせるようにしたい。
レシートのOCRには gemini を使う また商品購入の間隔からどれくらいの期間で消費するか予測し、購入のタイミングを知らせるようにしたい。
理由としては、特売のときに購入しておけばコストを削減できるため

だけだと足りない

次にセルフメディケーションの抽出を試す

杏林堂なら商品名に♦️マークがつく
ピアゴならセ[ と商品名につく

ココカラファインはまだ試していない

Gemini で
DBで購入管理したい
そのために
購入店舗名
購入年月日
商品名
価格
個数
をCSVファイルにしたい

また食品なども同時に購入することがあるため
セルフメディケーション対象商品を識別できるようにしたい

この場合、◆をCSVにいれる以外の方法で何かあるか?

に対して

承知いたしました。セルフメディケーション税制対象商品を明確に識別しつつ、購入データをCSVで管理したいというご要望ですね。
◆マークをCSVに直接入れる以外の方法で、よりデータとして扱いやすく、かつ後から分析しやすい方法をいくつか提案します。
1. 専用の「セルフメディケーション対象フラグ」列を追加する
これが最も一般的で、データ管理のベストプラクティスです。

購入店舗名	購入年月日	商品名	価格	個数	セルフメディケーション対象
杏林堂袋井旭町店	2025/06/18	サンテビオ 15mL	871	1	TRUE (または 1, Y, ○など)
杏林堂袋井旭町店	2025/06/18	(食品A)	200	1	FALSE (または 0, N, ×など)
...	...	...	...	...	...

利点:
* 明確な識別: セルフメディケーション対象か否かが一目で分かります。
* フィルタリング・ソートが容易: CSVをDBにインポートしたり、スプレッドシートで開いたりした際に、「セルフメディケーション対象」列でフィルタリングやソートを行うことで、対象商品のみを簡単に抽出できます。
* 集計が容易: 対象商品の合計金額などを計算する際に、このフラグ列を条件に設定することで簡単に集計できます。
* データの一貫性: 「◆」のような記号を直接入れるよりも、TRUE/FALSEや1/0といった標準的なデータ形式の方が、後のデータ処理(プログラミングによる集計など)がしやすくなります。
* OCRとの連携: OCRで「◆」マークや「セルフメディケーション対象」という文字列を認識した場合、その情報に基づいてこのフラグ列に自動的に「TRUE」を設定するようなシステムを構築することも可能です。
2. 「セルフメディケーション対象金額」列を追加する(より詳細な管理)
セルフメディケーション対象の商品であればその金額を、そうでなければ0円を記入する列を設ける方法です。これにより、対象金額の合計がより簡単に算出できます。

購入店舗名	購入年月日	商品名	価格	個数	セルフメディケーション対象金額
杏林堂袋井旭町店	2025/06/18	サンテビオ 15mL	871	1	871
杏林堂袋井旭町店	2025/06/18	(食品A)	200	1	0
...	...	...	...	...	...

利点:
* セルフメディケーション対象金額の合計が、この列を単純に合計するだけで算出できます。
* 「セルフメディケーション対象」かどうかのフラグも兼ねることができます(金額が0でなければ対象)。
OCRで実現する場合の考慮事項:
* OCRでレシートを読み取った際に、商品名に「◆」があることを認識し、その商品に対応する価格を「セルフメディケーション対象金額」の列に転記するロジックが必要になります。
* 食品などの非対象商品は、この列には0を自動で入力するように設定します。
どちらを選ぶべきか?
* シンプルに「対象か否か」だけを識別したいなら、「セルフメディケーション対象フラグ」列(1番目の案)がおすすめです。 後から合計金額を出すのは簡単です。
* 合計金額の算出をより直接的に行いたい場合や、金額をベースに集計を行いたい場合は、「セルフメディケーション対象金額」列(2番目の案)も有効です。
どちらの方法でも、CSVファイルとしては非常に扱いやすくなります。DBに取り込んだ後も、この列を使って柔軟な集計や分析が可能になります。
CSVファイルの例(1番目の案を採用した場合):
コード スニペット

購入店舗名,購入年月日,商品名,価格,個数,セルフメディケーション対象
杏林堂袋井旭町店,2025/06/18,サンテビオ 15mL,871,1,TRUE
杏林堂袋井旭町店,2025/06/18,牛乳,250,1,FALSE
杏林堂袋井旭町店,2025/06/18,パン,150,2,FALSE
杏林堂袋井旭町店,2025/06/18,〇〇鎮痛剤,1280,1,TRUE

とりあえずCSVファイルを作成できるので

mkdir purcharge_history

で作業ディレクトリの作成

購入店舗名,購入年月日,商品名,価格,個数,セルフメディケーション対象金額 杏林堂 袋井旭町店,2025/06/18,サンテビオ 15mL,871,1,871 ピアゴ 袋井店,2025/04/18,タイレノールA 30錠,1772,1,1772 

このcsvファイルを 日付_購入店舗名.csv で作成
とchatfgpt で行ったが
ファイルは作成されない
面倒なので gemini で作成する

vim 20250618_杏林堂袋井旭町店.csv

中身を

購入店舗名,購入年月日,商品名,価格,個数,セルフメディケーション対象金額
杏林堂 袋井旭町店,2025/06/18,サンテビオ 15mL,871,1,871
vim 20250418_ピアゴ袋井店.csv
中身を
購入店舗名,購入年月日,商品名,価格,個数,セルフメディケーション対象金額
ピアゴ 袋井店,2025/04/18,タイレノールA 30錠,1772,1,1772

コープとクリエイトで試したら単品価格がないので
単品価格のカラムの追加

一度

rm *.csv

で削除して作り直す

 vim 20240618-19.csv

内容を

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
杏林堂 袋井旭町店,2025/06/18,サンテビオ 15mL,968,871,1,871
ピアゴ 袋井店,2025/04/18,タイレノールA 30錠,1790,1772,1,1772
COOP ユーコープ 袋井町店,2025/06/19,COゼリーエネルギー,118,354,3,0
COOP ユーコープ 袋井町店,2025/06/19,鮭・くず桜,238,190,1,0
COOP ユーコープ 袋井町店,2025/06/19,天ぷら用えび,398,238,1,0
COOP ユーコープ 袋井町店,2025/06/19,たい,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/19,塩銀鮭切身,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/19,真あじ開き,398,796,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,日興フーズ 王様のチーズ,298,298,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,ロイヤルブレッド山型(5),139,139,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,家族の潤い白桃ミックス,108,216,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,鶏卵卵パック MS 10個,228,228,1,0

で保存

Geminiでは回数制限があるようで時間をおけばできるが10回ぐらいで
無料枠が終わる
Pro にすると3回が限界

残りは chatGPT 4.5 で作成した

vim 20250614-19.csv

内容を

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
杏林堂 袋井旭町店,2025/06/18,サンテビオ 15mL,968,871,1,871
ピアゴ 袋井店,2025/04/18,タイレノールA 30錠,1790,1772,1,1772
COOP ユーコープ 袋井町店,2025/06/19,COゼリーエネルギー,118,354,3,0
COOP ユーコープ 袋井町店,2025/06/19,鮭・くず桜,238,190,1,0
COOP ユーコープ 袋井町店,2025/06/19,天ぷら用えび,398,238,1,0
COOP ユーコープ 袋井町店,2025/06/19,たい,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/19,塩銀鮭切身,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/19,真あじ開き,398,796,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,日興フーズ 王様のチーズ,298,298,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,ロイヤルブレッド山型(5),139,139,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,家族の潤い白桃ミックス,108,216,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/18,鶏卵卵パック MS 10個,228,228,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/17,ぶなしめじ,98,98,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/17,森永バター 200g,458,412,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/17,鶏卵パック MS 10個,228,228,1,0
COOP ユーコープ 袋井町店,2025/06/17,バジルMIXピッツァ,428,342,1,0
COOP ユーコープ 袋井町店,2025/06/17,若鶏ムネ肉,594,594,1,0
COOP ユーコープ 袋井町店,2025/06/17,ボスコEVオリーブオイル,898,898,1,0
COOP ユーコープ 袋井町店,2025/06/17,豚小間切れ,190,190,1,0
COOP ユーコープ 袋井町店,2025/06/17,赤魚粕漬け,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/17,ししゃも,298,298,1,0
COOP ユーコープ 袋井町店,2025/06/17,塩銀鮭切身,597,597,1,0
COOP ユーコープ 袋井町店,2025/06/17,真あじ開き,398,796,2,0
COOP ユーコープ 袋井町店,2025/06/17,なす/袋,138,138,1,0
COOP ユーコープ 袋井町店,2025/06/17,まいたけ,88,88,1,0
COOP ユーコープ 袋井町店,2025/06/17,COゼリーエネルギー,118,354,3,0
COOP ユーコープ 袋井町店,2025/06/17,フィリピンバナナ,138,276,2,0
COOP ユーコープ 袋井町店,2025/06/14,シュガーコーン,278,278,1,0
COOP ユーコープ 袋井町店,2025/06/14,CO冷凍茶豆,158,158,1,0
COOP ユーコープ 袋井町店,2025/06/14,豚小間,535,535,1,0
COOP ユーコープ 袋井町店,2025/06/14,生豚挽肉,522,522,1,0
COOP ユーコープ 袋井町店,2025/06/14,豚ヒレかたまりカナダ,630,567,1,0
COOP ユーコープ 袋井町店,2025/06/14,かつおたたき,398,398,1,0
COOP ユーコープ 袋井町店,2025/06/14,塩銀鮭切身,498,996,2,0
COOP ユーコープ 袋井町店,2025/06/14,真あじ開き,398,1194,3,0
COOP ユーコープ 袋井町店,2025/06/14,COゼリーエネルギー60F,178,178,1,0
COOP ユーコープ 袋井町店,2025/06/14,COジョウエラミックスナッツ,298,298,1,0
COOP ユーコープ 袋井町店,2025/06/14,黄金しょうが,178,178,1,0
とれたて食楽部,2025/06/14,イチゴ/鈴木康友,230,230,1,0
とれたて食楽部,2025/06/14,雑貨/小路仁徳,390,390,1,0
とれたて食楽部,2025/06/14,コーヒー/濱小路徳,1090,1090,1,0
とれたて食楽部,2025/06/14,アーリーレッド,230,230,1,0
とれたて食楽部,2025/06/14,きゅうり/丸数農園,150,150,1,0
とれたて食楽部,2025/06/14,白ねぎ/鈴木正孝,170,170,1,0
とれたて食楽部,2025/06/14,バラ/大場安輝子,300,150,1,0
とれたて食楽部,2025/06/14,なす/大石真智子,260,130,1,0
とれたて食楽部,2025/06/14,レタス/山田さち子,140,140,1,0
とれたて食楽部,2025/06/14,ピーマン/小林宗作,140,140,1,0
とれたて食楽部,2025/06/14,じゃが芋/澤口正則,250,250,1,0
とれたて食楽部,2025/06/14,大根/石原義浩,150,150,1,0
とれたて食楽部,2025/06/14,菌床椎茸/永井晃太,260,260,1,0

として保存

次に店舗ごとのcsvの作成

 vim 20250613.csv

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
遠鉄ストア 浅羽店,2025/06/13,イチジク習慣,398,1592,4,0
遠鉄ストア 浅羽店,2025/06/13,はごろも シーチキンLフ,338,338,1,0
vim 20250612.csv

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
ピアゴ 袋井店,2025/06/12,明治おいしい牛乳900ml,247,247,1,0
ピアゴ 袋井店,2025/06/12,明和水産 切身・丸魚,478,478,1,0
ピアゴ 袋井店,2025/06/12,だしのきいたなめらか玉子,128,128,1,0
ピアゴ 袋井店,2025/06/12,おかめ納豆 まろやか旨味ミ,97,97,1,0
vim 20250605.csv

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
とれたて食楽部,2025/06/05,その他切り花/竹原秀,210,210,1,0
とれたて食楽部,2025/06/05,コーヒー/満り路仁徳,1090,1090,1,0
とれたて食楽部,2025/06/05,ベビーリーフ/南グロー,120,120,1,0
とれたて食楽部,2025/06/05,きゅうり/河井 恵美,100,100,1,0
とれたて食楽部,2025/06/05,レタス/山田さち子,150,150,1,0
とれたて食楽部,2025/06/05,豚小間切,336,336,1,0
とれたて食楽部,2025/06/05,静岡そだち牛肉入りコロ,390,390,1,0
とれたて食楽部,2025/06/05,ルッコラ/Gファーム,180,180,1,0
とれたて食楽部,2025/06/05,ごぼう/とれたて食楽部,280,280,1,0
vim 20250611.csv

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
ドラッグストア クリエイト 袋井山名店,2025/06/11,鶏卵パック MS 10個,228,228,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/11,ロイヤルブレッド(6),139,139,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/11,フルーツフェスタアップル,198,198,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/11,フルーツフェスタフルーツ,198,198,1,0
vim 20250621.csv

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
ドラッグストア クリエイト 袋井山名店,2025/06/21,ロイヤルブレッド(6),139,139,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,日興フーズ 王様のチーズ,298,298,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,家族の潤いマスカット,108,108,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,家族の潤い白桃ミックス,108,216,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,ビオヨーグルト プレーン加糖,178,356,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,雪印メグミルク 牧場の朝ヨー,99,198,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,ブルボン デザートマルシェ白,93,278,3,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,フルーツフェスタフルーツミックス,198,396,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,鶏卵パック MS 10個,228,228,1,0

これだと商品名が違っていたので修正する

購入店舗名,購入年月日,商品名,単品税抜価格,価格,個数,セルフメディケーション対象金額
ドラッグストア クリエイト 袋井山名店,2025/06/21,ロイヤルブレッド(6),139,139,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,日興フーズ 王様のデーツ,298,298,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,家族の潤いマスカット,108,108,1,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,家族の潤い白桃ミックス,108,216,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,ビオヨーグルト プレーン加糖,178,356,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,雪印メグミルク 牧場の朝ヨー,99,198,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,ブルボン デザートマルシェ白,93,278,3,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,フルーツフェスタフルーツミックス,198,396,2,0
ドラッグストア クリエイト 袋井山名店,2025/06/21,鶏卵パック MS 10個,228,228,1,0

が正解

これを使ってsqliteにインポートする

yomitokuでレシートOCR

yomitokuでレシートOCR

pip install yomitoku

でインストール

https://qiita.com/kanzoo/items/9d382fe4ec991a7eacd2
を参考に

VRAMは8GB以内で動作

# HTMLファイルを出力
yomitoku ./images -f html
# JSONファイルを出力
yomitoku ./images -f json
# Markdownファイルを出力
yomitoku ./images -f md
# CSVファイルを出力
yomitoku ./images -f csv

で実行

実行データは
https://template.the-board.jp/invoice_templates/article/invoice_template_001/
を使っている

コマンドを実行すると、最初にモデルをダウンロードします。これは自動的に行われ、初回のみで2回目以降はない

解析を実行し、 results ディレクトリに結果が出力

実行時間はGPUによって異なりますが、 Google ColabのA100で実行した場合は2.5〜3.5秒程度でした。T4 GPUなどにしても高速になる訳ではない

HTMLでは、表部分が table タグで出力

JSONファイルの場合
表になっている部分は table キー以下にあるので、
システム連携する際にはこの部分を利用します。 box は認識された座標

Markdownの場合、表の部分はMarkdownのテーブル形式で出力

CSVの場合、表の部分はCSV形式で出力されます。Excelなどで開いた際にも、レイアウトが維持されているので確認しやすい

コマンドオプション -v を付けると、解析結果を可視化した画像を出力

yomitoku ./images -f html -v

レシートの実例を探した

https://qiita.com/ayoyo/items/033b5d46acc641b91208
にサンクスのレシートで試しているのがあるので
これを使う

! yomitoku 0003.jpg -f json -o results -v --figure

が実行コマンド

とりあえず実践

 mkdir receipt
cd receipt 
mv ../PXL_20250531_012457081* .

で実験フォルダに移動

yomitoku PXL_20250531_002845534.jpg -f json -o results -v --figure

で実行

2025-06-10 06:08:32,215 - yomitoku.base - INFO - Initialize TextDetector
2025-06-10 06:08:34,123 - yomitoku.base - WARNING - CUDA is not available. Use CPU instead.
2025-06-10 06:08:34,125 - yomitoku.base - INFO - Initialize TextRecognizer
2025-06-10 06:08:35,802 - yomitoku.base - WARNING - CUDA is not available. Use CPU instead.
2025-06-10 06:08:35,803 - yomitoku.base - INFO - Initialize LayoutParser
2025-06-10 06:08:37,339 - yomitoku.base - WARNING - CUDA is not available. Use CPU instead.
2025-06-10 06:08:37,342 - yomitoku.base - INFO - Initialize TableStructureRecognizer
2025-06-10 06:08:38,195 - yomitoku.base - WARNING - CUDA is not available. Use CPU instead.
2025-06-10 06:08:38,200 - yomitoku.cli.main - INFO - Output directory: results
2025-06-10 06:08:38,200 - yomitoku.cli.main - INFO - Processing file: PXL_20250531_002845534.jpg
2025-06-10 06:08:39,332 - yomitoku.base - INFO - LayoutParser __call__ elapsed_time: 1.0590169429779053
2025-06-10 06:08:39,381 - yomitoku.base - INFO - TableStructureRecognizer __call__ elapsed_time: 0.04888606071472168
2025-06-10 06:08:41,263 - yomitoku.base - INFO - TextDetector __call__ elapsed_time: 2.9893980026245117
2025-06-10 06:08:48,173 - yomitoku.base - INFO - TextRecognizer __call__ elapsed_time: 6.865458965301514
2025-06-10 06:08:48,226 - yomitoku.cli.main - INFO - Output file: results/_PXL_20250531_002845534_p1_ocr.jpg
2025-06-10 06:08:48,256 - yomitoku.cli.main - INFO - Output file: results/_PXL_20250531_002845534_p1_layout.jpg
2025-06-10 06:08:48,259 - yomitoku.cli.main - INFO - Total Processing time: 10.06 sec

結果は resultsフォルダに入っているので
JSONファイルをみると結果が見れる

また、どのように認識しているかを画像で見ることができる

実際に行った結果はgeminiで抽出した結果と変わらないため
レシートによってできないものもある

レシートのOCR

レシートのOCR

yomitokuもしくは Google Cloud Vision API または
Gemini を使ってレシートをOCRして解析結果から
店舗名、商品名、商品の価格、購入年月日、購入した商品の数量を抽出する

なお
Google Cloud Vision API でもある程度はできるが
購入数量が複数の場合はまとめた金額が表示されてしまう
また、直売所のようなところで購入した場合
生産者の名前が商品名に含まれるのでこれを削除する必要がある

コードである程度の添削は可能だが
店舗ごとに処理を分ける必要があったため
以前、Google Cloud Vision API でOCR結果をLLMでCSVにしたが
うまくいかなかったため
今回はgemini で行ってみた

このレシートから
店舗名、商品名、商品の価格、購入年月日、購入した商品の数量を抽出して

結果は
承知いたしました。提供されたレシート画像から、店舗名、商品名、商品の価格、購入年月日、購入した商品の数量を抽出します。

抽出結果:
* 店舗名: とれたて食菜館
* 購入年月日: 2025年5月31日
* 商品と価格、数量:
    1. 商品名: イチゴ / 鈴木康友
        * 価格: ¥350
        * 数量: 1 (画像からは数量を特定できませんが、一般的にこの形式では1点と解釈されます)
    2. 商品名: ニラ
        * 価格: ¥110
        * 数量: 1
    3. 商品名: 加子 / 山本 貢
        * 価格: ¥160
        * 数量: 1
    4. 商品名: 新玉ねぎ&さや / 守屋 昇
        * 価格: ¥180
        * 数量: 1
    5. 商品名: コーヒー / 浅路小徳
        * 価格: ¥1,090
        * 数量: 1
    6. 商品名: ミニトマト
        * 価格: ¥230
        * 数量: 1
    7. 商品名: きゅうり / 稲田政正
        * 価格: ¥150
        * 数量: 1
    8. 商品名: 白ねぎ / 鈴木正孝
        * 価格: ¥180
        * 数量: 1
    9. 商品名: キャベツ / 鈴木正孝
        * 価格: ¥100
        * 数量: 1
    10. 商品名: バラ / 山崎きぬ子
        * 価格: ¥300
        * 数量: 1
    11. 商品名: レタス / 山田さち子
        * 価格: ¥140
        * 数量: 1
    12. 商品名: 乾しいたけ / 大石礼店
        * 価格: ¥620
        * 数量: 1
    13. 商品名: 園味椎茸 / 永井克太
        * 価格: ¥260
        * 数量: 1

補足:
* レシートの各項目には購入数量が明示されていませんが、一般的に商品の価格がそのまま記載されている場合は「1点」の購入と解釈されます。
* 商品名の後ろに記載されている「鈴木康友」などの名前は、生産者名であると推測されます。

この結果をCSVファイルにして

店舗名,購入年月日,商品名,価格,数量
とれたて食菜館,2025年5月31日,イチゴ / 鈴木康友,350,1
とれたて食菜館,2025年5月31日,ニラ,110,1
とれたて食菜館,2025年5月31日,加子 / 山本 貢,160,1
とれたて食菜館,2025年5月31日,新玉ねぎ&さや / 守屋 昇,180,1
とれたて食菜館,2025年5月31日,コーヒー / 浅路小徳,1090,1
とれたて食菜館,2025年5月31日,ミニトマト,230,1
とれたて食菜館,2025年5月31日,きゅうり / 稲田政正,150,1
とれたて食菜館,2025年5月31日,白ねぎ / 鈴木正孝,180,1
とれたて食菜館,2025年5月31日,キャベツ / 鈴木正孝,100,1
とれたて食菜館,2025年5月31日,バラ / 山崎きぬ子,300,1
とれたて食菜館,2025年5月31日,レタス / 山田さち子,140,1
とれたて食菜館,2025年5月31日,乾しいたけ / 大石礼店,620,1
とれたて食菜館,2025年5月31日,園味椎茸 / 永井克太,260,1

CSVファイルの内容について:
* 1行目はヘッダー行で、各列が何のデータであるかを示しています。
* 各行が1つの商品の情報に対応しており、店舗名と購入年月日も各行に繰り返して記載しています。
* 価格は数字のみを記載し、通貨記号「¥」は除外しています。
* 数量は、レシートに明記されていないため、一般的な解釈として「1」としています。

ここまでは cloud Vision API と同じ

CSVファイルにするときに、商品名から人物名を削除して

店舗名,購入年月日,商品名,価格,数量
とれたて食菜館,2025年5月31日,イチゴ,350,1
とれたて食菜館,2025年5月31日,ニラ,110,1
とれたて食菜館,2025年5月31日,加子,160,1
とれたて食菜館,2025年5月31日,新玉ねぎ&さや,180,1
とれたて食菜館,2025年5月31日,コーヒー,1090,1
とれたて食菜館,2025年5月31日,ミニトマト,230,1
とれたて食菜館,2025年5月31日,きゅうり,150,1
とれたて食菜館,2025年5月31日,白ねぎ,180,1
とれたて食菜館,2025年5月31日,キャベツ,100,1
とれたて食菜館,2025年5月31日,バラ,300,1
とれたて食菜館,2025年5月31日,レタス,140,1
とれたて食菜館,2025年5月31日,乾しいたけ,620,1
とれたて食菜館,2025年5月31日,園味椎茸,260,1

変更点:
* 「イチゴ / 鈴木康友」 → 「イチゴ」
* 「加子 / 山本 貢」 → 「加子」
* 「新玉ねぎ&さや / 守屋 昇」 → 「新玉ねぎ&さや」
* 「コーヒー / 浅路小徳」 → 「コーヒー」
* 「きゅうり / 稲田政正」 → 「きゅうり」
* 「白ねぎ / 鈴木正孝」 → 「白ねぎ」
* 「キャベツ / 鈴木正孝」 → 「キャベツ」
* 「バラ / 山崎きぬ子」 → 「バラ」
* 「レタス / 山田さち子」 → 「レタス」
* 「乾しいたけ / 大石礼店」 → 「乾しいたけ」
* 「園味椎茸 / 永井克太」 → 「園味椎茸」

段階的に処理したけど目的は達成できる

次に個数の問題のテスト

今回は別のレシートを使う
しかし
クリエイト、ピアゴのレシートではうまくいかない
横向きなのを縦にして実験してみる

判定を自動化できるかgeminiでためした
このレシートは横向き、縦向きどっち?

このレシート画像は、横向き です。
文字が右から左に流れるように見え、日付や金額の表記も横方向になっているため、画像を時計回りに90度回転させると、文字が正しく上から下へ読める縦向きになると思われます。 提供されたレシート画像は、縦向きです。
画像の左側に「ピアゴ」のロゴがあり、その下に文字が縦書きで並び、右側には商品名や価格が横書きで並んでいますが、全体としてはレシートの長い辺が縦になるように撮影されています。
この向きで情報を抽出することができますので、ご安心ください。

となるので gemini で回転しようとしたけど
Geminiでは画像の回転ができない

pip install Pillow

で必要なライブラリインストール

vim rotate_image.py

で中身を

from PIL import Image
import os

def rotate_image_90_degrees(image_path, output_path=None, clockwise=True):
    """
    指定された画像を90度回転させる関数。

    Args:
        image_path (str): 回転させたい画像のパス。
        output_path (str, optional): 回転後の画像を保存するパス。
                                     指定しない場合、元のファイル名に '_rotated' を追加して保存。
        clockwise (bool): Trueの場合時計回りに90度、Falseの場合反時計回りに90度回転。
    """
    try:
        # 画像を開く
        img = Image.open(image_path)
        print(f"画像をロードしました: {image_path}")

        # 回転方向を決定
        if clockwise:
            rotated_img = img.rotate(-90, expand=True) # 時計回りに90度回転 (反時計回りの-90度と同じ)
            rotation_direction = "時計回り"
        else:
            rotated_img = img.rotate(90, expand=True)  # 反時計回りに90度回転
            rotation_direction = "反時計回り"

        # 出力パスが指定されていない場合、自動生成
        if output_path is None:
            base, ext = os.path.splitext(image_path)
            output_path = f"{base}_rotated{ext}"

        # 回転した画像を保存
        rotated_img.save(output_path)
        print(f"{rotation_direction}に90度回転した画像を保存しました: {output_path}")

    except FileNotFoundError:
        print(f"エラー: 指定されたファイルが見つかりません - {image_path}")
    except Exception as e:
        print(f"画像の回転中にエラーが発生しました: {e}")

if __name__ == "__main__":
    # --- 使用例 ---
    # 回転したい画像のファイル名
    # スクリプトと同じディレクトリにある場合、ファイル名のみでOK
    # 別の場所にある場合、'path/to/your/image.jpg' のようにフルパスを指定
    input_image_name = "PXL_20250531_012457081.jpg" # ← ここを回転させたい画像ファイル名に置き換えてください

    # 回転後の画像の保存パス (Noneにすると自動で '_rotated' が追加される)
    output_image_name = None # 例: "rotated_image.jpg"

    # 時計回りに回転するかどうか (True: 時計回り, False: 反時計回り)
    rotate_clockwise = True

    rotate_image_90_degrees(input_image_name, output_image_name, rotate_clockwise)

    print("\n-----------------------------------------------------")
    print("スクリプトの実行が完了しました。")
    print(f"回転後の画像は '{input_image_name}' (または指定したパス) の場所に保存されています。")
    print("-----------------------------------------------------\n")

これを

cp ~/Downloads/PXL_20250531_012457081.jpg . 
python rotate_image.py PXL_20250531_012457081.jpg        
画像をロードしました: PXL_20250531_012457081.jpg
時計回りに90度回転した画像を保存しました: PXL_20250531_012457081_rotated.jpg

-----------------------------------------------------
スクリプトの実行が完了しました。

として
再度 gemini で実行したけど結果は変わらない

レシートの向きは関係ないらしい

* クエリが正常に完了しました


承知いたしました。90度回転された「ピアゴ」のレシート画像から、店舗名、商品名、商品の価格、購入年月日、購入した商品の数量を抽出します。

抽出結果:
* 店舗名: ピアゴ
* 購入年月日: 2025年5月31日
* 商品と価格、数量:
    1. 商品名: コミックス
        * 価格: ¥436
        * 数量: 1
    2. 商品名: 大豆もやし 1.900M
        * 価格: ¥288
        * 数量: 1
    3. 商品名: 明治おやつヨーグルト
        * 価格: ¥130
        * 数量: 1
    4. 商品名: Bio 旬摘みフルーツ
        * 価格: ¥178
        * 数量: 1
    5. 商品名: Bio プチダノン
        * 価格: ¥90
        * 数量: 1
    6. 商品名: 減塩カツオ,2個
        * 価格: ¥198
        * 数量: 1
    7. 商品名: 青森県産にんにく
        * 価格: ¥165
        * 数量: 1
    8. 商品名: 鶏肉もも肉
        * 価格: ¥645
        * 数量: 1
    9. 商品名: 大根
        * 価格: ¥323
        * 数量: 1
    10. 商品名: 信州田舎造りみそ
        * 価格: ¥136
        * 数量: 1
    11. 商品名: 田中商店もめんとうふ
        * 価格: ¥116
        * 数量: 1
    12. 商品名: (品名不明、単価 ¥271)
        * 価格: ¥813 (271 x 3個と推測)
        * 数量: 3
    13. 商品名: まとめ値引き
        * 価格: -¥8
        * 数量: 1

補足:
* 回転後の画像でも、前回の画像と同様に情報を読み取ることができました。
* レシートの各項目には購入数量が明示されていませんが、一般的にこの形式では1点と解釈されます。ただし、12番目の商品については「3個」の記載があるため、数量を3としました。価格も271円が3個で813円と計算されているようです。
* 商品名の一部には、ブランド名や種類が含まれています。
* 最後の「まとめ値引き」は商品というよりは値引き項目ですが、形式に合わせて抽出しています。
* 商品名の後ろの「JAN」や数字は、商品のJANコードや識別番号と思われます。

プロンプトでは限界っぽいので yomitoku を使うことにする

RAGデータの作成

RAGデータの作成

GoogleDrive に
学校関連はSchool
特に使うのがshizensaigai.pdfに書いてある
休校や自然災害の時の対処方法

保険フォルダにある
自動車保険、県民共済の約款

取扱説明書フォルダにある
各種家電の取説

これらをダウンロードしてRAGにする

まず以前wikipediaから一部記事をダウンロードしRAGにした

/aw10s/ollama/save_index_test.py

のコード

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("これで、次回からはこのインデックスを再利用できます。")

これを改造して
wikipedia のページからではなく

data/
  school/
    shizensaigai.pdf
    他の学校関連PDF
  insurance/
    自動車保険.pdf
    県民共済.pdf
    他の保険PDF
  manuals/
    洗濯機.pdf
    冷蔵庫.pdf
    他の家電PDF

のRAGインデックスを作成するようにする

なお
llama-index-ocr
も考えたけど
https://x.com/alexweberk/status/1858316517668249755
によれば
2024年9月時点だと日本語はダメっぽい

とりあえず実験する

GPTによれば

pip install llama-index llama-index-pdf

となっているけど
そんなパッケージは存在しない…

 pip install llama-index llama-index-pdf

Collecting llama-index
  Downloading llama_index-0.12.38-py3-none-any.whl.metadata (12 kB)
ERROR: Could not find a version that satisfies the requirement llama-index-pdf (from versions: none)

[notice] A new release of pip is available: 24.3.1 -> 25.1.1
[notice] To update, run: pip install --upgrade pip
ERROR: No matching distribution found for llama-index-pdf

となる

pip install llama-index-readers-file

が正解
これは geminiの回答

次にいくつか疑問点を解決していく

    │ ├── shizensaigai.pdf
│ └── other_school_document.pdf
├── insurance/
│ ├── 自動車保険.pdf
│ └── 県民共済.pdf
└── manuals/
├── 洗濯機.pdf
└── 冷蔵庫.pdf

のPDFファイル名はインデックスデータの精度に関係するか?
例:家電のマニュアルが製品の型番、例えば001.pdf だった場合など

これに関しては
ファイル名自体がインデックスデータの精度に直接的な影響を与えることは、ほとんどありません。
LlamaIndexのようなRAGシステムでは、インデックスを作成する際にPDFファイルの中身(テキストコンテンツ)を読み込み、それをチャンクに分割し、埋め込みモデルがそのチャンクのテキストからベクトル表現を生成します。このベクトル表現が、検索時の関連度計算の基礎となります。
ファイル名が精度に間接的に影響する可能性
しかし、ファイル名が間接的に、そして限定的ながら影響を与える可能性はあります。
1. メタデータとしての利用: もし、あなたがRAGシステムを高度にカスタマイズし、PDFファイル名からメタデータを抽出してノードに追加するような処理を実装した場合、そのメタデータは検索の精度に影響を与える可能性があります。 例えば、「洗濯機.pdf」というファイル名から「製品カテゴリ:家電、製品名:洗濯機」といったメタデータを自動生成し、ノードに付与するとします。ユーザーが「家電の修理方法」と質問した際に、このメタデータを使って関連ドキュメントを絞り込むことができます。 ただし、これはLlamaIndexのデフォルトの動作ではなく、カスタムで実装が必要です。
2. 可読性と管理: これは直接的な精度とは異なりますが、ファイル名が「001.pdf」のような意味のないものだと、システムを管理する側がどのファイルが何を指しているのか把握しにくくなります。デバッグやドキュメントの追加・削除の際に、非効率的になる可能性があります。

結論
基本的には、shizensaigai.pdf や 001.pdf のようなファイル名の違いは、インデックス作成後のRAGの検索精度や回答精度に直接的な影響を与えません。 RAGの精度は、主に以下の要素によって決まります。
* PDFファイルの内容: 情報の質、網羅性、テキストのクリーンさ。
* 埋め込みモデルの性能: テキストをどれだけ適切にベクトル化できるか。
* チャンク化の戦略: チャンクサイズ、オーバーラップ、セパレーターの選び方。
* LLMの性能: 検索されたコンテキストを理解し、クエリに適切に回答する能力。
ですので、製品の型番や具体的な内容がわかるようなファイル名(例: 洗濯機_ABC123.pdf)にしておくことは、人間がファイルを管理する上での利便性には繋がりますが、RAGシステムの内部的な精度向上に劇的に貢献するわけではありません。

とのこと

後々、参照元のPDFを回答の根拠として表示する場合には影響するか?
については

その場合はファイル名が非常に重要になります。
LlamaIndexのようなRAGシステムでは、最終的な回答を生成する際に、どのドキュメント(チャンク)から情報が取得されたかを示す「参照元(ソース)」情報を提供できます。この参照元情報には、通常、以下のものが含まれます。
* ファイル名: どのPDFファイルから情報が得られたか。
* ページ番号: PDFの何ページ目から情報が得られたか(PDFReaderが対応している場合)。
* チャンクのテキスト: 実際に参照されたテキストスニペット。
ファイル名が「参照元のPDFを回答の根拠として表示する」際に影響する理由
1. ユーザーへの視認性・理解度:
* 分かりやすい例: ユーザーが「洗濯機の操作方法について」質問し、回答とともに参照元として「洗濯機_ABC123.pdf」と表示されれば、ユーザーはすぐに「これは私が持っている洗濯機のマニュアルだ」と理解し、その根拠を信頼できます。
* 分かりにくい例: もし参照元が「001.pdf」と表示された場合、ユーザーはそれがどのドキュメントなのか全く分かりません。自分でファイルを探し出して内容を確認することが困難になります。
2. 根拠の信頼性:
* ユーザーが回答の正確性を検証したい場合、ファイル名が具体的であれば、そのファイルを直接開いて確認できます。抽象的なファイル名では、確認作業が非常に煩雑になります。
3. デバッグとシステム管理:
* RAGシステムを開発・運用する側にとっても、どの回答がどのソースドキュメントに由来しているのか、ファイル名を見るだけで一目瞭然であることは非常に重要です。問題が発生した場合の原因特定や、特定のドキュメントの更新・削除などの管理が容易になります。
対策と推奨されるファイル名
参照元の表示を考慮すると、PDFファイル名は以下の点を満たすようにすることをお勧めします。
* 内容を簡潔に表す: そのファイルに何が書かれているのかが、ファイル名からある程度推測できること。
* 具体的な情報を含める: 製品名、型番、バージョン、日付など、ドキュメントを一意に特定できる情報を含めること。
* 日本語名でもOK: LlamaIndexは日本語のファイル名を問題なく扱えます。
良い例:
* shizensaigai_防災ガイド_2023年版.pdf
* 自動車保険_ABC社_約款_2024.pdf
* Panasonic_洗濯機_NA-VX9000_取扱説明書.pdf
* 冷蔵庫_三菱_MR-B46F_マニュアル.pdf
悪い例:
* doc1.pdf
* file.pdf
* image.pdf
* 123.pdf
コード上の考慮点
LlamaIndexのSimpleDirectoryReaderやPDFReaderは、読み込んだドキュメントにファイルパス(したがってファイル名)をメタデータとして自動的に付与します。このメタデータは、後でクエリエンジンからソースノードを取得する際に利用可能です。
例えば、クエリ結果からソース情報を取得する際には、以下のようにアクセスできます。

# Assuming 'response' is the object returned by query_engine.query(query_text)
for source_node in response.source_nodes:
    file_name = source_node.metadata.get('file_name')
    page_label = source_node.metadata.get('page_label') # ページ番号
    text = source_node.text # チャンクのテキスト

    print(f"参照元ファイル: {file_name} (ページ: {page_label or 'N/A'})")
    print(f"関連テキスト: {text[:200]}...") # 関連するテキストの一部

したがって、参照元の表示を考えている場合は、PDFファイル名に意味を持たせるように命名規則を設けることが非常に重要

つまりファイル名を家電や約款の場合ダウンロードしてからわかりやすい名前に変更する必要がある

 mkdir rag_data

ここにファイルをまとめる

cd rag_data 
mkdir school insurance manuals

Mkdirコマンドでスペースで区切れば複数フォルダを一度に作成可能

cp ~/Downloads/取扱説明書/* manuals 
cp ~/Downloads/保険/* insurance    
cp ~/Downloads/School/shizensaigai.pdf school
mv shizensaigai.pdf 自然災害発生時、警報発表・避難情報発表時等に伴う学校の対処.pdf

でファイル名を変更しておく

また manualsの中の家電ごとのファイル名も変更しておく

cd ../manuals 
mv axha20_mn.pdf ウォーターオーブンAX-HA20.pdf
mv np_tr8.pdf 食洗機NP-TR8.pdf
mv MCK70ZKS-T_取扱説明書_3P698812-3E.pdf 空気清浄機MCK70ZKS-W.pdf

とりあえずこれくらいにして実験を開始

なお
elyza8bで作成したRAGデータを他のモデルで使用するとどうなるか?
に対しては
elyza8b (LLM) で作成したRAGデータを他のLLMで使用することは、一般的に問題なく可能です。RAGのデータ保存とLLMの利用は、それぞれ異なる役割を持つ
とのこと

RAGシステムにおけるLLMの役割
RAG (Retrieval Augmented Generation) システムは、大きく以下の2つの主要なコンポーネントに分けられます。
1. Retrieval (検索) フェーズ:
* 埋め込みモデル (Embedding Model): ドキュメントのテキストやクエリのテキストを数値ベクトル(埋め込みベクトル)に変換します。このベクトルが類似度計算に使用され、関連性の高いドキュメントを検索します。
* ベクトルデータベース/インデックス: 生成された埋め込みベクトルを保存し、クエリベクトルとの類似度に基づいて関連ドキュメントを効率的に検索します。
2. Generation (生成) フェーズ:
* LLM (Large Language Model): 検索フェーズで取得された関連ドキュメントのコンテキスト情報とユーザーのクエリを受け取り、それに基づいて最終的な回答を生成します。
elyza8bで作成したRAGデータとは?
ご質問の「elyza8bで作成したRAGデータ」という文脈では、おそらく以下の部分を指していると考えられます。
* VectorStoreIndex (ベクトルストアインデックス): これは、ドキュメントのテキストを埋め込みモデル(この場合は intfloat/multilingual-e5-large)がベクトル化したものを保存したものです。elyza8b (LLM) は、このインデックスの作成には直接関与していません。LLMは、RAGの「生成」フェーズで使われるものです。
つまり、elyza8bはRAGの「生成」フェーズで使われるLLMであり、RAGデータの「作成」には関与していません。RAGデータ(VectorStoreIndex)は埋め込みモデルによって作成されます。
埋め込みモデルとLLMの分離
* 埋め込みモデル: intfloat/multilingual-e5-large このモデルがドキュメントのテキストをベクトルに変換し、そのベクトルがVectorStoreIndexに保存されます。この埋め込みモデルを変更しない限り、RAGデータ(VectorStoreIndex)の構造や内容は変わりません。
* LLM: elyza8b このモデルは、ユーザーのクエリと検索されたコンテキストを受け取って最終的な回答を生成します。
elyza8bで作成したRAGデータを他のLLMで使用するとどうなるか?
「elyza8bで作成したRAGデータ」が、実際にはintfloat/multilingual-e5-largeという埋め込みモデルによって生成されたVectorStoreIndexを指していると仮定します。
このVectorStoreIndexは、特定のLLM(elyza8b)とは独立したデータです。したがって、このRAGデータ(VectorStoreIndex)を、異なるLLM(例: gemma:2b、gpt-3.5-turbo、mixtralなど)と組み合わせて使用することは全く問題ありません。

LLMを変更することで、RAGシステムの回答生成の質が変わります。
1. 回答のスタイルと質:
* 自然さ、流暢さ: 新しいLLMの言語生成能力に依存します。例えば、gemma:2bは高速ですが、elyza8bと比較して日本語の自然さや文脈理解に差が出る可能性があります(特に日本語に特化していない場合)。
* 正確性: LLMが提供されたコンテキスト情報をどれだけ正確に理解し、それに基づいて回答を生成できるかに影響します。推論能力の高いLLMであれば、より適切な回答が期待できます。
* 情報統合能力: 検索された複数の関連チャンクをどれだけうまく統合し、矛盾のない回答を生成できるか。
* 冗長性: 回答が長すぎたり短すぎたりするか。
* トーン: 回答のトーン(フォーマル、カジュアルなど)が変わる可能性があります。
2. 推論速度:
* LLMのモデルサイズやアーキテクチャによって、回答生成にかかる時間が大きく変わります。gemma:2bのような小型モデルに変更すれば、推論速度が向上する可能性が高いです。
3. リソース消費:
* LLMを変更すると、VRAM使用量やCPU使用率が変わります。小型のLLMに変更すれば、必要なリソースが減り、より効率的に動作する可能性があります。
4. ハルシネーション(幻覚)の傾向:
* LLMによって、提供されたコンテキストから逸脱した情報を生成する「ハルシネーション」の傾向が変わります。一般的に、より高性能なLLMほどハルシネーションが少ないとされますが、これはモデルの特性やチューニングにも依存します。
まとめ
* elyza8bで「作成」されたRAGデータ(VectorStoreIndex)は、実際には埋め込みモデルによって作成されており、LLMとは独立しています。
* このRAGデータは、他の任意のLLMと組み合わせて使用できます。
* LLMを変更すると、RAGシステムの回答生成の質、スタイル、推論速度、リソース消費が変わります。
* 異なるLLMを試すことで、ご自身の用途や環境に最適なRAGシステムを構築できる可能性があります。

あと
ファイル名自体がインデックスデータの精度に直接的な影響を与えることは、ほとんどありません

しかし、ファイル名が間接的に、そして限定的ながら影響を与える可能性はあります。
1. メタデータとしての利用: もし、あなたがRAGシステムを高度にカスタマイズし、PDFファイル名からメタデータを抽出してノードに追加するような処理を実装した場合、そのメタデータは検索の精度に影響を与える可能性があります。 例えば、「洗濯機.pdf」というファイル名から「製品カテゴリ:家電、製品名:洗濯機」といったメタデータを自動生成し、ノードに付与するとします。ユーザーが「家電の修理方法」と質問した際に、このメタデータを使って関連ドキュメントを絞り込むことができます。 ただし、これはLlamaIndexのデフォルトの動作ではなく、カスタムで実装が必要です。
2. 可読性と管理: これは直接的な精度とは異なりますが、ファイル名が「001.pdf」のような意味のないものだと、システムを管理する側がどのファイルが何を指しているのか把握しにくくなります。デバッグやドキュメントの追加・削除の際に、非効率的になる可能性があります。

とりあえず問題はなさそうなので
実際に実験を開始する

 touch create_rag_data.py

でファイルを作成

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.core import SimpleDirectoryReader
from llama_index.core.node_parser import TokenTextSplitter
from llama_index.core import VectorStoreIndex
import os

# --- 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. データのロード ---
# 指定されたディレクトリからPDFファイルをロード
print("指定されたディレクトリからPDFファイルをロード中...")
base_data_dir = "./data"
sub_dirs = ["school", "insurance", "manuals"]
all_documents = []

for sub_dir in sub_dirs:
    dir_path = os.path.join(base_data_dir, sub_dir)
    if os.path.exists(dir_path) and os.path.isdir(dir_path):
        print(f"ディレクトリ '{dir_path}' からPDFを読み込み中...")
        # PDFReaderはllama-index-readers-fileに含まれています
        reader = SimpleDirectoryReader(input_dir=dir_path, required_exts=[".pdf"])
        documents = reader.load_data()
        all_documents.extend(documents)
        print(f"'{dir_path}' から {len(documents)} ページのドキュメントをロードしました。")
    else:
        print(f"警告: ディレクトリ '{dir_path}' が見つからないか、ディレクトリではありません。スキップします。")

print(f"合計 {len(all_documents)} ページのドキュメントをロードしました。")

# ロードされたドキュメントがない場合は処理を終了
if not all_documents:
    print("ロードされたドキュメントがありません。インデックスの作成をスキップします。")
else:
    # --- 3. ドキュメントのチャンク化とノード化 ---
    # 読み込んだドキュメントをチャンクに分割し、ノードとして処理
    print("ドキュメントをノードに分割中...")
    splitter = TokenTextSplitter(
        chunk_size=1000,
        chunk_overlap=100,
        separator=" ",
    )
    nodes = splitter.get_nodes_from_documents(all_documents)
    print(f"合計 {len(nodes)} 個のノードが生成されました。")

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

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

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

として保存
しかし実行時にエラー
フォルダ名が
Dataではなく rag_dataだったので修正

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.core import SimpleDirectoryReader
from llama_index.core.node_parser import TokenTextSplitter
from llama_index.core import VectorStoreIndex
import os

# --- 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. データのロード ---
# 指定されたディレクトリからPDFファイルをロード
print("指定されたディレクトリからPDFファイルをロード中...")
base_data_dir = "./rag_data"
sub_dirs = ["school", "insurance", "manuals"]
all_documents = []

for sub_dir in sub_dirs:
    dir_path = os.path.join(base_data_dir, sub_dir)
    if os.path.exists(dir_path) and os.path.isdir(dir_path):
        print(f"ディレクトリ '{dir_path}' からPDFを読み込み中...")
        # PDFReaderはllama-index-readers-fileに含まれています
        reader = SimpleDirectoryReader(input_dir=dir_path, required_exts=[".pdf"])
        documents = reader.load_data()
        all_documents.extend(documents)
        print(f"'{dir_path}' から {len(documents)} ページのドキュメントをロードしました。")
    else:
        print(f"警告: ディレクトリ '{dir_path}' が見つからないか、ディレクトリではありません。スキップします。")

print(f"合計 {len(all_documents)} ページのドキュメントをロードしました。")

# ロードされたドキュメントがない場合は処理を終了
if not all_documents:
    print("ロードされたドキュメントがありません。インデックスの作成をスキップします。")
else:
    # --- 3. ドキュメントのチャンク化とノード化 ---
    # 読み込んだドキュメントをチャンクに分割し、ノードとして処理
    print("ドキュメントをノードに分割中...")
    splitter = TokenTextSplitter(
        chunk_size=1000,
        chunk_overlap=100,
        separator=" ",
    )
    nodes = splitter.get_nodes_from_documents(all_documents)
    print(f"合計 {len(nodes)} 個のノードが生成されました。")

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

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

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

これで再度実行
5:12 からスタート

python create_rag_data.py
LLMと埋め込みモデルの設定が完了しました。
指定されたディレクトリからPDFファイルをロード中...
ディレクトリ './rag_data/school' からPDFを読み込み中...
'./rag_data/school' から 4 ページのドキュメントをロードしました。
ディレクトリ './rag_data/insurance' からPDFを読み込み中...
Impossible to decode XFormObject /Fm0: Invalid Elementary Object starting with b'\x80' @0: b'\x80'
Impossible to decode XFormObject /Fm1: Invalid Elementary Object starting with b'\x81' @0: b'\x81'
Impossible to decode XFormObject /Fm0: Invalid Elementary Object starting with b'\x9a' @0: b'\x9a'
Impossible to decode XFormObject /Fm1: Invalid Elementary Object starting with b'\x97' @0: b'\x97'
Impossible to decode XFormObject /Fm0: Invalid Elementary Object starting with b'\x80' @0: b'\x80'
Impossible to decode XFormObject /Fm1: Invalid Elementary Object starting with b'\xc4' @0: b'\xc4'
Impossible to decode XFormObject /Fm0: Invalid Elementary Object starting with b'\xc7' @0: b'\xc7'
Impossible to decode XFormObject /Fm1: Invalid Elementary Object starting with b'\xb1' @0: b'\xb1'
Impossible to decode XFormObject /Fm0: Invalid Elementary Object starting with b'\xd7' @0: b'\xd7'
Impossible to decode XFormObject /Fm1: Invalid Elementary Object starting with b'\x8e' @0: b'\x8e'
Impossible to decode XFormObject /Fm0: Invalid Elementary Object starting with b'\xc8' @0: b'\xc8'
Impossible to decode XFormObject /Fm1: Invalid Elementary Object starting with b'\x99' @0: b'\x99'
'./rag_data/insurance' から 322 ページのドキュメントをロードしました。
ディレクトリ './rag_data/manuals' からPDFを読み込み中...
Failed to load file /Users/snowpool/aw10s/ollama/rag_data/manuals/ウォーターオーブンAX-HA20.pdf with error: RetryError[<Future at 0x367318710 state=finished raised DependencyError>]. Skipping...
Failed to load file /Users/snowpool/aw10s/ollama/rag_data/manuals/食洗機NP-TR8.pdf with error: RetryError[<Future at 0x368388250 state=finished raised DependencyError>]. Skipping...
'./rag_data/manuals' から 48 ページのドキュメントをロードしました。
合計 374 ページのドキュメントをロードしました。
ドキュメントをノードに分割中...
合計 1180 個のノードが生成されました。
VectorStoreIndexを作成中...
Generating embeddings: 100%|████████████████| 1180/1180 [05:33<00:00,  3.54it/s]
VectorStoreIndexの作成が完了しました。
インデックスを './storage/my_local_pdfs' に ID 'my_local_pdfs' で保存中...
インデックスの保存が完了しました。
これで、次回からはこのインデックスを再利用できます。

実行終了まで約7分

次に回答が出るかテストする

touch chat_with_rag.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と埋め込みモデルの設定 ---
# インデックス作成時と同じモデルを設定する必要があります。
# これにより、クエリのベクトル化と回答生成が正しく行われます。
print("LLMと埋め込みモデルの設定を開始します...")
try:
    Settings.llm = Ollama(model="elyza:jp8b", request_timeout=360.0)
    # 日本語性能の高い埋め込みモデルを設定
    Settings.embed_model = HuggingFaceEmbedding(model_name="intfloat/multilingual-e5-large")
    print("LLMと埋め込みモデルの設定が完了しました。")
except Exception as e:
    print(f"LLMまたは埋め込みモデルの設定中にエラーが発生しました: {e}")
    print("Ollamaサーバーが起動しており、'elyza:jp8b'モデルがダウンロードされているか確認してください。")
    sys.exit(1)

# --- 2. インデックスのロード ---
print("インデックスをロード中...")
try:
    # 保存先のディレクトリとインデックスIDを指定
    # 以前のインデックス保存時のパスとIDに合わせる必要があります。
    persist_dir = "./storage/my_local_pdfs" # あなたのインデックス保存パスに修正
    index_id = "my_local_pdfs"             # あなたのインデックスIDに修正

    # 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--------------------------------------------------")
    print("質問を入力してください(終了するには 'exit' と入力)。")
    print("--------------------------------------------------")
    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.response) # .response をつけて回答テキストのみを出力
            print("------------\n")
        except Exception as e:
            print(f"質問処理中にエラーが発生しました: {e}")
            print("もう一度お試しください。")

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

これを実行すると

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

--------------------------------------------------
質問を入力してください(終了するには 'exit' と入力)。
--------------------------------------------------
あなたの質問: 台風の時の対処
回答を生成中...

--- 回答 ---
暴風警報や特別警報が発表された場合、下校後は放課後児童クラブに登所し、状況に応じて速やかな迎えを依頼し、安全を確認した後保護者への引き渡しが行われます。
------------

あなたの質問: 大雨警報の時は
回答を生成中...

--- 回答 ---
原則として登校します。
------------

あなたの質問: 保険金の支払いはいつ?
回答を生成中...

--- 回答 ---
請求完了日からその日を含めて30日以内に、自転車賠償保険金が支払われます。
------------

あなたの質問: エアコンから異音がする
回答を生成中...

--- 回答 ---
運転中、「シュー」や「ヒュー」という音がする場合、ストリーマの運転中はストリーマ放電の音であり、異常ではありません。また、ご使用環境により、音が小さくなったり、音質が変わることがありますが、異常ではありません。
------------

あなたの質問: 食洗機の乾燥時間は?
回答を生成中...

--- 回答 ---
There is no mention of食洗機 or乾燥時間 in the provided context information.
------------

あなたの質問: 対物の金額
回答を生成中...

--- 回答 ---
対物保険金額とは、同一事故につき既に当社が支払った対物賠償保険金または第8条(損害賠償請求権者の直接請求権)の損害賠償額がある場合、その全額を差し引いた額をいいます。
------------

あなたの質問: 対物の補償金額は?
回答を生成中...

--- 回答 ---
対物保険金額とは、同一事故につき既に当社が支払った対物賠償保険金または第8条(損害賠償請求権者の直接請求権)の損害賠償額がある場合、その全額を差し引いた額をいいます。
------------

あなたの質問: 生命 次回の保険の更新日は?
回答を生成中...

--- 回答 ---
この保険の更新日を特定する情報が提供されていないため、次回の保険の更新日はわかりません。
------------

あなたの質問: 保険期間は?
回答を生成中...

--- 回答 ---
保険期間が1年未満の場合、保険期間の日数を365で除した割合を乗じて保険料を算出したものと記載されています。
------------

あなたの質問: 自動車保険の保険期間は?
回答を生成中...

--- 回答 ---
「条」の中に保険期間に関する規定があるため、自動車保険の保険期間は始期と終期があります。始期は記名被保険者の管理下に入った時もしくは保険契約の始期、終期は保険契約の終期です。
------------

あなたの質問: 登録番号
回答を生成中...

--- 回答 ---
登録番号とは、自動車や交通乗用具が持つ一意に識別される番号を指します。
------------

あなたの質問: 自動車保険の内容で仕様について
回答を生成中...

--- 回答 ---
自家用8車種は、以下のいずれかに該当する自動車をいう。
① 自家用普通乗用車
② 自家用小型乗用車
③ 自家用軽四輪乗用車
④ 自家用普通貨物車 (最大積載量0.5 トン超2 トン以下)
⑤ 自家用普通貨物車 (最大積載量0.5 トン以下)
⑥ 自家用小型貨物車
⑦ 自家用軽四輪貨物車
⑧ 特種用途自動車
------------

あなたの質問: 自動車保険証の中で仕様を知りたい
回答を生成中...

--- 回答 ---
自動車の用途や種類は、当社が定める区分表によるものとし、例えば登録番号標や車両番号標の分類番号や塗色に基づいて区分しています。
------------

実験結果からPDFの単純なものはすぐに出る
例:学校のPDF
しかし保険証券のように少し複雑になると途端に精度が下がる

Gemma3 4b + faster-whisperのコード公開

Gemma3 4b + faster-whisperのコード公開

音声でやり取りできるようにしたのでコードを公開する

まずリポジトリ公開のため作成する
とりあえず voice_llm としておく

いくつか忘れているのでメモ
ライセンスはMITにして
Readme も最初に作成しておく

mkdir github_open
cd github_open 


これから公開ずみコードはここへおく

git clone git@github.com:Snowpooll/voice_llm.git

でリポジトリコピー

cd voice_llm

あとはファイルをコピーしていく

cp -rp ../../gemma/configs .

でfaster-whisper の設定ファイル

cp -rp ../../gemma/module .

でモジュール関連
なおウェイクワード関連は未完成なので削除する

rm module/module_wakeword*
rm -r module/__pycache__

メイン処理部分のコピーとリネーム

cp -rp ../../gemma/main5.py .
 mv main5.py main.py         

必要なものはコピーできたので
次に requestment.txt の作成

from module.module_audio_to_text import AudioToTextCorrector
from module.module_speaker import Speaker
from ollama import chat, ChatResponse
import time
from module.module_whisper import FasterWhisperModel
from module.module_recorder import Recorder
from ollama_text_correction import OllamaTextCorrector
import numpy as np
import sounddevice as sd

import os
import configparser
# ファイルの存在チェック用モジュール
import errno
import pyttsx3
import emoji
import re
from faster_whisper import WhisperModel
import numpy as np
import torch

import os
import configparser
# ファイルの存在チェック用モジュール
import errno

これらを使えるように
requirements.txt
を作成する

touch requirements.txt

内容は

ollama
numpy
sounddevice
faster-whisper
pyttsx3
emoji
torch
configparser

| ライブラリ名           | 説明                               |
| ---------------- | -------------------------------- |
| `ollama`         | ローカルで実行可能なLLMを利用するためのPythonパッケージ |
| `numpy`          | 数値演算を効率よく行うためのライブラリ              |
| `sounddevice`    | オーディオ入力・録音機能のためのライブラリ            |
| `faster-whisper` | Whisperモデルの高速音声認識ライブラリ           |
| `pyttsx3`        | テキスト読み上げ(TTS)ライブラリ(オフライン動作可)     |
| `emoji`          | 絵文字を扱うためのライブラリ                   |
| `torch`          | PyTorch機械学習フレームワーク(Whisperで必要)   |
| `configparser`   | 設定ファイル(.ini形式)を扱うためのライブラリ        |

これで

pip install -r requirements.txt

でモジュールの一括インストールが可能

次に

vim .gitignore

で除外するファイルを設定

なお
README.md や LICENSE はプロジェクトの利用方法やライセンス情報を示すために GitHubなどに必ず公開するべきファイル です。
これらを .gitignore に書いてしまうと、Gitリポジトリに追加されず、外部に公開できなくなります。
とのこと

# OS固有の隠しファイル
.DS_Store

# Pythonキャッシュ関連
__pycache__/
*.py[cod]
*$py.class

を追記して保存

git add .
git commit -m "add source code"

あとは

git push origin main

でsshキーのパスワードを入れればOK

これで
https://github.com/Snowpooll/voice_llm#

コードが公開された

あとは readme の作成