Files
rag/api/document_routes.py
lacerate551 cb75b9b274 fix(boundary): 修复多库边界问题、版本管理及删除清理
多库检索与存储修复:
- RRF 融合去重改用 (collection, chunk_id) 复合键,修复同名文件结果被吞
- DocStore 存储路径加 collection 前缀,修复跨库同名切片数据覆盖
- search_multiple 去重改用复合键
- chunk_id 解析改用 rsplit 兼容下划线文件名

上传与版本管理修复:
- 同名文件上传改为覆盖模式,自动清理旧切片
- 修复首次上传不创建版本记录
- 修复覆盖上传版本号回退到 v1
- sync ADDED 分支改用动态版本号生成
- _generate_version_id 改为基于全部版本递增
- 废止/恢复操作同步 SQLite 版本记录
- mark_document_as_superseded 改为仅更新 SQLite

删除清理修复:
- 删除文档时同步清理 SQLite 版本记录和变更日志
- 删除向量库时同步清理该库所有版本记录
- cleanup 改为清理 SQLite 记录而非 ChromaDB

测试:
- test_version_management.py: 27 条版本管理单元测试
- test_edge_cases.py: 28 条边界用例测试
- test_upload_dedup.py: 5 条上传去重测试
- e2e_risk_test.py: 27 条端到端风险测试

文档:
- 新增风险边界问题修复注意事项.md(面向后端的对接文档)
- 新增向量库边界风险分析.md
- 更新多篇现有文档
2026-06-04 23:58:44 +08:00

