🦅

はやぶさノーティング② 環境構築とベース部分の実装

に公開

TL;DR

  • はやぶさノーティング第二回は環境構築と基本機能の実装編。
  • Markdown + Git + Obsidian の基本環境構築と設定のポイント。
  • Qdrantとの同期を設定。
  • 仮想プロジェクトの進捗ノートのLLM(Github Copilot)による最新化は結構うまくいった。

前回のおさらい

前回の記事では、"はやぶさノーティング" の概念を紹介しました。

  • "Markdownファイル+Git+LLM" を核にしたノート術
  • メモ作成・整理・検索を ほぼ自動化 する仕組み
  • セキュリティを考慮した1PC完結型の設計

今回は実際に環境を作り、ベースになるノートのデータ部分を整備する。

最初にアーキテクチャとか実装を全部思い描いて決めてやってるわけではないので、コア部分から思いつきで作っていくので、行きつ戻りつやっていくことになる予感。


環境

前提となる環境は以下で、ここまでの準備は省略する。

  • Windows 11
  • Obsidian、Git(Windows版)、VSCodeがインストール済み

基本環境の構築

1. ObsidianのVaultを作成

正直Obsidianはツールの概要だけは知ってて全然使ったことなかったので、へー、ファイルの格納庫のことをVaultっていうんや、かっこよーとか思いながら Users\myusername\hayabusa\とかそんな感じでホームディレクトリに作成する。

2. Gitリポジトリとして初期化

いきなりVSCodeでそのリポジトリを開いてGit Initする。CLIでやるなら以下。

> cd hayabusa
> git init

これには理由があって、はやぶさではAIの力を借りつつも普通にノートを従来通り読み書きもすることになる。その時、以下のどちらも適宜使い分けられる状態にする。

  • Obsidian: きれいで読みやすいUIのノートツール
  • VSCode: Github Copilotのアシストを受けられるエディタ

なぜGitリポジトリ化するかというと、「どうせGithubにPushするんでしょ」と思ったそこのあなたには、 チッ、チッ、チッ(人差し指を横に揺らしながら)。しないんです。ノートの内容は業務のセンシティブな内容も入ってくるから、PCから外には出さないんです。

なのになぜGit管理するかというと、 VSCodeでCommitしていない変更点に色がついて見える。コレ!!このためだけにローカルでGit化するんですね。

今後はLLMが勝手にノートに何か書いてくるわけで、余計なことをされたかもしれない時に、触られた場所を色で見えるようにしといて、確認が終わったらコミットする。この運用は閃いたときに、結構いいなと思ったんですね。OneNoteじゃ絶対できない。

VSCodeとObsidian

VSCode、Obsidian両方からノートの読み書きができて双方にリアルタイムで反映されることが確認できた。これぶっちゃけできてよかったとだいぶ安心した。ファイルロックとかの関係で片方閉じないとだめ、とかそういう挙動だったらワークアラウンド調査面倒そうだったので。

3. ノートの構造の決定

ノートの階層構造を決定する。実はここは後からだいぶ効いてくるので、結構悩ましい。が、GTDの考え方は、どうせ後からグダグダになるのはわかっているけどいったん取り入れていく必要があるので、エイッと決めるしかない。

hayabusa/
├── 0_inbox/          # 未整理の情報を一時的に置く場所
├── 1_tasks/          # タスク管理用
├── 2_projects/       # プロジェクト単位の情報
├── 3_knowledges/     # 知識情報
├── 4_archive/        # 完了したプロジェクトや参照頻度が下がった情報
├── 5_prompts/        # AIへの指示のプロンプトをここで管理
└── _templates/       # Obsidianのテンプレート用

これでフォルダを作っておく。

ちょっとメモ、Claudeによるとこの趣旨でObsidian使うなら以下のプラグインは入れた方がいいって言っているので、後で見てみる。

オススメプラグイン

  • Calendar: 日次ノートとの連携に便利
  • Dataview: ノートの検索・フィルタリング機能を強化
  • Tasks: タスク管理をパワーアップ
  • Templater: テンプレート機能の強化
  • Natural Language Dates: 日付の入力を自然言語で

_templates フォルダは、Obsidianのテンプレート機能を使って、日次ノートやタスクノートの雛形を作成するために使用するモノらしいが、LLMで好き放題に色々やる前提でデイリーノートとかいるかなーというのがちょっと疑問。一応デイリーノートはそれはそれで使うのか? いらないのか?

Qdrantのセットアップとノートの同期

こちらの記事でも紹介したオープンソースのベクトルDBであるQdrantさんを活用させていただく。これはAIのベクトル検索に使うのだが、ノートのフォルダをリアルタイムに読んでエンベディングして更新みたいなことをしないといけない。これが各種ツールで簡単にできるわけではないので、一発目のスクリプトが必要になる。

