# 萌芽 RAG 知识库

轻量版 Obsidian 笔记 RAG 项目，基于 LlamaIndex 和 DeepSeek API。
检索使用 Markdown 结构感知分块 + 本地小 embedding + BM25 的混合检索，适合个人 Obsidian 笔记这种规模。

## 项目结构

```text
mengya-rag/
├── data/
│   ├── notes/          # 从 bigmengya 同步来的 Obsidian Markdown 笔记
│   └── index/bm25/     # 本地节点和向量索引
│       ├── rag.sqlite3 # SQLite + sqlite-vec 本地向量库
│       └── nodes.jsonl # 分块内容和元数据备份
├── scripts/
│   ├── sync_notes.sh   # 同步 bigmengya 笔记
│   ├── build_index.sh  # 构建索引
│   └── ask.sh          # 命令行问答
├── src/mengya_rag/
│   ├── config.py       # 配置读取
│   ├── markdown_chunker.py # Markdown 结构感知分块
│   ├── indexing.py     # 建索引
│   ├── retrieval.py    # 检索、融合、盘点模式
│   ├── qa.py           # DeepSeek 生成回答
│   └── cli.py          # CLI 命令入口
├── .env
├── .env.example
├── pyproject.toml
└── README.md
```

## 技术栈

- RAG 基础框架：LlamaIndex
- 本地 embedding：`BAAI/bge-small-zh-v1.5`
- embedding 运行：FastEmbed + ONNX
- 大模型回答：DeepSeek API
- 当前推荐模型：`deepseek-v4-flash`
- 检索方式：BM25 + 向量检索
- 融合方式：RRF 类融合 + 原始相似度过滤 + 同文件去重
- 数据库：无
- 向量库：SQLite + sqlite-vec
- 服务端：无，当前是命令行工具

## 初始化

```bash
cd /shumengya/project/agent/mengya-rag
cp .env.example .env
```

编辑 `.env`，填入：

```env
DEEPSEEK_API_KEY=你的 DeepSeek API Key
```

安装依赖：

```bash
uv sync
```

## 同步笔记

笔记源路径：

```text
bigmengya:/shumengya/docker/mengyanote-backend/data/mengyanote/
```

同步到本地：

```bash
./scripts/sync_notes.sh
# 或
uv run python scripts/sync_notes.py
```

## 构建索引

```bash
./scripts/build_index.sh
# 或
uv run python scripts/build_index.py
```

## 提问

```bash
./scripts/ask.sh "萌芽笔记是什么"
# 或
uv run python scripts/ask.py "萌芽笔记是什么"
```

也可以直接使用安装后的命令：

```bash
uv run mengya-ask "Docker 常用命令有哪些"
```

## Agent 调用推荐

统一入口是 `mengya-rag`。给大模型 Agent 或脚本调用时，推荐固定使用 `--json`，输出是一行 JSON，便于解析。

```bash
# 查看配置、索引和笔记数量
uv run mengya-rag status --json

# 只检索，不调用大模型，返回命中文档和内容
uv run mengya-rag search "Docker 常用命令" -k 5 --json

# 返回可直接注入上游大模型的 context 字段
uv run mengya-rag context "WireGuard 怎么配置" -k 4 --json

# 按 search/context 返回的 source 读取完整笔记
uv run mengya-rag read "Docker/Docker常用命令总结.md" --json

# 本项目自己调用 DeepSeek 生成回答
uv run mengya-rag ask "Docker 常用命令有哪些" -k 4 --json

# 同步笔记并重建索引
uv run mengya-rag reindex --json
```

Agent 优先使用这几个命令：

- `status --json`：检查索引是否存在、笔记数、节点数、模型配置。
- `search --json`：拿结构化检索结果，包含 `results[].source`、`title`、`heading_path`、`score`、`content`。
- `context --json`：拿拼好的 `context` 字符串，适合直接放进其他大模型提示词。
- `read --json`：按 `source` 读取完整 Markdown 原文，适合检索命中后补充上下文。
- `ask --json`：让本项目完成 RAG + DeepSeek 回答，返回 `answer` 和 `sources`。
- `reindex --json`：一条命令完成同步笔记和重建索引。

常用参数：

```bash
--mode auto|hybrid|inventory   # auto 默认；hybrid 普通语义检索；inventory 盘点/目录类检索
-k, --top-k 数字               # 控制返回条数
--env-file /path/.env          # 指定配置文件
--notes-dir /path/notes        # 覆盖笔记目录
--index-dir /path/index        # 覆盖索引目录
```

## 查询模式

项目会自动区分两类问题：

- 普通问答：例如“WireGuard 命令怎么用”，走 Markdown 分块 + 向量/BM25 混合检索。
- 盘点列表：例如“查一下我目前博客文章有哪些”“查一下安卓 Gradle 相关笔记”，走文件级目录检索，优先列出相关 Markdown 文件和内容预览。

## 技术原理

### Markdown 分块

项目不使用固定字符数暴力切割，而是使用结构感知分块：

```text
Markdown 文件
  ↓
解析 frontmatter
  ↓
识别 H1-H6 标题
  ↓
按标题层级组织分块
  ↓
代码块和表格整体保留
  ↓
长块使用滑动窗口兜底
```

每个 chunk 前会附加上下文前缀：

```text
文件: AI/控制台Agent工具安装教程.md
标题: 控制台Agent工具安装教程
标题路径: H1 > H2 > H3
```

每个 chunk 会保存这些元数据：

```text
source_file
rel_path
file_name
folder_path
title
heading_path
h1
h2
h3
h4
h5
h6
chunk_index
tags
created_at
```

### 索引构建

执行：

```bash
./scripts/build_index.sh
```

构建流程：

```text
Markdown 文件
  ↓
结构感知分块
  ↓
生成 TextNode
  ↓
写入 nodes.jsonl
  ↓
用 bge-small-zh-v1.5 生成 embedding
  ↓
归一化向量
  ↓
写入 SQLite + sqlite-vec
```

当前索引规模参考：

```text
分块数: 1802
索引大小: 约 13M
nodes.jsonl: 约 3.6M
rag.sqlite3: 约 9.3M，包含 chunk 表和 sqlite-vec 向量表
```

### 普通问答检索

普通问答适合“怎么做”“是什么”“原理是什么”这类问题。

```text
用户问题
  ↓
query 清洗
  ↓
BM25 关键词检索
  ↓
本地向量语义检索
  ↓
RRF 融合
  ↓
同文件去重
  ↓
相关性过滤
  ↓
top-k chunk 注入 DeepSeek
  ↓
生成回答和来源
```

### 盘点列表检索

盘点列表适合“有哪些”“查一下”“相关笔记”“目录”“清单”这类问题。

```text
用户问题
  ↓
识别为盘点类问题
  ↓
构建文件级摘要节点
  ↓
文件级 BM25 检索
  ↓
关键词覆盖过滤
  ↓
输出 Markdown 文件列表、路径和内容预览
```

## 重新更新知识库

笔记变更后按顺序执行：

```bash
./scripts/sync_notes.sh
./scripts/build_index.sh
```

## 设计取舍

- Markdown 笔记按结构感知分块：识别 frontmatter、H1-H6、代码块、表格。
- H1-H6 标题会写入 `h1` 到 `h6` 元数据，同时保存 `heading_path` 面包屑。
- 代码块和表格整体保留，不跨块切割；长块再用滑动窗口兜底。
- 每个 chunk 都带 `source_file`、`heading_path`、`chunk_index`、`tags`、`created_at`。
- 本地 embedding 使用 `BAAI/bge-small-zh-v1.5`，通过 FastEmbed/ONNX 运行，不依赖 PyTorch 和 GPU。
- 检索采用 BM25 + 向量召回，再用 RRF 融合和同文件去重，避免只靠关键词或只靠语义。
- 使用 DeepSeek 只负责生成回答：减少 API 调用，只在提问时调用大模型。
- 本地向量库使用 SQLite + sqlite-vec：无需 Qdrant、Milvus、Postgres 等额外服务。

## 当前效果

目前比较适合处理：

- 某类笔记有哪些
- 某个教程在哪里
- 根据笔记总结某个主题
- 列出博客文章
- 列出 Agent 安装教程
- 查 Gradle 构建相关内容

示例：

```bash
./scripts/ask.sh '查一下我目前博客文章有哪些'
./scripts/ask.sh '查一下我的安卓gradle构建相关笔记'
./scripts/ask.sh '查一下我的Agent安装教程'
```

## 当前限制

- 当前是 SQLite + sqlite-vec 单机向量库，数据量很大后可以换 Qdrant 或 Chroma。
- 没有 reranker，复杂问题可能还会有弱相关内容混入。
- embedding 模型是轻量中文模型，效果够用但不是最强。
- `build_index` 每次是全量重建，还没有增量索引。
- frontmatter 解析是轻量手写版，不是完整 YAML 解析。
- 当前是 CLI 工具，没有 Web 页面和 API 服务。

## 后续优化方向

- 增量索引，只重建变更文件。
- 加 reranker，例如 `bge-reranker-v2-m3`。
- 给盘点类问题增加目录树模式。
- 增加 `--debug` 输出检索过程。
- 数据量变大后接 Qdrant。

## 调参

`.env` 中可调整：

```env
TOP_K=6
EMBED_MODEL=BAAI/bge-small-zh-v1.5
EMBED_BATCH_SIZE=32
VECTOR_WEIGHT=0.65
BM25_WEIGHT=0.35
```


