fix: 修复 11 个代码架构审计问题
H1: AppConfig 自定义 __init__ 改用 from_dict() 类方法 H2: list_collections_with_stats 改为从 ChromaDB 直接查询 H3: RateLimiter 过期 key 自动清理, 防止内存泄漏 M1: delete_by_source 区分 ValueError 与真实异常, 记日志 M2: VectorDB 新增 list_collections() 封装方法 M3: _remove_by_source 异常记日志, 不再静默吞掉 M4: CLI 集合回退支持 MD_VECTOR_DB_COLLECTION 环境变量 L1: DEFAULT_CONFIG_PATH 自动从项目根目录解析 L2: ingest 命令内重复 import 移至模块顶部 L3: 新增死循环回归测试 + 密集分隔符分块测试 L4: 统一 logger 名称为 md-vector-db 删除旧版审计文档 测试: 48 passed
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
# md-vector-db 架构审计报告
|
||||
|
||||
**审计日期**: 2026-07-05
|
||||
**审计范围**: 全量代码 (src/, tests/, config.yaml, pyproject.toml)
|
||||
**审计员**: 架构评审工程师
|
||||
**严重等级定义**:
|
||||
|
||||
| 等级 | 符号 | 含义 |
|
||||
|------|------|------|
|
||||
| **严重** | 🔴 | 可导致数据泄露、服务崩溃或安全漏洞,须立即修复 |
|
||||
| **高** | 🟠 | 可能导致线上故障、数据不一致或性能严重退化 |
|
||||
| **中** | 🟡 | 影响可维护性、可测试性或存在潜在风险 |
|
||||
| **低** | 🔵 | 代码风格、命名或次要改进建议 |
|
||||
|
||||
---
|
||||
|
||||
## 一、安全性 (Security)
|
||||
|
||||
### 🔴 S-01: API 密钥明文存储
|
||||
|
||||
**文件**: `config.yaml:9`, `src/core/config.py:25`
|
||||
|
||||
```yaml
|
||||
embed:
|
||||
api_key: "" # api 模式下填写
|
||||
```
|
||||
|
||||
API 密钥直接存在 YAML 文件中,任何能读取项目目录的人都能获取。`config.yaml` 虽然列在 `.gitignore` 的 `data/` 规则之外,但目前文件已在 git 追踪中。
|
||||
|
||||
**修复建议**: 从 `config.yaml` 移除 `api_key` 字段,改为从环境变量 `EMBED_API_KEY` 读取:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EmbedConfig:
|
||||
api_key: str = field(default_factory=lambda: os.getenv("EMBED_API_KEY", ""))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 S-02: 路径遍历漏洞
|
||||
|
||||
**文件**: `src/server/app.py:111-114`
|
||||
|
||||
```python
|
||||
if req.file_path:
|
||||
path = Path(req.file_path)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, ...)
|
||||
count = ingestor.ingest_file(str(path))
|
||||
```
|
||||
|
||||
`file_path` 参数无任何校验,攻击者可通过 `/api/v1/ingest` 传入 `../../../etc/passwd` 读取服务器任意文件。虽然 Windows 上影响有限,但若部署到 Linux 则是严重漏洞。
|
||||
|
||||
**修复建议**:
|
||||
```python
|
||||
SAFE_ROOT = Path("./md_docs").resolve()
|
||||
path = Path(req.file_path).resolve()
|
||||
if not str(path).startswith(str(SAFE_ROOT)):
|
||||
raise HTTPException(status_code=403, detail="禁止访问该路径")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 S-03: 无任何认证/授权机制
|
||||
|
||||
**文件**: `src/server/app.py` 全部端点
|
||||
|
||||
`POST /api/v1/search`、`POST /api/v1/ingest`、`DELETE /api/v1/documents/{file_name}` 均无任何认证。任何能访问 `http://localhost:8000` 的人都能读取、写入、删除向量库数据。
|
||||
|
||||
**修复建议**: 至少添加一个静态 API Key 验证中间件:
|
||||
```python
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
async def verify_api_key(x_api_key: str = Header(None)):
|
||||
expected = os.getenv("MD_VECTOR_API_KEY", "")
|
||||
if expected and x_api_key != expected:
|
||||
raise HTTPException(status_code=401)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟠 S-04: 无速率限制
|
||||
|
||||
`POST /api/v1/ingest` 和 `POST /api/v1/search` 无速率限制,每次请求都触发一次嵌入计算(CPU 密集型)。攻击者可以短时间内发送大量请求耗尽 CPU/内存。
|
||||
|
||||
**修复建议**: 引入 `slowapi` 或 `fastapi-limiter` 限制每 IP 的请求频率。
|
||||
|
||||
---
|
||||
|
||||
### 🟠 S-05: 错误信息泄露
|
||||
|
||||
**文件**: `src/server/app.py:128`
|
||||
|
||||
```python
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
```
|
||||
|
||||
直接将异常信息返回给客户端,可能暴露文件路径、内部类名、库版本等敏感信息。
|
||||
|
||||
**修复建议**: 返回通用错误消息,详细错误记入日志:
|
||||
```python
|
||||
except Exception:
|
||||
logger.exception("ingest failed")
|
||||
raise HTTPException(status_code=500, detail="入库失败")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟡 S-06: FastAPI 默认 CORS 无限制
|
||||
|
||||
**文件**: `src/server/app.py:80-85`
|
||||
|
||||
FastAPI 默认不设置 CORS 中间件,但在同源场景没问题。若后续被其他域名下的前端页面调用,需要显式配置 `CORSMiddleware`。
|
||||
|
||||
---
|
||||
|
||||
## 二、正确性与可靠性 (Correctness & Reliability)
|
||||
|
||||
### 🔴 C-01: ChromaDB 并发不安全
|
||||
|
||||
**文件**: `src/core/db.py`, `src/server/app.py`
|
||||
|
||||
`ChromaDB` 的 `PersistentClient` 使用 SQLite 作为底层存储,SQLite 在默认配置下不支持多写者并发。当前所有端点通过模块级单例共享同一个 `PersistentClient`,FastAPI 的线程池模型下多个请求并发写操作可能导致数据库锁错误甚至数据损坏。
|
||||
|
||||
**修复建议**:
|
||||
1. 使用 `chromadb.Client` (内存模式) + 定期持久化,或
|
||||
2. 在写操作上加 `threading.Lock`,或
|
||||
3. 升级到 ChromaDB 服务模式 (客户端-服务端架构)
|
||||
|
||||
---
|
||||
|
||||
### 🟠 C-02: Embedder 全局环境变量副作用
|
||||
|
||||
**文件**: `src/core/embedder.py:6-7`
|
||||
|
||||
```python
|
||||
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
||||
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
||||
```
|
||||
|
||||
模块导入时即修改全局环境变量,影响同一进程中的所有库。任何其他使用 HuggingFace 的代码都会受到意外影响。`__init__` 中还临时 `pop`/`set` 这些变量——在并发场景下会产生竞态条件。
|
||||
|
||||
**修复建议**: 不用全局环境变量,改为通过 `SentenceTransformer` 构造参数控制:
|
||||
```python
|
||||
self._model = SentenceTransformer(
|
||||
config.local_model,
|
||||
local_files_only=True,
|
||||
)
|
||||
```
|
||||
下载回退逻辑单独提供一个 `download_model()` 脚本。
|
||||
|
||||
---
|
||||
|
||||
### 🟠 C-03: 分块 overlap 逻辑缺陷
|
||||
|
||||
**文件**: `src/core/ingest.py:113-123`
|
||||
|
||||
```python
|
||||
if self.overlap > 0 and len(current) > self.overlap:
|
||||
current = current[-self.overlap:] + "\n\n" + para
|
||||
else:
|
||||
current = para
|
||||
```
|
||||
|
||||
当一个 chunk flush 后,`current` 被设为上一块的末尾 100 字符 + 新段落。但下一轮循环中 `len(current)` 可能已经接近 100 且小于 `max_size`,导致下一块的实际内容丰富度不足,overlap 区段被重复入库。**语义上问题不大(overlap 本就是设计如此),但 extra chunks 可能导致搜索结果中出现高度重复内容。**
|
||||
|
||||
---
|
||||
|
||||
### 🟠 C-04: `SearchRequest.top_k` 无上界
|
||||
|
||||
**文件**: `src/server/app.py:23-24`
|
||||
|
||||
```python
|
||||
class SearchRequest(BaseModel):
|
||||
query: str
|
||||
top_k: int = 10
|
||||
```
|
||||
|
||||
可传入 `top_k=999999` 导致 ChromaDB 尝试返回巨量数据,消耗内存。
|
||||
|
||||
**修复建议**:
|
||||
```python
|
||||
top_k: int = Field(default=10, ge=1, le=100)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟠 C-05: 大文件入库无限制导致 OOM
|
||||
|
||||
**文件**: `src/core/ingest.py:183-216`
|
||||
|
||||
`ingest_content()` 将整个文件分块后一次性调用 `self.embedder.embed(texts)` 嵌入所有 chunks。对于超大文档(如 500KB+ 的 Markdown),生成的 chunks 数量和嵌入向量可能耗尽内存。
|
||||
|
||||
**修复建议**: 对 chunks 进行分批嵌入,每批最多 32 条:
|
||||
```python
|
||||
BATCH_SIZE = 32
|
||||
for i in range(0, len(chunks), BATCH_SIZE):
|
||||
batch = chunks[i:i+BATCH_SIZE]
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟡 C-06: 配置路径硬编码
|
||||
|
||||
**文件**: 多处
|
||||
|
||||
`"config.yaml"` 字符串硬编码在 `config.py:62`, `server/app.py:47,59`, `cli/main.py:26` 共 4 处。修改文件名需要改动所有位置。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 C-07: health 端点不检查实际依赖
|
||||
|
||||
**文件**: `src/server/app.py:94-96`
|
||||
|
||||
```python
|
||||
@app.get("/api/v1/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
```
|
||||
|
||||
无论 ChromaDB 是否可访问、模型是否加载成功,始终返回 `ok`。这不是真正的健康检查。
|
||||
|
||||
---
|
||||
|
||||
## 三、设计缺陷 (Design)
|
||||
|
||||
### 🟠 D-01: Embedder 违反单一职责原则
|
||||
|
||||
**文件**: `src/core/embedder.py`
|
||||
|
||||
`Embedder` 同时包含本地模型和 API 两种完全不同的嵌入实现,通过 `if config.mode == "local"` 分支判断。每增加一种嵌入后端都需要修改 `__init__`、`dimension`、`embed` 三个方法。
|
||||
|
||||
**修复建议**: 拆分为策略模式:
|
||||
```python
|
||||
class Embedder(Protocol):
|
||||
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
||||
@property
|
||||
def dimension(self) -> int: ...
|
||||
|
||||
class LocalEmbedder: ...
|
||||
class APIEmbedder: ...
|
||||
|
||||
def create_embedder(config: EmbedConfig) -> Embedder: # 工厂
|
||||
if config.mode == "local": return LocalEmbedder(config)
|
||||
if config.mode == "api": return APIEmbedder(config)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟠 D-02: 服务层模块级全局单例
|
||||
|
||||
**文件**: `src/server/app.py:28-31`
|
||||
|
||||
```python
|
||||
_db: VectorDB | None = None
|
||||
_searcher: Searcher | None = None
|
||||
_ingestor: DocumentIngestor | None = None
|
||||
```
|
||||
|
||||
模块级全局变量是经典的**反模式**:
|
||||
- 无法在测试中替换依赖(必须靠环境变量 hack)
|
||||
- 无法为不同请求使用不同配置
|
||||
- FastAPI 的依赖注入被完全绕过
|
||||
|
||||
**修复建议**: 使用 FastAPI 的 `Depends` 注入:
|
||||
```python
|
||||
def get_searcher() -> Searcher:
|
||||
...
|
||||
return searcher
|
||||
|
||||
@app.post("/api/v1/search")
|
||||
def search_documents(req: SearchRequest, searcher: Searcher = Depends(get_searcher)):
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟠 D-03: 同步嵌入阻塞事件循环
|
||||
|
||||
**文件**: `src/server/app.py:132-134`, `src/core/embedder.py:55-61`
|
||||
|
||||
`embedder.embed()` 是同步方法(CPU 密集型),在 FastAPI 的 async 事件循环中调用会阻塞整个线程。FastAPI 默认线程池很小,多个并发搜索请求会排队等待。
|
||||
|
||||
**修复建议**: 使用 `run_in_executor` 将嵌入放到线程池:
|
||||
```python
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
embeddings = await loop.run_in_executor(None, self.embedder.embed, texts)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟡 D-04: `AppConfig` 双重初始化模式
|
||||
|
||||
**文件**: `src/core/config.py:44-57`
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
chroma: ChromaConfig = field(default_factory=ChromaConfig) # dataclass 字段
|
||||
...
|
||||
def __init__(self, **kwargs): # 自定义 __init__ 覆盖 dataclass 的 __init__
|
||||
self.chroma = ChromaConfig(**kwargs.get("chroma", {}))
|
||||
```
|
||||
|
||||
`@dataclass` 生成的 `__init__` 被完全替换,`field(default_factory=...)` 成为死代码但仍在类定义中(混淆阅读者)。`__eq__` 和 `__repr__` 仍由 dataclass 生成,行为与手写的 `__init__` 不一致(`AppConfig()` 的 `chroma` 字段在 `field()` 定义中是 `ChromaConfig()` 但在 `__init__` 中也是 `ChromaConfig()` ——刚好一致,但依赖巧合)。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 D-05: `DocumentIngestor` 未测试核心流程
|
||||
|
||||
**文件**: `tests/test_ingest.py:72-87`
|
||||
|
||||
`TestDocumentIngestor` 只测了"读取 Markdown 文件"这一行代码,没有测试 `ingest_content()`(分块→嵌入→入库的核心路径)。真正的 `DocumentIngestor` 集成测试隐藏在 `test_search.py` 的 fixture 里。这导致修改 `ingest.py` 时可能没有直接的测试反馈。
|
||||
|
||||
---
|
||||
|
||||
## 四、可维护性 (Maintainability)
|
||||
|
||||
### 🟡 M-01: CLI 和 Server 重复组件初始化逻辑
|
||||
|
||||
**文件**: `src/cli/main.py:26-33` vs `src/server/app.py:34-74`
|
||||
|
||||
两处各自实现了一套几乎相同的 `_get_components` / `_get_db` + `_get_embedder` + `_init_services` 逻辑。如果 VectorDB 或 Embedder 的初始化签名改变,需要改两个地方。
|
||||
|
||||
**修复建议**: 在 `core` 层抽象一个 `AppContext` 工厂:
|
||||
```python
|
||||
# src/core/context.py
|
||||
@dataclass
|
||||
class AppContext:
|
||||
config: AppConfig
|
||||
db: VectorDB
|
||||
embedder: Embedder
|
||||
searcher: Searcher
|
||||
ingestor: DocumentIngestor
|
||||
|
||||
def create_context(config_path: str = "config.yaml") -> AppContext:
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟡 M-02: 无日志系统
|
||||
|
||||
整个项目无结构化日志。所有输出都通过 `typer.echo()` 或 `print()`。API 请求无日志记录,错误信息只能靠 FastAPI 的默认 stderr。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 M-03: API 模型无请求校验
|
||||
|
||||
**文件**: `src/server/app.py:16-19`
|
||||
|
||||
```python
|
||||
class IngestRequest(BaseModel):
|
||||
file_path: str | None = None
|
||||
content: str | None = None
|
||||
file_name: str | None = None
|
||||
```
|
||||
|
||||
缺少对 `file_name` 的格式校验——可传入包含路径分隔符、空字符串或非法字符的文件名。
|
||||
|
||||
---
|
||||
|
||||
## 五、测试覆盖 (Testing)
|
||||
|
||||
### 测试覆盖概览
|
||||
|
||||
| 模块 | 测试数 | 覆盖关键路径? | 缺失 |
|
||||
|------|--------|--------------|------|
|
||||
| config | 5 | ✅ | - |
|
||||
| db | 4 | ✅ | - |
|
||||
| embedder | 5 | ⚠️ | API 模式未测、Invalid mode 未测 |
|
||||
| ingest | 6 | ⚠️ | DocumentIngestor 核心未测 |
|
||||
| search | 5 | ✅ | - |
|
||||
| api | 6 | ⚠️ | DELETE 未测、ingest file_path 未测 |
|
||||
|
||||
### 🔴 T-01: API 嵌入模式零测试
|
||||
|
||||
**文件**: `tests/test_embedder.py`
|
||||
|
||||
全部 5 个测试仅测 `local` 模式。`Embedder(mode="api")` 的初始化、`dimension` 属性、`embed()` 方法均无测试覆盖。如果 API 模式的代码被重构引入 bug,不会触发任何测试失败。
|
||||
|
||||
### 🟠 T-02: DELETE 端点零测试
|
||||
|
||||
**文件**: `tests/test_api.py`
|
||||
|
||||
`DELETE /api/v1/documents/{file_name}` 无任何测试。删除不存在的文件、删除已入库的文件、删除后搜索验证——全部缺失。
|
||||
|
||||
### 🟡 T-03: 空数据搜索测试语义不准确
|
||||
|
||||
**文件**: `tests/test_api.py:53-61`
|
||||
|
||||
`test_search_empty_collection` 断言空 collection 搜索返回 `[]`。但 ChromaDB 在空 collection 下 `query()` 的行为可能随时间变化——当前"恰好"返回空,但这是未定义行为。
|
||||
|
||||
---
|
||||
|
||||
## 六、性能 (Performance)
|
||||
|
||||
### 🟡 P-01: `list_sources()` 全量扫描
|
||||
|
||||
**文件**: `src/core/search.py:65-74`
|
||||
|
||||
```python
|
||||
def list_sources(self) -> list[str]:
|
||||
result = self.collection.get(include=["metadatas"]) # 取全部 metadatas
|
||||
```
|
||||
|
||||
当 collection 有数十万条 chunks 时,一次性拉取全部 metadata 到内存做去重,极其浪费。
|
||||
|
||||
**修复建议**: 使用 ChromaDB 的 `get` 方法配合 `limit` 和分页,或将 source 列表单独维护在一个轻量集合中(如 Python set 持久化到 JSON 文件)。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 P-02: 每次 CLI 命令重建 Embedder
|
||||
|
||||
**文件**: `src/cli/main.py:26-33`
|
||||
|
||||
每个 CLI 命令调用 `_get_components()` 都会重新创建 `VectorDB` + `Embedder`。模型加载(即使从缓存)也要 1-2 秒。对于连续执行的命令,这是不必要的开销。
|
||||
|
||||
---
|
||||
|
||||
## 七、汇总
|
||||
|
||||
### 按严重等级统计
|
||||
|
||||
| 等级 | 数量 | 条目 |
|
||||
|------|------|------|
|
||||
| 🔴 严重 | 4 | S-01(明文密钥), S-02(路径遍历), S-03(无认证), C-01(并发不安全) |
|
||||
| 🟠 高 | 8 | S-04, S-05, C-02, C-03, C-04, C-05, D-01, D-02, D-03, T-02 |
|
||||
| 🟡 中 | 11 | S-06, C-06, C-07, D-04, D-05, M-01, M-02, M-03, T-01, T-03, P-01, P-02 |
|
||||
| 🔵 低 | 0 | - |
|
||||
|
||||
### 修复优先级
|
||||
|
||||
```
|
||||
Phase 1 (立即)
|
||||
├── 🔴 S-02: 路径遍历防护 → 加路径白名单
|
||||
├── 🔴 S-03: 无认证 → 加 API Key 中间件
|
||||
├── 🔴 C-01: 并发安全 → 加写锁
|
||||
└── 🔴 S-01: 明文密钥 → 改为环境变量
|
||||
|
||||
Phase 2 (本周)
|
||||
├── 🟠 C-04: top_k 无上界 → Pydantic Field(le=100)
|
||||
├── 🟠 C-05: 大文件 OOM → 分批嵌入
|
||||
├── 🟠 S-05: 错误信息泄露 → 通用错误 + 日志
|
||||
├── 🟠 D-02: 全局单例 → FastAPI Depends 注入
|
||||
└── 🟠 T-02: DELETE 测试缺失 → 补充
|
||||
|
||||
Phase 3 (迭代)
|
||||
├── 🟡 D-01: Embedder 拆分 → 策略模式
|
||||
├── 🟡 M-01: 重复逻辑 → AppContext 工厂
|
||||
├── 🟡 M-02: 无日志 → logging 模块
|
||||
├── 🟡 T-01: API 模式测试 → 补充
|
||||
└── 🟡 P-01: list_sources 全量 → 分页
|
||||
```
|
||||
@@ -0,0 +1,275 @@
|
||||
# md-vector-db 代码架构审计报告
|
||||
|
||||
**审计日期**: 2026-07-06
|
||||
**审计范围**: `src/` 全部模块 + `scripts/ingest_obsidian.py` + 测试
|
||||
**审计方法**: 逐文件静态分析,按安全性 → 可靠性 → 设计 → 测试四个维度评审
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|------|------|
|
||||
| 安全性 | ⭐⭐⭐⭐ | 整体良好,认证/限流/路径防护到位 |
|
||||
| 可靠性 | ⭐⭐⭐ | 有 2 处吞异常 + 1 处内存泄漏风险 |
|
||||
| 设计 | ⭐⭐⭐ | 核心架构清晰,但 2 处封装泄漏 |
|
||||
| 测试 | ⭐⭐⭐ | 46 个测试覆盖主要路径,缺回归测试 |
|
||||
|
||||
共发现 **11 个问题**:3 个高、4 个中、4 个低。
|
||||
|
||||
---
|
||||
|
||||
## 高优先级问题
|
||||
|
||||
### H1. `config.py:75` — `AppConfig.__init__` 破坏 dataclass 构造
|
||||
|
||||
```python
|
||||
# 问题代码 (config.py:66-79)
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
chroma: ChromaConfig = field(default_factory=ChromaConfig)
|
||||
embed: EmbedConfig = field(default_factory=EmbedConfig)
|
||||
chunk: ChunkConfig = field(default_factory=ChunkConfig)
|
||||
server: ServerConfig = field(default_factory=ServerConfig)
|
||||
|
||||
def __init__(self, **kwargs): # ← 覆盖了 dataclass 生成的 __init__
|
||||
self.chroma = ChromaConfig(**kwargs.get("chroma", {}))
|
||||
self.embed = EmbedConfig(**kwargs.get("embed", {}))
|
||||
...
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- `AppConfig(chroma=ChromaConfig())` — 正常 dataclass 构造方式会报 `TypeError`
|
||||
- `@dataclass` 的 `field(default_factory=...)` 全部被绕过
|
||||
- 所有字段失去类型检查
|
||||
|
||||
**修复**:
|
||||
```python
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
chroma: ChromaConfig = field(default_factory=ChromaConfig)
|
||||
embed: EmbedConfig = field(default_factory=EmbedConfig)
|
||||
chunk: ChunkConfig = field(default_factory=ChunkConfig)
|
||||
server: ServerConfig = field(default_factory=ServerConfig)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AppConfig":
|
||||
return cls(
|
||||
chroma=ChromaConfig(**data.get("chroma", {})),
|
||||
embed=EmbedConfig(**data.get("embed", {})),
|
||||
chunk=ChunkConfig(**data.get("chunk", {})),
|
||||
server=ServerConfig(**data.get("server", {})),
|
||||
)
|
||||
|
||||
def load_config(path: str | None = None) -> AppConfig:
|
||||
...
|
||||
return AppConfig.from_dict(data)
|
||||
```
|
||||
|
||||
**严重程度**: HIGH — 调用方如果用标准 dataclass 构造会直接崩溃。
|
||||
|
||||
---
|
||||
|
||||
### H2. `deps.py:47-58` — `list_collections_with_stats` 只列出内存中的集合
|
||||
|
||||
```python
|
||||
# 问题代码
|
||||
def list_collections_with_stats(self) -> list[dict]:
|
||||
all_names = set(self._searchers.keys()) | set(self._ingestors.keys())
|
||||
all_names.add(self.default_collection)
|
||||
# ↑ 只包含被访问过的集合!ChromaDB 中已有的其他集合不会出现
|
||||
```
|
||||
|
||||
**影响**:通过 CLI 或直接操作 ChromaDB 创建的集合,在 HTTP API 的 `/api/v1/collections` 中看不到。
|
||||
|
||||
**修复**:应从 ChromaDB 直接获取。
|
||||
```python
|
||||
def list_collections_with_stats(self) -> list[dict]:
|
||||
result = []
|
||||
for coll in self.db.client.list_collections():
|
||||
result.append({"name": coll.name, "count": coll.count()})
|
||||
return result
|
||||
```
|
||||
|
||||
**严重程度**: HIGH — 功能缺陷,`/api/v1/collections` 返回不完整数据。
|
||||
|
||||
---
|
||||
|
||||
### H3. `auth.py:27` — RateLimiter 内存无限增长
|
||||
|
||||
```python
|
||||
# 问题代码
|
||||
class RateLimiter:
|
||||
def __init__(self, max_requests=30, window_seconds=60):
|
||||
self._store: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
def is_allowed(self, client_id: str) -> bool:
|
||||
with self._lock:
|
||||
records = self._store[client_id]
|
||||
records[:] = [t for t in records if now - t < self.window]
|
||||
# ↑ 只清理时间戳,不清理空 key
|
||||
```
|
||||
|
||||
**影响**:每个新 IP 都在 `_store` 中永久保留一个 key,长期运行后字典膨胀。
|
||||
|
||||
**修复**:在清理过期时间戳后,删除空的 key:
|
||||
```python
|
||||
records[:] = [t for t in records if now - t < self.window]
|
||||
if not records:
|
||||
del self._store[client_id]
|
||||
```
|
||||
|
||||
**严重程度**: HIGH — 长期运行会内存泄漏。
|
||||
|
||||
---
|
||||
|
||||
## 中优先级问题
|
||||
|
||||
### M1. `search.py:87-88` — `delete_by_source` 吞掉所有异常
|
||||
|
||||
```python
|
||||
def delete_by_source(self, file_name: str) -> bool:
|
||||
try:
|
||||
with self.db.write_lock:
|
||||
...
|
||||
except Exception:
|
||||
pass # ← 隐藏了 ChromaDB 损坏、磁盘满等严重错误
|
||||
return False
|
||||
```
|
||||
|
||||
**修复**:至少记录日志:
|
||||
```python
|
||||
except Exception:
|
||||
logger.exception("删除文档失败: %s", file_name)
|
||||
return False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M2. `db.py` — 缺少 `list_collections()` 封装
|
||||
|
||||
`VectorDB` 没有暴露 `list_collections()`,导致:
|
||||
- `deps.py` 无法从 `db` 对象获取集合列表
|
||||
- MCP server 需要绕过封装直接访问 `db.client`
|
||||
|
||||
**修复**:
|
||||
```python
|
||||
def list_collections(self) -> list:
|
||||
return self.client.list_collections()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M3. `ingest.py:237-245` — `_remove_by_source` 吞掉去重异常
|
||||
|
||||
```python
|
||||
def _remove_by_source(self, file_name: str) -> None:
|
||||
try:
|
||||
...
|
||||
except Exception:
|
||||
pass # collection 为空时 get 可能抛异常
|
||||
```
|
||||
|
||||
**影响**:如果去重失败(非空 collection),重复入库不会报错但会静默产生重复数据。
|
||||
|
||||
**修复**:区分 "collection 为空" 和真正的错误:
|
||||
```python
|
||||
except ValueError:
|
||||
pass # collection 无数据时 ChromaDB 会抛 ValueError
|
||||
except Exception:
|
||||
logger.exception("去重检查失败: %s", file_name)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M4. `cli/main.py:47-48` — 硬编码回退值
|
||||
|
||||
```python
|
||||
def _get_default_collection() -> str:
|
||||
return _cfg.chroma.collection_name if _cfg else "markdown_docs"
|
||||
```
|
||||
|
||||
与 `deps.py:27-29` 中的 `default_collection` 逻辑不一致(deps.py 支持 `MD_VECTOR_DB_COLLECTION` 环境变量)。
|
||||
|
||||
---
|
||||
|
||||
## 低优先级问题
|
||||
|
||||
### L1. `config.py:16` — 相对路径 `DEFAULT_CONFIG_PATH`
|
||||
|
||||
```python
|
||||
DEFAULT_CONFIG_PATH = "config.yaml" # 从不同 CWD 运行会找不到
|
||||
```
|
||||
|
||||
**建议**:在 `load_config` 中基于项目根目录解析。
|
||||
|
||||
---
|
||||
|
||||
### L2. `cli/main.py:97-101` — 函数内重复 import
|
||||
|
||||
```python
|
||||
for fp in file_paths:
|
||||
from pathlib import Path as _Path # 每次循环都 import
|
||||
...
|
||||
import glob as _glob
|
||||
```
|
||||
|
||||
**修复**:移到文件顶部。
|
||||
|
||||
---
|
||||
|
||||
### L3. 缺少死循环回归测试
|
||||
|
||||
`_split_single_paragraph` 修复(`start = max(start + 1, next_start)`)没有对应的单元测试:分隔符距 chunk 起点 < overlap 的场景。
|
||||
|
||||
```python
|
||||
def test_separator_near_start_does_not_loop(self, splitter):
|
||||
"""分隔符紧挨 start 时不会死循环 (回归测试)."""
|
||||
# 构造: 前 overlap 字符内就有分隔符的情况
|
||||
text = "。" * 50 + "内容" * 300 # 分隔符密集在开头
|
||||
chunks = splitter.split(f"# T\n{text}", source_file="test.md")
|
||||
assert len(chunks) > 0 # 不卡死即通过
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### L4. `deps.py:11` — Logger 名称
|
||||
|
||||
```python
|
||||
logger = logging.getLogger(__name__) # __name__ = "src.server.deps"
|
||||
```
|
||||
|
||||
与 `app.py:17` 的 `logging.getLogger("md-vector-db")` 不一致,日志输出时名称不统一。
|
||||
|
||||
---
|
||||
|
||||
## 架构评价
|
||||
|
||||
### 做得好的地方
|
||||
|
||||
- **依赖方向正确**: `config ← db ← embedder ← ingest/search ← server/cli`,核心层不依赖传输层
|
||||
- **策略模式**: `Embedder` Protocol + 工厂函数 `create_embedder`,方便扩展新 Provider
|
||||
- **依赖注入**: `AppState` + `FastAPI Depends`,可测试可替换
|
||||
- **线程安全**: `threading.Lock` 保护 ChromaDB 写操作
|
||||
- **安全**: API Key 认证 + 速率限制 + 路径遍历防护三重保护
|
||||
- **MCP 友好**: CLI `--json` 输出,支持 stdin 输入
|
||||
|
||||
### 待改进
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| 封装泄漏 | `db.client` 被外部直接访问(MCP server、deps.py),应通过 `VectorDB` 方法暴露 |
|
||||
| AppConfig 构造 | 自定义 `__init__` 与 dataclass 冲突 |
|
||||
| 集合发现 | 没有统一的 "列出所有集合" 方法,deps/MCP/CLI 各自实现 |
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| 优先级 | 数量 | 建议处理 |
|
||||
|--------|------|----------|
|
||||
| HIGH | 3 | H1 (AppConfig) 需立即修,H2/H3 尽快修 |
|
||||
| MEDIUM | 4 | 逐个修,影响小但累积会出问题 |
|
||||
| LOW | 4 | 择机修,不紧急 |
|
||||
|
||||
**整体评价**: 代码架构设计良好,核心逻辑清晰。主要问题集中在配置系统的 dataclass 使用不当、集合发现的封装泄漏、以及几处静默吞异常。修复 H1~H3 后即可达到生产就绪状态。
|
||||
Reference in New Issue
Block a user