📔 NotebookLM, and taking the research home · NotebookLM กับการพางานวิจัยกลับบ้าน · NotebookLM,把研究带回家

Drive NotebookLM from Python. Pull what it wrote onto your own disk. A local Ollama model then answers from it, with LlamaIndex. Nothing leaves the building after the first download. Four challenges. The last is a page that admits when it does not know. · สั่ง NotebookLM จาก Python ดึงสิ่งที่มันเขียนลงดิสก์ของคุณเอง แล้วให้โมเดล Ollama ในเครื่องตอบจากมันด้วย LlamaIndex หลังดาวน์โหลดครั้งแรก ไม่มีอะไรออกไปนอกอาคารอีก โจทย์สี่ข้อ จบด้วยหน้าเว็บที่ยอมรับเมื่อมันไม่รู้ · 用 Python 驱动 NotebookLM,把它写的东西拉到你自己的硬盘上,再用 LlamaIndex 让本地 Ollama 模型从里面回答。第一次下载之后,什么都不出这栋楼。四道题,最后是一个会承认自己不知道的页面。

🇬🇧 English

NotebookLM reads documents and answers questions about them, with a citation for every claim.

It is very good, and it is somebody else's computer. This page takes the research out and runs it on yours.

Three parts. Google does the reading. LlamaIndex keeps the result. A local model answers from it, offline.

🇹🇭 ไทย

NotebookLM อ่านเอกสารแล้วตอบคำถามเกี่ยวกับมัน พร้อมอ้างอิงให้ทุกข้อความที่พูด

มันเก่งมาก และมันคือคอมพิวเตอร์ของคนอื่น หน้านี้จะเอางานวิจัยออกมารันบนเครื่องของคุณ

สามส่วน Google เป็นคนอ่าน LlamaIndex เก็บผลลัพธ์ไว้ แล้วโมเดลในเครื่องตอบจากมันแบบออฟไลน์

🇨🇳 中文

NotebookLM 读文档、回答关于它们的问题,每一句都带引用。

它很好用,而它是别人的电脑。这一页把研究结果取出来,放到你自己的机器上跑。

三个部分。Google 负责读。LlamaIndex 负责存。本地模型离线地从里面回答。

NotebookLMNotebookLMNotebookLMYour own copyสำเนาของคุณเอง你自己的副本
Who reads the documentsใครอ่านเอกสาร谁读文档NotebookLM: Google, very wellNotebookLM: Google และอ่านได้ดีมากNotebookLM:Google,读得很好yours: whatever you indexของคุณ: อะไรก็ตามที่คุณทำดัชนีไว้你的:你索引了什么就是什么
Citationsการอ้างอิง引用NotebookLM: a quote per claimNotebookLM: อ้างอิงทุกข้อความNotebookLM:每句都有出处yours: a score per chunkของคุณ: คะแนนต่อชิ้นข้อความ你的:每块一个分数
Needs the internetต้องใช้อินเทอร์เน็ต需要联网NotebookLM: alwaysNotebookLM: ตลอดเวลาNotebookLM:一直要yours: only to set upของคุณ: เฉพาะตอนติดตั้ง你的:只在搭建时
Measured hereวัดได้ที่นี่这里实测NotebookLM: 15.0 secondsNotebookLM: 15.0 วินาทีNotebookLM:15.0 秒yours: 7.9 secondsของคุณ: 7.9 วินาที你的:7.9 秒

💻 INSTALL THE THREE PIECES

pip install notebooklm-py
pip install llama-index-core llama-index-llms-ollama
pip install llama-index-embeddings-huggingface

python -m notebooklm login    # a browser opens. There is no API key.
THERE IS NO API KEY FOR NOTEBOOKLM This is not an official API. You log in through a browser and the library reuses that session. It can expire without warning. notebooklm status will still say you are fine. Test with notebooklm list, which actually asks the server. นี่ไม่ใช่ API อย่างเป็นทางการ คุณล็อกอินผ่านเบราว์เซอร์ แล้วไลบรารีจะใช้เซสชันนั้นต่อ มันหมดอายุได้โดยไม่เตือน และ notebooklm status ก็ยังจะบอกว่าคุณปกติดี ให้ทดสอบด้วย notebooklm list ซึ่งถามเซิร์ฟเวอร์จริง ๆ 这不是官方 API。你通过浏览器登录,库沿用那个会话。它会毫无预警地过期,而 notebooklm status 还会说你没事。用 notebooklm list 测,它才真的去问服务器。

🖥️ Tutorial 1 — Two machines, one API · บทที่ 1 — สองเครื่อง หนึ่ง API · 教程 1 —— 两台机器,一个 API

🇬🇧 English

Ollama speaks the same HTTP on a laptop and on a box across the room. Only the address changes.

So the only real question is which one to send a job to, and that is a measurement, not an opinion.

🇹🇭 ไทย

Ollama พูด HTTP แบบเดียวกันทั้งบนแล็ปท็อปและบนเครื่องอีกฝั่งห้อง เปลี่ยนแค่ที่อยู่เท่านั้น

คำถามจริงจึงมีข้อเดียว คือจะส่งงานไปเครื่องไหน และนั่นเป็นเรื่องของการวัด ไม่ใช่ความเห็น

🇨🇳 中文

Ollama 在笔记本上和在房间另一头那台机器上,说的是同一套 HTTP。变的只有地址。

所以真正的问题只有一个:把活派给哪一台。那是量出来的,不是意见。

1 What is on the other machine · อีกเครื่องมีอะไรอยู่บ้าง · 另一台机器上有什么

🇬🇧 English

Before anything clever, check the box is awake and see what it is holding.

Two different questions: what is on its disk, and what is in its memory right now.

🇹🇭 ไทย

ก่อนจะทำอะไรฉลาด ๆ ให้เช็กก่อนว่าเครื่องตื่นอยู่ไหม และมันถืออะไรอยู่

เป็นสองคำถามคนละอัน คือมีอะไรอยู่ในดิสก์ กับมีอะไรอยู่ในหน่วยความจำตอนนี้

🇨🇳 中文

在做任何聪明事之前,先确认那台机器醒着,看看它手上有什么。

这是两个不同的问题:硬盘上有什么,和此刻内存里有什么。

📄 NEW FILE · spark1.py

"""spark1.py — is the box there, and what is loaded on it right now?"""
import json
import sys
import urllib.error
import urllib.request

# The machine's name on the network. Yours will be different.
SPARK = "http://spark.local:11434"

sys.stdout.reconfigure(encoding="utf-8")


def ask_spark(path):
    """One GET to the Spark's Ollama, or a plain complaint if it is not there."""
    try:
        with urllib.request.urlopen(SPARK + path, timeout=10) as response:
            return json.load(response)
    except urllib.error.URLError as trouble:
        # A machine that is asleep, renamed or on another network all look
        # the same from here. Say so instead of raising a wall of red.
        print("cannot reach", SPARK)
        print("  ", trouble.reason)
        raise SystemExit(1)


def main():
    """List what the Spark has, and what it is holding in memory."""
    models = ask_spark("/api/tags")["models"]
    print(f"{len(models)} models on the Spark:")
    for model in sorted(models, key=lambda m: -m["size"]):
        print(f"  {model['name']:38} {model['size'] / 1e9:5.1f} GB")

    # /api/ps is different from /api/tags. tags is what is on the disk,
    # ps is what is loaded into memory and answering quickly right now.
    loaded = ask_spark("/api/ps")["models"]
    print()
    if not loaded:
        print("nothing loaded in memory. The first question will be slower.")
    for model in loaded:
        print(f"loaded now: {model['name']}  until {model['expires_at'][:19]}")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
SPARK = "http://spark.local:11434"The other machine's name on your network. Yours will differ.ชื่อของอีกเครื่องบนเครือข่ายของคุณ ของคุณจะไม่เหมือนกัน另一台机器在你网络上的名字。你的会不一样。
except urllib.error.URLError as trouble:Asleep, renamed or on another network all look the same from here.หลับอยู่ เปลี่ยนชื่อ หรืออยู่คนละเครือข่าย มองจากตรงนี้เหมือนกันหมด睡着了、改了名、或者在别的网络上,从这儿看都一样。
ask_spark("/api/tags")What is on the disk. Every model it could load.สิ่งที่อยู่ในดิสก์ ทุกโมเดลที่มันโหลดได้硬盘上有什么。它能加载的每一个模型。
ask_spark("/api/ps")What is in memory. These answer at once; the rest load first.สิ่งที่อยู่ในหน่วยความจำ พวกนี้ตอบทันที ที่เหลือต้องโหลดก่อน内存里有什么。这些立刻能答,其余的要先加载。