这是一个很详细的 README。让我继续查看源代码文件来更深入了解项目。


总计 80
drwxr-xr-x 3 root root  4096  5月13日 14:15 .
drwxr-xr-x 3 root root  4096  5月12日 16:53 ..
-rw-r--r-- 1 root root 18172  5月13日 14:22 cli.py
-rw-r--r-- 1 root root  2227  5月13日 14:14 config.py
-rw-r--r-- 1 root root  2448  5月12日 21:35 embeddings.py
-rw-r--r-- 1 root root  3848  5月12日 21:46 indexing.py
-rw-r--r-- 1 root root    53  5月12日 17:09 __init__.py
-rw-r--r-- 1 root root  8592  5月12日 21:07 markdown_chunker.py
drwxr-xr-x 2 root root  4096  5月13日 14:23 __pycache__
-rw-r--r-- 1 root root  2854  5月13日 14:23 qa.py
-rw-r--r-- 1 root root 11689  5月12日 21:46 retrieval.py
-rw-r--r-- 1 root root  3530  5月12日 21:46 vector_store.py


让我查看主要的源代码文件。


from __future__ import annotations

import re
from dataclasses import dataclass, field
from pathlib import Path

from llama_index.core.schema import TextNode

TARGET_TOKENS = 512
MAX_TOKENS = 1024
OVERLAP_TOKENS = 96

@dataclass
class MarkdownBlock:
    kind: str
    text: str
    level: int | None = None
    title: str = ""

@dataclass
class Section:
    headings: dict[int, str]
    blocks: list[MarkdownBlock] = field(default_factory=list)

def extract_frontmatter(text: str) -> tuple[dict[str, object], str]:
    if not text.startswith("---\n"):
        return {}, text

    end = text.find("\n---", 4)
    if end == -1:
        return {}, text

    raw = text[4:end].strip()
    body = text[end + len("\n---") :].lstrip("\n")
    metadata: dict[str, object] = {}
    lines = raw.splitlines()
    index = 0

    while index < len(lines):
        line = lines[index]
        if ":" not in line:
            index += 1
            continue

        key, value = line.split(":", 1)
        key = key.strip()
        value = value.strip()

        if value:
            if value.startswith("[") and value.endswith("]"):
                metadata[key] = [
                    item.strip().strip("'\"")
                    for item in value[1:-1].split(",")
                    if item.strip()
                ]
            else:
                metadata[key] = value.strip("'\"")
            index += 1
            continue

        items: list[str] = []
        index += 1
        while index < len(lines) and lines[index].lstrip().startswith("- "):
            items.append(lines[index].split("- ", 1)[1].strip().strip("'\""))
            index += 1
        metadata[key] = items

    return metadata, body

def parse_markdown_blocks(text: str) -> list[MarkdownBlock]:
    lines = text.splitlines()
    blocks: list[MarkdownBlock] = []
    paragraph: list[str] = []
    index = 0

    def flush_paragraph() -> None:
        nonlocal paragraph
        if paragraph:
            blocks.append(MarkdownBlock(kind="text", text="\n".join(paragraph).strip()))
            paragraph = []

    while index < len(lines):
        line = lines[index]
        stripped = line.strip()

        if stripped.startswith("```") or stripped.startswith("~~~"):
            flush_paragraph()
            fence = stripped[:3]
            code_lines = [line]
            index += 1
            while index < len(lines):
                code_lines.append(lines[index])
                if lines[index].strip().startswith(fence):
                    index += 1
                    break
                index += 1
            blocks.append(MarkdownBlock(kind="code", text="\n".join(code_lines)))
            continue

        heading = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
        if heading:
            flush_paragraph()
            level = len(heading.group(1))
            title = heading.group(2).strip()
            blocks.append(
                MarkdownBlock(kind="heading", text=line.strip(), level=level, title=title)
            )
            index += 1
            continue

        if stripped.startswith("|") and "|" in stripped[1:]:
            flush_paragraph()
            table_lines = [line]
            index += 1
            while index < len(lines):
                next_line = lines[index]
                if not next_line.strip().startswith("|"):
                    break
                table_lines.append(next_line)
                index += 1
            blocks.append(MarkdownBlock(kind="table", text="\n".join(table_lines)))
            continue

        if not stripped:
            flush_paragraph()
            index += 1
            continue

        paragraph.append(line)
        index += 1

    flush_paragraph()
    return blocks

def build_sections(blocks: list[MarkdownBlock]) -> list[Section]:
    sections: list[Section] = []
    headings: dict[int, str] = {}
    current = Section(headings={})

    for block in blocks:
        if block.kind != "heading":
            current.blocks.append(block)
            continue

        level = block.level or 1
        for old_level in list(headings):
            if old_level >= level:
                del headings[old_level]
        headings[level] = block.title
        current_headings = dict(headings)

        if level <= 2 or token_count(current.blocks) >= TARGET_TOKENS:
            if current.blocks:
                sections.append(current)
            current = Section(headings=current_headings, blocks=[block])
            continue

        current.blocks.append(block)
        current.headings = current_headings

    if current.blocks:
        sections.append(current)
    return sections

def token_count(blocks: list[MarkdownBlock]) -> int:
    text = "\n".join(block.text for block in blocks)
    return estimate_tokens(text)

def estimate_tokens(text: str) -> int:
    chinese = len(re.findall(r"[\u4e00-\u9fff]", text))
    latin = len(re.findall(r"[A-Za-z0-9_#+.-]+", text))
    other = max(len(text) - chinese, 0) // 6
    return chinese + latin + other

def split_section_blocks(section: Section) -> list[list[MarkdownBlock]]:
    chunks: list[list[MarkdownBlock]] = []
    current: list[MarkdownBlock] = []

    for block in section.blocks:
        block_tokens = estimate_tokens(block.text)
        current_tokens = token_count(current)

        if block.kind in {"code", "table"}:
            if current and current_tokens + block_tokens > MAX_TOKENS:
                chunks.append(current)
                current = []
            current.append(block)
            continue

        if current and current_tokens + block_tokens > MAX_TOKENS:
            chunks.append(current)
            current = overlap_tail(current)

        current.append(block)

    if current:
        chunks.append(current)
    return chunks

def overlap_tail(blocks: list[MarkdownBlock]) -> list[MarkdownBlock]:
    kept: list[MarkdownBlock] = []
    total = 0
    for block in reversed(blocks):
        if block.kind in {"code", "table"}:
            continue
        count = estimate_tokens(block.text)
        if kept and total + count > OVERLAP_TOKENS:
            break
        kept.insert(0, block)
        total += count
    return kept

def chunk_markdown_file(path: Path, notes_dir: Path) -> list[TextNode]:
    text = path.read_text(encoding="utf-8", errors="ignore")
    frontmatter, body = extract_frontmatter(text)
    blocks = parse_markdown_blocks(body)
    sections = build_sections(blocks)

    rel_path = path.relative_to(notes_dir)
    title = path.stem
    mtime = path.stat().st_mtime
    tags = frontmatter.get("tags", [])
    if isinstance(tags, str):
        tags = [tags]

    nodes: list[TextNode] = []
    chunk_index = 0
    for section in sections:
        heading_path = heading_path_from_levels(section.headings)
        prefix = build_context_prefix(title, rel_path, heading_path)
        for chunk_blocks in split_section_blocks(section):
            chunk_text = "\n\n".join(block.text for block in chunk_blocks).strip()
            if not chunk_text:
                continue
            node_text = f"{prefix}\n\n{chunk_text}".strip()
            nodes.append(
                TextNode(
                    text=node_text,
                    metadata={
                        "source_file": str(rel_path),
                        "rel_path": str(rel_path),
                        "file_name": path.name,
                        "folder_path": str(rel_path.parent),
                        "title": title,
                        "heading_path": " > ".join(heading_path),
                        "h1": section.headings.get(1, ""),
                        "h2": section.headings.get(2, ""),
                        "h3": section.headings.get(3, ""),
                        "h4": section.headings.get(4, ""),
                        "h5": section.headings.get(5, ""),
                        "h6": section.headings.get(6, ""),
                        "chunk_index": chunk_index,
                        "tags": tags,
                        "created_at": mtime,
                    },
                )
            )
            chunk_index += 1

    return nodes

def heading_path_from_levels(headings: dict[int, str]) -> list[str]:
    return [headings[level] for level in sorted(headings)]

def build_context_prefix(title: str, rel_path: Path, heading_path: list[str]) -> str:
    lines = [
        f"文件: {rel_path}",
        f"标题: {title}",
    ]
    if heading_path:
        lines.append(f"标题路径: {' > '.join(heading_path)}")
    return "\n".join(lines)


让我继续查看其他核心文件。


from __future__ import annotations

import math
import re
from collections import Counter
from dataclasses import dataclass

import numpy as np
from llama_index.core.schema import TextNode

from .config import Settings
from .embeddings import embed_query
from .indexing import chinese_tokenizer
from .vector_store import db_path, search_vectors

@dataclass(frozen=True)
class RetrievedNode:
    node: TextNode
    score: float
    raw_score: float = 0.0

def clean_query(query: str) -> str:
    query = query.strip()
    for phrase in [
        "请根据笔记简要回答",
        "请根据笔记回答",
        "根据笔记简要回答",
        "根据笔记回答",
        "简要回答",
        "有哪些",
        "是什么",
    ]:
        query = query.replace(phrase, " ")
    for word in [
        "我的",
        "我",
        "内容",
        "请",
        "根据",
        "简要",
        "回答",
        "哪些",
        "什么",
        "一下",
    ]:
        query = query.replace(word, " ")
    query = re.sub(r"[，。！？、；：,.!?;:]+", " ", query)
    return " ".join(query.split())

