NotebookLMのローカル版「Open Notebook」をLM Studioで完全オフライン構築してみた

NotebookLMのローカル版「Open Notebook」をLM Studioで完全オフライン構築してみた!

Googleの「NotebookLM」、便利ですよね。資料をアップロードするだけで、AIが内容を理解して回答してくれたり、ポッドキャスト風に解説してくれたりするあのツールです。

「でも、社内秘のドキュメントをGoogleにアップロードするのはちょっと…」
「インターネットに繋がっていない環境でも使いたい!」

そんなニーズに応えるべく、GitHubで公開されているOSSの「Open Notebook」を試してみました。今回はこれを、以前の記事でも紹介したLM Studioと組み合わせて、「完全ローカル(インターネット不要)」な環境で動作させることに挑戦しました。

構築にあたって少しハマりポイントがあったので、その回避方法も含めて共有します。


Open Notebookとは?

Open Notebookは、Google NotebookLMのオープンソース代替を目指したプロジェクトです。PDFやWebサイトのURLをソースとして読み込ませ、その内容についてチャットを行うことができます。

公式リポジトリはこちら:
https://github.com/lfnovo/open-notebook


構成:LLMは「LM Studio」を採用

Open Notebookは通常、OpenAIなどのAPIキーを設定して使いますが、今回は完全ローカルを目指すため、バックエンドのLLMとしてLM Studioを採用します。

LM Studioでサーバーを立ち上げ(ポート1234など)、Open Notebook(Dockerコンテナ)からそこに接続する構成です。
ベースとなる docker-compose.yml は以下のようになります。

docker-compose.yml(基本設定)

services:
  open_notebook:
    image: lfnovo/open_notebook:v1-latest-single
    ports:
      - "8502:8502"  # Web UI
      - "5055:5055"  # API
    environment:
      # Database connection (required)
      - SURREAL_URL=ws://localhost:8000/rpc
      - SURREAL_USER=root
      - SURREAL_PASSWORD=root
      - SURREAL_NAMESPACE=open_notebook
      - SURREAL_DATABASE=production
      
      # ここがポイント!LM Studio等のローカルサーバーに向ける
      - OPENAI_COMPATIBLE_BASE_URL=http://192.168.11.45:1234/v1
      - OPENAI_COMPATIBLE_API_KEY=lm-studio

      # LLMとEmbeddingも同様に向け先を指定
      - OPENAI_COMPATIBLE_BASE_URL_LLM=http://192.168.11.45:1234/v1
      - OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://192.168.11.45:1234/v1

    volumes:
      - ./notebook_data:/app/data
      - ./surreal_data:/mydata
    restart: always

192.168.11.45 の部分は、ご自身のPCのIPアドレスに変更してください。Dockerコンテナ内からホスト(LM Studio)を見るため、localhost ではなく実IPを指定するのが無難です。


Open Notebookの起動

docker-compose.yml が用意できたら、そのディレクトリで以下のコマンドを実行しコンテナを起動させます。

docker compose up -d

コンテナが起動したら、ブラウザでアクセスします。Webのポートは8502です。

http://192.168.11.40:8502

正常にアクセスできれば次のような画面になります。こうなれば成功です。


トラブル発生:JSONモードのエラー

意気揚々とコンテナを起動し、UIが表示され、ドキュメントのアップロードまでは順調でした。しかし、いざチャットで質問を投げかけてみると…

ERROR | api.routers.search:stream_ask_response:105 - Error in ask streaming: Error code: 400 - {'error': "'response_format.type' must be 'json_schema' or 'text'"}

エラーが出て動きません。
原因は、Open Notebookが回答生成のプロセスで「JSONモード(json_object)」を強制していることにありました。OpenAIのAPIなら問題ないのですが、LM Studio(およびそこで動かしているローカルモデル)が、このリクエスト形式(response_format={"type": "json_object"})に完全には対応していなかった、あるいは互換性の問題が生じたようです。


解決策:ask.pyの修正とマウント

これを解決するには、Open Notebook側のコードを少し修正し、JSONモードの強制を解除する必要があります。

具体的には、open_notebook/graphs/ask.py というファイル内の記述を変更します。Dockerイメージを直接作り直すのは手間なので、修正したファイルをローカルに作成し、Dockerのvolumeマウントで上書きする方法をとりました。

手順1: 修正版 ask.py の作成

docker-compose.yml と同じ階層に ask.py というファイルを作成し、以下のコードを保存します。
変更点は、provision_langchain_model 関数呼び出し時の structured=dict(type="json") を削除(またはコメントアウト)している点です。

# ask.py (修正版)
import operator
from typing import Annotated, List
from ai_prompter import Prompter
from langchain_core.output_parsers.pydantic import PydanticOutputParser
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, START, StateGraph
from langgraph.types import Send
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
from open_notebook.domain.notebook import vector_search
from open_notebook.graphs.utils import provision_langchain_model
from open_notebook.utils import clean_thinking_content

class SubGraphState(TypedDict):
    question: str
    term: str
    instructions: str
    results: dict
    answer: str
    ids: list

class Search(BaseModel):
    term: str
    instructions: str = Field(
        description="Tell the answeting LLM what information you need extracted from this search"
    )

class Strategy(BaseModel):
    reasoning: str
    searches: List[Search] = Field(
        default_factory=list,
        description="You can add up to five searches to this strategy",
    )

