跳到主要内容

把 RAG 送到用户手里:CLI、MCP、ReAct 与数据连接器

30 秒导读: 前几章讲的是 LEANN 的"引擎"——省 97% 存储的图索引、按需重算的向量检索。 本章讲"方向盘和油门":一个 leann 命令行工具、一个 MCP 服务器、一个 ReAct 多轮检索 agent,把同一套索引分别送到Claude Code 这类 agent、以及需要多轮推理的 复杂问题手里。再加上关键词/向量混合检索、元数据过滤,和把邮件/浏览器/微信/ChatGPT 记录都变成索引的一堆数据连接器——这就是 LEANN 口号"RAG Everything"落地的那一层。

本章是 LEANN 全景导览 下的应用层。底层原理见 01 存储魔法02 查询与重算03 后端注册04 embedding 与分块; 本章只讲"怎么用起来",不重复它们的内容。


1. 这是什么(零基础也能懂)

一句话定义: 本章讲的是 LEANN 的应用层——把"建索引 / 搜索 / 问答"这三件事, 通过命令行、MCP、agent 三种方式交到用户手里。

给谁用、解决什么: 假设你在笔记本上,想让 AI 帮你在自己的代码、邮件、聊天记录里做 语义搜索,又不想把数据传到云端。LEANN 让你一条命令就把某个目录(或某个数据源)变成一个 本地向量索引,然后:

  • 自己在终端里 leann search / leann ask;
  • 或者让 Claude Code 通过 MCP 协议调用 leann_search,在改代码前先"读懂"你的仓库;
  • 或者用 leann react 让 agent 多轮检索、边搜边想,回答复杂问题。

用起来什么样: 一段最小的真实交互(取自 cli.py:230-246 的帮助示例):

leann build my-docs --docs ./documents # 把 ./documents 建成索引 my-docs
leann search my-docs "向量索引怎么省内存" # 语义搜索,返回 top-k 片段
leann ask my-docs "总结一下第三章" # 检索 + 让 LLM 基于片段作答
leann react my-docs "对比 A 和 B 两种方案" # 多轮检索 agent
leann list # 列出本机所有索引

一句话直觉: 把 LEANN 想成"给你私人数据装的 Spotlight + ChatGPT"—— Spotlight 负责"找到相关片段"(检索),ChatGPT 负责"读片段答问题"(生成), 全在本地跑。


2. 顶层全景(它大概怎么转)

怎么读这张图: 上面三行是三个"入口",都最终落到中间那一层同样的检索/问答 API; 右边是数据怎么进来(连接器)、索引存在哪(磁盘布局)。

人在终端 Claude Code 复杂问题
│ │ │
▼ ▼ ▼
┌─────────┐ ┌────────────┐ ┌─────────────┐
│ CLI │ │ MCP server │ │ ReAct agent │
│ cli.py │ │ mcp.py │ │react_agent │
│build/ │ │ JSON-RPC │ │Thought/ │
│search/ │──subprocess─┤ 转发到 │ │Action 循环 │
│ask/... │ │ leann CLI │ │ │
└────┬────┘ └────────────┘ └──────┬──────┘
│ │
│ ┌───────────────────────┐ │
└───────────▶│ 核心检索/问答 API │◀────────┘
│ api.py │
│ LeannSearcher.search │ ← 向量 + BM25 混合、元数据过滤
│ LeannChat.ask │ ← 拼 prompt 调 LLM(chat.py)
└───────────┬───────────┘

┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌────────────┐ ┌──────────────┐ ┌──────────────────┐
│ 数据连接器 │ │ 磁盘索引布局 │ │ 全局注册表 │
│ apps/*.py │ │.leann/indexes│ │~/.leann/ │
│ index-* │ │ <name>/... │ │ projects.json │
└────────────┘ └──────────────┘ └──────────────────┘

部件一句话职责:

部件干什么在哪个文件
LeannCLI注册所有子命令、分发到各 handlercli.py:197 LeannCLI
MCP server把 4 个工具经 JSON-RPC 暴露给 Claude Codemcp.py:320 handle_request
ReActAgentThought→Action→Observation 多轮检索循环react_agent.py:26 ReActAgent
LeannSearcher.search向量 / BM25 / 混合检索 + 过滤api.py:1240
LeannChat.ask取 top-k 片段拼 prompt、调 LLMapi.py:1689
get_llm按 provider 造 LLM 客户端chat.py:1094
数据连接器把邮件/浏览器/聊天记录读成 Documentapps/*_rag.pycli.pyindex_*
全局注册表记录哪些目录有 LEANN 索引,供 leann list 发现registry.py:52 register_project_directory

主线走一遍(高层): 用户敲 leann ask my-docs "问题" → CLI 解析参数、定位索引路径 (cli.py:3303 ask_questions)→ 造 LeannChatask 里先 search 取 top-k 片段 → 把片段拼进 prompt → get_llm 造好的客户端调模型 → 打印回答。搜索这一步内部可能是纯向量、 纯关键词或两者线性融合(见 §7)。


3. CLI:一个命令,十几个子命令

这一节讲 leann 命令怎么组织。它是所有入口里最"全"的那个——MCP 其实是转发给它, ReAct 也是它的一个子命令。

3.1 子命令体系

命令注册全在 create_parser 里(cli.py:225 LeannCLI.create_parser),用 argparse 的 subparsers。按用途分四类:

类别子命令干什么
建/维护索引build rebuild watch migrate-ids建库、重建、监控自动重建、迁移 ID 方案
查询search ask react语义搜索 / 检索问答 / 多轮 agent
数据源直连index-browser index-email index-calendar index-imessage index-wechat index-chatgpt index-claude一条命令把某数据源索引掉
运维list remove warmup daemon serve列表、删除、预热、常驻嵌入进程、HTTP 服务

分发在 run(cli.py:3680)里,一串 if args.command == ...;除少数命令外,大多 被 suppress_cpp_output(cli.py:102)包住,把 FAISS/HNSW 的 C++ 日志重定向到 /dev/null,只留 Python 的 print——默认安静,-v 才放出底层日志。

3.2 三条主入口:build / search / ask

build(cli.py:2360 build_index):先按 --doc-chunk-size 等参数重建 分块器,再检测增量变化(新增/修改/删除),能增量就增量、否则全量; 分块与 embedding 的细节属于 04 章,这里只需知道它最终调 LeannBuilder.build_index 落盘,然后 register_project_dir() 把当前目录登记进全局注册表。

search(cli.py:3099 search_documents):定位索引 → 造 LeannSearchersearcher.search(...)。有两个实用开关:--json 输出机器可读数组(MCP 就靠它), --metadata-filters 传一段 JSON 过滤条件(见 §8)。JSON 模式下它还会把 fd 1 临时 重定向到 /dev/null,保证 stdout 里只有干净的 JSON(cli.py:3134-3143)。

ask(cli.py:3303 ask_questions):按 --llm 组装 llm_config(不同 provider 填不同字段,如 ollama 填 host、openai 填 base_url/api_key),造 LeannChat, 然后 _ask_once 里调 chat.ask(...)--interactive 会进多轮会话循环。 问答链路的细节见 §6。

3.3 索引存储布局与全局注册表

这是理解 leann list / remove 行为的关键:LEANN 有两种索引落盘形态。

CLI 形态(leann build 产出):项目本地 .leann/indexes/<name>/,像 .git 一样 藏在当前目录下(cli.py:200)。一个索引由这几个文件组成:

文件内容
documents.leann.meta.json元数据:后端、embedding 模型、维度、是否 compact
documents.leann.passages.jsonl原始文本块 + 每块的 metadata(一行一块)
documents.leann.passages.idx偏移表,按 passage id 快速定位到 jsonl 某行
documents.leann.index后端专属的向量索引(图/IVF/DiskANN)

三件套齐了才算完整(cli.py:72 _existing_index_artifacts)。

App 形态(apps/*.pyindex-* 产出):*.leann.meta.json 可以散落在项目任意子目录, 用文件名前缀父目录名寻址。

全局注册表 ~/.leann/projects.json 是一个目录路径的 JSON 数组。每次 build 成功都会 register_project_directory()(registry.py:52)把当前目录加进去—— 但它先检查目录里确实有 LEANN 内容(有 .leann/indexes 或能 rglob 到 *.leann.meta.json) 才登记,避免污染。leann list(cli.py:926)遍历这张表 + 当前目录, 把 CLI 与 App 两种形态的索引都发现出来。


4. 问答链路:从片段到答案

这一节回答:leann ask 敲下去,一句问题怎么变成一段回答。

4.1 LeannChat.ask 的三步

LeannChat.ask(api.py:1689)极简,就是"检索 → 拼 prompt → 调 LLM":

# 示意,非源码:ask 的骨架
results = self.searcher.search(question, top_k=top_k, ...) # 1. 取 top-k 片段
context = "\n\n".join(r.text for r in results) # 2. 片段拼成上下文
prompt = ( # 塞进固定模板
"Here is some retrieved context ...\n\n"
f"{context}\n\nQuestion: {question}\n\n"
"Please provide the best answer ..."
)
ans = self.llm.ask(prompt, **llm_kwargs) # 3. 交给 LLM

真实 prompt 模板见 api.py:1735-1740。注意它是一次性检索——取 top-k、拼一把、 问一次就返回;想要"多轮边搜边想"得用 ReAct(§5)。

4.2 get_llm:一个工厂,多个 provider

llm.ask 背后的 self.llmget_llm(chat.py:1094)按 llm_config["type"] 造出来。 支持的 provider 与默认模型:

type默认模型说明
ollamaOllamaChat (chat.py:468)llama3:8b本地,走 /api/generate
hfHFChat (chat.py:556)deepseek-llm-7b-chat本地 transformers,自动选 cuda/mps/cpu
openaiOpenAIChat (chat.py:787)gpt-4o也兼容任何 OpenAI 协议端点
anthropicAnthropicChat (chat.py:872)Claude,可自定义 base_url
geminiGeminiChat (chat.py:733)gemini-2.5-flashGoogle
minimax / novitaMiniMaxChat / NovitaChat均走 OpenAI 兼容 API
simulatedSimulatedChat (chat.py:1085)测试用,返回假答案

所有 provider 都实现同一个抽象接口 LLMInterface.ask(chat.py:424),所以上层 LeannChat / ReActAgent 对具体是谁在答无感

一处贴心设计: ollama/hf 在初始化时会先校验模型名,不存在就用 difflib 做模糊匹配 给出"你是不是想输 X"的建议并教你 ollama pull(chat.py:322 validate_model_and_suggest)—— 把"模型名打错"这个高频翻车点前置拦掉。


5. ReAct 多轮检索:边搜边想

这一节讲 leann react:当一次检索不够(要对比、要追问、要跨来源),让 agent 循环 "想一步 → 搜一次 → 看结果 → 再想"。

5.1 思路:Thought / Action / Observation 循环

ReAct = Reasoning + Acting。LLM 每一轮先输出一段 Thought(推理),再给一个 Action(调哪个工具),拿到 Observation(工具结果)后进入下一轮, 直到它给出 Final Answer(react_agent.py:182 ReActAgent.run)。

┌──────────────────────────────┐
问题 ───▶ │ 拼 prompt(带历史 observation)│
└───────────────┬──────────────┘

LLM 生成一段
Thought + Action

┌────────────────┼────────────────┐
▼ ▼ ▼
leann_search web_search Final Answer
(本地知识库) (Serper 联网) → 结束,返回
│ │
└──────┬─────────┘

Observation 存进历史 ── 回到顶部,iteration+1
(最多 max_iterations 轮)

5.2 三个工具,按可用性动态拼 prompt

ReActAgent 支持三个工具:leann_search(本地库)、web_search(Serper 联网)、 visit_page(Jina 抓网页正文)。关键在 _create_react_prompt(react_agent.py:69)会 按当前可用工具动态生成 prompt:没配 Serper key 时,web_search_available 为 False, prompt 里就只写 leann_search 一个工具,并明确告诉模型"联网不可用,只用本地库回答"。 这样同一个 agent 在有/无 API key 时都能优雅工作。

工具是否可用由构造时是否传入 key 决定(react_agent.py:55-56);CLI 侧从 --serper-api-key / --jina-api-key 或环境变量取(cli.py:736-747)。

5.3 解析与回退

LLM 输出是自由文本,_parse_llm_response(react_agent.py:129)用正则从 Action: leann_search("...") 里抠出工具名和参数;还兼容裸 search("...") 写法, 统一归一到 leann_search。若跑满 max_iterations 还没给最终答案,就把所有搜索结果 塞进一个 final prompt 逼模型收尾(react_agent.py:286-297)。每轮都记进 search_history,CLI 结束后打印检索轨迹(cli.py:3642-3649)。


6. MCP 集成:让 Claude Code 拿到语义搜索

这一节讲 leann-mcp:一个 stdio 上的 MCP(Model Context Protocol,模型上下文协议—— 让 agent 以标准 JSON-RPC 方式调用外部工具)服务器,把 LEANN 的能力接进 Claude Code。

6.1 四个工具

mcp.py 顶部的 TOOLS(mcp.py:48)声明了四个工具,每个带 JSON Schema 参数:

工具干什么底层
leann_search语义搜索索引,返回带文件路径/分数的片段转发 leann search --json
leann_list列出所有可用索引转发 leann list
leann_build建/增量更新索引转发 leann build
leann_status看索引状态(后端、模型、块数、大小)直接读 meta.json

它们不是重新实现,而是用 subprocess 调 leann CLI(mcp.py:21 _run_leann), 再把输出整理成 MCP 的 content 结构。例如 handle_search(mcp.py:149)带 --json --show-metadata --non-interactive 调 search,解析 JSON 后拼成带代码块的 markdown 给 agent 看。

6.2 JSON-RPC 分发

handle_request(mcp.py:320)是入口:按 method 分发。 initialize 返回能力握手;tools/list 返回上面的 TOOLS;tools/call 按工具名路由到 四个 handler。main(mcp.py:368)就是从 stdin 逐行读 JSON、处理、把结果写回 stdout—— 标准的 stdio MCP server 骨架。

为什么这样设计: 复用 CLI 意味着 MCP 和人用的是同一条代码路径,不会出现 "命令行搜出来一个结果、Claude 搜出来另一个"的分裂。代价是每次工具调用都 fork 一个进程, 所以 leann_build 给了 600 秒超时(mcp.py:244),search 默认 120 秒。


7. 混合检索:向量 + BM25 线性融合

这一节讲 LEANN 怎么把"语义相似"和"关键词命中"两种检索揉在一起。

7.1 为什么要混合

纯向量检索擅长语义(问"怎么省内存"能命中讲"压缩"的段落),但对精确关键词、 罕见专有名词、代码标识符常常不如老式关键词检索。BM25(经典的词频-逆文档频率打分算法) 正好补这个短板。LEANN 让你用一个权重 vector_weight 在两者间线性滑动。

7.2 Fts5BM25Index:SQLite FTS5 扛关键词

BM25 那一侧由 Fts5BM25Index(api.py:310)实现,建在 SQLite 的 FTS5 虚拟表上。 建表 schema(api.py:319-323)把文本列全文索引,查询时直接 bm25() 打分。 一个关键细节:SQLite 的 bm25()越小越好,而 LEANN 全局约定越大越好, 所以它在 SQL 里 -bm25(...) 取负(api.py:362),让分数方向和向量侧统一, 后面的融合数学才不用特判。查询前还会剥掉标点、小写化、把词用 OR 连起来, 避开 FTS5 的查询语法坑(api.py:356-359)。

BM25 索引可以 leann build 时预建(prebuild_bm25 / bm25_backend="fts5", api.py:386-395),也可以首次搜索时从 passages.jsonl 现场 fit 出来 (_init_bm25,api.py:1516)。

7.3 融合数学

融合逻辑在 LeannSearcher.search(api.py:1240),由 vector_weight 分三种情况:

vector_weight行为代码
1.0(默认)纯向量检索api.py:1331 else 分支
0.0纯 BM25 关键词检索api.py:1323
0.0 < w < 1.0线性融合api.py:1408

融合时对每个文档:score = w * 向量分 + (1-w) * BM25分,同一文档两边都命中就相加, 只在一边命中就单侧计权,最后按合并分排序取 top-k(api.py:1412-1427)。

# 示意,非源码:线性融合的核心
bm25_weight = 1.0 - vector_weight
for doc_id, score in vector_hits: # 向量侧
hybrid[doc_id] = vector_weight * score
for r in bm25_hits: # BM25 侧,命中则叠加
hybrid[r.id] = hybrid.get(r.id, 0) + bm25_weight * r.score
top = sorted(hybrid.items(), key=lambda x: -x[1])[:top_k]

历史坑: 早期版本把这个权重错拼成 gemma(本想写 "gamma")。现在 gemma= 仍被接受但会发 DeprecationWarning,内部转成 vector_weight (api.py:1288-1295)。看到 gemma 别当成 Google 那个模型。

另有一个 use_grep 分支(api.py:1593 _grep_search),直接对 passages.jsonl 跑 grep 按出现次数打分——精确子串匹配的"逃生舱"。


8. 元数据过滤:检索之后再筛

向量/BM25 找出候选后,常还想按结构化字段收窄——"只要第 5 章之前"、"只要 fiction"。 这由 MetadataFilterEngine(metadata_filter.py:20)负责,是一个检索后过滤器, 和后端无关。

支持的算子(metadata_filter.py:33-47):

类别算子
比较== != < <= > >=
成员in not_in
字符串contains starts_with ends_with
布尔is_true is_false

过滤规范是一段 JSON,形如 {"chapter": {"<=": 5}, "genre": {"==": "fiction"}}, 多个字段之间是 AND(_evaluate_filters,metadata_filter.py:77,任一不过就淘汰)。 取值时先看结果顶层字段、再看 metadata 子字典(metadata_filter.py:110-114), 字段缺失一律判不过。数值比较带类型强转:两边都是字符串就按字符串比, 否则尽量转 float 比,转失败再退回字符串比(_numeric_compare,metadata_filter.py:209)。

CLI 侧 searchask 都有 --metadata-filters,会先 json.loads 校验、 无效就快速失败(cli.py:3118-3132cli.py:3336-3352),避免白白启动嵌入服务。


9. 数据连接器:RAG Everything 的"Everything"

口号里的 "Everything" 就靠这批连接器:把各种私人数据源读成统一的 Document, 再走同一套 build 流程。两种形态并存。

index-* 子命令(cli.py 内,共享 _build_index_from_documents,cli.py:3397):

命令数据源reader
index-browserChrome/Brave 历史ChromeHistoryReader (cli.py:3448)
index-emailApple Mail(.emlx)EmlxReader (cli.py:3463)
index-calendarApple 日历缓存直接读 SQLite (cli.py:3477)
index-imessageiMessage 对话IMessageReader (cli.py:3520)
index-wechat微信导出 JSONWeChatHistoryReader (cli.py:3529)
index-chatgptChatGPT 导出ChatGPTReader (cli.py:3542)
index-claudeClaude 导出ClaudeReader (cli.py:3554)

apps/*_rag.py 示例脚本(每个一句话):

文件一句话
document_rag.pyPDF/TXT/MD 文档问答,最易上手
code_rag.py代码库检索,配 AST 感知分块
email_rag.pyApple Mail 邮件 RAG
browser_rag.py浏览器历史 RAG
imessage_rag.py / wechat_rag.pyiMessage / 微信聊天记录
chatgpt_rag.py / claude_rag.py把过往 AI 对话变成可检索知识
slack_rag.py / twitter_rag.py基于实时数据源
image_rag.py / colqwen_rag.py图像 / 多模态检索
qwen_rag.py / gemini_rag.py换特定 LLM 的示例

所有连接器的共同套路:reader 把源数据读成一批 Document(含 text + metadata)→ _build_index_from_documentsLeannBuilder 建成 hnsw 索引 → register_project_dir() 登记(cli.py:3436-3445)。也就是说连接器只负责"读", 索引/检索/问答复用的还是同一条主干。


10. 边界与局限(诚实说)

  • MCP = fork 子进程。 每次工具调用都起一个 leann 进程(mcp.py:23), 高并发或超大 build 会慢;靠超时(build 600s / search 120s)兜底。
  • ReAct 依赖 LLM 守格式。 动作解析是正则抠 Action: tool("arg") (react_agent.py:160);模型不按格式输出时,可能既没解析出 action 也没 Final Answer,只能靠跑满迭代后的兜底 prompt 收场。
  • ask 是单轮检索。 LeannChat.ask 只检索一次(api.py:1717);需要多跳 推理请用 react
  • 元数据过滤是"检索后筛"。 先取 top-k 再过滤,若过滤条件很严,可能筛到几乎为空—— 它不会自动加大候选量补回来。
  • BM25 缺 passages 就退化。 若 passages.jsonl 丢失,_init_bm25 记 error 后 BM25/混合检索返回空(api.py:1550-1556),需重新 leann build

11. 横向对比(同组兄弟章)

关切本章(应用层)去哪深入
索引为什么这么小只是"用"索引01 存储魔法
search 内部怎么遍历+重算searcher.search 就完事02 查询与重算
换 hnsw/ivf/diskann 后端--backend-name 一个参数03 后端注册
embedding 怎么算、怎么分块build 时的参数04 embedding 与分块

一句话:本章是"把前四章的引擎接上方向盘"。


12. 代码地图(导航索引)

主题文件路径符号名
CLI 命令注册packages/leann-core/src/leann/cli.pyLeannCLI.create_parser
CLI 命令分发packages/leann-core/src/leann/cli.pyLeannCLI.run
build 入口packages/leann-core/src/leann/cli.pyLeannCLI.build_index
search 入口packages/leann-core/src/leann/cli.pyLeannCLI.search_documents
ask 入口packages/leann-core/src/leann/cli.pyLeannCLI.ask_questions
react 入口packages/leann-core/src/leann/cli.pyLeannCLI.react_agent
数据源共享建库packages/leann-core/src/leann/cli.pyLeannCLI._build_index_from_documents
全局注册表packages/leann-core/src/leann/registry.pyregister_project_directory
问答链路packages/leann-core/src/leann/api.pyLeannChat.ask
检索 + 混合融合packages/leann-core/src/leann/api.pyLeannSearcher.search
BM25 / FTS5packages/leann-core/src/leann/api.pyFts5BM25Index
BM25 惰性初始化packages/leann-core/src/leann/api.pyLeannSearcher._init_bm25
LLM 工厂packages/leann-core/src/leann/chat.pyget_llm
LLM 抽象接口packages/leann-core/src/leann/chat.pyLLMInterface
ReAct 循环packages/leann-core/src/leann/react_agent.pyReActAgent.run
ReAct 动态 promptpackages/leann-core/src/leann/react_agent.pyReActAgent._create_react_prompt
MCP 工具声明packages/leann-core/src/leann/mcp.pyTOOLS
MCP JSON-RPC 分发packages/leann-core/src/leann/mcp.pyhandle_request
元数据过滤引擎packages/leann-core/src/leann/metadata_filter.pyMetadataFilterEngine