Hermes + Obsidian + LLM-wiki 三工具联动架构设计

文档版本:v1.0 | 日期:2026-06-09 | 作者:系统架构设计


1. 三工具职责划分

在构建个人或团队 AI 知识库体系时,三个工具各司其职、相互协作,形成一个完整的知识生命周期管理闭环。

工具定位核心职责对外接口
Hermes摄取 + 路由 Agent多源信息摄取、清洗、分类、提炼、路由分发CLI / REST API / Webhook / Claude Code Hooks
ObsidianCanonical 存储层人类可读的结构化存储、双链知识图谱、版本控制Local REST API(端口 27123)/ 本地文件系统
LLM-wiki知识提炼引擎从碎片笔记中提炼结构化 wiki 条目、建立语义关联批处理命令 / 增量 Watch 模式 / HTTP API

1.1 工具定位的哲学

三工具的分工基于以下核心原则:

  • 关注点分离(Separation of Concerns):摄取、存储、提炼三个关注点分别由独立工具负责,互不耦合。
  • 人机协同(Human-in-the-Loop):Obsidian 作为 canonical 存储,保证人类随时可以直接介入编辑,不依赖任何 AI 工具即可使用。
  • 渐进式增强:三工具可以独立工作(退化模式),也可以全量联动(增强模式),新用户可以从单个工具开始,逐步接入其余组件。

1.2 数据所有权模型

原始数据所有权:用户(本地文件系统)
  └── Obsidian Vault = Single Source of Truth
       ├── Hermes 写入 → 原始摄取产物
       ├── LLM-wiki 写入 → 提炼产物
       └── 用户直接编辑 → 主动创作

所有工具都只是 Obsidian Vault 的读写客户端,任何工具的损坏或迁移都不会导致数据丢失。


2. 完整数据流架构

2.1 顶层数据流

graph TD
    A[信息源: 网页/文档/对话/代码仓库] --> B[Hermes Agent]

    B --> C{LLM 分类路由}

    C -->|原始笔记 / 待处理| D[Obsidian: inbox/]
    C -->|已结构化经验| E[Obsidian: experiences/]
    C -->|代码片段| F[Obsidian: snippets/]
    C -->|参考资料| G[Obsidian: references/]

    D --> H[LLM-wiki 提炼引擎]
    H -->|提炼完成| E
    H -->|建立双向链接| D

    E --> I[文件变化监听器 Watchdog]
    F --> I
    G --> I

    I --> J[增量向量化处理器]
    J --> K[向量数据库 ChromaDB]

    E --> L[Git 版本控制]
    F --> L
    G --> L

    K --> M[AI 消费: RAG 查询接口]
    E --> N[人类消费: Obsidian UI 直接阅读]

    M --> O[Claude / GPT 等 LLM 的上下文增强]

2.2 Hermes 内部数据流

graph LR
    subgraph 输入适配器层
        U[URL Fetcher]
        FI[File Importer]
        TI[Text Input]
        AI[API Receiver]
        WH[Webhook Listener]
    end

    subgraph 预处理层
        CL[HTML 清洗器]
        FMT[格式转换器]
        CHUNK[文本分块器]
    end

    subgraph LLM 分析层
        CLS[分类器]
        SUM[摘要生成器]
        TAG[标签提取器]
        LINK[关联推断器]
    end

    subgraph 输出路由层
        ROUTER[路由决策器]
        OBS[Obsidian Writer]
        VEC[Vector Indexer]
    end

    U --> CL
    FI --> FMT
    TI --> CHUNK
    AI --> CHUNK
    WH --> CHUNK

    CL --> CHUNK
    FMT --> CHUNK

    CHUNK --> CLS
    CLS --> SUM
    SUM --> TAG
    TAG --> LINK

    LINK --> ROUTER
    ROUTER --> OBS
    ROUTER --> VEC

2.3 LLM-wiki 提炼流水线

sequenceDiagram
    participant W as Watchdog
    participant LW as LLM-wiki
    participant LLM as LLM API
    participant OBS as Obsidian REST
    participant VEC as Vector DB

    W->>LW: 检测到 inbox/ 新文件
    LW->>OBS: GET /vault/inbox/{file}
    OBS-->>LW: 原始笔记内容
    LW->>LW: 判断是否可提炼(长度/质量阈值)
    LW->>LLM: 发送提炼 Prompt
    LLM-->>LW: 结构化 wiki 条目(Markdown)
    LW->>OBS: PUT /vault/experiences/{slug}.md
    LW->>OBS: PATCH /vault/inbox/{file}(追加 processed 标记)
    LW->>OBS: POST 建立双向链接
    LW->>VEC: 更新向量索引
    LW-->>W: 完成通知