🖥️ WHAT IT PRINTED · against a real machine on the wifi

5 models on the Spark:
  qwen2.5:14b                              9.0 GB
  scb10x/typhoon2.1-gemma3-12b:latest      7.8 GB
  gemma2:9b                                5.4 GB
  llama3.1:8b                              4.9 GB
  llama3.2:3b                              2.0 GB

nothing loaded in memory. The first question will be slower.
YOUR TURN
  1. Run it. Change the address to a name that does not exist.
  2. Read the complaint. That is what a sleeping machine looks like.
  3. Put it back. Ask a question, then run this again.
  4. Check: now something is loaded in memory.
  1. รันดู แล้วเปลี่ยนที่อยู่เป็นชื่อที่ไม่มีอยู่จริง
  2. อ่านข้อความบ่นของมัน นั่นคือหน้าตาของเครื่องที่หลับอยู่
  3. เปลี่ยนกลับ ถามคำถามหนึ่งข้อ แล้วรันอันนี้ใหม่
  4. ตรวจ: ตอนนี้ต้องมีอะไรโหลดอยู่ในหน่วยความจำแล้ว
  1. 运行。把地址改成一个不存在的名字。
  2. 读它的抱怨。一台睡着的机器就长这样。
  3. 改回来。问一个问题,再跑一次这个。
  4. 检查:现在内存里有东西了。

2 Ask both of them the same thing · ถามทั้งสองเครื่องด้วยคำถามเดียวกัน · 同一个问题,问两台机器

🇬🇧 English

One function, two addresses. Ollama reports where its own time went, so you get more than a stopwatch.

Total time includes the network. Writing time is only the model. The gap between them is the wire.

🇹🇭 ไทย

ฟังก์ชันเดียว สองที่อยู่ Ollama รายงานด้วยว่าเวลาของมันหมดไปกับอะไร คุณจึงได้มากกว่าแค่จับเวลา

เวลารวมนับเครือข่ายด้วย ส่วนเวลาเขียนคือตัวโมเดลอย่างเดียว ช่องว่างระหว่างสองอันนั้นคือสายเน็ต

🇨🇳 中文

一个函数,两个地址。Ollama 还会报告它自己的时间花在哪,所以你拿到的不只是一个秒表。

总时间含网络。写的时间只算模型。两者之差就是那根线。

📄 NEW FILE · ask1.py

"""ask1.py — the same question to this laptop and to the Spark, timed."""
import json
import sys
import time
import urllib.request

LAPTOP = "http://localhost:11434"
SPARK = "http://spark.local:11434"

QUESTION = "In one sentence, what is a GPU for?"

sys.stdout.reconfigure(encoding="utf-8")


def ask(host, model, question):
    """Send one question and time the whole round trip."""
    payload = json.dumps({
        "model": model,
        "stream": False,
        "options": {"temperature": 0},
        "messages": [{"role": "user", "content": question}],
    }).encode()
    request = urllib.request.Request(
        host + "/api/chat", data=payload,
        headers={"Content-Type": "application/json"})

    start = time.perf_counter()
    with urllib.request.urlopen(request, timeout=600) as response:
        answer = json.load(response)
    wall = time.perf_counter() - start

    # Ollama reports where its own time went, in nanoseconds.
    return {
        "said": answer["message"]["content"].strip(),
        "wall": wall,
        "thinking": answer.get("eval_duration", 0) / 1e9,
        "tokens": answer.get("eval_count", 0),
    }


def main():
    for label, host, model in [("this laptop", LAPTOP, "llama3.2"),
                               ("the Spark  ", SPARK, "qwen2.5:14b")]:
        got = ask(host, model, QUESTION)

        # wall includes the network. thinking is only the model writing.
        # The gap between them is what the wire cost you.
        print(f"{label}  {model:14} {got['wall']:6.2f}s total, "
              f"{got['thinking']:5.2f}s writing, {got['tokens']:3} tokens")
        print("   ", got["said"][:90])
        print()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
answer.get("eval_duration", 0) / 1e9How long the model spent writing, in nanoseconds. Divide for seconds.เวลาที่โมเดลใช้เขียน หน่วยนาโนวินาที หารเพื่อให้เป็นวินาที模型写字花了多久,单位纳秒。除一下变成秒。
answer.get("eval_count", 0)How many tokens came out. Tokens over seconds is the real speed.มีโทเคนออกมากี่ตัว โทเคนหารวินาทีคือความเร็วจริง出了多少 token。token 除以秒才是真正的速度。

🖥️ WHAT IT PRINTED · warm, both models already loaded

this laptop  llama3.2         3.46s total,  3.34s writing,  55 tokens
the Spark    qwen2.5:14b      5.34s total,  2.38s writing,  59 tokens

🇬🇧 English

The big model on the other machine wrote faster than the small one here. It still took longer.

Nearly three seconds went on the network. For a short answer that is most of the job.

🇹🇭 ไทย

โมเดลใหญ่บนอีกเครื่องเขียนเร็วกว่าโมเดลเล็กที่นี่ แต่มันก็ยังใช้เวลานานกว่า

เกือบสามวินาทีหมดไปกับเครือข่าย สำหรับคำตอบสั้น ๆ นั่นคือเกือบทั้งงาน

🇨🇳 中文

另一台机器上的大模型写得更快,可它总共还是更慢。

将近三秒花在网络上。对一个短答案来说,那就是大部分。

YOUR TURN
  1. Run it twice. The first run includes loading the models.
  2. Compare the two writing times, not the two totals.
  3. Work out tokens per second for each.
  4. Which machine is actually faster at thinking?
  1. รันสองครั้ง ครั้งแรกรวมเวลาโหลดโมเดลด้วย
  2. เทียบเวลาเขียนของสองเครื่อง ไม่ใช่เวลารวม
  3. คำนวณโทเคนต่อวินาทีของแต่ละเครื่อง
  4. เครื่องไหนคิดเร็วกว่ากันจริง ๆ
  1. 跑两次。第一次包含加载模型的时间。
  2. 比两个写的时间,不是两个总时间。
  3. 各算一下每秒多少 token。
  4. 到底哪台机器想得更快?

3 So when is sending it away worth it · แล้วเมื่อไหร่ที่การส่งงานออกไปถึงจะคุ้ม · 那什么时候派出去才划算

🇬🇧 English

Two jobs, one short and one long, on both machines. The answer is a rule you can use.

🇹🇭 ไทย

สองงาน อันหนึ่งสั้น อันหนึ่งยาว ทำบนทั้งสองเครื่อง คำตอบคือกฎที่เอาไปใช้ได้จริง

🇨🇳 中文

两个任务,一短一长,在两台机器上都跑。答案是一条你能拿去用的规则。

📄 NEW FILE · when1.py

"""when1.py — when is sending the job to the Spark actually worth it?"""
import json
import sys
import time
import urllib.request

LAPTOP = "http://localhost:11434"
SPARK = "http://spark.local:11434"

JOBS = [("a short answer", "In one sentence, what is a GPU for?"),
        ("a long answer",
         "Write 500 words about why Chiang Mai has a burning season.")]

sys.stdout.reconfigure(encoding="utf-8")


def ask(host, model, question):
    payload = json.dumps({"model": model, "stream": False,
                          "options": {"temperature": 0},
                          "messages": [{"role": "user", "content": question}]}).encode()
    request = urllib.request.Request(host + "/api/chat", data=payload,
                                     headers={"Content-Type": "application/json"})
    start = time.perf_counter()
    with urllib.request.urlopen(request, timeout=1800) as response:
        answer = json.load(response)
    wall = time.perf_counter() - start
    tokens = answer.get("eval_count", 0)
    writing = answer.get("eval_duration", 0) / 1e9
    return wall, tokens, writing


def main():
    print(f"{'job':16} {'where':12} {'model':14} {'total':>8} {'writing':>9} "
          f"{'tokens':>7} {'tok/s':>7}")
    print("-" * 80)
    for label, question in JOBS:
        for where, host, model in [("laptop", LAPTOP, "llama3.2"),
                                   ("Spark", SPARK, "qwen2.5:14b")]:
            wall, tokens, writing = ask(host, model, question)
            rate = tokens / writing if writing else 0

            # wall minus writing is everything that was not the model:
            # the network, the queue, and reading the question back.
            print(f"{label:16} {where:12} {model:14} {wall:7.2f}s "
                  f"{writing:8.2f}s {tokens:7} {rate:7.1f}")
        print()


