SmartWriter v0.3:带研究的写作 — 文档加载与基础 RAG 检索链实战
SmartWriter v0.3:带研究的写作 — 文档加载与基础 RAG 检索链实战
前言
- 核心痛点:v0.2 的 SmartWriter 已经能生成结构化的文章大纲和正文,但它有一个致命缺陷——所有知识完全来自 LLM 的训练数据。当你需要写一篇关于「2026 年最新 React Server Components 最佳实践」的文章时,模型训练数据截止日期之前的旧知识无法满足需求,而模型幻觉又可能编造出不存在的 API 和概念。本文解决的核心问题:如何让 SmartWriter 具备研究能力,从外部文档中检索相关知识,写出有据可依的文章。
- 前置知识:需要掌握 LangChain 基础(ChatModel、PromptTemplate、Chain)、Python 基础编程能力、理解 LLM 的基本工作原理。如果你已经完成了 v0.1 和 v0.2 的实战,可以直接开始。
- 系列阶段:入门篇第 3 篇(共 4 篇入门,全系列共 24 篇)
- 收获能力:读完本文你将掌握 RAG(检索增强生成)的完整底层原理、文档加载→文本分割→向量嵌入→向量存储→相似检索→上下文注入全链路实战能力,能够独立搭建一个可用的带研究功能的写作系统。
目录
- 技术背景与演进逻辑
- 核心原理深度解析
- 文档加载器体系
- 文本分割策略
- Embedding 模型与向量空间
- VectorStore 向量数据库实战
- 基础 RAG Chain 完整构建
- 检索结果注入与 Prompt 策略
- 技术优缺点与适用场景
- 实战落地:SmartWriter v0.3 完整实现
- 全文总结
- 本期专栏更新说明
- 参考资料
技术背景与演进逻辑
LLM 的两大原生局限
要理解 RAG 为什么诞生,需要先理解大语言模型的两个根本性限制。
第一,知识截止日期(Knowledge Cutoff)。任何 LLM 的训练数据都有截止时间。即使是最新发布的模型,其训练数据也至少落后于现实世界几个月。当你问模型「最新的 Python 版本引入了什么特性」,它可能回答 Python 3.12 的内容,而实际上 Python 3.14 甚至 3.15 已经发布。这种信息滞后在生产场景中是不可接受的——一份技术文档如果引用已废弃的 API,不仅无益,反而有害。
第二,有限上下文窗口(Finite Context Window)。虽然现代模型已经支持 128K 甚至 1M Token 的上下文,但这并不意味着你可以直接把整个知识库塞进 Prompt。且不说成本问题——每次请求都把整本技术手册附在 Prompt 里,Token 消耗会高到无法承受——更重要的是,过长的上下文会导致模型注意力稀释(Attention Dilution),对中间段落的信息提取能力显著下降。这就是著名的「Lost in the Middle」现象。
从 Fine-tuning 到 RAG:知识注入的范式转移
在 RAG 流行之前,让模型习得特定领域知识的首选方案是微调(Fine-tuning):
传统方案:Fine-tuning ├── 收集领域数据 → 标注 → 微调训练 → 部署模型 │ 优点:推理时无额外延迟 │ 缺点: │ ├── 训练成本高(GPU 算力、时间) │ ├── 知识更新需要重新训练 │ ├── 无法追溯到原始文档来源 │ └── 可能导致灾难性遗忘RAG 用完全不同的思路解决同一问题:
RAG 方案 ├── 检索阶段(运行时) │ ├── 用户提出问题 │ ├── 从知识库中检索相关文档片段 │ └── 将检索结果注入 Prompt ├── 生成阶段 │ └── LLM 基于检索到的上下文生成回答 │ 优点: │ ├── 知识可随时更新(只需更新向量库) │ ├── 可追溯来源(每个回答都能引用原文) │ ├── 无需训练(零微调成本) │ └── 避免灾难性遗忘SmartWriter 为什么需要研究能力
回到我们的 SmartWriter 产品。v0.2 的写作流程是:
用户输入主题 → Prompt 模板 → LLM 直接生成 → 输出解析 → 文章这个流程的瓶颈显而易见:LLM 只能基于训练数据「回忆」信息,无法「查找」新信息。SmartWriter v0.3 引入研究能力后,流程变为:
用户输入主题 → 检索知识库 → 获取相关文档 → 注入 Prompt → LLM 基于素材写作 → 文章一个具体的例子:用户要求 SmartWriter 写一篇关于「2026 年 AI Agent 协议标准化进展」的文章。v0.2 只能依靠模型训练数据中的知识(可能是 2024 年甚至更早的信息),而 v0.3 会先从本地知识库(已导入的 arXiv 论文、技术博客、官方文档)中检索相关资料,再基于最新素材组织文章。这样的文章不仅时效性强,而且有据可查。
RAG 技术演进简史
| 阶段 | 时间 | 核心技术 | 代表实现 |
|---|---|---|---|
| 萌芽期 | 2020-2021 | Dense Passage Retrieval (DPR)、朴素检索-阅读架构 | RAG 原始论文(Lewis et al.) |
| 工程化 | 2022-2023 | 文档分块策略、多种向量库、ReRank 重排序 | LangChain、LlamaIndex |
| 成熟期 | 2024-2025 | 多路混合检索、Self-RAG、CRAG 自纠错 | LangChain/LlamaIndex 生态 |
| 智能体化 | 2025-2026 | Agentic RAG、多步推理检索、GraphRAG | LangGraph Agent + MCP 工具 |
本文聚焦于工程化阶段的核心技术栈——也就是构建一个可靠、可维护的基础 RAG 系统所需的所有组件。这是后续进阶篇中多跳检索、Agentic RAG、GraphRAG 等高级技术的基石。
核心原理深度解析
RAG 的数学本质:从概率视角理解检索增强
从概率论的角度,标准 LLM 生成是在最大化条件概率:
P ( m a t h r m a n s w e r m i d m a t h r m q u e r y ) P(mathrm{answer} mid mathrm{query})P(mathrmanswermidmathrmquery)
而 RAG 引入了一个隐变量——检索到的文档集合 D——将生成过程分解为两个阶段:
P ( m a t h r m a n s w e r m i d m a t h r m q u e r y ) = s u m D P ( m a t h r m a n s w e r m i d m a t h r m q u e r y , D ) c d o t P ( D m i d m a t h r m q u e r y ) P(mathrm{answer} mid mathrm{query}) = sum_{D} P(mathrm{answer} mid mathrm{query}, D) cdot P(D mid mathrm{query})P(mathrmanswermidmathrmquery)=sumDP(mathrmanswermidmathrmquery,D)cdotP(Dmidmathrmquery)
其中:
P(D | query)是检索阶段:给定查询,找到最相关的文档集合P(answer | query, D)是生成阶段:给定查询和相关文档,生成答案
在实践中,我们不计算所有可能的 D 的求和,而是用近似方法选择 top-K 最相关的文档:
D ∗ = a r g m a x D m a t h r m s i m ( m a t h r m e m b e d ( q ) , m a t h r m e m b e d ( d ) ) D^* = argmax_{D} mathrm{sim}(mathrm{embed}(q), mathrm{embed}(d))D∗=argmaxDmathrmsim(mathrmembed(q),mathrmembed(d))
然后条件化生成:
m a t h r m a n s w e r = m a t h r m L L M ( m a t h r m q u e r y , D ∗ ) mathrm{answer} = mathrm{LLM}(mathrm{query}, D^*)mathrmanswer=mathrmLLM(mathrmquery,D∗)
这个公式虽然简化,但揭示了 RAG 的核心设计空间:
- 检索质量决定了 P(D|query) 的准确度——如果检索不到相关文档,再好的 LLM 也无能为力
- 上下文融合决定了 P(answer|query, D) 的质量——如何将检索结果有效地注入 Prompt
RAG 五阶段流水线
RAG 的工程实现包含五个独立但紧密耦合的阶段,每个阶段都有可替换的组件:
文档加载 → 文本分割 → 向量嵌入 → 向量存储 → 相似检索 → 上下文注入 → 生成 ↑ │ └──────────────── 离线索引阶段 ──────────────────────┘ │ 在线查询阶段 ──────────────────────┘离线索引阶段(做一次,定期更新):
- 文档加载:从各种来源读取原始文档
- 文本分割:将长文档切分为适当大小的 Chunk
- 向量嵌入:将每个 Chunk 转换为向量表示
- 向量存储:将向量持久化到向量数据库
在线查询阶段(每次请求执行):
5. 相似检索:将用户查询向量化,检索最相似的 Chunk
6. 上下文注入:将检索结果填入 Prompt 模板
7. 生成:LLM 基于增强后的 Prompt 生成输出
向量嵌入与语义相似度
向量嵌入是 RAG 能够工作的数学基础。嵌入模型将文本映射到一个高维向量空间(通常是 768、1024 或 1536 维),在这个空间中,语义相似的文本距离更近。
两个文本的相似度通常用余弦相似度度量:
m a t h r m c o s s i m ( a , b ) = d f r a c a c d o t b ∣ ∣ a ∣ ∣ c d o t ∣ ∣ b ∣ ∣ = d f r a c s u m i = 1 n a i b i s q r t s u m i = 1 n a i 2 c d o t s q r t s u m i = 1 n b i 2 mathrm{cos_sim}(a, b) = dfrac{a cdot b}{||a|| cdot ||b||} = dfrac{sum_{i=1}^{n} a_i b_i}{sqrt{sum_{i=1}^{n} a_i^2} cdot sqrt{sum_{i=1}^{n} b_i^2}}mathrmcossim(a,b)=dfracacdotb∣∣a∣∣cdot∣∣b∣∣=dfracsumi=1naibisqrtsumi=1nai2cdotsqrtsumi=1nbi2
余弦相似度的取值范围是 [-1, 1],其中 1 表示完全相同方向(语义高度相似),0 表示正交(无关),-1 表示完全相反。在实际的嵌入模型中,由于向量各维度都是非负值,相似度一般在 [0, 1] 范围内。
检索质量的核心矛盾
RAG 系统面临一个核心矛盾:chunk_size 的 Goldilocks 问题。
- Chunk 太大(如 2000 Token):包含更多上下文,但检索精度下降——一个 Chunk 中可能包含多个主题,导致检索到的内容只有一小部分真正相关
- Chunk 太小(如 100 Token):检索更精准,但缺乏足够上下文——模型看到的内容太碎片化,无法理解完整语义
这个矛盾的解决方案不是单一的,而是一个组合策略:适中的 Chunk 大小 + 重叠窗口 + 重排序。我们在后面的文本分割策略中会详细展开。
文档加载器体系
LangChain Document 对象
在深入各种加载器之前,需要理解所有加载器共同的「输出协议」:Document对象。
fromlangchain_core.documentsimportDocument# Document 是最核心的数据抽象doc=Document(page_content="文档的正文内容...",metadata={"source":"data/report.pdf","page":3,"author":"Zhang Wei",})Document只有两个字段:
page_content(str):文档的文本内容,这是后续分割和嵌入的输入metadata(dict):文档的元信息,包括来源、页码、作者、日期等。Metadata 在检索结果的溯源和过滤中至关重要
这个设计的精髓在于统一接口。无论你的数据来自 PDF、网页、数据库还是 Slack 消息,最终都转换为统一的Document对象,后续的文本分割器、嵌入模型、向量库都不需要关心数据来源。
文档加载器全景图
LangChain 内置了 200+ 种文档加载器,覆盖了几乎所有常见的数据来源。对于 SmartWriter 写作助手来说,最核心的场景是:
SmartWriter 研究素材来源 ├── 本地文件 │ ├── TextLoader — 纯文本文件 (.txt) │ ├── PyPDFLoader — PDF 文件 (.pdf) │ ├── UnstructuredMarkdownLoader — Markdown 文件 (.md) │ └── Docx2txtLoader — Word 文档 (.docx) ├── 网页内容 │ ├── WebBaseLoader — 通用网页抓取 │ ├── RecursiveUrlLoader — 递归抓取网站 │ └── FireCrawlLoader — 专业网页抓取(处理 JS 渲染) ├── 批量加载 │ ├── DirectoryLoader — 加载整个目录 │ └── CSVLoader / JSONLoader — 结构化数据核心加载器实战
TextLoader — 最基础的加载器
fromlangchain_community.document_loadersimportTextLoader# 加载单个文本文件loader=TextLoader("research_notes.txt",encoding="utf-8")docs=loader.load