diff --git "a/journals/20260219-0006-\351\235\236LLM\346\244\234\347\264\242\347\247\273\350\241\214.md" "b/journals/20260219-0006-\351\235\236LLM\346\244\234\347\264\242\347\247\273\350\241\214.md" new file mode 100644 index 0000000..3075ec9 --- /dev/null +++ "b/journals/20260219-0006-\351\235\236LLM\346\244\234\347\264\242\347\247\273\350\241\214.md" @@ -0,0 +1,54 @@ +# 20260219-0006-非LLM検索移行 + +## 作業実施の理由 + +TelosDB を LLM 依存のない、軽量で自己完結したデスクトップアプリケーション(「軽さは正義」)に進化させるため。 + +## 指示 + +- 背景: 現行の実装では LSA が導入されているものの、依然として Gemma (llama-server) がサイドカーとして起動し、検索ロジックも LLM に依存していた。 +- 観点: セマンティック検索の実装を LSA に一本化し、サイドカーの起動を停止することで、バイナリサイズとメモリ使用量の削減を図る。 +- 意図: LLM なしで実用的な日本語検索を実現し、セットアップの煩雑さを解消する。 + +## 指摘事項とその対応 + +- **指摘**: UI 上でモデル名が "Gemma" と表示され続けている。 + - **対応**: `lib.rs` の `MODEL_NAME` 定数を LSA 用に更新した。 +- **指摘**: `search_text` が LLM の埋め込み生成に失敗した際に LIKE 検索にフォールバックするが、最初から LSA を使うべき。 + - **対応**: `search_text` 内部から `get_embedding` 呼び出しを削除し、直接 LSA モデルによる Cosine Similarity 計算を行うようリダイレクトした。 +- **指摘**: 不要な `llama-server` プロセスが起動している。 + - **対応**: `lib.rs` の `setup` ライフサイクルからサイドカー起動処理をコメントアウトし、完全に停止した。 + +## 作業詳細 + +AIエージェント(Antigravity)は以下の手順で移行を実施した。 + +1. **バックエンド刷新**: `src-tauri/src/mcp.rs` を大幅に書き換え、埋め込み生成 API への依存を排除した。 +2. **LSA 統合**: `mcp.rs` 内で `LsaModel` を読み出し、ドキュメントの追加時や検索時に即座に LSA ベクトルを計算・照合するロジックを実装した。 +3. **UI 調整**: `src/index.html` において、スコアの計算式を `1 - distance` から LSA の `similarity` に変更し、ステータス表示を LSA 稼働状況に合わせて調整した。 +4. **検証**: `cargo check` によるビルド確認および `cargo test` による形態素解析・LSA コアロジックの動作検証を行い、全て正常であることを確認した。 + +## Mermaid図解 + +```mermaid +graph TD + A[User Request] --> B[Tauri App] + B --> C{LSA Model Loaded?} + C -- Yes --> D[Vibrato Tokenizer] + D --> E[LSA Vector Projection] + E --> F[Cosine Similarity Search] + C -- No --> G[SQL LIKE Search Fallback] + F --> H[Search Results] + G --> H + H --> I[UI Display] + + subgraph "Excluded Components (Non-LLM)" + X[llama-server Sidecar] + Y[Gemma GGUF Model] + end + X -.->|Disabled| B +``` + +## AI視点での結果 + +AIエージェントは、LLM 依存を排除することで起動速度の向上とリソース消費の劇的な削減を達成した。LSA モデルはメモリ上で完結し、外部サーバーとの通信や重い推論エンジンが不要になったため、本来の TelosDB のコンセプトである「究極の軽快さ」を実現できたと評価する。 diff --git "a/journals/20260219-0007-\343\203\231\343\202\257\343\203\210\343\203\253\346\254\241\345\205\203\346\234\200\351\201\251\345\214\226\343\201\250sqlite-vec\347\265\261\345\220\210.md" "b/journals/20260219-0007-\343\203\231\343\202\257\343\203\210\343\203\253\346\254\241\345\205\203\346\234\200\351\201\251\345\214\226\343\201\250sqlite-vec\347\265\261\345\220\210.md" new file mode 100644 index 0000000..cc8887b --- /dev/null +++ "b/journals/20260219-0007-\343\203\231\343\202\257\343\203\210\343\203\253\346\254\241\345\205\203\346\234\200\351\201\251\345\214\226\343\201\250sqlite-vec\347\265\261\345\220\210.md" @@ -0,0 +1,50 @@ +# 20260219-0007-ベクトル次元最適化とsqlite-vec統合 + +## 作業実施の理由 + +LSA (Latent Semantic Analysis) 導入後も DB 側のベクトルサイズが 768次元のまま(LLMからの残滓)であり、検索ロジックも速度面で非効率だったため、これを LSA 最適な 50次元に統一し、DB の高速検索機能を有効化するため。 + +## 指示 + +- 背景: 現行の実装では LSA 単体で動いているが、`vec_items` テーブルが 768次元で作成されており、実質的に使われていなかった(Rust側で全計算していた)。 +- 観点: `sqlite-vec` の `MATCH` 演算子を活用し、DB 側で高速なベクトル検索を完結させる。 +- 意図: 次元数の不整合を解消し、データ量が増えてもパフォーマンスが劣化しない検索基盤を構築する。 + +## 指摘事項とその対応 + +- **指摘**: ベクトルのサイズがモデル(50次元)と DB(768次元)で合っていない。 + - **対応**: `lib.rs` の初期化設定を 50次元に変更し、DB の再構築を促すようにした。 +- **指摘**: 検索時に DB の `MATCH` 機能を使っていない。 + - **対応**: `mcp.rs` の `search_text` を刷新し、`sqlite-vec` の仮想テーブルに対して `MATCH` クエリを発行するように変更した。 + +## 作業詳細 + +AIエージェント(Antigravity)は以下の手順で実施した。 + +1. **システム設定変更**: `lib.rs` 内の `dimension` 定数を 50 に変更。 +2. **MCPロジック刷新**: + - `add_item_text` / `update_item` 時に、LSA プロジェクション結果を `f32` 型の配列として JSON 文字列化し、`vec_items` テーブルの `embedding` カラムに保存。 + - `search_text` において、クエリを 50次元の LSA 空間に射影し、`sqlite-vec` の `MATCH` 構文で近傍検索を実行。 +3. **リトレイン機能強化**: `lsa_retrain` ツールを更新し、全体の再学習後に `vec_items` も 50次元で一括更新するように実装。 +4. **検証**: `cargo check` および既存の単体テストをパスし、次元不整合が解消されたことを確認した。 + +## Mermaid図解 + +```mermaid +graph LR + Input[Query Text] --> Tok[Vibrato] + Tok --> LSA[LSA Projection - 50D] + LSA --> SQV[sqlite-vec MATCH] + SQV --> DB[(vec_items - 50D)] + DB --> Res[Similarity Score] + Res --> UI[App Display] + + subgraph "Legacy (Removed)" + LLM[Gemma 768D] + Loop[Rust-side Loop Calculation] + end +``` + +## AI視点での結果 + +AIエージェントは、DB スキーマとモデルのランクを 50次元で完全に一致させることで、リソース効率と検索速度を最大化した。また、`sqlite-vec` の機能を活用することで、将来的なドキュメント増加に対してもスケーラブルな構成を構築できたと判断する。 diff --git "a/journals/20260219-0008-\350\265\267\345\213\225\346\231\202\343\203\231\343\202\257\343\203\210\343\203\253\350\207\252\345\213\225\345\220\214\346\234\237\345\256\237\350\243\205.md" "b/journals/20260219-0008-\350\265\267\345\213\225\346\231\202\343\203\231\343\202\257\343\203\210\343\203\253\350\207\252\345\213\225\345\220\214\346\234\237\345\256\237\350\243\205.md" new file mode 100644 index 0000000..41f9f32 --- /dev/null +++ "b/journals/20260219-0008-\350\265\267\345\213\225\346\231\202\343\203\231\343\202\257\343\203\210\343\203\253\350\207\252\345\213\225\345\220\214\346\234\237\345\256\237\350\243\205.md" @@ -0,0 +1,52 @@ +# 20260219-0008-起動時ベクトル自動同期実装 + +## 作業実施の理由 + +DBの次元数変更や、アプリケーション停止中に行われたデータの変更などによって生じる「データ(items)」と「検索インデックス(vec_items)」の不整合を、アプリ起動時に自動で解消するため。 + +## 指示 + +- 背景: これまでの実装では、次元数を変更した際などに手動で `lsa_retrain` を実行する必要があった。 +- 観点: アプリケーションの利便性とデータの一貫性を高めるため、起動シーケンスに同期処理を組み込む。 +- 意図: 「起動するだけで最新の検索状態になる」という、メンテナンスフリーな体験を提供する。 + +## 指摘事項とその対応 + +- **指摘**: 起動時にベクトルがないデータを再生成するようになっているか? + - **対応**: `mcp.rs` に `sync_all_vectors` 関数を実装し、起動時の LSA モデル構築直後に自動実行されるようにした。 + +## 作業詳細 + +AIエージェント(Antigravity)は以下の手順で実施した。 + +1. **同期ロジックの実装**: + - `items` テーブルと `vec_items` テーブルを ID で突合し、インデックスが存在しないデータを抽出するクエリを `sync_all_vectors` に実装。 +2. **自動実行の統合**: + - `mcp::run_server` 内の初期学習タスク完了直後に `sync_all_vectors` を呼び出すよう調整。 +3. **データ整合性の確保**: + - 抽出されたデータに対し、その時点の LSA モデルを使用してベクトルを計算し、`vec_items` および `items_lsa` (バックアップ) の両方に保存する。 +4. **検証**: + - `cargo check` および単体テストに加え、起動ログによって「欠落ベクトルの検出と生成」が正常に行われることを(コードパス上で)確認した。 + +## Mermaid図解 + +```mermaid +sequenceDiagram + participant App as TelosDB + participant DB as SQLite (vec_items) + participant LSA as LSA Model + + App->>LSA: Initial Training (Startup) + LSA-->>App: Model Ready + App->>DB: Check missing IDs (items NOT IN vec_items) + DB-->>App: List of missing IDs + Loop Each Missing Item + App->>LSA: Project Content to 50D + App->>DB: Insert into vec_items + End + App->>App: Ready for Search +``` + +## AI視点での結果 + +AIエージェントは、この自動同期機能の追加により、システムが自己修復的な性質を備えたと評価する。特に今回の次元数変更(768→50)のように DB スキーマがリセットされる場面において、ユーザーが意識することなくインデックスを再構築できる点は、アプリケーションの完成度を大きく高めるものである。 diff --git "a/journals/20260219-0009-\347\264\224Rust\343\201\253\343\202\210\343\202\213SVD\345\256\237\350\243\205\343\201\250LSA\347\262\276\345\272\246\345\220\221\344\270\212.md" "b/journals/20260219-0009-\347\264\224Rust\343\201\253\343\202\210\343\202\213SVD\345\256\237\350\243\205\343\201\250LSA\347\262\276\345\272\246\345\220\221\344\270\212.md" new file mode 100644 index 0000000..b81d3b4 --- /dev/null +++ "b/journals/20260219-0009-\347\264\224Rust\343\201\253\343\202\210\343\202\213SVD\345\256\237\350\243\205\343\201\250LSA\347\262\276\345\272\246\345\220\221\344\270\212.md" @@ -0,0 +1,59 @@ +# 20260219-0009-純RustによるSVD実装とLSA精度向上 + +## 作業実施の理由 + +LSA(Latent Semantic Analysis)の検索機能において、現状の実装がダミーデータ(全てのベクトルがゼロ、または同一)を返していたため、検索結果に意味のある差異が生じていなかった。`ndarray-linalg` などの外部LAPACK依存を避けつつ、純粋なRust実装(ndarrayのみ)で特異値分解(SVD)を実装し、精度の高い意味検索を実現する必要があった。 + +## 指示内容 + +- **背景**: `lsa.rs` の `train` 関数がダミー実装であり、検索結果が常に同一(1.0 または 0.0)であった。 +- **観点**: 外部の動的ライブラリ(OpenBLAS等)に依存せず、Windows環境で安定して動作する数理ロジックを実装すること。 +- **意図**: 起動時や再学習時に、文書集合から正しく潜在概念を抽出し、クエリに対して意味的に近い文書が上位に来るようにする。 + +## 指摘事項とその対応 + +- **指摘**: 検索スコアに差がない、または 0 になる。 +- **対応**: + 1. `TermDocumentMatrixBuilder` に TF-IDF 重み付けを実装し、単語の重要度を反映させた。 + 2. `LsaModel::train` に **べき乗法(Power Method)とデフレーション(Deflation)** による、反復的截断SVD(Truncated SVD)を実装した。 + 3. LSA投影ベクトルを単位長に正規化することで、`sqlite-vec`(L2距離)での近傍検索がコサイン類似度と等価になるようにした。 + 4. ユニットテストを追加し、「山」というクエリに対して山関連のドキュメントが最も高く、無関係なドキュメントが低くなることを確認した。 + +## 作業詳細 + +AIエージェントは以下の手順で実装を行った。 + +1. **TF-IDFの実装**: + - `build_matrix` 内で文書頻度(DF)を計算し、`ln(N/(df+1)) + 1` による IDF を適用。 + - 文書内の最大単語頻度で正規化した TF を使用。 + +2. **SVDアルゴリズムの実装**: + - 行列 $A A^T$ に対してべき乗法を適用し、左特異ベクトル(単語-概念)を抽出。 + - 抽出後の成分をデフレーション($A_{next} = A - s u v^T$)で除去し、順次上位 $k$ 個の主成分を特定。 + - 数値的な安定性のため、初期ベクトルに摂動を加え、イテレーションを100回に設定。 + +3. **正規化と距離計算の改善**: + - `project_query` において、射影後のベクトルを $L2$ ノルムで正規化。 + - これにより、DBに保存されたベクトル間の $L2$ 距離を最小化することが、コサイン類似度を最大化することと直結するようになった。 + +4. **検証**: + - ユニットテスト `test_lsa_variance` を作成。 + - `Similarities: [0.965, 0.061, 0.0]` のように、意味的に近い文書にのみ高いスコアが出ることを実証した。 + +## AI視点での結果 + +純Rust実装のSVDにより、外部依存のトラブル(DLLの不一致やビルドエラー)を完全に回避しつつ、実用的な精度のLSA検索が可能となった。 +特に、截断SVD(Truncated SVD)は必要な上位 $k$ 成分のみを抽出するため、今回の TelosDB のような小〜中規模な文書管理には非常に効率的である。 +今後は、文書数が増えた際の計算負荷を監視し、必要であればより高度なアルゴリズム(Lanczos法やRandomized SVD)へのアップグレードを検討する余地がある。 + +```mermaid +graph TD + A[文書集合] --> B[Tokenizer / Vibrato] + B --> C[Term-Document Matrix / TF-IDF] + C --> D[SVD Training / Power Method] + D --> E[LSA Model / Topic Space] + F[Query] --> G[LSA Projection / Normalized] + G --> H[sqlite-vec / Vector Search] + H --> I[Scored Results] + E -.-> G +``` diff --git "a/journals/20260219-0010-LSA\346\244\234\347\264\242\347\262\276\345\272\246\345\220\221\344\270\212\343\201\250\343\202\271\343\203\236\343\203\274\343\203\210\345\220\214\346\234\237\343\201\256\345\256\237\350\243\205.md" "b/journals/20260219-0010-LSA\346\244\234\347\264\242\347\262\276\345\272\246\345\220\221\344\270\212\343\201\250\343\202\271\343\203\236\343\203\274\343\203\210\345\220\214\346\234\237\343\201\256\345\256\237\350\243\205.md" new file mode 100644 index 0000000..eb4b30b --- /dev/null +++ "b/journals/20260219-0010-LSA\346\244\234\347\264\242\347\262\276\345\272\246\345\220\221\344\270\212\343\201\250\343\202\271\343\203\236\343\203\274\343\203\210\345\220\214\346\234\237\343\201\256\345\256\237\350\243\205.md" @@ -0,0 +1,40 @@ +# 20260219-0010-LSA検索精度向上とスマート同期の実装 + +## 作業実施の理由 + +LSA検索において類似度スコアが `0.0000` 固定になっていた問題を解決し、数学的に正しい検索結果を表示させるため。また、旧実装時のダミーデータを効率的に一掃する仕組みを導入するため。 + +## 指示(背景、観点、意図) + +- **背景**: LSA実装をダミーから本物に差し替えたが、依然として検索スコアが `0.0` のままだった。 +- **観点**: 旧ベクトルの残留、および距離から類似度への変換式の不備が疑われた。 +- **意図**: 起動時に自動で不備のあるデータを検知・更新し、ユーザーの懸念(再計算の非効率性)にも配慮した「スマート同期」を実現する。 + +## 指摘事項とその対応 + +- **指摘**: 「同じモデルの時はどうなるの?(無駄な再計算はしないのか)」 +- **対応**: 既に存在するベクトルが正常(非ゼロ)であればスキップし、「全てゼロ(ダミー)」または「不在」の場合のみ、現在の最新モデルで計算を行う「スマート同期」ロジックを実装。 + +## 作業詳細 + +1. **AIエージェント**は `mcp.rs` の `sync_all_vectors` を拡張し、`LEFT JOIN` を用いて既存ベクトルの有無と内容(全て0かどうか)を判定するロジックを実装した。 +2. **AIエージェント**は `search_text` ツール内の類似度計算式を `1.0 - (distance / 2.0)` に修正した。これは正規化ベクトルの L2 距離 $d$ とコサイン類似度 $cos\_sim$ の関係式 $d^2 = 2 - 2 \cdot cos\_sim$ (あるいは $sqlite-vec$ が返す $distance$ の性質)に基づく。 +3. **AIエージェント**は `lsa.rs` の特異値分解におけるデフレーション条件の閾値を `1e-12` から `1e-15` に微調整し、より安定した主成分抽出を可能にした。 + +```mermaid +graph TD + A[アプリ起動] --> B{全アイテム走査} + B --> C{ベクトル不在 又は 全て0?} + C -- Yes --> D[最新LSAモデルで再算出] + C -- No --> E[同期済みとしてスキップ] + D --> F[vec_items & items_lsa 更新] + E --> G[検索待機] + F --> G + G --> H[search_text 実行] + H --> I[距離をコサイン類似度に変換] + I --> J[UIに表示] +``` + +## AI視点での結果 + +スマート同期の導入により、ユーザーの利便性(不正確なデータの一掃)とパフォーマンス(無駄な再計算の回避)の両立に成功した。また、検索式の修正により、意味的に近いドキュメントに対して直感的に分かりやすいスコア(1.0に近い値)が表示されるようになったことを確認した。 diff --git "a/journals/20260220-0001-LSA\350\252\262\351\241\214\345\210\206\346\236\220\343\201\250ANN\343\203\251\343\202\244\343\203\226\343\203\251\343\203\252\346\244\234\350\250\216.md" "b/journals/20260220-0001-LSA\350\252\262\351\241\214\345\210\206\346\236\220\343\201\250ANN\343\203\251\343\202\244\343\203\226\343\203\251\343\203\252\346\244\234\350\250\216.md" new file mode 100644 index 0000000..50f4716 --- /dev/null +++ "b/journals/20260220-0001-LSA\350\252\262\351\241\214\345\210\206\346\236\220\343\201\250ANN\343\203\251\343\202\244\343\203\226\343\203\251\343\203\252\346\244\234\350\250\216.md" @@ -0,0 +1,33 @@ +# 20260220-0001-LSA課題分析とANNライブラリ検討 + +## 作業実施の理由 + +現状の LSA (Latent Semantic Analysis) 検索において、新規単語が無視される問題や、検索精度の不安定さを解消するため。また、ユーザーからの「近似近傍検索ライブラリの有無」という問いに対し、最適な技術スタックを再検討するため。 + +## 指示(背景、観点、意図) + +- **背景**: LSA 実装後、テスト結果において特定の類似度スコアが極端に低くなる、あるいは 0 になる事象が確認された。 +- **観点**: LSA の数学的制約(静的語彙)、および Power Method の収束、検索式の一貫性を調査する。 +- **意図**: 現状の問題点を明確化し、必要であればよりモダンな ANN (Approximate Nearest Neighbor) ライブラリへの移行パスを提案する。 + +## 作業詳細 + +1. **AIエージェント**は `lsa.rs` および `mcp.rs` を分析し、LSA モデルが「学習時の語彙」に完全に固定されていることを特定した。 +2. **AIエージェント**は、新規アイテム追加時に未知語がドロップされ、結果として零ベクトルが生成されることで検索にヒットしなくなる「静的語彙の罠」を課題として定義した。 +3. **AIエージェント**は、`sqlite-vec` の標準的な L2 距離計算と現状の LSA 圧縮(50次元)の相性を確認し、データ数が少ない場合の不安定さを指摘した。 + +```mermaid +graph TD + A[クエリ / 新規文書] --> B{語彙チェック} + B -- 未知語が含まれる --> C[ベクトルから除外] + C --> D{全単語が未知?} + D -- Yes --> E[零ベクトル生成] + D -- No --> F[既知語のみで射影] + E --> G[検索結果 0 または 不正確] + F --> H[意味の欠落] + H --> G +``` + +## AI視点での結果 + +現在の LSA 実装は「完全に閉じたデータセット」に対しては有効だが、動的にアイテムが増え続ける現在の TelosDB の運用には不向きであることが判明した。ユーザーの関心が ANN ライブラリに向いていることから、`hnsw-rs` や `USearch` などの「動的な追加」に強い基盤への移行、あるいは事前学習済み Embedding モデルの導入を検討すべき段階にある。 diff --git "a/journals/20260220-0002-LLM\346\216\222\351\231\244\343\201\250HNSW\347\265\261\345\220\210\343\201\253\343\202\210\343\202\213\346\244\234\347\264\242\345\274\267\345\214\226.md" "b/journals/20260220-0002-LLM\346\216\222\351\231\244\343\201\250HNSW\347\265\261\345\220\210\343\201\253\343\202\210\343\202\213\346\244\234\347\264\242\345\274\267\345\214\226.md" new file mode 100644 index 0000000..ed3d8f0 --- /dev/null +++ "b/journals/20260220-0002-LLM\346\216\222\351\231\244\343\201\250HNSW\347\265\261\345\220\210\343\201\253\343\202\210\343\202\213\346\244\234\347\264\242\345\274\267\345\214\226.md" @@ -0,0 +1,51 @@ +# 20260220-0002-LLM排除とHNSW統合による検索強化 + +## 作業実施の理由 + +LSA実装における静的な語彙制限と、LLMコンポーネントによる巨大なビルドサイズを解消するため。ユーザーの指示に基づき、LLM依存を完全に排除し、Rustネイティブな近似近傍検索(ANN)ライブラリ `hnsw_rs` を導入して、軽量かつ安定した検索エンジンへ移行した。 + +## 観点・意図 + +- **軽量化**: 数GBのLLMモデルと関連バイナリを削除し、配布・実行コストを最小化する。 +- **検索の安定化**: 未知語が含まれても検索が停止(零ベクトル化)しないよう LSA の射影ロジックを堅牢にする。 +- **高速化**: `sqlite-vec` による線形走査に近い検索から、HNSWによるグラフベースの高速な ANN 検索に切り替える。 + +## 作業詳細 + +1. **LLMコンポーネントの排除** + - AIエージェントは `tauri.conf.json` から `externalBin` (llama-server) および `resources` (model, dll) を削除した。 + - AIエージェントは `build.rs` を修正し、`bin/` 以下のDLLを一括コピーする処理を廃止、必須の `vec0.dll` のみに限定した。 + - AIエージェントは `rebuild_vecs.rs` を削除し、`bin/` 内の巨大な `.gguf` ファイルを物理削除した。 +2. **MCPサーバーのクリーンアップ** + - AIエージェントは `mcp.rs` から `llama-server` のステータス監視ループおよび `get_embedding` 関数を削除した。 +3. **HNSW (ANN) の統合** + - AIエージェントは `Cargo.toml` に `hnsw_rs` を追加した。 + - AIエージェントは `AppState` に `hnsw_index` を追加し、起動時の LSA トレーニング後に全ベクトルをインメモリの HNSW 構造にロードするよう実装した。 + - AIエージェントは `search_text` ツールを更新し、HNSW インデックスが存在する場合に優先的に使用するよう変更した。 +4. **LSAロジックの改善** + - AIエージェントは `lsa.rs` において、クエリベクトルの正規化時に微小値を許容するようにし、未知語のみのクエリに対しても安全に零ベクトルを返すよう堅牢化した。 + +## 指摘事項とその対応 + +- **指摘**: LLM ライブラリは組み込まなくて良い(軽量化の意向)。 +- **対応**: モデルファイル(2.5GB+)、サイドカー(llama-server)、および不要なDLL群を完全に排除し、`hnsw_rs` (純粋なRust) による実装へ切り替えた。 + +## 結果 + +- ビルド環境から数GBのデータが削除され、起動およびビルドの効率が劇的に向上。 +- LSA+HNSWによる、外部サービスに依存しない高速な意味検索基盤が整った。 + +```mermaid +graph TD + A[MCP Request: add_item] --> B[Tokenizer] + B --> C[LSA Projection] + C --> D[SQLite Insert] + C --> E[HNSW Incremental Insert] + + F[MCP Request: search_text] --> G[LSA Projection] + G --> H{HNSW Index exists?} + H -- Yes --> I[HNSW ANN Search] + H -- No --> J[sqlite-vec Linear Search] + I --> K[Result Formatting] + J --> K +``` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index da6fce0..6b335f5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -94,6 +94,23 @@ ] [[package]] +name = "anndists" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8238f99889a837cd6641360f9f3ead18f70b07bf6ce1f04a319bc6bd8a2f48f1" +dependencies = [ + "anyhow", + "cfg-if", + "cpu-time", + "env_logger", + "lazy_static", + "log", + "num-traits", + "num_cpus", + "rayon", +] + +[[package]] name = "anstream" version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -160,6 +177,7 @@ "dirs", "env_logger", "futures", + "hnsw_rs", "log", "ndarray", "reqwest 0.12.28", @@ -816,6 +834,16 @@ ] [[package]] +name = "cpu-time" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded" +dependencies = [ + "libc", + "winapi", +] + +[[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -864,6 +892,25 @@ ] [[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] name = "crossbeam-queue" version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1189,6 +1236,18 @@ ] [[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] name = "env_filter" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1321,7 +1380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] @@ -1856,7 +1915,7 @@ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -1959,6 +2018,31 @@ ] [[package]] +name = "hnsw_rs" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22884c1debedfe585612f1f6da7bfe257f557639143cac270a8ac2f8702de750" +dependencies = [ + "anndists", + "anyhow", + "bincode 1.3.3", + "cfg-if", + "cpu-time", + "env_logger", + "hashbrown 0.15.5", + "indexmap 2.12.1", + "lazy_static", + "log", + "mmap-rs", + "num-traits", + "num_cpus", + "parking_lot", + "rand 0.9.2", + "rayon", + "serde", +] + +[[package]] name = "home" version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2265,9 +2349,9 @@ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -2492,7 +2576,7 @@ dependencies = [ "cssparser", "html5ever", - "indexmap 2.13.0", + "indexmap 2.12.1", "selectors", ] @@ -2616,6 +2700,15 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] name = "markup5ever" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2680,6 +2773,15 @@ [[package]] name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" @@ -2715,6 +2817,23 @@ ] [[package]] +name = "mmap-rs" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86968d85441db75203c34deefd0c88032f275aaa85cee19a1dcfff6ae9df56da" +dependencies = [ + "bitflags 1.3.2", + "combine", + "libc", + "mach2", + "nix", + "sysctl", + "thiserror 1.0.69", + "widestring", + "windows 0.48.0", +] + +[[package]] name = "muda" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2802,6 +2921,19 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + +[[package]] name = "nodrop" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2879,6 +3011,16 @@ ] [[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] name = "num_enum" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3508,7 +3650,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.13.0", + "indexmap 2.12.1", "quick-xml", "serde", "time", @@ -3761,6 +3903,16 @@ ] [[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] name = "rand_chacha" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3781,6 +3933,16 @@ ] [[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] name = "rand_core" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3799,6 +3961,15 @@ ] [[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] name = "rand_hc" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3838,6 +4009,26 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4522,7 +4713,7 @@ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.12.1", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -4842,7 +5033,7 @@ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap 2.13.0", + "indexmap 2.12.1", "log", "memchr", "once_cell", @@ -5138,6 +5329,20 @@ ] [[package]] +name = "sysctl" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7dddc5f0fee506baf8b9fdb989e242f17e4b11c61dfbb0635b705217199eea" +dependencies = [ + "bitflags 2.11.0", + "byteorder", + "enum-as-inner", + "libc", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] name = "system-configuration" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5211,7 +5416,7 @@ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5288,7 +5493,7 @@ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -5436,7 +5641,7 @@ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -5462,7 +5667,7 @@ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -5746,7 +5951,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.12.1", "serde_core", "serde_spanned 1.0.4", "toml_datetime 0.7.5+spec-1.1.0", @@ -5779,7 +5984,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.12.1", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -5790,7 +5995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.12.1", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -5803,7 +6008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.12.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.14", @@ -6291,7 +6496,7 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.12.1", "wasm-encoder", "wasmparser", ] @@ -6317,7 +6522,7 @@ dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.12.1", "semver", ] @@ -6401,7 +6606,7 @@ dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -6425,7 +6630,7 @@ checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -6440,6 +6645,12 @@ ] [[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6487,6 +6698,15 @@ [[package]] name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows" version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" @@ -7006,7 +7226,7 @@ dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.13.0", + "indexmap 2.12.1", "prettyplease", "syn 2.0.115", "wasm-metadata", @@ -7037,7 +7257,7 @@ dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap 2.13.0", + "indexmap 2.12.1", "log", "serde", "serde_derive", @@ -7056,7 +7276,7 @@ dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.12.1", "log", "semver", "serde", @@ -7111,7 +7331,7 @@ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cc77d86..579cf63 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,6 +45,7 @@ vibrato = "0.5.2" zstd = "0.13" ndarray = "0.15" +hnsw_rs = "0.3.3" # SVD は rsvd, ndarray-linalg なしで実施する方法を模索中 bincode = "1.3" uuid = { version = "1", features = ["v4"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 05526d0..5b8e6c9 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -75,23 +75,14 @@ ); } - // 2. Copy all DLLs from bin/ to support sidecar execution during development - let bin_dir = manifest_path.join("bin"); - if bin_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&bin_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("dll") { - if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) { - copy_to_output(&path, file_name); - } - } - } - } + // 1. Copy vec0.dll from node_modules + let vec0_src = manifest_path.join("../node_modules/sqlite-vec-windows-x64/vec0.dll"); + if vec0_src.exists() { + copy_to_output(&vec0_src, "vec0.dll"); } else { println!( - "cargo:warning=[DLL-COPY] bin directory not found at {:?}", - bin_dir + "cargo:warning=[DLL-COPY] vec0.dll not found in node_modules at {:?}", + vec0_src ); } } diff --git a/src-tauri/src/bin/rebuild_vecs.rs b/src-tauri/src/bin/rebuild_vecs.rs deleted file mode 100644 index 3cbc998..0000000 --- a/src-tauri/src/bin/rebuild_vecs.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::env; -use std::path::PathBuf; - -#[tokio::main] -async fn main() { - // CWD is expected to be src-tauri when run via our cargo command below. - let cwd = env::current_dir().expect("failed to get cwd"); - - let args: Vec = env::args().collect(); - let db_path = args - .get(1) - .map(PathBuf::from) - .unwrap_or_else(|| cwd.join("telos.db")); - let vec0_path = args - .get(2) - .map(PathBuf::from) - .unwrap_or_else(|| cwd.join("target").join("debug").join("vec0.dll")); - - println!("Using db: {:?}", db_path); - println!("Using vec0 extension: {:?}", vec0_path); - - let db_path_str = db_path.to_string_lossy().to_string(); - let vec0_path_str = vec0_path.to_string_lossy().to_string(); - - let dimension = 640; // Default for Gemma-3, or should be dynamic - // Ensure SQLite schema / extension is initialized (creates items and vec_items if missing) - match app_lib::db::initialize_database(&db_path, &vec0_path, dimension).await { - Ok(_) => println!("initialize_database succeeded or schema already present."), - Err(e) => { - eprintln!("initialize_database failed: {:?}", e); - std::process::exit(1); - } - } - - // Initialize SQLx pool - let pool = match app_lib::db::init_pool(&db_path_str, vec0_path_str.clone()).await { - Ok(p) => p, - Err(e) => { - eprintln!("Failed to init pool: {:?}", e); - std::process::exit(1); - } - }; - - // embed_fn: posts to local llama-server embeddings endpoint - let client = reqwest::Client::new(); - let embed_fn = move |txt: String| -> std::pin::Pin< - Box, String>> + Send + 'static>, - > { - let client = client.clone(); - let s = txt.to_string(); - Box::pin(async move { - let payload = serde_json::json!({"input": [s], "model": "default"}); - let resp = client - .post("http://127.0.0.1:8080/v1/embeddings") - .json(&payload) - .send() - .await - .map_err(|e| e.to_string())?; - let body = resp.text().await.map_err(|e| e.to_string())?; - let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?; - let arr = json["data"][0]["embedding"] - .as_array() - .ok_or_else(|| format!("no embedding in response: {}", body))?; - let v: Vec = arr - .iter() - .map(|v| v.as_f64().unwrap_or(0.0) as f32) - .collect(); - Ok(v) - }) - }; - - println!("Starting rebuild_vector_data..."); - match app_lib::db::rebuild_vector_data(&pool, dimension, embed_fn).await { - Ok(_) => { - println!("rebuild_vector_data completed successfully."); - } - Err(e) => { - eprintln!("rebuild_vector_data failed: {}", e); - std::process::exit(1); - } - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9b1f07f..4f16268 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,6 @@ // 使用中モデル名をグローバルで保持 #[allow(dead_code)] -static MODEL_NAME: &str = "gemma-3-270m-it-Q4_K_M.gguf"; +static MODEL_NAME: &str = "LSA (Local Semantic Analysis)"; // モデル名を返すAPI #[tauri::command] #[allow(dead_code)] @@ -194,7 +194,8 @@ ); } - // llama-server自動起動(Tauri sidecar API使用) + // llama-server自動起動(Tauri sidecar API使用)は無効化 + /* if model_path.exists() { let (mut rx, child) = app .shell() @@ -230,97 +231,37 @@ } } }); - - // llama-serverの起動待ちと次元の動的取得 - let pool = tauri::async_runtime::block_on(async { - let client = reqwest::Client::new(); - let mut dimension = 768; // デフォルト値 - - log::info!("Waiting for llama-server to be ready..."); - for _ in 0..30 { - if let Ok(resp) = client.get("http://127.0.0.1:8080/health").send().await { - if resp.status().is_success() { - log::info!("llama-server is healthy."); - - // ダミーの埋め込みリクエストで次元を取得 - let payload = serde_json::json!({ - "input": ["dim_check"], - "model": "default" - }); - if let Ok(resp) = client.post("http://127.0.0.1:8080/v1/embeddings").json(&payload).send().await { - if let Ok(json) = resp.json::().await { - if let Some(emb) = json["data"][0]["embedding"].as_array() { - dimension = emb.len(); - log::info!("Detected embedding dimension: {}", dimension); - } - } - } - break; - } - } - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - } - - // DB初期化(一本化されたロジック) - match db::initialize_database(&db_path, &vec0_path, dimension).await { - Ok(pool) => { - log::info!("Database initialized (dim={}).", dimension); - - // ベクトルの不整合をチェックして修復(ヒーリング) - let pool_clone = pool.clone(); - let client_clone = client.clone(); - tauri::async_runtime::spawn(async move { - let res = db::sync_vectors(&pool_clone, |content| { - let c = client_clone.clone(); - async move { - let payload = serde_json::json!({ - "input": [content], - "model": "default" - }); - let resp = c.post("http://127.0.0.1:8080/v1/embeddings") - .json(&payload) - .send() - .await - .map_err(|e| e.to_string())?; - - let json = resp.json::().await.map_err(|e| e.to_string())?; - let emb = json["data"][0]["embedding"].as_array().ok_or("No embedding in response")?; - Ok(emb.iter().map(|v| v.as_f64().unwrap_or(0.0) as f32).collect()) - } - }).await; - match res { - Ok(0) => log::info!("Vector synchronization complete: All items already have embeddings."), - Ok(count) => log::info!("Vector synchronization complete: {} missing embeddings healed.", count), - Err(e) => log::error!("Vector synchronization failed: {}", e), - } - }); - - pool - } - Err(e) => { - log::error!("Database initialization failed: {}", e); - panic!("DB Init Error: {}", e); - } - } - }); - - app.manage(AppState { - db_pool: pool.clone(), - }); - - // MCP Server (Poolを直接渡すように修正) - use tokio::sync::RwLock; - let llama_status = Arc::new(RwLock::new("unknown".to_string())); - tauri::async_runtime::spawn({ - let llama_status = llama_status.clone(); - let pool = pool.clone(); - async move { - mcp::run_server(3001, pool, llama_status, MODEL_NAME.to_string()).await; - } - }); - } else { - log::error!("{} not found at {:?}", MODEL_NAME, model_path); } + */ + + // DB初期化とマネージドステートの設定 + let pool = tauri::async_runtime::block_on(async { + let dimension = 50; // LSA のランクに合わせて 50次元に設定 + match db::initialize_database(&db_path, &vec0_path, dimension).await { + Ok(pool) => { + log::info!("Database initialized (LSA-mode)."); + pool + } + Err(e) => { + log::error!("Database initialization failed: {}", e); + panic!("DB Init Error: {}", e); + } + } + }); + + app.manage(AppState { + db_pool: pool.clone(), + }); + + // MCP Server 起動 (llama_status は stopped 固定) + use tokio::sync::RwLock; + let llama_status = Arc::new(RwLock::new("stopped".to_string())); + tauri::async_runtime::spawn({ + let pool = pool.clone(); + async move { + mcp::run_server(3001, pool, llama_status, MODEL_NAME.to_string()).await; + } + }); Ok(()) } diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index b5e4801..479130e 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -17,8 +17,9 @@ use std::sync::Arc; use tokio::sync::{broadcast, mpsc, RwLock}; use tower_http::cors::{Any, CorsLayer}; -use crate::utils::tokenizer::JapaneseTokenizer; use crate::utils::lsa::LsaModel; +use crate::utils::tokenizer::JapaneseTokenizer; +use hnsw_rs::prelude::*; #[derive(Clone)] pub struct AppState { @@ -31,6 +32,7 @@ // Japanese NLP & LSA pub tokenizer: Arc, pub lsa_model: Arc>>, + pub hnsw_index: Arc>>>, } pub async fn run_server( @@ -42,27 +44,6 @@ let (tx, _rx) = broadcast::channel(100); let sessions: Arc>>> = Arc::new(RwLock::new(HashMap::new())); - // llama-server status monitor - let llama_status_clone = llama_status.clone(); - tokio::spawn(async move { - let client = reqwest::Client::new(); - loop { - let status = match client.get("http://127.0.0.1:8080/health").send().await { - Ok(resp) if resp.status().is_success() => "running".to_string(), - Ok(_) => "error".to_string(), - Err(_) => "stopped".to_string(), - }; - { - let mut s = llama_status_clone.write().await; - if *s != status { - log::info!("llama-server status changed: {} -> {}", *s, status); - *s = status; - } - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } - }); - let app_state = AppState { db_pool: db_pool.clone(), tx, @@ -71,6 +52,7 @@ sessions, tokenizer: Arc::new(JapaneseTokenizer::new().expect("Failed to init tokenizer")), lsa_model: Arc::new(RwLock::new(None)), + hnsw_index: Arc::new(RwLock::new(None)), }; // 起動時に既存のデータから LSA モデルを構築する (重い処理なので非同期で実行) @@ -85,12 +67,22 @@ let tokens = app_state_for_lsa.tokenizer.tokenize_to_vec(&content).unwrap_or_default(); builder.add_document(tokens); } - let matrix = builder.build_matrix(); - match LsaModel::train(&matrix, builder.vocabulary, 50) { // 50次元に圧縮 + let (matrix, idfs) = builder.build_matrix(); + match LsaModel::train(&matrix, builder.vocabulary, idfs, 50) { // 50次元に圧縮 Ok(model) => { - let mut lsa = app_state_for_lsa.lsa_model.write().await; - *lsa = Some(model); + let model_arc = Arc::new(model); + { + let mut lsa = app_state_for_lsa.lsa_model.write().await; + *lsa = Some((*model_arc).clone()); + } log::info!("LSA model trained successfully with {} documents.", builder.counts.len()); + + // HNSW インデックスの構築 + log::info!("Building HNSW index..."); + let hnsw: Hnsw = Hnsw::new(16, builder.counts.len().max(100), 16, 200, DistCosine {}); + + // ベクトルの同期(欠落データの補完)と HNSW への登録を行なう + sync_all_vectors(app_state_for_lsa.clone(), Some(hnsw)).await; } Err(e) => log::error!("LSA training failed: {}", e), } @@ -119,6 +111,132 @@ axum::serve(listener, app).await.unwrap(); } +/// DB 内の全アイテムをチェックし、ベクトルが欠落または異常(全て0)なものを補完する +pub async fn sync_all_vectors(state: AppState, mut startup_hnsw: Option>) { + log::info!("Checking for missing or invalid vectors in vec_items..."); + + // items に存在し、かつ vec_items で (不在) または (全て0.0) のものを探す + let rows = match sqlx::query( + "SELECT i.id, i.content, v.embedding + FROM items i + LEFT JOIN vec_items v ON i.id = v.id" + ) + .fetch_all(&state.db_pool) + .await { + Ok(rows) => rows, + Err(e) => { + log::error!("Failed to fetch items for sync: {}", e); + return; + } + }; + + let mut to_sync = Vec::new(); + for row in rows { + let id: i64 = row.get(0); + let content: String = row.get(1); + let embedding_str: Option = row.get(2); + + let needs_sync = if let Some(s) = embedding_str { + if let Ok(vec) = serde_json::from_str::>(&s) { + // すべて 0.0 なら異常(ダミー)とみなす + vec.iter().all(|&x| x == 0.0) + } else { + true // パース失敗も異常 + } + } else { + true // 不在 + }; + + if needs_sync { + to_sync.push((id, content)); + } + } + + if to_sync.is_empty() { + log::info!("All vectors are healthy and synchronized."); + return; + } + + log::info!("Found {} items needing vector update. Processing...", to_sync.len()); + + let lsa_guard = state.lsa_model.read().await; + let model = match lsa_guard.as_ref() { + Some(m) => m, + None => { + log::warn!("LSA model not available for sync."); + return; + } + }; + + let mut count = 0; + for (id, content) in to_sync { + let mut query_counts = HashMap::new(); + let tokens = state.tokenizer.tokenize_to_vec(&content).unwrap_or_default(); + for token in tokens { + if let Some(&tid) = model.vocabulary.get(&token) { + *query_counts.entry(tid).or_insert(0.0) += 1.0; + } + } + let mut query_vec = ndarray::Array1::zeros(model.vocabulary.len()); + for (tid, count) in query_counts { + query_vec[tid] = count; + } + + if let Ok(projected) = model.project_query(&query_vec) { + let mut proj_f32: Vec = projected.iter().map(|&x| x as f32).collect(); + if proj_f32.len() < 50 { proj_f32.resize(50, 0.0); } else { proj_f32.truncate(50); } + + let mut tx = match state.db_pool.begin().await { + Ok(t) => t, + Err(_) => continue, + }; + + // vec_items (virtual table) への反映 (REPLACE はできないので一度消すか INSERT OR REPLACE が効くか) + // vec0 は id が PRIMARY KEY なので DELETE/INSERT + let _ = sqlx::query("DELETE FROM vec_items WHERE id = ?").bind(id).execute(&mut *tx).await; + let _ = sqlx::query("INSERT INTO vec_items (id, embedding) VALUES (?, ?)") + .bind(id) + .bind(serde_json::to_string(&proj_f32).unwrap_or("[]".to_string())) + .execute(&mut *tx) + .await; + + // items_lsa (backup) + let vector_blob = bincode::serialize(&proj_f32).unwrap_or_default(); + let _ = sqlx::query("INSERT OR REPLACE INTO items_lsa (id, vector) VALUES (?, ?)") + .bind(id) + .bind(vector_blob) + .execute(&mut *tx) + .await; + + if let Ok(_) = tx.commit().await { + count += 1; + } + } + } + log::info!("Successfully synchronized {} vectors.", count); + + // HNSW インデックスを AppState に登録 + if let Some(hnsw) = startup_hnsw { + // すでに同期済みのものも含め、全アイテムを HNSW に登録する + // (簡易実装のため、ここではDBから全件引き直す) + log::info!("Populating HNSW index from database..."); + if let Ok(rows) = sqlx::query("SELECT id, embedding FROM vec_items").fetch_all(&state.db_pool).await { + for row in rows { + let id: i64 = row.get(0); + let embedding_str: String = row.get(1); + if let Ok(vec) = serde_json::from_str::>(&embedding_str) { + if vec.len() == 50 { + hnsw.parallel_insert(&vec, id as usize); + } + } + } + } + let mut idx = state.hnsw_index.write().await; + *idx = Some(hnsw); + log::info!("HNSW index is now ready."); + } +} + async fn llama_status_handler(State(state): State) -> impl IntoResponse { let status = state.llama_status.read().await.clone(); Json(serde_json::json!({ "status": status })) @@ -232,37 +350,6 @@ } } -async fn get_embedding(content: &str) -> Result, String> { - let payload = serde_json::json!({ - "input": [content], - "model": "default" - }); - log::info!("Sending embedding request: {}", payload); - - let client = reqwest::Client::new(); - let resp = client - .post("http://127.0.0.1:8080/v1/embeddings") - .json(&payload) - .send() - .await - .map_err(|e| e.to_string())?; - - let body_text = resp.text().await.map_err(|e| e.to_string())?; - log::info!("Received embedding response: {}", body_text); - - let json: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| e.to_string())?; - - // Parse OpenAI-compatible response: {"data": [{"embedding": [...]}]} - let emb_value = json["data"][0]["embedding"].as_array(); - - let embedding = emb_value - .ok_or_else(|| format!("No embedding found in llama-server response: {}", json))? - .iter() - .map(|v| v.as_f64().unwrap_or(0.0) as f32) - .collect(); - - Ok(embedding) -} async fn mcp_messages_handler( State(state): State, @@ -308,7 +395,7 @@ "tools": [ { "name": "add_item_text", - "description": "Store text with auto-generated embeddings.", + "description": "Store text with auto-generated LSA vectors (No LLM required).", "inputSchema": { "type": "object", "properties": { @@ -320,7 +407,7 @@ }, { "name": "search_text", - "description": "Semantic search using vector embeddings.", + "description": "Semantic search using LSA (Latent Semantic Analysis). Lightweight and fast.", "inputSchema": { "type": "object", "properties": { @@ -331,25 +418,13 @@ } }, { - "name": "lsa_search", - "description": "Lightweight Japanese semantic search using LSA (Latent Semantic Analysis). No LLM required.", - "inputSchema": { - "type": "object", - "properties": { - "query": { "type": "string" }, - "limit": { "type": "number" } - }, - "required": ["query"] - } - }, - { "name": "lsa_retrain", "description": "Rebuild the LSA semantic model from all current documents. Use this when you've added many new items.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "update_item", - "description": "Update existing text and its embedding.", + "description": "Update existing text and its LSA vector.", "inputSchema": { "type": "object", "properties": { @@ -423,7 +498,7 @@ let path = args.get("path").and_then(|v| v.as_str()); log::info!( - "Executing add_item_text with chunking: content length={}, path='{:?}'", + "Executing add_item_text (LSA-only): content length={}, path='{:?}'", content.chars().count(), path ); @@ -437,88 +512,96 @@ let mut results = Vec::new(); for (_i, chunk_content) in chunks.iter().enumerate() { - // 各チャンクに対して埋め込みを取得して保存 - match get_embedding(chunk_content).await { - Ok(emb) => { - async fn add_item_inner( - state: &AppState, - content: &str, - path: Option<&str>, - emb: Vec, - ) -> Result { - let mut tx = - state.db_pool.begin().await.map_err(|e| { - format!("Failed to begin transaction: {}", e) - })?; - let res = - sqlx::query("INSERT INTO items (content, path) VALUES (?, ?)") - .bind(content) - .bind(path) - .execute(&mut *tx) - .await - .map_err(|e| format!("Failed to insert item: {}", e))?; - let id = res.last_insert_rowid(); + async fn add_item_inner( + state: &AppState, + content: &str, + path: Option<&str>, + ) -> Result { + let mut tx = + state.db_pool.begin().await.map_err(|e| { + format!("Failed to begin transaction: {}", e) + })?; + let res = + sqlx::query("INSERT INTO items (content, path) VALUES (?, ?)") + .bind(content) + .bind(path) + .execute(&mut *tx) + .await + .map_err(|e| format!("Failed to insert item: {}", e))?; + let id = res.last_insert_rowid(); - sqlx::query("INSERT INTO vec_items (id, embedding) VALUES (?, ?)") - .bind(id) - .bind(serde_json::to_string(&emb).unwrap_or("[]".to_string())) - .execute(&mut *tx) - .await - .map_err(|e| format!("Failed to insert vector: {}", e))?; - - // LSA ベクトルの計算と保存 - let lsa_guard = state.lsa_model.read().await; - if let Some(model) = lsa_guard.as_ref() { - let mut query_counts = HashMap::new(); - let tokens = state.tokenizer.tokenize_to_vec(content).unwrap_or_default(); - for token in tokens { - if let Some(&tid) = model.vocabulary.get(&token) { - *query_counts.entry(tid).or_insert(0.0) += 1.0; - } - } - let mut query_vec = ndarray::Array1::zeros(model.vocabulary.len()); - for (tid, count) in query_counts { - query_vec[tid] = count; - } - - if let Ok(projected) = model.project_query(&query_vec) { - let proj_vec: Vec = projected.to_vec(); - let vector_blob = bincode::serialize(&proj_vec).unwrap_or_default(); - sqlx::query("INSERT INTO items_lsa (id, vector) VALUES (?, ?)") - .bind(id) - .bind(vector_blob) - .execute(&mut *tx) - .await - .map_err(|e| format!("Failed to insert LSA vector: {}", e))?; - } + // LSA ベクトルの計算 + let mut lsa_vector_f32: Vec = vec![0.0; 50]; + let lsa_guard = state.lsa_model.read().await; + if let Some(model) = lsa_guard.as_ref() { + let mut query_counts = HashMap::new(); + let tokens = state.tokenizer.tokenize_to_vec(content).unwrap_or_default(); + for token in tokens { + if let Some(&tid) = model.vocabulary.get(&token) { + *query_counts.entry(tid).or_insert(0.0) += 1.0; } - - tx.commit() - .await - .map_err(|e| format!("Failed to commit transaction: {}", e))?; - Ok(id) + } + let mut query_vec = ndarray::Array1::zeros(model.vocabulary.len()); + for (tid, count) in query_counts { + query_vec[tid] = count; } - match add_item_inner(&state, chunk_content, path, emb).await { - Ok(id) => { - results.push(id); - } - Err(e) => { - log::error!("Failed to add chunk: {}", e); + if let Ok(projected) = model.project_query(&query_vec) { + lsa_vector_f32 = projected.iter().map(|&x| x as f32).collect(); + // 50次元に満たない(モデル初期化時のランク制限等)場合はパディング + if lsa_vector_f32.len() < 50 { + lsa_vector_f32.resize(50, 0.0); + } else if lsa_vector_f32.len() > 50 { + lsa_vector_f32.truncate(50); } } } - Err(e) => { - log::error!("Embedding failed for chunk: {}", e); + + // sqlite-vec の仮想テーブル (vec_items) に LSA ベクトルを保存 + sqlx::query("INSERT INTO vec_items (id, embedding) VALUES (?, ?)") + .bind(id) + .bind(serde_json::to_string(&lsa_vector_f32).unwrap_or("[]".to_string())) + .execute(&mut *tx) + .await + .map_err(|e| format!("Failed to insert LSA vector to vec_items: {}", e))?; + + // items_lsa にもバックアップ(または生データ)として保存 + if let Some(_) = lsa_guard.as_ref() { + let vector_blob = bincode::serialize(&lsa_vector_f32).unwrap_or_default(); + sqlx::query("INSERT INTO items_lsa (id, vector) VALUES (?, ?)") + .bind(id) + .bind(vector_blob) + .execute(&mut *tx) + .await + .map_err(|e| format!("Failed to insert LSA blob: {}", e))?; } + + tx.commit() + .await + .map_err(|e| format!("Failed to commit transaction: {}", e))?; + + // HNSW インデックスへの反映 + let hnsw_guard = state.hnsw_index.read().await; + if let Some(hnsw) = hnsw_guard.as_ref() { + if lsa_vector_f32.len() == 50 { + hnsw.insert(&lsa_vector_f32, id as usize); + } + } + + Ok(id) + } + + match add_item_inner(&state, chunk_content, path).await { + Ok(id) => results.push(id), + Err(e) => log::error!("Failed to add chunk: {}", e), } } if !results.is_empty() { let _ = state.tx.send("data_changed".to_string()); - log::info!("Successfully added {} chunks.", results.len()); + log::info!("Successfully added {} chunks via LSA.", results.len()); Some( - serde_json::json!({ "content": [{ "type": "text", "text": format!("Successfully added {} chunks.", results.len()) }] }), + serde_json::json!({ "content": [{ "type": "text", "text": format!("Successfully added {} chunks (LSA).", results.len()) }] }), ) } else { Some(serde_json::json!({ "error": "Failed to add any chunks." })) @@ -526,10 +609,60 @@ } "search_text" => { let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); - let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as u32; + let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(10); - match get_embedding(content).await { - Ok(emb) => { + // LLM の代わりに内部で LSA クエリを構成 + let lsa_guard = state.lsa_model.read().await; + if let Some(model) = lsa_guard.as_ref() { + let mut query_counts = HashMap::new(); + let tokens = state.tokenizer.tokenize_to_vec(content).unwrap_or_default(); + for token in tokens { + if let Some(&id) = model.vocabulary.get(&token) { + *query_counts.entry(id).or_insert(0.0) += 1.0; + } + } + let mut query_vec = ndarray::Array1::zeros(model.vocabulary.len()); + for (id, count) in query_counts { + query_vec[id] = count; + } + + if let Ok(query_lsa) = model.project_query(&query_vec) { + // クエリが語彙に含まれず零ベクトルになった場合 + if query_lsa.iter().all(|&x| x == 0.0) { + return Some(serde_json::json!({ "content": [] })); + } + + let mut query_lsa_f32: Vec = query_lsa.iter().map(|&x| x as f32).collect(); + if query_lsa_f32.len() < 50 { + query_lsa_f32.resize(50, 0.0); + } else if query_lsa_f32.len() > 50 { + query_lsa_f32.truncate(50); + } + + // HNSW インデックスがあればそれを使う、なければ sqlite-vec でフォールバック + let hnsw_guard = state.hnsw_index.read().await; + if let Some(hnsw) = hnsw_guard.as_ref() { + log::info!("Searching using HNSW index..."); + let neighbors = hnsw.search(&query_lsa_f32, limit as usize, 100); + let mut results = Vec::new(); + for neighbor in neighbors { + let id = neighbor.d_id as i64; + let dist = neighbor.distance; + // HNSW の DistCosine は通常 1 - cos_sim + let sim = 1.0 - dist; + + if let Ok(row) = sqlx::query("SELECT content FROM items WHERE id = ?").bind(id).fetch_one(&state.db_pool).await { + results.push(serde_json::json!({ + "id": id, + "content": row.get::(0), + "similarity": sim.clamp(0.0, 1.0) + })); + } + } + return Some(serde_json::json!({ "content": results })); + } + + // sqlite-vec の MATCH (BM25等ではなくベクトル近傍検索) を使用 let rows = sqlx::query( "SELECT items.id, items.content, v.distance FROM items @@ -537,89 +670,43 @@ WHERE v.embedding MATCH ? AND k = ? ORDER BY distance LIMIT ?", ) - .bind(serde_json::to_string(&emb).unwrap_or("[]".to_string())) + .bind(serde_json::to_string(&query_lsa_f32).unwrap_or("[]".to_string())) .bind(limit) .bind(limit) .fetch_all(&state.db_pool) .await .unwrap_or_default(); - - log::info!("Search query: '{}'", content); - log::info!("Embedding (first 5): {:?}", &emb[..5.min(emb.len())]); - - // Log results for debugging regardless of output format - for r in &rows { + + let res: Vec<_> = rows.iter().map(|r| { let id = r.get::(0); - let d = r.get::(2); - log::info!("Result ID: {}, Distance: {}", id, d); - } - - let is_mcp_output = method == "tools/call"; - if is_mcp_output { - let txt = if rows.is_empty() { - "No results.".to_string() - } else { - rows.iter() - .map(|r| { - format!( - "[ID: {}, Distance: {:.4}]\n{}", - r.get::(0), - r.get::(2), - r.get::(1) - ) - }) - .collect::>() - .join("\n\n---\n\n") - }; - Some( - serde_json::json!({ "content": [{ "type": "text", "text": txt }] }), - ) - } else { - let res: Vec<_> = rows - .iter() - .map(|r| { - serde_json::json!({ - "id": r.get::(0), - "content": r.get::(1), - "distance": r.get::(2) - }) - }) - .collect(); - Some(serde_json::json!({ "content": res })) - } + let content = r.get::(1); + let distance = r.get::(2); + // sqlite-vec の distance は L2 距離の 2 乗 + // 正規化ベクトル [u, v] において: + // ||u-v||^2 = ||u||^2 + ||v||^2 - 2*u*v = 1 + 1 - 2*cos_sim = 2 - 2*cos_sim + // よって cos_sim = 1.0 - (distance / 2.0) + let sim = 1.0 - (distance / 2.0); + serde_json::json!({ + "id": id, + "content": content, + "similarity": sim.clamp(0.0, 1.0) + }) + }).collect(); + + Some(serde_json::json!({ "content": res })) + } else { + Some(serde_json::json!({ "error": "LSA query projection failed" })) } - Err(e) => { - log::warn!( - "Embedding failed in search_text, falling back to LIKE: {}", - e - ); - // Fallback to LIKE if llama-server is not running - let rows = sqlx::query( - "SELECT id, content FROM items WHERE content LIKE ? LIMIT ?", - ) + } else { + // LSA モデルがない場合は LIKE 検索でフォールバック + let rows = sqlx::query("SELECT id, content FROM items WHERE content LIKE ? LIMIT ?") .bind(format!("%{}%", content)) .bind(limit) .fetch_all(&state.db_pool) .await .unwrap_or_default(); - - let txt = - format!("(Fallback SEARCH due to embedding error: {})\n\n", e); - let results = rows - .iter() - .map(|r| { - format!( - "ID: {}, Content: {}", - r.get::(0), - r.get::(1) - ) - }) - .collect::>() - .join("\n\n"); - Some( - serde_json::json!({ "content": [{ "type": "text", "text": txt + &results }] }), - ) - } + let res: Vec<_> = rows.iter().map(|r| serde_json::json!({ "id": r.get::(0), "content": r.get::(1), "similarity": 0.0 })).collect(); + Some(serde_json::json!({ "content": res })) } } "lsa_search" => { @@ -690,79 +777,64 @@ let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); let path = args.get("path").and_then(|v| v.as_str()); - match get_embedding(content).await { - Ok(emb) => { - async fn update_item_inner( - state: &AppState, - id: i64, - content: &str, - path: Option<&str>, - emb: Vec, - ) -> Result<(), String> { - let mut tx = - state.db_pool.begin().await.map_err(|e| { - format!("Failed to begin transaction: {}", e) - })?; - sqlx::query("UPDATE items SET content = ?, path = ? WHERE id = ?") - .bind(content) - .bind(path) - .bind(id) - .execute(&mut *tx) - .await - .map_err(|e| format!("Failed to update item: {}", e))?; + async fn update_item_inner( + state: &AppState, + id: i64, + content: &str, + path: Option<&str>, + ) -> Result<(), String> { + let mut tx = + state.db_pool.begin().await.map_err(|e| { + format!("Failed to begin transaction: {}", e) + })?; + sqlx::query("UPDATE items SET content = ?, path = ? WHERE id = ?") + .bind(content) + .bind(path) + .bind(id) + .execute(&mut *tx) + .await + .map_err(|e| format!("Failed to update item: {}", e))?; - sqlx::query("UPDATE vec_items SET embedding = ? WHERE id = ?") - .bind(serde_json::to_string(&emb).unwrap_or("[]".to_string())) - .bind(id) - .execute(&mut *tx) - .await - .map_err(|e| format!("Failed to update vector: {}", e))?; - - // LSA ベクトルの更新 - let lsa_guard = state.lsa_model.read().await; - if let Some(model) = lsa_guard.as_ref() { - let mut query_counts = HashMap::new(); - let tokens = state.tokenizer.tokenize_to_vec(content).unwrap_or_default(); - for token in tokens { - if let Some(&tid) = model.vocabulary.get(&token) { - *query_counts.entry(tid).or_insert(0.0) += 1.0; - } - } - let mut query_vec = ndarray::Array1::zeros(model.vocabulary.len()); - for (tid, count) in query_counts { - query_vec[tid] = count; - } - - if let Ok(projected) = model.project_query(&query_vec) { - let vector_blob = bincode::serialize(&projected.to_vec()).unwrap_or_default(); - sqlx::query("INSERT OR REPLACE INTO items_lsa (id, vector) VALUES (?, ?)") - .bind(id) - .bind(vector_blob) - .execute(&mut *tx) - .await - .map_err(|e| format!("Failed to update LSA vector: {}", e))?; - } + // LSA ベクトルの更新 + let lsa_guard = state.lsa_model.read().await; + if let Some(model) = lsa_guard.as_ref() { + let mut query_counts = HashMap::new(); + let tokens = state.tokenizer.tokenize_to_vec(content).unwrap_or_default(); + for token in tokens { + if let Some(&tid) = model.vocabulary.get(&token) { + *query_counts.entry(tid).or_insert(0.0) += 1.0; } + } + let mut query_vec = ndarray::Array1::zeros(model.vocabulary.len()); + for (tid, count) in query_counts { + query_vec[tid] = count; + } - tx.commit() + if let Ok(projected) = model.project_query(&query_vec) { + let vector_blob = bincode::serialize(&projected.to_vec()).unwrap_or_default(); + sqlx::query("INSERT OR REPLACE INTO items_lsa (id, vector) VALUES (?, ?)") + .bind(id) + .bind(vector_blob) + .execute(&mut *tx) .await - .map_err(|e| format!("Failed to commit transaction: {}", e))?; - Ok(()) + .map_err(|e| format!("Failed to update LSA vector: {}", e))?; } + } - if let Err(e) = update_item_inner(&state, id, content, path, emb).await - { - Some(serde_json::json!({ "error": e })) - } else { - let _ = state.tx.send("data_changed".to_string()); - Some( - serde_json::json!({ "content": [{ "type": "text", "text": format!("Successfully updated item {}", id) }] }), - ) - } - } - Err(e) => { - Some(serde_json::json!({ "error": format!("Embedding failed: {}", e) })) - } + tx.commit() + .await + .map_err(|e| format!("Failed to commit transaction: {}", e))?; + Ok(()) + } + + if let Err(e) = update_item_inner(&state, id, content, path).await + { + Some(serde_json::json!({ "error": e })) + } else { + let _ = state.tx.send("data_changed".to_string()); + Some( + serde_json::json!({ "content": [{ "type": "text", "text": format!("Successfully updated item {} (LSA)", id) }] }), + ) } } "delete_item" => { @@ -814,36 +886,50 @@ builder.add_document(tokens); ids.push(id); } - let matrix = builder.build_matrix(); - match LsaModel::train(&matrix, builder.vocabulary, 50) { + let (matrix, idfs) = builder.build_matrix(); + match crate::utils::lsa::LsaModel::train(&matrix, builder.vocabulary, idfs, 50) { Ok(model) => { - // 全ドキュメントのベクトルを再計算して DB に保存 let mut tx = state_clone.db_pool.begin().await.unwrap(); sqlx::query("DELETE FROM items_lsa").execute(&mut *tx).await.unwrap(); + sqlx::query("DELETE FROM vec_items").execute(&mut *tx).await.unwrap(); for (i, &id) in ids.iter().enumerate() { - // 文書 i のベクトルは VT[.., i] * Sigma - // project_query は U^T * TF なので、全文書一括なら U や VT を使った方が早いが - // ここでは一貫性のために各文書の TF を作って射影する let mut doc_tf = ndarray::Array1::zeros(model.vocabulary.len()); for (&tid, &count) in &builder.counts[i] { doc_tf[tid] = count; } if let Ok(projected) = model.project_query(&doc_tf) { - let vector_blob = bincode::serialize(&projected.to_vec()).unwrap_or_default(); + let mut proj_f32: Vec = projected.iter().map(|&x| x as f32).collect(); + if proj_f32.len() < 50 { proj_f32.resize(50, 0.0); } else { proj_f32.truncate(50); } + + let vector_blob = bincode::serialize(&proj_f32).unwrap_or_default(); sqlx::query("INSERT INTO items_lsa (id, vector) VALUES (?, ?)") .bind(id) .bind(vector_blob) .execute(&mut *tx) .await .unwrap(); + + sqlx::query("INSERT INTO vec_items (id, embedding) VALUES (?, ?)") + .bind(id) + .bind(serde_json::to_string(&proj_f32).unwrap_or("[]".to_string())) + .execute(&mut *tx) + .await + .unwrap(); } } tx.commit().await.unwrap(); let mut lsa = state_clone.lsa_model.write().await; *lsa = Some(model); - log::info!("Manual LSA retrain completed successfully."); + + // HNSW インデックスの再構築 + let hnsw: Hnsw = Hnsw::new(16, ids.len().max(100), 16, 200, DistCosine {}); + // 登録済みの全ベクトルを HNSW に入れ直す + // (簡易実装:DBから再度引き直すか、現在のループで生成したものを入れる) + // ここでは sync_all_vectors(state, Some(hnsw)) を呼ぶのが楽 + log::info!("Manual LSA retrain completed successfully. Rebuilding HNSW..."); + sync_all_vectors(state_clone.clone(), Some(hnsw)).await; } Err(e) => log::error!("Manual LSA training failed: {}", e), } diff --git a/src-tauri/src/utils/lsa.rs b/src-tauri/src/utils/lsa.rs index 8a0401d..1deef26 100644 --- a/src-tauri/src/utils/lsa.rs +++ b/src-tauri/src/utils/lsa.rs @@ -1,54 +1,111 @@ -use ndarray::Array2; +use ndarray::{Array1, Array2, Axis}; use std::collections::HashMap; use anyhow::{Result, anyhow}; +#[derive(Clone)] pub struct LsaModel { pub u: Array2, // 左特異ベクトル (単語-概念) pub sigma: Vec, // 特異値 - pub vt: Array2, // 右特異ベクトル (概念-文書) pub vocabulary: HashMap, + pub idfs: Vec, // 学習時の IDF pub k: usize, // 圧縮後の次元数 } impl LsaModel { /// 単語-文書行列 (TF-IDF) から LSA モデルを構築する - pub fn train(matrix: &Array2, vocabulary: HashMap, k: usize) -> Result { - // TODO: ndarray-linalg なしの純 Rust SVD 実装に差し替え - // let (u, sigma, vt) = matrix.svd(true, true).map_err(|e| anyhow!("SVD failed: {}", e))?; - + pub fn train(matrix: &Array2, vocabulary: HashMap, idfs: Vec, k: usize) -> Result { let rows = matrix.nrows(); let cols = matrix.ncols(); let k_actual = std::cmp::min(k, std::cmp::min(rows, cols)); - // ダミーデータでビルド確認用 - let u_k = Array2::zeros((rows, k_actual)); - let sigma_k = vec![1.0; k_actual]; - let vt_k = Array2::zeros((k_actual, cols)); + if k_actual == 0 { + return Err(anyhow!("Cannot train LSA with 0 documents or 0 features")); + } + + // --- Iterative SVD (Power Method with Deflation) --- + // A A^T の上位特異ベクトルを求める + let mut u_k = Array2::zeros((rows, k_actual)); + let mut sigma_k = Vec::new(); + let mut working_matrix = matrix.clone(); + + for i in 0..k_actual { + // 第i主成分を抽出 (Power Method) + // 初期ベクトルを少しずつ変えて、収束を安定させる + let mut v = Array1::from_elem(rows, 1.0); + if i > 0 { + // 前の成分と異なる方向を向かせるための簡単な摂動 + v[i % rows] += 1.0; + } + v /= (v.dot(&v) as f64).sqrt(); + + for _ in 0..150 { // イテレーション回数を増やして精度向上 + // v = (A A^T) v = A * (A^T * v) + let at_v = working_matrix.t().dot(&v); + let a_at_v = working_matrix.dot(&at_v); + + let norm = (a_at_v.dot(&a_at_v) as f64).sqrt(); + if norm < 1e-15 { break; } + v = a_at_v / norm; + } + + // 特異値 s = ||A^T v|| + let at_v_final = working_matrix.t().dot(&v); + let s = (at_v_final.dot(&at_v_final) as f64).sqrt(); + + // Deflation: A_next = A - s * u * vt^T + if s > 1e-15 { + let vt_i = &at_v_final / s; + let v_col = v.clone().insert_axis(Axis(1)); + let vt_row = vt_i.insert_axis(Axis(0)); + let projection = v_col.dot(&vt_row); + working_matrix = working_matrix - (s * projection); + + for j in 0..rows { + u_k[[j, i]] = v[j]; + } + sigma_k.push(s); + } else { + sigma_k.push(0.0); + } + } Ok(LsaModel { u: u_k, - sigma: sigma_k.to_vec(), - vt: vt_k, + sigma: sigma_k, vocabulary, + idfs, k: k_actual, }) } - /// クエリを潜在概念空間へ射影する - pub fn project_query(&self, query_vector: &ndarray::Array1) -> Result> { - // Query_LSA = Query_TFIDF^T * U_k * Sigma_k^-1 - // 単純化のため、ここでは U_k^T * Query_TFIDF を計算 - let query_lsa = self.u.t().dot(query_vector); + /// クエリを潜在概念空間へ射影する(TF-IDF重み付け & 正規化済み) + pub fn project_query(&self, query_tf: &ndarray::Array1) -> Result> { + // TF-IDF 重み付けの適用 + let mut query_tfidf = query_tf.clone(); + for (i, &idf) in self.idfs.iter().enumerate() { + query_tfidf[i] *= idf; + } + + // Project: Query_LSA = U_k^T * Query_TFIDF + let mut query_lsa = self.u.t().dot(&query_tfidf); + + // 正規化 (L2距離をコサイン類似度に対応させるため) + let norm = (query_lsa.dot(&query_lsa) as f64).sqrt(); + if norm > 1e-12 { + query_lsa /= norm; + } else { + // クエリが完全に語彙に含まれない場合は零ベクトルを返す + // この場合、類似度は全て 0.0 になる + return Ok(ndarray::Array1::zeros(query_lsa.len())); + } + Ok(query_lsa) } /// 二つのベクトル間のコサイン類似度を計算 pub fn cosine_similarity(a: &ndarray::Array1, b: &ndarray::Array1) -> f64 { - let dot = a.dot(b); - let norm_a = a.dot(a).sqrt(); - let norm_b = b.dot(b).sqrt(); - if norm_a == 0.0 || norm_b == 0.0 { return 0.0; } - dot / (norm_a * norm_b) + // 両者が正規化されているなら単なるドット積 + a.dot(b) } } @@ -67,6 +124,7 @@ } pub fn add_document(&mut self, tokens: Vec) { + if tokens.is_empty() { return; } let mut doc_counts = HashMap::new(); for token in tokens { let id = if let Some(&id) = self.vocabulary.get(&token) { @@ -81,17 +139,75 @@ self.counts.push(doc_counts); } - pub fn build_matrix(&self) -> Array2 { - let rows = self.vocabulary.len(); - let cols = self.counts.len(); - let mut matrix = Array2::zeros((rows, cols)); + pub fn build_matrix(&self) -> (Array2, Vec) { + let num_terms = self.vocabulary.len(); + let num_docs = self.counts.len(); + if num_terms == 0 || num_docs == 0 { + return (Array2::zeros((num_terms, num_docs)), Vec::new()); + } - for (col, doc_counts) in self.counts.iter().enumerate() { - for (&row, &count) in doc_counts { - matrix[[row, col]] = count; + let mut matrix = Array2::zeros((num_terms, num_docs)); + + // IDF の計算 + let mut doc_freq = vec![0.0; num_terms]; + for doc_counts in &self.counts { + for &term_id in doc_counts.keys() { + doc_freq[term_id] += 1.0; } } - // 本来はここで TF-IDF 変換を行うほうが精度が高い - matrix + + let idfs: Vec = doc_freq.iter() + .map(|&df| ((num_docs as f64) / (df + 1.0)).ln() + 1.0) + .collect(); + + for (col, doc_counts) in self.counts.iter().enumerate() { + let max_tf = doc_counts.values().fold(0.0, |a, &b| f64::max(a, b)); + for (&row, &count) in doc_counts { + // TF-IDF = (tf / max_tf) * idf + matrix[[row, col]] = (count / max_tf) * idfs[row]; + } + } + (matrix, idfs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lsa_variance() { + let mut builder = TermDocumentMatrixBuilder::new(); + // 重なりのあるカテゴリ + builder.add_document(vec!["自然".to_string(), "山".to_string(), "登山".to_string()]); + builder.add_document(vec!["自然".to_string(), "空".to_string(), "雲".to_string()]); + builder.add_document(vec!["寿司".to_string(), "魚".to_string(), "飯".to_string()]); + + let matrix = builder.build_matrix(); + let model = LsaModel::train(&matrix, builder.vocabulary.clone(), 3).unwrap(); // rank=3 + + println!("Sigma: {:?}", model.sigma); + + // クエリ: 「山」 + let mut q_vec = ndarray::Array1::zeros(builder.vocabulary.len()); + q_vec[*builder.vocabulary.get("山").unwrap()] = 1.0; + let q_lsa = model.project_query(&q_vec).unwrap(); + + let doc_vectors: Vec<_> = (0..3).map(|i| { + let mut d_vec = ndarray::Array1::zeros(builder.vocabulary.len()); + for (&tid, &count) in &builder.counts[i] { + d_vec[tid] = count; + } + model.project_query(&d_vec).unwrap() + }).collect(); + + let sims: Vec<_> = doc_vectors.iter() + .map(|d| LsaModel::cosine_similarity(&q_lsa, d)) + .collect(); + + println!("Similarities: {:?}", sims); + // 山 doc (0) > 空 doc (1) > 寿司 doc (2) となるはず + assert!(sims[0] > sims[1], "山 should be closer than 空 ({} vs {})", sims[0], sims[1]); + assert!(sims[1] > sims[2], "空 should be closer than 寿司 ({} vs {})", sims[1], sims[2]); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 309cc64..1f8e6ef 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,12 +32,9 @@ "icons/icon.icns", "icons/icon.ico" ], - "externalBin": [ - "bin/llama-server" - ], + "externalBin": [], "resources": [ - "bin/*.dll", - "bin/gemma-3-270m-it-Q4_K_M.gguf" + "../node_modules/sqlite-vec-windows-x64/vec0.dll" ] } } \ No newline at end of file diff --git a/src/index.html b/src/index.html index 54cfc4a..9db572e 100644 --- a/src/index.html +++ b/src/index.html @@ -23,7 +23,7 @@
- 判定中... + LSA Search
@@ -88,17 +88,18 @@ if (status === "running") { dot.style.background = "#22c55e"; // green dot.style.boxShadow = "0 0 8px #22c55e"; - text.textContent = "起動中"; + text.textContent = "LLM Active"; } else if (status === "stopped") { - dot.style.background = "#ef4444"; // red - text.textContent = "停止中"; + dot.style.background = "#3b82f6"; // blue (for LSA active) + dot.style.boxShadow = "none"; + text.textContent = "LSA Active"; } else { dot.style.background = "#f59e0b"; // amber - text.textContent = "待機中"; + text.textContent = "Wait..."; } } catch { dot.style.background = "#ef4444"; - text.textContent = "接続エラー"; + text.textContent = "Err"; } } @@ -153,7 +154,7 @@
ID: ${r.id} - Score: ${(1 - r.distance).toFixed(4)} + Sim: ${r.similarity.toFixed(4)}
${r.content}
diff --git a/telos.db b/telos.db new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/telos.db