scene-detection / module /qdrant_manager.py
jslmmfboom-coder
Fix: 修复首张文本场景图重复OCR问题,更新关于页技术栈徽章
6827314
Raw
History Blame Contribute Delete
9.37 kB
"""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