if __name__ == "__main__":
    main()

🖥️ WHAT IT PRINTED · the whole argument, in four rows

job              where     model           total   writing  tokens   tok/s
a short answer   laptop    llama3.2        3.36s     3.27s      55    16.8
a short answer   Spark     qwen2.5:14b     5.40s     2.38s      59    24.8

a long answer    laptop    llama3.2       51.48s    51.25s     724    14.1
a long answer    Spark     qwen2.5:14b    29.01s    25.69s     623    24.3
Jobงาน任务Laptopแล็ปท็อป笔记本SparkSparkSparkSend it away?ส่งออกไปไหม要派出去吗
short answerคำตอบสั้น短答案laptop: 3.36sแล็ปท็อป: 3.36s笔记本:3.36 秒Spark: 5.40sSpark: 5.40sSpark:5.40 秒no, the wire costs moreไม่ สายเน็ตแพงกว่า不,线比活贵
long answerคำตอบยาว长答案laptop: 51.48sแล็ปท็อป: 51.48s笔记本:51.48 秒Spark: 29.01sSpark: 29.01sSpark:29.01 秒yes, almost twice as fastใช่ เร็วขึ้นเกือบเท่าตัว要,快了将近一倍
THE RULE IS ABOUT LENGTH, NOT DIFFICULTY The Spark writes at about 24 tokens a second no matter what. The laptop manages 14 to 17. The network costs roughly three seconds every time. The big machine wins once the answer pays for the trip. Here that is past ten seconds of writing. Spark เขียนได้ราว 24 โทเคนต่อวินาทีไม่ว่าจะถามอะไร ส่วนแล็ปท็อปทำได้ 14 ถึง 17 และเครือข่ายกินเวลาราวสามวินาทีทุกครั้ง เครื่องใหญ่จึงชนะเมื่อคำตอบยาวพอจะคุ้มค่าเดินทาง ซึ่งตรงนี้คือประมาณเกินสิบวินาทีของการเขียน 不管问什么,Spark 大约每秒 24 个 token。笔记本是 14 到 17。而网络每次大约要三秒。所以一旦答案长到值回路费,大机器就赢;在这里,那个点大概在写满十秒之后。
YOUR TURN
  1. Run it. It takes a couple of minutes.
  2. Add a middle-sized job: 100 words.
  3. Find the length where the two machines tie.
  4. That number is yours, not mine. Your network is different.
  1. รันดู ใช้เวลาสองสามนาที
  2. เพิ่มงานขนาดกลาง 100 คำ
  3. หาความยาวที่สองเครื่องเสมอกัน
  4. ตัวเลขนั้นเป็นของคุณ ไม่ใช่ของผม เครือข่ายคุณไม่เหมือนกัน
  1. 运行。要花个几分钟。
  2. 加一个中等的活:100 个词。
  3. 找出两台机器打平的那个长度。
  4. 那个数字是你的,不是我的。你的网络不一样。

📔 Tutorial 2 — NotebookLM from Python · บทที่ 2 — NotebookLM จาก Python · 教程 2 —— 用 Python 操作 NotebookLM

🇬🇧 English

Everything the website does, from a script. Make notebooks, feed them documents, ask questions.

One surprise up front: every call is asynchronous.

🇹🇭 ไทย

ทุกอย่างที่เว็บไซต์ทำได้ ทำจากสคริปต์ได้หมด สร้างโน้ตบุ๊ก ป้อนเอกสารให้มัน แล้วถามคำถาม

มีเรื่องเซอร์ไพรส์อยู่อย่างหนึ่ง ทุกคำสั่งเป็นแบบอะซิงโครนัส

🇨🇳 中文

网站能做的一切,都能从脚本里做。建笔记本、喂文档、问问题。

先说一个意外:每一个调用都是异步的。

4 Connecting, and the async surprise · การเชื่อมต่อ กับเซอร์ไพรส์เรื่อง async · 连接,以及异步这个意外

🇬🇧 English

There is no API key. You log in once in a browser and the library reuses that session.

Then the surprise. Every method here returns a coroutine, so the whole file is async def and await.

🇹🇭 ไทย

ไม่มี API key คุณล็อกอินครั้งเดียวในเบราว์เซอร์ แล้วไลบรารีจะใช้เซสชันนั้นต่อ

แล้วก็เซอร์ไพรส์ ทุกเมธอดในนี้คืนค่าเป็น coroutine ทั้งไฟล์จึงเป็น async def กับ await

🇨🇳 中文

没有 API key。你在浏览器里登录一次,库沿用那个会话。

然后是那个意外:这里每个方法都返回一个协程,所以整个文件都是 async def 和 await。

📄 NEW FILE · nb1.py

"""nb1.py — connect to NotebookLM from Python, and see what is there.

Every call in this library is asynchronous, so the whole file is written
with async and await. That is three extra words, and nothing else changes.
"""
import asyncio
import sys

from notebooklm import AuthTokens
from notebooklm.client import NotebookLMClient

sys.stdout.reconfigure(encoding="utf-8")


async def main():
    """List the notebooks in the account this computer is logged in to."""
    # There is no API key for NotebookLM. You log in once at the terminal,
    #     python -m notebooklm login
    # which saves a file, and from_storage reads it back.
    auth = await AuthTokens.from_storage()

    # async with, not with. The client opens a connection and must close it.
    async with NotebookLMClient(auth) as client:
        # A property, not a method. It is already a True or False.
        print("connected:", client.is_connected)

        notebooks = await client.notebooks.list()
        print(f"{len(notebooks)} notebooks:")
        for notebook in notebooks[:8]:
            print(f"  {notebook.id[:8]}  {notebook.title[:52]}")


if __name__ == "__main__":
    # asyncio.run starts the loop, runs main to the end, and stops the loop.
    asyncio.run(main())
LineWhat it does · ทำอะไร · 做什么
auth = await AuthTokens.from_storage()Reads the file the browser login wrote. Not a key, a session.อ่านไฟล์ที่การล็อกอินผ่านเบราว์เซอร์เขียนไว้ ไม่ใช่คีย์ แต่เป็นเซสชัน读浏览器登录时写下的那个文件。不是密钥,是会话。
async with NotebookLMClient(auth) as client:async with, not with. It opens a connection and must close it.ใช้ async with ไม่ใช่ with มันเปิดการเชื่อมต่อและต้องปิดด้วย是 async with,不是 with。它开了连接,必须关掉。
print("connected:", client.is_connected)A property, with no brackets. Add brackets and Python complains.เป็นพร็อพเพอร์ตี ไม่ต้องใส่วงเล็บ ถ้าใส่ Python จะฟ้อง是一个属性,不加括号。加了 Python 会报错。
asyncio.run(main())Starts the loop, runs main to the end, stops the loop.เริ่มลูป รัน main จนจบ แล้วหยุดลูป启动循环,把 main 跑完,再停掉循环。

🖥️ WHAT IT PRINTED · against a real account

connected: True
84 notebooks:
  0c276440  auth probe
  59c83b7b  Homework: Why Chiang Mai burns every March
  44b9c845  smolagents — Hugging Face's Tiny Python Agent Librar
  9de4b9ed  PostgreSQL & SQL Basics — Beginner Lab
STATUS LIES. LIST DOES NOT. notebooklm status reads a local file and says you are logged in. The session behind it can be dead. The first honest signal is a call that reaches Google. Test with notebooklm list or client.is_connected. This page was written the day the login expired. Status never mentioned it. notebooklm status อ่านไฟล์ในเครื่องแล้วบอกว่าคุณล็อกอินอยู่ แต่เซสชันเบื้องหลังอาจตายไปแล้ว สัญญาณแรกที่ซื่อสัตย์คือคำสั่งที่วิ่งไปถึง Google จริง ๆ ให้ทดสอบด้วย notebooklm list หรือ client.is_connected หน้านี้เขียนขึ้นในวันที่การล็อกอินหมดอายุพอดี และ status ไม่เคยเอ่ยถึงเลย notebooklm status 读的是本地文件,会说你已登录。它背后的会话可能已经死了。第一个诚实的信号是真的连到 Google 的那种调用,所以用 notebooklm list 或 client.is_connected 测。这一页正好是在登录过期那天写的,而 status 一个字都没提。
YOUR TURN
  1. Run python -m notebooklm login first.
  2. Run the file. Check: connected is True.
  3. Remove one await. Read the error carefully.
  4. It says coroutine was never awaited. Remember that sentence.
  1. รัน python -m notebooklm login ก่อน
  2. รันไฟล์ ตรวจ: connected ต้องเป็น True
  3. ลบ await ออกหนึ่งตัว แล้วอ่านข้อผิดพลาดให้ดี
  4. มันบอกว่า coroutine was never awaited จำประโยคนี้ไว้
  1. 先运行 python -m notebooklm login。
  2. 运行这个文件。检查:connected 是 True。
  3. 删掉一个 await,仔细读报错。
  4. 它会说 coroutine was never awaited。记住这句话。