def inventory_keywords(query: str) -> list[str]:
    stopwords = {
        "查一下",
        "目前",
        "我的",
        "我",
        "相关",
        "笔记",
        "教程",
        "有哪些",
        "有什么",
        "列表",
        "清单",
        "目录",
        "安装",
    }
    words: list[str] = []
    cleaned = clean_query(query).lower()
    for part in re.findall(r"[a-z0-9_#+.-]+|[\u4e00-\u9fff]+", cleaned):
        if part in stopwords:
            continue
        if re.fullmatch(r"[a-z0-9_#+.-]+", part):
            words.append(part)
            continue
        for stopword in stopwords:
            part = part.replace(stopword, "")
        if len(part) >= 2:
            words.append(part)
    return words

class LocalBM25:
    def __init__(self, nodes: list[TextNode]) -> None:
        self.nodes = nodes
        self.doc_tokens = [chinese_tokenizer(self._weighted_text(node)) for node in nodes]
        self.doc_freq: Counter[str] = Counter()
        self.term_freqs: list[Counter[str]] = []

        for tokens in self.doc_tokens:
            term_freq = Counter(tokens)
            self.term_freqs.append(term_freq)
            self.doc_freq.update(term_freq.keys())

        self.doc_count = len(nodes)
        self.doc_lengths = [len(tokens) for tokens in self.doc_tokens]
        self.avg_doc_length = (
            sum(self.doc_lengths) / self.doc_count if self.doc_count else 0.0
        )

    def _weighted_text(self, node: TextNode) -> str:
        title = str(node.metadata.get("title", ""))
        rel_path = str(node.metadata.get("rel_path", ""))
        path_text = rel_path.replace("/", " ").replace(".md", "")
        content = node.get_content()

        return "\n".join(
            [
                " ".join([title] * 8),
                " ".join([path_text] * 4),
                content,
            ]
        )

    def search(self, query: str, top_k: int) -> list[RetrievedNode]:
        query_terms = chinese_tokenizer(query)
        if not query_terms or not self.nodes:
            return []

        k1 = 1.5
        b = 0.75
        scored_by_file: dict[str, RetrievedNode] = {}
        extra_scores_by_file: Counter[str] = Counter()

        for index, term_freq in enumerate(self.term_freqs):
            score = 0.0
            doc_length = self.doc_lengths[index] or 1

            for term in query_terms:
                freq = term_freq.get(term, 0)
                if freq == 0:
                    continue

                doc_freq = self.doc_freq.get(term, 0)
                idf = math.log(1 + (self.doc_count - doc_freq + 0.5) / (doc_freq + 0.5))
                denom = freq + k1 * (
                    1 - b + b * doc_length / (self.avg_doc_length or 1)
                )
                score += idf * freq * (k1 + 1) / denom

            if score <= 0:
                continue

            node = self.nodes[index]
            rel_path = str(node.metadata.get("rel_path", node.node_id))
            current = scored_by_file.get(rel_path)
            if current is None or score > current.score:
                if current is not None:
                    extra_scores_by_file[rel_path] += current.score * 0.2
                scored_by_file[rel_path] = RetrievedNode(
                    node=node,
                    score=score,
                    raw_score=score,
                )
            else:
                extra_scores_by_file[rel_path] += score * 0.2

        scored = [
            RetrievedNode(
                node=item.node,
                score=item.score + extra_scores_by_file[rel_path],
                raw_score=item.raw_score,
            )
            for rel_path, item in scored_by_file.items()
        ]
        scored.sort(key=lambda item: item.score, reverse=True)
        return scored[:top_k]

class HybridRetriever:
    def __init__(self, settings: Settings, nodes: list[TextNode]) -> None:
        self.settings = settings
        self.nodes = nodes
        self.bm25 = LocalBM25(nodes)

    def search(self, query: str, top_k: int) -> list[RetrievedNode]:
        query = clean_query(query)
        candidate_k = min(max(top_k * 8, 30), len(self.nodes))
        bm25_results = self.bm25.search(query, candidate_k)
        vector_results = self._vector_search(query, candidate_k)
        return self._fuse_results(bm25_results, vector_results, top_k)

    def _vector_search(self, query: str, top_k: int) -> list[RetrievedNode]:
        query_vector = embed_query(self.settings, query)
        rows = search_vectors(db_path(self.settings.index_dir), query_vector, top_k)
        results: list[RetrievedNode] = []
        for rowid, distance in rows:
            index = rowid - 1
            if index < 0 or index >= len(self.nodes):
                continue
            score = 1.0 / (1.0 + distance)
            results.append(
                RetrievedNode(
                    node=self.nodes[index],
                    score=score,
                    raw_score=score,
                )
            )
        return results

    def _fuse_results(
        self,
        bm25_results: list[RetrievedNode],
        vector_results: list[RetrievedNode],
        top_k: int,
    ) -> list[RetrievedNode]:
        fused: dict[str, tuple[TextNode, float]] = {}
        seen_files: set[str] = set()

        def add(results: list[RetrievedNode], weight: float) -> None:
            if not results:
                return
            best_raw = max(item.raw_score or item.score for item in results)
            if best_raw <= 0:
                return

            for rank, item in enumerate(results, start=1):
                key = item.node.node_id
                raw = item.raw_score or item.score
                normalized = raw / best_raw
                if normalized < 0.18:
                    continue
                score = weight * normalized / (60 + rank)
                node, current = fused.get(key, (item.node, 0.0))
                fused[key] = (node, current + score)

        add(bm25_results, self.settings.bm25_weight)
        add(vector_results, self.settings.vector_weight)

        ranked = sorted(fused.values(), key=lambda pair: pair[1], reverse=True)
        if not ranked:
            return []

        best_score = ranked[0][1]
        final: list[RetrievedNode] = []
        for node, score in ranked:
            if score < best_score * 0.7 and len(final) >= 1:
                continue
            rel_path = str(node.metadata.get("rel_path", node.node_id))
            if rel_path in seen_files:
                continue
            seen_files.add(rel_path)
            final.append(RetrievedNode(node=node, score=score))
            if len(final) >= top_k:
                break

        return final

def is_inventory_query(query: str) -> bool:
    return any(
        word in query
        for word in ["有哪些", "有什么", "哪些", "列表", "清单", "目录", "查一下", "相关笔记"]
    )

def build_file_nodes(nodes: list[TextNode]) -> list[TextNode]:
    by_path: dict[str, list[TextNode]] = {}
    for node in nodes:
        rel_path = str(node.metadata.get("rel_path", node.node_id))
        by_path.setdefault(rel_path, []).append(node)

    file_nodes: list[TextNode] = []
    for rel_path, file_chunks in by_path.items():
        first = file_chunks[0]
        title = str(first.metadata.get("title", rel_path.rsplit("/", 1)[-1]))
        preview = "\n".join(
            chunk.get_content().strip() for chunk in file_chunks[:2] if chunk.get_content().strip()
        )
        text = f"标题: {title}\n路径: {rel_path}\n内容预览:\n{preview[:1200]}"
        file_nodes.append(
            TextNode(
                text=text,
                metadata={
                    "rel_path": rel_path,
                    "title": title,
                    "is_file_summary": True,
                },
            )
        )
    return file_nodes

class InventoryRetriever:
    def __init__(self, nodes: list[TextNode]) -> None:
        self.file_nodes = build_file_nodes(nodes)
        self.bm25 = LocalBM25(self.file_nodes)

    def search(self, query: str, top_k: int) -> list[RetrievedNode]:
        query = clean_query(query)
        direct = self._direct_directory_results(query, top_k)
        if direct:
            return direct

        results = self.bm25.search(query, top_k)
        results = self._filter_by_keywords(query, results)
        if not results:
            return []

        best = results[0].score
        kept = [item for item in results if item.score >= best * 0.4]
        return kept[:top_k]

    def _filter_by_keywords(
        self,
        query: str,
        results: list[RetrievedNode],
    ) -> list[RetrievedNode]:
        keywords = inventory_keywords(query)
        if not keywords:
            return results

        latin_keywords = [
            keyword
            for keyword in keywords
            if re.fullmatch(r"[a-z0-9_#+.-]+", keyword)
        ]

        filtered: list[RetrievedNode] = []
        for item in results:
            haystack = (
                f"{item.node.metadata.get('title', '')} "
                f"{item.node.metadata.get('rel_path', '')} "
                f"{item.node.get_content()}"
            ).lower()
            if latin_keywords and not all(keyword in haystack for keyword in latin_keywords):
                continue

            matched = sum(1 for keyword in keywords if keyword in haystack)
            if matched == 0:
                continue
            if len(keywords) >= 2 and matched / len(keywords) < 0.5:
                continue
            filtered.append(item)

        return filtered or results[: min(3, len(results))]

    def _direct_directory_results(self, query: str, top_k: int) -> list[RetrievedNode]:
        candidates = [
            node
            for node in self.file_nodes
            if str(node.metadata.get("rel_path", "")).startswith(f"{query}/")
        ]
        if not candidates and query.endswith("文章"):
            candidates = [
                node
                for node in self.file_nodes
                if str(node.metadata.get("rel_path", "")).startswith(f"{query}/")
            ]
        if not candidates:
            return []

        candidates.sort(key=lambda node: str(node.metadata.get("rel_path", "")))
        return [
            RetrievedNode(node=node, score=float(top_k - index), raw_score=float(top_k - index))
            for index, node in enumerate(candidates[:top_k])
        ]


