手把手复现:用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解析选PyMuPDFpypdf遇到表格直接变乱码,而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的底气,来自每一行可打印的日志

这篇文章没讲高大上的概念,只干了三件事:

  1. 让解析可见print(splits[0].page_content)直接看到切块效果,不用猜PyMuPDF到底抠出了啥;

  2. 让向量可验curl调用Ollama API,亲眼确认“Q3”被转成了[-0.12, 0.88, ...],不是黑盒;

  3. 让检索可控retriever.invoke("Q3")返回原文片段,能人工判断“为啥这个chunk没被召回”。

真正的工程化,不是堆组件,是让每个环节都经得起质问。下一章(第4篇),我们就把这套能摸得着的系统,打包成FastAPI服务——解决并发请求排队、模型热加载、错误熔断这些生产级问题。

附:完整代码+测试文档+Dockerfile已开源:github.com/langchain-study-series/part3

字数:1527

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