QdrantはGoで実装されているようだがWindows用のバイナリは(たぶん)ないので、WSL2のUbuntu側で起動する。同期スクリプトもPythonで書くのでそっち側で動かすとする。

$ mkdir ~/hayabusa_vector
$ cd ~/hayabusa_vector
$ vim docker-compose.yml
services:
  note_qdrant:
    image: qdrant/qdrant:latest
    container_name: note_qdrant
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - qdrant_data:/qdrant/storage
    env_file:
      - ./.env
    environment:
      - QDRANT__STORAGE__BACKEND=local
      - QDRANT__STORAGE__LOCAL__PATH=/qdrant/storage
      - QDRANT__SERVICE__API_KEY=WatashiWaHimitsuNoKey
  sync_service:
    build:
      context: ./sync_service/
      dockerfile: Dockerfile
    volumes:
      - /mnt/c/Users/myusername/hayabusa:/note
    env_file:
      - ./.env
    depends_on:
      - note_qdrant

volumes:
  qdrant_data:

WSLからは /mnt/c/Users/myusername/hayabusa っていう感じでWindowsのフォルダが見えるので、たぶんこれでいいはず。

さらに同期サービスのために以下のように各種ファイルを作成する

hayabusa_vector/
├── .env
├── .env.example
├── .gitignore
├── docker-compose.yml
├── sync_service/
  ├── Dockerfile
  ├── requirements.txt
  └── sync.py
# Qdrantの設定
QDRANT_API_KEY=WatashiWaHimitsuNoKey
QDRANT_URL=http://note_qdrant:6333

# AzureOpenAIの設定
AZURE_OPENAI_API_ENDPOINT=https://<your-azure-openai-endpoint>.openai.azure.com/
AZURE_OPENAI_API_KEY=<your-azure-openai-api-key>

AZURE_OPENAI_COMP_MODEL=gpt-4o
AZURE_OPENAI_COMP_DEPLOYMENT=
AZURE_OPENAI_COMP_API_VERSION=

AZURE_OPENAI_EMB_MODEL=text-embedding-3-large
AZURE_OPENAI_EMB_API_VERSION=
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY sync.py .
CMD ["python", "-u", "sync.py"]
# .env
/.env
qdrant-client
openai

以下は、AzureOpenAIを利用しているが、ノートのフォルダ内のファイルの更新を監視して変更はエンベディング後にQdrantに登録するためのスクリプト。ざっくりした動作としては以下。

  1. ノートのフォルダを監視
  2. 変更があったら、そのファイルの内容を読み込んでエンベディング
  3. ファイルのパスと名称が既にQdrantのポイントとして登録されているものであれば更新、そうでなければ新規登録と判断
  4. 仮に画像が含まれていた場合は、格納前にその画像をてGPT-4oで言語化してもらってキャプションに突っ込む
# -*- coding: utf-8 -*-
"""
QdrantとAzureOpenAIを使って、ノートのフォルダを監視し、変更があったらエンベディングしてQdrantに登録するスクリプト
"""
import os
import json
import time
import qdrant_client
from qdrant_client import QdrantClient
from qdrant_client.http import models as rest
import openai
import hashlib
import sys
import re
import uuid

AzureOpenAI_API_ENDPOINT = os.environ.get("AZURE_OPENAI_API_ENDPOINT")
AzureOpenAI_API_KEY = os.environ.get("AZURE_OPENAI_API_KEY")
AzureOpenAI_COMP_API_VERSION = os.environ.get("AZURE_OPENAI_COMP_API_VERSION")
AzureOpenAI_COMP_API_ENDPOINT = os.environ.get("AZURE_OPENAI_COMP_API_ENDPOINT")
AzureOpenAI_COMP_MODEL = os.environ.get("AZURE_OPENAI_COMP_MODEL")
AzureOpenAI_EMB_API_VERSION = os.environ.get("AZURE_OPENAI_EMB_API_VERSION")
AzureOpenAI_EMB_API_ENDPOINT = os.environ.get("AZURE_OPENAI_EMB_API_ENDPOINT")
AzureOpenAI_EMB_MODEL = os.environ.get("AZURE_OPENAI_EMB_MODEL")

QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY")
QDRANT_URL = os.environ.get("QDRANT_URL")
NOTE_DIR = "/note"  # ノートのフォルダパス
VECTOR_DIMENSION = 3072  # 使用する埋め込み次元数

# 埋め込みクライアント設定
embclient = openai.AzureOpenAI(
    azure_endpoint=AzureOpenAI_API_ENDPOINT,
    api_key=AzureOpenAI_API_KEY,
    api_version=AzureOpenAI_EMB_API_VERSION
)
# マルチモーダルに対応したCompletionクライアント設定
compclient = openai.AzureOpenAI(
    azure_endpoint=AzureOpenAI_API_ENDPOINT,
    api_key=AzureOpenAI_API_KEY,
    api_version=AzureOpenAI_COMP_API_VERSION
)