from __future__ import annotations

import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Any

os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")

from .config import Settings, load_settings, override_settings
from .indexing import build_bm25_index, load_index_nodes, markdown_files
from .indexing import sync_notes as sync_notes_impl
from .vector_store import db_path

def _positive_int(value: str) -> int:
    parsed = int(value)
    if parsed <= 0:
        raise argparse.ArgumentTypeError("必须是大于 0 的整数")
    return parsed

def _existing_file(value: str) -> Path:
    path = Path(value)
    if not path.exists():
        raise argparse.ArgumentTypeError(f"文件不存在: {path}")
    if not path.is_file():
        raise argparse.ArgumentTypeError(f"不是文件: {path}")
    return path

def _add_config_args(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--env-file",
        type=_existing_file,
        default=argparse.SUPPRESS,
        help="指定 .env 配置文件",
    )
    parser.add_argument(
        "--notes-dir",
        type=Path,
        default=argparse.SUPPRESS,
        help="覆盖 NOTES_DIR",
    )
    parser.add_argument(
        "--index-dir",
        type=Path,
        default=argparse.SUPPRESS,
        help="覆盖 INDEX_DIR",
    )

def _add_output_args(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--format",
        choices=("text", "json"),
        default=argparse.SUPPRESS,
        help="输出格式，Agent 调用建议使用 json",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        default=argparse.SUPPRESS,
        help="等同于 --format json",
    )

def _add_retrieval_args(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--mode",
        choices=("auto", "hybrid", "inventory"),
        default="auto",
        help="检索模式，默认 auto 自动判断",
    )
    parser.add_argument("-k", "--top-k", type=_positive_int, help="覆盖返回条数")

def _settings_from_args(args: argparse.Namespace) -> Settings:
    settings = load_settings(getattr(args, "env_file", None))
    return override_settings(
        settings,
        notes_dir=getattr(args, "notes_dir", None),
        index_dir=getattr(args, "index_dir", None),
    )

def _json_default(value: object) -> str:
    if isinstance(value, Path):
        return str(value)
    raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")

def _write_json(payload: dict[str, Any]) -> None:
    print(json.dumps(payload, ensure_ascii=False, default=_json_default))

def _wants_json(args: argparse.Namespace) -> bool:
    return bool(getattr(args, "json", False) or getattr(args, "format", "text") == "json")

def _emit(args: argparse.Namespace, payload: dict[str, Any], text: str | None = None) -> None:
    if _wants_json(args):
        _write_json(payload)
        return
    if text is not None:
        print(text)

def _fail(args: argparse.Namespace, message: str, *, code: int = 1) -> None:
    if _wants_json(args):
        _write_json({"ok": False, "error": message})
        raise SystemExit(code)
    raise SystemExit(message)

def _question_from_args(args: argparse.Namespace) -> str:
    question = " ".join(getattr(args, "question", [])).strip()
    if not question and not sys.stdin.isatty():
        question = sys.stdin.read().strip()
    return question

def _print_sources(sources: list[str]) -> None:
    if not sources:
        print("\n来源: 无")
        return
    print("\n来源:")
    for source in sources:
        print(f"- {source}")

def _preview_text(text: str, limit: int) -> str:
    text = " ".join(text.strip().split())
    if len(text) <= limit:
        return text
    return f"{text[:limit].rstrip()}..."

def _resolve_note_path(settings: Settings, source: str) -> Path:
    notes_root = settings.notes_dir.resolve()
    note_path = (notes_root / source).resolve()
    try:
        note_path.relative_to(notes_root)
    except ValueError as exc:
        raise ValueError(f"笔记路径不能越过 NOTES_DIR: {source}") from exc
    if not note_path.exists():
        raise FileNotFoundError(f"笔记不存在: {source}")
    if not note_path.is_file():
        raise IsADirectoryError(f"不是笔记文件: {source}")
    return note_path

def _source_text(item: Any) -> str:
    metadata = item.node.metadata
    rel_path = metadata.get("rel_path") or metadata.get("file_path") or "未知来源"
    return f"{rel_path}  score={item.score:.4f}"

def _node_payload(item: Any, *, include_content: bool = True) -> dict[str, Any]:
    metadata = item.node.metadata
    payload: dict[str, Any] = {
        "source": metadata.get("rel_path") or metadata.get("file_path") or "未知来源",
        "title": metadata.get("title", ""),
        "heading_path": metadata.get("heading_path", ""),
        "chunk_index": metadata.get("chunk_index"),
        "score": item.score,
        "raw_score": item.raw_score,
        "metadata": dict(metadata),
    }
    if include_content:
        payload["content"] = item.node.get_content().strip()
    return payload

def _retrieve(args: argparse.Namespace) -> list[Any]:
    from .qa import retrieve_nodes

    question = _question_from_args(args)
    if not question:
        _fail(args, "请提供检索内容，例如: mengya-rag search Docker")

    settings = _settings_from_args(args)
    try:
        return retrieve_nodes(
            settings,
            question,
            mode=args.mode,
            top_k=args.top_k,
        )
    except Exception as exc:
        _fail(args, f"检索失败: {exc}")

def _sync_notes(args: argparse.Namespace) -> int:
    settings = _settings_from_args(args)
    started = time.time()
    try:
        sync_notes_impl(settings)
    except Exception as exc:
        _fail(args, f"同步失败: {exc}")

    payload = {
        "ok": True,
        "command": "sync",
        "notes_remote": settings.notes_remote,
        "notes_dir": settings.notes_dir,
        "elapsed_seconds": round(time.time() - started, 3),
    }
    _emit(args, payload, f"笔记已同步到: {settings.notes_dir}")
    return 0

def _build_index(args: argparse.Namespace) -> int:
    settings = _settings_from_args(args)
    started = time.time()
    try:
        count = build_bm25_index(settings)
    except Exception as exc:
        _fail(args, f"构建索引失败: {exc}")

    payload = {
        "ok": True,
        "command": "index",
        "index_dir": settings.index_dir,
        "db_path": db_path(settings.index_dir),
        "node_count": count,
        "elapsed_seconds": round(time.time() - started, 3),
    }
    text = f"索引已生成: {settings.index_dir}\n索引分块数: {count}"
    _emit(args, payload, text)
    return 0

def _reindex(args: argparse.Namespace) -> int:
    settings = _settings_from_args(args)
    started = time.time()
    try:
        sync_notes_impl(settings)
        count = build_bm25_index(settings)
    except Exception as exc:
        _fail(args, f"更新知识库失败: {exc}")

    payload = {
        "ok": True,
        "command": "reindex",
        "notes_remote": settings.notes_remote,
        "notes_dir": settings.notes_dir,
        "index_dir": settings.index_dir,
        "db_path": db_path(settings.index_dir),
        "node_count": count,
        "elapsed_seconds": round(time.time() - started, 3),
    }
    text = f"知识库已更新: {settings.index_dir}\n索引分块数: {count}"
    _emit(args, payload, text)
    return 0

def _ask(args: argparse.Namespace) -> int:
    from .qa import ask_question

    question = _question_from_args(args)
    if not question:
        _fail(args, "请提供问题，例如: mengya-rag ask Docker 常用命令有哪些")

    settings = _settings_from_args(args)
    try:
        answer, sources = ask_question(
            settings,
            question,
            mode=args.mode,
            top_k=args.top_k,
        )
    except Exception as exc:
        _fail(args, f"提问失败: {exc}")

    payload = {
        "ok": True,
        "command": "ask",
        "question": question,
        "answer": answer,
        "sources": sources,
        "mode": args.mode,
        "top_k": args.top_k,
    }
    if _wants_json(args):
        _write_json(payload)
    else:
        print(answer)
        if not args.no_source:
            _print_sources(sources)
    return 0

def _search(args: argparse.Namespace) -> int:
    question = _question_from_args(args)
    if not question:
        _fail(args, "请提供检索内容，例如: mengya-rag search Docker")

    nodes = _retrieve(args)
    include_content = _wants_json(args) or args.show_content

    if not nodes:
        _emit(
            args,
            {
                "ok": True,
                "command": "search",
                "question": question,
                "results": [],
            },
            "未检索到相关内容。",
        )
        return 0

    payload = {
        "ok": True,
        "command": "search",
        "question": question,
        "mode": args.mode,
        "top_k": args.top_k,
        "results": [
            _node_payload(item, include_content=include_content) for item in nodes
        ],
    }
    if _wants_json(args):
        _write_json(payload)
        return 0

    for index, item in enumerate(nodes, start=1):
        metadata = item.node.metadata
        heading_path = metadata.get("heading_path", "")
        print(f"{index}. {_source_text(item)}")
        if heading_path:
            print(f"   标题路径: {heading_path}")
        if args.show_content:
            preview = _preview_text(item.node.get_content(), args.preview_chars)
            print(f"   预览: {preview}")
    return 0

