RAG 完全指南:从一次"知识库问答把整个文档塞进 prompt、token 直接爆了"看懂检索增强生成

2024 年我做一个公司内部知识库问答系统,把几百份产品手册、技术文档、历史工单喂给大模型,让员工用大白话问问题。第一版想法很直接:既然要让模型根据我们的文档回答,那就把所有文档拼成一大段塞进 prompt。本地拿三五份测效果好极了,可一接真实文档库,第一个请求就报错——几百份文档拼起来 token 数远超模型上下文窗口上限。改成只塞一部分又出新问题:答案明明在第 200 份文档里,我只塞了前 30 份,模型压根没看到那段资料,要么答不知道要么直接编。后来才想明白,让模型基于知识库回答,真正的难点根本不是把文档喂给模型,而是在海量文档里先精准找出和这个问题相关的那几小段,只把这几小段喂给模型。模型的上下文窗口有限,你能给它的永远只是一小撮资料。这个先检索再把检索结果喂给模型生成回答的模式就是 RAG。本文从头梳理:为什么文档库塞不进 prompt、关键词检索为什么不够用、文本怎么用 embedding 向量化做语义检索、余弦相似度怎么衡量语义相近、文档为什么要切分又怎么切才合适、chunk 重叠在防什么、一个完整 RAG 流程怎么从离线建索引到在线检索生成串起来,以及检索召回是命门、向量粗筛加重排、答案带出处、索引随文档更新这些把 RAG 真正做好的工程细节。核心一句:RAG 不是把知识给模型,而是在提问的那一刻从知识里精准取出够用的一小撮再给模型,真正的工程量几乎全在检索这一端。

2024 年我做一个公司内部的知识库问答系统:把产品手册、技术文档、历史工单几百份资料喂给大模型,让员工能用大白话问问题,模型从这些资料里找出答案。第一版我的想法特别直接:既然要让模型"根据我们的文档回答",那就把所有文档拼成一大段文字,塞进 prompt,后面跟上用户的问题,一起发给模型。本地拿三五份文档测,效果好得让我兴奋。可一接上真实的文档库,第一个请求就报错了——几百份文档拼起来的文本,token 数量远远超过了模型的上下文窗口上限,API 直接拒绝。我退而求其次,改成"只塞前面一部分文档",这下不报错了,但新问题来了:用户问的内容,答案明明在第 200 份文档里,可我只塞了前 30 份,模型压根没看到那段资料,于是它要么回答"我不知道",要么干脆一个看起来合理的答案。我一开始以为是"塞的文档不够多",可文档再多也塞不下——上下文窗口就那么大。后来我才彻底想明白:让模型"基于我们的知识库回答",真正的难点根本不是把文档喂给模型,而是在海量文档里,先精准地找出和这个问题相关的那几小段,只把这几小段喂给模型。模型不会、也不可能"读完整个文档库再回答"——它的上下文窗口是有限的,你能给它的,永远只是一小撮资料。所以问题的核心,从"怎么把文档给模型"变成了"怎么从一堆文档里检索出最相关的片段"。这个"先检索、再把检索结果喂给模型生成回答"的模式,就是 RAG(Retrieval-Augmented Generation,检索增强生成)。我以为 RAG 不过是"搜一下文档再拼进 prompt",结果真做下来坑一个接一个:用关键词搜根本搜不准、文档怎么切成片段、片段怎么变成能比较相似度的东西、检索回来一堆不相关的怎么办……那次之后我才认真把 RAG 从头搞明白。这篇文章就把它梳理一遍:为什么需要 RAG、关键词检索为什么不够、文本怎么向量化做语义检索、文档怎么切分、一个完整的 RAG 流程怎么转,以及把 RAG 真正做好要避开的那些坑。

问题背景

先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:做一个基于公司内部文档的知识库问答,第一版把所有文档拼进 prompt 一起发给模型——文档一多,拼出来的文本 token 数超过模型上下文窗口上限,请求直接报错;改成只塞一部分文档,又导致答案所在的文档没被塞进去,模型答不出或干脆编造。

