# rag-qa-system **Repository Path**: wang-guangshuooo/rag-qa-system ## Basic Information - **Project Name**: rag-qa-system - **Description**: 基于 LangChain + FAISS 的本地知识库 RAG 问答系统,含 Hit Rate@k / MRR 评测模块 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-21 - **Last Updated**: 2026-10-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 校园知识库 RAG 问答系统 [![Python](https://img.shields.io/badge/Python-3.10%2B-blue)](https://www.python.org/) [![LangChain](https://img.shields.io/badge/LangChain-RAG-green)](https://www.langchain.com/) [![FAISS](https://img.shields.io/badge/Vector%20Store-FAISS-orange)](https://github.com/facebookresearch/faiss) [![Streamlit](https://img.shields.io/badge/UI-Streamlit-red)](https://streamlit.io/) [![License](https://img.shields.io/badge/License-MIT-yellow)](LICENSE) [![CI](https://github.com/wang-guangshuooo/rag-qa-system/actions/workflows/ci.yml/badge.svg)](.github/workflows/ci.yml) 基于 LangChain + FAISS + Ollama 的本地知识库问答系统,支持文档检索与生成式回答。 > **开发时间**:2026.03 – 2026.06 完成主体开发;2026.09 完成开源整理(补充文档、容器化与 CI)。 > 项目为毕设前期调研,重点探索 RAG 在不同参数配置下的召回效果差异,并沉淀了一套可复用的调优与评测方法。 ## 目录 - [项目背景](#项目背景) - [技术栈](#技术栈) - [项目结构](#项目结构) - [快速开始](#快速开始) - [使用说明](#使用说明) - [评测说明](#评测说明) - [评测指标](#评测指标) - [评测结果](#评测结果) - [检索方式与混合检索](#检索方式与混合检索) - [RAG 调优](#rag-调优) - [已知问题](#已知问题) - [更新日志](#更新日志) - [License](#license) ## 项目背景 毕设前期调研项目,主要探索 RAG(Retrieval-Augmented Generation)在校园场景(教务通知、课程资料等)的应用效果,以及不同配置对召回效果的影响。 ## 技术栈 - **框架**:LangChain、Streamlit - **向量数据库**:FAISS - **Embedding**:sentence-transformers(BAAI/bge-small-zh-v1.5) - **生成模型**:Ollama(qwen2.5:7b) - **容器化**:Docker ## 项目结构 ``` rag-qa-system/ ├── app.py # Streamlit 主界面 ├── config.py # 配置管理 ├── rag/ # RAG 核心模块 │ ├── document_loader.py # 文档加载 │ ├── chunker.py # 文本切分(recursive / fixed / paragraph 三策略) │ ├── indexer.py # 向量索引 │ ├── retriever.py # 检索入口(vector / bm25 / hybrid 三种方式) │ ├── bm25.py # BM25 词面检索(纯 Python,不依赖模型) │ ├── fusion.py # RRF 融合(只用名次,免疫量纲) │ ├── hybrid.py # 混合检索:向量 + BM25 + RRF │ ├── rerank.py # 重排句柄(状态可观测,不静默降级) │ └── generator.py # 生成回答 ├── eval/ # 评测模块 │ ├── dataset.py # 评测集加载与校验 │ ├── metrics.py # 检索指标 │ ├── guard.py # 有效性护栏(语料规模 + 题集词面泄漏) │ ├── grid.py # 实验网格构造 │ ├── stats.py # 波动统计与配对检验 │ └── run_eval.py # 实验入口 ├── utils/ │ └── logger.py ├── data/ # 数据目录 │ ├── docs/ # 文档存放 │ ├── corpus_synth/ # 受控合成语料(38 篇) │ ├── eval/ # 评测数据(含 164 题合成评测集) │ └── rag_checklist.md # 调优清单 └── scripts/ ├── gen_corpus.py # 生成合成语料与事实表 ├── gen_evalset.py # 由事实表反推评测集 ├── audit_corpus.py # 无模型语料审计(可作 CI 门禁) ├── start.sh # 启动脚本 └── init_data.sh ``` ## 快速开始 ### 前置要求 - Python 3.10+ - Ollama 已部署并运行 - Ollama 模型已拉取(qwen2.5:7b) 拉取生成模型: ```bash ollama pull qwen2.5:7b ``` > Embedding(`BAAI/bge-small-zh-v1.5`)与 Rerank(`BAAI/bge-reranker-base`) > 模型由 `sentence-transformers` 在首次运行时自动从 HuggingFace 下载, > **不通过 `ollama pull` 获取**。国内网络可设置镜像: > ```bash > export HF_ENDPOINT=https://hf-mirror.com > ``` > 若镜像不可用,可先从 ModelScope 下载,再用 `EMBEDDING_MODEL` / > `RERANKER_MODEL` 指向本地目录(见 `.env.example`)。 ### 方式一:Docker 启动(推荐) ```bash docker-compose up --build ``` 访问 http://localhost:8501 ### 方式二:本地启动 ```bash pip install -r requirements.txt cp .env.example .env bash scripts/start.sh ``` ## 使用说明 ### 文档管理 1. 将 txt / md / pdf 文件放入 `data/docs/` 目录 2. 在侧边栏点击「重建索引」 3. 系统自动完成切分、向量化、入库 ### 问答 1. 输入问题,点击「提问」 2. 系统检索相关文档片段 3. 基于检索结果生成回答,附带引用来源 ### 配置参数 | 参数 | 说明 | 默认值 | |------|------|------| | chunk_size | 文本块大小 | 500 | | chunk_overlap | 相邻块重叠长度 | 50 | | Top-K | 检索返回结果数 | 3 | | 启用 Rerank | 是否使用重排序 | false | ## 评测说明 运行对比实验: ```bash python -m eval.run_eval # 默认:向量单路 ``` 评测维度: - 检索方式 `retrieval_mode`(vector / bm25 / hybrid) - 不同 chunk_size(200 / 500 / 800) - 不同分块策略(recursive / fixed / paragraph)与重叠窗口 `overlap` - 不同 Top-K(1 / 3 / 5) - 有无 Rerank ```bash # 只跑词面基线:不加载 embedding,无模型机器上也能出真实指标 python -m eval.run_eval --docs-dir data/corpus_synth \ --qa-file data/eval/qa_pairs_synth.json --retrieval-modes bm25 --rerank false # 三路对比(需要 embedding 模型) python -m eval.run_eval --retrieval-modes vector,bm25,hybrid # 干跑:只判定"结果能不能用"的两条判据,不加载模型 python -m eval.run_eval --dry-run --retrieval-modes vector,bm25,hybrid ``` 输出结果保存至 `data/eval_results.json`;护栏不通过时**拒绝写入**该文件, 过程记录改写为 `data/eval_results.rejected.json` 并返回退出码 2。 ## 评测指标 | 指标 | 含义 | 计算方式 | | --- | --- | --- | | Hit Rate@k | Top-K 结果中是否命中正确来源文档 | 命中记 1,否则记 0,对全部问题取均值 | | MRR@k | 正确来源在结果中的排名质量 | 取 1/rank,未命中记 0 | | 回答质量分 | 关键词覆盖率 + 幻觉倾向惩罚 | 关键词覆盖占 70%,模糊措辞惩罚占 30% | 评测集由 `data/eval/qa_pairs.json` 提供,每条含标准来源文档与答案关键词。 > **来源匹配口径**:加载器写出的 `source` 是带目录的完整路径 > (`./data/docs/选课.md`),而 `qa_pairs.json` 标注的是文件名(`选课.md`)。 > 因此 `eval/metrics.py` 会先把两侧统一归一化为文件名再比较,避免 > 「同一次命中因路径写法不同被判为未命中」——这个坑本项目真实踩过, > 详见下方评测结果。 ## 评测结果 在仓库自带的示例语料(`data/docs/`,3 篇)与 10 条评测问题上完整跑一遍: ```bash python eval/run_eval.py ``` 结果:**全部 18 组配置均为满分**,原始数据见 `data/eval_results.json`。 | chunk_size | Top-K | Rerank | 组数 | Hit@K | MRR@K | | --- | --- | --- | --- | --- | --- | | 200 / 500 / 800 | 1 / 3 / 5 | false / true | 18 | 1.0000 | 1.0000 | ### 来源匹配口径的修复前后对照 上面这个「满分」只有在口径正确的前提下才成立。之前的一版实现里, `hit_rate` / `mrr` 用 `==` 直接比较来源字符串,结果是: 固定 `chunk_size=500`、`Top-K=3`、`Rerank=false`(即 `config.py` 的默认值): | | Hit@3 | 命中数 | MRR@3 | | --- | --- | --- | --- | | 修复前(`==` 精确比较) | 0.0000 | **0 / 10** | 0.0000 | | 修复后(归一化为文件名) | **1.0000** | **10 / 10** | **1.0000** | **检索结果本身没有变,变的只是匹配口径。** 实际写法的差异是: ``` qa_pairs.json 标注 : '教务通知_选课.md' retriever 返回 : './data\docs\教务通知_选课.md' == 结果 : False ``` 也就是说,修复前这两项指标**结构性恒为 0**——不是「效果差」,而是 **指标本身坏了**。基于它做的任何调优结论(哪个 chunk_size 更好、 Rerank 有没有用)都会失真,因为所有配置都会得到同样的 0。 这也说明:**评测跑通不等于评测可信,先验证指标在已知正确答案上能给非零值。** 逐题核对:10 个问题的 Top-1 来源与标注来源**全部一致**,不存在误命中, 所以这是真实结果。但它反映的是**天花板效应**,而不是「参数调优带来的提升」—— 示例语料只有 3 篇且主题词面差异极大(选课 / 考试 / 数据结构), 用 `bge-small-zh` 检索时任何配置都能一次命中,指标在基线处就已饱和, **无法区分 chunk_size、Top-K 与 Rerank 的差异**。 > 结论:当前自带语料只够验证链路是否跑通,**不足以支撑配置对比结论**。 > 要得到有区分度的对比,需要扩大语料规模并加入同主题干扰文档。 > 详见 [已知问题](#已知问题)。 ### 语料规模护栏:把「指标不可用」变成退出码 上面那张「18 组全 1.0000」的结果,现在会被代码**直接拒绝**。 根因是量级:默认 `chunk_size=500` 时整个语料只切出 **7 个块**,而实验网格最大跑到 `Top-K=5`——相当于「从 7 个块里挑 5 个」。再开启 Rerank,检索会请求 `k×3=15` 个, **超过全库**并被向量库静默截断。此时「命中」是结构性必然,指标不承载任何信息。 `eval/guard.py` 把这条判断做成纯函数判据(不依赖模型),`eval/run_eval.py` 在写结果前 强制校验:不通过就**拒绝写正式结果文件**(退出码 2,过程记录改写入 `*.rejected.json`)。 ```bash # 无需模型即可审计语料规模:退出码 2 表示语料不支撑结论 python scripts/audit_corpus.py --docs-dir data/docs --strategies recursive,fixed,paragraph ``` 实测(`data/docs/`,3 篇):**54 组配置中 48 组被判无效**,退出码 2。 同一命令跑扩好的语料则是 54/54 通过。 ### 扩好的语料与评测集 | 资产 | 规模 | 位置 | | --- | --- | --- | | 受控合成语料 | **38 篇 / 38403 字符**(12 类校园业务 × 3 校区 + 2 篇已废止旧版) | `data/corpus_synth/` | | 评测集 | **310 题**(292 positive + 18 negative) | `data/eval/qa_pairs_synth.json` | | ├ 引用型 | 146 题(沿用源文档措辞) | `style=quoted` | | ├ 改写型 | 146 题(口语化提问,不照抄原文) | `style=paraphrased` | | └ 手写负样本 | 18 题(语料中确实没有答案) | `style=handwritten` | | 事实表 | 38 条结构化事实,评测集由它反推生成 | `data/eval/synth_facts.json` | ```bash python scripts/gen_corpus.py # 生成语料 + 事实表 python scripts/gen_evalset.py # 由事实表反推评测集(含负样本) python -m eval.run_eval --docs-dir data/corpus_synth \ --qa-file data/eval/qa_pairs_synth.json \ --strategies recursive,fixed,paragraph ``` 语料刻意构造了检索难度,而不是堆一堆互不相关的文档: - 三校区版本**只有数字与地点不同**,问题必须同时匹配「主题 + 校区」才能命中; - 两篇已废止旧版与现行版本**同名同校区、数字不同**,用于检验「现行规定」类 问题是否会被失效文件带偏; - 单篇约 1000 字、事实分散在正文与表格中,因此「同文档取两处事实」的多跳题 会随 `chunk_size` 变化而受影响——这正是分块消融实验应当显现的效应; - 每道事实题生成**两种提法**(引用型 / 改写型),使"词面能不能撞上"成为一个 可控制的实验变量,而不是一个无法观测的混杂因素。见下一节的实测。 评测集的每道题都保证关键词确实出现在其标注的源文档中,该断言写在 `tests/test_eval_dataset.py` 里,改了语料忘改标注会直接测试失败。 > 合成语料的性质已明确标注:由 `scripts/gen_corpus.py` 生成、事实表随仓库发布, > **不代表真实校园文档的分布**,只用于在受控条件下做配置对比。 > 换成真实语料时,评测集需按同一套字段重新标注。 这次排查的完整过程写成了一篇文章,含最小复现、定位步骤与同类口径陷阱清单: > [《你的 RAG 召回率可能是假的:一个 `==` 比较让 Hit@K 恒为 0》](docs/article-csdn-hit-rate-zero.md) ## 检索方式与混合检索 系统支持三种检索方式,`retrieval_mode` 是实验网格里的一维(`eval/grid.py`): | 方式 | 组成 | 特点 | | --- | --- | --- | | `vector` | FAISS 向量召回 | 对同义改写稳健,对数字、编号、专有名词不敏感 | | `bm25` | 词面检索(`rag/bm25.py`) | 词面撞上就极稳,换个说法就问不出来;**不依赖任何模型** | | `hybrid` | 向量 + BM25 + RRF 融合 | 取两者之长;候选集先融合再重排 | ### 为什么用 RRF 融合,而不是"分数加权求和" 两路分数**量纲不同、方向相反**:向量侧是 L2 距离(越小越相关),BM25 侧是词频加权分 (越大越相关)。直接相加必须先归一化,而 min-max 归一化对离群分数极敏感——某一路出现 一个极大分数,另一路会被压到接近 0,融合实际退化为"只听一路"。 RRF(Cormack et al., SIGIR 2009)**只用名次、不看分数**: ``` RRF(d) = Σ w_r / (k + rank_r(d)) k 默认 60 ``` 因此天然免疫量纲差异与离群值。`rag/fusion.py` 是该公式的纯函数实现, `tests/test_rag_fusion.py` 用性质测试钉住了"排序只取决于名次"这一条。 ### BM25 基线的实测结果:先撞上"天花板",补题之后才拿到方向 先把词面基线跑出来——它不需要 embedding 模型: ```bash python -m eval.run_eval --docs-dir data/corpus_synth \ --qa-file data/eval/qa_pairs_synth.json \ --retrieval-modes bm25 --chunk-sizes 200,500,800 --top-ks 1,3,5 --rerank false ``` **第一阶段(只有引用型题目):比较不出任何东西。** | chunk_size | 块数 | Hit@1 | Hit@3 | MRR@3 | | --- | ---: | ---: | ---: | ---: | | 200 | 272 | 0.9178 | 1.0000 | 0.9532 | | 500 | 114 | 0.9726 | 1.0000 | 0.9863 | | 800 | 76 | 0.9795 | 1.0000 | 0.9886 | 单靠词面匹配就把 146 题打满 Hit@3。此时"混合检索 vs 单路检索"不可能有增益—— 不是融合没做好,而是比较没有区分度。这次运行被上述护栏**拒绝写入正式结果** (退出码 2),即为该判据的一个实例。 **第二阶段(补上改写型题目):区分度出现了。** 按提问方式分组后: | chunk_size | Top-K | 引用型 Hit@K | 改写型 Hit@K | 差距 | | ---: | ---: | ---: | ---: | ---: | | 200 | 1 | 0.9178 | 0.5890 | **0.3288** | | 200 | 3 | 1.0000 | 0.6986 | **0.3014** | | 500 | 1 | 0.9726 | 0.6438 | **0.3288** | | 500 | 3 | 1.0000 | 0.7397 | **0.2603** | | 800 | 3 | 1.0000 | 0.7466 | **0.2534** | | 800 | 5 | 1.0000 | 0.7808 | **0.2192** | **这才是混合检索可以发力的地方**:词面检索在"换个说法"的题上还有 22~33 个百分点的 空缺,向量召回与融合正是要补这一段。同一份数据上"引用型打满、改写型掉三成"这个 现象本身,就是"词面检索对措辞敏感"这一判断的量化证据。 > 报告口径提醒:这两组数必须**分开写**。把引用型与改写型混在一起取均值,得到的 > 数字只反映两组题的配比,既不表示"照抄时能不能找到",也不表示"换个说法能不能找到"。 > `eval/metrics.py: aggregate_by_style` 就是为此存在的,`run_eval` 会把分组结果 > 一起写进结果文件。 ### 由此新增的第二条护栏:题集"词面泄漏" 语料规模护栏管的是"分母太小"(Top-K 逼近语料块总数);这里管的是"题面泄题": **问题照抄了源文档的措辞**,于是词面检索在基线处就饱和。两者同属一类—— 指标被数据结构决定,而不是被方法决定。 `eval/guard.py: check_eval_set_leakage` 把这条判断做成纯函数(字符二元组重合率, 零依赖、可离线算),并在 `eval/run_eval.py` 里强制检查。**只在网格含 bm25/hybrid 臂时阻断结果**——纯向量实验不受此判据约束。 阈值由实测标定,不是拍的。判据有两条(任一命中即不通过): | 判据 | 阈值 | 改造前实测 | 补改写题后 | | --- | ---: | ---: | ---: | | **照抄型占比**(重合率 > 0.4 的题) | ≥ 80% → 不通过 | **100%** | 56.5% → 通过 | | **有区分度的题量**(重合率 ≤ 0.4) | < 50 道 → 不通过 | **0 道** | 127 道 → 通过 | 第二条(干净题量下限)是为了防止"用少数几道干净题把比例凑达标":真正能承载 「混合检索是否更好」这个结论的样本量是有区分度的**绝对题数**,它必须单独设下限。 > **中位数只作描述性统计,不参与判定。** 当评测集里同时有引用型与改写型两组题时, > 中位数恰好落在两组的交界处,它随"两组题量的配比"移动,而不随"结论可不可信"移动。 > 拿它当判据,等于把判据换成了题量配比。改造前的中位数是 0.667,两组混合后是 0.500, > 但真正变化的是"照抄型占比从 100% 降到 56.5%"。 要解除该判据,需补充**改写型**评测题。`scripts/gen_evalset.py` 现在会为每道事实题 自动生成一种口语化提法(`style=paraphrased`),并在生成后自检:**改写型的字面重合率 必须比引用型低出实质差距,否则直接报错退出**——防止"改写只换了几个虚词"这种 看起来完成了、实际没效果的情况。 > 顺带排除了一种想当然的归因:把校区名从问题里去掉后,重合率中位数只从 0.667 降到 > 0.630——说明重叠来自**业务措辞**本身,而不是那几个校区词。所以改写必须动提问框架 > 与标签词,单纯删词是没用的。 ## RAG 调优 详细调优建议见 `data/rag_checklist.md`,包含: - 切分策略选择 - Embedding 模型选型 - 召回参数调整 - Rerank 使用建议 - 评测方法 ## 已知问题 - **改写题由模板生成,覆盖的是"提问框架"而不是"用户真实语料"**:改写型题目通过 替换提问句式与标签词降低字面重合,能稳定地把词面检索的分数拉开,但它不等于 真实学生的说话方式。换到真实语料时,改写题应当从真实提问记录里采集。 - **生成阶段指标缺失**:`eval/run_eval.py` 只评检索(Hit@K / MRR),全程不调用 `AnswerGenerator`,因此忠实度、幻觉率、引用准确率三项完全空白。RAG 论文最需要的 恰恰是这几项。 - **负样本尚未接入评测**:`qa_pairs_synth.json` 里的 18 条负样本用于测「库里没有 答案时会不会硬编」,但拒答是生成阶段的行为,当前它们只被排除在 Hit@K 分母之外 (混进去会系统性拉低指标),需要一个调用生成器的独立评测脚本。 - **`answer_quality_score` 语义错位**:参数名为 `answer`、注释写「评估回答质量」, 实际传入的是 `retrieved_docs[0]["content"]`——评的是检索片段而非生成的回答; 且 0.7/0.3 权重是拍定的,模糊措辞惩罚会误伤正常表述。更关键的是它没有参与任何 实验,却占了 32 个单测中的一部分。 - **检索指标不全**:只有 Hit@K / MRR,缺 Precision@K / Recall@K / NDCG@K。 - **上传文件未做路径校验**:`app.py` 直接用 `file.name` 落盘,无扩展名白名单 (`type=` 只约束前端),也未过滤 `../` 路径穿越。 - **`embedding` 实例未跨配置复用**:`run_eval` 每组配置各自构造一次 `VectorIndexer`,跑 54 组网格会重复加载 embedding 模型 54 次。 `VectorIndexer` 已支持注入 `embeddings` 参数,`run_eval` 尚未使用。 - Rerank 模块首次加载较慢(CPU 环境需加载 `bge-reranker-base`) - PDF 解析对扫描版支持有限 - 大文档建议预先拆分 - **Windows + Conda 环境注意**:若 `torch` 与 `pyarrow` 的 DLL 版本冲突, `import langchain_text_splitters` 可能直接触发 access violation。 在导入 `sentence_transformers` 之前先 `import pyarrow` 即可规避。 > **生成阶段指标、负样本拒答评测、质量分语义、Precision/Recall/NDCG 这四项** > 都属于"要跑生成器与更完整统计"的一批,安排在论文实验章补;本仓库当前把 > **检索侧**的实验可信度先做扎实。 ## 更新日志 版本变更记录见 [CHANGELOG.md](CHANGELOG.md)。 ## 贡献 欢迎提交 Issue 与 PR,开发约定见 [CONTRIBUTING.md](CONTRIBUTING.md)。 ## License 本项目基于 [MIT License](LICENSE) 开源。 --- 有问题欢迎交流。