3. Hermes 架构详解

3.1 整体架构

Hermes 是整个系统的”神经中枢”,负责将非结构化的外部信息转化为 Obsidian 可消费的 Markdown 笔记。

hermes/
├── adapters/               # 输入适配器层
│   ├── url_fetcher.py      # 网页抓取(支持 JS 渲染)
│   ├── file_importer.py    # 本地文件导入(PDF/Word/TXT)
│   ├── text_handler.py     # 直接文本输入
│   ├── api_receiver.py     # REST API 接收器
│   └── webhook_listener.py # Webhook 服务器
├── processors/             # 内容预处理层
│   ├── html_cleaner.py     # HTML 清洗(去广告/导航)
│   ├── format_converter.py # 格式转换(PDF→MD / HTML→MD)
│   └── chunker.py          # 智能文本分块
├── analysis/               # LLM 分析层
│   ├── classifier.py       # 内容分类(主题/类型)
│   ├── summarizer.py       # 摘要生成
│   ├── tagger.py           # 标签提取
│   └── linker.py           # 关联推断(与已有笔记)
├── routing/                # 输出路由层
│   ├── router.py           # 路由决策逻辑
│   └── obsidian_writer.py  # Obsidian REST API 写入
├── state/                  # 状态管理
│   ├── queue.py            # 摄取队列(基于 SQLite)
│   └── retry.py            # 错误重试机制
└── main.py                 # CLI 入口

3.2 核心摄取流程代码

# hermes/core/ingestion_pipeline.py
 
import asyncio
from dataclasses import dataclass
from typing import Optional
from enum import Enum
 
class ContentType(Enum):
    RAW_NOTE = "inbox"
    EXPERIENCE = "experiences"
    CODE_SNIPPET = "snippets"
    REFERENCE = "references"
 
@dataclass
class IngestRequest:
    source: str              # URL / 文件路径 / 原始文本
    source_type: str         # "url" | "file" | "text" | "api"
    hint: Optional[str] = None  # 用户提供的分类提示
 
@dataclass
class IngestResult:
    content_type: ContentType
    title: str
    body: str                # 最终 Markdown 内容
    tags: list[str]
    related_notes: list[str] # 推断的关联笔记路径
    confidence: float        # 分类置信度
 
class IngestionPipeline:
    def __init__(self, llm_client, obsidian_client, config):
        self.llm = llm_client
        self.obsidian = obsidian_client
        self.config = config
 
    async def ingest(self, request: IngestRequest) -> IngestResult:
        # Step 1: 获取原始内容
        raw_content = await self._fetch(request)
 
        # Step 2: 清洗和格式化
        clean_content = await self._preprocess(raw_content, request.source_type)
 
        # Step 3: LLM 分析
        analysis = await self._analyze(clean_content, hint=request.hint)
 
        # Step 4: 路由并写入 Obsidian
        await self._route_and_write(analysis)
 
        return analysis
 
    async def _analyze(self, content: str, hint: Optional[str]) -> IngestResult:
        prompt = f"""
你是一个知识管理助手。请分析以下内容并:
1. 判断内容类型(raw_note/experience/snippet/reference)
2. 生成一个简洁的标题
3. 将内容转化为标准 Markdown 格式
4. 提取 3-5 个标签
5. 识别可能的关联主题
 
{'用户提示: ' + hint if hint else ''}
 
内容:
{content[:4000]}
 
请以 JSON 格式返回结果。
"""
        response = await self.llm.complete(prompt)
        return self._parse_analysis(response)
 
    async def _route_and_write(self, result: IngestResult):
        # 根据内容类型决定写入路径
        path_map = {
            ContentType.RAW_NOTE: "inbox",
            ContentType.EXPERIENCE: "experiences",
            ContentType.CODE_SNIPPET: "snippets",
            ContentType.REFERENCE: "references",
        }
        folder = path_map[result.content_type]
        filename = self._slugify(result.title)
        await self.obsidian.create_note(
            path=f"{folder}/{filename}.md",
            content=self._render_note(result)
        )
 
    def _render_note(self, result: IngestResult) -> str:
        tags_str = "\n".join(f"  - {t}" for t in result.tags)
        links_str = "\n".join(f"- [[{n}]]" for n in result.related_notes)
        return f"""---
tags:
{tags_str}
created: {self._now_iso()}
source_type: hermes_ingestion
---
 
# {result.title}
 
{result.body}
 
## 相关笔记
{links_str}
"""

3.3 状态管理与错误重试

# hermes/state/queue.py
 
import sqlite3
import json
from datetime import datetime
from enum import Enum
 
