RAG 那点事:从 8 份企业文档到能用的问答系统,全过程拆给你看

0 阅读22分钟

写在前面

最近问 RAG 的人越来越多。聊几句下来发现一个共同毛病:他们以为 RAG是个"模型"。

RAG 不是模型。RAG 是一套检索-拼接-生成的工程流水线,LLM 只是这条流水线最末端的一个零件。这套流水线里 80% 的工程量跟"AI"半毛钱关系没有 — 是 PDF 解析、是分词、是倒排索引、是 RRF 融合、是 prompt 工程、是缓存。把这 80% 干漂亮了,你随便换个 LLM 都能跑;干不漂亮,你用 GPT-5 也救不回来。

这两年但凡跟 AI 沾点边的会上,必有人讲RAG。讲完之后听众一脸"我懂了",回去自己写就两眼一抹黑。本文不讲 paper,不讲 transformer、不讲注意力机制、不讲 embedding 数学原理。我就拿一个真实跑起来的企业知识库项目 RAG-QYZSK从零到能跑,一共六步,把每一步拆给你看,踩过的坑也一并交代。代码全开源 :github.com/aleeeeexx/R…

1.一些背景交代

1.1 RAG 是个啥,先说人话

RAG = Retrieval-Augmented Generation,翻译叫"检索增强生成"。听着唬人,本质 就一句话:别让大模型瞎编,先去文档里翻一翻再回答。

为啥要翻?因为你公司的《员工手册 V2.0》、《差旅报销制度》这些东西,GPT-4

没见过、Qwen 也没见过,你直接问"我们公司试用期多久",它只能瞎掰。

RAG 解决的就是这事儿。流程也朴素:

  1. 把文档切成小块、算向量、存起来

  2. 用户提问时,把问题也算个向量,找最像的几块

  3. 把找到的内容塞给大模型,让它基于这些内容回答

完了。剩下的所谓"父文档检索"、"混合检索"、"重排"……都是在第 2 步上打补丁,让"找得准"这件事更靠谱。

1.2 项目背景

深圳某公司的内部知识库,8 份制度文档(员工手册、报销规定、商旅 手册、个税说明、财务培训、空白表单...),格式是 PDF + Excel + PPT 混着来。需求很朴素 — 让员工能问"差旅费报销标准是多少"、"试用期几个月",系统 能从这堆文档里把答案抽出来,还得告诉他出处。

最终成品长这样:8 份文档、104 页、175 chunks、混合检索 + LLM 重排 + 父文档检索 + Qwen 推理 + Streamlit 前端,从头到尾跑完一次问题答案的延迟大约 8-12 秒,成本几分钱。

下面开始拆。 主要是六阶段:P1 骨架 → P2 解析 → P3 索引 → P4 检索 → P5 推理 →P6 前端。

graph TD
  A["PDF / Excel / PPT"] --> B["P2 解析层<br/>MinerU 在线 OCR / pandas / python-pptx"]
  B --> C["统一 JSON<br/>metainfo, content: page/text/tables"]
  C --> D["P3 切分<br/>RecursiveCharacterTextSplitter + tiktoken<br/>中文分隔符 / 500 token / overlap 100"]
  D --> DI["P3 索引<br/>FAISS IndexFlatIP (cosine) + jieba BM25"]
  DI --> E["FAISS IndexFlatIP (cosine)"]
  DI --> F["jieba BM25"]
  E --> G["P4 检索<br/>向量 top30 ⊕ BM25 top30"]
  F --> G
  G --> H["RRF 融合 -> top20"]
  H --> I["Qwen 重排 -> top5"]
  I --> J["父文档检索<br/>召回所在整页"]
  J --> K["P5 推理<br/>拼 context + 中文 Prompt"]
  K --> L["Qwen-plus"]
  L --> M["结构化 JSON<br/>step_by_step / references / final_answer"]
  M --> N["P6 前端 Streamlit<br/>答案 + 来源 + 推理过程 + 原文回看"]