5 Give it documents, then ask · ป้อนเอกสารให้มัน แล้วถาม · 给它文档,然后问

🇬🇧 English

The example feeds it three pages from this site, so you can check the answers yourself.

Then the thing NotebookLM is actually for: an answer that only uses those pages, and says which one.

🇹🇭 ไทย

ตัวอย่างนี้ป้อนสามหน้าจากเว็บนี้ให้มัน คุณจะได้ตรวจคำตอบเองได้

แล้วก็สิ่งที่ NotebookLM มีไว้ทำจริง ๆ คือคำตอบที่ใช้แค่หน้าเหล่านั้น และบอกด้วยว่าใช้หน้าไหน

🇨🇳 中文

这个例子喂给它本站的三个页面,你可以自己核对答案。

然后是 NotebookLM 真正的用处:只用那些页面回答,并说清楚用了哪一页。

📄 NEW FILE · nb2.py

"""nb2.py — make a notebook, give it pages to read, and ask it a question."""
import asyncio
import sys

from notebooklm import AuthTokens
from notebooklm.client import NotebookLMClient

TITLE = "krueng.ai tutorials"
PAGES = ["https://krueng.ai/mailbot.html",
         "https://krueng.ai/gradio.html",
         "https://krueng.ai/python_classes.html"]

sys.stdout.reconfigure(encoding="utf-8")


async def find_or_make(client, title):
    """Use the notebook if it exists, otherwise make it. Run twice, safely."""
    for notebook in await client.notebooks.list():
        if notebook.title == title:
            print("using the notebook that is already there:", notebook.id[:8])
            return notebook
    notebook = await client.notebooks.create(title)
    print("made a new notebook:", notebook.id[:8])
    return notebook


async def main():
    auth = await AuthTokens.from_storage()
    async with NotebookLMClient(auth) as client:
        notebook = await find_or_make(client, TITLE)

        # Compare the addresses, not the titles. NotebookLM renames a source
        # to the page's own title, so matching on the file name never hits
        # and you quietly add everything a second time.
        already = {source.url for source in
                   await client.sources.list(notebook.id) if source.url}
        for url in PAGES:
            if url in already:
                print("  already has", url.rsplit("/", 1)[-1])
                continue

            # wait=True holds here until NotebookLM has fetched and read the
            # page. Without it you get a source id for something not ready,
            # and the first question comes back with nothing to cite.
            source = await client.sources.add_url(notebook.id, url, wait=True,
                                                  wait_timeout=300)
            print("  added", source.title[:48])

        sources = await client.sources.list(notebook.id)
        print(f"{len(sources)} sources in the notebook")

        # This is the part worth paying for. The answer comes only from the
        # sources, and it says which one each claim came from.
        answer = await client.chat.ask(
            notebook.id,
            "What does the mail bot use to decide whether to reply, "
            "and why was the earlier hidden watermark dropped?")
        print()
        print(answer.answer[:600])
        print()

        # The numbers in the answer, like [1-3], point at these. Each one
        # names the source it came from and quotes the exact sentence.
        by_source = {source.id: source.title for source in sources}
        print(f"{len(answer.references)} references behind that answer.")

        # Not every reference quotes a whole sentence. A page has headings,
        # table cells and navigation in it, and those get cited too. Show
        # the ones long enough to read.
        useful = [r for r in answer.references
                  if r.cited_text and len(r.cited_text.strip()) > 40]
        print(f"{len(useful)} of them quote a real sentence:")
        for reference in useful[:3]:
            where = (by_source.get(reference.source_id) or "?")[:30]
            quoted = " ".join(reference.cited_text.split())[:70]
            print(f"  [{reference.citation_number}] {where:32} {quoted}")


if __name__ == "__main__":
    asyncio.run(main())