我当时的错误认知:"要让模型基于我们的知识库回答,就是把文档都喂给它;塞得越多,它知道的越多,答得越准。"

真相:模型的上下文窗口是有限的,你不可能把整个文档库都塞进去;而且即使塞得下,把大量不相关的文档一起喂进去,也会干扰模型、稀释它的注意力。正确的做法是 RAG:把文档库预处理好(切成片段、转成向量、建索引),用户提问时,先检索出和这个问题最相关的少数几个片段,只把这几个片段连同问题一起喂给模型,让模型基于这几个片段生成回答。RAG 真正的工程量,几乎全在"检索"这一端——检索得准不准,直接决定了答案对不对。

要把 RAG 做好,需要几块认知:

  • 为什么不能把文档全塞进 prompt,RAG 到底在解决什么;
  • 为什么"关键词检索"做知识库问答不够用;
  • 文本怎么变成向量,怎么靠向量做"语义检索";
  • 文档为什么要切分、怎么切才合适;
  • 一个完整的 RAG 流程怎么串起来,以及检索召回、重排、引用这些工程坑怎么处理。

一、为什么需要 RAG:上下文窗口装不下知识库

先把这件最根本的事钉死:大模型不会"读"你的文档库

模型的知识,是在训练时"固化"进参数里的,训练截止之后的事、你公司内部的私有文档,它从来没见过。要让它基于你的文档回答,唯一的途径是把文档内容放进 prompt——而 prompt 的长度,被模型的上下文窗口死死卡住。一个文档库动辄几百上千份文档,拼起来的 token 数,轻松就是上下文窗口的几十倍、上百倍。下面这段代码,就是最典型的"全塞进去"的土办法:

def build_prompt_dump(documents: list[str], question: str) -> str:
    # 反面教材:把整个文档库一股脑拼进 prompt。
    all_text = "\n\n".join(documents)
    return f"以下是全部文档:\n{all_text}\n\n请根据上面的文档回答:{question}"
    # 问题:documents 稍微多一点,all_text 的 token 数就远远超过
    # 模型的上下文窗口上限 —— 请求直接被 API 拒绝。
    # 就算侥幸塞得下,大量不相关文档也会干扰模型,让它抓不住重点。

这段代码的错,不在某一行语法,在它的根本假设:它假设"模型能一次性消化整个文档库"。这个假设和模型的物理限制(上下文窗口)直接冲突。而且就算你的文档库小到能勉强塞下,这也不是好做法——把几百段不相关的文字一起丢给模型,等于让它在一大堆噪音里找那一句答案,它的注意力会被稀释,答得反而更差。

所以正确的方向很清楚:不要把整个文档库给模型,要先筛选——在用户提问的那一刻,从文档库里挑出和这个问题最相关的几小段,只把这几小段给模型。模型要的不是"全部资料",而是"恰好够用的、相关的资料"。这个"筛选"动作,就是 RAG 里的"检索"(Retrieval)。那么问题来了:怎么筛?

二、关键词检索的局限:用户的问法千变万化

说到"从文档里找相关内容",第一反应往往是关键词匹配:把用户问题里的词,拿去文档里找,谁包含这些词,谁就相关。

def keyword_search(documents: list[str], query: str) -> list[str]:
    # 反面教材:靠关键词字面匹配来检索。
    hits = []
    for doc in documents:
        if query in doc:          # 文档里必须【一字不差】地包含这句话
            hits.append(doc)
    return hits
    # 问题:用户问"怎么把密码改掉",文档里写的是"重置登录凭据"——
    # 一个字都对不上,这个最该命中的文档,反而一条都搜不到。

这种检索方式的根本问题是:它匹配的是字面,而人的语言是千变万化的。同一个意思,用户可能说"改密码",文档里可能写"重置登录凭据";用户问"系统卡",文档标题是"性能优化指南"。它们语义上高度相关,但字面上一个字都不挨着。关键词检索面对这种情况完全无能为力——它不理解"密码"和"登录凭据"是一回事。