记住这张图。下面每一节就是把图里某个方块掰开。

每步都有验证手段,没验证完不进下一步。这事儿很重要——很多人 RAG没跑通就是因为前面错了不知道,到最后看答案不对也不知道哪儿错了。

2.项目拆解

P1:骨架

  先决定栈:DashScope (Qwen +text-embedding-v3)。理由极朴素——国内可用、中文友好、便宜(Qwen-plus 大概 4元/百万 token)、不需要科学上网。

写了 src/config.py 集中所有常量

"""集中管理路径、模型名、检索参数。所有模块从这里读取常量。"""
  from pathlib import Path
  import os
  from dotenv import load_dotenv

  load_dotenv()

  PROJECT_ROOT = Path(__file__).resolve().parent.parent
  RAW_DIR = PROJECT_ROOT / "rag_data"
  DATA_DIR = PROJECT_ROOT / "data"
  PARSED_DIR = DATA_DIR / "parsed"
  CHUNKED_DIR = DATA_DIR / "chunked"
  VECTOR_DB_DIR = DATA_DIR / "vector_db"
  BM25_DB_DIR = DATA_DIR / "bm25_db"

  for d in (PARSED_DIR, CHUNKED_DIR, VECTOR_DB_DIR, BM25_DB_DIR):
      d.mkdir(parents=True, exist_ok=True)

  DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "")
  MINERU_API_TOKEN = os.getenv("MINERU_API_TOKEN", "")

  LLM_MODEL = "qwen-plus"
  RERANK_MODEL = "qwen-turbo"
  EMBED_MODEL = "text-embedding-v3"
  EMBED_DIM = 1024

  CHUNK_SIZE_TOKENS = 500
  CHUNK_OVERLAP_TOKENS = 100

  VECTOR_TOP_K = 30
  BM25_TOP_K = 30
  RRF_TOP_K = 20
  RERANK_TOP_K = 5

  LLM_MAX_TOKENS = 2048
  LLM_TEMPERATURE = 0.1 解析

src/llm_client.py 封装 chat() 和embed()、一个 scripts/smoke_test.py 验证 API 通不通。

 def main():

   print(chat("用一句话介绍 RAG 是什么。"))

   vecs = embed(["企业制度问答"])

   assert len(vecs[0]) == 1024


"""DashScope (Qwen) 调用封装:对话补全 + 文本嵌入。"""
from typing import Iterable
import dashscope
from dashscope import Generation, TextEmbedding

from src import config

dashscope.api_key = config.DASHSCOPE_API_KEY


def chat(
    prompt: str,
    system: str | None = None,
    model: str | None = None,
    temperature: float = config.LLM_TEMPERATURE,
    max_tokens: int = config.LLM_MAX_TOKENS,
) -> str:
    messages = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt})

    resp = Generation.call(
        model=model or config.LLM_MODEL,
        messages=messages,
        result_format="message",
        temperature=temperature,
        max_tokens=max_tokens,
    )
    if resp.status_code != 200:
        raise RuntimeError(f"DashScope chat failed: {resp.code} {resp.message}")
    return resp.output.choices[0].message.content