class JobStatus(Enum):
    PENDING = "pending"
    PROCESSING = "processing"
    DONE = "done"
    FAILED = "failed"
 
class IngestionQueue:
    """基于 SQLite 的持久化摄取队列,支持崩溃恢复"""
 
    def __init__(self, db_path: str = "~/.hermes/queue.db"):
        self.conn = sqlite3.connect(db_path)
        self._init_schema()
 
    def _init_schema(self):
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS jobs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                source TEXT NOT NULL,
                source_type TEXT NOT NULL,
                hint TEXT,
                status TEXT DEFAULT 'pending',
                retry_count INTEGER DEFAULT 0,
                error_msg TEXT,
                created_at TEXT,
                updated_at TEXT
            )
        """)
        self.conn.commit()
 
    def enqueue(self, source: str, source_type: str, hint: str = None) -> int:
        now = datetime.utcnow().isoformat()
        cursor = self.conn.execute(
            "INSERT INTO jobs (source, source_type, hint, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
            (source, source_type, hint, now, now)
        )
        self.conn.commit()
        return cursor.lastrowid
 
    def mark_failed(self, job_id: int, error: str):
        self.conn.execute(
            "UPDATE jobs SET status='failed', error_msg=?, retry_count=retry_count+1 WHERE id=?",
            (error, job_id)
        )
        self.conn.commit()
 
    def get_retryable(self, max_retries: int = 3):
        """获取可重试的失败任务(retry_count < max_retries)"""
        return self.conn.execute(
            "SELECT * FROM jobs WHERE status='failed' AND retry_count < ?",
            (max_retries,)
        ).fetchall()

4. Obsidian Local REST API 集成

4.1 API 端点全览

Obsidian Local REST API 插件默认监听 https://127.0.0.1:27123,提供以下核心端点:

端点方法功能
/vault/{path}GET读取文件内容
/vault/{path}PUT创建或覆盖文件
/vault/{path}PATCH追加内容到文件
/vault/{path}DELETE删除文件
/vault/GET列出目录内容
/search/simple/?query=GET全文搜索
/active/GET获取当前活跃文件
/commands/POST执行 Obsidian 命令

4.2 认证方式

Obsidian Local REST API 使用 Bearer Token 认证,token 在插件设置中生成:

Authorization: Bearer <your-api-key>

注意:默认使用自签名 TLS 证书,需要在客户端禁用证书验证(verify=False)或导入证书。

4.3 完整 Python 客户端封装

# obsidian_client/client.py
 
import httpx
import json
from pathlib import Path
from typing import Optional, Union
from dataclasses import dataclass
 
@dataclass
class ObsidianNote:
    path: str
    content: str
    frontmatter: dict = None
 
class ObsidianClient:
    """
    Obsidian Local REST API 的 Python 客户端封装。
    支持同步和异步两种使用方式。
    """
 
    def __init__(
        self,
        base_url: str = "https://127.0.0.1:27123",
        api_key: str = None,
        verify_ssl: bool = False,
    ):
        self.base_url = base_url.rstrip("/")
        self.headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "text/markdown",
        }
        self.verify_ssl = verify_ssl
        self._client = httpx.Client(
            headers=self.headers,
            verify=self.verify_ssl,
            timeout=30.0
        )
 
    # ── 文件操作 ──────────────────────────────────────────────────────────
 
    def read_note(self, path: str) -> Optional[str]:
        """读取笔记内容,path 为相对于 vault 根目录的路径"""
        resp = self._client.get(f"{self.base_url}/vault/{path}")
        if resp.status_code == 404:
            return None
        resp.raise_for_status()
        return resp.text
 
    def create_note(self, path: str, content: str) -> bool:
        """创建或覆盖笔记(PUT 语义)"""
        resp = self._client.put(
            f"{self.base_url}/vault/{path}",
            content=content.encode("utf-8")
        )
        resp.raise_for_status()
        return resp.status_code in (200, 204)
 
    def append_to_note(self, path: str, content: str) -> bool:
        """追加内容到笔记末尾(PATCH 语义)"""
        resp = self._client.patch(
            f"{self.base_url}/vault/{path}",
            content=content.encode("utf-8"),
            headers={**self.headers, "Content-Type": "text/markdown"}
        )
        resp.raise_for_status()
        return resp.status_code in (200, 204)
 
    def delete_note(self, path: str) -> bool:
        """删除笔记"""
        resp = self._client.delete(f"{self.base_url}/vault/{path}")
        return resp.status_code in (200, 204, 404)
 
    def list_directory(self, path: str = "") -> list[str]:
        """列出目录中的文件"""
        url = f"{self.base_url}/vault/{path}/" if path else f"{self.base_url}/vault/"
        resp = self._client.get(url)
        resp.raise_for_status()
        return resp.json().get("files", [])
 
    # ── 搜索 ──────────────────────────────────────────────────────────────
 
    def search(self, query: str, context_length: int = 200) -> list[dict]:
        """全文搜索,返回匹配的笔记路径和上下文摘要"""
        resp = self._client.get(
            f"{self.base_url}/search/simple/",
            params={"query": query, "contextLength": context_length}
        )
        resp.raise_for_status()
        return resp.json()
 
    # ── 辅助方法 ──────────────────────────────────────────────────────────
 
    def ensure_directory_exists(self, folder_path: str):
        """通过创建占位文件来确保目录存在(Obsidian 没有独立的目录创建 API)"""
        placeholder = f"{folder_path}/.gitkeep"
        if not self.read_note(placeholder):
            self.create_note(placeholder, "")
 
    def upsert_note(self, path: str, content: str) -> str:
        """
        幂等的创建/更新操作。
        返回 "created" 或 "updated"。
        """
        existing = self.read_note(path)
        self.create_note(path, content)
        return "updated" if existing else "created"
 
    def add_backlink(self, source_path: str, target_path: str):
        """在 source 笔记末尾追加指向 target 的反向链接"""
        target_name = Path(target_path).stem  # 去掉 .md 后缀
        link_section = f"\n\n## 反向链接\n- [[{target_name}]]\n"
        # 检查是否已存在该链接(避免重复)
        existing = self.read_note(source_path) or ""
        if f"[[{target_name}]]" not in existing:
            self.append_to_note(source_path, link_section)
 
 
# ── 使用示例 ──────────────────────────────────────────────────────────────
 
if __name__ == "__main__":
    client = ObsidianClient(
        base_url="https://127.0.0.1:27123",
        api_key="your-api-key-here"
    )
 
    # 创建一篇新笔记
    note_content = """---
