4 Commits

Author SHA1 Message Date
lacerate551
148559ee3c fix(knowledge): 全量修复多 worker 元数据竞态问题
1. collection.py: create/delete/update/exists 方法开头加 _load_metadata()
2. list_collections: 去掉磁盘扫描自动补充和 stale 清理逻辑,
   改为以元数据为唯一真相源,异常集合只跳过不删不改
3. manager.py: _load/_save_metadata 加 fcntl 文件锁防并发写覆盖,
   Windows 兼容(fcntl try/import 包裹)
2026-06-10 11:17:57 +08:00
lacerate551
431af0217a fix(knowledge): list_collections 每次从磁盘重载元数据,修复多 worker 返回数量不一致
GUNICORN_WORKERS=2 时各 worker 进程的 self._metadata 独立,
一个 worker 清理失效条目后保存 kb_metadata.json,另一个 worker
内存仍是旧版本,导致 /collections 返回数量波动(3 或 5)。
在 list_collections() 开头调用 _load_metadata() 重新加载,
保证每次请求基于最新的磁盘状态。
2026-06-09 13:29:49 +08:00
lacerate551
aa04bb94a6 chore(deploy): 添加 .dockerignore 排除大体积数据目录,加速镜像构建
排除 models/、vector_store/、documents/、.data/ 等数据目录,
这些通过 volume 挂载,不需要打进镜像。
2026-06-09 12:07:43 +08:00
lacerate551
84a8be0ce8 fix(knowledge): list_collections 增加异常保护,防止 ChromaDB 数据丢失导致 /collections 500
遍历 kb_metadata.json 时对每个集合的 get_collection/count 操作包裹
try/except,捕获 NotFoundError/InternalError 等异常后跳过并记录警告,
循环结束后自动清理失效的元数据条目。
2026-06-09 11:43:34 +08:00
3 changed files with 84 additions and 45 deletions

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
# 大体积数据目录(通过 volume 挂载,不需要打进镜像)
models/
knowledge/vector_store/
documents/
.data/
data/
# Python 虚拟环境
venv/
__pycache__/
*.pyc
# Git
.git/
# IDE 和编辑器
.vscode/
.idea/
*.swp
# 其他
*.log
.env*

View File

@@ -134,6 +134,9 @@ class CollectionMixin:
"""
from .base import BM25Index
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
self._metadata = self._load_metadata()
if not kb_name or not kb_name.replace('_', '').isalnum():
return False, "向量库名称只能包含字母、数字和下划线"
@@ -190,6 +193,9 @@ class CollectionMixin:
Returns:
更新成功返回 True向量库不存在返回 False
"""
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
self._metadata = self._load_metadata()
collections = self._metadata.get("collections", {})
if kb_name not in collections:
return False
@@ -228,6 +234,9 @@ class CollectionMixin:
"""
import shutil
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
self._metadata = self._load_metadata()
if kb_name == PUBLIC_KB_NAME:
return False, "公开知识库不能删除"
@@ -348,50 +357,36 @@ class CollectionMixin:
- department: 所属部门
- description: 描述
"""
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
self._metadata = self._load_metadata()
result = []
# 扫描 base_path 下的所有子目录作为向量库
# 每个向量库使用独立目录base_path/my_ky, base_path/public_kb 等
actual_collections = []
try:
if os.path.exists(self.base_path):
for item in os.listdir(self.base_path):
item_path = os.path.join(self.base_path, item)
if os.path.isdir(item_path) and not item.startswith('.'):
# 检查是否包含 chroma.sqlite3有效的向量库目录
if os.path.exists(os.path.join(item_path, 'chroma.sqlite3')):
actual_collections.append(item)
except Exception as e:
logger.warning(f"扫描向量库目录失败: {e}")
# 如果扫描失败,回退到元数据中的集合列表
if not actual_collections:
actual_collections = list(self._metadata.get("collections", {}).keys())
for name in actual_collections:
if name not in self._metadata.get("collections", {}):
if "collections" not in self._metadata:
self._metadata["collections"] = {}
self._metadata["collections"][name] = {
"display_name": name,
"department": "",
"description": "",
"created_at": datetime.now().isoformat()
}
logger.info(f"自动补充向量库元数据: {name}")
self._save_metadata()
# 以元数据为唯一真相源,不再扫描磁盘自动补充
# (扫描磁盘会导致其他 worker 刚创建/删除的集合被误操作)
for name, info in self._metadata.get("collections", {}).items():
collection = self.get_collection(name)
result.append(CollectionInfo(
name=name,
display_name=info.get("display_name", name),
document_count=collection.count() if collection else 0,
created_at=info.get("created_at", ""),
department=info.get("department", ""),
description=info.get("description", "")
))
try:
collection = self.get_collection(name)
result.append(CollectionInfo(
name=name,
display_name=info.get("display_name", name),
document_count=collection.count() if collection else 0,
created_at=info.get("created_at", ""),
department=info.get("department", ""),
description=info.get("description", "")
))
except Exception as e:
# 只跳过不修改元数据(可能是其他 worker 刚创建的集合)
logger.warning(
f"跳过异常向量库 '{name}': {e}"
)
result.append(CollectionInfo(
name=name,
display_name=info.get("display_name", name),
document_count=0,
created_at=info.get("created_at", ""),
department=info.get("department", ""),
description=info.get("description", "")
))
return result
@@ -405,4 +400,6 @@ class CollectionMixin:
Returns:
存在返回 True不存在返回 False
"""
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
self._metadata = self._load_metadata()
return kb_name in self._metadata.get("collections", {})

View File

@@ -29,6 +29,11 @@
import os
import json
import threading
try:
import fcntl
_HAS_FCNTL = True
except ImportError:
_HAS_FCNTL = False
from typing import List, Dict, Optional, Tuple
from pathlib import Path
import logging
@@ -134,22 +139,36 @@ class KnowledgeBaseManager(
logger.info(f"知识库管理器初始化完成,路径: {self.base_path},发现 {len(existing_kbs)} 个向量库: {existing_kbs}")
def _load_metadata(self) -> dict:
"""加载元数据"""
"""加载元数据(带文件锁)"""
metadata_path = os.path.join(self.base_path, KB_METADATA_FILE)
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
return json.load(f)
if _HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
try:
return json.load(f)
finally:
if _HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except Exception as e:
logger.error(f"加载元数据失败: {e}")
return {"collections": {}}
def _save_metadata(self):
"""保存元数据"""
"""保存元数据(带文件锁,防止并发写入覆盖)"""
metadata_path = os.path.join(self.base_path, KB_METADATA_FILE)
try:
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(self._metadata, f, ensure_ascii=False, indent=2)
if _HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
json.dump(self._metadata, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
finally:
if _HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except Exception as e:
logger.error(f"保存元数据失败: {e}")