背景
有一些文档集合,我们需要基于LLM开发出来一个应用,能够根据我们的文档资料回答相应的问题。从技术角度来看是将LLM与外部的数据进行结合。利用LLM的推理能力和我们自己的数据,打造垂直领域的专业性。
关键问题(key isssue)
大语言模型(LLM)有固定的上下文窗口长度(例如 4K、128K token)。每次调用 API 都是无状态的,模型不会自动记住之前的对话,而我们现代开发的Agentic App看起来有记忆是因为将历史消息拼接到新的请求中。
如果我们在上下文中塞入大量文本资料,会带来两个问题:
- 成本高——输入 token 按量计费,大量文本会“燃烧 token”;
- 技术限制——超过模型上下文窗口大小的内容会被截断或直接报错。
用户查询或者问一个问题,不可能需要我们的所有文档。我们只需要将相关的文档片段,加入到上下文,发送给LLM即可。嵌入模型(embedding model),向量存储(vector store),是用武之地,
RAG
RAG = 检索 (Retrieval) + 增强 (Augmented) + 生成 (Generation)
它是给大模型(LLM)装上的一个 "外挂知识库 U 盘"
从技术实现的重心来看,RAG 的核心挑战和工程复杂度,几乎全部集中在"检索"这一步——也就是 Embedding 模型和向量存储的设计上。
| 步骤 | 叫什么(术语) | 你之前拆解过的零件 | 它在干嘛(大白话) |
|---|---|---|---|
| 第 1 步 | Retrieval 检索 | 向量存储 + Embedding 模型 | 翻书。把你问的人话变成坐标,去库里找最像的那页书。 |
| 第 2 步 | Augmented 增强 | Prompt 组装 | 贴便签。把翻到的那页书内容,贴在你的问题上面,告诉 LLM:"喂,看这里!" |
| 第 3 步 | Generation 生成 | LLM (如 gpt-3.5-turbo) | 写回答。看着便签上的内容,组织语言回答你。 |
Embedding
文本向量化,计算文本的相似度,从而找出相关的文本
通过Embedding 模型进行向量化
from langchain.embeddings import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()
embed = embeddings.embed_query("我的名字叫 Pkmer")
print(len(embed)) # 1536
print(embed[:5]) # [-0.021964654326438904, 0.006758837960660458, -0.01824948936700821, -0.03923514857888222, -0.014007173478603363]
向量数据库Vector DataBase
向量数据库:
- 我们将我们的庞大的资料文档,切成小块的文本块(chunk),向量化之后,存储到向量数据库中
- 我们无法将整个庞大的文档资料传给LLM,但是可以利用这些小块文本,将最相关的小块文本,添加到上下文中,传输给LLM
这里使用内存的向量数据库来进行模拟
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import DocArrayInMemorySearch
embeddings = OpenAIEmbeddings()
db = DocArrayInMemorySearch.from_documents(
docs,
embeddings
)
当用户发来一段话的时候,我们想将用户发送的话,进行Embedding向量化。然后与向量库中的所有向量进行比较,找出top-N最相关的文本。
query = "Please suggest a shirt with sunblocking"
# 从向量数据库中检索出相似的文档
docs = db.similarity_search(query)
接着将找到的文本,添加到提示词中,让LLM给我们进行推理。
response = llm.predict(f"Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer \n\n context: \n{qdocs} \n\n Question: Please list all your \
shirts with sun protection in a table in markdown and summarize each one.")
重要
给 LLM 的数据,不是越多越好,而是越精炼、越结构化越好。
| 错误思维 | 正确思维 |
|---|---|
| “把原始数据全部塞进去,LLM 自己会理解” | “我要帮 LLM 预处理,提取最关键的信息” |
| “数据越多,信息越全” | “数据越精炼,LLM 越准确” |
| “反正 LLM 很聪明” | “LLM 需要清晰的结构和边界” |
如发送给LLM的相关数据:
❌️:数据格式混乱,会导致LLM解析失败
下面是我自己手动配装的
': 618\nname: Men's Tropical Plaid Short-Sleeve Shirt\ndescription: Our lightest hot-weather shirt is rated UPF 50+ for superior protection from the sun's UV rays. With a traditional fit that is relaxed through the chest, sleeve, and waist, this fabric is made of 100% polyester and is wrinkle-resistant. With front and back cape venting that lets in cool breezes and two front bellows pockets, this shirt is imported and provides the highest rated sun protection possible. \n\nSun Protection That Won't Wear Off. Our high-performance fabric provides SPF 50+ sun protection, blocking 98% of the sun's harmful rays.: 374\nname: Men's Plaid Tropic Shirt, Short-Sleeve\ndescription: Our Ultracomfortable sun protection is rated to UPF 50+, helping you stay cool and dry. Originally designed for fishing, this lightest hot-weather shirt offers UPF 50+ coverage and is great for extended travel. SunSmart technology blocks 98% of the sun's harmful UV rays, while the high-performance fabric is wrinkle-free and quickly evaporates perspiration. Made with 52% polyester and 48% nylon, this shirt is machine washable and dryable. Additional features include front and back cape venting, two front bellows pockets and an imported design. With UPF 50+ coverage, you can limit sun exposure and feel secure with the highest rated sun protection available.: 535\nname: Men's TropicVibe Shirt, Short-Sleeve\ndescription: This Men’s sun-protection shirt with built-in UPF 50+ has the lightweight feel you want and the coverage you need when the air is hot and the UV rays are strong. Size & Fit: Traditional Fit: Relaxed through the chest, sleeve and waist. Fabric & Care: Shell: 71% Nylon, 29% Polyester. Lining: 100% Polyester knit mesh. UPF 50+ rated – the highest rated sun protection possible. Machine wash and dry. Additional Features: Wrinkle resistant. Front and back cape venting lets in cool breezes. Two front bellows pockets. Imported.\n\nSun Protection That Won't Wear Off: Our high-performance fabric provides SPF 50+ sun protection, blocking 98% of the sun's harmful rays.: 255\nname: Sun Shield Shirt by\ndescription: "Block the sun, not the fun – our high-performance sun shirt is guaranteed to protect from harmful UV rays. \n\nSize & Fit: Slightly Fitted: Softly shapes the body. Falls at hip.\n\nFabric & Care: 78% nylon, 22% Lycra Xtra Life fiber. UPF 50+ rated – the highest rated sun protection possible. Handwash, line dry.\n\nAdditional Features: Wicks moisture for quick-drying comfort. Fits comfortably over your favorite swimsuit. Abrasion resistant for season after season of wear. Imported.\n\nSun Protection That Won't Wear Off\nOur high-performance fabric provides SPF 50+ sun protection, blocking 98% of the sun's harmful rays. This fabric is recommended by The Skin Cancer Foundation as an effective UV protectant.'
✅️:给 LLM 的提示词,越结构化越好
Langchain内部的提示优化结构大概如下:
Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.
Context:
{context}
Question: {question}
Helpful Answer:
代码实践
手动详细步骤
这里外部文档我们用csv文档,里面是户外服装/鞋类产品的商品目录数据
from langchain.document_loaders import CSVLoader
loader = CSVLoader(file_path=file)
docs = loader.load()
docs[0]
其中一条文档
| 字段 | 内容 |
|---|---|
| name | Women's Campside Oxfords |
| description | This ultracomfortable lace-to-toe Oxford boasts a super-soft canvas, thick cushioning, and quality construction for a broken-in feel from the first time you put them on. |
| Size & Fit | Order regular shoe size. For half sizes not offered, order up to next whole size. |
| Specs | Approx. weight: 1 lb.1 oz. per pair. |
| Construction | Soft canvas material for a broken-in feel and look. Comfortable EVA innersole with Cleansport NXT® antimicrobial odor control. Vintage hunt, fish and camping motif on innersole. Moderate arch contour of innersole. EVA foam midsole for cushioning and support. Chain-tread-inspired molded rubber outsole with modified chain-tread pattern. Imported. |
| Questions? | Please contact us for any inquiries. |
from langchain.chat_models import ChatOpenAI
from langchain.embeddings import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()
llm = ChatOpenAI()
db = DocArrayInMemorySearch.from_documents(
docs,
embeddings
)
query = "Please suggest a shirt with sunblocking"
docs = db.similarity_search(query)
# 整理组装准备发送给llm
qdocs = "\n\n".join([docs[i].page_content for i in range(len(docs))])
# 📚注意这里的提示词
response = llm.predict(f"Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer \n\n context: \n{qdocs} \n\n Question: \n Please list all your \
shirts with sun protection in a table in markdown and summarize each one.")
利用框架的VectorstoreIndexCreator
VectorstoreIndexCreator把一堆文档变成可以用自然语言问答的智能检索系统。
核心价值:让你用 3 行代码 完成原本需要 30 行代码 的 RAG 流程搭建。它把复杂的 RAG 流水线压缩成了一个黑盒函数
❌ 缺点生产环境不推荐 缺少细粒度控制
| 步骤 | 你最初的认知(理论流程) | 这段代码的实际操作(LangChain 封装后) |
|---|---|---|
| 1. 准备数据 | 手动切 Chunk,调用 API 算向量,存入数据库。 | index = VectorstoreIndexCreator(...).from_loaders([loader]) 这一行全干完了。 |
| 2. 查询向量 | 智能体把问题向量化,去库里找相似文本。 | response = index.query(...) 前半部分隐式执行。 |
| 3. 发给 LLM | 把查到的文本 + 问题拼成 Prompt 发给 LLM。 | response = index.query(..., llm = llm_replacement_model) 后半部分显式执行。 |
from langchain.llms import OpenAI
from langchain.document_loaders import CSVLoader
from langchain.vectorstores import DocArrayInMemorySearch
from langchain.indexes import VectorstoreIndexCreator
# 加载数据
file = 'OutdoorClothingCatalog_1000.csv'
loader = CSVLoader(file_path=file)
index = VectorstoreIndexCreator(
vectorstore_cls=DocArrayInMemorySearch
).from_loaders([loader])
query ="Please list all your shirts with sun protection \
in a table in markdown and summarize each one."
llm = OpenAI(temperature=0)
response = index.query(query,
llm = llm)
VectorstoreIndexCreator
框架封装背后,做的事情。
index = VectorstoreIndexCreator(
vectorstore_cls=DocArrayInMemorySearch
).from_loaders([loader])
| 步骤 | 做了什么 | 你原本需要写的代码 |
|---|---|---|
| 1. 加载文档 | 从 CSVLoader 读取文件 | loader.load() |
| 2. 分块 | 将长文档切分成小块 | text_splitter.split_documents() |
| 3. 生成向量 | 调用 Embeddings 模型 | embeddings.embed_documents() |
| 4. 存储向量 | 存入向量数据库 | vectorstore.from_documents() |
| 5. 创建检索器 | 包装成可查询的接口 | vectorstore.as_retriever() |
输入:原始文档(CSV、TXT、PDF 等)
↓
VectorstoreIndexCreator
↓
输出:一个可以 .query() 的索引对象
使用方式
# 创建索引(一次性工作)
index = VectorstoreIndexCreator(...).from_loaders([loader])
# 查询索引(核心功能)
response = index.query("请列出所有防晒衬衫")
总结图
┌─────────────────────────────────────────────────────────┐
│ VectorstoreIndexCreator │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 加载文档 │ → │ 分块切割 │ → │ 向量化 │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ ↓ ↓ ↓ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 存储向量 │ → │ 创建检索 │ → │ QA 链 │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────┘
↓
index.query("问题")
↓
返回答案
VectorstoreIndexCreator实际上做的事情,大概如下
# 以下代码等价于 VectorstoreIndexCreator
from langchain.document_loaders import CSVLoader
from langchain.text_splitter import CharacterTextSplitter
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import DocArrayInMemorySearch
from langchain.chains import RetrievalQA
from langchain.llms import OpenAI
# 1. 加载
loader = CSVLoader(file_path='file.csv')
documents = loader.load()
# 2. 分块(可选)
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
docs = text_splitter.split_documents(documents)
# 3. 生成向量并存储
embeddings = OpenAIEmbeddings()
vectorstore = DocArrayInMemorySearch.from_documents(docs, embeddings)
# 4. 创建检索器
retriever = vectorstore.as_retriever()
# 5. 创建 QA 链
llm = OpenAI()
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=retriever
)
# 使用
response = qa_chain.run("你的问题")
RetrievalQA
| 组件 | 做什么 | 输入 | 输出 |
|---|---|---|---|
vectorstore.similarity_search() | 只检索 | "防晒衬衫" | 相关文档列表 |
LLM.predict() | 只问答 | "请列出防晒衬衫" | 答案 |
RetrievalQA | 检索 + 问答 | "请列出防晒衬衫" | 基于文档的答案 |
from langchain.chains import RetrievalQA
# 假设你已经有了 vectorstore
vectorstore = DocArrayInMemorySearch.from_documents(docs, embeddings)
# 创建 RetrievalQA 链
qa_chain = RetrievalQA.from_chain_type(
llm=OpenAI(),
retriever=vectorstore.as_retriever(), # 检索器
chain_type="stuff" # 如何处理文档
)
# 一键问答(内部自动完成检索+生成)
answer = qa_chain.run("请列出所有防晒衬衫")
其他扫盲
Chunk到底是怎么切的
LangChain默认的切分器叫 RecursiveCharacterTextSplitter(递归字符切分器)。
它既不是死板地按100个字硬切,它的切分,优先级。
- 第一优先级:
\n\n(换两行,代表段落) - 第二优先级:
\n(换一行,代表句子结尾) - 第三优先级:
。(中文句号) - 第四优先级:
!? - 第五优先级:
;(分号) - 第六优先级:
,(逗号) - 最后通牒:空格 / 单个字符
-
第一步(看段落) :系统先看这一章有没有 段落分隔符(
\n\n)。如果有,它就在段落之间下刀。这是最安全的切法,语义几乎完整。 -
第二步(看句子) :如果某个段落太长了(超过你设定的上限,比如1000字),段落里切不开了。它才会降级,去看这个段落里的 句号(
。)。它在句号处下刀。 -
第三步(极端情况) :如果你写了一段2000字的话,中间一个句号都没有(比如意识流小说)。
- 这时候,系统找不到句号,没办法,为了不超过LLM的最大长度限制,它只能降级到逗号下刀。
- 如果连逗号都没有,最终手段:在第1000个字的位置强行切断。
重叠切分
一句话被从中间切开,比如这种极端情况(在第1000个字强行切断),导致前半句是 “贾宝玉看着林黛玉说:你...”,后半句是 “...这个妹妹我曾见过的。”。
这样上下文语义就不正确了。这是RAG里最重要的防呆设计。
LangChain的重叠机制会这么做:
- Chunk 1:
...贾宝玉看着林黛玉说:你...(取前1000字) - Chunk 2:
...宝玉看着林黛玉说:你这个妹妹我曾见过的。(从第800字开始取,重叠200字)
结论
就算在第1000个字物理位置被砍断了,由于 Chunk 2 包含了前面的200个字作为缓冲,那句“你这个妹妹我曾见过的”在 Chunk 2 里是完整的。
DocArrayInMemorySearch内存向量存储
它的内存结构图大概是这样,维护了两个数组结构
vectors = [
[0.12, -0.33, 0.78, ...], ← 索引 0 对应第1条
[0.91, 0.05, -0.44, ...], ← 索引 1 对应第2条
[0.23, 0.77, -0.12, ...] ← 索引 2 对应第3条
]
documents = [
Document(page_content="苹果公司的总部在加州", metadata={...}),
Document(page_content="苹果是一种很好吃的水果", metadata={...}),
Document(page_content="巴黎是法国的首都", metadata={...})
]
内存完全体结构
├── 数据区
│ ├── vectors: [向量0, 向量1, ..., 向量N]
│ └── documents: [文本0, 文本1, ..., 文本N]
│
└── 索引区 (加速用)
└── HNSW 图:一堆指针,告诉程序“从这跳到那更快”