Readwise → Obsidian → 本地 AI 问答:完整配置指南与可复制模板

> 这篇是技术配置文档,不是散文。目标是你照着做,90 分钟内跑通最小闭环。

> 所有需要你自己适配的地方用 ⚠️ 标出。


〇、你要搭的是什么

图:Readwise 高亮进入 Obsidian 金库,经过索引后,变成本地 AI 可以带引用回答的知识抽屉。

图:Readwise 高亮进入 Obsidian 金库,经过索引后,变成本地 AI 可以带引用回答的知识抽屉。

阅读源(Kindle/网页/PDF/RSS/Twitter)
        ↓
   Readwise Reader(高亮、标注、AI 摘要)
        ↓  自动同步
   Obsidian Vault(Markdown 笔记库)
        ↓  嵌入索引
   Ollama + ChromaDB(本地 AI 问答)
        ↓
   你问:"我读过的关于 X 的所有内容,核心观点是什么?"

四个环节,每个环节单独可替换。下面逐个讲配置。


一、Readwise → Obsidian 同步配置

1.1 前置条件

| 项目 | 要求 |

|------|------|

| Readwise 订阅 | ⚠️ 必须 Full 计划($9.99/月年付 或 $12.99/月月付)。Lite 计划不支持导出到 Obsidian |

| Obsidian | 1.4+ 版本 |

| 网络 | Obsidian 同步时需要能访问 Readwise API |

⚠️ 学生/教育者享 50% 折扣,在 Readwise 设置页提交 .edu 邮箱即可。

1.2 安装 Readwise Official 插件

Settings → Community plugins → Browse
搜索: "Readwise Official"
(注意:不是 "Readwise",不是 "Readwise Mirror",认准官方标识)
  • 当前版本:v3.0.3(2026 年 5 月发布)
  • 下载量:19.4 万+
  • 协议:GPL-3.0
  • GitHub:github.com/readwiseio/obsidian-readwise

安装后点击插件设置里的 Connect,浏览器会跳转到 Readwise 授权页,授权完成即连接成功。

1.3 同步频率设置

插件设置里有同步频率选项:

| 选项 | 适用场景 |

|------|---------|

| 打开 Obsidian 时自动同步 | 推荐。最省心,打开 vault 就拉最新 |

| 每 1 小时 | 重度阅读者,实时性要求高 |

| 每 12 小时 | 日常使用,每天早晚各一次 |

| 每 24 小时 | 轻度使用 |

| 手动 | 完全自控 |

⚠️ 建议选"打开 Obsidian 时自动同步"。原因:Obsidian 不是 24 小时开着的,选时间间隔的话,间隔内的高亮不会及时进来。

1.4 同步目标文件夹

在插件设置中指定同步目标文件夹:

Readwise Articles/

⚠️ 不要用根目录。单独建一个文件夹放 Readwise 同步内容,方便后续区分"自动同步进来的"和"自己手写的"笔记。

1.5 同步机制的重要限制(必读)

| 行为 | 说明 |

|------|------|

| 同步方向 | 单向:Readwise → Obsidian,不可逆 |

| 追加模式 | 新高亮追加到文件底部,不会覆盖已有内容 |

| 修改不同步 | 在 Readwise 中修改高亮文本,Obsidian 中不会更新 |

| 重命名陷阱 | 在 Obsidian 中重命名 Readwise 生成的文件后,下次同步会创建一个同名新文件,而不是追加到重命名后的文件 |

| 来源延迟 | Kindle/Instapaper/Pocket 的高亮不是实时的,一天只同步几次到 Readwise |

⚠️ 重命名陷阱是最常见的坑。 规则:还在活跃高亮的文档(你还会继续读、继续标注的),绝对不要在 Obsidian 里重命名。已经读完不再取新高亮的文档,重命名没问题。

1.6 Kindle 等延迟来源的处理

如果你用 Kindle 阅读,高亮同步到 Readwise 可能有延迟。处理方法:

1. 登录 readwise.io 网页版