def _context(args: argparse.Namespace) -> int:
    question = _question_from_args(args)
    if not question:
        _fail(args, "请提供问题，例如: mengya-rag context Docker 常用命令")

    nodes = _retrieve(args)
    context_parts: list[str] = []
    sources: list[dict[str, Any]] = []
    for index, item in enumerate(nodes, start=1):
        metadata = item.node.metadata
        source = metadata.get("rel_path") or metadata.get("file_path") or "未知来源"
        content = item.node.get_content().strip()
        context_parts.append(f"[{index}] 来源: {source}\n{content}")
        sources.append(_node_payload(item, include_content=False))

    context = "\n\n".join(context_parts)
    payload = {
        "ok": True,
        "command": "context",
        "question": question,
        "mode": args.mode,
        "top_k": args.top_k,
        "context": context,
        "sources": sources,
    }
    if _wants_json(args):
        _write_json(payload)
    else:
        print(context or "未检索到相关内容。")
    return 0

def _read(args: argparse.Namespace) -> int:
    settings = _settings_from_args(args)
    source = args.source.strip()
    if not source:
        _fail(args, "请提供笔记相对路径，例如: mengya-rag read Docker/Docker常用命令总结.md")

    try:
        path = _resolve_note_path(settings, source)
        content = path.read_text(encoding="utf-8")
    except Exception as exc:
        _fail(args, f"读取笔记失败: {exc}")

    original_length = len(content)
    if args.max_chars is not None:
        content = content[: args.max_chars]

    payload = {
        "ok": True,
        "command": "read",
        "source": source,
        "path": path,
        "content": content,
        "truncated": args.max_chars is not None and original_length > len(content),
    }
    _emit(args, payload, content)
    return 0

def _status(args: argparse.Namespace) -> int:
    settings = _settings_from_args(args)
    db_file = db_path(settings.index_dir)
    nodes_file = settings.index_dir / "nodes.jsonl"

    note_count: int | None = None
    note_error: str | None = None
    try:
        note_count = len(markdown_files(settings.notes_dir))
    except Exception as exc:
        note_error = str(exc)

    node_count: int | None = None
    node_error: str | None = None
    try:
        node_count = len(load_index_nodes(settings))
    except Exception as exc:
        node_error = str(exc)

    payload = {
        "ok": True,
        "command": "status",
        "project_root": Path(__file__).resolve().parents[2],
        "notes_dir": settings.notes_dir,
        "index_dir": settings.index_dir,
        "db_path": db_file,
        "db_exists": db_file.exists(),
        "nodes_file": nodes_file,
        "nodes_file_exists": nodes_file.exists(),
        "deepseek_api_key_configured": bool(settings.deepseek_api_key),
        "deepseek_model": settings.deepseek_model,
        "embed_backend": settings.embed_backend,
        "embed_model": settings.embed_model,
        "top_k": settings.top_k,
        "list_top_k": settings.list_top_k,
        "markdown_note_count": note_count,
        "markdown_note_error": note_error,
        "index_node_count": node_count,
        "index_node_error": node_error,
    }
    text_lines = [
        f"项目目录: {payload['project_root']}",
        f"笔记目录: {settings.notes_dir}",
        f"索引目录: {settings.index_dir}",
        f"向量库: {db_file} ({'存在' if db_file.exists() else '不存在'})",
        f"节点文件: {nodes_file} ({'存在' if nodes_file.exists() else '不存在'})",
        f"DeepSeek Key: {'已配置' if settings.deepseek_api_key else '未配置'}",
        f"DeepSeek 模型: {settings.deepseek_model}",
        f"Embedding: {settings.embed_backend} / {settings.embed_model}",
        f"默认 top_k: {settings.top_k}",
        f"默认 list_top_k: {settings.list_top_k}",
        f"Markdown 笔记数: {note_count if note_error is None else f'读取失败 ({note_error})'}",
        f"索引节点数: {node_count if node_error is None else f'读取失败 ({node_error})'}",
    ]
    _emit(args, payload, "\n".join(text_lines))
    return 0

def _add_question_arg(parser: argparse.ArgumentParser) -> None:
    parser.add_argument("question", nargs="*", help="问题；省略时从 stdin 读取")

def build_parser(prog: str = "mengya-rag") -> argparse.ArgumentParser:
    config_parent = argparse.ArgumentParser(add_help=False)
    _add_config_args(config_parent)
    _add_output_args(config_parent)

    parser = argparse.ArgumentParser(
        prog=prog,
        description="萌芽 RAG 知识库命令行工具",
        parents=[config_parent],
    )

    subparsers = parser.add_subparsers(dest="command")

    sync_parser = subparsers.add_parser(
        "sync",
        help="从远端同步 Markdown 笔记",
        parents=[config_parent],
    )
    sync_parser.set_defaults(func=_sync_notes)

    index_parser = subparsers.add_parser(
        "index",
        help="构建本地检索索引",
        parents=[config_parent],
    )
    index_parser.set_defaults(func=_build_index)

    reindex_parser = subparsers.add_parser(
        "reindex",
        help="同步笔记并重建索引",
        parents=[config_parent],
    )
    reindex_parser.set_defaults(func=_reindex)

    ask_parser = subparsers.add_parser(
        "ask",
        help="检索笔记并调用 DeepSeek 回答",
        parents=[config_parent],
    )
    _add_retrieval_args(ask_parser)
    ask_parser.add_argument("--no-source", action="store_true", help="不显示来源")
    _add_question_arg(ask_parser)
    ask_parser.set_defaults(func=_ask)

    search_parser = subparsers.add_parser(
        "search",
        help="只检索来源，不调用大模型",
        parents=[config_parent],
    )
    _add_retrieval_args(search_parser)
    search_parser.add_argument(
        "--show-content",
        action="store_true",
        help="显示命中的内容预览",
    )
    search_parser.add_argument(
        "--preview-chars",
        type=_positive_int,
        default=260,
        help="内容预览字符数，默认 260",
    )
    _add_question_arg(search_parser)
    search_parser.set_defaults(func=_search)

    context_parser = subparsers.add_parser(
        "context",
        help="返回可直接注入大模型的检索上下文",
        parents=[config_parent],
    )
    _add_retrieval_args(context_parser)
    _add_question_arg(context_parser)
    context_parser.set_defaults(func=_context)

    read_parser = subparsers.add_parser(
        "read",
        help="按 search 返回的 source 读取完整笔记",
        parents=[config_parent],
    )
    read_parser.add_argument("source", help="相对 NOTES_DIR 的 Markdown 路径")
    read_parser.add_argument(
        "--max-chars",
        type=_positive_int,
        help="最多输出多少字符，默认完整输出",
    )
    read_parser.set_defaults(func=_read)

    status_parser = subparsers.add_parser(
        "status",
        help="查看配置和索引状态",
        parents=[config_parent],
    )
    status_parser.set_defaults(func=_status)

    return parser

def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    if not hasattr(args, "func"):
        parser.print_help()
        return 0

    return int(args.func(args) or 0)

def sync_notes() -> None:
    parser = argparse.ArgumentParser(description="同步萌芽 RAG 笔记")
    _add_config_args(parser)
    _add_output_args(parser)
    args = parser.parse_args()
    _sync_notes(args)

def build_index() -> None:
    parser = argparse.ArgumentParser(description="构建萌芽 RAG 索引")
    _add_config_args(parser)
    _add_output_args(parser)
    args = parser.parse_args()
    _build_index(args)

def ask() -> None:
    parser = argparse.ArgumentParser(description="向萌芽 RAG 知识库提问")
    _add_config_args(parser)
    _add_output_args(parser)
    _add_retrieval_args(parser)
    parser.add_argument("--no-source", action="store_true", help="不显示来源")
    _add_question_arg(parser)
    args = parser.parse_args()
    _ask(args)

if __name__ == "__main__":
    raise SystemExit(main())


from __future__ import annotations

from .config import Settings
from .indexing import load_index_nodes
from .retrieval import HybridRetriever, InventoryRetriever, RetrievedNode, is_inventory_query

QA_PROMPT = """你是萌芽 RAG 知识库助手。请只根据下面检索到的笔记内容回答问题。
如果笔记内容不足以回答，请直接说明没有在笔记中找到足够依据。
回答要准确、完整，优先使用中文。
如果用户在问“有哪些/列表/目录/相关笔记/查一下”，请优先按来源文件列出条目，不要只总结一句话。

笔记内容：
---------------------
{context}
---------------------

问题：{question}
回答："""

def format_source(item: RetrievedNode) -> str:
    metadata = item.node.metadata
    rel_path = metadata.get("rel_path") or metadata.get("file_path") or "未知来源"
    return f"{rel_path}  score={item.score:.4f}"

def format_context(items: list[RetrievedNode]) -> str:
    parts: list[str] = []
    for index, item in enumerate(items, start=1):
        rel_path = item.node.metadata.get("rel_path", "未知来源")
        parts.append(
            f"[{index}] 来源: {rel_path}\n"
            f"{item.node.get_content().strip()}"
        )
    return "\n\n".join(parts)

def retrieve_nodes(
    settings: Settings,
    question: str,
    *,
    mode: str = "auto",
    top_k: int | None = None,
) -> list[RetrievedNode]:
    if mode not in {"auto", "hybrid", "inventory"}:
        raise ValueError("mode 只能是 auto、hybrid 或 inventory")

    index_nodes = load_index_nodes(settings)
    use_inventory = mode == "inventory" or (
        mode == "auto" and is_inventory_query(question)
    )
    limit = top_k or (settings.list_top_k if use_inventory else settings.top_k)

    if use_inventory:
        retriever = InventoryRetriever(index_nodes)
        return retriever.search(question, limit)

    retriever = HybridRetriever(settings, index_nodes)
    return retriever.search(question, limit)

