ico-cache 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ico_cache/__init__.py +7 -0
- ico_cache/async_ingest.py +207 -0
- ico_cache/backends/base.py +47 -0
- ico_cache/backends/embedding/fastembed_embedder.py +10 -0
- ico_cache/backends/exact/redis_store.py +42 -0
- ico_cache/backends/exact/sqlite_store.py +43 -0
- ico_cache/backends/vector/lancedb_store.py +160 -0
- ico_cache/backends/vector/qdrant_store.py +90 -0
- ico_cache/client.py +20 -0
- ico_cache/core/cache_engine.py +686 -0
- ico_cache/core/config.py +5 -0
- ico_cache/core/metadata_guard.py +74 -0
- ico_cache/gc.py +0 -0
- ico_cache/invalidation.py +227 -0
- ico_cache/loaders/__init__.py +45 -0
- ico_cache/loaders/_common.py +60 -0
- ico_cache/loaders/auto_loader.py +185 -0
- ico_cache/loaders/base.py +29 -0
- ico_cache/loaders/code_loader.py +321 -0
- ico_cache/loaders/html_loader.py +77 -0
- ico_cache/loaders/image_loader.py +57 -0
- ico_cache/loaders/ocr.py +190 -0
- ico_cache/loaders/odf_loader.py +163 -0
- ico_cache/loaders/office_loader.py +135 -0
- ico_cache/loaders/pdf_loader.py +110 -0
- ico_cache/loaders/structured_loader.py +213 -0
- ico_cache/loaders/txt_loader.py +117 -0
- ico_cache/py.typed +1 -0
- ico_cache/rag/pipeline.py +184 -0
- ico_cache/rag/reranker.py +0 -0
- ico_cache/telemetry/langfuse.py +79 -0
- ico_cache/telemetry/logging.py +56 -0
- ico_cache/telemetry/metrics.py +112 -0
- ico_cache/telemetry/tracing.py +143 -0
- ico_cache-1.0.0.dist-info/METADATA +92 -0
- ico_cache-1.0.0.dist-info/RECORD +39 -0
- ico_cache-1.0.0.dist-info/WHEEL +5 -0
- ico_cache-1.0.0.dist-info/licenses/LICENSE +1 -0
- ico_cache-1.0.0.dist-info/top_level.txt +1 -0
ico_cache/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .core.cache_engine import CacheEngine, CacheEngine as ICOCache
|
|
2
|
+
from .core.config import ICOConfig
|
|
3
|
+
from .loaders.auto_loader import ingest, AutoLoader
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
__all__ = ["CacheEngine", "ICOCache", "ICOConfig", "ingest", "AutoLoader", "__version__"]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from .loaders.auto_loader import AutoLoader
|
|
11
|
+
from .core.cache_engine import CacheEngine
|
|
12
|
+
from .core.metadata_guard import MetadataSchema
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("ico_cache.async_ingest")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IngestionJob(BaseModel):
|
|
18
|
+
job_id: str
|
|
19
|
+
tenant_id: str
|
|
20
|
+
file_path: str
|
|
21
|
+
file_size_bytes: int
|
|
22
|
+
status: str = "queued" # queued, processing, completed, failed
|
|
23
|
+
is_async: bool = False
|
|
24
|
+
chunks_total: int = 0
|
|
25
|
+
chunks_processed: int = 0
|
|
26
|
+
created_at: float = Field(default_factory=time.time)
|
|
27
|
+
completed_at: Optional[float] = None
|
|
28
|
+
elapsed_s: float = 0.0
|
|
29
|
+
error: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class IngestionJobManager:
|
|
33
|
+
"""
|
|
34
|
+
Manages synchronous and asynchronous document ingestion.
|
|
35
|
+
Files exceeding async_threshold_bytes (default 1MB) are ingested
|
|
36
|
+
asynchronously in background tasks with queryable job status.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
async_threshold_bytes: int = 1 * 1024 * 1024,
|
|
42
|
+
job_ttl_seconds: int = 3600,
|
|
43
|
+
max_jobs: int = 10000,
|
|
44
|
+
):
|
|
45
|
+
self.async_threshold_bytes = async_threshold_bytes
|
|
46
|
+
self.job_ttl_seconds = job_ttl_seconds
|
|
47
|
+
self.max_jobs = max_jobs
|
|
48
|
+
self.jobs: Dict[str, IngestionJob] = {}
|
|
49
|
+
self._lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
def _cleanup_expired_unlocked(self, now: Optional[float] = None) -> int:
|
|
52
|
+
"""Evicts completed/failed jobs older than job_ttl_seconds, and enforces max_jobs cap."""
|
|
53
|
+
ts = now if now is not None else time.time()
|
|
54
|
+
to_delete = []
|
|
55
|
+
|
|
56
|
+
# 1. Evict by TTL
|
|
57
|
+
for jid, job in self.jobs.items():
|
|
58
|
+
if job.status in ["completed", "failed"] and job.completed_at:
|
|
59
|
+
if (ts - job.completed_at) > self.job_ttl_seconds:
|
|
60
|
+
to_delete.append(jid)
|
|
61
|
+
|
|
62
|
+
for jid in to_delete:
|
|
63
|
+
del self.jobs[jid]
|
|
64
|
+
|
|
65
|
+
# 2. Evict oldest finished jobs if exceeding capacity cap
|
|
66
|
+
if len(self.jobs) > self.max_jobs:
|
|
67
|
+
finished = sorted(
|
|
68
|
+
[j for j in self.jobs.values() if j.status in ["completed", "failed"]],
|
|
69
|
+
key=lambda x: x.completed_at or 0.0,
|
|
70
|
+
)
|
|
71
|
+
excess = len(self.jobs) - self.max_jobs
|
|
72
|
+
for j in finished[:excess]:
|
|
73
|
+
if j.job_id in self.jobs:
|
|
74
|
+
del self.jobs[j.job_id]
|
|
75
|
+
to_delete.append(j.job_id)
|
|
76
|
+
|
|
77
|
+
return len(to_delete)
|
|
78
|
+
|
|
79
|
+
def cleanup_expired_jobs(self, now: Optional[float] = None) -> int:
|
|
80
|
+
"""Public thread-safe cleanup method."""
|
|
81
|
+
with self._lock:
|
|
82
|
+
return self._cleanup_expired_unlocked(now=now)
|
|
83
|
+
|
|
84
|
+
def get_job(self, job_id: str) -> Optional[IngestionJob]:
|
|
85
|
+
with self._lock:
|
|
86
|
+
self._cleanup_expired_unlocked()
|
|
87
|
+
job = self.jobs.get(job_id)
|
|
88
|
+
if job and job.status == "processing":
|
|
89
|
+
job.elapsed_s = time.time() - job.created_at
|
|
90
|
+
return job
|
|
91
|
+
|
|
92
|
+
def list_jobs(self, tenant_id: Optional[str] = None) -> Dict[str, IngestionJob]:
|
|
93
|
+
with self._lock:
|
|
94
|
+
self._cleanup_expired_unlocked()
|
|
95
|
+
if tenant_id:
|
|
96
|
+
return {k: v for k, v in self.jobs.items() if v.tenant_id == tenant_id}
|
|
97
|
+
return dict(self.jobs)
|
|
98
|
+
|
|
99
|
+
def submit_ingest(
|
|
100
|
+
self,
|
|
101
|
+
file_path: str,
|
|
102
|
+
tenant_id: str,
|
|
103
|
+
cache_engine: CacheEngine,
|
|
104
|
+
schema: Optional[MetadataSchema] = None,
|
|
105
|
+
force_async: Optional[bool] = None,
|
|
106
|
+
) -> IngestionJob:
|
|
107
|
+
if not os.path.exists(file_path):
|
|
108
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
109
|
+
|
|
110
|
+
file_size = os.path.getsize(file_path)
|
|
111
|
+
job_id = str(uuid.uuid4())
|
|
112
|
+
should_async = force_async if force_async is not None else (file_size >= self.async_threshold_bytes)
|
|
113
|
+
|
|
114
|
+
job = IngestionJob(
|
|
115
|
+
job_id=job_id,
|
|
116
|
+
tenant_id=tenant_id,
|
|
117
|
+
file_path=file_path,
|
|
118
|
+
file_size_bytes=file_size,
|
|
119
|
+
status="processing",
|
|
120
|
+
is_async=should_async,
|
|
121
|
+
created_at=time.time(),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
with self._lock:
|
|
125
|
+
self.jobs[job_id] = job
|
|
126
|
+
|
|
127
|
+
if should_async:
|
|
128
|
+
# Dispatch background worker thread
|
|
129
|
+
worker = threading.Thread(
|
|
130
|
+
target=self._run_ingest,
|
|
131
|
+
args=(job_id, file_path, tenant_id, cache_engine, schema),
|
|
132
|
+
daemon=True,
|
|
133
|
+
)
|
|
134
|
+
worker.start()
|
|
135
|
+
return job
|
|
136
|
+
else:
|
|
137
|
+
# Run synchronously in an isolated thread to protect caller's event loop
|
|
138
|
+
worker = threading.Thread(
|
|
139
|
+
target=self._run_ingest,
|
|
140
|
+
args=(job_id, file_path, tenant_id, cache_engine, schema),
|
|
141
|
+
daemon=True,
|
|
142
|
+
)
|
|
143
|
+
worker.start()
|
|
144
|
+
worker.join()
|
|
145
|
+
with self._lock:
|
|
146
|
+
return self.jobs[job_id]
|
|
147
|
+
|
|
148
|
+
def _run_ingest(
|
|
149
|
+
self,
|
|
150
|
+
job_id: str,
|
|
151
|
+
file_path: str,
|
|
152
|
+
tenant_id: str,
|
|
153
|
+
cache_engine: CacheEngine,
|
|
154
|
+
schema: Optional[MetadataSchema] = None,
|
|
155
|
+
):
|
|
156
|
+
start_t = time.time()
|
|
157
|
+
try:
|
|
158
|
+
loader = AutoLoader(schema=schema)
|
|
159
|
+
chunks = loader.load(file_path)
|
|
160
|
+
|
|
161
|
+
with self._lock:
|
|
162
|
+
job = self.jobs[job_id]
|
|
163
|
+
job.chunks_total = len(chunks)
|
|
164
|
+
|
|
165
|
+
loop = asyncio.new_event_loop()
|
|
166
|
+
asyncio.set_event_loop(loop)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
for idx, chunk in enumerate(chunks):
|
|
170
|
+
loader_kind = getattr(chunk, "loader_type", None) or (chunk.metadata.get("loader") if isinstance(chunk.metadata, dict) else "text")
|
|
171
|
+
dummy_resp = {
|
|
172
|
+
"content": chunk.text[:200],
|
|
173
|
+
"source": chunk.source_file,
|
|
174
|
+
"section": chunk.page_or_section,
|
|
175
|
+
"loader_type": loader_kind,
|
|
176
|
+
"extraction_method": chunk.metadata.get("extraction_method", "direct") if isinstance(chunk.metadata, dict) else "direct",
|
|
177
|
+
"metadata": chunk.metadata if isinstance(chunk.metadata, dict) else {},
|
|
178
|
+
}
|
|
179
|
+
cache_engine.set_l1(chunk.text[:100], dummy_resp, meta=chunk.metadata, tenant_id=tenant_id)
|
|
180
|
+
loop.run_until_complete(
|
|
181
|
+
cache_engine.async_write_l2(
|
|
182
|
+
chunk.text[:100], dummy_resp, meta=chunk.metadata, tenant_id=tenant_id
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
with self._lock:
|
|
186
|
+
job.chunks_processed = idx + 1
|
|
187
|
+
finally:
|
|
188
|
+
loop.close()
|
|
189
|
+
|
|
190
|
+
with self._lock:
|
|
191
|
+
job = self.jobs[job_id]
|
|
192
|
+
job.status = "completed"
|
|
193
|
+
job.completed_at = time.time()
|
|
194
|
+
job.elapsed_s = job.completed_at - start_t
|
|
195
|
+
logger.info(f"Ingestion job {job_id} completed: {job.chunks_processed} chunks in {job.elapsed_s:.2f}s")
|
|
196
|
+
|
|
197
|
+
except Exception as e:
|
|
198
|
+
with self._lock:
|
|
199
|
+
job = self.jobs[job_id]
|
|
200
|
+
job.status = "failed"
|
|
201
|
+
job.completed_at = time.time()
|
|
202
|
+
job.elapsed_s = job.completed_at - start_t
|
|
203
|
+
job.error = str(e)
|
|
204
|
+
logger.error(f"Ingestion job {job_id} failed: {e}", exc_info=True)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
job_manager = IngestionJobManager()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List, Any, Optional
|
|
3
|
+
|
|
4
|
+
class BaseEmbedder(ABC):
|
|
5
|
+
@abstractmethod
|
|
6
|
+
def embed(self, text: str) -> List[float]:
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class BaseVectorStore(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
async def insert(self, collection: str, id: int, vector: Any, payload: dict):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def search(self, collection: str, vector: Any, query_filter: Any, limit: int, score_threshold: float, using: Optional[str] = None, **kwargs: Any) -> List[Any]:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
async def delete(self, collection: str, id: int):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def collection_exists(self, collection: str) -> bool:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def create_collection(self, collection: str, config: Any):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
def delete_collection(self, collection: str):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
async def delete_matching(self, collection: str, filter_dict: Optional[dict] = None) -> int:
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
class BaseExactStore(ABC):
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def get(self, key: str) -> Optional[bytes]:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def set(self, key: str, value: bytes, ex: Optional[int] = None, nx: bool = False):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def delete_prefix(self, prefix: str) -> int:
|
|
46
|
+
return 0
|
|
47
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from fastembed import TextEmbedding
|
|
2
|
+
from typing import List
|
|
3
|
+
from ..base import BaseEmbedder
|
|
4
|
+
|
|
5
|
+
class FastEmbedder(BaseEmbedder):
|
|
6
|
+
def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5"):
|
|
7
|
+
self.model = TextEmbedding(model_name=model_name)
|
|
8
|
+
|
|
9
|
+
def embed(self, text: str) -> List[float]:
|
|
10
|
+
return list(self.model.embed([text]))[0].tolist()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import redis
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..base import BaseExactStore
|
|
4
|
+
|
|
5
|
+
class RedisStore(BaseExactStore):
|
|
6
|
+
def __init__(self, host: str = "localhost", port: int = 6379, password: str = None):
|
|
7
|
+
self.r = redis.Redis(host=host, port=port, password=password)
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def client(self):
|
|
11
|
+
return self.r
|
|
12
|
+
|
|
13
|
+
def get(self, key: str) -> Optional[bytes]:
|
|
14
|
+
return self.r.get(key)
|
|
15
|
+
|
|
16
|
+
def set(self, key: str, value: bytes, ex: Optional[int] = None, nx: bool = False) -> bool:
|
|
17
|
+
# nx=True only writes when the key is absent (conditional write).
|
|
18
|
+
result = self.r.set(key, value, ex=ex, nx=nx)
|
|
19
|
+
return result is not None
|
|
20
|
+
|
|
21
|
+
def delete(self, key: str) -> bool:
|
|
22
|
+
return bool(self.r.delete(key))
|
|
23
|
+
|
|
24
|
+
def delete_prefix(self, prefix: str) -> int:
|
|
25
|
+
cursor = 0
|
|
26
|
+
deleted = 0
|
|
27
|
+
match_pattern = f"{prefix}*"
|
|
28
|
+
while True:
|
|
29
|
+
cursor, keys = self.r.scan(cursor=cursor, match=match_pattern, count=100) # type: ignore[misc]
|
|
30
|
+
if keys:
|
|
31
|
+
deleted += self.r.delete(*keys) # type: ignore[operator]
|
|
32
|
+
if cursor == 0:
|
|
33
|
+
break
|
|
34
|
+
return deleted
|
|
35
|
+
|
|
36
|
+
def xadd(self, stream: str, fields: dict) -> str:
|
|
37
|
+
res = self.r.xadd(stream, fields)
|
|
38
|
+
return res.decode() if isinstance(res, bytes) else str(res)
|
|
39
|
+
|
|
40
|
+
def xread(self, streams: dict, count: Optional[int] = None, block: Optional[int] = None):
|
|
41
|
+
return self.r.xread(streams, count=count, block=block)
|
|
42
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..base import BaseExactStore
|
|
4
|
+
|
|
5
|
+
class SQLiteStore(BaseExactStore):
|
|
6
|
+
def __init__(self, db_path: str = "cache.db"):
|
|
7
|
+
self.db_path = db_path
|
|
8
|
+
self._init_db()
|
|
9
|
+
|
|
10
|
+
def _init_db(self):
|
|
11
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
12
|
+
conn.execute('''CREATE TABLE IF NOT EXISTS cache
|
|
13
|
+
(key TEXT PRIMARY KEY, value BLOB)''')
|
|
14
|
+
|
|
15
|
+
def get(self, key: str) -> Optional[bytes]:
|
|
16
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
17
|
+
cursor = conn.cursor()
|
|
18
|
+
cursor.execute("SELECT value FROM cache WHERE key=?", (key,))
|
|
19
|
+
row = cursor.fetchone()
|
|
20
|
+
if row:
|
|
21
|
+
return row[0]
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def set(self, key: str, value: bytes, ex: Optional[int] = None, nx: bool = False) -> bool:
|
|
25
|
+
# Note: SQLite store doesn't support TTL out of the box in this simple
|
|
26
|
+
# implementation. nx=True performs a conditional (insert-if-absent) write.
|
|
27
|
+
verb = "INSERT OR IGNORE" if nx else "INSERT OR REPLACE"
|
|
28
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
29
|
+
cur = conn.execute(f"{verb} INTO cache (key, value) VALUES (?, ?)", (key, value))
|
|
30
|
+
if nx:
|
|
31
|
+
return cur.rowcount > 0
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
def delete(self, key: str) -> bool:
|
|
35
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
36
|
+
cur = conn.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
37
|
+
return cur.rowcount > 0
|
|
38
|
+
|
|
39
|
+
def delete_prefix(self, prefix: str) -> int:
|
|
40
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
41
|
+
cur = conn.execute("DELETE FROM cache WHERE key LIKE ?", (f"{prefix}%",))
|
|
42
|
+
return cur.rowcount
|
|
43
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import lancedb
|
|
4
|
+
from typing import Any, List, Optional
|
|
5
|
+
from ..base import BaseVectorStore
|
|
6
|
+
|
|
7
|
+
# Metadata keys/filter field names are identifiers, never expressions.
|
|
8
|
+
_IDENT_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sql_str(value: Any) -> str:
|
|
12
|
+
"""Render a value as a single-quoted SQL string literal, escaping quotes."""
|
|
13
|
+
return "'" + str(value).replace("'", "''") + "'"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LanceDBStore(BaseVectorStore):
|
|
17
|
+
def __init__(self, uri: str = "./lancedb"):
|
|
18
|
+
self.db = lancedb.connect(uri)
|
|
19
|
+
|
|
20
|
+
def _table_names(self) -> List[str]:
|
|
21
|
+
if hasattr(self.db, "list_tables"):
|
|
22
|
+
res = self.db.list_tables()
|
|
23
|
+
if hasattr(res, "tables"):
|
|
24
|
+
return list(res.tables)
|
|
25
|
+
try:
|
|
26
|
+
return list(self.db.table_names())
|
|
27
|
+
except Exception:
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
async def insert(self, collection: str, id: int, vector: Any, payload: dict):
|
|
31
|
+
if isinstance(vector, dict):
|
|
32
|
+
row = {"id": id}
|
|
33
|
+
for v_k, v_v in vector.items():
|
|
34
|
+
row[f"vector_{v_k}"] = v_v
|
|
35
|
+
row["vector"] = vector.get("query") or list(vector.values())[0]
|
|
36
|
+
else:
|
|
37
|
+
row = {"id": id, "vector": vector}
|
|
38
|
+
|
|
39
|
+
for k, v in payload.items():
|
|
40
|
+
# Structured values are stored as JSON text (LanceDB rows are flat),
|
|
41
|
+
# and decoded again on read. Never rely on Python repr().
|
|
42
|
+
if isinstance(v, (dict, list)):
|
|
43
|
+
row[k] = json.dumps(v, default=str)
|
|
44
|
+
else:
|
|
45
|
+
row[k] = v
|
|
46
|
+
|
|
47
|
+
if collection not in self._table_names():
|
|
48
|
+
self.db.create_table(collection, data=[row])
|
|
49
|
+
else:
|
|
50
|
+
table = self.db.open_table(collection)
|
|
51
|
+
table.add([row])
|
|
52
|
+
|
|
53
|
+
async def search(
|
|
54
|
+
self,
|
|
55
|
+
collection: str,
|
|
56
|
+
vector: Any,
|
|
57
|
+
query_filter: Any,
|
|
58
|
+
limit: int,
|
|
59
|
+
score_threshold: float,
|
|
60
|
+
using: Optional[str] = None,
|
|
61
|
+
**kwargs: Any,
|
|
62
|
+
) -> List[Any]:
|
|
63
|
+
if collection not in self._table_names():
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
table = self.db.open_table(collection)
|
|
67
|
+
schema_names = table.schema.names if hasattr(table, "schema") else []
|
|
68
|
+
vec_col = f"vector_{using}" if using and f"vector_{using}" in schema_names else "vector"
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
res = table.search(vector, vector_column_name=vec_col).metric("cosine").limit(limit).to_list()
|
|
72
|
+
except Exception:
|
|
73
|
+
try:
|
|
74
|
+
res = table.search(vector).metric("cosine").limit(limit).to_list()
|
|
75
|
+
except Exception:
|
|
76
|
+
res = table.search(vector).limit(limit).to_list()
|
|
77
|
+
|
|
78
|
+
filter_tenant_id = kwargs.get("tenant_id")
|
|
79
|
+
if not filter_tenant_id and query_filter is not None and hasattr(query_filter, "must"):
|
|
80
|
+
for cond in (query_filter.must or []):
|
|
81
|
+
if getattr(cond, "key", None) == "tenant_id" and hasattr(cond, "match") and hasattr(cond.match, "value"):
|
|
82
|
+
filter_tenant_id = cond.match.value
|
|
83
|
+
|
|
84
|
+
class Hit:
|
|
85
|
+
def __init__(self, payload, id):
|
|
86
|
+
self.payload = payload
|
|
87
|
+
self.id = id
|
|
88
|
+
|
|
89
|
+
hits = []
|
|
90
|
+
for r in res:
|
|
91
|
+
hid = r.pop("id", None)
|
|
92
|
+
r.pop("vector", None)
|
|
93
|
+
r.pop("vector_query", None)
|
|
94
|
+
r.pop("vector_context", None)
|
|
95
|
+
dist = r.pop("_distance", 0.0)
|
|
96
|
+
similarity = 1.0 - dist
|
|
97
|
+
if score_threshold > 0 and similarity < score_threshold:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
# Reconstruct dict payload
|
|
101
|
+
payload_dict = {}
|
|
102
|
+
for k, v in r.items():
|
|
103
|
+
if k in ("meta", "answer") and isinstance(v, str):
|
|
104
|
+
try:
|
|
105
|
+
payload_dict[k] = json.loads(v)
|
|
106
|
+
except Exception:
|
|
107
|
+
payload_dict[k] = v
|
|
108
|
+
else:
|
|
109
|
+
payload_dict[k] = v
|
|
110
|
+
|
|
111
|
+
if filter_tenant_id and payload_dict.get("tenant_id") != filter_tenant_id:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
hits.append(Hit(payload=payload_dict, id=hid))
|
|
115
|
+
return hits
|
|
116
|
+
|
|
117
|
+
async def delete(self, collection: str, id: int):
|
|
118
|
+
if collection in self._table_names():
|
|
119
|
+
table = self.db.open_table(collection)
|
|
120
|
+
table.delete(f"id = {id}")
|
|
121
|
+
|
|
122
|
+
def collection_exists(self, collection: str) -> bool:
|
|
123
|
+
return collection in self._table_names()
|
|
124
|
+
|
|
125
|
+
def create_collection(self, collection: str, config: Any):
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
def delete_collection(self, collection: str):
|
|
129
|
+
if collection in self._table_names():
|
|
130
|
+
self.db.drop_table(collection)
|
|
131
|
+
|
|
132
|
+
async def delete_matching(self, collection: str, filter_dict: Optional[dict] = None) -> int:
|
|
133
|
+
if collection not in self._table_names():
|
|
134
|
+
return 0
|
|
135
|
+
if not filter_dict:
|
|
136
|
+
self.db.drop_table(collection)
|
|
137
|
+
return -1
|
|
138
|
+
table = self.db.open_table(collection)
|
|
139
|
+
clauses = []
|
|
140
|
+
for k, v in filter_dict.items():
|
|
141
|
+
# Reject keys that could break out of the identifier/JSON context.
|
|
142
|
+
if not isinstance(k, str) or not _IDENT_RE.match(k):
|
|
143
|
+
continue
|
|
144
|
+
if not isinstance(v, (str, int, float, bool)):
|
|
145
|
+
continue
|
|
146
|
+
if k == "tenant_id":
|
|
147
|
+
clauses.append(f"tenant_id = {_sql_str(v)}")
|
|
148
|
+
else:
|
|
149
|
+
# LanceDB stores meta as a JSON string; match the escaped literal.
|
|
150
|
+
pattern = '%"' + str(k) + '": "' + str(v) + '"%'
|
|
151
|
+
clauses.append("meta LIKE " + _sql_str(pattern))
|
|
152
|
+
if not clauses:
|
|
153
|
+
return 0
|
|
154
|
+
where_clause = " AND ".join(clauses)
|
|
155
|
+
try:
|
|
156
|
+
table.delete(where_clause)
|
|
157
|
+
return len(clauses)
|
|
158
|
+
except Exception:
|
|
159
|
+
return 0
|
|
160
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import threading
|
|
3
|
+
from typing import List, Any, Optional
|
|
4
|
+
from qdrant_client import QdrantClient, AsyncQdrantClient
|
|
5
|
+
from qdrant_client.models import VectorParams, Distance
|
|
6
|
+
from ..base import BaseVectorStore
|
|
7
|
+
|
|
8
|
+
class QdrantStore(BaseVectorStore):
|
|
9
|
+
def __init__(self, host: str = "localhost", port: int = 6333):
|
|
10
|
+
self.host = host
|
|
11
|
+
self.port = port
|
|
12
|
+
self.qc = QdrantClient(host, port=port, check_compatibility=False)
|
|
13
|
+
self._local = threading.local()
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def aqc(self) -> AsyncQdrantClient:
|
|
17
|
+
if not hasattr(self._local, "client"):
|
|
18
|
+
self._local.client = AsyncQdrantClient(self.host, port=self.port, check_compatibility=False)
|
|
19
|
+
return self._local.client
|
|
20
|
+
|
|
21
|
+
async def insert(self, collection: str, id: int, vector: Any, payload: dict):
|
|
22
|
+
await self.aqc.upsert(
|
|
23
|
+
collection_name=collection,
|
|
24
|
+
points=[{
|
|
25
|
+
"id": id,
|
|
26
|
+
"vector": vector,
|
|
27
|
+
"payload": payload
|
|
28
|
+
}]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
async def search(self, collection: str, vector: Any, query_filter: Any, limit: int, score_threshold: float, using: Optional[str] = None, **kwargs: Any) -> List[Any]:
|
|
32
|
+
# Use the async client so the event loop is never blocked by network I/O.
|
|
33
|
+
if using:
|
|
34
|
+
res = await self.aqc.query_points(
|
|
35
|
+
collection_name=collection,
|
|
36
|
+
query=vector,
|
|
37
|
+
using=using,
|
|
38
|
+
query_filter=query_filter,
|
|
39
|
+
limit=limit,
|
|
40
|
+
score_threshold=score_threshold,
|
|
41
|
+
)
|
|
42
|
+
else:
|
|
43
|
+
res = await self.aqc.query_points(
|
|
44
|
+
collection_name=collection,
|
|
45
|
+
query=vector,
|
|
46
|
+
query_filter=query_filter,
|
|
47
|
+
limit=limit,
|
|
48
|
+
score_threshold=score_threshold,
|
|
49
|
+
)
|
|
50
|
+
return res.points
|
|
51
|
+
|
|
52
|
+
async def delete(self, collection: str, id: int):
|
|
53
|
+
await self.aqc.delete(collection_name=collection, points_selector=[id])
|
|
54
|
+
|
|
55
|
+
def collection_exists(self, collection: str) -> bool:
|
|
56
|
+
return self.qc.collection_exists(collection)
|
|
57
|
+
|
|
58
|
+
def create_collection(self, collection: str, config: Any = None):
|
|
59
|
+
if config is None:
|
|
60
|
+
config = VectorParams(size=384, distance=Distance.COSINE)
|
|
61
|
+
elif isinstance(config, dict):
|
|
62
|
+
# parse custom dict to VectorParams
|
|
63
|
+
new_config = {}
|
|
64
|
+
for k, v in config.items():
|
|
65
|
+
if isinstance(v, dict):
|
|
66
|
+
new_config[k] = VectorParams(size=v["size"], distance=Distance.COSINE)
|
|
67
|
+
else:
|
|
68
|
+
new_config[k] = v
|
|
69
|
+
config = new_config
|
|
70
|
+
self.qc.create_collection(collection, vectors_config=config)
|
|
71
|
+
|
|
72
|
+
def delete_collection(self, collection: str):
|
|
73
|
+
if self.qc.collection_exists(collection):
|
|
74
|
+
self.qc.delete_collection(collection)
|
|
75
|
+
|
|
76
|
+
async def delete_matching(self, collection: str, filter_dict: Optional[dict] = None) -> int:
|
|
77
|
+
if not await asyncio.to_thread(self.qc.collection_exists, collection):
|
|
78
|
+
return 0
|
|
79
|
+
from qdrant_client.http import models
|
|
80
|
+
if not filter_dict:
|
|
81
|
+
await asyncio.to_thread(self.qc.delete_collection, collection)
|
|
82
|
+
return -1
|
|
83
|
+
conditions = []
|
|
84
|
+
for k, v in filter_dict.items():
|
|
85
|
+
field_key = f"meta.{k}" if (not k.startswith("meta.") and k != "tenant_id" and k != "doc_id") else k
|
|
86
|
+
conditions.append(models.FieldCondition(key=field_key, match=models.MatchValue(value=v)))
|
|
87
|
+
q_filter = models.Filter(must=conditions)
|
|
88
|
+
await self.aqc.delete(collection_name=collection, points_selector=models.FilterSelector(filter=q_filter))
|
|
89
|
+
return len(conditions)
|
|
90
|
+
|
ico_cache/client.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
class IcoCache:
|
|
4
|
+
def __init__(self, base_url="http://localhost:8000", semantic_threshold=0.92):
|
|
5
|
+
self.base_url = base_url
|
|
6
|
+
self.semantic_threshold = semantic_threshold
|
|
7
|
+
|
|
8
|
+
def resolve(self, query: str, context: str, callback):
|
|
9
|
+
resp = requests.post(f"{self.base_url}/resolve", json={"query": query, "context": context}).json()
|
|
10
|
+
if resp.get("source") != "MISS":
|
|
11
|
+
return resp.get("response")
|
|
12
|
+
|
|
13
|
+
# MISS
|
|
14
|
+
generated = callback()
|
|
15
|
+
|
|
16
|
+
requests.post(f"{self.base_url}/ingest", json={"query": query, "context": context, "response": generated})
|
|
17
|
+
return generated
|
|
18
|
+
|
|
19
|
+
def ico_cache(base_url="http://localhost:8000", semantic_threshold=0.92):
|
|
20
|
+
return IcoCache(base_url, semantic_threshold)
|