def embed(texts: str | list[str]) -> list[list[flo

 

跑通,进入 P2。这步千万别跳。我见过太多人一开始就堆模块,最后发现 API key 有问题或者环境里有版本冲突,调试三小时。

P2 解析:把文档变成 JSON

rag_data/ 里 8 份文件:5 个 PDF、2 个 Excel、1 个 PPT。一个统一的 schema:

  {

  "metainfo": {"file_name", "file_type", "doc_id"},
  "content": [
   {"page": 1, "page_label": "第 1 页", "text": "...", "tables": [...]}
  ]
  }

  Excel / PPT:水到渠成

  • Excel: pd.read_excel(sheet_name=None) 拿到所有 sheet → 每个 sheet 转 markdown 表 → 一个 sheet 当成一"页"

  • PPT: python-pptx 遍历 slides → 每张抽 text_frame + notes → 一张幻灯片当成一"页"

  这俩都是十几行代码的事,没啥可吹的。

  PDF:真正的坑

 PDF 解析是 RAG 工程里最大的坑,没有之一。理论上 pdfplumber、PyMuPDF 都能用,但碰上扫描件、双栏、复杂表格就废。

很多人上手 RAG 第一反应:"用 PyPDF2 把 PDF 文字读出来不就完了?"

试一次你就知道为什么不行。PyPDF2.extract_text() 拿出来的中文 PDF 经常是这样的:

差     旅 费 报销 单
  基础信     息填写
  项目名称、 Base 地、出差事由 及 附件张数等信
  息均为 必填 项

文字间距乱七八糟,表格直接散架。LLM 看见这种东西,该胡说还是胡说。

所以 PDF 解析必须用上 OCR + 版面分析。我用的是 MinerU(mineru.net)的在线 API,是上海 AI 实验室开源的 PDF 解析工具,对中文文档识别非常好。它的工作方式是:你上传 PDF,它给你返回一个 zip,里面有 _content_list.json,每个块带 page_idx + type(text / table / equation) + text/table_body。表格直接给你抽成 HTML,你拿去喂 LLM 都能读懂。

踩坑点 1:MinerU 的官方示例代码是基于"PDF 已经是公网 URL"的简化写法。 实际本地文件你要走 batch 上传:

  1. 申请预签名 URL POST /api/v4/file-urls/batch { files: [{name, is_ocr, data_id}], language: "ch" } → { batch_id, file_urls: [pre_signed_url] }

  2. 上传 PDF (这一步不能带 Authorization!) PUT pre_signed_url

  3. 轮询 GET /api/v4/extract-results/batch/{batch_id} → { extract_result: [{state, full_zip_url}] }

第 2 步那个 不能带 Authorization 我交了一杯咖啡的学费。预签名 URL 自己已经带签名了,你再塞 token 进去会被认为是双重鉴权直接 403。

踩坑点 2:Excel/PPT 别想着用 LLM "看图"理解,直接结构化抽取就好。Excel 用 pandas.read_excel(sheet_name=None) 拿到所有 sheet,每个 sheet 用 df.to_markdown() 转成 markdown 表格;PPT 用 python-pptx 遍历 slide.shapes,有 text_frame 的就抽,顺手把 notes_slide 备注也带上 — 财务培训的 PPT 里"备注"经常是讲师重点,不抽就亏了。

踩坑点3:统一 schema。三种格式最后必须吐出一样的结构:

    {
    "metainfo": {"file_name", "file_type", "doc_id"},
    "content": [
        {"page": 1, 
         "page_label": "幻灯片 3", 
          "text": ..., 
          "tables":[...]
    }]
  }

page 是从 1 开始的整数(给检索用),page_label 是"幻灯片 3"、"工作表「Sheet1」"、"第 5 页" 这种给人看的标签。下游不需要知道你这页是从 PDF 还是 PPT 来的。

这种"对齐"的工作是 RAG 工程里最不性感但最关键的部分,即它让你的检索代码完全不用 if file_type == "pdf",意味着将来加个 markdown 解析器只要再写一个 parser、不用改任何下游。

踩坑点4:WSL 跨盘的 :Zone.Identifier 文件。Windows 文件拖进 WSL 会留一份元数据小文件,parser 遍历目录时碰到这玩意会直接挂(不是每个人都遇到)。两行代码挡掉:

if filename.endswith(":Zone.Identifier"): continue

踩坑点5:解析必须缓存。

MinerU 调一次几毛钱 + 等 1-3分钟,你不缓存每次重跑都重新解析,做开发就废了。我落了两层缓存 —data/parsed/_mineru_cache/{md5}/ 是 zip 解压结果,data/parsed/{name}.json是统一 schema 后的 JSON,用文件 md5 当 key,文件不变就跳 API。按 PDF 的 MD5 缓存到data/parsed/_mineru_cache/{md5}/。第二次跑同一份 PDF 直接读本地,不再调API。调用外部服务就要假设它会失败、会限流、会涨价,永远缓存。

 跑完 8 个文件,104 页。

突然发现一个鬼东西:《办公管理行为规范》明明是个 30 多页的 PDF,MinerU 只解析出 3 页。这就是 PDF 的玄学——可能是底层文档结构特殊,也可能是 OCR 大段失败。我没回头修,记下来了,等检索效果差再说。RAG 工程里不要追求一次完美,要追求"知道哪儿不完美"。

P3 切分:不是越长越好,也不是越短越好

很多教程说"chunk_size 设 1000、overlap 200,完事"。这是套话。

切分的目的是让一个 chunk 内的内容是一个完整的语义单位。500 token 是个起手值,在中文语境下大约是 350-400 个字,差不多是一段长论述、一条规章细则的体量。overlap=100 是保险,防止某个关键句正好被切到边界。

但更重要的是分隔符的优先级。中文不是英文,纯按 \n\n / \n / . 切,你会得到一堆半截子句。

我用的是 LangChain RecursiveCharacterTextSplitter + 自定义中文分隔符: separators=["\n\n", "\n", "。", ";", ";", ",", "、", " ", ""]

意思是:优先按段落切;切不动按句号;再切不动按分号;最后实在不行才硬切。

还有一个很多教程不提的点:表格千万不要切。我的做法是表格作为独立 chunk:

     for tb in page_block["tables"]:
      chunks.append({
          "text": tb["markdown"],
          "is_table": True,
          ...
      })

为什么?因为一张差旅费标准表如果被切成两半,LLM 看到上半截只会知道"I 类地区 350",看不到"II 类地区 300",回答必然残缺。

切完得到 175 chunks,平均每页 1.7 个 chunk,合理。

P3 索引:为什么要双索引,为什么是 RRF

单一向量检索 = 召回率天花板。

讲清楚:向量检索擅长"语义相似" — 你问"出差吃饭怎么算钱",它能找到"差旅补助标准"。但碰到精确关键词就抓瞎 — 你问"OA 申请编号",向量模型可能把"OA"嵌入成一个不痛不痒的稀薄向量,检索结果 飘到无关页面。

BM25 干的就是反过来的事 — 经典倒排索引,关键词命中得分高。它对"OA 申请编号"、"4 至 6 个月"、"50 元/天/人"这种字面信号敏感。

插一个BM25简介

BM25(Best Matching 25)是信息检索领域的一种经典词频相关性排序算法,在 RAG(Retrieval-Augmented Generation)中常被用作**稀疏检索(sparseretrieval)**的核心方法。

核心思想

给定一个查询 query 和一堆文档,BM25 会为每个文档算一个分数,表示"这个文档和 query 有多相关",然后按分数排序返回 top-k。

公式(简化版)

对查询 Q 中每个词 qᵢ,文档 D 的得分:

score(D, Q) = Σ IDF(qᵢ) · (f(qᵢ,D) · (k₁+1)) / (f(qᵢ,D) + k₁·(1 - b + b·|D|/avgdl))

三个关键因素:

  • TF(词频):词在文档中出现越多,分越高,但有饱和效应(出现 10 次和 100 次差别没那么大)

  • IDF(逆文档频率):词越罕见,权重越高("的""是"这种词权重低)

  • 文档长度归一化:长文档会被适当惩罚,避免长文天然占便宜

参数 k₁(一般 1.2~2.0)控制 TF 饱和速度,b(一般 0.75)控制长度归一化强度。

 在 RAG 中的角色

  • 稀疏检索(BM25): 基于关键词精确匹配,对专有名词、代码、ID 友好

  • 稠密检索(Embedding + 向量相似度): 基于语义,能理解同义词、改写

实践中常用 混合检索(Hybrid Search)= BM25 + 向量检索,再用 RRF 或加权融合,效果通常比单一方式更好——BM25 兜底关键词召回,向量补充语义召回。

常用实现:Elasticsearch、OpenSearch、rank_bm25(Python)、Pinecone/Weaviate/Qdrant 的 hybrid 模式。

中文 BM25 还有个坑:rank-bm25 库是按空格分词的,你要是直接喂中文进去,整段当一个 token,等于没建索引。所以前置必须挂 jieba:

  def tokenize_zh(text): return jieba.lcut(text)
    
  tokenized = [tokenize_zh(t) for t in texts]
    
  bm25 = BM25Okapi(tokenized)

两个索引各召回 30 条之后,怎么融合?这就是 RRF (Reciprocal Rank Fusion) 登场的地方:

插个RRF简介

RRF (Reciprocal Rank Fusion,倒数排名融合) 是一种用于合并多个排序列表的算法,常用于搜索和推荐系统中融合不同检索器的结果。

核心公式

对于文档 dd,其 RRF 分数为:

RRF(d)=i=1n1k+ranki(d)\text{RRF}(d) = \sum_{i=1}^{n} \frac{1}{k + \text{rank}_i(d)}

  • ranki(d)\text{rank}_i(d):文档 dd 在第 ii 个排序列表中的排名(从 1 开始)
  • kk:平滑常数,通常取 60(论文经验值)
  • nn:列表数量

举例

假设有两个检索器对同一查询返回结果(k=60):

0b341d49-40d8-45a9-8215-5e4035e0f706.png

  • ranki(d)\text{rank}_i(d):文档 dd 在第 ii 个排序列表中的排名(从 1 开始)
  • kk:平滑常数,通常取 60(论文经验值)
  • nn:列表数量

举例

假设有两个检索器对同一查询返回结果(k=60):

bcc8fd59-a0b4-4169-89da-2f2bd4281400.png

最终 B 排第一。

为什么常用

  1. 无需调参:不依赖各列表的原始分数(分数尺度可能差异巨大,如余弦相似度 vs BM25)
  2. 只看排名:天然抗噪声,鲁棒性强
  3. 简单有效:实现仅几行代码,效果常优于复杂的学习排序方法
  4. k 的作用:k 越大,靠后排名的权重相对提升,融合更"民主";k 小则更看重头部结果

典型应用场景

  • 混合检索 (Hybrid Search):融合 BM25(关键词)+ Dense Vector(语义)—— 这是 RAG 系统中最常见用法
  • Elasticsearch、Weaviate、Qdrant 等向量库都内置了 RRF
  • 多路召回结果合并

一句话总结

RRF = 把每个文档在各路召回中的"排名倒数"加起来,分数高的胜出,是工程上融合异构检索结果的事实标准。

score(doc) = Σ 1 / (k + rank_i) # k=60 是经验值

为什么不用加权 score 求和?因为向量分数和 BM25 分数压根不在一个量纲上 — 向量分数是 0-1 的余弦,BM25 是 0-几十的浮点。你强行加权要么向量主导要么 BM25 主导。RRF 的高明在于它只看排名不看绝对分数,对量纲完全免疫。

实测效果在 P4 验证脚本里看得很清楚:

Q: 差旅费报销的标准是什么?

RRF=0.0325 vec_rank=2 bm25_rank=1 《培训.pptx》幻灯片 6

RRF=0.0320 vec_rank=1 bm25_rank=4 《报销制度.pdf》第 4 页

两个不同来源,vec 和 bm25 的 rank 互补。如果只用一边,top-1 只能拿到其中一个。

向量库我用 FAISS IndexFlatIP + L2 归一化(等价 cosine)。175 条数据 + 1024 维,对内存 sub-second 检索,完全没必要上 HNSW、IVFPQ 这些花活。别动不动就上分布式向量库。175 条的项目你上 Milvus,本质是用 50 倍的运维复杂度换 0.001 秒的查询时间,纯纯是给自己找事。

具体是,建两个索引:

FAISS 向量索引:

  arr = np.asarray(embed(texts), dtype="float32")

  faiss.normalize_L2(arr)        # 归一化 -> IP 等价 cosine

  index = faiss.IndexFlatIP(arr.shape[1])

  index.add(arr)

 IndexFlatIP 就是暴力遍历内积。175 个向量用什么 HNSW、什么  IVF?纯属花拳绣腿。这种规模 cosine 相似度全量算,毫秒级返回。

  BM25 倒排:

  tokenized = [list(jieba.lcut(t)) for t in texts]

  bm25 = BM25Okapi(tokenized)

  注意 BM25 处理中文必须先分词。英文,按空格切就行;中文必须

  jieba。这步是 RAG 中文化的最大改动之一。

再回答一次为什么要双索引?因为向量检索擅长语义相近("试用期" ↔ "新员工考核期"),但对专有名词、编号、缩写就废。比如你问"OA-2024-001 这条记录是啥",向量检索基本完蛋,但 BM25 一打一个准。两路召回再融合,互补。

  整个建库 12 秒。这速度挺爽的,但别得意,8 份文档而已,企业真上规模就得分布式那一套了。

P4 重排:检索 → 重排 → 父文档

这是 RAG 整个工程里最值得抠细节的一步。

第一道:混合召回

向量 top-30 + BM25 top-30 → RRF 融合 → top-20。

前面说过RRF了,这里重复一下 RRF(Reciprocal Rank Fusion)这名字唬人,公式就一行:

score(doc) = Σ 1 / (k + rank_i)

rank_i 是这个 doc 在第 i 个召回器里的排名(从 1 开始),k=60 是经验常数。两个排名相加倒数。为什么不直接加 score?因为向量的 cosine 和 BM25 的 score 量纲完全不一样,硬加是耍流氓。RRF 只看排名,谁排前面谁加分,跨方法天然兼容。

我跑了一下"差旅费报销的标准"这个问题,结果挺有意思:

RRF=0.0325 vec_rank=2 bm25_rank=1 财务培训.pptx 幻灯片6

RRF=0.0320 vec_rank=1 bm25_rank=4 报销制度.pdf 第4页

RRF=0.0296 vec_rank=14 bm25_rank=2 财务培训.pptx 幻灯片7 ← 看这个

最后一条向量排第 14(基本没救),但 BM25 排第 2("借款审批单"里有"差旅"关 键词),融合后还是被捞回来了。这就是混合检索的价值。

第二道:LLM 重排

混合检索拿到 top-20 之后,还要再过一道 LLM 重排。

把 RRF 的 top-20 喂给 Qwen-turbo,让它给每条打 0~1 分。Prompt 大致:

 【问题】

  {query}

  【候选片段】

  [0] 来源:《xxx.pdf》第3页

  内容...

  [1] ...

  
  输出 JSON: {"scores": [{"id": 0, "score": 0.92}, ...]}

用 json_repair 容错解析,万一 LLM 输出有点歪也能救。

理论上 RRF 已经融合得不错了,为什么还要重排?

因为 RRF不理解语义,只看排名。它会把"看起来相关但其实没用"的片段排得很高。

我项目里有个绝佳的例子。

问"个税起征点是多少?",混合检索把个人所得税计算说明.xlsx 排到 top-1。

看起来完美对上。但你打开那份 xlsx 一看,里面就一行: gerensuodeshui.cn/ (此为个人所得税计算网址,点击链接试算)

它根本没有"起征点"这个数据。LLM 重排能识破这一点 — 我让 Qwen 给每个候选打 0-1 分,这条最终被打了 0.1 分,完美降权。

  RERANK_PROMPT = """你是相关性评估器,给问题与候选片段打 0~1 分。
  {candidates}
  输出: {"scores": [{"id":0, "score":0.92}, ...]}"""

再次重复,前面那一道是"语义/关键词命中",没看具体内容。我跑"个税起征点是多少",混合召回排第一的是 个人所得税计算说明.xlsx——名字一看就最相关嘛。但 LLM 重排给了 0.1 分。我打开一看,那 Excel 里只有一句"gerensuodeshui.cn/点击链接进入网址可输入… 它名字相关,但实际啥都没说 。

只有 LLM 真正"读"了内容,才能识别这种"挂羊头卖狗肉"。重排的价值就在这里。

LLM 重排的代价是 再调一次 API,延迟多 2-3 秒,每问大约 0.005 元。

什么时候可以省掉重排?

  • 候选量很少(top-5 直接给 LLM 也行,反正都要喂)
  • 答案要求快速响应(IM 机器人秒级返回那种)
  • 文档质量极高(每页都是干货,不存在"看起来相关但无用"的诱饵)

我这项目都不属于,所以保留。

踩坑:LLM 输出的 JSON 经常带 markdown fence(json ... ),还有时会少一个引号。 一定要用 json_repair 库,直接 json.loads() 偶尔会爆。

第三道:父文档检索

把重排后的 top-5 chunk 找出来,去查它们各自所属的整页全文,去重后拼成最终上下文。

为啥不直接把 top-5 chunk 喂给 LLM?因为 chunk 是切碎的,可能切在半句话里。

比如制度文档里"住宿费标准如下:(紧接着是表格)",chunk 切分时表格被切走了,光看"住宿费标准如下:"你怎么回答?

把整页拉回来就解决了这问题。代价是 context 变长,但 Qwen 现在动辄 128K 上下文,五页全文塞进去毛毛雨。

跑完 5 个测试问题,召回准确度肉眼可见地高。到这一步整个 RAG 已经能跑了,剩下的都是包装。

P5:Prompt + 端到端

Prompt 模板在 src/prompts.py。核心思路:

你是企业制度问答助手。你必须严格基于提供的文档片段回答问题,不可使用文档之 外的常识或编造内容。

回答规则:

  1. 答案必须能在给定文档片段中找到依据
  2. 若文档片段不足以回答问题,在 final_answer 中明确写"现有资料未提及"
  3. references 必须从【文档片段】里实际给出的来源中挑选,绝不能编造文件名或页码
  4. final_answer 要简洁直接
  5. 涉及金额、期限、比例等数值时,必须原样引用,不要四舍五入或换算

输出 JSON: { "step_by_step_analysis": "...", "reasoning_summary": "...",   "references": [{"file_name": "...", "page_label": "..."}], "final_answer": "..." }

几个关键设计:

  • 明确允许说"不知道"。这是反幻觉最有效的一句话。我 Prompt 里直接说"不足以回答时写'现有资料未提及'",问个税起征点那题 Qwen 老老实实说不知道,而不是编一个"5000 元"出来。
  • 强制结构化输出。step_by_step_analysis / reasoning_summary / references / final_answer 四段。final_answer 给用户看,前面三段给开发者调试 + 给前端做"展开看推理过程"。
  • 数值原样引用。"4 至 6 个月"不要被改成"约半年"。"50 元/天/人"不要被改成"50 元每天每人"。这条是企业知识库的硬底线 — 政策合规问答,任何改写都是事故。
  • references 字段单独要。LLM 自己挑出引用,跟我们父文档拼进去的 sources_used 是两份信息,前端可以做交叉验证。如果 LLM references 出现一个 sources_used 里没有的文件名,大概率是它在编造。

P6:前端

a76586e9-e33c-4a52-8a4d-aa3418123cdd.png

 Streamlit。理由:100 行写完。不用就是给自己找事。

侧边栏放问题输入 + 已索引文档列表 + 配置展示;主区放最终答案 + 引用来源(LLM 标注的 vs 实际拼入的,分两栏对比,便于排查)+ 折叠面板放分步推理 / 推理摘要 / 原文 / 调试 JSON。

 唯一坑了我一下的:Qwen 偶尔会把 file_name 贴心地包成 《xxx.pdf》——因为我在

context 里写的就是 【来源:《xxx.pdf》— 第 N 页】,它有样学样。前端再渲染时又加一层 《》,结果就成了 《《xxx.pdf》》 双书名号。

改一行就行:fname.strip("《》")。这种小恶心是 LLM 应用的常态,习惯就好。

3.几个值得记住的工程经验

  写完整套,攒了几条经验,按重要性排:

  1. 缓存一切外部调用。 MinerU 每个 PDF 解析一次几十秒甚至几分钟,重跑必死。按 MD5 缓存到本地,第二次零成本。

  2. 验证脚本必须独立可跑。 我每个阶段都有scripts/test_xxx.py,不依赖前端。这样后面发现 bug 能精确定位是哪一层。

  3. 别指望 PDF 解析 100% 完美。 那份 30 页的《办公管理规范》MinerU 只搞出 3 页,我也不知道为啥。先放着,等检索效果不行再回头修。RAG工程的关键不是每一步都对,而是每一步都知道自己有多对。

  4. 中文化 = 分词 + Prompt 重写 + 标点分隔符。别低估这三件事,每一个偷懒都会让效果掉一截。

  5. 不要相信"检索到的就是对的"。 加 LLM重排,让模型真正"读"过候选块。这是抗幻觉最有效的一道闸门。

  6. 父文档检索值得加。 多花点 token 而已,但能解决"chunk切坏了"和"上下文不完整"两个老问题。

  7. 输出强制 JSON。 用 json_repair 兜底。LLM 输出 JSON 这事儿在 2026 年依然不是 100% 稳定,但加了这俩兜底基本能用。

4.什么没做、为什么

按 YAGNI 原则,我没做:

  • 多用户/权限:企业内部用,Streamlit 跑在内网就行

  • 增量更新:8 份文档全量重建 12 秒,加增量纯属过度设计

  • 对话历史:制度问答没多轮诉求,加了反而引入"上轮污染下轮"风险

  • Ragas 评测:先把效果做对再谈量化

  • 比较类问题拆解:制度场景压根用不上

  • Docker:单机服务,搞这玩意儿是给自己找麻烦,工程是减法艺术。每加一个东西都得问"必须现在加吗"。

最后晒下数字,感受一下"做一个能跑的 RAG 究竟要多少东西":

代码: 600 行 Python

依赖: 18 个包

解析: 8 份文档 / 104 页

索引: 175 chunks / 1024 维向量 / jieba BM25

建库: 12 秒(不含 MinerU 那头)

查询: 端到端 5~10 秒(混合召回快,瓶颈在 LLM 重排和最终生成)

费用: DashScope 跑完整套测试不到一块钱

就这。

如果你看完觉得"原来 RAG这么简单"——是的,它本来就这么简单。被讲玄乎是因为有人靠这个吃饭。

如果你看完觉得"细节也太多了"——是的,魔鬼就在这些细节里。Prompt少一句"不要编造",整个系统就成了高级胡说八道机;BM25记中文分词,召回率直接掉一半;表格被切碎,差旅费表全废。这事儿没有银弹。祛魅 + 抠细节,就这两条。

当然这个项目是简版的,肯定不够复杂,但足够带你过遍RAG的全貌。

P.S. 项目代码完整在 github.com/aleeeeexx/R… 已剔除原始资源文件和配置项,需要配置上自己的api key

P.P.S. 评论区不要再问"DashScope 和 OpenAI哪个好"。好用的是你自己跑通的那个。

一起搞ai 加我xiongfufu914131。

wechatcode.jpg