使用LangChain+LangGraph开发AI应用(11) - 案例:个人知识库

https://static.xiongneng.me/langchain-langgraph-cover-20261017203759.png

学习进度

这一篇做一个完整的项目,个人知识库。把你散落的 markdown 笔记吃进本地向量库,之后随时向它提问,它自己决定检索几次、检索什么,回答里的每个关键结论都带 [文件名#块号] 引用标记,顺着标记能翻回库里的原文块。

第 6 篇的 kb.py 是这个项目的雏形,检索几条固定段落拼进提示词,模型照单全收。这一篇把它升级成 Agent 版,代码在 source/library.py,三百行出头,命令行工具,三条子命令,ingest 导入文档,stats 看库存,ask 提问。演示笔记在 source/notes/,三个文件,仓库里直接能跑。

前言用途

问答类应用有两道坎。第一道是答非所问,模型对私域内容一无所知,靠提示词硬编只能救急。第二道是空口无凭,模型给了一个答案,你不知道它是真记得还是现编的,越是用它做决策,这个问题越致命。

RAG 解第一道,把资料检索进上下文。但第 6 篇那种固定 top-k 的做法有个结构性缺陷,检索几次、用什么词,全是代码写死的。问题问得笼统,一次检索的段落就不够;问题涉及两个主题,固定检索只能照顾到一个。引用溯源解第二道,答案里的每个结论都挂着出处,出处能翻回原文,编没编一眼便知。

这两个能力合起来,就是这一篇的全部。做完你手里有一个能日常用的笔记问答工具,更重要的是有一套可复用的结构,文档导入管道、检索工具、Agent 循环、引用回查,换到任何「资料问答」场景都是这几块积木。

原理概念

雏形和 Agent 版的差别在谁掌握检索的主动权。雏形是代码调一次 similarity_search,拿固定条数塞给模型,模型只负责组织语言。Agent 版把检索包成一个工具交给 create_agent,模型读你的问题,自己拟查询词,自己决定查一次还是查几次,看到检索结果不对路还会换个词再查。真实模型实测,问「怎么给模型挂工具,要注意什么」,它先查工具调用,扫了一眼结果又补了一轮错误处理的检索,两轮拿全材料才动笔。这个自主性就是 Agent 版的核心增量。

主动权交出去之后,新的问题是回答还信不信得过。解法是把「可追溯」做进数据结构里。每一块笔记入库时带一个确定性 id,规则就是 文件名#块号,检索工具把资料递给模型时,每段前面都挂上这个 id。系统提示词里要求模型引用资料时带上标记。回答回来,正则把标记抽出来,拿 id 回向量库查原文。三步全是确定性的,模型的自由发挥被圈在「引用哪个 id」这一件事上,引用错了 id,溯源清单立刻现形。

还有一个不必手写的东西,检索循环本身。自己写也不难,调一次检索、拼提示词、调模型,不满意再来一轮,第 6 篇的雏形就是这么干的。但换词重试、最多几轮、什么时候停,这些判断得自己写状态机,写完还要测。create_agent 把这套循环管了,模型每轮自己决定要不要再调工具,工具结果自动变 ToolMessage 回传,想限制轮数加一个参数,想看全程开 debug。案例里的检索工具只写了十行,循环、重试、停止条件全是现成的,这就是把检索交给 agent 的真正好处,业务代码里只剩业务。

导入管道的幂等性也压在 id 上。重复导入同一个文件,如果直接 add,块数翻倍,库里全是重复内容。给每块按 文件名#块号 生成 id 再 add,同 id 的块被覆盖,重导一遍等于原地刷新。但覆盖只救得了还存在的 id,文件改短之后旧的多余块没有新内容来覆盖,成了僵尸块。所以导入顺序是先删后加,按 metadata 里的 source 翻出这个文件现存的全部块 id,整批 delete,再写入新的。两次导入,块数永远等于当前文件实际该有的块数。

id 的形态是个刻意的选择。用文件名加块号,而不是内容 hash,权衡有三点。块号是人类可读的,install.md#0 一眼看出来自哪个文件的第几块,溯源清单打出来用户直接能对上;id 稳定,文件没改的块重导后 id 不变,向量库里省一次重算嵌入;同文件内容变了,块 id 依然成对出现,先删后加的替换逻辑写起来是整批操作,不用逐块比对 hash。内容 hash 的方案只在跨文档去重时有优势,个人笔记场景里这个需求几乎不出现,可读性优先。

https://static.xiongneng.me/library-arch-20261017203800.png

核心用法

四块积木逐个过,代码都出自 source/library.py。

1. 文档导入管道

目录扫描、分割、确定性 id、替换式写入,一个函数收口。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def ingest(paths: list[str]) -> None:
    store = load_store()
    for doc in raw_docs:
        source = doc.metadata["source"]
        old = chunk_ids_of(store, source)
        if old:
            store.delete(old)               # 先清这个文件的旧块
        chunks = SPLITTER.split_documents([doc])
        for i, c in enumerate(chunks):
            c.metadata["chunk"] = i
        store.add_documents(chunks,
                            ids=[f"{source}#{i}" for i in range(len(chunks))])
    store.dump(str(STORE_FILE))
1
2
3
4
  install.md: 旧块 0 -> 新块 1
  tools.md: 旧块 0 -> 新块 1
  vector.md: 旧块 0 -> 新块 1
导入完成,替换 0 块,写入 3 块,库内现有 3 块

再跑一遍 ingest,输出变成替换 3 块、写入 3 块,库内还是 3 块,幂等得到实测。块 id 同时写进两个地方,InMemoryVectorStore 的主键位置和 metadata 的 source、chunk 字段,前者用来去重和回查,后者给 stats 和 list 做人读的展示。

管道的前半段是 load_docs 和分割器。目录扫描只认 txt 和 md 两种后缀,rglob 递归整棵目录树,排序保证多文件导入的顺序稳定。分割器沿用第 6 篇的配置,块 200 字、重叠 30 字,分隔符按中文排版习惯排了空行、换行、句号、逗号四级,标题和它的首段落在同一块的概率因此高了不少。chunk 字段是分割之后手工补进 metadata 的,split_documents 只继承原 metadata,块序号得自己数。

1
2
3
4
5
6
7
def load_docs(path: Path) -> list[Document]:
    if path.is_file():
        files = [path] if path.suffix.lower() in SUFFIXES else []
    else:
        files = sorted(p for p in path.rglob("*") if p.suffix.lower() in SUFFIXES)
    return [Document(page_content=f.read_text(encoding="utf-8"),
                     metadata={"source": f.name}) for f in files]

文件名进 metadata 这一步越早越好,它在导入的那一刻就定死了后续一切溯源的锚点。

2. 检索工具

把相似度检索包成模型能调的工具,关键动作是给每段资料挂上来源标记。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def format_hits(hits) -> str:
    if not hits:
        return "(没有检索到相关资料)"
    return "\n\n".join(
        f"[{d.metadata['source']}#{d.metadata['chunk']}] {d.page_content}"
        for d, _ in hits)

@tool
def search_notes(query: str, k: int = 3) -> str:
    """在个人笔记知识库里检索相关段落。

    返回带 [文件名#块号] 来源标记的资料,回答时引用这些标记。
    """
    return format_hits(store.similarity_search_with_score(query, k=k))
1
2
3
[install.md#0] LangChain 用 pip 安装,命令是 pip install langchain...

[vector.md#0] 把文本切成小块,用嵌入模型算出向量存进向量库...

工具的 docstring 写给模型看,两句说清两件事,这个工具干什么、返回值里的标记怎么用。docstring 缺了后半句,模型大概率把标记当噪音丢掉,溯源就断了。检索结果为空也有兜底文案,模型看到它知道该换查询词而不是硬编。

3. Agent 问答

create_agent 把模型和检索工具接成循环,这一步的代码反而最短。

1
2
3
4
5
6
7
8
9
def build_agent(store, model):
    return create_agent(model, tools=[search_notes], debug=False)

def ask(question: str) -> None:
    store = load_store()
    model = build_model() or fake_model_for(question, store)
    agent = build_agent(store, model)
    result = agent.invoke({"messages": [question]})
    answer = result["messages"][-1].content

result 的 messages 里能看到完整的协作过程,模型发起的每次 tool_calls、每条 ToolMessage 里的检索结果、最终回答。一次提问落下来的消息流长这样。

1
2
3
4
5
6
[HumanMessage] 怎么给模型挂工具?要注意什么?
[AIMessage] (发起 tool_calls,query 为 怎么给模型挂工具)
[ToolMessage] [tools.md#0] # 工具调用备忘 给模型挂工具用 create_agent...
[AIMessage] (再发起一轮 tool_calls,query 为 工具调用注意事项)
[ToolMessage] (没有检索到相关资料)
[AIMessage] 给模型挂工具用 create_agent... [tools.md#0]

检索轮数的统计就是数 AIMessage 里带 tool_calls 的条数,这个数字直接打给用户看,自主检索不再是黑盒。第二条 ToolMessage 的空结果很有信息量,模型明确收到「没有检索到相关资料」,回答末尾那句「笔记里还没有相关细节」的诚实表态正是从这里来的。

本地没有 key 也能跑。fake_model_for 按问题先替模型检索一遍,编排出「调一次工具再作答」的两步脚本,行为和真实模型同构,测试全部压在这条路径上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def fake_model_for(question: str, store) -> ScriptedChatModel:
    probe = store.similarity_search_with_score(question, k=3)
    cites = [f"{d.metadata['source']}#{d.metadata['chunk']}" for d, _ in probe]
    body = "、".join(f"[{c}]" for c in cites[:2]) or "资料中没有直接相关的内容"
    return ScriptedChatModel(messages=iter([
        AIMessage(content="", tool_calls=[
            {"name": "search_notes", "args": {"query": question, "k": 3},
             "id": "t1", "type": "tool_call"}]),
        AIMessage(content=f"依据笔记,{question} 的要点整理如下,出处 {body}。"),
    ]))

替身引用的 id 来自真实检索的结果,溯源清单因此翻得回真原文,演示链路一步不缺。

4. 引用溯源

回答里的标记抽出来,回库翻原文,溯源清单就成了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
CITE = re.compile(r"\[([^\[\]#]+#[0-9]+)\]")

def parse_citations(text: str) -> list[str]:
    seen, cites = set(), []
    for m in CITE.finditer(text):
        if m.group(1) not in seen:
            seen.add(m.group(1))
            cites.append(m.group(1))
    return cites

def source_trace(store, cites: list[str]) -> list[tuple[str, str]]:
    trace = []
    for cite in cites:
        item = store.store.get(cite)
        trace.append((cite, item["text"] if item else "(库内找不到该块)"))
    return trace
1
2
3
4
5
回答: 依据笔记,LangChain 怎么安装? 的要点整理如下,出处 [install.md#0]、[vector.md#0]。
(模型检索了 1 轮,回答带 2 处引用)
引用溯源:
  install.md#0 -> # LangChain 安装与初始化  LangChain 用 pip 安装,命令是 p
  vector.md#0 -> # 向量检索原理  把文本切成小块,用嵌入模型算出向量存进向量库

正则只认「非方括号内容加井号加数字」的形态,模型引用没写标记,或者写了 [链接] 这种普通方括号,都不会误伤。翻不到的 id 在清单里如实标注,不静默吞掉。–show-sources 开关把引用块的全文也展开,核对答案时不用再开笔记文件。

完整案例

四块积木拼起来的完整体验,三条命令。

1
2
3
uv run python library.py ingest notes/    # 导入,重复执行幂等
uv run python library.py list             # 库存清单
uv run python library.py ask "怎么给模型挂工具?要注意什么?"
1
2
3
4
库内共 3 块
  1. [install.md#0] # LangChain 安装与初始化  LangChain 用 pip 安装,命令
  2. [tools.md#0] # 工具调用备忘  给模型挂工具用 create_agent,传 tools 列
  3. [vector.md#0] # 向量检索原理  把文本切成小块,用嵌入模型算出向量存进

list 是块级视角,stats 是文件级视角,导入管道有没有吃对东西,两个命令对着看一眼就够。接上真实模型(AGENT_MODEL 设为 openai:deepseek-v4.1-flash,走 YI 网关),问一个需要综合两处资料的问题。

1
2
3
4
文件 3 个,共 3 块
  install.md: 1 块
  tools.md: 1 块
  vector.md: 1 块

接上真实模型(AGENT_MODEL 设为 openai:deepseek-v4.1-flash,走 YI 网关),问一个需要综合两处资料的问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
问题: 怎么给模型挂工具?要注意什么?
回答: 给模型挂工具用 create_agent,传 tools 列表 [tools.md#0]。要注意的几点:

1. docstring 要写清楚,模型靠它判断何时使用工具,还要说明参数含义;
2. 注意是否需要人工确认,涉及写操作的工具应当把确认环节放在工具外面。

---
笔记里还没有"工具报错怎么回传"的细节,如果你已经实践过,可以告诉我结论,我来补写。
(模型检索了 2 轮,回答带 1 处引用)
引用溯源:
  tools.md#0 -> # 工具调用备忘  给模型挂工具用 create_agent,传 tools 列表,模型

这段真实输出里有三个值得留意的细节。模型自己检索了两轮,先查怎么挂工具,看了一眼结果,又主动补了一轮检索去确认注意事项,主动权交接是真实发生的。回答末尾它指出笔记里缺少工具报错回传的内容,这是检索结果如实告诉它「没有相关资料」之后的诚实反应,比硬编一段强得多。溯源清单里 tools.md#0 翻出的原文正是回答第一句的依据,编造与引用的边界清清楚楚。

–show-sources 把核对做到极致,每个引用块的全文直接展开在溯源清单下面。

1
2
3
4
5
6
7
8
9
引用溯源:
  tools.md#0 -> # 工具调用备忘  给模型挂工具用 create_agent,传 tools 列表,模型
      原文: # 工具调用备忘

      给模型挂工具用 create_agent,传 tools 列表,模型自己决定调不调、调哪个。

      工具函数写清 docstring 很关键,模型靠它判断什么时候该用这个工具。

      工具返回 ToolMessage 回传给模型,模型拿到结果再决定下一步。

答案对不对,扫一眼原文块就有结论,全程不用离开终端。

库存侧的两个命令各管一层,stats 聚合、list 明细。

1
2
3
4
5
def stats() -> None:
    by_source: dict[str, int] = {}
    for item in store.store.values():
        src = item["metadata"].get("source", "?")
        by_source[src] = by_source.get(src, 0) + 1

实现就是遍历库内全部块,按 source 计数,二十行以内的函数,价值在养成导入后跑一下的习惯。块数骤降多半是源文件被误删或后缀没扫到,块数为零的知识库提问时只会得到一句「没有检索到相关资料」,提前在 stats 里看出来,省得去怀疑检索工具坏了。

测试在 tests/test_library.py 十一条,幂等导入、僵尸块清理、metadata 完整性、引用解析保序去重、溯源命中与翻空、替身问答、两轮检索计数、空库提示、按文件统计,全部走本地替身不花一分钱。加上前十篇的,全仓一百七十八条,全绿。

这个案例的扩展方向也留好了。换嵌入模型改 build_embeddings 一处,上持久化把 InMemoryVectorStore 换成任何 VectorStore 实现,接口不变;支持 pdf 和 docx 就往 load_docs 里加加载器;想把知识库挂进日常聊天,把 search_notes 注册进第 5 篇的任何 agent 就行,工具是自包含的。

源码

https://github.com/yidao620c/xneng-langchain-langgraph

参考