310 lines
15 KiB
Python
310 lines
15 KiB
Python
"""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
|