就算你改进一下,不要求整句匹配,改成"问题里的词命中得越多越相关",也只是缓解,治不了根。因为它的能力天花板,就卡在"它只会比字,不会比意思"。而知识库问答,恰恰最需要"比意思":用户用他自己的话问,文档用作者的话写,两边几乎永远对不齐字面。

所以我们真正需要的检索,得能理解语义——能判断出"改密码"和"重置登录凭据"说的是同一件事。要做到这一点,得先解决一个更基础的问题:怎么让"语义的相似度"变成一个计算机能算的数字。这就是下一节的向量化。

三、向量化与语义检索:把"意思"变成可计算的向量

让计算机理解语义,核心手段是 embedding(向量化):用一个专门的模型,把一段文本转换成一串数字组成的向量。这个向量的奇妙之处在于——意思相近的文本,转出来的向量在空间里也"挨得近";意思无关的文本,向量则"离得远"。"改密码"和"重置登录凭据"字面毫不相干,但它们的向量会非常接近。

把文本转成向量,通常调用一个 embedding 模型的 API 即可:

from openai import OpenAI

client = OpenAI()


def embed(text: str) -> list[float]:
    """把一段文本转成向量 —— 这串数字就是这段文本的"语义坐标"。"""
    resp = client.embeddings.create(
        model="text-embedding-3-small",
        input=text,
    )
    # 返回的是一串浮点数(比如 1536 维),意思相近的文本向量也相近
    return resp.data[0].embedding

有了向量,"两段文本意思有多近"就变成了"两个向量有多近"——一个纯数学问题。最常用的度量是余弦相似度:它算的是两个向量"方向"的接近程度,结果在 -1 到 1 之间,越接近 1,意思越相近。

import math


def cosine_similarity(a: list[float], b: list[float]) -> float:
    """余弦相似度:两个向量方向越一致,值越接近 1,语义越相近。"""
    dot = sum(x * y for x, y in zip(a, b))          # 点积
    norm_a = math.sqrt(sum(x * x for x in a))       # a 的模长
    norm_b = math.sqrt(sum(x * x for x in b))       # b 的模长
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)

到这里,"语义检索"的原理就完整了:把文档库里每一段文本都 embed 成向量、存起来;用户提问时,把问题embed 成向量,然后逐一算它和每段文档向量的余弦相似度,相似度最高的几段,就是和问题语义最相关的几段。这套机制,彻底绕开了"比字面"的死胡同——它比的是"意思"。但在真正建索引之前,还有一个绕不开的前置问题:文档不能整篇整篇地去向量化,得先切开

四、文档切分:切太大检索不准,切太小丢上下文

为什么不能把一整篇文档作为一个整体去 embed?两个原因。其一,一篇长文档可能讲了五六个主题,把它压成一个向量,这个向量就成了五六个主题的"平均",对哪个主题都不够精准。其二,就算检索命中了,你也只能把整篇长文档塞进 prompt——又回到了"塞不下"的老问题。

所以文档要先切分(chunking):把长文档切成一个个小片段(chunk),每个片段单独 embed、单独检索。最朴素的切法是按固定长度切:

def chunk_text(text: str, chunk_size: int = 500, overlap: int = 80) -> list[str]:
    """把长文本切成带重叠的片段。overlap 让相邻片段共享一段内容。"""
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        # 关键:下一段的起点往回退 overlap 个字 —— 让相邻片段重叠
        start = end - overlap
    return chunks

这里有两个参数,背后都是权衡chunk_size(片段大小):切得太大,一个片段塞进了多个主题,检索就不精准,又回到"一个向量代表太多东西"的问题;切得太小,一句话被拦腰截断,片段丢失了上下文,单独看根本看不懂(比如把"它"和"它指代的东西"切到了两个片段里)。overlap(重叠):为什么相邻片段要重叠一段?因为切分是机械的,很可能正好把一个完整的句子、一个关键的因果关系切成两半。让相邻片段共享一小段内容,就能缓解"答案恰好被切在边界上、哪个片段都不完整"的尴尬。

