Files

310 lines
15 KiB
Python
Raw Permalink Normal View History

"""Qdrant REST adapter preserving the application's original chunk IDs and metadata.
The small collection interface matches the store's existing snapshot/rollback code.
REST calls have deadlines; backend errors never fall back to a stale local index.
"""
from __future__ import annotations
import hashlib
import math
import threading
import uuid
import httpx
class QdrantError(RuntimeError):
def __init__(self, message, *, status_code=None):
super().__init__(message)
self.status_code = status_code
class QdrantClient:
def __init__(self, url, api_key, prefix='yawan_', *, http_client=None):
if not prefix or not all(c.isalnum() or c in '_-' for c in prefix):
raise ValueError('Qdrant 知识库命名空间无效')
self.prefix = prefix
self._creation_lock = threading.Lock()
self.http = http_client or httpx.Client(base_url=url.rstrip('/'),
headers={'api-key': api_key}, timeout=httpx.Timeout(25, connect=5),
limits=httpx.Limits(max_connections=8, max_keepalive_connections=4), trust_env=False)
def request(self, method, path, body=None, *, missing_ok=False):
try:
response = self.http.request(method, path, json=body)
except httpx.TimeoutException as exc:
raise QdrantError('Qdrant 知识索引请求超时,请检查向量服务') from exc
except httpx.HTTPError as exc:
raise QdrantError('Qdrant 知识索引连接失败,请检查向量服务') from exc
if missing_ok and response.status_code == 404:
return None
if not response.is_success:
# No body: it can contain stored user text or reflected credentials.
raise QdrantError(f'Qdrant 知识索引操作失败(HTTP {response.status_code})', status_code=response.status_code)
try:
payload = response.json()
return payload.get('result', payload)
except (ValueError, AttributeError) as exc:
raise QdrantError('Qdrant 知识索引返回了无效数据') from exc
def get_collection(self, name):
collection = QdrantCollection(self, name)
result = self.request('GET', collection.path, missing_ok=True)
if result is None:
raise QdrantError(f'知识索引集合不存在:{name}')
collection.configure(result)
return collection
def get_or_create_collection(self, name):
collection = QdrantCollection(self, name)
with self._creation_lock:
description = self.request('GET', collection.path, missing_ok=True)
if description is None:
try:
self.request('PUT', collection.path, {
'vectors': {'size': 512, 'distance': 'Cosine'},
'on_disk_payload': True,
'optimizers_config': {'default_segment_number': 2},
'hnsw_config': {'m': 16, 'ef_construct': 128}})
except QdrantError as exc:
# Another process can win after our GET; only accept a
# confirmed, compatible collection, never hide server errors.
if exc.status_code not in {400, 409}:
raise
description = self.request('GET', collection.path, missing_ok=True)
if description is None:
raise exc
else:
description = {'config': {'params': {'vectors': {'size': 512, 'distance': 'Cosine'}}}}
collection.configure(description)
self.request('PUT', collection.path + '/index?wait=true',
{'field_name': 'meta.file', 'field_schema': 'keyword'})
else:
collection.configure(description)
return collection
def delete_collection(self, name):
return self.request('DELETE', QdrantCollection(self, name).path, missing_ok=True)
def close(self):
self.http.close()
class QdrantCollection:
def __init__(self, client, name):
if not name or not all(c.isalnum() or c in '_-' for c in name):
raise ValueError('知识索引集合名称无效')
self.client, self.name = client, name
self.path = '/collections/' + client.prefix + name
self.distance = 'Cosine'
def configure(self, description):
try:
vectors = description['config']['params']['vectors']
except (KeyError, TypeError):
vectors = None
if not isinstance(vectors, dict) or vectors.get('size') != 512 or vectors.get('distance') not in {'Cosine', 'Euclid'}:
raise QdrantError('知识索引集合必须使用 512 维 Cosine 或 Euclid 向量')
self.distance = vectors['distance']
def point_id(self, original_id):
return str(uuid.uuid5(uuid.NAMESPACE_URL, self.client.prefix + self.name + ':' + str(original_id)))
@staticmethod
def filter(where):
if not where:
return None
if not isinstance(where, dict) or set(where) != {'file'}:
raise ValueError('不支持的知识索引过滤条件')
value = where['file']
if isinstance(value, dict):
if set(value) != {'$in'} or not isinstance(value['$in'], (list, tuple, set)) or any(not isinstance(v, str) for v in value['$in']):
raise ValueError('知识索引文件过滤条件无效')
match = {'any': list(value['$in'])}
elif isinstance(value, str):
match = {'value': value}
else:
raise ValueError('知识索引文件过滤条件无效')
return {'must': [{'key': 'meta.file', 'match': match}]}
def count(self, where=None):
body = {'exact': True}
filtering = self.filter(where)
if filtering is not None:
body['filter'] = filtering
result = self.client.request('POST', self.path + '/points/count', body)
return int(result['count'])
def _pages(self, where, include, size):
if isinstance(size, bool) or not isinstance(size, int) or not 1 <= size <= 4000:
raise ValueError('索引分页大小无效')
offset = None
seen_offsets = set()
while True:
body = {'limit': size, 'with_vector': 'embeddings' in include,
'with_payload': {'include': ['chunk_id', 'meta'] + (['text'] if 'documents' in include else [])}}
filtering = self.filter(where)
if filtering is not None:
body['filter'] = filtering
if offset is not None:
body['offset'] = offset
result = self.client.request('POST', self.path + '/points/scroll', body)
yield result.get('points', [])
new_offset = result.get('next_page_offset')
if new_offset is None:
break
if new_offset in seen_offsets:
raise QdrantError('Qdrant 分页游标未前进')
seen_offsets.add(new_offset)
offset = new_offset
@staticmethod
def _format(points, include):
result = {'ids': [], 'documents': [], 'metadatas': [], 'embeddings': []}
for point in points:
payload = point.get('payload') or {}
if not isinstance(payload.get('chunk_id'), str) or not payload['chunk_id']:
raise QdrantError('知识索引原始片段编号缺失')
result['ids'].append(payload['chunk_id'])
if 'documents' in include:
if not isinstance(payload.get('text'), str):
raise QdrantError('知识索引原文缺失')
result['documents'].append(payload['text'])
if 'metadatas' in include:
if not isinstance(payload.get('meta'), dict):
raise QdrantError('知识索引来源元数据缺失')
result['metadatas'].append(payload['meta'])
if 'embeddings' in include:
vector = point.get('vector')
if not isinstance(vector, list) or len(vector) != 512 or any(not isinstance(v, (int, float)) or not math.isfinite(v) for v in vector):
raise QdrantError('知识索引向量维度无效')
result['embeddings'].append(vector)
return result
def iter_records(self, where=None, include=None, page_size=256):
include = ['documents', 'metadatas'] if include is None else include
seen = set()
for page in self._pages(where, include, page_size):
self._check_scope(page, where)
result = self._format(page, include)
for identifier in result['ids']:
if identifier in seen:
raise QdrantError('知识索引分页返回重复片段,请重新读取')
seen.add(identifier)
yield result
@staticmethod
def _check_scope(points, where):
if not where:
return
allowed = where['file']
for point in points:
name = (point.get('payload', {}).get('meta') or {}).get('file')
if not (name in allowed['$in'] if isinstance(allowed, dict) else name == allowed):
raise QdrantError('知识索引返回了文件范围外的片段')
def get(self, *, ids=None, where=None, include=None, limit=None, offset=0):
include = ['documents', 'metadatas'] if include is None else include
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0 or (limit is not None and (isinstance(limit, bool) or not isinstance(limit, int) or limit < 0)):
raise ValueError('索引分页范围无效')
if limit == 0 or ids == []:
return self._format([], include)
if ids is not None:
self.filter(where)
found = {}
for start in range(0, len(ids), 128):
batch = ids[start:start + 128]
points = self.client.request('POST', self.path + '/points',
{'ids': [self.point_id(identifier) for identifier in batch],
'with_payload': True, 'with_vector': 'embeddings' in include})
for point in points:
original_id = (point.get('payload') or {}).get('chunk_id')
if original_id not in batch:
raise QdrantError('知识索引返回了请求范围外的片段')
if where:
name = (point.get('payload', {}).get('meta') or {}).get('file')
allowed = where['file']
if not (name in allowed['$in'] if isinstance(allowed, dict) else name == allowed):
continue
found[original_id] = point
selected = [found[identifier] for identifier in ids if identifier in found]
return self._format(selected[offset:None if limit is None else offset + limit], include)
retained = []
visited = 0
for page in self._pages(where, include, min(256, max(1, limit or 256))):
self._check_scope(page, where)
for point in page:
if visited >= offset:
retained.append(point)
visited += 1
if limit is not None and len(retained) >= limit:
return self._format(retained, include)
return self._format(retained, include)
def add(self, *, ids, documents, embeddings, metadatas):
if not (len(ids) == len(documents) == len(embeddings) == len(metadatas)):
raise ValueError('索引编号、正文、向量和元数据数量不一致')
# Validate the entire input before the first batch is written.
if any(not isinstance(identifier, str) or not identifier for identifier in ids) or len(set(ids)) != len(ids):
raise ValueError('索引片段编号为空或重复')
for text, vector, meta in zip(documents, embeddings, metadatas):
if not isinstance(text, str) or not text.strip() or not isinstance(meta, dict):
raise ValueError('索引正文或元数据无效')
if not isinstance(vector, (list, tuple)) or len(vector) != 512 or any(not isinstance(v, (int, float)) or not math.isfinite(v) for v in vector):
raise ValueError('索引向量必须是 512 维有限数值')
for start in range(0, len(ids), 128):
points = [{'id': self.point_id(ids[i]), 'vector': embeddings[i],
'payload': {'chunk_id': ids[i], 'text': documents[i], 'meta': metadatas[i]}}
for i in range(start, min(len(ids), start + 128))]
self.client.request('PUT', self.path + '/points?wait=true', {'points': points})
def delete(self, *, where=None, ids=None):
if ids is not None and where is not None:
raise ValueError('删除知识索引不可同时指定两种范围')
if ids is not None:
if not ids:
return None
body = {'points': [self.point_id(identifier) for identifier in ids]}
elif where:
body = {'filter': self.filter(where)}
else:
raise ValueError('删除知识索引必须指定文件或片段编号')
return self.client.request('POST', self.path + '/points/delete?wait=true', body)
def query(self, *, query_embeddings, n_results=6, where=None):
if isinstance(n_results, bool) or not isinstance(n_results, int) or n_results < 1:
raise ValueError('知识索引检索数量无效')
result = {'ids': [], 'documents': [], 'metadatas': [], 'distances': []}
for vector in query_embeddings:
body = {'query': vector, 'limit': n_results, 'with_payload': True,
'with_vector': False, 'params': {'hnsw_ef': max(64, min(256, n_results * 2))}}
filtering = self.filter(where)
if filtering is not None:
body['filter'] = filtering
points = self.client.request('POST', self.path + '/points/query', body).get('points', [])
self._check_scope(points, where)
formatted = self._format(points, ['documents', 'metadatas'])
for key in ('ids', 'documents', 'metadatas'):
result[key].append(formatted[key])
# Chroma's existing default is squared L2. Our embeddings are normalized.
result['distances'].append([max(0., 2. * (1. - float(point['score']))) if self.distance == 'Cosine'
else float(point['score']) ** 2 for point in points])
return result
_client = None
_client_signature = None
_lock = threading.Lock()
def get_client():
from .backend import qdrant_settings
global _client, _client_signature
url, key, prefix = qdrant_settings()
signature = (url, hashlib.sha256(key.encode()).hexdigest(), prefix)
with _lock:
if _client is None or _client_signature != signature:
if _client is not None:
_client.close()
_client = QdrantClient(url, key, prefix)
_client_signature = signature
return _client