def ask_question(
    settings: Settings,
    question: str,
    *,
    mode: str = "auto",
    top_k: int | None = None,
) -> tuple[str, list[str]]:
    from llama_index.llms.deepseek import DeepSeek

    if not settings.deepseek_api_key:
        raise RuntimeError("缺少 DEEPSEEK_API_KEY，请先复制 .env.example 为 .env 并填写。")

    nodes = retrieve_nodes(settings, question, mode=mode, top_k=top_k)

    llm = DeepSeek(
        model=settings.deepseek_model,
        api_key=settings.deepseek_api_key,
        temperature=settings.deepseek_temperature,
    )
    prompt = QA_PROMPT.format(context=format_context(nodes), question=question)
    response = llm.complete(prompt)

    sources = [format_source(node) for node in nodes]
    return str(response), sources

_format_source = format_source
_format_context = format_context


让我继续查看配置文件和向量存储文件。


from __future__ import annotations

import os
from dataclasses import replace
from dataclasses import dataclass
from pathlib import Path

from dotenv import load_dotenv

PROJECT_ROOT = Path(__file__).resolve().parents[2]

def _path_from_env(name: str, default: str) -> Path:
    value = os.getenv(name, default)
    path = Path(value)
    if not path.is_absolute():
        path = PROJECT_ROOT / path
    return path

@dataclass(frozen=True)
class Settings:
    notes_remote: str
    notes_dir: Path
    index_dir: Path
    deepseek_api_key: str
    deepseek_model: str
    deepseek_temperature: float
    top_k: int
    list_top_k: int
    embed_backend: str
    embed_model: str
    embed_batch_size: int
    embed_query_prompt_name: str
    vector_weight: float
    bm25_weight: float

def load_settings(env_file: str | Path | None = None) -> Settings:
    if env_file is None:
        load_dotenv(PROJECT_ROOT / ".env")
    else:
        load_dotenv(env_file)

    return Settings(
        notes_remote=os.getenv(
            "NOTES_REMOTE",
            "bigmengya:/shumengya/docker/mengyanote-backend/data/mengyanote/",
        ),
        notes_dir=_path_from_env("NOTES_DIR", "data/notes"),
        index_dir=_path_from_env("INDEX_DIR", "data/index/bm25"),
        deepseek_api_key=os.getenv("DEEPSEEK_API_KEY", ""),
        deepseek_model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
        deepseek_temperature=float(os.getenv("DEEPSEEK_TEMPERATURE", "0.2")),
        top_k=int(os.getenv("TOP_K", "6")),
        list_top_k=int(os.getenv("LIST_TOP_K", "20")),
        embed_backend=os.getenv("EMBED_BACKEND", "fastembed"),
        embed_model=os.getenv("EMBED_MODEL", "BAAI/bge-small-zh-v1.5"),
        embed_batch_size=int(os.getenv("EMBED_BATCH_SIZE", "32")),
        embed_query_prompt_name=os.getenv("EMBED_QUERY_PROMPT_NAME", ""),
        vector_weight=float(os.getenv("VECTOR_WEIGHT", "0.65")),
        bm25_weight=float(os.getenv("BM25_WEIGHT", "0.35")),
    )

def override_settings(settings: Settings, **values: object) -> Settings:
    overrides = {key: value for key, value in values.items() if value is not None}
    if not overrides:
        return settings
    return replace(settings, **overrides)


让我继续查看向量存储和索引文件。


from __future__ import annotations

import sqlite3
from pathlib import Path

import numpy as np
import sqlite_vec
from llama_index.core.schema import TextNode

DB_FILE = "rag.sqlite3"

def db_path(index_dir: Path) -> Path:
    return index_dir / DB_FILE

def connect_vector_db(path: Path) -> sqlite3.Connection:
    conn = sqlite3.connect(path)
    conn.enable_load_extension(True)
    sqlite_vec.load(conn)
    conn.enable_load_extension(False)
    conn.row_factory = sqlite3.Row
    return conn

def init_vector_db(path: Path, dim: int) -> None:
    conn = connect_vector_db(path)
    try:
        conn.execute("drop table if exists chunks")
        conn.execute("drop table if exists chunk_vectors")
        conn.execute(
            """
            create table chunks (
                id integer primary key,
                node_json text not null,
                rel_path text not null,
                title text not null,
                heading_path text not null,
                chunk_index integer not null
            )
            """
        )
        conn.execute(
            f"create virtual table chunk_vectors using vec0(embedding float[{dim}])"
        )
        conn.execute("create index idx_chunks_rel_path on chunks(rel_path)")
        conn.commit()
    finally:
        conn.close()

def write_vector_db(path: Path, nodes: list[TextNode], vectors: np.ndarray) -> None:
    if len(nodes) != len(vectors):
        raise ValueError("节点数量和向量数量不一致")

    init_vector_db(path, int(vectors.shape[1]))
    conn = connect_vector_db(path)
    try:
        chunk_rows = []
        vector_rows = []
        for rowid, (node, vector) in enumerate(zip(nodes, vectors), start=1):
            chunk_rows.append(
                (
                    rowid,
                    node.model_dump_json(),
                    str(node.metadata.get("rel_path", "")),
                    str(node.metadata.get("title", "")),
                    str(node.metadata.get("heading_path", "")),
                    int(node.metadata.get("chunk_index", 0)),
                )
            )
            vector_rows.append((rowid, np.asarray(vector, dtype=np.float32).tobytes()))

        conn.executemany(
            """
            insert into chunks
                (id, node_json, rel_path, title, heading_path, chunk_index)
            values (?, ?, ?, ?, ?, ?)
            """,
            chunk_rows,
        )
        conn.executemany(
            "insert into chunk_vectors(rowid, embedding) values (?, ?)",
            vector_rows,
        )
        conn.commit()
    finally:
        conn.close()

def load_nodes_from_db(path: Path) -> list[TextNode]:
    conn = connect_vector_db(path)
    try:
        rows = conn.execute("select node_json from chunks order by id").fetchall()
        return [TextNode.model_validate_json(row["node_json"]) for row in rows]
    finally:
        conn.close()

def search_vectors(path: Path, query_vector: np.ndarray, top_k: int) -> list[tuple[int, float]]:
    conn = connect_vector_db(path)
    try:
        vector_blob = np.asarray(query_vector, dtype=np.float32).tobytes()
        rows = conn.execute(
            """
            select rowid, distance
            from chunk_vectors
            where embedding match ?
            order by distance
            limit ?
            """,
            (vector_blob, top_k),
        ).fetchall()
        return [(int(row["rowid"]), float(row["distance"])) for row in rows]
    finally:
        conn.close()


让我查看一下索引构建文件和脚本。


from __future__ import annotations

import re
import shutil
import subprocess
from pathlib import Path

import numpy as np
from llama_index.core.schema import TextNode

from .config import Settings
from .embeddings import embed_documents
from .markdown_chunker import chunk_markdown_file
from .vector_store import db_path, load_nodes_from_db, write_vector_db

def sync_notes(settings: Settings) -> None:
    settings.notes_dir.mkdir(parents=True, exist_ok=True)
    subprocess.run(
        [
            "rsync",
            "-av",
            "--delete",
            "--exclude=.git/",
            "--exclude=.trash/",
            "--exclude=.obsidian/workspace*",
            settings.notes_remote,
            f"{settings.notes_dir}/",
        ],
        check=True,
    )

def markdown_files(notes_dir: Path) -> list[Path]:
    if not notes_dir.exists():
        raise FileNotFoundError(f"笔记目录不存在: {notes_dir}")

    return sorted(notes_dir.rglob("*.md"))

def chinese_tokenizer(text: str) -> list[str]:
    stopwords = {
        "请",
        "根据",
        "笔记",
        "简要",
        "回答",
        "哪些",
        "什么",
        "内容",
        "一下",
        "我的",
        "有哪些",
        "是什么",
    }
    tokens: list[str] = []

    for part in re.findall(r"[A-Za-z0-9_#+.-]+|[\u4e00-\u9fff]+", text.lower()):
        if re.fullmatch(r"[A-Za-z0-9_#+.-]+", part):
            if part in {"md", "markdown"}:
                continue
            if part not in stopwords:
                tokens.append(part)
            continue

        if len(part) == 1:
            if part not in stopwords:
                tokens.append(part)
            continue

        max_n = min(4, len(part))
        for n in range(2, max_n + 1):
            for index in range(0, len(part) - n + 1):
                token = part[index : index + n]
                if token not in stopwords:
                    tokens.append(token)

    return tokens

NODES_FILE = "nodes.jsonl"

def build_bm25_index(settings: Settings) -> int:
    nodes: list[TextNode] = []
    for path in markdown_files(settings.notes_dir):
        nodes.extend(chunk_markdown_file(path, settings.notes_dir))

    if settings.index_dir.exists():
        shutil.rmtree(settings.index_dir)
    settings.index_dir.mkdir(parents=True, exist_ok=True)

    nodes_file = settings.index_dir / NODES_FILE
    with nodes_file.open("w", encoding="utf-8") as f:
        for node in nodes:
            f.write(node.model_dump_json())
            f.write("\n")

    texts = [node_to_embedding_text(node) for node in nodes]
    vectors = embed_documents(settings, texts)
    write_vector_db(db_path(settings.index_dir), nodes, vectors)
    return len(nodes)