LineWhat it does · ทำอะไร · 做什么
already = {source.url for source inCompare addresses. NotebookLM renames a source to the page's title.เทียบที่อยู่ NotebookLM จะเปลี่ยนชื่อแหล่งข้อมูลเป็นชื่อของหน้านั้น比地址。NotebookLM 会把来源改名成页面自己的标题。
wait=True,Hold until it has fetched and read the page. Without it, no citations.รอจนกว่ามันจะดึงและอ่านหน้านั้นเสร็จ ถ้าไม่รอ จะไม่มีการอ้างอิง等到它抓完并读完这一页。不等的话,没有引用。
answer = await client.chat.ask(The answer comes from the sources only, not from the model's memory.คำตอบมาจากแหล่งข้อมูลเท่านั้น ไม่ได้มาจากความจำของโมเดล答案只来自这些来源,不来自模型的记忆。
if r.cited_text and len(r.cited_text.strip()) > 40Headings and table cells get cited too. Keep the real sentences.หัวข้อกับช่องในตารางก็ถูกอ้างอิงด้วย ให้เก็บเฉพาะประโยคจริง标题和表格单元也会被引用。留下真正的句子。

🖥️ WHAT IT PRINTED · three pages in, one grounded answer out

using the notebook that is already there: ab9fda9b
  already has mailbot.html
  already has gradio.html
  already has python_classes.html
3 sources in the notebook

1. Loop Guard Check ([horoscope-bot] tag): The bot first checks if the
subject line contains its explicit tag [1, 4, 5]. Because email replies,
forwards, and out-of-office auto-responses quote the original subject
line back, finding this tag means the email is a response to the bot
itself [1, 4].

12 references behind that answer.
8 of them quote a real sentence.
FEED IT A TRILINGUAL PAGE AND THE CITATIONS COME BACK TRILINGUAL The pages here carry English, Thai and Chinese in the same HTML. NotebookLM read all of it. Several citations quote Thai sentences. Several quote lines of Python. Both are on the page. It is not wrong. It reminds you what a source really is. Whatever the document contains, not the tidy version in your head. หน้าเว็บพวกนี้มีทั้งอังกฤษ ไทย และจีนอยู่ใน HTML เดียวกัน NotebookLM อ่านทั้งหมดนั้น การอ้างอิงหลายอันจึงยกประโยคภาษาไทยมา และอีกหลายอันยกบรรทัดโค้ด Python มา เพราะทั้งสองอย่างอยู่บนหน้านั้นจริง มันไม่ได้ผิด แต่เป็นเครื่องเตือนว่าแหล่งข้อมูลคือสิ่งที่เอกสารมีจริง ๆ ไม่ใช่เวอร์ชันเรียบร้อยที่อยู่ในหัวคุณ 这些页面把英文、泰文、中文放在同一份 HTML 里。NotebookLM 全都读了。所以好几条引用引的是泰文句子,还有好几条引的是 Python 代码行,因为它们确实都在那一页上。这不算错。它提醒你:来源是文档里真正有的东西,不是你脑子里那个整齐的版本。
YOUR TURN
  1. Run it. Open notebooklm.google.com and look at the notebook.
  2. Run it again. Check: it says already has, three times.
  3. Ask it something the pages do not cover. Read the refusal.
  4. Add a page of your own with add_url.
  1. รันดู แล้วเปิด notebooklm.google.com ไปดูโน้ตบุ๊กนั้น
  2. รันอีกครั้ง ตรวจ: ต้องขึ้นว่า already has สามครั้ง
  3. ถามเรื่องที่หน้าพวกนั้นไม่ได้พูดถึง แล้วอ่านคำปฏิเสธ
  4. เพิ่มหน้าของคุณเองด้วย add_url
  1. 运行。打开 notebooklm.google.com 看看那个笔记本。
  2. 再运行一次。检查:它说三次 already has。
  3. 问一个这些页面没讲的东西,读它的拒绝。
  4. 用 add_url 加一个你自己的页面。

🏠 Tutorial 3 — Bringing the research home · บทที่ 3 — พางานวิจัยกลับบ้าน · 教程 3 —— 把研究带回家

🇬🇧 English

NotebookLM has done the hard part. Now take the result off Google's computer and put it on yours.

After this, nothing on the page needs the internet again.

🇹🇭 ไทย

NotebookLM ทำส่วนที่ยากเสร็จแล้ว ทีนี้เอาผลลัพธ์ออกจากคอมพิวเตอร์ของ Google มาไว้บนเครื่องคุณ

หลังจากนี้ ไม่มีอะไรในหน้านี้ต้องใช้อินเทอร์เน็ตอีกเลย

🇨🇳 中文

难的那部分 NotebookLM 已经做完了。现在把结果从 Google 的电脑上取下来,放到你的电脑上。

之后,这一页上就再没有什么需要联网了。

6 Take the text and the briefing · เอาทั้งตัวข้อความและบทสรุปออกมา · 把正文和简报都取出来

🇬🇧 English

Two things worth having. The sources as plain text, with the HTML already stripped off.

And the briefing NotebookLM writes itself, which is the part you cannot get any other way.

🇹🇭 ไทย

มีสองอย่างที่ควรเก็บไว้ อย่างแรกคือตัวแหล่งข้อมูลในรูปข้อความล้วน ที่ลอก HTML ออกให้แล้ว

อีกอย่างคือบทสรุปที่ NotebookLM เขียนเอง ซึ่งเป็นส่วนที่หาจากที่อื่นไม่ได้

🇨🇳 中文

有两样东西值得留。一是来源的纯文本,HTML 已经替你剥掉了。

二是 NotebookLM 自己写的简报,那是别处拿不到的部分。

📄 NEW FILE · bring1.py

"""bring1.py — ask NotebookLM to write a briefing, then bring it home.

NotebookLM does the reading and the synthesis. This takes the result out
of Google and puts it in a folder on your disk, where the rest of the
page can use it without asking anybody's permission again.
"""
import asyncio
import sys
from pathlib import Path

from notebooklm import AuthTokens
from notebooklm.client import NotebookLMClient

TITLE = "krueng.ai tutorials"
HOME = Path("research")

sys.stdout.reconfigure(encoding="utf-8")


async def main():
    HOME.mkdir(exist_ok=True)
    auth = await AuthTokens.from_storage()

    async with NotebookLMClient(auth) as client:
        notebook = next(n for n in await client.notebooks.list()
                        if n.title == TITLE)
        sources = await client.sources.list(notebook.id)

        # 1. The sources themselves, as plain text. This is what NotebookLM
        #    read, with the HTML stripped off for you.
        for source in sources:
            fulltext = await client.sources.get_fulltext(notebook.id, source.id)
            name = (source.url or source.title or source.id).rsplit("/", 1)[-1]
            target = HOME / (name.replace(".html", "") + ".txt")
            target.write_text(fulltext.content, encoding="utf-8")
            print(f"  {target.name:22} {len(fulltext.content):>7,} characters")

        # 2. The briefing NotebookLM writes itself. Generating one is slow,
        #    so reuse the last one rather than making a new one every run.
        reports = await client.artifacts.list_reports(notebook.id)
        if reports:
            print("reusing the briefing already there")
        else:
            print("asking for a briefing, this takes a minute")
            task = await client.artifacts.generate_report(notebook.id)
            await client.artifacts.wait_for_completion(notebook.id, task.task_id,
                                                       timeout=600)

        target = HOME / "briefing.md"
        await client.artifacts.download_report(notebook.id, str(target))
        print(f"  {target.name:22} {len(target.read_text(encoding='utf-8')):>7,} characters")


if __name__ == "__main__":
    asyncio.run(main())
LineWhat it does · ทำอะไร · 做什么
await client.sources.get_fulltext(notebook.id, source.id)What NotebookLM read, as text. Someone else did the scraping.สิ่งที่ NotebookLM อ่าน ในรูปข้อความ คนอื่นขูดข้อมูลมาให้แล้วNotebookLM 读到的东西,纯文本。抓取的活别人替你干了。
reports = await client.artifacts.list_reports(notebook.id)Reuse the last briefing. Making one costs a minute every time.ใช้บทสรุปอันเดิมซ้ำ การสร้างใหม่กินเวลาเป็นนาทีทุกครั้ง重用上一份简报。每做一份都要花上一分钟。
await client.artifacts.wait_for_completion(Generating is a job with an id. This waits for that job.การสร้างคืองานที่มี id อันนี้คือการรองานนั้น生成是一个带 id 的任务。这一句在等那个任务。

🖥️ WHAT IT PRINTED · 163,000 characters in, 7,925 out

  mailbot.txt             44,052 characters
  python_classes.txt      67,756 characters
  gradio.txt              51,711 characters
asking for a briefing, this takes a minute
  briefing.md              7,925 characters
YOUR TURN
  1. Run it. Open research/briefing.md and read it.
  2. Compare it with the pages it came from.
  3. Run it again. Check: it reuses the briefing.
  4. Try generate_study_guide instead of a report.
  1. รันดู แล้วเปิด research/briefing.md อ่าน
  2. เทียบกับหน้าที่มันมาจาก
  3. รันอีกครั้ง ตรวจ: ต้องใช้บทสรุปเดิมซ้ำ
  4. ลอง generate_study_guide แทนการทำรายงาน
  1. 运行。打开 research/briefing.md 读一读。
  2. 和它来源的那些页面比一比。
  3. 再运行一次。检查:它重用了简报。
  4. 把报告换成 generate_study_guide 试试。

7 A local model answers from it · โมเดลในเครื่องตอบจากมัน · 本地模型从里面回答

🇬🇧 English

LlamaIndex cuts the folder into chunks, turns each into numbers, and finds the few that match a question.

Only those few go to the model. That is the whole trick, and it is why a small machine can answer about a big folder.

Nothing in this file talks to Google.

🇹🇭 ไทย

LlamaIndex หั่นโฟลเดอร์เป็นชิ้น ๆ เปลี่ยนแต่ละชิ้นเป็นตัวเลข แล้วหาชิ้นที่ตรงกับคำถาม

มีแค่ไม่กี่ชิ้นนั้นที่ถูกส่งไปให้โมเดล นั่นคือทริกทั้งหมด และเป็นเหตุผลที่เครื่องเล็ก ๆ ตอบเรื่องโฟลเดอร์ใหญ่ ๆ ได้

ไม่มีอะไรในไฟล์นี้ที่คุยกับ Google

🇨🇳 中文

LlamaIndex 把文件夹切成块,每块变成数字,再找出跟问题对得上的那几块。

只有那几块会送到模型那里。诀窍全在这,所以一台小机器也能回答一个大文件夹的问题。

这个文件里没有任何东西会连 Google。

📄 NEW FILE · index1.py

"""index1.py — the research goes in, a local model answers out of it.

Nothing here talks to Google. NotebookLM did its part in bring1.py and
left the text in a folder. From this point on the whole thing runs on
machines you can touch.
"""
import sys
import time
from pathlib import Path

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

HOME = Path("research")

# The Spark, from step 2. Long answers are where it wins.
OLLAMA = "http://spark.local:11434"
MODEL = "qwen2.5:14b"

# The sources are in English, Thai and Chinese, so the English-only
# embedding model would score a Thai paragraph the same as any other.
EMBEDDINGS = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"

QUESTIONS = ["Why was the hidden white-text watermark dropped from the mail bot?",
             "What does gr.State do, and why not a plain variable?"]

sys.stdout.reconfigure(encoding="utf-8")


def build():
    """Read the folder, cut it into chunks, and embed every chunk."""
    # Settings is LlamaIndex's box of defaults. Set these two and every
    # other call in the library uses them.
    Settings.llm = Ollama(model=MODEL, base_url=OLLAMA, request_timeout=900)
    Settings.embed_model = HuggingFaceEmbedding(model_name=EMBEDDINGS)

    documents = SimpleDirectoryReader(str(HOME)).load_data()
    print(f"{len(documents)} documents read from {HOME}/")

    started = time.perf_counter()
    index = VectorStoreIndex.from_documents(documents)
    print(f"indexed in {time.perf_counter() - started:.1f}s")
    return index


def main():
    index = build()

    # top_k=3 means three chunks go to the model, not the whole folder.
    # That is the point: the model only reads what the search found.
    engine = index.as_query_engine(similarity_top_k=3)

    for question in QUESTIONS:
        started = time.perf_counter()
        answer = engine.query(question)
        print()
        print("Q:", question)
        print("  ", str(answer).strip()[:320])
        print(f"   ({time.perf_counter() - started:.1f}s, "
              f"{len(answer.source_nodes)} chunks used)")
        for node in answer.source_nodes:
            where = Path(node.metadata.get("file_name", "?")).name
            print(f"     {node.score:.3f}  {where}")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
Settings.llm = Ollama(model=MODEL, base_url=OLLAMA,The Spark, from step 3. A RAG answer is long enough to send away.ใช้ Spark จากขั้นที่ 3 คำตอบแบบ RAG ยาวพอที่จะคุ้มค่าส่งออกไป用第 3 步的 Spark。RAG 的答案够长,值得派出去。
Settings.embed_model = HuggingFaceEmbedding(The embeddings run here, on the CPU. No API and no key.ตัวเอมเบดดิงรันที่นี่ บน CPU ไม่มี API และไม่มีคีย์嵌入在这儿跑,用 CPU。没有 API,也没有密钥。
EMBEDDINGS = "sentence-transformers/paraphrase-multilingualMultilingual on purpose. The sources are in three languages.เลือกแบบหลายภาษาโดยตั้งใจ เพราะแหล่งข้อมูลมีสามภาษา特意选多语言的。这些来源有三种语言。
engine = index.as_query_engine(similarity_top_k=3)Three chunks go to the model, not the whole folder.ส่งไปให้โมเดลสามชิ้น ไม่ใช่ทั้งโฟลเดอร์送三块给模型,不是整个文件夹。

🖥️ WHAT IT PRINTED · the answer, and where it came from

4 documents read from research/
indexed in 6.7s

Q: Why was the hidden white-text watermark dropped from the mail bot?
   The hidden white-text watermark was dropped because it caused issues
   with SpamAssassin scoring hidden text, leading the bot's own emails
   to be flagged as spam.
   (6.8s, 3 chunks used)
     0.547  mailbot.txt
     0.499  mailbot.txt
     0.441  mailbot.txt
A GROUNDED ANSWER AND A CONFIDENT GUESS LOOK THE SAME Ask this index for the capital of Thailand and it answers Bangkok. The best chunk scored 0.464, from the Gradio page. That page does not say it. The model answered from its own memory. The output looks identical to a real one. Ask it about sourdough, where the best chunk scores 0.073. It correctly refuses. Read the score, not the confidence. ลองถามดัชนีนี้ว่าเมืองหลวงของไทยคืออะไร มันตอบว่ากรุงเทพ ชิ้นที่ดีที่สุดได้ 0.464 มาจากหน้า Gradio ซึ่งไม่ได้พูดเรื่องนี้เลย โมเดลตอบจากความจำของตัวเอง และผลลัพธ์หน้าตาเหมือนคำตอบจริงทุกประการ ลองถามเรื่องขนมปังซาวร์โดว์ ที่ชิ้นดีที่สุดได้ 0.073 มันจะปฏิเสธอย่างถูกต้อง ให้อ่านคะแนน ไม่ใช่ความมั่นใจ 问这个索引泰国的首都是哪,它答曼谷。最好的那块得 0.464,来自 Gradio 那一页,而那页根本没提。模型是凭自己的记忆答的,输出看起来和真正有据的答案一模一样。再问它酸面包,最好的块只有 0.073,它就正确地拒绝了。看分数,别看语气。
YOUR TURN
  1. Run it. Read the scores under each answer.
  2. Ask it something not in the folder. Watch the scores fall.
  3. Ask the same question vaguely, then precisely. Compare scores.
  4. Set similarity_top_k=1. Do the answers get worse?
  1. รันดู แล้วอ่านคะแนนใต้คำตอบแต่ละอัน
  2. ถามเรื่องที่ไม่มีในโฟลเดอร์ แล้วดูคะแนนตก
  3. ถามคำถามเดิมแบบกว้าง ๆ แล้วแบบเจาะจง เทียบคะแนนกัน
  4. ตั้ง similarity_top_k=1 คำตอบแย่ลงไหม
  1. 运行,读每个答案下面的分数。
  2. 问一个文件夹里没有的东西,看分数掉下去。
  3. 同一个问题,先含糊地问,再具体地问,比分数。
  4. 把 similarity_top_k=1。答案变差了吗?

🏆 Tutorial 4 — Four challenges · บทที่ 4 — โจทย์สี่ข้อ · 教程 4 —— 四道题

🇬🇧 English

Each one was run before it was written down. Try it first.

🇹🇭 ไทย

ทุกข้อถูกรันจริงก่อนจะถูกเขียนลงมา ลองทำเองก่อน

🇨🇳 中文

每一道都是先跑过才写下来的。先自己试。

8 Try first. The answers are folded away. · ลองเองก่อน เฉลยพับซ่อนไว้ · 先自己试。答案折起来放着。

🇬🇧 English

They build on each other. The last one is a page you can leave running.

🇹🇭 ไทย

แต่ละข้อต่อยอดกัน ข้อสุดท้ายคือหน้าเว็บที่เปิดทิ้งไว้ใช้ได้เลย

🇨🇳 中文

它们一环扣一环。最后一道是一个可以一直开着的页面。

1Stop embedding the same text every run · เลิกเอมเบดข้อความเดิมซ้ำทุกครั้งที่รัน · 别每次都把同样的文字再嵌入一遍

🇬🇧 English

index1.py rebuilds the whole index at every start. For four files that is fine. For four hundred it is not.

Save it to disk and load it back.

🇹🇭 ไทย

index1.py สร้างดัชนีใหม่ทั้งหมดทุกครั้งที่เริ่ม สำหรับสี่ไฟล์ก็ไม่เป็นไร แต่สำหรับสี่ร้อยไฟล์ไม่ไหว

บันทึกลงดิสก์แล้วโหลดกลับมา

🇨🇳 中文

index1.py 每次启动都把整个索引重建一遍。四个文件没关系,四百个就不行了。

把它存到硬盘上,再读回来。

Show the answerดูเฉลย看答案

📄 keep1.py

"""keep1.py — challenge 1: stop embedding the same text every single run."""
import sys
import time
from pathlib import Path

from llama_index.core import (Settings, SimpleDirectoryReader,
                              StorageContext, VectorStoreIndex,
                              load_index_from_storage)
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.llms.ollama import Ollama

HOME = Path("research")
SAVED = Path("index_store")

OLLAMA = "http://spark.local:11434"
MODEL = "qwen2.5:14b"
EMBEDDINGS = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"

sys.stdout.reconfigure(encoding="utf-8")


def settings():
    """The same two defaults as index1.py, in one place."""
    Settings.llm = Ollama(model=MODEL, base_url=OLLAMA, request_timeout=900)
    Settings.embed_model = HuggingFaceEmbedding(model_name=EMBEDDINGS)


def load_or_build():
    """Read the saved index if it is there, otherwise build and save it."""
    settings()
    started = time.perf_counter()

    if SAVED.exists():
        # The embeddings are already computed and written down. This reads
        # numbers off the disk instead of running the model again.
        storage = StorageContext.from_defaults(persist_dir=str(SAVED))
        index = load_index_from_storage(storage)
        how = "loaded from " + SAVED.name
    else:
        documents = SimpleDirectoryReader(str(HOME)).load_data()
        index = VectorStoreIndex.from_documents(documents)
        index.storage_context.persist(persist_dir=str(SAVED))
        how = "built and saved"

    print(f"{how} in {time.perf_counter() - started:.2f}s")
    return index


def main():
    index = load_or_build()
    engine = index.as_query_engine(similarity_top_k=3)
    answer = engine.query("What is the loop guard in the mail bot?")
    print(str(answer).strip()[:220])


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
index.storage_context.persist(persist_dir=str(SAVED))Writes the chunks and their numbers into a folder.เขียนชิ้นข้อความกับตัวเลขของมันลงในโฟลเดอร์把那些块和它们的数字写进一个文件夹。
load_index_from_storage(storage)Reads them back. The embedding model never runs.อ่านกลับมา โมเดลเอมเบดดิงไม่ต้องทำงานเลย读回来。嵌入模型完全不用跑。

🖥️ WHAT IT PRINTED · first run, then second

built and saved in 2.34s
loaded from index_store in 0.29s
DELETE THE FOLDER WHEN THE RESEARCH CHANGES Nothing here notices that research/ has new files in it. The saved index is a photograph, not a window. Add a source in NotebookLM and run bring1.py. Then delete index_store, or you will keep answering from last week. ไม่มีอะไรในนี้รู้ว่า research/ มีไฟล์ใหม่เพิ่มเข้ามา ดัชนีที่บันทึกไว้คือภาพถ่าย ไม่ใช่หน้าต่าง ถ้าเพิ่มแหล่งข้อมูลใน NotebookLM แล้วรัน bring1.py คุณต้องลบ index_store ทิ้ง ไม่อย่างนั้นคุณจะตอบจากข้อมูลของสัปดาห์ที่แล้วไปเรื่อย ๆ 这里没有任何东西会发现 research/ 多了新文件。存下来的索引是一张照片,不是一扇窗。在 NotebookLM 里加了来源、跑过 bring1.py 之后,你必须删掉 index_store,否则你会一直用上周的资料回答。

2Ask Google and ask yourself, and compare · ถาม Google แล้วถามตัวเอง แล้วเทียบกัน · 问 Google,也问自己,然后比一比

🇬🇧 English

Same question, both ways. Time them, count what each one gives you back besides the words.

🇹🇭 ไทย

คำถามเดียวกัน สองทาง จับเวลาทั้งคู่ แล้วนับดูว่าแต่ละทางให้อะไรกลับมานอกจากตัวข้อความ

🇨🇳 中文

同一个问题,两条路都走。各计时,再数数除了文字之外,每一边还给了你什么。

Show the answerดูเฉลย看答案

📄 both1.py

"""both1.py — challenge 2: ask NotebookLM and your own copy the same thing."""
import asyncio
import sys
import time
from pathlib import Path

from llama_index.core import Settings, StorageContext, load_index_from_storage
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.llms.ollama import Ollama
from notebooklm import AuthTokens
from notebooklm.client import NotebookLMClient

TITLE = "krueng.ai tutorials"
SAVED = Path("index_store")
OLLAMA = "http://spark.local:11434"
MODEL = "qwen2.5:14b"
EMBEDDINGS = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"

QUESTION = "Why was the hidden white-text watermark dropped from the mail bot?"

sys.stdout.reconfigure(encoding="utf-8")


async def ask_notebooklm(question):
    """Google reads the sources and answers, with citations."""
    auth = await AuthTokens.from_storage()
    async with NotebookLMClient(auth) as client:
        notebook = next(n for n in await client.notebooks.list()
                        if n.title == TITLE)
        started = time.perf_counter()
        answer = await client.chat.ask(notebook.id, question)
        return answer.answer, len(answer.references), time.perf_counter() - started


def ask_local(question):
    """Your machine reads its own copy and answers, with scores."""
    Settings.llm = Ollama(model=MODEL, base_url=OLLAMA, request_timeout=900)
    Settings.embed_model = HuggingFaceEmbedding(model_name=EMBEDDINGS)
    index = load_index_from_storage(
        StorageContext.from_defaults(persist_dir=str(SAVED)))
    started = time.perf_counter()
    answer = index.as_query_engine(similarity_top_k=3).query(question)
    return str(answer), len(answer.source_nodes), time.perf_counter() - started


def main():
    print("Q:", QUESTION)
    print()

    said, refs, spent = asyncio.run(ask_notebooklm(QUESTION))
    print(f"NotebookLM   {spent:5.1f}s, {refs} references, needs the internet")
    print("  ", " ".join(said.split())[:260])
    print()

    said, chunks, spent = ask_local(QUESTION)
    print(f"your machine {spent:5.1f}s, {chunks} chunks, needs nothing")
    print("  ", " ".join(said.split())[:260])


if __name__ == "__main__":
    main()

🖥️ WHAT IT PRINTED · the same answer, two ways

Q: Why was the hidden white-text watermark dropped from the mail bot?

NotebookLM    15.0s, 3 references, needs the internet
   ...SpamAssassin score hidden text as suspicious, which caused the
   mail bot's outgoing messages to land in rec

your machine   7.9s, 3 chunks, needs nothing
   ...it caused issues with SpamAssassin scoring hidden text, which
   led to the bot's own emails being flagged as spam

🇬🇧 English

The same answer, twice as fast, with nothing leaving the building.

What you gave up is the citation. NotebookLM quotes the sentence. Yours gives a number.

🇹🇭 ไทย

คำตอบเดียวกัน เร็วขึ้นเท่าตัว และไม่มีอะไรออกไปนอกอาคาร

สิ่งที่คุณเสียไปคือการอ้างอิง NotebookLM ยกประโยคมาให้ ส่วนของคุณให้แค่ตัวเลข

🇨🇳 中文

一样的答案,快了一倍,而且什么都没出这栋楼。

你放弃的是引用。NotebookLM 会把句子引给你,你自己的只给一个数字。

3Hand the research to an agent · ยื่นงานวิจัยให้เอเจนต์ · 把研究交给一个 agent

🇬🇧 English

The query engine answers when you call it. Wrap it in a @tool and the model decides when to call it.

Then ask it something the research does not cover, and watch what it does.

🇹🇭 ไทย

query engine จะตอบเมื่อคุณเรียกมัน ถ้าห่อมันไว้ใน @tool โมเดลจะเป็นคนตัดสินใจเองว่าจะเรียกเมื่อไหร่

แล้วลองถามเรื่องที่งานวิจัยไม่ได้ครอบคลุม แล้วดูว่ามันทำอะไร

🇨🇳 中文

查询引擎是你叫它它才答。把它包进一个 @tool,就由模型决定什么时候叫它。

然后问一个研究里没有的东西,看它怎么做。

💻 INSTALL IT TOO

pip install smolagents openai
Show the answerดูเฉลย看答案

📄 agent1.py

"""agent1.py — challenge 3: give the research to an agent as a tool.

The query engine answers when you call it. A tool is the same engine,
handed to a model that decides for itself whether the question needs it.
"""
import os
import sys
from pathlib import Path

from llama_index.core import Settings, StorageContext, load_index_from_storage
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.llms.ollama import Ollama
from smolagents import OpenAIServerModel, ToolCallingAgent, tool

SAVED = Path("index_store")
OLLAMA = "http://spark.local:11434"
MODEL = "qwen2.5:14b"
EMBEDDINGS = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"

# smolagents talks the OpenAI dialect, which Ollama also speaks.
LLM_URL = os.environ.get("LLM_URL", OLLAMA + "/v1")
LLM_KEY = os.environ.get("LLM_KEY", "ollama")

sys.stdout.reconfigure(encoding="utf-8")

Settings.llm = Ollama(model=MODEL, base_url=OLLAMA, request_timeout=900)
Settings.embed_model = HuggingFaceEmbedding(model_name=EMBEDDINGS)
index = load_index_from_storage(
    StorageContext.from_defaults(persist_dir=str(SAVED)))
engine = index.as_query_engine(similarity_top_k=3)


@tool
def search_the_tutorials(question: str) -> str:
    """Look something up in the krueng.ai tutorials and answer from them.

    Args:
        question: a question about the mail bot, Gradio or Python classes
    """
    return str(engine.query(question))


def main():
    llm = OpenAIServerModel(model_id=MODEL, api_base=LLM_URL, api_key=LLM_KEY)
    agent = ToolCallingAgent(tools=[search_the_tutorials], model=llm)

    for question in ["What stops the mail bot replying to itself?",
                     "What is the capital of Thailand?"]:
        print()
        print("Q:", question)
        print("A:", str(agent.run(question))[:260])


if __name__ == "__main__":
    main()

🖥️ WHAT IT PRINTED · two questions, one tool

Q: What stops the mail bot replying to itself?
   Calling tool: 'search_the_tutorials'
A: The mail bot checks the subject of incoming emails for a specific
   tag [horoscope-bot]. If it sees that an email already has this
   tag, it won't send another horoscope.

Q: What is the capital of Thailand?
   Calling tool: 'search_the_tutorials'
   Observations: Bangkok
A: Bangkok
IT USED THE TOOL FOR A QUESTION THE TOOL CANNOT ANSWER The tutorials do not say what the capital of Thailand is. The agent called the search anyway. The search found a weak match. The model then answered from its own knowledge. The tool was meant to ground it. Two mistakes stacked, and the output looks fine. Challenge 4 puts a floor under it. บทเรียนพวกนั้นไม่ได้บอกว่าเมืองหลวงของไทยคืออะไร แต่เอเจนต์ก็เรียกการค้นหาอยู่ดี การค้นหาเจอชิ้นที่ตรงแบบอ่อน ๆ แล้วโมเดลก็ตอบจากความรู้ของตัวเองผ่านเครื่องมือที่ควรจะยึดมันไว้กับแหล่งข้อมูล ความผิดสองชั้นซ้อนกัน และผลลัพธ์ดูปกติดี โจทย์ข้อ 4 จะวางพื้นรองไว้ให้ 教程里没说泰国的首都是哪。agent 还是调了搜索,搜索找到一个很弱的匹配,然后模型通过一个本该把它绑在资料上的工具,用自己的知识答了。两个错误叠在一起,而输出看起来没问题。第 4 题给它垫个底。

4A page that admits when it does not know · หน้าเว็บที่ยอมรับเมื่อมันไม่รู้ · 一个会承认自己不知道的页面

🇬🇧 English

Put the research behind a Gradio page. Show the scores next to the answer, not instead of it.

Then do the thing step 7 did not: refuse to answer when the best chunk is too weak.

🇹🇭 ไทย

เอางานวิจัยไปไว้หลังหน้า Gradio แสดงคะแนนไว้ข้างคำตอบ ไม่ใช่แทนคำตอบ

แล้วทำสิ่งที่ขั้นที่ 7 ไม่ได้ทำ คือปฏิเสธที่จะตอบเมื่อชิ้นที่ดีที่สุดยังอ่อนเกินไป

🇨🇳 中文

把研究放到一个 Gradio 页面后面。把分数放在答案旁边,不是用来代替答案。

然后做第 7 步没做的事:当最好的一块太弱时,拒绝回答。

Show the answerดูเฉลย看答案

📄 ask_app.py

"""ask_app.py — challenge 4: the research, as a page you can ask.

Also does the thing index1.py did not: refuse to answer when the search
did not actually find anything.
"""
import sys
from pathlib import Path

import gradio as gr
from llama_index.core import Settings, StorageContext, load_index_from_storage
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.llms.ollama import Ollama

SAVED = Path("index_store")
OLLAMA = "http://spark.local:11434"
MODEL = "qwen2.5:14b"
EMBEDDINGS = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"

# Below this, the search found nothing worth answering from. Measured on
# this folder: a real question scores about 0.5, nonsense about 0.07.
ENOUGH = 0.25

sys.stdout.reconfigure(encoding="utf-8")

Settings.llm = Ollama(model=MODEL, base_url=OLLAMA, request_timeout=900)
Settings.embed_model = HuggingFaceEmbedding(model_name=EMBEDDINGS)
engine = load_index_from_storage(
    StorageContext.from_defaults(persist_dir=str(SAVED))
).as_query_engine(similarity_top_k=3)


def answer(question):
    """Answer from the research, or say plainly that it is not in there."""
    if not question.strip():
        return "", []

    found = engine.query(question)
    rows = [[round(node.score, 3), Path(node.metadata.get("file_name", "?")).name,
             " ".join(node.text.split())[:90]] for node in found.source_nodes]

    best = found.source_nodes[0].score if found.source_nodes else 0
    if best < ENOUGH:
        return (f"Not in the research. The closest thing scored {best:.3f}, "
                f"and anything under {ENOUGH} is noise."), rows
    return str(found).strip(), rows


with gr.Blocks(title="Ask the research") as demo:
    gr.Markdown("## Ask the research\nAnswered only from the notebook, on this network.")
    question = gr.Textbox(label="Your question", scale=3)
    said = gr.Markdown(label="Answer")
    chunks = gr.Dataframe(headers=["score", "file", "what was found"],
                          label="What the search actually found", wrap=True)
    question.submit(fn=answer, inputs=question, outputs=[said, chunks])

    # The first two are the same question. The vague one scores 0.31 and
    # gets nothing; the specific one scores 0.547 and gets the answer.
    # Click both, and watch the score column rather than the text.
    gr.Examples(examples=["Why was the hidden watermark dropped?",
                          "Why was the hidden white-text watermark dropped "
                          "from the mail bot?",
                          "What does gr.State do?",
                          "How do I bake sourdough bread?"],
                inputs=question)

if __name__ == "__main__":
    demo.launch()
LineWhat it does · ทำอะไร · 做什么
ENOUGH = 0.25Measured, not guessed. A real question here scores about 0.5.วัดมา ไม่ได้เดา คำถามจริงตรงนี้ได้ราว 0.5量出来的,不是猜的。这里一个真问题大约 0.5。
if best < ENOUGH:The floor. Without it the model fills the silence.พื้นรอง ถ้าไม่มีอันนี้ โมเดลจะเติมความเงียบเอง那个底。没有它,模型会自己把沉默填满。
gr.Dataframe(headers=["score", "file", "what was found"],Show the evidence. A reader can then disagree with it.แสดงหลักฐานออกมา คนอ่านจะได้เถียงกับมันได้把证据摆出来。读的人才有办法不同意。

🖥️ WHAT IT PRINTED · one answered, one refused

Q: Why was the hidden watermark dropped?
A: The context does not mention a specific reason...   top chunk 0.31

Q: How do I bake sourdough bread?
A: Not in the research. The closest thing scored 0.073,
   and anything under 0.25 is noise.            top chunk 0.073

🇬🇧 English

Click the first two examples in order. They are the same question, worded twice.

The vague one scores 0.31 and gets nothing. The precise one scores 0.547 and gets the answer.

Same index, same model. In this kind of system the question is part of the machine.

🇹🇭 ไทย

คลิกสองตัวอย่างแรกตามลำดับ มันคือคำถามเดียวกัน แค่เขียนคนละแบบ

แบบกว้าง ๆ ได้ 0.31 แล้วไม่ได้อะไรเลย ส่วนแบบเจาะจงได้ 0.547 แล้วได้คำตอบ

ดัชนีเดียวกัน โมเดลเดียวกัน ในระบบแบบนี้ คำถามคือส่วนหนึ่งของเครื่อง

🇨🇳 中文

按顺序点前两个示例。它们是同一个问题,写了两遍。

含糊的那个得 0.31,什么都没拿到。具体的那个得 0.547,拿到了答案。

同一个索引,同一个模型。在这种系统里,问题本身就是机器的一部分。

🇬🇧 English

Eleven files. Google read three web pages and wrote a briefing. Your own machines answer from it, offline.

That is the shape worth keeping. Use the good hosted tool for the reading, then take the result home.

The part to be careful about is not the code. It is that a wrong answer and a right one look exactly the same.

🇹🇭 ไทย

สิบเอ็ดไฟล์ Google อ่านหน้าเว็บสามหน้าแล้วเขียนบทสรุป เครื่องของคุณเองตอบจากมันแบบออฟไลน์

นั่นคือรูปแบบที่ควรเก็บไว้ ใช้เครื่องมือบนคลาวด์ดี ๆ ทำส่วนการอ่าน แล้วเอาผลลัพธ์กลับบ้าน

ส่วนที่ต้องระวังไม่ใช่โค้ด แต่คือคำตอบที่ผิดกับคำตอบที่ถูกหน้าตาเหมือนกันเป๊ะ

🇨🇳 中文

十一个文件。Google 读了三个网页,写了一份简报。你自己的机器离线地从里面回答。

这个形状值得记住:读的部分用好的托管工具,然后把结果带回家。

要当心的不是代码。是错的答案和对的答案,长得一模一样。

You wantคุณอยากได้你想要Look upไปหาคำว่า去查
Audio from a notebookเสียงจากโน้ตบุ๊ก从笔记本生成音频artifacts.generate_audio
A mind mapแผนผังความคิด思维导图artifacts.generate_mind_map
Flashcards or a quizแฟลชการ์ดหรือควิซ抽认卡或测验generate_flashcards
PDFs instead of web pagesPDF แทนหน้าเว็บ用 PDF 而不是网页sources.add_file
Better chunksชิ้นข้อความที่ดีกว่า更好的分块LlamaIndex node parsersnode parsers ของ LlamaIndexLlamaIndex 的 node parser
Check the retrieval, not the answerตรวจการค้นคืน ไม่ใช่คำตอบ检查检索,不是答案hit rate at k, on your own questionshit rate at k กับคำถามของคุณเอง用你自己的问题算 hit rate at k

Fifteen lines, your own address, and an app password. That is the whole trick. · สิบห้าบรรทัด อีเมลของคุณเอง และ app password หนึ่งอัน เคล็ดลับมีแค่นี้ · 十五行代码、你自己的邮箱地址,加一个应用专用密码。诀窍就这些。

All eleven files on this page were run on 25 September 2026, and every output shown is copied from those runs. The notebook is real: three live pages from this site, 163,000 characters of source, and a 7,925-character briefing NotebookLM wrote from them. The timings in step 3 are from this laptop and a DGX Spark on the same wifi, and your numbers will differ. Three things on this page are mistakes I made while writing it, kept because they are the useful part: the first dedupe compared file names against titles, so every source was added twice; the query engine answers "Bangkok" for the capital of Thailand from a chunk scoring 0.464 that never mentions it; and the same question worded vaguely scores 0.31 and gets nothing where the precise wording scores 0.547 and gets the answer. The NotebookLM login expired the morning this page was started, while notebooklm status still reported everything was fine.