tags:
  - python
  - async
created: 2026-06-09
---
 
# Python 异步编程要点
 
asyncio 的核心模型是单线程事件循环,通过协程实现并发。
 
## 关键概念
- `async def` 定义协程函数
- `await` 挂起当前协程,让出控制权
- `asyncio.gather()` 并发运行多个协程
"""
    result = client.upsert_note("experiences/python-async-notes.md", note_content)
    print(f"笔记操作结果: {result}")
 
    # 搜索
    results = client.search("asyncio 事件循环")
    for r in results:
        print(f"找到: {r['filename']}{r['context'][:100]}")

5. LLM-wiki 提炼流程

5.1 提炼触发策略

# llm_wiki/refiner.py
 
import os
import re
import asyncio
from pathlib import Path
from datetime import datetime
from obsidian_client import ObsidianClient
 
# 可提炼的判断条件
MIN_WORD_COUNT = 100        # 最少 100 词才值得提炼
MAX_AGE_DAYS = 30           # inbox 中超过 30 天的笔记强制处理
PROCESSED_MARKER = "<!-- llm-wiki:processed -->"
 
class WikiRefiner:
    def __init__(self, obsidian: ObsidianClient, llm_client):
        self.obsidian = obsidian
        self.llm = llm_client
 
    async def run_refine_cycle(self):
        """扫描 inbox,批量提炼"""
        inbox_files = self.obsidian.list_directory("inbox")
        md_files = [f for f in inbox_files if f.endswith(".md") and f != ".gitkeep"]
 
        print(f"[llm-wiki] 发现 {len(md_files)} 个待处理文件")
 
        tasks = []
        for filename in md_files:
            path = f"inbox/{filename}"
            content = self.obsidian.read_note(path)
            if content and self._should_refine(content):
                tasks.append(self._refine_single(path, content))
 
        # 并发提炼(最多 3 个并发,避免 API 限流)
        semaphore = asyncio.Semaphore(3)
        async def bounded_refine(coro):
            async with semaphore:
                return await coro
 
        results = await asyncio.gather(
            *[bounded_refine(t) for t in tasks],
            return_exceptions=True
        )
        success = sum(1 for r in results if not isinstance(r, Exception))
        print(f"[llm-wiki] 提炼完成: {success}/{len(tasks)} 成功")
 
    def _should_refine(self, content: str) -> bool:
        """判断内容是否值得提炼"""
        if PROCESSED_MARKER in content:
            return False
        word_count = len(content.split())
        return word_count >= MIN_WORD_COUNT
 
    async def _refine_single(self, inbox_path: str, raw_content: str):
        """提炼单篇笔记"""
        print(f"[llm-wiki] 提炼: {inbox_path}")
 
        # Step 1: 调用 LLM 生成结构化 wiki 条目
        wiki_entry = await self._call_llm_refine(raw_content)
 
        # Step 2: 生成目标路径(基于 LLM 返回的 slug)
        slug = wiki_entry.get("slug") or self._auto_slug(wiki_entry.get("title", "untitled"))
        target_path = f"experiences/{slug}.md"
 
        # Step 3: 写入 experiences/
        final_content = self._render_wiki_entry(wiki_entry)
        action = self.obsidian.upsert_note(target_path, final_content)
        print(f"[llm-wiki]   → {target_path} ({action})")
 
        # Step 4: 在 inbox 原文末尾追加处理标记和反向链接
        marker = f"\n\n{PROCESSED_MARKER}\n提炼时间: {datetime.now().isoformat()}\n提炼结果: [[{slug}]]\n"
        self.obsidian.append_to_note(inbox_path, marker)
 
        # Step 5: 建立双向链接
        self.obsidian.add_backlink(target_path, inbox_path)
 
        # Step 6: 触发向量重新索引(发送事件)
        await self._notify_reindex(target_path)
 
    async def _call_llm_refine(self, raw_content: str) -> dict:
        """调用 LLM 将原始笔记提炼为结构化 wiki 条目"""
        system_prompt = """你是一个知识工程师,擅长将碎片化笔记整理为结构清晰的 wiki 条目。
