目录
前面三篇已经完成了模型调用、PromptTemplate、结构化输出、工具调用和 Agent。
这一篇开始进入 RAG。
RAG 全称是 Retrieval-Augmented Generation,也就是检索增强生成。简单来说,不是让大模型凭空回答,而是先从本地资料里找到相关内容,再把这些内容作为上下文交给模型。
本文对应代码位于 code/langchain-demo/chapter04。
这一篇先不引入向量数据库,也不依赖 embedding 模型,而是用最朴素的本地 Markdown 文章 + 关键词检索跑通流程。原因很简单:先把 RAG 的基本链路看明白,比一上来就堆向量库要更清楚。
本篇目标
这一篇要做的事情有三个:
- 读取 Hugo 博客里的 Markdown 文章。
- 根据用户问题检索相关片段。
- 把检索结果放进 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", "。", ",", " ", ""],
)
这里的参数先不追求最优,只要记住两点:
chunk_size控制每个片段大概多长。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:
- 不依赖额外服务。
- 逻辑透明,容易调试。
- 可以清楚看到“检索结果”对最终回答的影响。
组装 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:
...
可以看到,这里已经不是单纯问模型了,而是先从本地博客里找到了相关文章,再基于文章内容回答。
需要注意的问题
这一版实现很简单,也有明显限制:
- 关键词检索对表达方式比较敏感。
- 如果用户问题和文章用词不一致,可能检索不到。
- 中文分词只是简单切片,不适合复杂搜索场景。
- 没有保存索引,每次运行都会重新读取文章。
这些问题后面几篇会逐步处理:
- 第五篇引入向量检索。
- 第六篇把索引持久化。
- 第七篇专门做检索质量调优。
小结
这一篇完成了最小 RAG 流程。
虽然检索方式只是关键词匹配,但它已经具备 RAG 的核心结构:
检索资料 -> 构造上下文 -> 约束模型基于上下文回答
先把这条链路跑通,后面再替换检索方式、持久化索引、接入 Agent,都会比较自然。
参考
code/langchain-demo/chapter04- LangChain
Document - LangChain
RecursiveCharacterTextSplitter - LangChain
ChatPromptTemplate
原创文章,转载请注明来源: LangChain 入门学习第四篇-基于本地 Markdown 的 RAG 问答
相关文章
- LangChain 入门学习第三篇-工具调用和 Agent
- LangChain 入门学习第二篇-PromptTemplate 和结构化输出
- LangChain 入门学习第一篇-环境准备和模型调用
- 使用 Cursor 进行 AI 编程的年度总结
- 真的,AI 可能就是新时代的信息差
- 充值 Cursor 之后,工作有了哪些变化?🤔
- 个人'蒸馏'大模型能做哪些有意思的事情
- DeepSeek 大模型在 Mac 上的部署和运行
- 博客图床迁移记
- Python 函数式编程