先跑通一个不带框架的最小实现

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 模型,相似度整体偏低却查不出原因;以及换了分块策略但旧块没删,同一段内容被召回两遍。