def load_index_nodes(settings: Settings) -> list[TextNode]:
    database = db_path(settings.index_dir)
    if database.exists():
        return load_nodes_from_db(database)

    nodes_file = settings.index_dir / NODES_FILE
    if not nodes_file.exists():
        raise FileNotFoundError(
            f"索引目录不存在: {settings.index_dir}，请先运行 mengya-build-index"
        )

    nodes: list[TextNode] = []
    with nodes_file.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line:
                nodes.append(TextNode.model_validate_json(line))
    return nodes

def node_to_embedding_text(node: TextNode) -> str:
    title = str(node.metadata.get("title", ""))
    rel_path = str(node.metadata.get("rel_path", ""))
    heading_path = str(node.metadata.get("heading_path", ""))
    tags = node.metadata.get("tags", [])
    return (
        f"文件名: {title}\n"
        f"路径: {rel_path}\n"
        f"标题路径: {heading_path}\n"
        f"标签: {tags}\n"
        f"正文:\n{node.get_content()}"
    )


让我查看一下 embedding 文件和脚本。


from __future__ import annotations

from functools import lru_cache

import numpy as np
from fastembed import TextEmbedding

from .config import Settings

QWEN_QUERY_INSTRUCTION = (
    "Instruct: Given a search query in Chinese or English, retrieve relevant "
    "Markdown note passages from a personal knowledge base.\nQuery: "
)

@lru_cache(maxsize=2)
def _fastembed_model(model_name: str) -> TextEmbedding:
    return TextEmbedding(model_name=model_name)

@lru_cache(maxsize=2)
def _sentence_transformer_model(model_name: str):
    from sentence_transformers import SentenceTransformer

    return SentenceTransformer(model_name)

def embed_documents(settings: Settings, texts: list[str]) -> np.ndarray:
    if settings.embed_backend == "sentence-transformers":
        model = _sentence_transformer_model(settings.embed_model)
        vectors = model.encode(
            texts,
            batch_size=settings.embed_batch_size,
            normalize_embeddings=True,
            show_progress_bar=False,
        )
        return np.asarray(vectors, dtype=np.float32)

    model = _fastembed_model(settings.embed_model)
    vectors = np.array(
        list(model.embed(texts, batch_size=settings.embed_batch_size)),
        dtype=np.float32,
    )
    return normalize_vectors(vectors)

def embed_query(settings: Settings, query: str) -> np.ndarray:
    if settings.embed_backend == "sentence-transformers":
        model = _sentence_transformer_model(settings.embed_model)
        prompt_name = settings.embed_query_prompt_name or None
        if prompt_name:
            vector = model.encode(
                [query],
                prompt_name=prompt_name,
                normalize_embeddings=True,
                show_progress_bar=False,
            )[0]
        else:
            vector = model.encode(
                [query],
                normalize_embeddings=True,
                show_progress_bar=False,
            )[0]
        return np.asarray(vector, dtype=np.float32)

    model = _fastembed_model(settings.embed_model)
    vector = np.array(next(model.query_embed(query)), dtype=np.float32)
    return normalize_vector(vector)

def normalize_vectors(vectors: np.ndarray) -> np.ndarray:
    norms = np.linalg.norm(vectors, axis=1, keepdims=True)
    return vectors / np.clip(norms, 1e-12, None)

def normalize_vector(vector: np.ndarray) -> np.ndarray:
    return vector / max(float(np.linalg.norm(vector)), 1e-12)


让我查看一下脚本目录和 pyproject.toml 文件。


[project]
name = "mengya-rag"
version = "0.1.0"
description = "轻量版萌芽 Obsidian RAG 知识库"
requires-python = ">=3.11"
dependencies = [
    "fastembed>=0.7.0",
    "llama-index-core>=0.12.0",
    "llama-index-llms-deepseek>=0.1.0",
    "python-dotenv>=1.0.1",
    "sqlite-vec>=0.1.9",
]

[project.scripts]
mengya-rag = "mengya_rag.cli:main"
mengya-sync-notes = "mengya_rag.cli:sync_notes"
mengya-build-index = "mengya_rag.cli:build_index"
mengya-ask = "mengya_rag.cli:ask"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/mengya_rag"]


#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "$0")/.."
export TRANSFORMERS_VERBOSITY=error
uv run mengya-ask "$@"


让我查看一下 .env.example 文件来了解配置。


DEEPSEEK_API_KEY=
DEEPSEEK_MODEL=deepseek-chat
DEEPSEEK_TEMPERATURE=0.2

NOTES_REMOTE=bigmengya:/shumengya/docker/mengyanote-backend/data/mengyanote/
NOTES_DIR=data/notes
INDEX_DIR=data/index/bm25
TOP_K=6
LIST_TOP_K=20
EMBED_BACKEND=fastembed
EMBED_MODEL=BAAI/bge-small-zh-v1.5
EMBED_BATCH_SIZE=32
EMBED_QUERY_PROMPT_NAME=
VECTOR_WEIGHT=0.65
BM25_WEIGHT=0.35


现在我已经收集了足够的信息来给用户详细介绍这个项目。让我组织一下内容，给用户一个全面的介绍。

我已经查看了 mengya-rag 项目的完整代码。让我为你详细介绍这个项目。

---

# 萌芽 RAG 知识库 (mengya-rag) 详细介绍

## 一、项目概述

**萌芽 RAG 知识库**是一个轻量级的个人 Obsidian 笔记 RAG（检索增强生成）项目，专为个人知识管理设计。

| 特性 | 说明 |
|------|------|
| **定位** | 个人 Obsidian 笔记的智能检索和问答 |
| **技术栈** | LlamaIndex + FastEmbed + SQLite + DeepSeek |
| **特点** | 无需 GPU、无需外部向量数据库、本地 embedding |
| **规模** | 适合个人笔记（当前 1802 个分块，约 13MB 索引） |

---

## 二、系统架构

```
┌─────────────────────────────────────────────────────────────────┐
│                      mengya-rag 系统架构                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐          │
│  │  Obsidian   │    │   sync      │    │  本地笔记    │          │
│  │  笔记源     │───→│   notes.sh  │───→│  data/notes │          │
│  │ (bigmengya) │    │             │    │             │          │
│  └─────────────┘    └─────────────┘    └──────┬──────┘          │
│                                                │                 │
│                                                ↓                 │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │              Markdown 结构感知分块                       │    │
│  │  - 解析 frontmatter                                     │    │
│  │  - 识别 H1-H6 标题层级                                  │    │
│  │  - 代码块和表格整体保留                                  │    │
│  │  - 滑动窗口长块分割                                      │    │
│  └─────────────────────────────────────────────────────────┘    │
│                          │                                      │
│                          ↓                                      │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                    索引构建                              │    │
│  │  ┌─────────────┐    ┌─────────────┐                      │    │
│  │  │ BM25 索引   │    │ 向量索引     │                      │    │
│  │  │ (关键词)    │    │ (语义)      │                      │    │
│  │  └─────────────┘    └─────────────┘                      │    │
│  │         │                  │                             │    │
│  │         └────────┬─────────┘                             │    │
│  │                  ↓                                       │    │
│  │         SQLite + sqlite-vec                              │    │
│  │         (rag.sqlite3)                                    │    │
│  └─────────────────────────────────────────────────────────┘    │
│                          │                                      │
│                          ↓                                      │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                    检索融合                              │    │
│  │  - BM25 关键词检索                                       │    │
│  │  - 向量语义检索                                          │    │
│  │  - RRF 融合                                             │    │
│  │  - 同文件去重                                            │    │
│  └─────────────────────────────────────────────────────────┘    │
│                          │                                      │
│                          ↓                                      │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                  DeepSeek 生成                          │    │
│  │  - 注入检索上下文                                        │    │
│  │  - 生成回答和来源                                        │    │
│  └─────────────────────────────────────────────────────────┘    │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
```

---

## 三、核心模块详解

### 3.1 Markdown 结构感知分块 (markdown_chunker.py)

这是项目的核心创新点，不是简单的按字符数切割：

```python
# 分块流程
Markdown 文件
  ↓
解析 frontmatter (提取 tags, created_at 等)
  ↓
识别 H1-H6 标题层级
  ↓
按标题层级组织分块 (Section)
  ↓
代码块和表格整体保留 (不跨块切割)
  ↓
长块使用滑动窗口兜底 (TARGET_TOKENS=512, MAX_TOKENS=1024)
```

**每个 chunk 保存的元数据**：
```python
metadata = {
    "source_file": "AI/控制台Agent工具安装教程.md",  # 相对路径
    "rel_path": "AI/控制台Agent工具安装教程.md",
    "file_name": "控制台Agent工具安装教程.md",
    "folder_path": "AI",
    "title": "控制台Agent工具安装教程",
    "heading_path": "H1 > H2 > H3",  # 面包屑导航
    "h1": "AI工具",
    "h2": "控制台Agent",
    "h3": "安装教程",
    "chunk_index": 0,
    "tags": ["AI", "Agent"],
    "created_at": 1778572129.327,
}
```

**上下文前缀**：
```python
prefix = f"""文件: {rel_path}
标题: {title}
标题路径: {heading_path}"""

# 最终 chunk 文本
node_text = f"{prefix}\n\n{chunk_content}"
```

### 3.2 向量存储 (vector_store.py)

使用 SQLite + sqlite-vec 实现本地向量数据库：

```python
# 数据库结构
chunks 表:
  - id: 主键
  - node_json: 完整节点 JSON
  - rel_path: 相对路径
  - title: 标题
  - heading_path: 标题路径
  - chunk_index: 分块索引

chunk_vectors 表 (sqlite-vec 虚拟表):
  - rowid: 对应 chunks.id
  - embedding: float 向量 (384 维)
```

