目录

一个专注音视频领域的小圈子

前面三篇已经完成了模型调用、PromptTemplate、结构化输出、工具调用和 Agent。

这一篇开始进入 RAG。

RAG 全称是 Retrieval-Augmented Generation,也就是检索增强生成。简单来说,不是让大模型凭空回答,而是先从本地资料里找到相关内容,再把这些内容作为上下文交给模型。

本文对应代码位于 code/langchain-demo/chapter04

这一篇先不引入向量数据库,也不依赖 embedding 模型,而是用最朴素的本地 Markdown 文章 + 关键词检索跑通流程。原因很简单:先把 RAG 的基本链路看明白,比一上来就堆向量库要更清楚。

本篇目标

这一篇要做的事情有三个:

  1. 读取 Hugo 博客里的 Markdown 文章。
  2. 根据用户问题检索相关片段。
  3. 把检索结果放进 Prompt,让模型基于上下文回答。

完整流程可以理解成:

用户问题
  -> 读取本地 Markdown
  -> 切分文章
  -> 检索相关片段
  -> 组装 Prompt
  -> 调用模型回答

这就是最小版本的 RAG。

代码结构

第四篇的代码结构如下:

code/langchain-demo/
  chapter04/
    __init__.py
    config.py
    llm.py
    load_docs.py
    simple_rag.py

其中:

  • config.py:读取 .env,同时定位博客文章目录。
  • llm.py:创建 ChatOpenAI 模型。
  • load_docs.py:读取和切分 Markdown。
  • simple_rag.py:完成检索和问答流程。

这里的博客文章目录是:

BLOG_POST_ROOT = REPO_ROOT / "blog" / "content" / "post"

也就是说,RAG 的知识库先直接使用当前博客已有文章。

读取 Markdown 文章

本地博客文章是 Hugo leaf bundle 结构,每篇文章通常是一个目录里的 index.md

读取文章时,需要去掉开头的 front matter,只保留正文内容:

FRONT_MATTER_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)


def _strip_front_matter(text: str) -> str:
    """去掉 Hugo Markdown 文件开头的 front matter。"""

    return FRONT_MATTER_RE.sub("", text, count=1).strip()

然后把每篇文章转换成 LangChain 的 Document

documents.append(
    Document(
        page_content=content,
        metadata={
            "source": str(path.relative_to(REPO_ROOT)),
            "title": _extract_title(raw_text, path),
        },
    )
)

这里需要注意的是 metadata

RAG 不只是把文本给模型,还要把来源一起保留下来。否则模型回答完之后,就不知道答案是从哪篇文章来的了。

切分文章

博客文章通常比较长,不能直接整篇塞进 prompt,所以需要切成小块:

splitter = RecursiveCharacterTextSplitter(
    chunk_size=900,
    chunk_overlap=150,
    separators=["\n\n", "\n", "。", ",", " ", ""],
)

这里的参数先不追求最优,只要记住两点:

  1. chunk_size 控制每个片段大概多长。
  2. chunk_overlap 保留片段之间的重叠,避免一句话被切断后上下文丢失。

第七篇会专门讲这些参数怎么调。

关键词检索

第四篇的检索方式是关键词匹配。

代码思路很直接:

tokens = _query_tokens(question)

for chunk in chunks:
    title = chunk.metadata.get("title", "").lower()
    content = chunk.page_content.lower()

    title_score = sum(title.count(token) for token in tokens) * 5
    content_score = sum(content.count(token) for token in tokens)
    score = title_score + content_score

这里给标题更高权重,是因为标题命中往往更能说明文章相关。

这不是最强的检索方式,但适合第一版 RAG:

  1. 不依赖额外服务。
  2. 逻辑透明,容易调试。
  3. 可以清楚看到“检索结果”对最终回答的影响。

组装 Prompt

检索到片段之后,需要把它们整理成上下文:

context = format_context(relevant_chunks)

然后通过 ChatPromptTemplate 交给模型:

prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "你是一个博客知识库问答助手。"
            "请只根据给定上下文回答问题;如果上下文不足,就说明没有找到足够信息。",
        ),
        (
            "human",
            "问题:{question}\n\n"
            "上下文:\n{context}\n\n"
            "请用中文回答,并在回答末尾列出参考文章。",
        ),
    ]
)

这里的 system prompt 很关键。

它明确告诉模型:只能根据上下文回答。如果上下文不足,不要硬编。

RAG 做得好不好,不只是检索问题,也和提示词约束有关。

运行 demo

code/langchain-demo 目录下运行:

uv run python -m chapter04.simple_rag "Python 函数式编程讲了什么?"

如果当前 shell 里有其他项目设置的 PYTHONPATH,可以这样运行:

env -u PYTHONPATH .venv/bin/python -m chapter04.simple_rag "Python 函数式编程讲了什么?"

正常输出会包含三部分:

Question:
Python 函数式编程讲了什么?

Sources:
- blog/content/post/code/Python 函数式编程/index.md

Answer:
...

可以看到,这里已经不是单纯问模型了,而是先从本地博客里找到了相关文章,再基于文章内容回答。

需要注意的问题

这一版实现很简单,也有明显限制:

  1. 关键词检索对表达方式比较敏感。
  2. 如果用户问题和文章用词不一致,可能检索不到。
  3. 中文分词只是简单切片,不适合复杂搜索场景。
  4. 没有保存索引,每次运行都会重新读取文章。

这些问题后面几篇会逐步处理:

  1. 第五篇引入向量检索。
  2. 第六篇把索引持久化。
  3. 第七篇专门做检索质量调优。

小结

这一篇完成了最小 RAG 流程。

虽然检索方式只是关键词匹配,但它已经具备 RAG 的核心结构:

检索资料 -> 构造上下文 -> 约束模型基于上下文回答

先把这条链路跑通,后面再替换检索方式、持久化索引、接入 Agent,都会比较自然。

参考

  • code/langchain-demo/chapter04
  • LangChain Document
  • LangChain RecursiveCharacterTextSplitter
  • LangChain ChatPromptTemplate

原创文章,转载请注明来源:    LangChain 入门学习第四篇-基于本地 Markdown 的 RAG 问答