2. 进入 Documents 页面

3. 找到对应文档,点击强制同步按钮

4. 等 Readwise 侧同步完成后,再在 Obsidian 中触发同步


二、Obsidian 侧配置

2.1 与阅读流相关的插件清单

⚠️ 以下只列与"阅读→沉淀→AI 问答"链路直接相关的插件。其他插件(日记、Git 备份、Omnisearch 等)在之前的文章中已讲过,这里不重复。

| 插件 | 作用 | 是否必须 |

|------|------|---------|

| Readwise Official | 高亮同步 | 必须 |

| Templater | 笔记模板引擎 | 推荐(用于自定义同步模板) |

| Smart Connections | 语义搜索 + 相关笔记发现 | 可选(方案 A) |

| Copilot | 在 Obsidian 内与 AI 对话 | 可选(方案 B) |

2.2 Readwise 同步模板配置(Jinja2)

Readwise Official 插件支持 Jinja2 模板自定义导出格式。以下是经过实测的推荐模板:

文档模板(Document Template):

---
title: "{{title}}"
author: "{{author}}"
source: "{{source}}"
url: "{{source_url}}"
synced_at: "{{#each highlights}}{{@last}}{{/each}}"
tags:
  - reading/highlight
  - {{#if category}}reading/{{category}}{{else}}reading/uncategorized{{/if}}
---

# {{title}}

> 来源:{{source}} | 作者:{{author}}
> 原文链接:{{source_url}}

## 高亮

{{#highlights}}
> {{text}}

{{#if note}}
我的批注:{{note}}
{{/if}}

---
{{/highlights}}

⚠️ 这个模板会在每篇同步文档的 frontmatter 中自动生成 tags: reading/highlight 和按来源分类的标签。你不需要手动打标签。

2.3 标签规范

为了与既有笔记系统兼容,建议用以下标签层级:

reading/
├── reading/highlight      ← Readwise 同步的所有高亮自动带这个标签
├── reading/article        ← 来自网页文章
├── reading/book           ← 来自书籍
├── reading/paper          ← 来自论文
├── reading/twitter        ← 来自 Twitter/X
└── reading/newsletter     ← 来自 Newsletter

⚠️ Readwise 的 category 字段会自动映射为 reading/articlereading/book 等。如果你的分类不在预设中,会落入 reading/uncategorized,需要手动调整。

标签使用规则:

  • 同步进来的高亮笔记:自动带 reading/highlight + 来源标签,不需要手动处理
  • 从高中升级的常青笔记:去掉 reading/highlight,加上主题标签(如 knowledge-managementai-tools
  • 不要在 Readwise 同步的文件上叠加太多手动标签——它下次同步会追加内容,但不会动你手动加的标签,容易造成标签体系混乱

2.4 文件夹结构(仅阅读流相关部分)

Vault/
├── Readwise Articles/          ← Readwise 同步目标文件夹
│   ├── 文章标题.md
│   └── 书名.md
├── 00_收件箱/                  ← 从高中升级的笔记先放这里
├── 10_项目/
├── 20_领域/
├── 30_资源/
├── 90_归档/
└── Templates/
    ├── tpl-reading-note.md     ← 阅读笔记模板
    └── tpl-evergreen.md        ← 常青笔记模板

⚠️ Readwise Articles/ 是插件的同步目标,不要手动往里面放文件。其他文件夹沿用之前的 PARA 结构。


三、本地 AI 问答配置(Ollama + ChromaDB)

3.1 硬件要求

| 配置 | 最低要求 | 推荐配置 |

|------|---------|---------|

| 内存 | 16GB | 32GB(跑大模型时不卡) |

| 磁盘 | 20GB 可用空间 | 50GB+(模型文件 4-16GB 不等) |

| GPU | 不强制 | Apple Silicon / NVIDIA 8GB+ VRAM |

| CPU | 4 核 | 8 核+(无 GPU 时推理全靠 CPU) |

⚠️ 没有 GPU 也能跑,但推理速度会慢很多。 Apple Silicon Mac(M1/M2/M3/M4)是目前性价比最高的本地 AI 方案,统一内存直接当显存用。

3.2 安装 Ollama

macOS / Linux:

# macOS(Homebrew)
brew install ollama

# Linux(官方脚本)
curl -fsSL https://ollama.com/install.sh | sh

Windows:

从 https://ollama.com/download 下载安装包。

安装完成后,启动服务:

ollama serve
# 默认监听 http://localhost:11434

3.3 拉取模型

需要两个模型:一个用于对话(LLM),一个用于文本嵌入(Embedding)。

# 对话模型:推荐 llama3.2:8b(4.7GB,速度与质量平衡好)
ollama pull llama3.2:8b

# 嵌入模型:推荐 nomic-embed-text(274MB,768 维,速度快质量好)
ollama pull nomic-embed-text

⚠️ 模型选择建议:

| 场景 | 推荐模型 | 大小 | 说明 |

|------|---------|------|------|

| 16GB 内存 | llama3.2:8b | 4.7GB | 默认选择,够用 |

| 32GB+ 内存 | llama3.2:70b 或 qwen2.5:32b | 40GB+ | 质量明显更好,但慢 |

| 8GB 内存 | llama3.2:3b | 2.0GB | 勉强能用,回答质量会下降 |

| 中文场景 | qwen2.5:7b | 4.7GB | 中文理解能力比 llama3.2 好 |

⚠️ 如果你的笔记主要是中文内容,建议用 qwen2.5:7b 替代 llama3.2:8b。中文问答场景下 Qwen 系列明显优于 Llama 系列。

3.4 安装 Python 依赖

# 创建虚拟环境(推荐)
python3 -m venv ~/obsidian-rag
source ~/obsidian-rag/bin/activate

# 安装依赖
pip install chromadb langchain langchain-text-splitters

⚠️ langchainchromadb 的版本更新较快,如果安装时遇到依赖冲突,尝试:

pip install --upgrade pip
pip install chromadb langchain langchain-text-splitters --upgrade

3.5 最小可行 RAG 脚本

以下是完整的、可直接运行的 Python 脚本。功能:读取 Obsidian vault 中的 markdown 文件 → 切分 → 嵌入到 ChromaDB → 提供问答接口。

#!/usr/bin/env python3
"""
Obsidian Vault RAG - 最小可行版本
功能:读取 vault 中的 markdown → 嵌入到 ChromaDB → 问答
"""

import os
import glob
from pathlib import Path

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import MarkdownHeaderTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.llms import Ollama
from langchain.chains import RetrievalQA

# ========== 配置区 ==========
VAULT_PATH = os.path.expanduser("~/Documents/Obsidian Vault")  # ⚠️ 改成你的 vault 路径
CHROMA_DIR = os.path.expanduser("~/.obsidian-rag/chroma")       # 向量数据库存储位置
OLLAMA_BASE_URL = "http://localhost:11434"
LLM_MODEL = "llama3.2:8b"           # ⚠️ 中文场景换成 "qwen2.5:7b"
EMBED_MODEL = "nomic-embed-text"
CHUNK_SIZE = 1000                    # 每个切片的最大字符数
CHUNK_OVERLAP = 200                  # 相邻切片重叠字符数
TOP_K = 5                            # 检索最相关的 K 个片段
# ============================


def load_markdown_files(vault_path: str) -> list:
    """加载 vault 中所有 markdown 文件"""
    md_files = glob.glob(os.path.join(vault_path, "**/*.md"), recursive=True)

    # 排除模板文件和配置文件夹
    exclude_patterns = ["Templates/", ".obsidian/", ".trash/", "Templates"]
    md_files = [
        f for f in md_files
        if not any(pat in f for pat in exclude_patterns)
    ]

    documents = []
    for fp in md_files:
        try:
            loader = TextLoader(fp, encoding="utf-8")
            docs = loader.load()
            # 给每个文档加上来源标记
            for doc in docs:
                doc.metadata["source_file"] = os.path.relpath(fp, vault_path)
            documents.extend(docs)
        except Exception as e:
            print(f"  跳过 {fp}: {e}")

    print(f"已加载 {len(documents)} 个文档")
    return documents


def split_documents(documents: list) -> list:
    """将文档切分为较小的片段"""
    # 使用 Markdown 标题分割器
    headers_to_split_on = [
        ("#", "H1"),
        ("##", "H2"),
        ("###", "H3"),
    ]
    md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)

    chunks = []
    for doc in documents:
        # 先按 Markdown 标题切
        md_chunks = md_splitter.split_text(doc.page_content)
        for chunk in md_chunks:
            chunk.metadata.update(doc.metadata)
            chunks.append(chunk)

        # 如果某个 section 太长,再按字符数切
        if len(doc.page_content) > CHUNK_SIZE:
            from langchain_text_splitters import RecursiveCharacterTextSplitter
            text_splitter = RecursiveCharacterTextSplitter(
                chunk_size=CHUNK_SIZE,
                chunk_overlap=CHUNK_OVERLAP,
                separators=["\n\n", "\n", "。", ".", " ", ""]
            )
            sub_chunks = text_splitter.split_text(doc.page_content)
            for sc in sub_chunks:
                from langchain.schema import Document
                new_doc = Document(page_content=sc, metadata=doc.metadata.copy())
                chunks.append(new_doc)

    print(f"切分为 {len(chunks)} 个片段")
    return chunks


def build_vectorstore(chunks: list) -> Chroma:
    """构建 ChromaDB 向量数据库"""
    os.makedirs(CHROMA_DIR, exist_ok=True)

    embeddings = OllamaEmbeddings(
        model=EMBED_MODEL,
        base_url=OLLAMA_BASE_URL
    )

    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=CHROMA_DIR,
    )
    vectorstore.persist()

    print(f"向量数据库已保存到 {CHROMA_DIR}")
    return vectorstore


def create_qa_chain(vectorstore: Chroma):
    """创建问答链"""
    embeddings = OllamaEmbeddings(
        model=EMBED_MODEL,
        base_url=OLLAMA_BASE_URL
    )

    llm = Ollama(
        model=LLM_MODEL,
        base_url=OLLAMA_BASE_URL,
    )

    qa_chain = RetrievalQA.from_chain_type(
        llm=llm,
        chain_type="stuff",           # 简单拼接检索结果到 prompt
        retriever=vectorstore.as_retriever(
            search_kwargs={"k": TOP_K}
        ),
        return_source_documents=True,
    )

    return qa_chain


def ask(qa_chain, question: str):
    """提问并打印结果"""
    result = qa_chain({"query": question})

    print(f"\n{'='*60}")
    print(f"问题:{question}")
    print(f"{'='*60}")
    print(f"\n回答:\n{result['result']}")
    print(f"\n参考来源({len(result['source_documents'])} 个片段):")
    for i, doc in enumerate(result['source_documents'], 1):
        source = doc.metadata.get('source_file', '未知')
        print(f"  [{i}] {source}")
        # 打印片段前 100 字符作为预览
        preview = doc.page_content[:100].replace('\n', ' ')
        print(f"      → {preview}...")
    print()


def main():
    print("=" * 60)
    print("Obsidian Vault RAG 系统")
    print("=" * 60)

    # 第一步:加载文档
    print("\n[1/4] 加载 Markdown 文件...")
    documents = load_markdown_files(VAULT_PATH)

    if not documents:
        print("错误:没有找到任何 markdown 文件。请检查 VAULT_PATH 配置。")
        return

    # 第二步:切分文档
    print("\n[2/4] 切分文档...")
    chunks = split_documents(documents)

    # 第三步:构建向量数据库
    print("\n[3/4] 构建向量索引(首次可能需要几分钟)...")
    vectorstore = build_vectorstore(chunks)

    # 第四步:创建问答链
    print("\n[4/4] 初始化问答系统...")
    qa_chain = create_qa_chain(vectorstore)

    print("\n系统就绪!输入问题开始问答,输入 'quit' 退出。")
    print("-" * 60)

    # 交互式问答
    while True:
        try:
            question = input("\n你的问题:").strip()
            if question.lower() in ('quit', 'exit', 'q'):
                break
            if not question:
                continue
            ask(qa_chain, question)
        except KeyboardInterrupt:
            break
        except Exception as e:
            print(f"出错:{e}")

    print("\n再见!")


if __name__ == "__main__":
    main()

使用方法:

# 确保 Ollama 正在运行
ollama serve &

# 运行脚本
python obsidian_rag.py

⚠️ 首次运行会花较长时间——需要把所有 markdown 文件嵌入到向量数据库。10,000 篇笔记在 Apple Silicon 上约 2-2.5 分钟。之后增量更新很快。

3.6 性能参考

| 指标 | 数值 | 测试环境 |

|------|------|---------|

| 10,000 篇笔记嵌入耗时 | 2-2.5 分钟 | Apple Silicon M1 |

| 语义搜索响应时间 | < 100ms | 本地 ChromaDB |

| 完整搭建时间 | 约 90 分钟 | 从零开始 |

| 磁盘占用(模型+索引) | ~8GB | llama3.2:8b + nomic-embed-text |


四、自动化脚本

4.1 定时同步脚本(增量更新索引)

每次 Readwise 同步新内容到 Obsidian 后,需要更新向量索引。以下脚本实现增量更新——只处理新增和修改的文件。

#!/usr/bin/env python3
"""
增量更新向量索引
建议通过 cron 或 launchd 定时运行
"""

import os
import json
import hashlib
from pathlib import Path
from datetime import datetime

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import OllamaEmbeddings

# ========== 配置区(与主脚本保持一致)==========
VAULT_PATH = os.path.expanduser("~/Documents/Obsidian Vault")
CHROMA_DIR = os.path.expanduser("~/.obsidian-rag/chroma")
STATE_FILE = os.path.expanduser("~/.obsidian-rag/sync_state.json")
OLLAMA_BASE_URL = "http://localhost:11434"
EMBED_MODEL = "nomic-embed-text"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
EXCLUDE_PATTERNS = ["Templates/", ".obsidian/", ".trash/"]
# =============================================


def load_sync_state() -> dict:
    """加载上次同步状态"""
    if os.path.exists(STATE_FILE):
        with open(STATE_FILE, 'r') as f:
            return json.load(f)
    return {}


def save_sync_state(state: dict):
    """保存同步状态"""
    os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
    with open(STATE_FILE, 'w') as f:
        json.dump(state, f, indent=2)


def file_hash(filepath: str) -> str:
    """计算文件内容的 MD5 哈希"""
    with open(filepath, 'rb') as f:
        return hashlib.md5(f.read()).hexdigest()


def find_changed_files(state: dict) -> tuple:
    """找出新增和修改的文件"""
    current_files = {}
    md_files = []

    for root, dirs, files in os.walk(VAULT_PATH):
        # 排除不需要的目录
        dirs[:] = [d for d in dirs if not any(pat in d for pat in EXCLUDE_PATTERNS)]
        for fname in files:
            if fname.endswith('.md'):
                md_files.append(os.path.join(root, fname))

    new_files = []
    modified_files = []

    for fp in md_files:
        rel_path = os.path.relpath(fp, VAULT_PATH)
        current_hash = file_hash(fp)
        current_files[rel_path] = current_hash

        if rel_path not in state:
            new_files.append(fp)
        elif state[rel_path] != current_hash:
            modified_files.append(fp)

    deleted_files = [fp for fp in state if fp not in current_files]

    return new_files, modified_files, deleted_files, current_files


def update_index():
    """增量更新向量索引"""
    state = load_sync_state()
    new_files, modified_files, deleted_files, current_state = find_changed_files(state)

    total_changes = len(new_files) + len(modified_files) + len(deleted_files)

    if total_changes == 0:
        print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] 无变更,跳过更新。")
        return

    print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] 发现变更:")
    print(f"  新增: {len(new_files)} 个文件")
    print(f"  修改: {len(modified_files)} 个文件")
    print(f"  删除: {len(deleted_files)} 个文件")

    embeddings = OllamaEmbeddings(
        model=EMBED_MODEL,
        base_url=OLLAMA_BASE_URL
    )

    vectorstore = Chroma(
        persist_directory=CHROMA_DIR,
        embedding_function=embeddings
    )

    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP,
        separators=["\n\n", "\n", "。", ".", " ", ""]
    )

    # 处理新增和修改的文件
    files_to_process = new_files + modified_files
    for fp in files_to_process:
        try:
            loader = TextLoader(fp, encoding="utf-8")
            docs = loader.load()
            for doc in docs:
                doc.metadata["source_file"] = os.path.relpath(fp, VAULT_PATH)

            chunks = text_splitter.split_documents(docs)

            # 先删除该文件的旧向量(如果有的话)
            rel_path = os.path.relpath(fp, VAULT_PATH)
            try:
                vectorstore.delete(where={"source_file": rel_path})
            except Exception:
                pass  # 新文件没有旧向量

            # 添加新向量
            vectorstore.add_documents(chunks)
            print(f"  ✓ 已索引: {rel_path} ({len(chunks)} 个片段)")

        except Exception as e:
            print(f"  ✗ 跳过 {fp}: {e}")

    # 处理删除的文件
    for rel_path in deleted_files:
        try:
            vectorstore.delete(where={"source_file": rel_path})
            print(f"  ✓ 已移除: {rel_path}")
        except Exception as e:
            print(f"  ✗ 移除失败 {rel_path}: {e}")

    vectorstore.persist()
    save_sync_state(current_state)

    print(f"\n索引更新完成。")


if __name__ == "__main__":
    update_index()

4.2 定时任务配置

macOS(launchd):

创建 ~/Library/LaunchAgents/com.obsidian-rag.sync.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.obsidian-rag.sync</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/python3</string>
        <string>/Users/你的用户名/scripts/obsidian_rag_sync.py</string>
    </array>
    <key>StartInterval</key>
    <integer>3600</integer>  <!-- 每小时执行一次 -->
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/你的用户名/.obsidian-rag/sync.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/你的用户名/.obsidian-rag/sync_error.log</string>
</dict>
</plist>
# 加载定时任务
launchctl load ~/Library/LaunchAgents/com.obsidian-rag.sync.plist

# 查看状态
launchctl list | grep obsidian-rag

⚠️ 把路径中的 你的用户名 和脚本路径改成实际值。

Linux(cron):

# 编辑 crontab
crontab -e

# 添加以下行(每小时执行一次)
0 * * * * /usr/bin/python3 /home/你的用户名/scripts/obsidian_rag_sync.py >> /home/你的用户名/.obsidian-rag/sync.log 2>&1

4.3 元数据清洗脚本

Readwise 同步进来的文件可能带有不规范的 frontmatter 或冗余元数据。以下脚本做基础清洗:

#!/usr/bin/env python3
"""
清洗 Readwise 同步文件的元数据
- 统一标签格式
- 移除空字段
- 标准化日期格式
"""

import os
import re
import glob
import yaml

SYNC_DIR = os.path.expanduser("~/Documents/Obsidian Vault/Readwise Articles")
DRY_RUN = True  # ⚠️ 设为 False 才会真正修改文件


def clean_frontmatter(content: str) -> str:
    """清洗 frontmatter"""
    # 提取 frontmatter
    fm_match = re.match(r'^---\n(.*?)\n---\n', content, re.DOTALL)
    if not fm_match:
        return content

    fm_text = fm_match.group(1)
    body = content[fm_match.end():]

    try:
        fm = yaml.safe_load(fm_text)
    except yaml.YAMLError:
        return content  # 解析失败就不动

    if not isinstance(fm, dict):
        return content

    # 清洗规则
    # 1. 移除空值字段
    fm = {k: v for k, v in fm.items() if v is not None and v != "" and v != []}

    # 2. 统一标签格式(确保是列表,去掉 # 前缀)
    if 'tags' in fm:
        if isinstance(fm['tags'], str):
            fm['tags'] = [fm['tags']]
        fm['tags'] = [
            t.strip().lstrip('#').strip()
            for t in fm['tags']
            if t and t.strip()
        ]

    # 3. 确保 synced_at 是字符串
    if 'synced_at' in fm and not isinstance(fm['synced_at'], str):
        fm['synced_at'] = str(fm['synced_at'])

    # 重新生成 frontmatter
    new_fm = yaml.dump(fm, allow_unicode=True, default_flow_style=False, sort_keys=False)
    return f"---\n{new_fm}---\n{body}"


def process_files():
    """处理所有同步文件"""
    md_files = glob.glob(os.path.join(SYNC_DIR, "**/*.md"), recursive=True)
    changed = 0

    for fp in md_files:
        with open(fp, 'r', encoding='utf-8') as f:
            original = f.read()

        cleaned = clean_frontmatter(original)

        if cleaned != original:
            changed += 1
            rel_path = os.path.relpath(fp, SYNC_DIR)
            if DRY_RUN:
                print(f"  [DRY RUN] 将修改: {rel_path}")
            else:
                with open(fp, 'w', encoding='utf-8') as f:
                    f.write(cleaned)
                print(f"  ✓ 已清洗: {rel_path}")

    print(f"\n{'[DRY RUN] ' if DRY_RUN else ''}处理了 {len(md_files)} 个文件,"
          f"{'将' if DRY_RUN else '已'}修改 {changed} 个。")


if __name__ == "__main__":
    process_files()

⚠️ 默认 DRY_RUN = True,只打印会改什么但不实际修改。确认无误后改成 False 再运行。


五、可复制模板

5.1 阅读笔记模板(Obsidian Templater 格式)

当你想把某篇 Readwise 同步的高亮笔记"升级"为常青笔记时,用这个模板:

---
title: "{{title}}"
created: "{{date}}"
tags:
  - reading/processed
source: "[[{{title}}]]"
---

# {{title}}

## 核心观点(用自己的话)

> 用 1-3 句话概括这篇内容的核心观点。
> 不要复制粘贴原文,用自己的话说。

## 关键论据 / 数据

-

## 与我已有知识的关联

- 与 [[相关笔记1]] 的观点一致/冲突
- 补充了 [[相关笔记2]] 中没讲清楚的部分

## 可行动项

- [ ] 基于这篇内容,我可以...

## 原始高亮(精选)

> 只保留最重要的 3-5 条高亮,其余留在原文中。

---
*从 [[{{title}}]] 升级*

⚠️ 这个模板的关键设计:

  • source 字段用双链指回原始同步文件,保持溯源
  • reading/processed 标签标记已处理,与未处理的 reading/highlight 区分
  • "核心观点"要求用自己的话写——这是从"收藏"到"理解"的关键步骤
  • "可行动项"把阅读转化为行动

5.2 AI 问答提示词模板

以下是经过调优的提示词,用于向本地 AI 提问你的阅读积累:

单文档理解:

基于以下从我的笔记库中检索到的内容,回答我的问题。

要求:
1. 明确引用来源文件名
2. 如果检索到的内容不足以回答,直接说"根据现有笔记找不到足够信息"
3. 如果多个来源有不同观点,分别列出

我的问题:{question}

跨文档综合:

我读过以下内容。请综合这些材料,回答我的问题。

要求:
1. 找出不同文档之间的共同观点和分歧
2. 按主题组织回答,不要按文档逐个复述
3. 标注每个观点来自哪篇文档

我的问题:{question}

知识缺口发现:

基于我的笔记库,我想了解 {topic} 这个领域。

请告诉我:
1. 我的笔记中已经覆盖了哪些方面
2. 明显缺少哪些关键概念或视角
3. 建议我接下来应该读什么来补全知识盲区

5.3 标签规范速查表

# 阅读流标签体系

## 来源标签(Readwise 自动打)
reading/highlight      所有同步内容
reading/article        网页文章
reading/book           书籍
reading/paper          论文
reading/twitter        Twitter/X
reading/newsletter     Newsletter
reading/uncategorized  未分类(需手动调整)

## 状态标签(手动打)
reading/processed      已升级为常青笔记
reading/to-process     值得进一步处理
reading/archived       已归档,不再活跃

## 质量标签(可选)
reading/important      重要内容,值得反复回顾
reading/actionable     包含可执行的建议或方法
reading/contrarian     反直觉或挑战主流观点的内容

⚠️ 标签不要打太多。每篇笔记最多 3-5 个标签。标签是分类工具,不是收藏夹——太多标签等于没有标签。


六、最小闭环验证清单

搭完之后,用以下清单验证整个链路是否跑通:

  • [ ] Readwise 中有一条新的高亮
  • [ ] 打开 Obsidian,Readwise 插件自动同步,在 Readwise Articles/ 中看到了新文件
  • [ ] 新文件的 frontmatter 包含正确的标签(reading/highlight + 来源标签)
  • [ ] 运行增量更新脚本,终端显示"已索引"该文件
  • [ ] 在问答界面提问:"我最近同步了什么内容?",AI 能引用刚同步的文件回答
  • [ ] 把一条高亮升级为常青笔记(用阅读笔记模板),确认双链指向原始文件

如果以上 6 步全部通过,你的最小闭环就跑通了。


七、常见问题

Q: 同步进来的文件太多,每天几十篇,怎么处理?

不要试图每篇都精读和升级。建议的节奏:

  • 每天 5 分钟扫一眼 Readwise Daily Review
  • 每周五花 20 分钟,从本周同步的文件中挑 2-3 篇值得深读的,升级为常青笔记
  • 其余的就放着。高亮本身已经被 AI 索引了,需要的时候能检索到

Q: ChromaDB 索引损坏了怎么办?

删除 ~/.obsidian-rag/chroma/ 目录,重新运行主脚本的全量构建。这是一次性操作,10,000 篇笔记 2-3 分钟就能重建。

Q: 本地 AI 回答质量不好怎么办?

按优先级排查:

1. 嵌入模型:nomic-embed-text 是通用选择,如果中文效果差,试试 mxbai-embed-large(更精确但慢 3 倍)

2. 切分策略:CHUNK_SIZE=1000 是默认值。如果你的笔记很短(日记、想法),调小到 500;如果是长论文,可以调到 2000

3. LLM 模型:llama3.2:8b 在中文场景下确实有限。换 qwen2.5:7b 或更大的模型

4. TOP_K 值:默认 5。如果发现 AI 漏掉了相关笔记,调到 8-10

Q: 不想用 Python,有更简单的方案吗?

有。安装 Obsidian 的 Copilot 插件,配置 Provider 为 Ollama,URL 填 http://localhost:11434,选择模型即可。不需要写任何代码,但自定义能力有限。详见第三节方案 B。

Q: Readwise 太贵了,有免费替代吗?

Readwise 的核心价值是"多来源高亮聚合 + 自动同步到 Obsidian"。免费替代方案:

  • 手动导出:大多数阅读 App 支持导出高亮为文本,手动复制到 Obsidian
  • Omnivore(已开源,可自托管):免费的稍后读工具,支持高亮和导出
  • 浏览器插件 SingleFile + Obsidian Web Clipper:网页文章直接存为 Markdown

但这些都缺少"自动同步"这一环。如果你每天阅读量不大(< 5 篇),手动方案够用;如果阅读量大,Readwise 的自动化值得付费。


*本文所有配置均在 2026 年 6 月验证通过。工具版本更新后部分细节可能需要调整,已用 ⚠️ 标注需要用户自行适配的地方。*

Last modification:June 29, 2026
如果觉得我的文章对你有用,请随意赞赏