切分没有一套万能参数。文档结构性强(比如有清晰的小标题),最好按结构切(按段落、按小节),而不是机械地按字数切——按结构切,每个片段天然是一个完整的语义单元。这是 RAG 里很影响效果、却最容易被敷衍的一步。

五、完整的 RAG 流程:索引、检索、生成

前面的零件都齐了:切分、向量化、相似度。现在把它们串成一个完整的 RAG。整个流程分两个阶段——离线建索引在线问答

先看建索引。我们需要一个地方存放所有片段和它们的向量,这就是向量存储。这里手写一个最简单的,看清它的内核——它要能"加片段"和"按相似度检索":

class VectorStore:
    """一个最简向量存储:存片段+向量,支持按语义相似度检索。"""

    def __init__(self):
        self._items: list[tuple[str, list[float]]] = []   # (片段文本, 向量)

    def add(self, text: str, vector: list[float]):
        self._items.append((text, vector))

    def search(self, query_vector: list[float], top_k: int = 3):
        """算 query 和每个片段的相似度,返回最相关的 top_k 个。"""
        scored = [(cosine_similarity(query_vector, vec), text)
                  for text, vec in self._items]
        scored.sort(key=lambda x: x[0], reverse=True)     # 相似度从高到低
        return scored[:top_k]

建索引,就是把每篇文档切分、每个片段向量化、再一起存进这个 store。这一步是离线做的——文档库不变,索引就只建一次,之后所有问答都复用它:

def build_index(documents: list[str]) -> VectorStore:
    """离线建索引:把每篇文档切片、向量化,全部存进向量存储。"""
    store = VectorStore()
    for doc in documents:
        for chunk in chunk_text(doc):     # 切片
            vec = embed(chunk)            # 向量化
            store.add(chunk, vec)         # 入库
    return store

索引建好,就进入在线问答。这一步才是 RAG 的"R + G"合体:先用问题的向量去 store 里检索(Retrieval)出最相关的几个片段,再把这几个片段拼进 prompt、交给模型生成(Generation)回答。拼 prompt 时有个关键约束要写进去——只许根据给定资料回答,资料里没有就说没有,不准编:

def build_rag_prompt(question: str, chunks: list[str]) -> str:
    """把检索到的片段和问题拼成 prompt,并约束模型只能据此回答。"""
    context = "\n\n".join(f"[资料 {i + 1}]\n{c}"
                          for i, c in enumerate(chunks))
    return f"""请【只】根据下面的资料回答问题。
如果资料里没有答案,就直接回答"资料中未提及",绝对不要编造。

{context}

问题:{question}
"""


def answer(store: VectorStore, question: str) -> str:
    """完整的 RAG 在线问答:检索相关片段 -> 拼 prompt -> 模型生成。"""
    q_vec = embed(question)                       # 1. 问题向量化
    hits = store.search(q_vec, top_k=3)           # 2. 检索最相关的片段
    chunks = [text for score, text in hits]
    prompt = build_rag_prompt(question, chunks)   # 3. 拼进 prompt
    resp = client.chat.completions.create(        # 4. 交给模型生成
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    )
    return resp.choices[0].message.content

这段 answer 就是文章开头那个问题的正解。对比一下:土办法是"把全部文档塞进 prompt",而 RAG 是"先检索出最相关的 3 段,只塞这 3 段"。无论你的文档库有 100 份还是 100 万份,喂给模型的永远只是那几段——token 量被稳稳控制住,模型的注意力也聚焦在真正相关的内容上。build_rag_prompt 里那句"资料里没有就说没有、不准编",同样关键:它把模型从"凭印象作答"约束回"基于给定资料作答",这正是 RAG 能压制幻觉的原因——答案有据可查。

