先跑通一个不带框架的最小实现
RAG 说穿了就五步:加载文档、切块、把块和查询都变成向量、按相似度取前几块、把块塞进提示词让模型作答。别一上来就装 LangChain,先用 numpy 手写一遍,才能看清哪里会出问题。下面这段除了 embed 函数需要替换成真实接口,其余是可执行的;注意 query 和文档必须用同一个 embedding 模型,且向量先做 L2 归一化,这样点积就直接等于余弦相似度。
import numpy as np
def embed(texts):
# 换成真实接口,例如 Ollama /api/embed,返回已归一化的向量
raise NotImplementedError
def chunks_from(text, size=500, overlap=50):
out, start = [], 0
while start < len(text):
out.append(text[start:start + size])
if start + size >= len(text):
break
start += size - overlap
return out
chunks = chunks_from(open("doc.md", encoding="utf-8").read())
vecs = np.array([embed([c])[0] for c in chunks])
def search(query, top_k=4, threshold=0.35):
q = embed([query])[0]
sims = np.array([float(v @ q) for v in vecs])
idx = np.argsort(sims)[-top_k:][::-1]
picked = [i for i in idx if sims[i] > threshold]
return "
---
".join(chunks[i] for i in picked)
prompt = "只依据下面的材料回答问题,材料没提到就说不知道。
材料:
"
prompt += search("num_ctx 默认是多少")
prompt += "
问题:num_ctx 默认是多少?"
分块才是效果大头,中文尤其
固定按字符数硬切是最差的做法,会把一个句子或一段代码从中间劈开。可行的是递归切分:优先按两个换行分段、再按单换行、再按中文句号问号和英文句点、最后才按字符兜底,chunk_size 取 500 左右、overlap 取 50 到 80。中文别照搬英文的空格切分,标点才是天然边界。表格整块保留并转成 Markdown 行,代码块不要拆,否则检索出来的片段没法用。每个块前面拼上它的标题路径,例如"第 3 章 / 2.1 节",能明显缓解块脱离上下文后语义模糊的问题。
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=60,
separators=["
", "
", "。", "!", "?", ".", ",", " ", ""],
length_function=len,
)
parts = splitter.split_text(raw_text)
取舍判断:块太小,答案需要的证据被拆到两个块里,召回得到一半也没用;块太大,里面塞满无关内容,相似度被稀释,还白白吃掉上下文预算。技术文档我从 500/60 起调,FAQ 类短条目直接一条一块,不分。
检索环节的几件小事
top_k 给 4 到 6,配一个相似度阈值过滤明显无关的块;纯向量容易召回一堆近义重复内容,用 MMR 做去重,或者在后面挂一个 bge-reranker-v2-m3 对候选重排,通常比换更大的 embedding 模型收益更高。元数据过滤别忘,按来源、时间、文档类型先缩小范围再算相似度。
- 评估:手工标 30 条真实问题,记 top4 命中率和答案有据率,改参数前后对比,别凭感觉。
- PDF 坑:双栏 PDF 抽出来左右串行、页眉页脚混进正文,先清洗再切块,否则检索全被"第 3 页 版权所有"污染。
- HyDE:查询太短时可以先用模型生成一段"假答案",拿它去检索,长文档场景常有小幅提升。
最常见的两个事故:query 和文档用了不同 embedding 模型,相似度整体偏低却查不出原因;以及换了分块策略但旧块没删,同一段内容被召回两遍。