Python 代码质量保障:Ruff、mypy 和 pytest 在 RAG 项目中的协作配置

发布时间:2026/7/22 12:40:03

Python 代码质量保障:Ruff、mypy 和 pytest 在 RAG 项目中的协作配置 Python 代码质量保障Ruff、mypy 和 pytest 在 RAG 项目中的协作配置一、深度引言与场景痛点大家好我是赵咕咕。RAG 项目有一个特殊之处它比普通 Web 应用多了一个向量维度。你的 embedding 模型输出 1024 维检索时代码里写的却是 768 维Python 不会在运行时报错——NumPy 数组广播会默默地帮你修正形状然后检索结果全是错的。更隐蔽的是一个float类型的similarity_threshold参数被误传成了strif threshold 0.5永远不会触发但 Python 不会报任何错。这就是动态类型的代价。我们团队花了两个 Sprint 建立了一套代码质量防线Ruff格式化lint、mypy静态类型检查、pytest单元测试集成测试。三剑客在 CI 流水线上各司其职把 90% 的低级错误挡在合并之前。二、底层机制与原理深度剖析2.1 为什么 RAG 项目更需要类型检查普通的 CRUD 项目里类型错误最多导致页面 500。但 RAG 项目里类型错误的表现非常隐晦embedding 维度不匹配 → 检索结果全是错的但服务正常返回 200。similarity 阈值类型错误 → 所有文档都通过/不通过看起来只是检索质量有问题。Chunk size 被误传为 str → split 逻辑静默失败返回空列表。这些问题不会触发异常只会在线上表现为RAG 质量不如预期——而你排查时要花几个小时才找到那个类型错误。2.2 三剑客的职责分工三剑客在 CI 流水线中的协作逻辑Ruff 在最前面格式和 import 排序优先处理减少后续 mypy 的噪音。Ruff 的结果可以自动修复ruff format --fix不需要人工介入。mypy 在中间类型检查是阻塞项。embedding 维度、向量类型、threshold 类型这些一旦不符合CI 直接失败。pytest 在最后前面两步都过了才开始跑测试。减少因为格式问题导致测试失败的无效等待。2.3 渐进式引入策略如果项目已经有几万行代码直接开mypy --strict会收到上千条错误。我们的做法是先对新增代码开 strict 检查。老代码用# type: ignore标记已知问题逐步修复。核心模块embedding、retrieval、LLM 调用优先做到零 mypy 错误。三、生产级代码实现3.1 pyproject.toml 完整配置[project] name rag-service version 0.1.0 requires-python 3.11 dependencies [ langchain0.2.0, openai1.30.0, numpy1.26.0, faiss-cpu1.8.0, httpx0.27.0, ] [project.optional-dependencies] dev [ ruff0.5.0, mypy1.10.0, pytest8.2.0, pytest-asyncio0.23.0, pytest-cov5.0.0, langchain-openai, ] # ─── Ruff 配置 ─── [tool.ruff] target-version py311 line-length 100 [tool.ruff.lint] select [ E, # pycodestyle errors F, # pyflakes I, # isort N, # pep8-naming W, # pycodestyle warnings B, # flake8-bugbear C4, # flake8-comprehensions UP, # pyupgrade SIM, # flake8-simplify RUF, # Ruff-specific rules ] ignore [ E501, # line too long由 formatter 处理 B008, # 不强制禁止函数调用默认参数 ] [tool.ruff.lint.isort] known-first-party [rag_service] [tool.ruff.format] quote-style double indent-style space docstring-code-format true # ─── mypy 配置 ─── [tool.mypy] python_version 3.11 strict true warn_return_any true warn_unused_ignores true disallow_untyped_defs true disallow_incomplete_defs true check_untyped_defs true no_implicit_optional true # 第三方库缺少类型 stub 时的处理 [[tool.mypy.overrides]] module [ langchain.*, faiss.*, numpy.*, ] ignore_missing_imports true # ─── pytest 配置 ─── [tool.pytest.ini_options] asyncio_mode auto testpaths [tests] python_files test_*.py addopts [ -v, --tbshort, --strict-markers, --covrag_service, --cov-reportterm-missing, ] markers [ slow: marks tests as slow (deselect with -m \not slow\), integration: marks tests as integration tests, ]3.2 conftest.py 测试夹具import asyncio from collections.abc import AsyncGenerator from typing import Any import numpy as np import pytest from numpy.typing import NDArray class MockEmbeddingModel: Mock embedding 模型返回固定维度的零向量。 def __init__(self, dim: int 1024): self.dim dim async def embed(self, texts: list[str]) - list[NDArray[np.float32]]: return [ np.zeros(self.dim, dtypenp.float32) for _ in texts ] async def embed_query(self, text: str) - NDArray[np.float32]: return np.zeros(self.dim, dtypenp.float32) class MockVectorStore: Mock 向量存储返回预设的检索结果。 def __init__( self, mock_results: list[dict[str, Any]] | None None, ): self._results mock_results or [] async def similarity_search_with_score( self, query_embedding: NDArray[np.float32], k: int 5 ) - list[tuple[str, float]]: return [ (r[content], r[score]) for r in self._results[:k] ] async def add_documents( self, texts: list[str], embeddings: list[NDArray[np.float32]] ) - None: pass # mock 不实际存储 pytest.fixture async def mock_embedding_model() - MockEmbeddingModel: return MockEmbeddingModel(dim1024) pytest.fixture async def mock_vector_store() - MockVectorStore: return MockVectorStore([ {content: Python 异步编程指南, score: 0.95}, {content: FastAPI 性能优化, score: 0.88}, {content: 数据库连接池配置, score: 0.72}, ])3.3 带类型标注的 RAG 服务函数及其测试rag_service/retrieval.py — RAG 检索核心逻辑。 import logging from datetime import datetime, timezone from typing import Any import numpy as np from numpy.typing import NDArray logger logging.getLogger(__name__) class RetrievalConfig: 检索配置不可变。 def __init__( self, top_k: int 5, similarity_threshold: float 0.7, embedding_dim: int 1024, ) - None: if top_k 1: raise ValueError(ftop_k 必须 0收到: {top_k}) if not 0.0 similarity_threshold 1.0: raise ValueError( fsimilarity_threshold 必须在 [0, 1]收到: {similarity_threshold} ) if embedding_dim 1: raise ValueError(fembedding_dim 必须 0收到: {embedding_dim}) self.top_k top_k self.similarity_threshold similarity_threshold self.embedding_dim embedding_dim class RetrievalService: RAG 检索服务。 def __init__( self, embedder: Any, # 实际应为 EmbeddingProtocol vector_store: Any, # 实际应为 VectorStoreProtocol config: RetrievalConfig, ) - None: self._embedder embedder self._vector_store vector_store self._config config async def retrieve( self, query: str ) - list[dict[str, Any]]: 检索相关文档。 Args: query: 用户查询文本 Returns: 相关文档列表按相似度降序排列 Raises: ValueError: 查询文本为空 RuntimeError: embedding 或检索失败 if not query.strip(): raise ValueError(查询文本不能为空) # 1) Embedding try: query_emb: NDArray[np.float32] ( await self._embedder.embed_query(query) ) except Exception as e: logger.error(Embedding 失败: %s, e) raise RuntimeError(f查询 embedding 失败: {e}) from e # 2) 维度校验 if query_emb.shape[0] ! self._config.embedding_dim: raise RuntimeError( fEmbedding 维度不匹配: 期望 {self._config.embedding_dim}, f实际 {query_emb.shape[0]} ) # 3) 检索 try: raw_results await self._vector_store.similarity_search_with_score( query_emb, kself._config.top_k ) except Exception as e: logger.error(向量检索失败: %s, e) raise RuntimeError(f向量检索失败: {e}) from e # 4) 过滤 构造返回 results: list[dict[str, Any]] [] for content, score in raw_results: if score self._config.similarity_threshold: results.append({ content: content, score: float(score), retrieved_at: datetime.now(timezone.utc).isoformat(), }) logger.info( 检索完成: query%.50s, 候选%d, 过滤后%d, query, len(raw_results), len(results), ) return resultstests/test_retrieval.py — RAG 检索服务单元测试。 import pytest import numpy as np from rag_service.retrieval import RetrievalConfig, RetrievalService pytest.mark.asyncio async def test_retrieve_returns_filtered_results( mock_embedding_model, mock_vector_store ): 基本检索返回高于阈值的文档。 config RetrievalConfig( top_k5, similarity_threshold0.8, embedding_dim1024, ) service RetrievalService( embeddermock_embedding_model, vector_storemock_vector_store, configconfig, ) results await service.retrieve(Python 异步编程) assert len(results) 2 # 只有前两个 score 0.8 assert results[0][content] Python 异步编程指南 assert results[0][score] 0.95 pytest.mark.asyncio async def test_retrieve_empty_query_raises( mock_embedding_model, mock_vector_store ): 空查询应抛出 ValueError。 config RetrievalConfig() service RetrievalService( embeddermock_embedding_model, vector_storemock_vector_store, configconfig, ) with pytest.raises(ValueError, match查询文本不能为空): await service.retrieve( ) pytest.mark.asyncio async def test_retrieve_dimension_mismatch_detected( mock_vector_store, ): 维度不匹配应抛出 RuntimeError。 config RetrievalConfig(embedding_dim768) # 期望 768 # 构造一个返回 1024 维向量的 embedder class MismatchedEmbedder: async def embed_query(self, text: str): return np.zeros(1024, dtypenp.float32) service RetrievalService( embedderMismatchedEmbedder(), vector_storemock_vector_store, configconfig, ) with pytest.raises(RuntimeError, match维度不匹配): await service.retrieve(test) pytest.mark.asyncio async def test_retrieval_config_validation(): 配置参数校验。 with pytest.raises(ValueError, matchtop_k): RetrievalConfig(top_k0) with pytest.raises(ValueError, matchsimilarity_threshold): RetrievalConfig(similarity_threshold1.5) with pytest.raises(ValueError, matchembedding_dim): RetrievalConfig(embedding_dim0) pytest.mark.asyncio async def test_all_below_threshold_returns_empty( mock_embedding_model, mock_vector_store ): 所有文档低于阈值时返回空列表。 config RetrievalConfig( similarity_threshold0.99, # 极高阈值 ) service RetrievalService( embeddermock_embedding_model, vector_storemock_vector_store, configconfig, ) results await service.retrieve(任意查询) assert results []关键设计点维度校验在运行时即使 mypy 检查通过运行时仍然做 embedding 维度校验。类型检查是编译期防线维度校验是运行期防线。Mock 的维度一致性Mock 模型和配置的embedding_dim必须一致否则测试本身就是错的。参数校验在构造时RetrievalConfig.__init__中对参数做合法性检查把错误挡在入口。四、边界分析与架构权衡4.1 mypy strict 的代价开启mypy --strict后你会发现第三方库的大量 API 缺少类型 stub。LangChain 就是一个典型——它的很多方法返回Any导致下游代码类型推断失效。务实做法是对第三方库设ignore_missing_imports true但对项目自己的代码保持 strict。这个折中损失了跨库的类型检查但避免了被第三方库的类型问题拖死。4.2 测试应该有多真单元测试用 Mock embedding 很快毫秒级但无法验证真实 embedding 模型的行为。集成测试用真实模型很慢几十秒但能发现换了一个 embedding 模型后 top-k 变了这种问题。我们的策略PR 合并前跑单元测试必过5 秒内完成。每日构建跑集成测试允许失败人工评估。线上回滚条件集成测试不过则禁止部署。4.3 格式 vs 类型 vs 测试的优先级问题类型发现阶段修复成本工具缩进/import 乱序Ruff (1s)自动修复ruff format --fix类型不匹配mypy (5s)5 分钟mypyembedding 维度不对mypy 运行校验15 分钟mypy assert阈值过滤逻辑错误pytest (3s)30 分钟pytest检索质量下降线上监控2 小时监控告警教训是让工具修它擅长的事。格式问题 Ruff 自动修类型问题 mypy 严格拦逻辑问题 pytest 用例全覆盖。4.4 CI 时间控制三者叠加后 CI 大概 8-15 秒不含集成测试。如果 pytest 里有真实 embedding 调用的测试建议用 marker 标记为pytest.mark.integration在 CI 配置中用-m not integration跳过。五、总结Ruff mypy pytest 三剑客本质上构成了三道防线Ruff防线一代码格式和基本 Lint自动修复零人工成本。mypy防线二类型检查。RAG 项目里最重要的是保证向量维度和阈值类型正确这两个是隐式 bug的重灾区。pytest防线三逻辑正确性验证。Mock 保证了测试速度维度校验保证了运行时安全。这三者配置好之后代码质量提升是非常直观的——我们项目里 embedding 维度不匹配的 bug 从每周至少一次降到了零。不是说工程师变厉害了是 mypy 替我们挡住了。工具不替代思考但工具能替你记住那些不该被忘记的约束。下一篇预告RAG 在工业设备维修手册中的应用技术术语的向量化检索挑战。

相关新闻