六、工程坑:召回质量、重排、引用与更新

主干通了,但要把 RAG 真正做好,还有几个绕不开的工程坑。

坑 1:检索召回质量,是 RAG 的命门。RAG 的回答质量,有一条铁律:检索不到,模型就答不出。如果相关片段根本没被 search 召回,后面的模型再强也没用——它手里压根没有那段资料。所以最该投入精力的不是调 prompt,而是检索。一个实用的手段是给检索结果设一个相似度阈值:相似度太低的片段,说明它和问题其实不相关,与其塞进去当噪音,不如直接丢掉。

def search_with_threshold(store: VectorStore, question: str,
                          top_k: int = 5, min_score: float = 0.3):
    """带阈值的检索:相似度太低的片段宁可不要,免得当噪音干扰模型。"""
    q_vec = embed(question)
    hits = store.search(q_vec, top_k=top_k)
    good = [(score, text) for score, text in hits if score >= min_score]
    if not good:
        # 一个够格的片段都没有 —— 知识库里很可能真没有这个问题的答案
        return []
    return good

坑 2:向量检索召回快,但不够"准",需要重排(rerank)。向量相似度检索很快,适合从海量片段里粗筛出一批候选(比如 top 20),但它对"哪个最相关"的排序并不够精细。生产级 RAG 常加一道重排:用一个更精细、但更慢的模型(rerank 模型),对这 20 个候选重新打分排序,取真正最好的 top 3 喂给模型。"向量粗筛一大批 + 重排精选一小撮",是 RAG 提升召回质量的标准套路。

坑 3:答案要带"出处",可追溯。RAG 相比直接问模型,一个巨大的优势是答案有据可查。你应该在检索时一并记下每个片段来自哪篇文档,生成回答时让模型标注引用,最终把"答案 + 出处"一起给用户。这样用户能自己核对,你也能在答错时快速定位是哪段资料的问题。一个连出处都给不出的 RAG,可信度会大打折扣。

坑 4:索引会过时,要能更新。文档库不是一成不变的——产品手册会改、新工单会进来。如果你的向量索引还是上个月建的,那用户问到新内容时,RAG 自然检索不到。所以要有一套索引更新机制:文档新增或修改时,把对应的片段重新切分、重新向量化、更新进向量库。索引的新鲜度,和检索算法本身一样重要。下面这张图,把 RAG 离线建索引和在线问答两个阶段串起来:

关键概念速查

概念 / 手段 说明
RAG 检索增强生成:先从文档库检索相关片段,再把片段喂给模型生成回答
上下文窗口限制 模型能接收的 token 有上限,整个文档库根本塞不进 prompt
关键词检索的局限 只比字面,用户问法和文档用词对不上就检索不到,无法理解语义
embedding 向量化 把文本转成向量,意思相近的文本向量在空间里也相近
余弦相似度 用向量方向的接近程度衡量两段文本的语义相似度,越接近 1 越相近
文档切分 chunking 长文档切成小片段单独索引,切太大不精准,切太小丢上下文
chunk 重叠 相邻片段共享一小段内容,缓解答案恰好被切在边界上的问题
向量检索 问题向量和片段向量算相似度,取最相关的 top_k 个片段
相似度阈值 相似度太低的片段直接丢弃,避免不相关内容当噪音干扰模型
重排 rerank 向量粗筛一大批候选,再用更精细的模型重排精选,提升召回质量

