引言
很多独立开发者想给产品接上”问答自己文档”的能力,一上来就被一堆概念劝退。其实最小可用的RAG没有你想的那么复杂:把文档切碎、向量化、存进索引,然后查的时候先向量检索再让LLM生成答案。本文用纯Python从零搭一个能跑的最小RAG,全程可复现。
为什么你需要RAG,而不是直接把文档塞给LLM
直接把几千页文档拼进提示词里,token成本和超时都扛不住。RAG的思路是”先缩小范围再回答”:只在检索到的最相关片段上生成答案。这样既省token,又降低幻觉——答案有原文出处可查。根据LangChain官方文档,检索增强生成是目前落地最广的企业级LLM应用范式之一。
RAG的三个核心模块
拆开看就三件事:文档切分与向量化、向量索引、检索+生成。任何一个环节做得糙,整体效果都打折。下面按顺序做。
第一步:文档切分与向量化
切分决定了检索的单位。我用定长加overlap的方式:每块500字符、重叠80字符,避免句子被拦腰截断导致语义丢失。向量化这里直接用OpenAI官方Embedding接口(text-embedding-3-small,官方定价每百万token约$0.02),够用且便宜。
from openai import OpenAI
client = OpenAI()
def split_text(text, chunk=500, overlap=80):
return [text[i:i+chunk] for i in range(0, len(text), chunk-overlap)]
def embed(texts):
resp = client.embeddings.create(model="text-embedding-3-small", input=texts)
return [d.embedding for d in resp.data]
第二步:建索引与向量存储
小规模场景我直接上一个简单的向量库就够了,甚至可以先用内存里的列表暴力扫描(几千条完全没问题)。真要上规模再换pgvector这类带HNSW索引的方案。这里演示一个最小可运行的NaiveIndex,方便你理解检索原理。
import numpy as np
class NaiveIndex:
def __init__(self):
self.chunks, self.vecs = [], []
def add(self, chunks, vecs):
self.chunks += chunks
self.vecs += vecs
def search(self, q_vec, top_k=3):
scores = np.dot(self.vecs, q_vec)
idx = np.argsort(scores)[::-1][:top_k]
return [self.chunks[i] for i in idx]
用余弦相似度(归一化后等价于点积)找出最相关的3个片段,这就是检索层。
第三步:检索 + 生成
检索到相关片段后,把内容放进system prompt,让模型只基于这些片段回答,并明说”不知道就说不知道”。这能显著压低幻觉概率。
def ask(question, index, top_k=3):
q_vec = embed([question])[0]
ctx = "\n---\n".join(index.search(q_vec, top_k))
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system",
"content": "只根据提供的资料回答,资料里没有的明说不知道。"},
{"role": "user",
"content": f"资料:\n{ctx}\n\n问题:{question}"},
])
return resp.choices[0].message.content
到这里,一个能回答你文档问题的最小RAG就成立了。
上线前一定要做的两件优化
第一,切分策略值得调:结构化文档按标题切比定长切更准。第二,别把所有检索结果都塞进去,top_k控制在3-5,多了噪音反而拉低答案质量。想查更细,参考Haystack或LlamaIndex的官方最佳实践文档,很多坑前人已经踩过。
小结
RAG没那么玄,核心就”切、存、查、答”四步。先跑通最小闭环,再逐步优化切分和索引,比一开始就上重型框架务实得多。