请将用户提供的笔记内容整理为标准 wiki 格式,返回 JSON:
{
  "title": "简洁准确的标题",
  "slug": "kebab-case-filename",
  "summary": "一句话摘要(< 100 字)",
  "tags": ["tag1", "tag2"],
  "body": "完整的 Markdown 内容,包含层级标题、代码块、要点列表",
  "related_topics": ["相关主题1", "相关主题2"]
}"""
        response = await self.llm.complete(
            system=system_prompt,
            user=raw_content[:6000]
        )
        return json.loads(response)
 
    def _render_wiki_entry(self, entry: dict) -> str:
        tags = "\n".join(f"  - {t}" for t in entry.get("tags", []))
        related = "\n".join(f"- [[{r}]]" for r in entry.get("related_topics", []))
        return f"""---
title: {entry['title']}
tags:
{tags}
summary: {entry.get('summary', '')}
created: {datetime.now().strftime('%Y-%m-%d')}
source: llm-wiki-refiner
---
 
# {entry['title']}
 
> {entry.get('summary', '')}
 
{entry['body']}
 
## 相关主题
{related}
"""

6. 增量索引机制

6.1 文件变化监听

# vector_indexer/watcher.py
 
import asyncio
import hashlib
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent, FileDeletedEvent
 
class VaultChangeHandler(FileSystemEventHandler):
    """监听 Obsidian Vault 的文件变化,触发增量向量索引"""
 
    IGNORED_FOLDERS = {".obsidian", ".git", ".trash"}
    WATCHED_FOLDERS = {"experiences", "snippets", "references"}  # 不监听 inbox(未提炼)
 
    def __init__(self, indexer):
        self.indexer = indexer
        self._pending_events = asyncio.Queue()
 
    def on_modified(self, event):
        if not event.is_directory and self._should_index(event.src_path):
            asyncio.run_coroutine_threadsafe(
                self._pending_events.put(("upsert", event.src_path)),
                asyncio.get_event_loop()
            )
 
    def on_created(self, event):
        if not event.is_directory and self._should_index(event.src_path):
            asyncio.run_coroutine_threadsafe(
                self._pending_events.put(("upsert", event.src_path)),
                asyncio.get_event_loop()
            )
 
    def on_deleted(self, event):
        if not event.is_directory and self._should_index(event.src_path):
            asyncio.run_coroutine_threadsafe(
                self._pending_events.put(("delete", event.src_path)),
                asyncio.get_event_loop()
            )
 
    def on_moved(self, event):
        # 重命名 = 删除旧 + 创建新
        if self._should_index(event.src_path):
            asyncio.run_coroutine_threadsafe(
                self._pending_events.put(("delete", event.src_path)),
                asyncio.get_event_loop()
            )
        if self._should_index(event.dest_path):
            asyncio.run_coroutine_threadsafe(
                self._pending_events.put(("upsert", event.dest_path)),
                asyncio.get_event_loop()
            )
 
    def _should_index(self, path: str) -> bool:
        p = Path(path)
        if not p.suffix == ".md":
            return False
        for part in p.parts:
            if part in self.IGNORED_FOLDERS:
                return False
        # 只索引 WATCHED_FOLDERS 中的内容
        for folder in self.WATCHED_FOLDERS:
            if folder in p.parts:
                return True
        return False
 
    async def process_events(self):
        """事件处理循环,带防抖(1秒内同一文件的多个事件合并)"""
        pending = {}  # path → (action, timestamp)
        while True:
            try:
                action, path = await asyncio.wait_for(
                    self._pending_events.get(), timeout=1.0
                )
                pending[path] = action
            except asyncio.TimeoutError:
                # 1秒无新事件,处理积压
                if pending:
                    for path, action in pending.items():
                        await self.indexer.handle_change(action, path)
                    pending.clear()

6.2 向量数据库增量更新

# vector_indexer/indexer.py
 
import chromadb
from chromadb.utils import embedding_functions
from pathlib import Path
import hashlib
 
class IncrementalVectorIndexer:
    """
    基于 ChromaDB 的增量向量索引器。
    只重新索引变更的文件,通过内容哈希判断是否真正变化。
    """
 
    def __init__(self, chroma_url: str, collection_name: str = "obsidian_vault"):
        self.client = chromadb.HttpClient(host=chroma_url)
        self.ef = embedding_functions.DefaultEmbeddingFunction()
        self.collection = self.client.get_or_create_collection(
            name=collection_name,
            embedding_function=self.ef,
            metadata={"hnsw:space": "cosine"}
        )
 
    async def handle_change(self, action: str, file_path: str):
        doc_id = self._path_to_id(file_path)
        if action == "delete":
            self._delete_document(doc_id)
        elif action == "upsert":
            await self._upsert_document(file_path, doc_id)
 
    async def _upsert_document(self, file_path: str, doc_id: str):
        content = Path(file_path).read_text(encoding="utf-8")
        content_hash = hashlib.md5(content.encode()).hexdigest()
 
        # 检查哈希是否变化(避免无意义的重新嵌入,节省 API 费用)
        existing = self.collection.get(ids=[doc_id], include=["metadatas"])
        if existing["ids"] and existing["metadatas"][0].get("hash") == content_hash:
            return  # 内容未变,跳过
 
        # 按段落分块(每块最多 500 词)
        chunks = self._chunk_document(content)
        chunk_ids = [f"{doc_id}#{i}" for i in range(len(chunks))]
        metadatas = [
            {"source": file_path, "chunk_index": i, "hash": content_hash}
            for i in range(len(chunks))
        ]
 
        # 先删除旧的所有 chunk
        self._delete_document(doc_id)
 
        # 批量 upsert 新 chunk
        self.collection.upsert(
            ids=chunk_ids,
            documents=chunks,
            metadatas=metadatas
        )
        print(f"[indexer] 已索引: {Path(file_path).name} ({len(chunks)} chunks)")
 
    def _delete_document(self, doc_id: str):
        """删除文档的所有 chunk(通过前缀匹配)"""
        existing = self.collection.get(
            where={"source": {"$like": f"%{doc_id}%"}}
        )
        if existing["ids"]:
            self.collection.delete(ids=existing["ids"])
 
    def _chunk_document(self, content: str, max_words: int = 500) -> list[str]:
        paragraphs = content.split("\n\n")
        chunks, current_chunk, current_words = [], [], 0
        for para in paragraphs:
            words = len(para.split())
            if current_words + words > max_words and current_chunk:
                chunks.append("\n\n".join(current_chunk))
                current_chunk, current_words = [para], words
            else:
                current_chunk.append(para)
                current_words += words
        if current_chunk:
            chunks.append("\n\n".join(current_chunk))
        return chunks
 
    def _path_to_id(self, path: str) -> str:
        return Path(path).stem.replace(" ", "_")

7. 三工具的配置和部署

7.1 最小可用部署(单机 Docker)

# docker-compose.yml
version: "3.9"
 
services:
  hermes:
    image: ghcr.io/yourorg/hermes:latest
    restart: unless-stopped
    volumes:
      - ${VAULT_PATH}:/vault:rw        # Obsidian Vault 挂载
      - ./hermes_data:/data            # 队列数据库
    environment:
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - OBSIDIAN_API_URL=http://host.docker.internal:27123
      - OBSIDIAN_API_KEY=${OBSIDIAN_API_KEY}
      - HERMES_QUEUE_PATH=/data/queue.db
      - CHROMA_URL=http://chroma:8000
      - LOG_LEVEL=INFO
    ports:
      - "8765:8765"   # Webhook 监听端口
    extra_hosts:
      - "host.docker.internal:host-gateway"
 
  llm-wiki:
    image: ghcr.io/yourorg/llm-wiki:latest
    restart: unless-stopped
    volumes:
      - ${VAULT_PATH}:/vault:rw
    environment:
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - OBSIDIAN_API_URL=http://host.docker.internal:27123
      - OBSIDIAN_API_KEY=${OBSIDIAN_API_KEY}
      - CHROMA_URL=http://chroma:8000
      - REFINE_INTERVAL=3600     # 每小时执行一次提炼
      - WATCH_MODE=true          # 同时监听文件变化
    extra_hosts:
      - "host.docker.internal:host-gateway"
 
  chroma:
    image: chromadb/chroma:0.5.0
    restart: unless-stopped
    ports:
      - "8000:8000"
    volumes:
      - ./chroma_data:/chroma/chroma
    environment:
      - CHROMA_SERVER_AUTH_PROVIDER=chromadb.auth.token.TokenAuthServerProvider
      - CHROMA_SERVER_AUTH_CREDENTIALS=${CHROMA_TOKEN}
      - CHROMA_SERVER_AUTH_TOKEN_TRANSPORT_HEADER=X-Chroma-Token
 
  # 可选: 向量检索 API(供 Claude/GPT 等 LLM 调用)
  rag-api:
    image: ghcr.io/yourorg/rag-api:latest
    restart: unless-stopped
    ports:
      - "8001:8001"
    environment:
      - CHROMA_URL=http://chroma:8000
      - CHROMA_TOKEN=${CHROMA_TOKEN}
      - API_KEY=${RAG_API_KEY}
    depends_on:
      - chroma
# .env
VAULT_PATH=/home/user/obsidian-vault
ANTHROPIC_API_KEY=sk-ant-...
OBSIDIAN_API_KEY=your-obsidian-key
CHROMA_TOKEN=your-chroma-token
RAG_API_KEY=your-rag-api-key

7.2 Vault 目录结构规范

obsidian-vault/
├── inbox/              # Hermes 写入的原始摄取内容(待处理)
│   ├── 2026-06-09-python-async.md
│   └── 2026-06-09-docker-tips.md
├── experiences/        # LLM-wiki 提炼的结构化知识(最终形态)
│   ├── python-async-programming.md
│   └── docker-best-practices.md
├── snippets/           # 代码片段
│   └── python-retry-decorator.md
├── references/         # 参考资料(技术文档/论文摘要)
│   └── obsidian-rest-api-reference.md
├── projects/           # 项目专属笔记(手动维护)
└── journal/            # 日记/会议记录(不参与向量索引)

8. 触发机制设计

8.1 四种触发方式

graph LR
    subgraph 触发方式
        M[手动 CLI]
        FW[文件监听 Watchdog]
        CR[定时 Cron]
        WH[Webhook / HTTP]
        CCH[Claude Code Hooks]
    end

    subgraph 处理目标
        HI[Hermes 摄取]
        LR[LLM-wiki 提炼]
        VI[向量重建索引]
    end

    M --> HI
    M --> LR
    FW --> VI
    FW --> LR
    CR --> LR
    CR --> VI
    WH --> HI
    CCH --> HI

8.2 CLI 接口设计

# 摄取单个 URL
hermes ingest --url "https://example.com/article" --hint "Python 性能优化"
 
# 摄取本地文件
hermes ingest --file ./notes.txt
 
# 批量摄取(stdin 或文件列表)
cat urls.txt | hermes ingest --batch
 
# 手动触发 LLM-wiki 提炼循环
llm-wiki refine --dry-run   # 预览模式,不实际写入
llm-wiki refine             # 执行提炼
 
# 重建全量向量索引
llm-wiki reindex --folder experiences
 
# 查询 RAG(测试用)
llm-wiki query "Python asyncio 的并发模型是什么"

8.3 Claude Code Hooks 集成

.claude/settings.json 中配置 Hook,实现 Claude Code 会话结束后自动归档:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "hermes ingest --text \"$CLAUDE_SESSION_SUMMARY\" --hint \"claude-session\" 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}

8.4 定时任务配置

# crontab -e
# 每天凌晨 2 点执行 LLM-wiki 提炼(低峰期避免影响使用)
0 2 * * * docker exec llm-wiki llm-wiki refine >> /var/log/llm-wiki-cron.log 2>&1
 
# 每周日重建全量向量索引(保证索引完整性)
0 3 * * 0 docker exec llm-wiki llm-wiki reindex --folder experiences >> /var/log/reindex-cron.log 2>&1

9. 可观测性

9.1 结构化日志格式

所有三个工具统一采用 JSON 结构化日志,方便聚合和查询:

{
  "timestamp": "2026-06-09T02:15:30.123Z",
  "level": "INFO",
  "service": "hermes",
  "event": "ingest_completed",
  "source_url": "https://example.com/article",
  "content_type": "experience",
  "target_path": "experiences/python-perf-notes.md",
  "duration_ms": 2341,
  "token_usage": {
    "input": 1200,
    "output": 800
  }
}
{
  "timestamp": "2026-06-09T02:16:01.456Z",
  "level": "WARNING",
  "service": "llm-wiki",
  "event": "refine_skipped",
  "reason": "below_word_threshold",
  "file": "inbox/short-note.md",
  "word_count": 42,
  "threshold": 100
}

9.2 关键指标监控

指标名类型说明告警阈值
hermes_ingest_totalCounter总摄取数量
hermes_ingest_errors_totalCounter摄取错误数错误率 > 10%
hermes_queue_depthGauge当前队列积压深度> 50
llm_wiki_refine_duration_secondsHistogram单次提炼耗时P99 > 30s
vector_index_documents_totalGauge向量库文档总数
llm_token_cost_usd_totalCounter累计 LLM 费用日费用 > $5
obsidian_api_errors_totalCounterObsidian API 错误数> 0(即告警)

9.3 Prometheus + Grafana 部署

# docker-compose.yml 追加
  prometheus:
    image: prom/prometheus:v2.51.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"
 
  grafana:
    image: grafana/grafana:10.3.0
    ports:
      - "3000:3000"
    volumes:
      - ./grafana_data:/var/lib/grafana
# prometheus.yml
global:
  scrape_interval: 30s
 
scrape_configs:
  - job_name: hermes
    static_configs:
      - targets: ["hermes:8766"]   # /metrics 端点
  - job_name: llm-wiki
    static_configs:
      - targets: ["llm-wiki:8767"]

10. 替代方案对比

方案优点缺点适合谁
Hermes + Obsidian + LLM-wiki(本方案)完全本地、高度可定制、数据自主、可编程集成需要自行搭建维护,初始配置成本高有一定工程能力的个人开发者、研究员
Notion + AI(Notion AI / 第三方插件)开箱即用、界面美观、多人协作数据存储在 Notion 云端、API 费用高、定制能力有限非技术用户、小团队协作
AnythingLLM界面友好、支持多种 LLM 后端、文档上传简单知识组织能力弱(无双链)、定制能力有限、本地部署资源占用大希望有 GUI 的小团队
Khoj开源完整、支持 Obsidian 插件、跨平台搜索配置复杂、文档不完善、社区较小有 Linux 运维经验的高级用户
Mem.aiAI 自动组织强、搜索体验好完全云端、价格较贵($14.99/月)、无法自托管愿意付费、不在意数据隐私的用户
Logseq + LLM 插件大纲式思维友好、本地优先LLM 集成生态不如 Obsidian 成熟大纲式笔记爱好者

10.1 选型决策树

你有编程能力,且希望数据完全本地?
  └── 是 → 本方案(Hermes + Obsidian + LLM-wiki)
  └── 否 →
        你愿意为托管服务付费?
          └── 是 → Mem.ai(个人)或 Notion AI(团队)
          └── 否 →
                你接受 GUI 配置复杂度?
                  └── 是 → Khoj(功能最完整的开源方案)
                  └── 否 → AnythingLLM(最容易上手的本地方案)

附录:快速启动指南

A. 环境准备清单

# 1. 安装 Obsidian 并启用 Local REST API 插件
# 插件市场搜索: "Local REST API",安装后记录 API Key
 
# 2. 克隆项目
git clone https://github.com/yourorg/knowledge-stack.git
cd knowledge-stack
 
# 3. 复制并填写环境变量
cp .env.example .env
vim .env
 
# 4. 启动所有服务
docker compose up -d
 
# 5. 验证服务状态
docker compose ps
curl http://localhost:8001/health   # RAG API 健康检查
 
# 6. 测试摄取
docker exec hermes hermes ingest \
  --url "https://docs.python.org/3/library/asyncio.html" \
  --hint "Python 异步编程"
 
# 7. 检查 Obsidian Vault 中是否出现新笔记
ls ~/obsidian-vault/inbox/

B. 故障排查速查

症状可能原因解决方法
Obsidian API 返回 401API Key 错误或 Obsidian 未打开检查 .env 中的 OBSIDIAN_API_KEY;确保 Obsidian 正在运行
LLM 调用超时网络问题或 API Key 无效检查 ANTHROPIC_API_KEY;测试 curl api.anthropic.com 连通性
向量索引未更新Watchdog 未监听到变化检查 Vault 挂载路径是否正确;重启 llm-wiki 服务
ChromaDB 连接拒绝Chroma 服务未启动docker compose up -d chroma
摄取队列积压LLM API 限流或网络慢调低 MAX_CONCURRENT_INGESTS 环境变量