Spaces:
Running
Running
| """Qdrant 向量数据库管理器 — 嵌入式模式(无需独立服务进程) | |
| 使用 QdrantClient(path=...) 在本地文件系统中运行 Qdrant, | |
| 适合 HF Spaces 单容器部署,无需额外启动 Qdrant 服务。 | |
| """ | |
| import os | |
| import hashlib | |
| import uuid | |
| import numpy as np | |
| import torch | |
| from qdrant_client import QdrantClient | |
| from qdrant_client.models import ( | |
| Distance, VectorParams, PointStruct, | |
| OptimizersConfigDiff, HnswConfigDiff, | |
| ) | |
| from module.config import QDRANT_PATH | |
| from module.text_classifier import classify_scene | |
| COLL_PREFIX = 'user_' | |
| _client_instance = None | |
| def _get_client(): | |
| """获取 Qdrant 客户端单例(嵌入式本地文件模式)""" | |
| global _client_instance | |
| if _client_instance is None: | |
| os.makedirs(QDRANT_PATH, exist_ok=True) | |
| _client_instance = QdrantClient(path=QDRANT_PATH) | |
| return _client_instance | |
| def pt_id(img_path): | |
| """根据图片绝对路径生成确定性 UUID""" | |
| return str(uuid.UUID(hex=hashlib.md5(os.path.abspath(img_path).encode()).hexdigest())) | |
| def _collection_name(username, scene_type): | |
| return f'{COLL_PREFIX}{username}_{scene_type}' | |
| def _bge_encode(text, bge_tokenizer, bge_model): | |
| """编码文本为 numpy float32[512]""" | |
| if not text.strip(): | |
| return np.zeros(512, dtype=np.float32) | |
| encoded = bge_tokenizer(text, padding=True, truncation=True, return_tensors='pt', max_length=512) | |
| # CPU 模式:不调用 .cuda() | |
| with torch.no_grad(): | |
| outputs = bge_model(**encoded) | |
| emb = outputs.last_hidden_state[:, 0] | |
| emb = torch.nn.functional.normalize(emb, p=2, dim=1) | |
| return emb.cpu().numpy()[0] | |
| def _dinov2_encode(img_path, dinov2_extractor): | |
| """提取 DINOv2 特征向量(384维)""" | |
| if dinov2_extractor is None or not dinov2_extractor.is_available: | |
| return np.zeros(384, dtype=np.float32) | |
| feat = dinov2_extractor.extract_feature(img_path) | |
| if feat is None: | |
| return np.zeros(384, dtype=np.float32) | |
| return feat.cpu().numpy() | |
| # ==================== 预存储: 构建用户缓存 ==================== | |
| def build_user_cache(username, user_dir, ocr_engine, dinov2_extractor, | |
| bge_tokenizer, bge_model): | |
| """为用户目录下所有历史图片构建 Qdrant 缓存""" | |
| from module.file_manager import get_history_images | |
| client = _get_client() | |
| images = get_history_images(user_dir) | |
| if not images: | |
| return 0, 0 | |
| text_pts, complex_pts = [], [] | |
| for img_path in images: | |
| try: | |
| scene_type, doc_score, detail, ocr_result = classify_scene(img_path, ocr_engine) | |
| pid = pt_id(img_path) | |
| payload = {'path': img_path, 'filename': os.path.basename(img_path)} | |
| if scene_type == 'text': | |
| full_text = detail.get('full_text', '') if detail else '' | |
| payload['ocr_text'] = full_text | |
| vector = _bge_encode(full_text, bge_tokenizer, bge_model) | |
| text_pts.append(PointStruct(id=pid, vector=vector.tolist(), payload=payload)) | |
| else: | |
| vector = _dinov2_encode(img_path, dinov2_extractor) | |
| complex_pts.append(PointStruct(id=pid, vector=vector.tolist(), payload=payload)) | |
| print(f" [qdrant] {os.path.basename(img_path):<30s} {scene_type}") | |
| except Exception as e: | |
| print(f" [qdrant] {os.path.basename(img_path)} ERROR: {e}") | |
| for suffix, pts, dim in [('text', text_pts, 512), ('complex', complex_pts, 384)]: | |
| if not pts: | |
| continue | |
| cname = _collection_name(username, suffix) | |
| if client.collection_exists(cname): | |
| client.delete_collection(cname) | |
| client.create_collection( | |
| cname, | |
| vectors_config=VectorParams(size=dim, distance=Distance.COSINE), | |
| optimizers_config=OptimizersConfigDiff(indexing_threshold=0), | |
| ) | |
| client.upsert(cname, points=pts, wait=True) | |
| for suffix in ['text', 'complex']: | |
| cname = _collection_name(username, suffix) | |
| if client.collection_exists(cname): | |
| client.update_collection(cname, hnsw_config=HnswConfigDiff(m=16, ef_construct=100)) | |
| return len(text_pts), len(complex_pts) | |
| # ==================== 查询: 从 Qdrant 拉取缓存 ==================== | |
| def qdrant_query_scene(username, scene_type): | |
| """从 Qdrant 拉取指定场景类型的用户缓存(scroll 一次,返回 dict)""" | |
| client = _get_client() | |
| cache = {} | |
| cname = _collection_name(username, scene_type) | |
| if not client.collection_exists(cname): | |
| return cache | |
| records, _ = client.scroll(cname, limit=1000, with_payload=True, with_vectors=True) | |
| for rec in records: | |
| entry = dict(rec.payload) | |
| entry['_vector'] = np.array(rec.vector, dtype=np.float32) | |
| if scene_type == 'text': | |
| entry['bge_vector'] = entry['_vector'] | |
| cache[rec.id] = entry | |
| return cache | |
| def qdrant_delete_user_collections(username): | |
| """删除指定用户的所有 Qdrant collection(text + complex) | |
| 用于用户历史库为空时清理残留数据 | |
| """ | |
| client = _get_client() | |
| for scene_type in ('text', 'complex'): | |
| cname = _collection_name(username, scene_type) | |
| try: | |
| if client.collection_exists(cname): | |
| client.delete_collection(cname) | |
| print(f"[Qdrant] 已删除 collection: {cname}") | |
| except Exception as e: | |
| print(f"[Qdrant] 删除 collection {cname} 失败: {e}") | |
| # ==================== 向量相似度搜索 ==================== | |
| def qdrant_search_similar(username, scene_type, query_vector, top_k=3, score_threshold=0.0): | |
| client = _get_client() | |
| cname = _collection_name(username, scene_type) | |
| if not client.collection_exists(cname): | |
| return [] | |
| response = client.query_points( | |
| collection_name=cname, | |
| query=query_vector.tolist(), | |
| limit=top_k, | |
| score_threshold=score_threshold, | |
| with_payload=True, | |
| with_vectors=True, | |
| ) | |
| results = [] | |
| for hit in response.points: | |
| entry = dict(hit.payload) | |
| entry['_vector'] = np.array(hit.vector, dtype=np.float32) | |
| if scene_type == 'text': | |
| entry['bge_vector'] = entry['_vector'] | |
| results.append((hit.score, hit.id, entry)) | |
| return results | |
| # ==================== 增量更新 ==================== | |
| def add_to_qdrant(username, img_path, ocr_engine, dinov2_extractor, | |
| bge_tokenizer, bge_model, scene_type=None, | |
| precomputed_text=None, precomputed_bge=None, | |
| precomputed_dinov2=None): | |
| """将新图片添加到 Qdrant(增量插入)""" | |
| client = _get_client() | |
| if scene_type is None: | |
| scene_type, _, detail, _ = classify_scene(img_path, ocr_engine) | |
| # 复用 classify_scene 的 OCR 结果,避免对同一张图重复 OCR | |
| if precomputed_text is None and scene_type == 'text': | |
| precomputed_text = detail.get('full_text', '') | |
| pid = pt_id(img_path) | |
| payload = {'path': img_path, 'filename': os.path.basename(img_path)} | |
| if scene_type == 'text': | |
| if precomputed_text is not None: | |
| full_text = precomputed_text | |
| else: | |
| from module.text_matcher import extract_text | |
| full_text, _ = extract_text(img_path, ocr_engine) | |
| payload['ocr_text'] = full_text | |
| if precomputed_bge is not None: | |
| vector = precomputed_bge | |
| else: | |
| vector = _bge_encode(full_text, bge_tokenizer, bge_model) | |
| dim = 512 | |
| else: | |
| if precomputed_dinov2 is not None: | |
| vector = precomputed_dinov2 | |
| else: | |
| vector = _dinov2_encode(img_path, dinov2_extractor) | |
| dim = 384 | |
| cname = _collection_name(username, scene_type) | |
| if not client.collection_exists(cname): | |
| client.create_collection( | |
| cname, | |
| vectors_config=VectorParams(size=dim, distance=Distance.COSINE), | |
| optimizers_config=OptimizersConfigDiff(indexing_threshold=0), | |
| ) | |
| client.update_collection(cname, hnsw_config=HnswConfigDiff(m=16, ef_construct=100)) | |
| client.upsert(cname, points=[PointStruct(id=pid, vector=vector.tolist(), payload=payload)], wait=True) | |
| return scene_type | |
| def remove_from_qdrant(username, img_path, scene_type=None): | |
| """从 Qdrant 中移除指定图片""" | |
| client = _get_client() | |
| pid = pt_id(img_path) | |
| if scene_type: | |
| cname = _collection_name(username, scene_type) | |
| if client.collection_exists(cname): | |
| try: | |
| client.delete(cname, points=[pid]) | |
| except Exception: | |
| pass | |
| else: | |
| for st in ['text', 'complex']: | |
| cname = _collection_name(username, st) | |
| if client.collection_exists(cname): | |
| try: | |
| client.delete(cname, points=[pid]) | |
| except Exception: | |
| pass | |
| def has_qdrant_cache(username): | |
| """检查用户是否有 Qdrant 缓存""" | |
| client = _get_client() | |
| for st in ['text', 'complex']: | |
| cname = _collection_name(username, st) | |
| if client.collection_exists(cname): | |
| count = client.count(cname).count | |
| if count > 0: | |
| return True | |
| return False | |