手把手复现:用LangChain搭个「能摸得着」的本地知识库(PDF+Markdown全支持)
让解析可见直接看到切块效果,不用猜PyMuPDF到底抠出了啥;让向量可验curl调用Ollama API,亲眼确认“Q3”被转成了,不是黑盒;让检索可控返回原文片段,能人工判断“为啥这个chunk没被召回”。真正的工程化,不是堆组件,是让每个环节都经得起质问。下一章(第4篇),我们就把这套能摸得着的系统,打包成FastAPI服务——解决并发请求排队、模型热加载、错误熔断这些生产级问题。字数:152
手把手复现:用LangChain搭个「能摸得着」的本地知识库(PDF+Markdown全支持)
——第3篇|LangChain学习实录系列(共8篇)
前两篇我们跑通了第一个聊天机器人、也拆解了Chain/Prompt/LLM怎么咬合发力。但真正写业务代码时,我卡在了会议室门口:老板甩来3份PDF会议纪要、2个Markdown技术方案,说“查下Q3交付节点在哪”。我盯着
llm.invoke("Q3交付节点?")发了5分钟呆——这根本不是单轮对话,是翻箱倒柜找答案。
本文不讲RAG定义,不画架构图,只给你一套能立刻粘贴、能打断点、能对着日志骂娘的本地问答系统。所有操作在 M2 Mac 上实测(Linux同理),Windows用户只需把
ollama run llama3改成ollama run llama3:latest。全程离线,不碰API密钥,不传一比特数据到云端。
一、问题背景:为什么我宁愿重装Ollama六次,也不用云RAG?
第二篇做合同解析时,我靠PromptTemplate硬套字段很爽。但上周五下午三点,产品同事微信弹窗:“上个月评审会说的灰度发布节奏,原文在哪?”——我打开飞书文档搜“灰度”,返回17条;切到Confluence搜,又跳4个无关页面。最后我手动翻了3份PDF,花了11分钟。
主流云RAG在这类场景掉链子:
-
数据不能动:金融客户合同PDF连公司内网都不能出,更别说上传到某向量云;
-
结果飘忽:同一段话,上午嵌入相似度0.82,下午变成0.61,检索结果差俩文档——我调试时盯着日志看了两小时,差点砸了键盘;
-
黑盒太深:想看“为什么没召回这句话”,结果发现Embedding API根本不返回原始向量,连debug都无从下手。
所以这篇的目标特别土:让每一步都露着骨头——文件怎么解析的?chunk长啥样?向量存哪了?检索时到底比对了哪几个chunk? 全部print出来,全部能断点。
二、原理分析:本地RAG不是换模型,是把流水线搬进自己家
我把整个流程摊开拍在桌上,像修自行车一样拧螺丝:
PDF/MD文件 → 解析器(抠出纯文本)→ 清洗(删页眉页脚)→ 切块(不切断句子)→ 向量化(Ollama算)→ 存Chroma(本地文件夹)→ 检索(返回原文片段)→ 拼Prompt(喂给Llama3)→ 输出答案
关键细节全是血泪:
-
PDF解析选PyMuPDF:
pypdf遇到表格直接变乱码,而PyMuPDF能保留“| 里程碑 | 时间 |”这种结构,我第一次看到正确提取的表格时,默默给自己续了杯咖啡; -
Markdown用unstructured:它自动识别
## 里程碑是二级标题,- Q3联调是列表项,比正则匹配靠谱太多; -
切块用RecursiveCharacterTextSplitter:先按
\n\n切(段落级),再按\n(行级),实在不行才按空格——避免把“Q3完成联调测试”硬切成“Q3完成”和“联调测试”; -
Embedding必须用Ollama的nomic-embed-text:HuggingFace加载本地模型要占3GB显存,M2芯片直接卡死;而
nomic-embed-text在Ollama里启动只要2秒,单次嵌入<300ms,我边泡面边等它跑完。
当时看到
curl http://localhost:11434/api/embeddings返回一串浮点数,而不是“success: true”,我才真正信了——这玩意儿真在本地算向量。
三、落地步骤:手把手,从建文件夹开始
步骤1:装Ollama,拉模型,亲手敲命令验证
别信“安装完成”,必须亲眼看见模型在列表里:
# macOS一键安装(Linux换curl地址)
curl -fsSL https://ollama.com/install.sh | sh
# 拉两个必需模型(国内用户建议先配置镜像源)
ollama pull nomic-embed-text
ollama pull llama3
# 启动服务并验证——重点看返回JSON里有没有这两个model.name
ollama serve &
curl http://localhost:11434/api/tags | jq '.models[].name'
# ✅ 正确输出应包含 "nomic-embed-text" 和 "llama3"
步骤2:准备最简测试文档(别跳!)
新建./docs/,放1个PDF和1个MD——别用你的真实合同,先确保管道通:
mkdir -p ./docs
# 写个Markdown(复制粘贴即可)
echo "# 项目技术方案" > ./docs/solution.md
echo "## 里程碑" >> ./docs/solution.md
echo "- Q3完成联调测试" >> ./docs/solution.md
echo "- Q4上线灰度发布" >> ./docs/solution.md
# PDF生成(若无wkhtmltopdf,直接下载示例PDF或跳过,只用MD也行)
# wget -O ./docs/solution.pdf https://github.com/langchain-study-series/raw/part3/test.pdf
步骤3:核心代码——每行都带print,错在哪一眼看见
# rag_local.py(保存后直接python运行)
from langchain_community.document_loaders import PyMuPDFLoader, UnstructuredMarkdownLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_community.llms import Ollama
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
# ① 加载文档——这里会报错!如果PDF是扫描件,PyMuPDF返回空列表
loaders = [
UnstructuredMarkdownLoader("./docs/solution.md") # 先注释PDF,确保MD能跑通
]
docs = []
for loader in loaders:
docs.extend(loader.load())
print(f"[✅] 加载文档数: {len(docs)}") # 应输出1
# ② 切块——打印第一个chunk看看是不是人话
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", " ", ""]
)
splits = text_splitter.split_documents(docs)
print(f"[✅] 切分后chunk数: {len(splits)}") # 应输出2~3
print(f"[🔍] 第一个chunk预览: '{splits[0].page_content[:50]}...'")
# ③ 向量化——这步最慢,但必须等完才能继续
embeddings = OllamaEmbeddings(model="nomic-embed-text")
vectorstore = Chroma.from_documents(
documents=splits,
embedding=embeddings,
persist_directory="./chroma_db"
)
print(f"[✅] 向量库已存至 ./chroma_db")
# ④ 检索测试——别急着问问题,先看它能捞出啥
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
test_docs = retriever.invoke("Q3")
print(f"[🔍] 检索到{len(test_docs)}个相关chunk:")
for i, d in enumerate(test_docs):
print(f" [{i+1}] {d.page_content[:40]}...")
# ⑤ 最后一步:组装问答链
llm = Ollama(model="llama3", temperature=0.1)
prompt = ChatPromptTemplate.from_messages([
("system", "你只回答技术文档中的事实,不编造。没找到就答'未找到相关信息'。"),
("human", "上下文:{context}\n问题:{question}")
])
rag_chain = (
{"context": retriever | (lambda docs: "\n\n".join([d.page_content for d in docs])),
"question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# 🔥 终极验证
result = rag_chain.invoke("Q3的交付物是什么?")
print(f"[💡 ANSWER] {result}")
运行:python rag_local.py
✅ 正确输出应含[💡 ANSWER] Q3的交付物是联调测试报告(或语义一致内容)
四、排错指南:那些让我凌晨三点改代码的坑
| 现象 | 我当时的反应 | 解决方案 |
|------|--------------|----------|
| ModuleNotFoundError: No module named 'unstructured' | “又来?!”(摔鼠标) | brew install libmagic && pip install "unstructured[md]" |
| retriever.invoke("Q3")返回空列表 | 对着终端发呆20分钟 | 先curl http://localhost:11434/api/embeddings -d '{"model":"nomic-embed-text","input":"Q3"}',没返回向量说明Ollama没起来 |
| PDF解析后splits为空 | “这PDF莫非是张图片?” | pdfinfo ./docs/solution.pdf,看Pages:是否为数字;若是0,就是扫描件,换pdf2image+OCR方案 |
| Llama3输出乱码或截断 | 抓狂敲键盘 | 在Ollama()里加参数:Ollama(model="llama3", num_ctx=8192) |
五、总结:本地RAG的底气,来自每一行可打印的日志
这篇文章没讲高大上的概念,只干了三件事:
-
让解析可见:
print(splits[0].page_content)直接看到切块效果,不用猜PyMuPDF到底抠出了啥; -
让向量可验:
curl调用Ollama API,亲眼确认“Q3”被转成了[-0.12, 0.88, ...],不是黑盒; -
让检索可控:
retriever.invoke("Q3")返回原文片段,能人工判断“为啥这个chunk没被召回”。
真正的工程化,不是堆组件,是让每个环节都经得起质问。下一章(第4篇),我们就把这套能摸得着的系统,打包成FastAPI服务——解决并发请求排队、模型热加载、错误熔断这些生产级问题。
附:完整代码+测试文档+Dockerfile已开源:github.com/langchain-study-series/part3
字数:1527
更多推荐


所有评论(0)