一个围绕“可讲清楚、可跑起来、可扩展”设计的模块化 RAG 项目。当前仓库的核心是 4 条能力链路:
- 文档摄取:
PDF -> Chunk -> Transform -> Embedding -> Chroma + BM25 - 查询检索:
Dense -> Sparse(BM25) -> RRF Fusion -> Optional Rerank - MCP 暴露:把检索能力包装成标准 MCP Server,供支持 MCP 的 AI 客户端调用
- 可观测性:通过 Streamlit Dashboard 查看配置、数据、摄取轨迹、查询轨迹和评估结果
这份 README 不再沿用原来的教程式介绍,而是严格按当前代码的真实结构和真实运行方式说明,方便你自己跑项目,也方便面试时把项目讲清楚。
这个项目本质上不是“一个问答脚本”,而是一套模块化知识检索系统,包含:
src/ingestion/:负责离线数据摄取src/core/query_engine/:负责在线检索与排序src/mcp_server/:负责把检索能力暴露为 MCP 工具src/observability/dashboard/:负责可视化管理与追踪src/libs/:负责底层可插拔 Provider 实现
面试里可以直接这样概括:
我做的是一套模块化 RAG 系统,离线侧负责 PDF 文档解析、分块、元数据增强、向量化和双索引落库;在线侧负责 Dense + BM25 混合召回、RRF 融合和可选重排;然后通过 MCP Server 对外暴露标准工具接口,同时用 Streamlit Dashboard 做链路观测和数据管理。
先说“现在代码真的支持什么”,这比泛泛而谈更重要。
- 稳定的摄取入口是 PDF。
scripts/ingest.py当前只递归处理.pdf文件。 Dashboard上传框允许pdf/txt/md/docx,但当前IngestionPipeline实际绑定的是PdfLoader,所以稳定可用的仍然是 PDF。- 检索链路已经完整:Dense 检索、Sparse(BM25) 检索、RRF 融合、可选 Rerank 都在代码里。
- 默认配置下
rerank、vision_llm、evaluation、摄取阶段 LLM 增强都是关闭的,目的是先把主链路稳定跑通。 main.py不是正式入口,实际入口是scripts/*.py和python -m src.mcp_server.server。
这几个边界在面试里反而是加分项,因为你能清楚说明“代码里已有扩展位”和“当前默认启用能力”的区别。
建议重点讲下面这棵目录树,而不是把整个仓库所有文件背下来:
MODULAR-RAG-MCP-SERVER/
├─ config/
│ ├─ settings.yaml # 主配置文件
│ └─ prompts/ # 摄取增强与图片描述提示词
├─ scripts/
│ ├─ ingest.py # 文档摄取 CLI
│ ├─ query.py # 检索查询 CLI
│ ├─ evaluate.py # Golden Set 评估 CLI
│ └─ start_dashboard.py # Streamlit 启动脚本
├─ src/
│ ├─ core/
│ │ ├─ settings.py # 配置加载与校验
│ │ ├─ types.py # Document / Chunk / RetrievalResult 等核心类型
│ │ ├─ query_engine/ # 在线检索主链路
│ │ ├─ response/ # 引用与响应封装
│ │ └─ trace/ # Trace 记录结构
│ ├─ ingestion/
│ │ ├─ pipeline.py # 摄取总编排
│ │ ├─ chunking/ # 文档分块
│ │ ├─ transform/ # chunk 精修 / metadata 增强 / 图片描述
│ │ ├─ embedding/ # dense + sparse 编码
│ │ └─ storage/ # Chroma / BM25 / image index 落库
│ ├─ libs/
│ │ ├─ llm/ # OpenAI / Azure / Ollama / DeepSeek LLM
│ │ ├─ embedding/ # OpenAI / Azure / Ollama Embedding
│ │ ├─ reranker/ # none / cross_encoder / llm reranker
│ │ ├─ loader/ # 当前默认 PdfLoader
│ │ ├─ splitter/ # recursive / semantic / fixed_length 抽象
│ │ └─ vector_store/ # 当前默认 Chroma
│ ├─ mcp_server/
│ │ ├─ server.py # MCP stdio 入口
│ │ ├─ protocol_handler.py # 工具注册与协议处理
│ │ └─ tools/ # query_knowledge_hub / list_collections / get_document_summary
│ └─ observability/
│ ├─ dashboard/ # Streamlit 多页面面板
│ └─ evaluation/ # 评估运行器与 evaluator
├─ tests/ # unit / integration / e2e
├─ pyproject.toml # 依赖与 uv 脚本入口
└─ README.md
core负责“协议和编排层”的公共能力,不关心具体厂商。libs负责“底层后端实现”,比如 OpenAI、Ollama、Chroma、CrossEncoder。ingestion负责离线建库。query_engine负责在线检索。mcp_server负责把在线检索封装成标准工具接口。observability负责让系统可视化和可追踪。
这种分层在面试里很好讲,因为它对应的是“业务编排层”和“可替换基础设施层”的分离。
PDF
-> PdfLoader
-> DocumentChunker
-> ChunkRefiner
-> MetadataEnricher
-> ImageCaptioner
-> DenseEncoder
-> SparseEncoder
-> VectorUpserter(Chroma)
-> BM25Indexer
-> ImageStorage
User Query
-> QueryProcessor
-> DenseRetriever
-> SparseRetriever
-> RRFFusion
-> Optional Reranker
-> ResponseBuilder
MCP Client / CLI / Dashboard
-> QueryKnowledgeHubTool / scripts/query.py / Dashboard page
-> HybridSearch
-> Vector Store + BM25 Index
项目已经适配 uv,推荐统一用 uv 管理环境。
uv venv
# Windows PowerShell
.\.venv\Scripts\activate
uv sync说明:
uv sync会按 pyproject.toml 安装主依赖。- 当前依赖里已经包含
chromadb、streamlit、mcp、markitdown[pdf]、pymupdf、openai、httpx等运行所需包。 - 如果你要本地向量化,需要确保本机 Ollama 服务已启动,并且已经拉好 embedding 模型。
所有运行都依赖 settings.yaml。
当前推荐的起步思路是:
- 文本生成
llm:走阿里百炼的 OpenAI 兼容接口 - 向量化
embedding:走本地 Ollama - 视觉模型
vision_llm:先关闭 rerank:先关闭- 摄取阶段
chunk_refiner.use_llm和metadata_enricher.use_llm:先关闭
这样做的原因很简单:
- 先把主链路稳定跑通,比一上来堆功能更重要。
- 检索质量的基础首先来自文档质量、chunk 质量、embedding 质量和双路召回,不是先靠重排或多模态。
- 本地 8G 机器更适合承担 embedding,不适合再叠图像模型和大参数生成模型。
- 生成模型走百炼,兼顾效果与资源;embedding 走本地,兼顾成本与控制力。
命令:
uv run python scripts/ingest.py --path tests/fixtures/sample_documents --collection demo这里会发生 6 个阶段:
-
完整性检查
计算文件 SHA256,查看是否已经处理过。未开启--force时,相同文件会跳过,避免重复建库。 -
文档加载
PdfLoader用MarkItDown提取文本,并在可用时用PyMuPDF提取图片,插入[IMAGE: xxx]占位符。 -
文档分块
DocumentChunker调用底层SplitterFactory,按chunk_size和chunk_overlap生成 chunk,并补齐chunk_id、chunk_index、source_ref等元数据。 -
Transform 阶段
ChunkRefiner:规则清洗,可选 LLM 精修MetadataEnricher:抽标题、摘要、标签,可选 LLM 增强ImageCaptioner:仅在vision_llm.enabled=true时对引用图片做描述并拼回文本
-
编码阶段
DenseEncoder:把 chunk 编成稠密向量,供语义检索使用SparseEncoder:把 chunk 编成稀疏表示,本质上是统计分词后的词项频次和文档长度,供 BM25 检索使用
-
存储阶段
VectorUpserter把 dense 向量写入 ChromaBM25Indexer把 sparse 信息写入data/db/bm25/<collection>ImageStorage记录图片索引
- Chroma:
data/db/chroma - BM25 索引:
data/db/bm25/<collection> - 图片:
data/images/<collection>和data/images/<doc_hash> - 摄取历史:
data/db/ingestion_history.db - Trace:
logs/traces.jsonl
命令:
uv run python scripts/query.py --query "这个项目的核心能力是什么" --collection demo --verbose查询时会经过下面几个阶段:
-
QueryProcessor
对问题做标准化、分词、停用词过滤,并支持collection:xxx这类轻量过滤语法。 -
DenseRetriever
把 query 做 embedding,在 Chroma 中做向量召回。 -
SparseRetriever
对 query 分词后,在 BM25 索引中做关键词召回。 -
RRFFusion
用 Reciprocal Rank Fusion 融合 dense 和 sparse 的排序结果。 -
CoreReranker
如果rerank.enabled=true,再对融合结果精排;否则直接返回融合结果。 -
输出结果
CLI 会打印 chunk 的分数、来源路径、页码、chunk 编号和文本片段;--verbose会额外打印 dense、sparse、fusion、rerank 的中间结果。
- Dense 检索擅长语义匹配,但对专有名词、缩写、错拼不稳定。
- Sparse(BM25) 擅长关键词和术语精确匹配,但语义泛化弱。
- RRF 融合的好处是不需要强依赖两边分数尺度统一,工程上更稳,解释成本也低。
面试里可以直接说:
我没有只做向量检索,而是做了 Dense + BM25 的混合召回。Dense 负责语义覆盖,Sparse 负责专有名词和术语命中,再用 RRF 做无监督融合,这样比单一路径更稳。
命令:
uv run python scripts/start_dashboard.py --port 8501Dashboard 当前有 6 个页面:
Overview:看当前组件配置、Chroma collection 统计、trace 数量Data Browser:浏览库里的 chunk 和元数据Ingestion Manager:上传文档并触发摄取,查看和删除文档Ingestion Traces:查看每次摄取的阶段结果Query Traces:查看每次查询的中间阶段Evaluation Panel:查看评估相关信息
这个面板的作用不是“做产品前端”,而是帮助你解释和排查系统行为,属于工程观测能力。
命令:
uv run mcp-server这个服务启动后,会通过 stdio 暴露 3 个工具:
query_knowledge_hublist_collectionsget_document_summary
这部分的核心含义是:
- RAG 是系统内部能力
- MCP 是对外接口层
也就是说,这个项目不是“为了 MCP 才有 RAG”,而是“先有 RAG 能力,再通过 MCP 暴露给 Copilot、Claude Desktop、Cursor 等支持 MCP 的客户端”。
命令:
uv run python scripts/evaluate.py --collection demo默认会读取 tests/fixtures/golden_test_set.json,然后:
- 用当前 Hybrid Search 对测试问题做召回
- 调用配置里的 evaluator 计算指标
- 输出 aggregate metrics 和 per-query metrics
当前默认配置下 evaluation.enabled=false,因为这一步更适合在主链路稳定后再开启。它的意义不是让项目“更完整”,而是让你能用数据驱动调参,而不是凭感觉调。
本项目所有关键参数都在 settings.yaml。
作用:负责生成和摄取阶段可选的文本 LLM 增强。
关键字段:
provider:openai / azure / ollama / deepseekmodel:具体模型名base_url:OpenAI 兼容接口地址或 Ollama 地址api_key:兼容接口密钥temperature:生成稳定性max_tokens:回答长度上限
为什么默认建议:
provider: openai+ 百炼兼容接口,是因为代码里已经兼容 OpenAI SDK 调用方式,接入简单。temperature: 0.0适合知识问答和摄取增强,减少回答抖动,更利于复现和评估。max_tokens: 4096给问答和可能的元数据增强留足空间,但不会无限放大成本。
这一节描述的是“稠密编码”这一路的配置,也就是 Dense Retrieval 使用的 embedding 模型配置。
但如果从完整流程看,文档在上传并分块之后,编码阶段实际上会同时做两件事:
- 稠密编码:
DenseEncoder调用 embedding 模型,把每个 chunk 转成 dense vector,写入 Chroma,后续 query 也会用同一个 embedding 模型转成 query vector 做语义召回。 - 稀疏编码:
SparseEncoder对每个 chunk 做分词、词频统计和文档长度统计,再由BM25Indexer写入 BM25 索引,后续 query 也会先分词,再走 BM25 关键词召回。
所以更完整的说法不是“上传后只做 embedding”,而是:
文档分块后会同时完成稠密编码和稀疏编码。稠密编码服务于语义检索,稀疏编码服务于 BM25 关键词检索,查询阶段再把两条召回路径融合。
这里之所以单独叫 embedding,只是因为 settings.yaml 里的这个配置块只负责稠密编码模型本身的参数。
作用:控制 chunk 和 query 的稠密向量生成方式。
关键字段:
provider:openai / azure / ollamamodel:embedding 模型名dimensions:目标向量维度base_url:本地 Ollama 或兼容接口地址
当前推荐配置:
provider: ollamamodel: qwen3-embedding:4bdimensions: 1536
为什么这样配:
qwen3-embedding:4b在本地质量和资源之间比较平衡。1536D是一个工程折中值,足够表达语义,又比更高原生维度更省存储和检索开销。- 项目已经支持把
dimensions传给 Ollama/api/embed,所以这里不是“写着看”,而是真实生效。
注意:
- 同一个 collection 内向量维度必须一致。
- 切换 embedding 模型或维度后,要重新 ingest,不要混用旧索引。
这一部分没有单独的 bm25: 配置块,但它是当前项目检索链路里的另一半。
在摄取阶段:
SparseEncoder会对每个 chunk 做分词- 统计每个 chunk 的词项频次、唯一词数、文档长度
- 再由
BM25Indexer写入data/db/bm25/<collection>
在查询阶段:
QueryProcessor会先对 query 做标准化、分词和停用词过滤SparseRetriever再基于 BM25 索引做关键词召回
为什么这条链路必须讲清楚:
- 稠密向量更擅长语义相似
- BM25 更擅长专有名词、缩写、版本号、术语和关键词精确匹配
- 两条路径并行召回再做融合,才构成这个项目的 Hybrid Search
所以面试里讲“编码阶段”时,建议直接说完整:
文档分块后我没有只做 embedding,而是同时做了稠密编码和稀疏编码。稠密编码把 chunk 存进 Chroma 做语义召回,稀疏编码把词频统计写进 BM25 索引做关键词召回,查询时两条路径并行检索,再用 RRF 融合。
作用:给 PDF 里的图片做描述,把视觉信息转成文本,回到文本检索链路。
关键字段:
enabledprovidermodelbase_urlmax_image_size
为什么默认关闭:
- 多模态会显著增加摄取成本和复杂度。
- 8G 本地机不适合再叠一层图像推理。
- 先把纯文本链路跑通,再开视觉链路,更容易定位问题。
作用:dense 向量存储。
当前默认:
provider: chromapersist_directory: ./data/db/chroma
为什么用 Chroma:
- 本地可直接持久化,适合教程型和个人项目验证。
- 对小中型知识库启动成本低。
- 与当前代码集成已经完整。
关键字段:
dense_top_k: 20sparse_top_k: 20fusion_top_k: 10rrf_k: 60
为什么这样配:
- 先从 dense 和 sparse 各召回更多候选,是为了给融合保留足够搜索空间。
fusion_top_k=10适合大多数问答场景,既能覆盖答案,又不会把太多噪声带入后续阶段。rrf_k=60是很常见的经验值,排序衰减较平滑,工程上稳定。
这组参数的讲法可以是:
我把召回和最终返回做了分层,先各自多召回,再融合裁剪,这样能兼顾召回率和后续成本。
关键字段:
enabledprovider: none / cross_encoder / llmmodeltop_k
为什么默认关闭:
- 重排要么增加本地模型负担,要么增加额外 API 成本。
- 在数据量不大、主链路还没稳定前,先看 Hybrid Search 的基础效果更合理。
- 关闭重排后,系统更容易解释,也更适合初始调试。
什么时候开:
- 你发现 Top20 里有答案,但 Top5 排不进去。
- 你需要进一步优化查准率。
cross_encoder 和 llm 的差异:
cross_encoder:更偏检索任务,速度通常比 LLM 重排更可控llm:解释性更强,但成本更高、延迟更高
关键字段:
chunk_size: 1000chunk_overlap: 200splitter: recursivebatch_size: 100chunk_refiner.use_llmmetadata_enricher.use_llm
为什么这样配:
chunk_size=1000:避免 chunk 太碎导致上下文断裂,也避免太长导致检索粒度太粗。chunk_overlap=200:让相邻 chunk 保留一定上下文,减少答案刚好落在边界上的问题。recursive:是当前仓库最稳定的 splitter,适合先跑通。batch_size=100:兼顾吞吐与内存占用。chunk_refiner.use_llm=false:避免在初期引入额外不稳定因素。metadata_enricher.use_llm=false:先依赖规则抽取标题/摘要/tag,成本更低、更可复现。
这部分在面试里可以这样讲:
我把 chunk 相关参数定在一个偏稳的区间,优先保证检索稳定性和工程成本,而不是一开始追求复杂策略。后续如果要继续优化,我会基于评估集再调整 chunk size、overlap 和是否启用 LLM 增强。
建议按下面顺序,不要一上来同时开所有功能:
- 配好
llm和embedding - 保持
vision_llm=false - 保持
rerank=false - 保持摄取阶段 LLM 增强为
false - 先 ingest 样例 PDF
- 再 query 验证检索结果
- 再开 dashboard 看 traces
- 最后再尝试 rerank、evaluation、vision
推荐命令:
uv run python scripts/ingest.py --path tests/fixtures/sample_documents --collection demo
uv run python scripts/query.py --query "这个项目的核心能力是什么" --collection demo --verbose
uv run python scripts/start_dashboard.py
uv run mcp-server如果你要在面试里用 2 到 3 分钟介绍这个项目,可以按下面顺序讲:
我做的是一个模块化 RAG 系统,离线侧完成文档解析、分块、向量化和双索引建库,在线侧完成混合召回和排序,并通过 MCP 协议把检索能力暴露给外部 AI 客户端。
- 我把
core和libs分开,是为了把业务编排和底层 Provider 解耦。 - 我做 Dense + BM25 混合召回,是为了兼顾语义匹配和术语匹配。
- 我把 MCP 单独做成接口层,是为了让这套 RAG 可以复用到多个 AI 客户端,而不是绑死在一个前端页面里。
- 我保留 Dashboard 和 Trace,是为了让整个链路可观察、可调试、可解释。
- 默认先关闭 rerank、视觉模型和摄取 LLM 增强,先跑通主链路。
- embedding 用本地 Ollama,生成用百炼兼容接口,平衡成本、效果和本地资源限制。
- chunk 参数使用偏保守设置,优先保证稳定性。
- 基于 golden test set 调 chunk 参数和 rerank 策略
- 扩展非 PDF loader
- 对比不同 embedding 维度和召回指标
- 把 MCP 接到真实 Agent 流程里
因为脚本层的 --collection 会显式指定运行时 collection。为了避免混淆,建议你在 ingest 和 query 时始终手动传同一个 --collection。
因为上传控件支持的格式比底层 IngestionPipeline 当前实际绑定的 loader 更宽。当前稳定链路仍是 PdfLoader。
以下场景都建议重新 ingest:
- 修改了 embedding 模型
- 修改了 embedding 维度
- 修改了 chunk 策略
- 开启或关闭了摄取阶段增强并希望效果真实反映到索引
MCP 不是 RAG 本身,而是 RAG 的对外协议层。这个仓库做的是“把现有检索能力包装成 MCP 工具”,这样 Claude Desktop、Copilot、Cursor 之类的客户端就能直接调用。
如果你的目标是“尽快跑通项目并能讲清楚”:
- 用
uv管理环境 llm走百炼兼容接口embedding走qwen3-embedding:4bdimensions用1536- 先只 ingest PDF
- 先关闭 rerank、vision、evaluation 和摄取 LLM 增强
这是当前最贴合这套代码、也最适合面试表达的一条路径。