**搜索流程**：
```python
def search_vectors(path, query_vector, top_k):
    # 使用 sqlite-vec 的余弦距离搜索
    rows = conn.execute("""
        select rowid, distance
        from chunk_vectors
        where embedding match ?
        order by distance
        limit ?
    """, (vector_blob, top_k))
    return [(rowid, distance) for rowid, distance in rows]
```

### 3.3 混合检索 (retrieval.py)

实现 BM25 + 向量的混合检索：

```python
class HybridRetriever:
    def search(self, query, top_k):
        # 1. 查询清洗
        query = clean_query(query)

        # 2. BM25 关键词检索
        bm25_results = self.bm25.search(query, candidate_k)

        # 3. 向量语义检索
        vector_results = self._vector_search(query, candidate_k)

        # 4. RRF 融合
        return self._fuse_results(bm25_results, vector_results, top_k)
```

**RRF 融合算法**：
```python
def _fuse_results(self, bm25_results, vector_results, top_k):
    fused = {}

    def add(results, weight):
        for rank, item in enumerate(results, start=1):
            # 归一化分数
            normalized = raw / best_raw
            # RRF 公式: weight * normalized / (60 + rank)
            score = weight * normalized / (60 + rank)
            fused[key] = (node, current + score)

    add(bm25_results, self.settings.bm25_weight)  # 0.35
    add(vector_results, self.settings.vector_weight)  # 0.65

    # 同文件去重 + 相关性过滤
    return final[:top_k]
```

### 3.4 BM25 本地实现 (LocalBM25)

完全本地实现的 BM25，不依赖外部服务：

```python
class LocalBM25:
    def __init__(self, nodes):
        # 中文分词 (n-gram)
        self.doc_tokens = [chinese_tokenizer(text) for node in nodes]

        # 计算 IDF
        self.doc_freq = Counter()
        for tokens in self.doc_tokens:
            self.doc_freq.update(set(tokens))

    def search(self, query, top_k):
        query_terms = chinese_tokenizer(query)
        for term in query_terms:
            # BM25 公式
            idf = log(1 + (N - df + 0.5) / (df + 0.5))
            score += idf * tf * (k1 + 1) / (tf + k1 * (1 - b + b * dl / avgdl))
```

**中文分词**：
```python
def chinese_tokenizer(text):
    # 支持中文和英文
    for part in re.findall(r"[A-Za-z0-9_#+.-]+|[\u4e00-\u9fff]+", text):
        # 英文: 整词保留
        # 中文: n-gram 切分 (2-4 字)
        for n in range(2, min(4, len(part)) + 1):
            for i in range(len(part) - n + 1):
                tokens.append(part[i:i+n])
```

### 3.5 盘点检索模式 (InventoryRetriever)

针对"有哪些""目录""清单"类问题的特殊检索：

```python
class InventoryRetriever:
    def search(self, query, top_k):
        # 1. 构建文件级摘要节点
        file_nodes = build_file_nodes(nodes)

        # 2. BM25 检索
        results = self.bm25.search(query, top_k)

        # 3. 关键词覆盖过滤
        results = self._filter_by_keywords(query, results)

        return results

def build_file_nodes(nodes):
    # 每个文件一个摘要节点
    for rel_path, chunks in by_path.items():
        text = f"标题: {title}\n路径: {rel_path}\n内容预览:\n{preview}"
        file_nodes.append(TextNode(text=text, metadata={...}))
```

---

## 四、CLI 命令系统

项目提供丰富的 CLI 命令：

| 命令 | 说明 | 输出 |
|------|------|------|
| `status` | 查看配置和索引状态 | JSON/文本 |
| `sync` | 从 bigmengya 同步笔记 | - |
| `index` | 构建本地检索索引 | - |
| `reindex` | 同步 + 重建索引 | - |
| `search` | 只检索，不调用大模型 | 检索结果 |
| `context` | 返回可注入大模型的上下文 | context 字符串 |
| `read` | 按 source 读取完整笔记 | 原文 |
| `ask` | 检索 + DeepSeek 生成回答 | 回答 + 来源 |

**Agent 调用示例**：
```bash
# 查看状态
uv run mengya-rag status --json

# 检索
uv run mengya-rag search "Docker 常用命令" -k 5 --json

# 获取上下文
uv run mengya-rag context "WireGuard 怎么配置" -k 4 --json

# 读取笔记
uv run mengya-rag read "Docker/Docker常用命令总结.md" --json

# 问答
uv run mengya-rag ask "Docker 常用命令有哪些" -k 4 --json

# 更新知识库
uv run mengya-rag reindex --json
```

---

## 五、技术栈详解

| 组件 | 技术选择 | 原因 |
|------|---------|------|
| **RAG 框架** | LlamaIndex | 成熟的 RAG 框架 |
| **本地 Embedding** | FastEmbed + ONNX | 无需 GPU，CPU 即可运行 |
| **Embedding 模型** | BAAI/bge-small-zh-v1.5 | 轻量中文模型，效果够用 |
| **大模型** | DeepSeek API | 性价比高，中文能力强 |
| **向量数据库** | SQLite + sqlite-vec | 无需额外服务，单机部署 |
| **BM25** | 本地 Python 实现 | 无需 Elasticsearch 等服务 |
| **笔记同步** | rsync | 从 bigmengya 同步 |
| **依赖管理** | uv | 快速、现代的 Python 包管理 |

---

## 六、配置参数

`.env` 文件配置：

```env
# DeepSeek 配置
DEEPSEEK_API_KEY=你的 API Key
DEEPSEEK_MODEL=deepseek-chat
DEEPSEEK_TEMPERATURE=0.2

# 笔记源
NOTES_REMOTE=bigmengya:/shumengya/docker/mengyanote-backend/data/mengyanote/
NOTES_DIR=data/notes
INDEX_DIR=data/index/bm25

# 检索参数
TOP_K=6              # 普通问答返回条数
LIST_TOP_K=20        # 盘点列表返回条数

# Embedding 配置
EMBED_BACKEND=fastembed
EMBED_MODEL=BAAI/bge-small-zh-v1.5
EMBED_BATCH_SIZE=32

# 融合权重
VECTOR_WEIGHT=0.65   # 向量检索权重
BM25_WEIGHT=0.35     # BM25 检索权重
```

---

## 七、数据流示例

### 7.1 索引构建流程

```
1. rsync 同步笔记
   bigmengya:/shumengya/docker/mengyanote-backend/data/mengyanote/
   → data/notes/

2. 扫描所有 .md 文件
   data/notes/**/*.md → 150+ 个笔记文件

3. 结构感知分块
   每个文件 → 多个 TextNode (平均 12 个/文件)
   总计: 1802 个 chunks

4. 生成 embedding
   使用 bge-small-zh-v1.5
   384 维向量

5. 写入 SQLite
   chunks 表 + chunk_vectors 表
   索引大小: 约 13MB
```

### 7.2 查询流程

```
用户问题: "Docker 常用命令有哪些"

1. 查询清洗
   "Docker 常用命令有哪些" → "Docker 常用命令"

2. 识别查询类型
   包含"有哪些" → 盘点模式

3. BM25 检索
   中文 n-gram 分词
   "Docker" "常用" "命令"
   → 匹配文件级摘要

4. 关键词过滤
   保留包含关键词的文件

5. 返回结果
   - Docker/Docker常用命令总结.md
   - Docker/Docker安装教程.md
   - ...
```

---

## 八、项目优势

| 优势 | 说明 |
|------|------|
| **零依赖外部服务** | SQLite + 本地 embedding，无需 Qdrant、Milvus、Elasticsearch |
| **无需 GPU** | FastEmbed + ONNX，CPU 即可运行 |
| **结构感知分块** | 保留 Markdown 语义结构，不是暴力切割 |
| **混合检索** | BM25 + 向量，兼顾关键词和语义 |
| **轻量部署** | 单个 Python 项目，`uv sync` 即可 |
| **Agent 友好** | `--json` 输出，便于程序调用 |

---

## 九、当前限制与优化方向

### 限制
- 全量重建索引，无增量索引
- 无 reranker，复杂问题可能有弱相关内容
- embedding 模型轻量，效果不是最强
- 无 Web 界面和 API 服务

### 优化方向
- [ ] 增量索引，只重建变更文件
- [ ] 加 reranker (如 bge-reranker-v2-m3)
- [ ] 盘点类问题增加目录树模式
- [ ] 增加 `--debug` 输出检索过程
- [ ] 数据量变大后接 Qdrant

---

## 十、使用示例

```bash
# 进入项目目录
cd /shumengya/project/agent/mengya-rag

# 查看状态
uv run mengya-rag status

# 同步笔记
uv run mengya-rag sync

# 构建索引
uv run mengya-rag index

# 更新知识库 (同步 + 索引)
uv run mengya-rag reindex

# 搜索
uv run mengya-rag search "WireGuard 配置" -k 5

# 获取上下文 (适合注入其他大模型)
uv run mengya-rag context "Docker 常用命令" -k 4 --json

# 问答
uv run mengya-rag ask "萌芽笔记是什么"

# 读取完整笔记
uv run mengya-rag read "AI/控制台Agent工具安装教程.md"
```

这是一个设计精巧的轻量级 RAG 系统，非常适合个人知识管理场景 (＾▽＾)