# Qdrantクライアント設定
qdrant = QdrantClient(
    url=QDRANT_URL,
    api_key=QDRANT_API_KEY
)
# コレクション名
COLLECTION_NAME = "hayabusa_notes"

# 監視対象のフォルダ
WATCH_DIR = NOTE_DIR
# 監視対象のファイル拡張子
WATCH_EXTENSIONS = [".md", ".txt"]

def get_file_hash(file_path):
    """
    ファイルのハッシュ値を取得する
    """
    hasher = hashlib.sha256()
    with open(file_path, "rb") as f:
        while chunk := f.read(8192):
            hasher.update(chunk)
    return hasher.hexdigest()

def get_file_embedding(file_path):
    """
    ファイルの内容をエンベディングする関数
    """
    with open(file_path, "r", encoding="utf-8") as f:
        content = f.read()
    # 画像が含まれている場合は、GPT-4oで言語化してキャプションに突っ込む
    if re.search(r'!\[.*\]\((.*)\)', content):
        image_path = re.search(r'!\[.*\]\((.*)\)', content).group(1)
        caption = get_image_caption(image_path)
        content = content.replace(f'![Image]({image_path})', caption)
    embedding = embclient.embeddings.create(
        input=content,
        model=AzureOpenAI_EMB_MODEL,
        dimensions=VECTOR_DIMENSION
    )
    return embedding.data[0].embedding

def get_image_caption(image_path):
    """
    画像をGPT-4oで言語化してキャプションを取得する関数
    """
    with open(image_path, "rb") as f:
        image_data = f.read()
    response = compclient.chat.completions.create(
        model=AzureOpenAI_COMP_MODEL,
        messages=[
            {"role": "user", "content": (
                "この画像を詳細に説明してください。"
                "文字情報は省略することなく全て読み上げるとともに、図表情報から読み取れる関連性可能な限り言語化してください。"
                "同じことを繰り返すなど冗長になっても構いませんので、時間をかけて口頭で説明したら"
                "それを聞いた人が頭の中にイメージを再現できるレベルまで努力してください。"
                "前後にあなたのコメントや説明は不要で、依頼されたタスクの結果のみを出力してください。"
            )},
            {"role": "user", "content": image_data}
        ]
    )
    caption = response.choices[0].message.content
    return caption

def process_file(file_path):
    """
    ファイルを処理してQdrantに登録する関数
    """
    file_hash = get_file_hash(file_path)
    embedding = get_file_embedding(file_path)
    with open(file_path, "r", encoding="utf-8") as f:
        file_content = f.read()
    file_name = os.path.basename(file_path)
    rel_file_path = os.path.relpath(file_path, WATCH_DIR)
    # ファイルパスからUUIDを一貫して生成するため、決定的なUUID(v5)を使用
    point_id = str(uuid.uuid5(uuid.NAMESPACE_OID, rel_file_path))
    point = rest.PointStruct(
        id=point_id,
        vector=embedding,
        payload={
            "file_name": file_name,
            "file_path": rel_file_path,
            "text": file_content,
        }
    )
    # Qdrantに登録
    qdrant.upsert(
        collection_name=COLLECTION_NAME,
        points=[point]
    )
    print(f"File {rel_file_path} processed and uploaded to Qdrant.")
    return file_hash

def watch_directory():
    """
    指定したディレクトリを監視し、変更があったら処理する関数
    """
    file_hashes = {}
    while True:
        for root, dirs, files in os.walk(WATCH_DIR):
            for file in files:
                if any(file.endswith(ext) for ext in WATCH_EXTENSIONS):
                    file_path = os.path.join(root, file)
                    file_hash = get_file_hash(file_path)
                    if file_hash not in file_hashes:
                        file_hashes[file_hash] = process_file(file_path)
        time.sleep(15)

def process_all_files():
    """
    指定したディレクトリ内の全てのファイルを処理する関数
    """
    for root, dirs, files in os.walk(WATCH_DIR):
        for file in files:
            if any(file.endswith(ext) for ext in WATCH_EXTENSIONS):
                file_path = os.path.join(root, file)
                process_file(file_path)

if __name__ == "__main__":
    # Qdrantのコレクションを作成(存在する場合は一度削除して再作成)
    if qdrant.collection_exists(COLLECTION_NAME):
        qdrant.delete_collection(collection_name=COLLECTION_NAME)
    qdrant.create_collection(
        collection_name=COLLECTION_NAME,
        vectors_config=rest.VectorParams(
            size=VECTOR_DIMENSION,
            distance=rest.Distance.COSINE
        )
    )

    # 同期サービスを最初に起動したときはすべてのファイルを処理
    process_all_files()

    # ディレクトリを監視
    watch_directory()

