/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
Naive RAG:从零搭建最简单的检索增强生成
上一篇把 RAG 的三个阶段简单梳理了一遍:Naive RAG、Advanced RAG、Modular RAG。这篇先从最基础的 Naive RAG 开始,把它从头搭一遍
Naive RAG 的核心就三步:
Indexing → Retrieval → Generation
先把知识文档处理成向量库,再用用户问题去检索 Top-K,最后把检索结果拼进 Prompt,交给大模型生成答案
流程大概是这样:
离线阶段:Indexing
文档 → 加载 → 清洗 → 分块 → 向量化 → 存入向量数据库
在线阶段:Retrieval + Generation
用户问题 → 向量化 → 检索 Top-K → 拼接 Prompt → 大模型 → 答案
1. 加载知识文档
首先将知识文档加载到程序中
文档可能是 PDF、Word、Markdown、网页、数据库里的内容。先不管格式,统一读进来
documents = load_documents("knowledge/")
每个文档一般包含两部分:正文和元数据
{
"text": "文档正文…",
"metadata": {
"source": "xxx.pdf",
"page": 1,
"title": "第一章"
}
}
2. 清洗文档
文档加载进来后需要对其进行清洗
清洗的目的是为了去除页眉页脚、乱码、广告、重复内容、无用符号等无用信息。这些东西如果不清掉,后面向量化时会变成噪声,影响检索
for doc in documents:
doc.text = remove_header_footer(doc.text)
doc.text = remove_ads(doc.text)
doc.text = remove_duplicates(doc.text)
doc.text = remove_garbage_chars(doc.text)
doc.text = normalize_whitespace(doc.text)
注意不要清洗过度。标题、层级、关键语义要保留,不然检索时反而找不到
3. 分块
清洗完后再对文档进行分块
分块主要是为了提高检索精度,控制上下文长度,避免整篇文档向量化后语义被平均掉
如果一整篇文档只生成一个向量,那这个向量代表的是整篇文档的“平均语义”,检索时很难精准命中某一段。所以要把长文档切成小块
chunks = []
for doc in documents:
sub_chunks = split_text(
doc.text,
chunk_size=500,
chunk_overlap=50
)
for sub in sub_chunks:
chunks.append({
"text": sub,
"metadata": doc.metadata
})
chunk_size 控制每块多大,chunk_overlap 让相邻块之间有一点重叠,避免正好在边界处切断关键信息
分块的过程中给每个 chunk 带上元数据,比如来源、页码、标题,方便后续溯源和过滤
注意:在 Naive RAG 里,这些元数据主要用于溯源,不参与检索排序。一旦用元数据做过滤,就进入 Advanced RAG 了
4. 向量化
接下来使用 Embedding 模型进行向量化
Embedding 模型会把每个 chunk 的文本转成一个向量。这个向量可以理解为这段文本在语义空间里的坐标
for chunk in chunks:
chunk.vector = embedding_model.encode(chunk.text)
注意:入库和查询必须用同一个 Embedding 模型。不然向量空间对不上,相似度匹配会失效
5. 存入向量数据库
向量化完成就能存入向量数据库当中
常见的向量数据库有 FAISS、Chroma、Milvus、Qdrant、Weaviate 等
vector_db = VectorDB()
for chunk in chunks:
vector_db.insert(
vector=chunk.vector,
text=chunk.text,
metadata=chunk.metadata
)
vector_db.build_index()
最好再建个索引,比如 HNSW、IVF,加快后续检索。数据量小的时候感觉不出来,数据一多差别就大了
到这里,离线知识库就搭建完成了
6. 检索
接着就可以实现检索功能,看看搭建的离线知识库是否好用。
进行检索时需先对问题进行向量化,向量化后与知识库中的向量进行余弦相似度匹配。实际向量库一般用 ANN 近似最近邻来加速,不一定真的一条条算余弦
def retrieve(query, top_k=5):
query_vector = embedding_model.encode(query)
results = vector_db.search(
query_vector=query_vector,
top_k=top_k
)
return results
返回的结果大概长这样:
[
{"text": "片段1…", "score": 0.87, "metadata": {...}},
{"text": "片段2…", "score": 0.82, "metadata": {...}},
...
]
这一步可以先用几个问题测一下:Top-K 里能不能召回相关内容?如果召回不准,就要回头检查清洗、分块、Embedding 模型
到检索为止,它还是一个离线知识库,还不是 RAG
7. 生成
之后得到 Top-K 条数据,拼接到问题中,再将其一并交给大模型输出结果
def naive_rag(query, top_k=5):
results = retrieve(query, top_k)
context = "\\n\\n".join([r["text"] for r in results])
prompt = f"""
请根据以下资料回答问题。
资料:
{context}
问题:
{query}
回答:
"""
answer = llm.generate(prompt)
return answer
这一步就是 Generation
把 Indexing、Retrieval、Generation 串起来,一个最简单的 Naive RAG 就搭建完成了
8. 完整伪代码
# ========== 1. Indexing 离线索引 ==========
documents = load_documents("knowledge/")
for doc in documents:
doc.text = clean(doc.text)
chunks = []
for doc in documents:
chunks.extend(split_text(doc.text, chunk_size=500, chunk_overlap=50))
for chunk in chunks:
chunk.vector = embedding_model.encode(chunk.text)
vector_db = VectorDB()
for chunk in chunks:
vector_db.insert(chunk.vector, chunk.text, chunk.metadata)
vector_db.build_index()
# ========== 2. Retrieval 检索 ==========
def retrieve(query, top_k=5):
query_vector = embedding_model.encode(query)
return vector_db.search(query_vector, top_k)
# ========== 3. Generation 生成 ==========
def naive_rag(query, top_k=5):
results = retrieve(query, top_k)
context = "\\n\\n".join([r.text for r in results])
prompt = f"根据以下资料回答问题:\\n{context}\\n\\n问题:{query}\\n回答:"
return llm.generate(prompt)
9. Naive RAG 的局限
Naive RAG 简单、直接,能快速跑通,也能缓解一部分知识过时和私有知识缺失的问题
但它也有明显的局限:
- 只有一次向量检索,没有查询改写
- Top-K 直接给 LLM,没有 Rerank
- 没有混合检索,关键词匹配能力弱
- 没有上下文压缩,噪声可能很多
- 不会多跳推理
- 不验证答案是否忠实于检索结果
所以 Naive RAG 是基线,不是终点
它遇到检索精度瓶颈之后,就会进入 Advanced RAG:检索前做查询改写、问题拆解、HyDE;检索中做混合检索、元数据过滤、多路召回;检索后做 Rerank、上下文压缩、去重
下一篇再写 Advanced RAG