1087 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
文档管理 API
本模块提供文档上传、查询、删除等管理功能,包括:
- 单文件和批量上传
- 文档列表查询
- 文档切片管理
路由列表:
POST /documents/upload : 上传文件(单个)
POST /documents/batch-upload : 批量上传文件
GET /documents/list : 文档列表
GET /documents/<doc_id>/status : 文件处理状态
PUT /documents/<doc_id> : 更新文件
DELETE /documents/<doc_path> : 删除文档
GET /documents/<doc_id>/chunks : 查看文件切片
GET /documents/<doc_id>/preview : 文档预览(支持按切片序号跳转)
切片管理:
POST /chunks : 新增切片
PUT /chunks/<chunk_id> : 修改切片
DELETE /chunks/<chunk_id> : 删除切片
架构说明:
- 权限验证由后端网关完成RAG 服务不做权限判断
- 文档存储在 documents/<collection>/ 目录下
- 上传后自动触发向量化(如果同步服务可用)
Example:
# 上传文件
curl -X POST http://localhost:5001/documents/upload \\
-H "Authorization: Bearer mock-token-admin" \\
-F "file=@report.pdf" \\
-F "collection=public_kb"
"""
import os
import re
import uuid
from datetime import datetime
from typing import Optional, Tuple, Any, List, Dict
from flask import Blueprint, request, jsonify
import logging
logger = logging.getLogger(__name__)
from werkzeug.utils import secure_filename
from auth.gateway import require_gateway_auth
from core.status_codes import (
UPLOAD_SUCCESS, BATCH_UPLOAD_SUCCESS, BAD_REQUEST,
NO_FILE, NO_FILE_SELECTED, NO_COLLECTION,
FILE_TOO_LARGE, UNSUPPORTED_FORMAT, INTERNAL_ERROR
)
from api.response_utils import success_response, error_response
document_bp = Blueprint('document', __name__)
# 文件限制
ALLOWED_EXTENSIONS = {'.pdf', '.docx', '.doc', '.xlsx', '.txt'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
def safe_filename(filename: str) -> str:
"""
安全文件名处理 - 保留中文,仅移除危险字符
与 werkzeug.secure_filename() 不同,此函数保留 Unicode 字符(包括中文),
仅移除路径分隔符和其他可能导致路径遍历攻击的字符。
Args:
filename: 原始文件名
Returns:
安全的文件名;无效输入返回空字符串
Example:
>>> safe_filename("财务报告.pdf")
'财务报告.pdf'
>>> safe_filename("../../../etc/passwd")
'etc_passwd'
"""
if not filename:
return ""
# 移除路径分隔符和危险字符
dangerous_chars = ['/', '\\', '..', '\x00', '\n', '\r', '\t']
safe_name = filename
for char in dangerous_chars:
safe_name = safe_name.replace(char, '_')
# 移除首尾空格和点
safe_name = safe_name.strip(' .')
# 如果文件名为空或只有扩展名,返回空
if not safe_name or safe_name.startswith('.') and safe_name.count('.') == 1:
return ""
return safe_name
# 延迟初始化缓存
_kb_manager = None
_kb_checked = False
def _get_kb_manager() -> Optional[Any]:
"""
获取知识库管理器(延迟加载)
Returns:
知识库管理器实例,导入失败返回 None
"""
global _kb_manager, _kb_checked
if not _kb_checked:
try:
from knowledge.manager import get_kb_manager
_kb_manager = get_kb_manager()
except ImportError as e:
logger.warning(f"知识库管理器导入失败: {e}")
_kb_checked = True
return _kb_manager
def _get_sync_service() -> Optional[Any]:
"""
获取同步服务实例
Returns:
同步服务实例,不可用时返回 None
"""
try:
from flask import current_app
return current_app.config.get('SYNC_SERVICE')
except Exception as e:
logger.debug(f"获取同步服务失败: {e}")
return None
# ==================== 文档管理 ====================
@document_bp.route('/documents/<path:doc_path>/raw', methods=['GET'])
@require_gateway_auth
def serve_document_file(doc_path: str) -> Tuple[Any, int]:
"""
返回文档原始文件(仅开发模式)
用于前端预览文档内容,生产环境禁用此接口。
Args:
doc_path: 文档相对路径collection/filename
Returns:
文件内容或错误响应
Note:
仅在 DEV_MODE=true 时可用
"""
if os.environ.get('DEV_MODE', 'true').lower() == 'false':
return jsonify({"error": "仅开发环境可用"}), 403
from config import DOCUMENTS_PATH
from flask import send_from_directory
filepath = os.path.join(DOCUMENTS_PATH, doc_path)
if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404
directory = os.path.dirname(filepath)
filename = os.path.basename(filepath)
return send_from_directory(directory, filename)
@document_bp.route('/documents/upload', methods=['POST'])
@require_gateway_auth
def upload_document() -> Tuple[Any, int]:
"""
上传单个文件到知识库
接收文件上传,保存到指定向量库目录,并触发向量化。
表单参数:
file: 文件(必需)
collection: 目标向量库名称(必需)
支持的文件类型:
- PDF (.pdf)
- Word (.docx, .doc)
- Excel (.xlsx)
- 文本 (.txt)
文件大小限制:
- 最大 10MB
Returns:
成功: {"success": true, "data": {"file": {...}, "sync_status": "..."}}
失败: {"error": "...", "error_code": "..."}
Example:
curl -X POST http://localhost:5001/documents/upload \\
-F "file=@report.pdf" \\
-F "collection=public_kb"
"""
from config import DOCUMENTS_PATH
# 1. 检查文件
if 'file' not in request.files:
return error_response("NO_FILE", NO_FILE, "没有上传文件", http_status=400)
file = request.files['file']
if file.filename == '':
return error_response("NO_FILE_SELECTED", NO_FILE_SELECTED, "没有选择文件", http_status=400)
# 2. 获取目标向量库
collection = request.form.get('collection') or request.form.get('kb_name')
if not collection:
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定目标向量库 (collection 参数)", http_status=400)
# 3. 文件类型验证
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
return error_response(
"UNSUPPORTED_FORMAT",
UNSUPPORTED_FORMAT,
f"不支持的文件类型: {ext},支持: pdf, docx, doc, xlsx, txt",
http_status=400
)
# 4. 文件大小验证
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
return error_response("FILE_TOO_LARGE", FILE_TOO_LARGE, "文件大小超过限制 (最大 10MB)", http_status=400)
# 5. 保存文件到对应目录(目录名 = 向量库名)
target_subdir = collection
target_dir = os.path.join(DOCUMENTS_PATH, target_subdir)
os.makedirs(target_dir, exist_ok=True)
# 安全文件名 + 处理重名
original_filename = file.filename
ext = os.path.splitext(original_filename)[1].lower()
filename = safe_filename(original_filename)
if not filename:
filename = f"upload_{datetime.now().strftime('%Y%m%d%H%M%S')}{ext}"
filepath = os.path.join(target_dir, filename)
# 同名文件处理:清理旧切片 + 覆盖旧文件(替代原来的时间戳重命名策略)
replaced = False
if os.path.exists(filepath):
replaced = True
# 1. 清理旧切片ChromaDB + BM25 + DocStore
kb_manager = _get_kb_manager()
if kb_manager:
old_chunks = kb_manager.get_document_chunks(collection, filename)
if old_chunks:
old_ids = [c['id'] for c in old_chunks]
coll_obj = kb_manager.get_collection(collection)
if coll_obj:
coll_obj.delete(ids=old_ids)
logger.info(f"替换上传: 清理旧切片 {filename} -> {collection}, 共 {len(old_ids)}")
# 清理关联的 DocStore 文件
try:
from pathlib import Path as _Path
docstore_dir = _Path('.data/docstore')
if docstore_dir.exists():
for ds_file in docstore_dir.glob(f'{collection}_{filename}_*.json'):
ds_file.unlink()
except Exception as e:
logger.warning(f"清理 DocStore 失败: {e}")
# 重建 BM25 索引
kb_manager.rebuild_bm25_index(collection)
# 2. 清理同步哈希记录(以便 sync 能正确检测为新文件)
try:
from knowledge.sync import SyncDatabase
sync_db = SyncDatabase()
sync_db.delete_document_hash(f"{collection}/{filename}")
except Exception as e:
logger.warning(f"清理哈希记录失败: {e}")
# 保存文件(覆盖同名旧文件)
file.save(filepath)
# 版本管理:覆盖上传时标记旧版本为 superseded新版本记录由 sync 服务统一创建)
if replaced:
try:
from knowledge.document_versions import get_version_query
from data.db import get_connection
vq = get_version_query()
active = vq.get_active_version(collection, filename)
if active:
with get_connection("knowledge") as conn:
conn.execute("""
UPDATE document_versions
SET status='superseded', deprecated_date=?,
deprecated_reason='重新上传覆盖'
WHERE collection=? AND document_id=? AND version=?
""", (datetime.now().isoformat(), collection,
filename, active.version))
conn.commit()
vq.log_version_change(
collection, filename,
change_type="reupload",
old_version=active.version,
old_status="active", new_status="superseded",
reason="重新上传覆盖",
changed_by=request.current_user.get('user_id', '')
)
except Exception as e:
logger.warning(f"标记旧版本失败: {e}")
# 6. 触发向量化
sync_status = "已保存,等待手动同步"
sync_service = _get_sync_service()
if sync_service:
try:
from knowledge.sync import DocumentChange, ChangeType
change = DocumentChange(
document_id=f"{target_subdir}/{filename}",
document_name=filename,
change_type=ChangeType.ADDED,
old_hash=None,
new_hash=sync_service.calculate_file_hash(filepath),
change_time=datetime.now()
)
sync_service.process_change(change)
sync_status = "已保存并添加到向量库"
except Exception as e:
sync_status = f"已保存,向量化失败: {str(e)}"
return success_response(
data={
"file": {
"filename": filename,
"collection": collection,
"path": f"{target_subdir}/{filename}",
"size": file_size,
"replaced": replaced
},
"sync_status": sync_status
},
status_code=UPLOAD_SUCCESS,
message=f"文件上传成功,{sync_status}"
)
@document_bp.route('/documents/batch-upload', methods=['POST'])
@require_gateway_auth
def batch_upload_documents() -> Tuple[Any, int]:
"""
批量上传文件到知识库
支持同时上传多个文件到指定向量库目录。
表单参数:
files: 文件列表(必需)
collection: 目标向量库名称(必需)
Returns:
{
"success": true,
"data": {
"total": N,
"success_count": M,
"results": [...]
}
}
"""
from config import DOCUMENTS_PATH
# 检查文件
if 'files' not in request.files:
return error_response("NO_FILE", NO_FILE, "没有上传文件", http_status=400)
files = request.files.getlist('files')
if not files:
return error_response("NO_FILE_SELECTED", NO_FILE_SELECTED, "没有选择文件", http_status=400)
# 获取目标向量库
collection = request.form.get('collection') or request.form.get('kb_name')
if not collection:
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定目标向量库 (collection 参数)", http_status=400)
# 确定存储目录(目录名 = 向量库名)
target_subdir = collection
target_dir = os.path.join(DOCUMENTS_PATH, target_subdir)
os.makedirs(target_dir, exist_ok=True)
# 批量处理
results = []
for file in files:
if file.filename == '':
continue
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
results.append({
"filename": file.filename,
"status": "error",
"message": f"不支持的文件类型: {ext}"
})
continue
try:
original_filename = file.filename
ext = os.path.splitext(original_filename)[1].lower()
filename = safe_filename(original_filename)
if not filename:
filename = f"upload_{datetime.now().strftime('%Y%m%d%H%M%S')}{ext}"
filepath = os.path.join(target_dir, filename)
# 同名文件处理:清理旧切片 + 覆盖旧文件
replaced = False
if os.path.exists(filepath):
replaced = True
kb_mgr = _get_kb_manager()
if kb_mgr:
old_chunks = kb_mgr.get_document_chunks(collection, filename)
if old_chunks:
old_ids = [c['id'] for c in old_chunks]
coll_obj = kb_mgr.get_collection(collection)
if coll_obj:
coll_obj.delete(ids=old_ids)
logger.info(f"批量替换: 清理旧切片 {filename} -> {collection}, 共 {len(old_ids)}")
kb_mgr.rebuild_bm25_index(collection)
try:
from knowledge.sync import SyncDatabase
sync_db = SyncDatabase()
sync_db.delete_document_hash(f"{collection}/{filename}")
except Exception as e:
logger.warning(f"清理哈希记录失败: {e}")
file.save(filepath)
# 版本管理:覆盖上传时标记旧版本为 superseded新版本记录由 sync 服务统一创建)
if replaced:
try:
from knowledge.document_versions import get_version_query
from data.db import get_connection
vq = get_version_query()
active = vq.get_active_version(collection, filename)
if active:
with get_connection("knowledge") as conn:
conn.execute("""
UPDATE document_versions
SET status='superseded', deprecated_date=?,
deprecated_reason='批量上传覆盖'
WHERE collection=? AND document_id=? AND version=?
""", (datetime.now().isoformat(), collection,
filename, active.version))
conn.commit()
except Exception as e:
logger.warning(f"批量上传标记旧版本失败: {e}")
results.append({
"filename": filename,
"status": "success",
"path": f"{target_subdir}/{filename}",
"replaced": replaced
})
except Exception as e:
results.append({
"filename": file.filename,
"status": "error",
"message": str(e)
})
return success_response(
data={
"total": len(results),
"success_count": len([r for r in results if r["status"] == "success"]),
"results": results
},
status_code=BATCH_UPLOAD_SUCCESS,
message=f"批量上传完成,成功 {len([r for r in results if r['status'] == 'success'])}/{len(results)} 个文件"
)
@document_bp.route('/documents/list', methods=['GET'])
@require_gateway_auth
def list_documents() -> Tuple[Any, int]:
"""
获取文档列表
扫描文档目录,返回所有可用的文档信息。
查询参数:
collection: 过滤指定向量库(可选)
Returns:
{
"documents": [
{
"filename": "...",
"collection": "...",
"path": "...",
"size": N,
"last_modified": "ISO 8601"
},
...
],
"total": N
}
"""
from config import DOCUMENTS_PATH
collection = request.args.get('collection') or request.args.get('kb_name')
# 确定要扫描的目录(目录名 = 向量库名)
if collection:
subdirs = [collection]
else:
# 列出所有文档目录
subdirs = []
if os.path.exists(DOCUMENTS_PATH):
for d in os.listdir(DOCUMENTS_PATH):
if os.path.isdir(os.path.join(DOCUMENTS_PATH, d)):
subdirs.append(d)
documents = []
supported_extensions = {'.pdf', '.docx', '.doc', '.xlsx', '.txt'}
for subdir in subdirs:
level_dir = os.path.join(DOCUMENTS_PATH, subdir)
if not os.path.exists(level_dir):
continue
# 目录名即向量库名
coll_name = subdir
for filename in os.listdir(level_dir):
ext = os.path.splitext(filename)[1].lower()
if ext not in supported_extensions:
continue
filepath = os.path.join(level_dir, filename)
try:
stat = os.stat(filepath)
documents.append({
"filename": filename,
"collection": coll_name,
"path": f"{subdir}/{filename}",
"size": stat.st_size,
"last_modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
})
except Exception as e:
logger.warning(f"读取文件信息失败: {filename}, {e}")
# 按修改时间倒序
documents.sort(key=lambda x: x['last_modified'], reverse=True)
return jsonify({
"documents": documents,
"total": len(documents)
})
@document_bp.route('/documents/<path:doc_path>/status', methods=['GET'])
@require_gateway_auth
def get_document_status(doc_path: str) -> Tuple[Any, int]:
"""
获取文件处理状态
查询指定文档的向量化状态和切片数量。
Args:
doc_path: 文档相对路径collection/filename
Returns:
{
"success": true,
"status": "processed|pending|error",
"chunk_count": N,
"last_processed": "ISO 8601"
}
"""
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400
subdir = parts[0]
filename = '/'.join(parts[1:])
# 目录名即向量库名
collection = subdir
# 获取文档信息
from config import DOCUMENTS_PATH
file_on_disk = os.path.isfile(os.path.join(DOCUMENTS_PATH, subdir, filename))
doc_info = kb_manager.get_document_info(collection, filename)
if not doc_info:
if file_on_disk:
return jsonify({
"success": True,
"status": "unprocessed",
"chunk_count": 0,
"last_processed": None
})
return jsonify({"error": "文档不存在"}), 404
return jsonify({
"success": True,
"status": doc_info.get("status", "unknown"),
"chunk_count": doc_info.get("total_chunks", 0),
"last_processed": doc_info.get("effective_date") or doc_info.get("version")
})
@document_bp.route('/documents/<path:doc_path>', methods=['PUT'])
@require_gateway_auth
def update_document(doc_path: str) -> Tuple[Any, int]:
"""
更新文件(重新上传覆盖)
替换现有文件内容,并触发重新向量化。
Args:
doc_path: 文档相对路径collection/filename
表单参数:
file: 新文件(必需)
Returns:
{"success": true, "message": "文件已更新"}
"""
from config import DOCUMENTS_PATH
if 'file' not in request.files:
return jsonify({"error": "没有上传文件"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"error": "没有选择文件"}), 400
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400
filepath = os.path.join(DOCUMENTS_PATH, doc_path)
if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404
# 覆盖文件
file.save(filepath)
# 触发重新向量化
sync_service = _get_sync_service()
if sync_service:
try:
subdir = parts[0]
filename = '/'.join(parts[1:])
from knowledge.sync import DocumentChange, ChangeType
change = DocumentChange(
document_id=doc_path,
document_name=filename,
change_type=ChangeType.MODIFIED,
old_hash=None,
new_hash=sync_service.calculate_file_hash(filepath),
change_time=datetime.now()
)
sync_service.process_change(change)
except Exception as e:
logger.warning(f"重新向量化失败: {e}")
return jsonify({
"success": True,
"message": "文件已更新"
})
@document_bp.route('/documents/<path:doc_path>', methods=['DELETE'])
@require_gateway_auth
def delete_document(doc_path: str) -> Tuple[Any, int]:
"""
删除文档
同时删除向量库中的切片和物理文件。
Args:
doc_path: 文档相对路径collection/filename
Returns:
{"success": true, "message": "文档已删除"}
Note:
此操作不可逆,请谨慎使用
"""
from config import DOCUMENTS_PATH
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400
subdir = parts[0]
filename = '/'.join(parts[1:])
# 目录名即向量库名
collection = subdir
filepath = os.path.join(DOCUMENTS_PATH, doc_path)
if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404
try:
# 1. 从向量库删除source 存的是文件名,不是完整路径)
kb_manager = _get_kb_manager()
if kb_manager:
kb_manager.delete_document(collection, filename)
# 2. 删除文件
os.remove(filepath)
return jsonify({
"success": True,
"message": "文档已删除"
})
except Exception as e:
return jsonify({"error": f"删除失败: {str(e)}"}), 500
@document_bp.route('/documents/<path:doc_path>/chunks', methods=['GET'])
@require_gateway_auth
def list_document_chunks(doc_path: str) -> Tuple[Any, int]:
"""
查看文件切片
返回指定文档的所有切片内容,用于调试和验证。
Args:
doc_path: 文档相对路径collection/filename
Returns:
{
"success": true,
"document_id": "...",
"collection": "...",
"chunks": [...],
"total": N
}
"""
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400
subdir = parts[0]
# 目录名即向量库名
collection = subdir
chunks = kb_manager.get_document_chunks(collection, os.path.basename(doc_path))
return jsonify({
"success": True,
"document_id": doc_path,
"collection": collection,
"chunks": chunks,
"total": len(chunks)
})
@document_bp.route('/documents/<path:doc_path>/preview', methods=['GET'])
@require_gateway_auth
def preview_document(doc_path: str) -> Tuple[Any, int]:
"""
文档预览接口(支持按切片序号跳转)
用于前端引用溯源点击跳转:给定 chunk_index返回目标切片及其上下文。
复用现有切片查询逻辑,不新增存储。
Query Params:
chunk_index (int): 目标切片序号(来自 citation 的 chunk_index 字段)
context (int): 上下文切片数,默认 2前后各取 2 个)
Returns:
{
"success": true,
"collection": "...",
"source": "文件名",
"total_chunks": N,
"target_index": M,
"chunks": [
{"id": "...", "content": "...", "metadata": {...}, "is_target": true/false},
...
]
}
"""
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径,格式: collection/filename"}), 400
collection = parts[0]
filename = os.path.basename(doc_path)
# 查询参数
chunk_index_str = request.args.get('chunk_index')
context_count = int(request.args.get('context', 2))
# 获取所有切片
all_chunks = kb_manager.get_document_chunks(collection, filename)
if not all_chunks:
return jsonify({"error": f"文档 '{filename}' 不存在或无切片"}), 404
total = len(all_chunks)
# 如果未指定 chunk_index返回前 5 个切片作为概览
if chunk_index_str is None:
preview_chunks = all_chunks[:5]
for c in preview_chunks:
c['is_target'] = False
return jsonify({
"success": True,
"collection": collection,
"source": filename,
"total_chunks": total,
"target_index": None,
"chunks": preview_chunks
})
# 定位目标切片 —— 按 meta.chunk_index 排序后查找,避免数组下标与 chunk_index 不一致
try:
target_chunk_index = int(chunk_index_str)
except ValueError:
return jsonify({"error": "chunk_index 必须为整数"}), 400
# 按 chunk_index 排序Chroma 返回顺序不保证有序)
all_chunks.sort(key=lambda c: c.get('metadata', {}).get('chunk_index', 0))
# 按 meta.chunk_index 查找目标切片在排序后数组中的实际位置
target_pos = None
for i, c in enumerate(all_chunks):
if c.get('metadata', {}).get('chunk_index') == target_chunk_index:
target_pos = i
break
# 回退:直接用数组下标(兼容旧数据无 chunk_index 字段的情况)
if target_pos is None:
if 0 <= target_chunk_index < total:
target_pos = target_chunk_index
else:
max_idx = max(
(c.get('metadata', {}).get('chunk_index', 0) for c in all_chunks),
default=total - 1
)
return jsonify({
"error": f"chunk_index={target_chunk_index} 未找到对应切片 (可用范围 0-{max_idx})"
}), 400
# 截取上下文窗口
start = max(0, target_pos - context_count)
end = min(total, target_pos + context_count + 1)
window = all_chunks[start:end]
# 标记目标切片
for i, c in enumerate(window):
c['is_target'] = (start + i == target_pos)
return jsonify({
"success": True,
"collection": collection,
"source": filename,
"total_chunks": total,
"target_index": target_chunk_index,
"chunks": window
})
# ==================== 切片管理 ====================
@document_bp.route('/chunks', methods=['POST'])
@require_gateway_auth
def create_chunk() -> Tuple[Any, int]:
"""
新增切片
手动向向量库添加一个切片,用于补充或修正内容。
请求体:
{
"collection": "向量库名称",
"content": "切片内容",
"metadata": {} // 可选
}
Returns:
{"success": true, "chunk_id": "...", "message": "切片已添加"}
"""
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
data = request.json or {}
collection = data.get('collection')
content = data.get('content')
metadata = data.get('metadata', {})
if not collection:
return jsonify({"error": "请指定向量库 (collection)"}), 400
if not content:
return jsonify({"error": "切片内容不能为空"}), 400
chunk_id = kb_manager.add_chunk(collection, content, metadata)
return jsonify({
"success": True,
"chunk_id": chunk_id,
"message": "切片已添加"
})
@document_bp.route('/chunks/<chunk_id>', methods=['PUT'])
@require_gateway_auth
def update_chunk(chunk_id: str) -> Tuple[Any, int]:
"""
修改切片
更新指定切片的内容或元数据。
Args:
chunk_id: 切片 ID
请求体:
{
"collection": "向量库名称",
"content": "新内容", // 可选
"metadata": {} // 可选
}
Returns:
{"success": true, "message": "切片已更新"}
"""
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
data = request.json or {}
collection = data.get('collection')
content = data.get('content')
metadata = data.get('metadata')
if not collection:
return jsonify({"error": "请指定向量库 (collection)"}), 400
success = kb_manager.update_chunk(collection, chunk_id, content=content, metadata=metadata)
if success:
return jsonify({"success": True, "message": "切片已更新"})
return jsonify({"error": "更新失败"}), 500
@document_bp.route('/chunks/<chunk_id>', methods=['DELETE'])
@require_gateway_auth
def delete_chunk(chunk_id: str) -> Tuple[Any, int]:
"""
删除切片
从向量库中移除指定切片。如果删除后该文件没有其他切片,
同时清理文档哈希记录,以便同步服务能重新检测该文件。
Args:
chunk_id: 切片 ID
请求体或查询参数:
collection: 向量库名称(必需)
Returns:
{"success": true, "message": "切片已删除"}
"""
data = request.get_json(silent=True) or {}
collection = data.get('collection') if data else None
if not collection:
collection = request.args.get('collection')
if not collection:
return jsonify({"error": "请指定向量库 (collection)"}), 400
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
# 删除切片,返回 (success, source_file)
success, source_file = kb_manager.delete_chunk(collection, chunk_id)
if success and source_file:
# 检查该文件是否还有其他切片
remaining_chunks = kb_manager.list_chunks(collection, limit=100000)
has_other_chunks = any(
c.get('metadata', {}).get('source') == source_file
for c in remaining_chunks
)
# 如果没有其他切片了,删除哈希记录
if not has_other_chunks:
try:
from knowledge.sync import SyncDatabase
sync_db = SyncDatabase()
document_id = f"{collection}/{source_file}"
sync_db.delete_document_hash(document_id)
logger.info(f"清理文档哈希记录: {document_id}")
except Exception as e:
logger.warning(f"清理哈希记录失败: {e}")
if success:
return jsonify({"success": True, "message": "切片已删除"})
return jsonify({"error": "删除失败"}), 500
@document_bp.route('/chunks/batch', methods=['DELETE'])
@require_gateway_auth
def delete_chunks_by_source() -> Tuple[Any, int]:
"""
批量删除指定文件的所有切片
删除指定向量库中某个文件的所有切片,并清理哈希记录。
相比逐个删除,批量删除更高效,避免前端超时。
请求体:
{
"collection": "向量库名称",
"source": "文件名"
}
Returns:
{"success": true, "deleted_count": N, "message": "..."}
"""
data = request.get_json(silent=True) or {}
collection = data.get('collection')
source = data.get('source')
if not collection:
return jsonify({"error": "请指定向量库 (collection)"}), 400
if not source:
return jsonify({"error": "请指定文件名 (source)"}), 400
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
# 批量删除该文件的所有切片
try:
deleted_count = kb_manager.delete_chunks_by_source(collection, source)
# 清理哈希记录
if deleted_count > 0:
try:
from knowledge.sync import SyncDatabase
sync_db = SyncDatabase()
# 统一使用正斜杠格式(与 sync.py scan_documents 一致)
document_id = f"{collection}/{source}"
sync_db.delete_document_hash(document_id)
logger.info(f"清理文档哈希记录: {document_id}")
except Exception as e:
logger.warning(f"清理哈希记录失败: {e}")
return jsonify({
"success": True,
"deleted_count": deleted_count,
"message": f"已删除 {deleted_count} 个切片"
})
except Exception as e:
logger.error(f"批量删除切片失败: {e}")
return jsonify({"error": str(e)}), 500