上記スクリプトはまあまあ手こずったけど何とか動いた。

これでようやくDockerを起動。

$ docker-compose up -d

これでQdrantが起動して、ノートのフォルダを監視し、変更があったらエンベディングしてQdrantに登録するスクリプトが実行される。

ノートを書いて動作確認

給湯器交換プロジェクトという架空のノートをプロジェクトフォルダ配下に作成

alt text

Qdrantのダッシュボードを見てみたら、ポイントが来ている。スクショは省略するが、ビジュアル画面を見たらちゃんとエンベディングもされていた。

alt text

ちなみにVSCode側で見てみるとあたりまでではあるが、前回コミットした時点からの差分が色分けで見える。いいぞいいぞ。たまってきたら結構役に立つでしょうこれ。

Github Copilotにも仕事をさせてみる

Qdrantを準備したのとは関係ないけど、VSCodeのCopilotに仕事をさせてみる。

元々ある給湯器交換プロジェクトのノートは、以下のような記述が中に含まれる。

## ⚠️ 課題

- **工事日の家族スケジュール調整が難航中**
  - 妻:在宅ワーク対応可能だが会議多数
  - 長男:模試あり風呂使用制限が厳しい
- **妻が「浴室乾燥機もまとめて工事できないの?」と提案し始め、計画が膨張傾向**
- **古い配管カバーの補修要否**が業者で意見割れ中

---

## 📝 その他経緯や連絡メモなど

- 2025/5/3:夜の入浴時に突然給湯停止 → 故障確定
- 2025/5/4:妻より「今月中にやらなければ一人でやる」との圧
- 2025/5/6:A社に連絡 → 翌日に訪問&現地確認
- 2025/5/20:候補製品の仕様比較 → 週末に最終決定予定

さて、5/27にA社の人が自宅に来てくれて、給湯器の事で打ち合わせをしたときにその会話を文字起こしツールでテキスト化したとする。その時にはいくつかの新事実が発生する。

## 🗣️ 打ち合わせ記録:A社との現地見積(2025/5/27)

**参加者:**  
- A社 工事担当:中村さん  
- 当方(施主):夫(40代・技術職)/妻(在宅ワーク・主婦)

---

### 📝 会話内容(文字起こし)

**中村さん(A社)**  
では早速見せていただきますね。…うん、やはり現在の機種は**2008年製のリンナイですね**。もうメーカーの部品供給も終了してますし、交換は妥当な判断です。

**夫**  
そうですよね。で、以前いただいたノーリツとリンナイの機種候補、ちょっとまだ決めきれなくて…。

**中村さん**  
はい、それなんですが、**一つ注意点**がありまして。  
今、業界全体で**2025年10月から機器価格が一斉に値上がり**するんですよ。だいたい**15〜20%くらい**上がる予定です。  
エコジョーズ系は熱交換器にレアメタル使ってるので、影響が大きいですね。

(続く)

こうなると、プロジェクトの概況に関してのノートに更新が必要になり、通常ならどこに反映されるかを目で追っていく必要があるのだが、はやぶさはここはAIに任せる。

  1. この会話ログをそのまま同じプロジェクトのフォルダに突っ込む
  2. Github Copilotに、このメモを読んで、プロジェクト概況ノートを更新しろ、と命令する

我々オフィスワーカーの仕事でもまま発生するシチュエーションですよね。最新断面の進捗報告資料に対して、いくつかのインプットをもらって差分を反映するという作業で、大事だし必要なんだけど、誰かが勝手にやってくれないかこういうの、っていう。

では、雑な指示でどこまでやれるか見てみようか・・・。Agentモードかつ、Claude 3.7 Sonnetで回した。

alt text

結果は、3~4分くらい待たされて、遅いな・・・とは思ったけど大成功。

alt text

alt text

内容めっちゃ正しい。まあ問題が簡単で、インプットがきれいだったからハードル低めだったか。

当たり前ですけど、こんな風にLLMにノートを更新させるということを、この後色んなとこでやるので、差分をこんな風に見る。納得したらCommitっていうのは人が判断してやらないといけないので、これだけちょっと手間かな。地味にめんどくさくなりそうだけど、これだけはどう考えてもしょうがない。やるしかない。

alt text

Obsidianだとこうはなかなか見えないからなー。もしかしたらプラグインとかあるんだろうか。


エピローグ

コンセプト的にはめっちゃ使える可能性が見えてきた。

次回は、凝った機能の実装をやり始める前にもう少しベースの部分を整備していこうかと思う。Qdrantが生きるのはもう少し先かな。

  • ノートの階層、それぞれの意味合いをもう少しフレームワーク化する
  • プロンプトを管理して自動更新の第一歩をやってみる
  • タスク管理の仕組みを整備する
Accenture Japan (有志)

Discussion