class ThreadState(TypedDict):
    question: str
    strategy: Strategy
    answers: Annotated[list, operator.add]
    final_answer: str

async def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:
    parser = PydanticOutputParser(pydantic_object=Strategy)
    system_prompt = Prompter(prompt_template="ask/entry", parser=parser).render(
        data=state
    )
    # 【修正箇所】structured=dict(type="json") を削除しました
    model = await provision_langchain_model(
        system_prompt,
        config.get("configurable", {}).get("strategy_model"),
        "tools",
        max_tokens=2000,
        # structured=dict(type="json"),  <-- この行を削除またはコメントアウト
    )
    
    ai_message = await model.ainvoke(system_prompt)
    message_content = ai_message.content if isinstance(ai_message.content, str) else str(ai_message.content)
    cleaned_content = clean_thinking_content(message_content)
    strategy = parser.parse(cleaned_content)
    return {"strategy": strategy}

async def trigger_queries(state: ThreadState, config: RunnableConfig):
    return [
        Send(
            "provide_answer",
            {
                "question": state["question"],
                "instructions": s.instructions,
                "term": s.term,
            },
        )
        for s in state["strategy"].searches
    ]

async def provide_answer(state: SubGraphState, config: RunnableConfig) -> dict:
    payload = state
    results = await vector_search(state["term"], 10, True, True)
    if len(results) == 0:
        return {"answers": []}
    payload["results"] = results
    ids = [r["id"] for r in results]
    payload["ids"] = ids
    system_prompt = Prompter(prompt_template="ask/query_process").render(data=payload)
    model = await provision_langchain_model(
        system_prompt,
        config.get("configurable", {}).get("answer_model"),
        "tools",
        max_tokens=2000,
    )
    ai_message = await model.ainvoke(system_prompt)
    ai_content = ai_message.content if isinstance(ai_message.content, str) else str(ai_message.content)
    return {"answers": [clean_thinking_content(ai_content)]}

async def write_final_answer(state: ThreadState, config: RunnableConfig) -> dict:
    system_prompt = Prompter(prompt_template="ask/final_answer").render(data=state)
    model = await provision_langchain_model(
        system_prompt,
        config.get("configurable", {}).get("final_answer_model"),
        "tools",
        max_tokens=2000,
    )
    ai_message = await model.ainvoke(system_prompt)
    final_content = ai_message.content if isinstance(ai_message.content, str) else str(ai_message.content)
    return {"final_answer": clean_thinking_content(final_content)}

agent_state = StateGraph(ThreadState)
agent_state.add_node("agent", call_model_with_messages)
agent_state.add_node("provide_answer", provide_answer)
agent_state.add_node("write_final_answer", write_final_answer)
agent_state.add_edge(START, "agent")
agent_state.add_conditional_edges("agent", trigger_queries, ["provide_answer"])
agent_state.add_edge("provide_answer", "write_final_answer")
agent_state.add_edge("write_final_answer", END)
graph = agent_state.compile()

手順2: docker-compose.yml の更新

作成した ask.py をコンテナ内の所定の位置にマウントするように volumes を追記します。

    volumes:
      - ./notebook_data:/app/data
      - ./surreal_data:/mydata
      # 以下の行を追加:作成したask.pyでコンテナ内のファイルを上書き
      - ./ask.py:/app/open_notebook/graphs/ask.py

これで docker compose up -d をし直せば、エラーが解消され、LM Studioを経由して回答が生成されるようになります!


試してみる

適当にNotebookを作成し、ヤフーニュースからニュース記事をテキストでアップロードします。
3つの記事を試しに入れてみました。

マンションに関するニュースがあるので、「マンションに関する内容は?」とします。
ただし、英語で回答することが多いので、「日本語で回答して」という依頼も付与します。

すると、参考にしたドキュメントリンクも含めながら回答を生成します。

最後に2つのニュース記事に関連する用語で質問します。 「日本語で回答して。高市総理大臣に関するニュースは?」とします。

マンションのニュースもヒットしてしまっていますが、しっかり2つの記事から内容を抽出しています。


さいごに

以上の手順で、Open Notebook × LM Studio による完全ローカルなドキュメント対話環境が構築できました。外部にデータを出したくない場合には非常に有効な選択肢です。

完全ローカルを実現することは可能。、モデルの精度に依存しますが、NotebookLMライクな使い方をすることは可能でした! しかし、1つ課題もありました。色々データを追加してみたのですが、ローカルLLMはコンテキスト長が限られるため、大量にデータ投入したり文字数の多いドキュメントがあるとエラーになってしまったり、処理が終わらないことがありました。 NotebookLMのような大規模な使い方は難しいようです。

一般的なGPU搭載PCで動かすローカルLLM(Llama 3やMistralなど)は、数千〜数万トークン程度が現実的なラインです。
少量のドキュメントなら問題ありませんが、「本を丸ごと一冊読み込ませて、全体を俯瞰して質問する」といった使い方では、やはり本家NotebookLMのパワーには及びません。

用途に合わせて、クラウド(NotebookLM)とローカル(Open Notebook)を使い分けるのが賢い選択と言えそうです。

今回はこの辺で、ではまた!

コメント

このブログの人気の投稿

Power Automateでファイル名から拡張子を取得

PowerAppsで座席表を作成する

Power AutomateでTeamsのキーワードをトリガーにする