避坑清单

  1. 模型上下文窗口有限,整个文档库根本塞不进 prompt;塞得下也会因大量噪音稀释注意力而答得更差。
  2. RAG 的核心不是把文档喂给模型,而是先从海量文档里精准检索出最相关的几小段,只喂这几段。
  3. 关键词检索只比字面,用户的问法和文档用词几乎永远对不齐,语义相关但字面不同的内容会被漏掉。
  4. 用 embedding 把文本转成向量,意思相近的向量也相近,语义检索靠的是算向量相似度而非匹配字面。
  5. 文档必须切分后再索引:切太大一个向量代表太多主题不精准,切太小片段丢失上下文单独看不懂。
  6. 相邻片段要留重叠(overlap),缓解关键句子或因果关系被机械切分拦腰截断的问题。
  7. 文档结构性强时优先按段落、小节切分,让每个片段是完整语义单元,效果远好于机械按字数切。
  8. 检索召回是 RAG 的命门:相关片段没被召回,模型再强也答不出;给检索设相似度阈值过滤噪音。
  9. 向量检索快但排序不够精细,生产级 RAG 要加重排:向量粗筛一大批,再用精细模型重排选 top 几。
  10. 拼 prompt 要约束模型只根据给定资料回答、没有就说没有;答案带文档出处;索引要随文档更新保持新鲜。

总结

回头看那个"把整个文档库塞进 prompt、token 直接爆了"的知识库问答,以及我后来在 RAG 上接连踩的坑,最该记住的不是某一段检索代码,而是我动手前那个想当然的判断——"要让模型基于我们的知识库回答,就是把文档都喂给它"。这句话错在它没意识到模型有一道物理边界:上下文窗口。你的文档库可以无限增长,但模型一次能"看"的内容,是固定的、有限的。这个矛盾决定了 RAG 必然的形态——不是"把知识给模型",而是"在提问的那一刻,从知识里精准取出够用的一小撮,再给模型"。

所以做 RAG,真正的工程量不在"调用一次模型生成回答"那一下。把检索到的片段拼进 prompt、调 chat.completions,这部分 Demo 里谁都能跑通。真正的工程量在"检索"这一端:文档怎么切分成既精准又不丢上下文的片段?用什么模型做向量化?相似度算出来一批候选,怎么重排出最该用的那几个?检索回来的东西够不够相关,你设阈值过滤了吗?文档更新了,你的索引跟上了吗?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚为什么文档库塞不进 prompt,再看关键词检索为什么不够,然后是向量化、文档切分这两个地基,接着是完整 RAG 流程的串联,最后是召回质量、重排、引用、更新这几个把 RAG 真正做好的工程细节。

你会发现,RAG 的思路和我们做任何"在海量数据里回答问题"的工程经验都是相通的。一个人面对一个他不熟悉的专业问题,他不会、也不可能把整座图书馆都读一遍再回答——他会先查目录、查索引,精准翻到相关的那几页,读完那几页,再组织语言回答。RAG 做的就是同一件事:向量库是那本"语义索引",检索是"翻到相关的几页",模型是那个"读完相关几页后组织语言回答的人"。模型负责它擅长的"理解和表达",而"该读哪几页",是检索系统的职责——这两件事分开,各自做好,才是 RAG。

最后想说,RAG 做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你就三五份文档,问的也都是文档里有明确答案的问题,检索随便搜搜都能命中,跑起来漂亮极了。它只在真实的大文档库、真实千奇百怪的用户问法、真实持续更新的资料面前才显形。那时候它会用最难堪的方式给你结账:用户问的内容明明在文档里,RAG 却检索不到、回一句"资料中未提及";或者检索回来一堆不相关的片段,模型被带偏、给出一个似是而非的错误答案;又或者用户拿着上个月的旧政策去做了决定,因为你的索引几周没更新了。所以别等用户拿着错误答案来投诉,在你建第一个索引的时候就该想清楚:文档我切得合理吗?用户那样问,我检索得到吗?检索回来的片段,真的相关吗?文档更新了,我的索引跟上了吗?这几个问题都有了答案,你的知识库问答才不只是 Demo 里那个对答如流的演示,而是一个无论文档库多大、用户怎么问,都能精准检索、有据回答的可靠系统。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

数据库连接池完全指南:从一次"流量一高数据库就报 Too many connections"看懂连接池

2026-5-21 19:37:30

技术教程

接口限流完全指南:从一次"活动一开服务就被瞬时流量冲垮"看懂限流算法

2026-5-21 19:46:01

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索