refactor: rename the application package to docsgpt

The backend import package is now docsgpt, the name it will carry on PyPI;
application was far too generic to install into anyone's site-packages.
git mv plus a mechanical rewrite of every import, dotted string and path
reference: 734 Python files, the compose files, Dockerfile, workflows, docs,
setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage
config, .gitignore. Behaviour is unchanged.

Kept for one release:
- A top-level application package whose meta-path finder resolves
  application.x.y to the already-imported docsgpt.x.y object, so old imports
  and entry points (celery -A application.app.celery,
  uvicorn application.asgi:asgi_app) keep working with a FutureWarning.
- Celery registers every application.* task name as an alias of its
  docsgpt.* task on start-up, so messages queued by the previous release still
  run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries
  the previous release wrote are left unread instead of firing twice.

The backend image builds from the repository root (docker build -f
docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore
allow-lists docsgpt/ and application/ and keeps caches, local data, .env
files, the sample index files and the Dockerfile out. Compose and the image
workflows point at the new context.
This commit is contained in:
Alex committed 2026-09-07 10:20:43 +01:00
1 parent 3e7f1f91b4
commit 574f96341e
984 files changed
+9847 -9682

No files matched your search

View File
Whitespace-only changes.
+455
View File
@@ -0,0 +1,455 @@
import logging
from abc import ABC, abstractmethod
from typing import Optional
import requests
from docsgpt.core.settings import settings
from docsgpt.vectorstore.embeddings_openai import OpenAIEmbeddings
from docsgpt.vectorstore.model_registry import (
dimension_for,
max_input_tokens_for,
resolve,
)
def _embeddings_name_is_explicit() -> bool:
"""True when ``EMBEDDINGS_NAME`` names a model somebody actually chose.
Not ``model_fields_set``: pydantic marks a field as set for any value that
reached it, including one read from ``.env``, and every setup script has
always written ``EMBEDDINGS_NAME`` unconditionally. An install carrying the
legacy name a script wrote years ago would read as a deliberate choice and
lend a remote server that model's context window.
"""
fields = getattr(type(settings), "model_fields", None)
if not isinstance(fields, dict):
return False
field = fields.get("EMBEDDINGS_NAME")
if field is None:
return False
return settings.EMBEDDINGS_NAME != field.default
class RemoteEmbeddings:
"""
Wrapper for remote embeddings API (OpenAI-compatible).
Used when EMBEDDINGS_BASE_URL is configured.
Sends requests to {base_url}/v1/embeddings in OpenAI format.
"""
def __init__(self, api_url: str, model_name: str, api_key: str = None):
self.api_url = api_url.rstrip("/")
self.model_name = model_name
self.headers = {"Content-Type": "application/json"}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
# Width comes from the registry. This used to be a hardcoded 768 that
# ``embed_query`` claimed to correct on first use -- but the correction
# was guarded by ``if self.dimension is None``, which the hardcode made
# unreachable, so a remote model of any other width silently produced a
# ``vector(768)`` column. ``None`` here means "unknown", and the probe
# below now genuinely runs.
self.dimension = dimension_for(model_name)
def _token_counter(self):
"""Counter matching the remote model's tokenizer, cached per process."""
from docsgpt.parser.tokenization import get_token_counter
return get_token_counter(self.model_name)
def _resolve_input_limit(self):
"""Token ceiling for a single embed input, or ``None`` for no limit.
``EMBEDDINGS_MAX_INPUT_TOKENS`` wins when set. Otherwise a registered
model contributes its own context window, so a request that the server
would reject -- or silently truncate -- is clipped here instead of
being sent and paid for.
That fallback needs the name to mean something. For a remote server it
is only a label forwarded as the ``model`` field, so the settings
default must not lend the server mpnet's 384-token window: a name
nobody chose describes nothing, and clipping on it would silently
discard most of every chunk.
"""
configured = settings.EMBEDDINGS_MAX_INPUT_TOKENS
if configured and configured > 0:
return configured
if not _embeddings_name_is_explicit():
return None
model_limit = max_input_tokens_for(self.model_name)
return model_limit if model_limit and model_limit > 0 else None
def _truncate_inputs(self, inputs):
"""Clip each input to the resolved token limit.
The remote server (e.g. llama.cpp) hard-rejects any single input
larger than its physical batch size with a 500, so oversized inputs are
truncated before the request and the overflow is dropped (lossy by
design).
Counting uses the embedding model's own tokenizer where it is known, so
the limit and the count are in the same unit. When it is not -- an
unregistered model, or no tokenizer available -- this falls back to
tiktoken, and the limit should then carry headroom to absorb the skew
between the two tokenizers.
Args:
inputs: A single string or a list of strings to embed.
Returns:
The inputs with each string clipped to the token limit, or the
inputs unchanged when no limit applies.
"""
limit = self._resolve_input_limit()
if not limit:
return inputs
counter = self._token_counter()
def clip(text):
if not isinstance(text, str):
return text
count = counter.count(text)
if count <= limit:
return text
logging.warning(
"Truncating remote embeddings input from %d to %d tokens (%d dropped)",
count,
limit,
count - limit,
)
pieces = counter.split(text, limit)
return pieces[0] if pieces else text
if isinstance(inputs, list):
return [clip(text) for text in inputs]
return clip(inputs)
def _embed(self, inputs):
"""Send embedding request to remote API in OpenAI-compatible format."""
inputs = self._truncate_inputs(inputs)
payload = {"input": inputs}
if self.model_name:
payload["model"] = self.model_name
url = f"{self.api_url}/v1/embeddings"
response = requests.post(url, headers=self.headers, json=payload, timeout=180)
response.raise_for_status()
result = response.json()
# Handle OpenAI-compatible response format
if isinstance(result, dict):
if "error" in result:
raise ValueError(f"Remote embeddings API error: {result['error']}")
if "data" in result:
# Sort by index to ensure correct order
data = sorted(result["data"], key=lambda x: x.get("index", 0))
return [item["embedding"] for item in data]
raise ValueError(
f"Unexpected response format from remote embeddings API: {result}"
)
else:
raise ValueError(
f"Unexpected response format from remote embeddings API: {result}"
)
def embed_query(self, query: str):
"""Embed a single query string."""
embeddings_list = self._embed(query)
if (
isinstance(embeddings_list, list)
and len(embeddings_list) == 1
and isinstance(embeddings_list[0], list)
):
if self.dimension is None:
self.dimension = len(embeddings_list[0])
return embeddings_list[0]
raise ValueError(
f"Unexpected result structure after embedding query: {embeddings_list}"
)
def embed_documents(self, documents: list):
"""Embed a list of documents."""
if not documents:
return []
embeddings_list = self._embed(documents)
if self.dimension is None and embeddings_list:
self.dimension = len(embeddings_list[0])
return embeddings_list
def __call__(self, text):
if isinstance(text, str):
return self.embed_query(text)
elif isinstance(text, list):
return self.embed_documents(text)
else:
raise ValueError("Input must be a string or a list of strings")
def _get_embeddings_wrapper():
"""Lazy import of EmbeddingsWrapper, so a remote setup never loads ONNX."""
from docsgpt.vectorstore.embeddings_local import EmbeddingsWrapper
return EmbeddingsWrapper
class EmbeddingsSingleton:
_instances = {}
@staticmethod
def _remote_instance(embeddings_name, embeddings_key=None):
"""Return a cached ``RemoteEmbeddings`` for the configured remote API.
Centralizes the ``EMBEDDINGS_BASE_URL`` dispatch so every caller —
including code that calls :meth:`get_instance` directly (GraphRAG,
semantic chunking) rather than via
:meth:`BaseVectorStore._get_embeddings` — routes to the remote
embeddings server instead of attempting a local model download.
Args:
embeddings_name: Model name forwarded to the remote API.
embeddings_key: Optional API key; falls back to
``settings.EMBEDDINGS_KEY`` when not provided.
Returns:
RemoteEmbeddings: Shared instance keyed by base URL and model name.
"""
api_key = embeddings_key if embeddings_key is not None else settings.EMBEDDINGS_KEY
cache_key = f"remote_{settings.EMBEDDINGS_BASE_URL}_{embeddings_name}"
if cache_key not in EmbeddingsSingleton._instances:
EmbeddingsSingleton._instances[cache_key] = RemoteEmbeddings(
api_url=settings.EMBEDDINGS_BASE_URL,
model_name=embeddings_name,
api_key=api_key,
)
return EmbeddingsSingleton._instances[cache_key]
@staticmethod
def get_instance(embeddings_name, *args, **kwargs):
if settings.EMBEDDINGS_BASE_URL:
return EmbeddingsSingleton._remote_instance(embeddings_name)
if embeddings_name not in EmbeddingsSingleton._instances:
EmbeddingsSingleton._instances[embeddings_name] = (
EmbeddingsSingleton._create_instance(embeddings_name, *args, **kwargs)
)
return EmbeddingsSingleton._instances[embeddings_name]
@staticmethod
def _create_instance(embeddings_name, *args, **kwargs):
"""Build the runner for ``embeddings_name``, per the model registry.
The registry replaced a hand-maintained factory dict whose entries
existed only to rewrite a configured name into a repository id. That
rewrite is now a registry field, so an unknown name needs no entry
here: it is passed through as a Hugging Face repository.
"""
spec = resolve(embeddings_name)
if spec is not None and spec.provider == "openai":
return OpenAIEmbeddings(*args, **kwargs)
EmbeddingsWrapper = _get_embeddings_wrapper()
if spec is not None and (args or kwargs):
logging.debug(
"Dropping %d positional and %d keyword argument(s) for registered "
"embeddings model %s: the registry supplies its configuration.",
len(args),
len(kwargs),
embeddings_name,
)
return EmbeddingsWrapper(embeddings_name)
return EmbeddingsWrapper(embeddings_name, *args, **kwargs)
def _azure_configured() -> bool:
"""True when the Azure OpenAI deployment settings are all present."""
return bool(
settings.OPENAI_API_BASE
and settings.OPENAI_API_VERSION
and settings.AZURE_DEPLOYMENT_NAME
)
def _delegation_enabled() -> bool:
"""True when this process should embed on the worker rather than locally.
Compared against ``True`` rather than coerced: tests patch ``settings``
with a ``MagicMock``, whose every attribute is a truthy object, and
``bool()`` on that would silently route them through the broker.
"""
return getattr(settings, "EMBEDDINGS_DELEGATE_TO_WORKER", False) is True
def get_embeddings(
embeddings_name: Optional[str] = None, embeddings_key: Optional[str] = None
):
"""Resolve the configured embeddings instance. The single entry point.
Callers that reach for :meth:`EmbeddingsSingleton.get_instance` directly
skip the remote dispatch and the OpenAI/Azure key handling. Route every
caller through here.
With ``EMBEDDINGS_DELEGATE_TO_WORKER`` this returns a client that runs the
model on the Celery worker, so an API process never loads one. The client
embeds locally when it finds itself inside a worker task, so the worker is
unaffected.
Args:
embeddings_name: Model name; defaults to ``settings.EMBEDDINGS_NAME``.
embeddings_key: API key; defaults to ``settings.EMBEDDINGS_KEY``.
Returns:
The shared embeddings instance for the resolved model.
"""
embeddings_name = embeddings_name or settings.EMBEDDINGS_NAME
if not settings.EMBEDDINGS_BASE_URL and _delegation_enabled():
cache_key = f"delegated_{embeddings_name}"
if cache_key not in EmbeddingsSingleton._instances:
from docsgpt.vectorstore.embeddings_delegated import DelegatedEmbeddings
EmbeddingsSingleton._instances[cache_key] = DelegatedEmbeddings(
embeddings_name, embeddings_key
)
return EmbeddingsSingleton._instances[cache_key]
return build_local_embeddings(embeddings_name, embeddings_key)
def build_local_embeddings(
embeddings_name: Optional[str] = None, embeddings_key: Optional[str] = None
):
"""Resolve the embeddings instance that runs in *this* process.
Bypasses worker delegation, so it is what the worker's embed task and the
boot hook use. Everything else should call :func:`get_embeddings`.
Args:
embeddings_name: Model name; defaults to ``settings.EMBEDDINGS_NAME``.
embeddings_key: API key; defaults to ``settings.EMBEDDINGS_KEY``.
Returns:
The shared in-process embeddings instance for the resolved model.
"""
embeddings_name = embeddings_name or settings.EMBEDDINGS_NAME
embeddings_key = (
embeddings_key if embeddings_key is not None else settings.EMBEDDINGS_KEY
)
# Check for remote embeddings first
if settings.EMBEDDINGS_BASE_URL:
logging.info(
f"Using remote embeddings API at: {settings.EMBEDDINGS_BASE_URL}"
)
return EmbeddingsSingleton._remote_instance(embeddings_name, embeddings_key)
# Match through the registry, not on the canonical string: the bare
# ``text-embedding-ada-002`` alias resolves here too, and skipping this
# branch would drop the Azure deployment name and the API key.
spec = resolve(embeddings_name)
if spec is not None and spec.provider == "openai":
if _azure_configured():
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name, model=settings.AZURE_EMBEDDINGS_DEPLOYMENT_NAME
)
else:
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name, openai_api_key=embeddings_key
)
else:
# No per-model branching: the registry resolves names and FastEmbed
# caches artifacts under EMBEDDINGS_CACHE_DIR, which is where the
# image warms them at build time.
embedding_instance = EmbeddingsSingleton.get_instance(embeddings_name)
return embedding_instance
class BaseVectorStore(ABC):
def __init__(self):
pass
@abstractmethod
def search(self, *args, **kwargs):
"""Search for similar documents/chunks in the vectorstore.
Implementations accept an optional ``query_vector`` kwarg: the query
already embedded by the caller, so a multi-source retrieval embeds once
instead of once per store. A store that cannot use it must still swallow
the kwarg (every signature here ends in ``**kwargs``) and embed the
question itself.
"""
pass
def keyword_search(self, question, k=10):
"""Keyword/full-text search.
Default returns no results so hybrid retrieval degrades to vector-only
on stores without keyword support. Override in stores that support it.
"""
return []
# What ``search_with_scores`` reports, so a caller can label the number.
# ``cosine_similarity`` is higher-is-better in [0, 1]; ``l2_distance`` is
# lower-is-better and unbounded. None = this store reports no score.
score_kind = None
def search_with_scores(self, question, k=2, *args, **kwargs):
"""Search, pairing each hit with its raw relevance score.
Default pairs every hit from :meth:`search` with ``None`` so stores that
surface no score still satisfy the contract. Stores that already compute
one override this and set :attr:`score_kind`.
Returns:
A list of ``(Document, score | None)`` in the same rank order
:meth:`search` would return.
"""
return [
(doc, None) for doc in self.search(question, k, *args, **kwargs) or []
]
@abstractmethod
def add_texts(self, texts, metadatas=None, *args, **kwargs):
"""Add texts with their embeddings to the vectorstore"""
pass
def delete_index(self, *args, **kwargs):
"""Delete the entire index/collection"""
pass
def save_local(self, *args, **kwargs):
"""Save vectorstore to local storage"""
pass
def get_chunks(self, *args, **kwargs):
"""Get all chunks from the vectorstore"""
pass
def add_chunk(self, text, metadata=None, *args, **kwargs):
"""Add a single chunk to the vectorstore"""
pass
def delete_chunk(self, chunk_id, *args, **kwargs):
"""Delete a specific chunk from the vectorstore"""
pass
def delete_chunks_by_source_path(self, path) -> int:
"""Delete every chunk whose ``metadata.source`` equals ``path``.
Default implementation iterates ``get_chunks()`` and deletes the
matches via ``delete_chunk()`` — works for any store. Override with a
single targeted statement where the store supports it. Returns the
number of chunks deleted.
"""
deleted = 0
for chunk in self.get_chunks() or []:
if (chunk.get("metadata") or {}).get("source") == path:
if self.delete_chunk(chunk.get("doc_id")):
deleted += 1
return deleted
def is_azure_configured(self):
"""Kept for compatibility; delegates to the module-level check."""
return _azure_configured()
def _get_embeddings(self, embeddings_name, embeddings_key=None):
"""Resolve embeddings for this store; see :func:`get_embeddings`."""
return get_embeddings(embeddings_name, embeddings_key)
+8
View File
@@ -0,0 +1,8 @@
class Document(str):
"""Class for storing a piece of text and associated metadata."""
def __new__(cls, page_content: str, metadata: dict):
instance = super().__new__(cls, page_content)
instance.page_content = page_content
instance.metadata = metadata
return instance
+225
View File
@@ -0,0 +1,225 @@
from docsgpt.vectorstore.base import BaseVectorStore
from docsgpt.core.settings import settings
from docsgpt.vectorstore.document_class import Document
class ElasticsearchStore(BaseVectorStore):
_es_connection = None # Class attribute to hold the Elasticsearch connection
def __init__(self, source_id, embeddings_key, index_name=settings.ELASTIC_INDEX):
super().__init__()
self.source_id = source_id.replace("docsgpt/indexes/", "").rstrip("/")
self.embeddings_key = embeddings_key
self.index_name = index_name
if ElasticsearchStore._es_connection is None:
connection_params = {}
if settings.ELASTIC_URL:
connection_params["hosts"] = [settings.ELASTIC_URL]
connection_params["http_auth"] = (settings.ELASTIC_USERNAME, settings.ELASTIC_PASSWORD)
elif settings.ELASTIC_CLOUD_ID:
connection_params["cloud_id"] = settings.ELASTIC_CLOUD_ID
connection_params["basic_auth"] = (settings.ELASTIC_USERNAME, settings.ELASTIC_PASSWORD)
else:
raise ValueError("Please provide either elasticsearch_url or cloud_id.")
import elasticsearch
ElasticsearchStore._es_connection = elasticsearch.Elasticsearch(**connection_params)
self.docsearch = ElasticsearchStore._es_connection
def connect_to_elasticsearch(
*,
es_url = None,
cloud_id = None,
api_key = None,
username = None,
password = None,
):
try:
import elasticsearch
except ImportError:
raise ImportError(
"Could not import elasticsearch python package. "
"Please install it with `pip install elasticsearch`."
)
if es_url and cloud_id:
raise ValueError(
"Both es_url and cloud_id are defined. Please provide only one."
)
connection_params = {}
if es_url:
connection_params["hosts"] = [es_url]
elif cloud_id:
connection_params["cloud_id"] = cloud_id
else:
raise ValueError("Please provide either elasticsearch_url or cloud_id.")
if api_key:
connection_params["api_key"] = api_key
elif username and password:
connection_params["basic_auth"] = (username, password)
es_client = elasticsearch.Elasticsearch(
**connection_params,
)
try:
es_client.info()
except Exception as e:
raise e
return es_client
def search(
self,
question,
k=2,
index_name=settings.ELASTIC_INDEX,
*args,
query_vector=None,
**kwargs,
):
"""Search by kNN + full text, fused with RRF.
Args:
query_vector: Precomputed embedding of ``question``; when given the
store skips embedding the query itself.
"""
vector = query_vector
if vector is None:
embeddings = self._get_embeddings(
settings.EMBEDDINGS_NAME, self.embeddings_key
)
vector = embeddings.embed_query(question)
knn = {
"filter": [{"match": {"metadata.source_id.keyword": self.source_id}}],
"field": "vector",
"k": k,
"num_candidates": 100,
"query_vector": vector,
}
full_query = {
"knn": knn,
"query": {
"bool": {
"must": [
{
"match": {
"text": {
"query": question,
}
}
}
],
"filter": [{"match": {"metadata.source_id.keyword": self.source_id}}],
}
},
"rank": {"rrf": {}},
}
resp = self.docsearch.search(index=self.index_name, query=full_query['query'], size=k, knn=full_query['knn'])
# create Documents objects from the results page_content ['_source']['text'], metadata ['_source']['metadata']
doc_list = []
for hit in resp['hits']['hits']:
doc_list.append(Document(page_content = hit['_source']['text'], metadata = hit['_source']['metadata']))
return doc_list
def _create_index_if_not_exists(
self, index_name, dims_length
):
if self._es_connection.indices.exists(index=index_name):
print(f"Index {index_name} already exists.")
else:
indexSettings = self.index(
dims_length=dims_length,
)
self._es_connection.indices.create(index=index_name, **indexSettings)
def index(
self,
dims_length,
):
return {
"mappings": {
"properties": {
"vector": {
"type": "dense_vector",
"dims": dims_length,
"index": True,
"similarity": "cosine",
},
}
}
}
def add_texts(
self,
texts,
metadatas = None,
ids = None,
refresh_indices = True,
create_index_if_not_exists = True,
bulk_kwargs = None,
**kwargs,
):
bulk_kwargs = bulk_kwargs or {}
import uuid
embeddings = []
ids = ids or [str(uuid.uuid4()) for _ in texts]
requests = []
embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, self.embeddings_key)
vectors = embeddings.embed_documents(list(texts))
dims_length = len(vectors[0])
if create_index_if_not_exists:
self._create_index_if_not_exists(
index_name=self.index_name, dims_length=dims_length
)
for i, (text, vector) in enumerate(zip(texts, vectors)):
metadata = metadatas[i] if metadatas else {}
requests.append(
{
"_op_type": "index",
"_index": self.index_name,
"text": text,
"vector": vector,
"metadata": metadata,
"_id": ids[i],
}
)
if len(requests) > 0:
from elasticsearch.helpers import BulkIndexError, bulk
try:
success, failed = bulk(
self._es_connection,
requests,
stats_only=True,
refresh=refresh_indices,
**bulk_kwargs,
)
return ids
except BulkIndexError as e:
print(f"Error adding texts: {e}")
firstError = e.errors[0].get("index", {}).get("error", {})
print(f"First error reason: {firstError.get('reason')}")
raise e
else:
return []
def delete_index(self):
self._es_connection.delete_by_query(index=self.index_name, query={"match": {
"metadata.source_id.keyword": self.source_id}},)
+254
View File
@@ -0,0 +1,254 @@
"""Query embedding executed in the Celery worker instead of in the API.
The API embeds every query it serves, so it needs an embedder -- and a local
one costs roughly 800 MB of ONNX Runtime per process. That is the whole
footprint of an API container that otherwise holds no model.
This client keeps the interface (``embed_query``/``embed_documents``/
``dimension``) and moves only the computation: the text goes to the worker over
Celery and the vector comes back. The API pays a broker round trip per query
and no resident model.
Inside a worker there is nothing to delegate to -- dispatching would queue work
behind the task already running and wait on itself -- so a call made while a
task is executing runs locally, on a model this process loads once and caches.
``DOCUMENT_PARSE_QUEUE`` exists for the same reason on the parsing side.
Production deployments should point ``EMBEDDINGS_BASE_URL`` at a real embedding
service instead: that removes the model from *both* processes and costs a
network hop rather than a broker round trip.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any, List, Optional
from docsgpt.core.settings import settings
from docsgpt.vectorstore.model_registry import dimension_for
logger = logging.getLogger(__name__)
#: Dispatched by name so the API never imports the task module -- and through
#: it ``docsgpt.worker``, which pulls in the whole parsing stack.
EMBED_TASK = "docsgpt.vectorstore.embeddings_tasks.embed_texts"
#: How long after a failed dispatch to fail fast instead of waiting out another
#: full ``EMBEDDINGS_DELEGATE_TIMEOUT``. Short enough that a worker restart is
#: picked up within one query, long enough to collapse the retries inside a
#: single retrieval into one timeout rather than one per source.
_FAILURE_COOLDOWN = 30.0
#: How long a caller waits for the outcome of the dispatch already in flight
#: before giving up on its own. Only applies while the worker is unproven --
#: once one dispatch has succeeded, every caller goes straight to the broker.
#: Comfortably above a healthy round trip (~60 ms on a prefork worker) and far
#: below ``EMBEDDINGS_DELEGATE_TIMEOUT``, which is the point.
_PROBE_WAIT = 2.0
_NO_WORKER_HINT = (
"Start a worker consuming it, point EMBEDDINGS_BASE_URL at an embedding "
"service, or set EMBEDDINGS_DELEGATE_TO_WORKER=false to load the model in "
"this process instead."
)
def _forget(result) -> None:
"""Drop the task's stored vector from the result backend.
Nothing ever reads it back. The key is ``celery-task-meta-<uuid>``, minted
per dispatch rather than derived from the text, so a repeated query is a new
task and a new key -- the value is written once, read once by the ``get()``
already waiting on it, then dead. Left alone it occupies ~17 KB for
``result_expires`` (7 days), in the Redis the broker also runs on.
Also releases the backend's pub/sub subscription for the task, which
``get()`` alone does not.
Never raises: the vector is already in hand, and a backend that cannot
delete must not fail the search. On the timeout path the worker may still
store its result afterwards, leaving one orphaned key -- no worse than not
forgetting at all, and bounded by the same expiry.
"""
try:
result.forget()
except Exception as exc: # noqa: BLE001 — cleanup must never fail a query
logger.debug("Could not forget the embed task result: %s", exc)
def _in_worker() -> bool:
"""True when a Celery task is executing in this process."""
try:
from docsgpt.celery_init import celery
return celery.current_worker_task is not None
except Exception:
return False
class DelegatedEmbeddings:
"""Embeds by dispatching to the Celery worker, or locally inside one."""
def __init__(self, embeddings_name: str, embeddings_key: Optional[str] = None) -> None:
self.embeddings_name = embeddings_name
self.embeddings_key = embeddings_key
self._local: Any = None
self._dimension: Optional[int] = dimension_for(embeddings_name)
self._failed_at: Optional[float] = None
# A dispatch has completed successfully, so the worker is known to be
# consuming the queue and callers need not take turns proving it.
self._verified = False
self._probing = False
self._state_lock = threading.Lock()
self._probe_done = threading.Event()
def _cooldown_remaining(self) -> float:
"""Seconds left of the fail-fast window after a failed dispatch."""
# One load: a concurrent success clearing the latch between two reads
# would otherwise subtract from None.
failed_at = self._failed_at
if failed_at is None:
return 0.0
return max(0.0, _FAILURE_COOLDOWN - (time.monotonic() - failed_at))
def _local_embeddings(self):
"""The in-process model, built once, for use inside a worker task."""
if self._local is None:
from docsgpt.vectorstore.base import build_local_embeddings
self._local = build_local_embeddings(self.embeddings_name, self.embeddings_key)
return self._local
def _send(self, texts: List[str], queue: str, timeout: int) -> List[List[float]]:
"""Publish the embed task and wait for its vectors."""
from docsgpt.celery_init import celery
result = celery.send_task(EMBED_TASK, args=[texts, self.embeddings_name], queue=queue)
try:
vectors = result.get(timeout=timeout)
except Exception as exc:
self._failed_at = time.monotonic()
# Drop the proof with the worker that supplied it. ``_verified``
# short-circuits ahead of the probe gate, so leaving it set means
# the gate only ever covers a worker that was never healthy --
# while the case that actually happens is a healthy one being
# redeployed or OOM-killed. Every caller would then pay the full
# timeout, together, on every wave once the cooldown lapses.
self._verified = False
raise RuntimeError(
f"Embedding request to the Celery worker timed out or failed ({exc}). "
f"A worker must be consuming the {queue!r} queue for retrieval to "
f"work. {_NO_WORKER_HINT}"
) from exc
finally:
_forget(result)
self._failed_at = None
self._verified = True
return vectors
def _cooldown_error(self, queue: str, remaining: float) -> RuntimeError:
return RuntimeError(
f"Skipping the embed dispatch: a previous request to the {queue!r} "
f"queue failed and the {_FAILURE_COOLDOWN}s cooldown has "
f"{remaining:.0f}s left. {_NO_WORKER_HINT}"
)
def _dispatch(self, texts: List[str]) -> List[List[float]]:
"""Run the embed task on the worker and wait for its vectors.
A missing worker is a property of the deployment, not of this call, so
at most one caller waits out ``EMBEDDINGS_DELEGATE_TIMEOUT`` to discover
it. Two guards do that:
The cooldown latch covers requests arriving *after* a failure -- without
it a single retrieval pays the timeout twice, once in
``fanout.embed_questions`` and again per source when it falls back to
letting each store embed its own query.
The probe covers requests already in flight *alongside* the first one,
which the latch cannot: nothing is latched until that first ``get()``
returns, so every thread in the opening wave would otherwise block for
the full timeout at once -- at the shipped 60s and 96 WSGI threads, an
API that serves nothing at all, health checks included.
"""
queue = getattr(settings, "EMBEDDINGS_QUEUE", "embeddings")
timeout = getattr(settings, "EMBEDDINGS_DELEGATE_TIMEOUT", 60)
remaining = self._cooldown_remaining()
if remaining > 0:
raise self._cooldown_error(queue, remaining)
if self._verified:
return self._send(texts, queue, timeout)
with self._state_lock:
# "send" -- proven while we waited for the lock, just go.
# "wait" -- another caller is already finding out; don't pay a
# second full timeout to learn the same thing.
# "probe" -- nobody is; this call is the one that finds out.
role = "send" if self._verified else "wait" if self._probing else "probe"
if role == "probe":
self._probing = True
self._probe_done.clear()
if role == "probe":
try:
return self._send(texts, queue, timeout)
finally:
with self._state_lock:
self._probing = False
self._probe_done.set()
if role == "wait":
self._probe_done.wait(_PROBE_WAIT)
remaining = self._cooldown_remaining()
if remaining > 0:
raise self._cooldown_error(queue, remaining)
if not self._verified:
raise RuntimeError(
f"Skipping the embed dispatch: an earlier request to the "
f"{queue!r} queue is still unanswered after {_PROBE_WAIT}s, so "
f"no worker appears to be consuming it. {_NO_WORKER_HINT}"
)
return self._send(texts, queue, timeout)
def embed_documents(self, documents: List[str]) -> List[List[float]]:
"""Embed a list of texts, preserving order."""
if not documents:
return []
if _in_worker():
return self._local_embeddings().embed_documents(documents)
vectors = self._dispatch(list(documents))
if self._dimension is None and vectors:
self._dimension = len(vectors[0])
return vectors
def embed_query(self, query: str) -> List[float]:
"""Embed a single query string."""
return self.embed_documents([query])[0]
@property
def dimension(self) -> Optional[int]:
"""Vector width, from the registry where possible.
Falls back to one round trip for a model the registry does not
describe, and to ``None`` when even that fails -- callers already treat
an unknown width as "nothing to compare yet" rather than an error.
"""
if self._dimension is None:
try:
self._dimension = len(self.embed_query("dimension probe"))
except Exception as exc:
logger.warning("Could not determine embedding width: %s", exc)
return None
return self._dimension
def __call__(self, text):
if isinstance(text, str):
return self.embed_query(text)
elif isinstance(text, list):
return self.embed_documents(text)
raise ValueError("Input must be a string or a list of strings")
+356
View File
@@ -0,0 +1,356 @@
"""Local embeddings via FastEmbed (ONNX Runtime).
Replaces the previous SentenceTransformer implementation. Both run the same
weights; FastEmbed reaches them through ONNX Runtime instead of torch, which
removes torch, transformers and sentence-transformers from the dependency set
and measurably reduces both resident memory and import cost.
The swap is numerically transparent for existing indexes: embedding the same
text with SentenceTransformer and with FastEmbed's fp32 ONNX graph of mpnet
returns vectors at cosine 1.0, so an mpnet index built before this change
keeps working unchanged. That result is specific to the fp32 graph -- the
granite entries run an int8-quantised one and are not bit-comparable to a
fp32 index of the same model.
Models are described in :mod:`docsgpt.vectorstore.model_registry`. A name
the registry does not know is treated as a Hugging Face repository, which is
what someone configuring an arbitrary model expects; how to run it is read
from the repository itself rather than assumed.
"""
import json
import logging
import threading
from dataclasses import replace
from typing import Any, List, Optional
from docsgpt.core.settings import settings
from docsgpt.vectorstore.model_registry import EmbeddingModel, resolve, known_names
logger = logging.getLogger(__name__)
# ``add_custom_model`` mutates a process-global registry inside FastEmbed, so
# repeated registration of the same name is both wasteful and racy under the
# thread pool the API serves requests from.
_registered: set = set()
_register_lock = threading.Lock()
# Last-resort layout for a repository that declares nothing about itself.
_FALLBACK_ONNX_FILE = "onnx/model.onnx"
_FALLBACK_POOLING = "mean"
# Sentence-transformers records how a model turns token vectors into one
# vector, and whether it normalises the result, as files in the repository.
# Reading them is the difference between running a model and running something
# that merely shares its weights: mean-pooling a CLS model returns vectors at
# cosine ~0.95 to the correct ones -- close enough to look like it works, far
# enough to degrade retrieval, and silent either way.
_POOLING_CONFIG = "1_Pooling/config.json"
_MODULES_CONFIG = "modules.json"
def _pooling_type(pooling: str):
"""Map our ``"cls"``/``"mean"`` spelling onto FastEmbed's enum."""
from fastembed.common.model_description import PoolingType
return {"cls": PoolingType.CLS, "mean": PoolingType.MEAN}[pooling]
def _is_builtin(repo: str) -> bool:
"""True when FastEmbed already ships a description for ``repo``."""
from fastembed import TextEmbedding
lowered = repo.lower()
return any(
str(entry.get("model", "")).lower() == lowered
for entry in TextEmbedding.list_supported_models()
)
def _register(model: EmbeddingModel) -> None:
"""Teach FastEmbed about a model, exactly once per process."""
from fastembed import TextEmbedding
from fastembed.common.model_description import ModelSource
with _register_lock:
if model.repo in _registered:
return
if _is_builtin(model.repo):
# ``add_custom_model`` refuses a name FastEmbed already ships, and
# its own description carries the pooling, width and graph file we
# would be supplying, so there is nothing to add. Without this,
# configuring any of FastEmbed's ~30 built-in models (bge, e5,
# MiniLM, gte, ...) fails every embed call.
_registered.add(model.repo)
return
TextEmbedding.add_custom_model(
model=model.repo,
pooling=_pooling_type(model.pooling),
normalization=model.normalize,
sources=ModelSource(hf=model.repo),
dim=model.dimension,
model_file=model.onnx_file,
)
_registered.add(model.repo)
def _read_repo_json(repo: str, filename: str) -> Optional[dict]:
"""Fetch one small JSON from ``repo``, or ``None`` when it is not there.
Reads through the Hugging Face hub cache, so a warmed image finds it
offline. Every failure -- absent file, no network, malformed JSON -- is the
same answer to the caller: this repository does not tell us.
"""
try:
from huggingface_hub import hf_hub_download
try:
path = hf_hub_download(repo_id=repo, filename=filename, local_files_only=True)
except Exception: # noqa: BLE001 -- not cached: fetch it
path = hf_hub_download(repo_id=repo, filename=filename)
with open(path, encoding="utf-8") as handle:
return json.load(handle)
except Exception as exc:
logger.debug("No %s for %s (%s)", filename, repo, exc)
return None
def _describe_from_repo(repo: str) -> Optional[EmbeddingModel]:
"""Build a spec from a repository's sentence-transformers metadata.
Args:
repo: Hugging Face repository id.
Returns:
The described model, or ``None`` when the repository carries no
metadata to read -- leaving the caller to fall back to assumptions.
Raises:
RuntimeError: If the model has a Dense projection head. FastEmbed runs
the transformer and pools it, and nothing else, so the projection
would be skipped and the vectors come out both the wrong width and
in a different space. There is no correct way to run it here.
"""
pooling_config = _read_repo_json(repo, _POOLING_CONFIG)
if pooling_config is None:
return None
kinds = {
str(module.get("type", "")).rsplit(".", 1)[-1]
for module in (_read_repo_json(repo, _MODULES_CONFIG) or [])
if isinstance(module, dict)
}
if "Dense" in kinds:
raise RuntimeError(
f"Embedding model {repo!r} has a Dense projection layer, which FastEmbed "
"cannot run: its vectors would be the wrong width and in a different "
"space than the model was trained to produce. Choose a model without "
"one, or serve this one over EMBEDDINGS_BASE_URL."
)
if pooling_config.get("pooling_mode_cls_token"):
pooling = "cls"
elif pooling_config.get("pooling_mode_mean_tokens"):
pooling = "mean"
else:
# max, mean_sqrt_len, weighted-mean: FastEmbed offers none of them, so
# there is nothing to describe and guessing is what we are avoiding.
logger.warning(
"Embedding model %s uses a pooling mode FastEmbed cannot reproduce (%s).",
repo,
", ".join(sorted(k for k, v in pooling_config.items() if v is True)) or "unknown",
)
return None
# ``Normalize`` appears in modules.json only when the model L2-normalises.
# Its absence is a fact, not missing data: a dot-product model is trained
# on unnormalised vectors and normalising re-ranks its results.
normalize = "Normalize" in kinds
dimension = int(pooling_config.get("word_embedding_dimension") or 0)
logger.info(
"Embedding model %s declares %s pooling, normalize=%s, dimension=%s.",
repo,
pooling,
normalize,
dimension or "unknown",
)
return EmbeddingModel(
name=repo,
dimension=dimension,
max_input_tokens=512,
pooling=pooling,
normalize=normalize,
repo=repo,
onnx_file=_FALLBACK_ONNX_FILE,
)
def _apply_overrides(spec: EmbeddingModel) -> EmbeddingModel:
"""Let ``EMBEDDINGS_POOLING``/``EMBEDDINGS_NORMALIZE`` win over any source."""
pooling = getattr(settings, "EMBEDDINGS_POOLING", None)
normalize = getattr(settings, "EMBEDDINGS_NORMALIZE", None)
changes = {}
if isinstance(pooling, str) and pooling.strip().lower() in ("cls", "mean"):
changes["pooling"] = pooling.strip().lower()
if isinstance(normalize, bool):
changes["normalize"] = normalize
if not changes:
return spec
logger.info("Overriding %s from settings: %s", spec.repo, changes)
return replace(spec, **changes)
def _spec_for(model_name: str) -> EmbeddingModel:
"""Registry entry for ``model_name``, or a best-effort one for a raw repo.
Args:
model_name: Configured ``EMBEDDINGS_NAME``.
Returns:
The registry entry, else one read from the repository's own
sentence-transformers metadata, else a last-resort entry assuming the
standard ONNX layout with mean pooling and ``dimension = 0`` so the
caller knows to probe for the real width. Settings overrides win over
all three.
"""
spec = resolve(model_name)
if spec is not None:
return _apply_overrides(spec)
described = _describe_from_repo(model_name)
if described is not None:
return _apply_overrides(described)
logger.warning(
"Embedding model %r is not in the registry (known: %s) and its repository "
"declares no pooling, so %s with L2 normalisation is assumed. If that is "
"wrong the vectors will be quietly poor rather than fail; set "
"EMBEDDINGS_POOLING=cls|mean and EMBEDDINGS_NORMALIZE to pin it.",
model_name,
", ".join(known_names()),
_FALLBACK_POOLING,
)
return _apply_overrides(
EmbeddingModel(
name=model_name,
dimension=0,
max_input_tokens=512,
pooling=_FALLBACK_POOLING,
normalize=True,
repo=model_name,
onnx_file=_FALLBACK_ONNX_FILE,
)
)
def _pad_to_longest_in_batch(model: Any) -> None:
"""Undo a fixed padding width baked into a model's ``tokenizer.json``."""
# FastEmbed enables padding only when the tokenizer declares none, so a
# fixed ``length`` survives loading. Shorter inputs are then padded to that
# width while longer ones keep their own, the batch is ragged, and the ONNX
# tensor build fails. mpnet ships ``length: 128``; granite does not.
# Mean pooling masks pad tokens, so the vectors are unaffected.
tokenizer = getattr(getattr(model, "model", None), "tokenizer", None)
padding = getattr(tokenizer, "padding", None)
if not isinstance(padding, dict) or padding.get("length") is None:
return
tokenizer.enable_padding(
direction=padding.get("direction", "right"),
pad_id=padding.get("pad_id", 0),
pad_type_id=padding.get("pad_type_id", 0),
pad_token=padding.get("pad_token", "<pad>"),
length=None,
pad_to_multiple_of=padding.get("pad_to_multiple_of"),
)
class EmbeddingsWrapper:
"""Runs an embedding model locally through FastEmbed.
Exposes the ``embed_query``/``embed_documents``/``dimension`` interface the
vector stores rely on, matching ``RemoteEmbeddings`` and ``OpenAIEmbeddings``.
"""
def __init__(self, model_name: str, *args: Any, **kwargs: Any) -> None:
"""Load ``model_name`` locally.
Args:
model_name: Registry name, alias, or a Hugging Face repository id.
Raises:
RuntimeError: If the model cannot be loaded, with the configured
name and the registry's known names in the message.
"""
from fastembed import TextEmbedding
self.spec = _spec_for(model_name)
logger.info("Loading embeddings model %s via FastEmbed", self.spec.repo)
try:
_register(self.spec)
init_kwargs = {"model_name": self.spec.repo}
threads = getattr(settings, "EMBEDDINGS_THREADS", None)
if isinstance(threads, int) and threads > 0:
init_kwargs["threads"] = threads
cache_dir = getattr(settings, "EMBEDDINGS_CACHE_DIR", None)
if cache_dir:
init_kwargs["cache_dir"] = cache_dir
self.model = TextEmbedding(**init_kwargs)
except Exception as exc:
raise RuntimeError(
f"Could not load embeddings model {model_name!r} via FastEmbed: "
f"{exc}. Known models: {', '.join(known_names())}."
) from exc
_pad_to_longest_in_batch(self.model)
self.dimension = self.spec.dimension or self._probe_dimension()
logger.info("Embeddings model ready (dimension=%d)", self.dimension)
def _probe_dimension(self) -> int:
"""Determine the vector width of a model the registry does not describe."""
return len(self.embed_query("dimension probe"))
def embed_query(self, query: str) -> List[float]:
"""Embed a single query string."""
return self.embed_documents([query])[0]
def embed_documents(self, documents: List[str]) -> List[List[float]]:
"""Embed a list of documents, preserving input order.
Batched by ``EMBEDDINGS_MODEL_BATCH_SIZE``, not by the pipeline's
``EMBEDDINGS_BATCH_SIZE``: one is documents per forward pass, the other
is chunks per store transaction, and sizing the forward pass from the
transaction is what made ingest peak at 6.6 GB.
Inputs are grouped by length first. ONNX needs a rectangular tensor, so
every input in a pass is padded up to the longest one in it; with mixed
lengths that padding is most of the work. The original order is
restored before returning, so callers zipping these against their texts
are unaffected.
"""
if not documents:
return []
batch_size: Optional[int] = None
raw = getattr(settings, "EMBEDDINGS_MODEL_BATCH_SIZE", None)
if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0:
batch_size = raw
documents = list(documents)
if batch_size is None or len(documents) <= batch_size:
# One batch either way: sorting would only add work.
return [v.tolist() for v in self.model.embed(documents, batch_size=batch_size)]
order = sorted(range(len(documents)), key=lambda i: len(documents[i]))
grouped = [documents[i] for i in order]
vectors = [v.tolist() for v in self.model.embed(grouped, batch_size=batch_size)]
restored: List[Optional[List[float]]] = [None] * len(documents)
for position, original_index in enumerate(order):
restored[original_index] = vectors[position]
return restored
def __call__(self, text):
if isinstance(text, str):
return self.embed_query(text)
elif isinstance(text, list):
return self.embed_documents(text)
raise ValueError("Input must be a string or a list of strings")
+89
View File
@@ -0,0 +1,89 @@
"""OpenAI (and Azure OpenAI) embeddings built on the official ``openai`` SDK."""
from typing import List, Optional
from docsgpt.core.settings import settings
# openai >= 2.53 rejects a falsy api_key at construction; Azure authenticates
# through its own deployment credentials, so a placeholder keeps the client
# constructible when no key is configured.
NO_API_KEY = "sk-no-key"
DEFAULT_MODEL = "text-embedding-ada-002"
class OpenAIEmbeddings:
"""Embeddings client for OpenAI and Azure OpenAI.
Mirrors the ``embed_query``/``embed_documents`` interface the vector
stores expect, matching :class:`RemoteEmbeddings` and
:class:`EmbeddingsWrapper`.
"""
def __init__(
self,
openai_api_key: Optional[str] = None,
model: Optional[str] = None,
**kwargs,
) -> None:
"""Build the client, routing to Azure when the Azure settings are set.
Args:
openai_api_key: API key; falls back to ``EMBEDDINGS_KEY`` then
``OPENAI_API_KEY``.
model: Model name, or the Azure deployment name when running
against Azure.
"""
api_key = (
openai_api_key
or settings.EMBEDDINGS_KEY
or settings.OPENAI_API_KEY
or NO_API_KEY
)
self.model = model or DEFAULT_MODEL
self.dimension = None
is_azure = bool(
settings.OPENAI_API_BASE
and settings.OPENAI_API_VERSION
and settings.AZURE_DEPLOYMENT_NAME
)
if is_azure:
from openai import AzureOpenAI
self.client = AzureOpenAI(
api_key=api_key,
azure_endpoint=settings.OPENAI_API_BASE,
api_version=settings.OPENAI_API_VERSION,
)
else:
from openai import OpenAI
base_url = settings.OPENAI_BASE_URL or None
self.client = OpenAI(api_key=api_key, base_url=base_url)
def _embed(self, inputs: List[str]) -> List[List[float]]:
"""Embed a batch, returning vectors in request order."""
response = self.client.embeddings.create(model=self.model, input=inputs)
ordered = sorted(response.data, key=lambda item: item.index)
vectors = [item.embedding for item in ordered]
if vectors and self.dimension is None:
self.dimension = len(vectors[0])
return vectors
def embed_query(self, query: str) -> List[float]:
"""Embed a single query string."""
return self._embed([query])[0]
def embed_documents(self, documents: List[str]) -> List[List[float]]:
"""Embed a list of documents."""
if not documents:
return []
return self._embed(list(documents))
def __call__(self, text):
if isinstance(text, str):
return self.embed_query(text)
elif isinstance(text, list):
return self.embed_documents(text)
raise ValueError("Input must be a string or a list of strings")
+29
View File
@@ -0,0 +1,29 @@
"""The Celery task behind :mod:`docsgpt.vectorstore.embeddings_delegated`.
Kept out of ``docsgpt.api.user.tasks`` deliberately: that module imports
``docsgpt.worker`` and the whole parsing stack with it, which is the
opposite of what delegation is for.
"""
from __future__ import annotations
from typing import List, Optional
from docsgpt.celery_init import celery
from docsgpt.vectorstore.embeddings_delegated import EMBED_TASK
@celery.task(name=EMBED_TASK, acks_late=False, ignore_result=False)
def embed_texts(texts: List[str], embeddings_name: Optional[str] = None) -> List[List[float]]:
"""Embed ``texts`` with the worker's local model.
Args:
texts: Strings to embed.
embeddings_name: Model to use; the configured one when omitted.
Returns:
One vector per input, in input order.
"""
from docsgpt.vectorstore.base import get_embeddings
return get_embeddings(embeddings_name).embed_documents(list(texts))
+385
View File
@@ -0,0 +1,385 @@
import io
import logging
import os
import tempfile
import uuid
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from docsgpt.core.settings import settings
from docsgpt.storage.storage_creator import StorageCreator
from docsgpt.vectorstore.base import BaseVectorStore
from docsgpt.vectorstore.document_class import Document
from docsgpt.vectorstore.faiss_docstore import (
dump_json_sidecar,
dump_pickle_sidecar,
load_json_sidecar,
load_pickle_sidecar,
)
logger = logging.getLogger(__name__)
# Sidecar holding chunk text and the row->id mapping. ``index.json`` is what
# this version writes; ``index.pkl`` is langchain's historical format, still
# read forever (uploads arrive in it) and still written for backward compat.
JSON_SIDECAR = "index.json"
PICKLE_SIDECAR = "index.pkl"
FAISS_INDEX = "index.faiss"
def _dependable_faiss_import():
"""Import faiss, with a clearer message than the raw ImportError."""
try:
import faiss
except ImportError as e:
raise ImportError(
"Could not import faiss. Install it with `pip install faiss-cpu`."
) from e
return faiss
def get_vectorstore(path: str) -> str:
"""Build a safe local path for a FAISS index.
Args:
path: Source identifier provided by the caller.
Returns:
The validated vectorstore path rooted under ``indexes``.
Raises:
ValueError: If ``path`` escapes the ``indexes`` directory.
"""
base_dir = "indexes"
if not path:
return base_dir
normalized = str(path).strip()
if "\\" in normalized:
raise ValueError("Invalid source_id path")
candidate = os.path.normpath(os.path.join(base_dir, normalized))
base_abs = os.path.abspath(base_dir)
candidate_abs = os.path.abspath(candidate)
if not candidate_abs.startswith(base_abs + os.sep) and candidate_abs != base_abs:
raise ValueError("Invalid source_id path")
return candidate
class FaissStore(BaseVectorStore):
"""Vector store backed by a local FAISS index.
Holds a flat L2 index plus an in-memory docstore mapping chunk ids to
their text and metadata, persisted through :class:`StorageCreator`.
"""
# Ranks by L2 distance (lower is better), not cosine — so the number here
# is NOT comparable to the ``score_threshold`` the other stores honour,
# and must not be shown as one.
score_kind = "l2_distance"
def __init__(
self,
source_id: str,
embeddings_key: str,
docs_init=None,
ids=None,
batch_size=None,
skip_dimension_check: bool = False,
):
"""Open or build one source's FAISS index.
Args:
source_id: Source whose index to open.
embeddings_key: API key handed to the embeddings provider.
docs_init: Documents to build a fresh index from. Loads the stored
index instead when omitted.
ids: Chunk ids to keep when building. Generated when omitted.
batch_size: Documents per embed call when building.
skip_dimension_check: Open an index whose width does not match the
configured model. Only for a caller that is about to replace
that index, such as the re-embed script, which otherwise cannot
read the chunks it needs to rebuild from.
"""
super().__init__()
self.source_id = source_id
self.path = get_vectorstore(source_id)
self.embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, embeddings_key)
self.storage = StorageCreator.get_storage()
self.index = None
self.documents: Dict[str, Dict[str, Any]] = {}
self.index_to_docstore_id: Dict[int, str] = {}
try:
if docs_init:
self._build_from_documents(docs_init, ids=ids, batch_size=batch_size)
else:
self._load_from_storage()
except Exception as e:
raise Exception(f"Error loading FAISS index: {str(e)}")
if not skip_dimension_check:
self.assert_embedding_dimensions(self.embeddings)
# -- Construction ----------------------------------------------------
def _build_from_documents(self, docs_init, ids=None, batch_size=None) -> None:
"""Create a fresh index seeded with ``docs_init``.
Args:
docs_init: Documents to embed.
ids: Chunk ids to keep. Generated when omitted, which renumbers
every chunk and orphans anything referencing the old ids.
batch_size: Documents per embed call. Without it the whole index
goes out in one call, which a remote embeddings server rejects
or times out on.
"""
texts, metadatas = [], []
for doc in docs_init:
texts.append(getattr(doc, "page_content", None) or getattr(doc, "text", "") or "")
metadatas.append(getattr(doc, "metadata", None) or getattr(doc, "extra_info", None) or {})
faiss = _dependable_faiss_import()
ids = list(ids) if ids else None
step = batch_size if batch_size and batch_size > 0 else len(texts)
for start in range(0, len(texts), max(1, step)):
stop = start + max(1, step)
vectors = self.embeddings.embed_documents(texts[start:stop])
if self.index is None:
self.index = faiss.IndexFlatL2(len(vectors[0]))
self._append(
texts[start:stop],
metadatas[start:stop],
vectors,
ids[start:stop] if ids else None,
)
def _load_from_storage(self) -> None:
"""Load the index and its sidecar, preferring JSON over the pickle."""
faiss = _dependable_faiss_import()
faiss_path = f"{self.path}/{FAISS_INDEX}"
json_path = f"{self.path}/{JSON_SIDECAR}"
pickle_path = f"{self.path}/{PICKLE_SIDECAR}"
if not self.storage.file_exists(faiss_path):
raise FileNotFoundError(f"Index files not found in storage at {self.path}")
if self.storage.file_exists(json_path):
sidecar, loader = json_path, load_json_sidecar
elif self.storage.file_exists(pickle_path):
sidecar, loader = pickle_path, load_pickle_sidecar
else:
raise FileNotFoundError(f"Index files not found in storage at {self.path}")
with tempfile.TemporaryDirectory() as temp_dir:
local_faiss = os.path.join(temp_dir, FAISS_INDEX)
with open(local_faiss, "wb") as f:
f.write(self.storage.get_file(faiss_path).read())
self.index = faiss.read_index(local_faiss)
self.documents, self.index_to_docstore_id = loader(
self.storage.get_file(sidecar).read()
)
# -- Internals -------------------------------------------------------
def _append(self, texts, metadatas, vectors, ids=None) -> List[str]:
"""Add embedded rows to the index and docstore, returning their ids."""
ids = list(ids) if ids else [str(uuid.uuid4()) for _ in texts]
self.index.add(np.array(vectors, dtype=np.float32))
start = len(self.index_to_docstore_id)
for offset, (text, metadata, doc_id) in enumerate(zip(texts, metadatas, ids)):
self.documents[doc_id] = {
"page_content": text,
"metadata": dict(metadata or {}),
}
self.index_to_docstore_id[start + offset] = doc_id
return ids
def _to_document(self, doc_id: str) -> Optional[Document]:
stored = self.documents.get(doc_id)
if stored is None:
return None
return Document(
page_content=stored.get("page_content", ""),
metadata=stored.get("metadata") or {},
)
# -- Search ----------------------------------------------------------
def search(self, question: str, k: int = 4, *args, **kwargs) -> List[Document]:
"""Return the ``k`` nearest chunks for ``question``."""
return [doc for doc, _ in self.search_with_scores(question, k, *args, **kwargs)]
def search_with_scores(
self,
question: str,
k: int = 4,
*args,
query_vector: Optional[List[float]] = None,
**kwargs,
) -> List[Tuple[Document, float]]:
"""Same search as :meth:`search`, pairing each hit with its L2 distance.
Args:
query_vector: Precomputed embedding of ``question``; when given the
store skips embedding the query itself.
"""
# FAISS has no relevance-threshold knob; drop it so the per-source
# score_threshold is safely ignored rather than crashing the forward.
kwargs.pop("score_threshold", None)
if self.index is None or self.index.ntotal == 0:
return []
if query_vector is None:
query_vector = self.embeddings.embed_query(question)
vector = np.array([query_vector], dtype=np.float32)
distances, rows = self.index.search(vector, min(k, self.index.ntotal))
results = []
for distance, row in zip(distances[0], rows[0]):
if row == -1:
continue
doc_id = self.index_to_docstore_id.get(int(row))
document = self._to_document(doc_id) if doc_id else None
if document is not None:
results.append((document, float(distance)))
return results
# -- Mutation --------------------------------------------------------
def add_texts(
self,
texts: List[str],
metadatas: Optional[List[dict]] = None,
*args,
ids: Optional[List[str]] = None,
**kwargs,
) -> List[str]:
"""Embed and append ``texts`` to the index."""
texts = list(texts)
if not texts:
return []
metadatas = list(metadatas or [{} for _ in texts])
vectors = self.embeddings.embed_documents(texts)
if self.index is None:
faiss = _dependable_faiss_import()
self.index = faiss.IndexFlatL2(len(vectors[0]))
return self._append(texts, metadatas, vectors, ids)
def delete_index(self, ids: Optional[List[str]] = None, *args, **kwargs):
"""Delete the given chunk ids, or the whole index when ids are omitted."""
if ids is None:
faiss = _dependable_faiss_import()
dimension = self.index.d if self.index is not None else None
self.index = faiss.IndexFlatL2(dimension) if dimension else None
self.documents = {}
self.index_to_docstore_id = {}
return True
missing = set(ids) - set(self.documents)
if missing:
raise ValueError(f"Chunk ids not found in index: {sorted(missing)}")
rows_by_id = {doc_id: row for row, doc_id in self.index_to_docstore_id.items()}
rows_to_drop = {rows_by_id[doc_id] for doc_id in ids}
self.index.remove_ids(np.array(sorted(rows_to_drop), dtype=np.int64))
for doc_id in ids:
self.documents.pop(doc_id, None)
# remove_ids compacts the index, so the mapping has to be renumbered.
remaining = [
doc_id
for row, doc_id in sorted(self.index_to_docstore_id.items())
if row not in rows_to_drop
]
self.index_to_docstore_id = dict(enumerate(remaining))
return True
def add_chunk(self, text: str, metadata: Optional[Dict[str, Any]] = None) -> str:
"""Add a new chunk and save to storage."""
ids = self.add_texts([text], [metadata or {}])
self._save_to_storage()
return ids[0]
def delete_chunk(self, chunk_id: str) -> bool:
"""Delete a chunk and save to storage."""
self.delete_index([chunk_id])
self._save_to_storage()
return True
# -- Persistence -----------------------------------------------------
def _write_index_files(self, directory: str) -> None:
"""Write index.faiss plus both sidecars into ``directory``."""
faiss = _dependable_faiss_import()
os.makedirs(directory, exist_ok=True)
faiss.write_index(self.index, os.path.join(directory, FAISS_INDEX))
with open(os.path.join(directory, JSON_SIDECAR), "wb") as f:
f.write(dump_json_sidecar(self.documents, self.index_to_docstore_id))
with open(os.path.join(directory, PICKLE_SIDECAR), "wb") as f:
f.write(dump_pickle_sidecar(self.documents, self.index_to_docstore_id))
def _save_to_storage(self) -> bool:
"""Persist the index through the configured storage backend.
Each file is replaced atomically by the backend, so an interrupted save
leaves the previous one readable. The three are still written in
sequence: a crash between them pairs a new index with an older sidecar,
which stays consistent because the ids and row order are preserved.
"""
with tempfile.TemporaryDirectory() as temp_dir:
self._write_index_files(temp_dir)
storage_path = get_vectorstore(self.source_id)
for name in (FAISS_INDEX, JSON_SIDECAR, PICKLE_SIDECAR):
with open(os.path.join(temp_dir, name), "rb") as f:
self.storage.save_file(io.BytesIO(f.read()), f"{storage_path}/{name}")
return True
def save_local(self, path: Optional[str] = None) -> bool:
if path:
self._write_index_files(path)
self._save_to_storage()
return True
# -- Introspection ---------------------------------------------------
def assert_embedding_dimensions(self, embeddings) -> None:
"""Check the index width matches the embedding model's width.
This used to run only when ``EMBEDDINGS_NAME`` was mpnet, so every
other model skipped the check entirely -- exactly the models most
likely to differ from an index built earlier. It now runs for any
model that reports a width.
"""
word_embedding_dimension = getattr(embeddings, "dimension", None)
if word_embedding_dimension is None:
# A remote model of unknown width reports None until its first
# call; there is nothing to compare yet.
return
if self.index is None:
return
if word_embedding_dimension != self.index.d:
raise ValueError(
f"Embedding dimension mismatch: {settings.EMBEDDINGS_NAME} produces "
f"{word_embedding_dimension}-dim vectors but this FAISS index is "
f"{self.index.d}-dim. The index was built with a different "
f"embedding model; re-embed it with "
f"`python -m docsgpt.scripts.reembed` or point "
f"EMBEDDINGS_NAME back at the original model."
)
def get_chunks(self) -> List[Dict[str, Any]]:
"""Return every chunk held in the index."""
return [
{
"doc_id": doc_id,
"text": stored.get("page_content", ""),
"metadata": stored.get("metadata") or {},
}
for doc_id, stored in self.documents.items()
]
+230
View File
@@ -0,0 +1,230 @@
"""Persistence for the FAISS docstore, independent of langchain.
A FAISS index is two files: ``index.faiss`` (written by faiss itself) and a
sidecar holding the chunk text plus the row-to-id mapping. Historically that
sidecar was ``index.pkl`` — a pickled
``(InMemoryDocstore, index_to_docstore_id)`` tuple produced by langchain.
Those files are still an active ingress: ``POST /api/upload_index`` accepts
them, and every index written before this change is in that format. So the
legacy sidecar is read forever, via a :class:`CompatUnpickler` that resolves
langchain's symbols onto local stand-ins and refuses everything else — the
previous ``pickle.load`` would execute arbitrary code from an uploaded file.
``index.json`` is the format written going forward. ``index.pkl`` is written
alongside it, byte-compatible with langchain's layout, so an older DocsGPT can
still read an index this version has re-saved.
"""
import io
import json
import pickle
import sys
import types
from contextlib import contextmanager
from typing import Any, Dict, Tuple
# Module paths the legacy pickle references, mapped onto the stand-ins below.
_LEGACY_DOCUMENT_PATHS = (
("langchain_core.documents.base", "Document"),
("langchain_core.documents", "Document"),
("langchain.schema.document", "Document"),
("langchain.docstore.document", "Document"),
("langchain.schema", "Document"),
)
_LEGACY_DOCSTORE_PATHS = (
("langchain_community.docstore.in_memory", "InMemoryDocstore"),
("langchain.docstore.in_memory", "InMemoryDocstore"),
)
# The path langchain itself writes, and therefore the one we emit.
_CANONICAL_DOCUMENT_PATH = _LEGACY_DOCUMENT_PATHS[0]
_CANONICAL_DOCSTORE_PATH = _LEGACY_DOCSTORE_PATHS[0]
class LegacyDocument:
"""Stand-in for ``langchain_core.documents.base.Document``.
langchain's Document is a pydantic v2 model, which pickles as
``__newobj__(cls)`` plus a state dict; reconstruction only needs a class
whose ``__setstate__`` understands that shape.
"""
__slots__ = ("page_content", "metadata", "id", "type")
def __init__(self, page_content: str = "", metadata: Dict[str, Any] = None,
id: Any = None) -> None:
self.page_content = page_content
self.metadata = metadata if metadata is not None else {}
self.id = id
self.type = "Document"
def __setstate__(self, state: Any) -> None:
fields = state.get("__dict__", state) if isinstance(state, dict) else {}
self.page_content = fields.get("page_content", "")
self.metadata = fields.get("metadata") or {}
self.id = fields.get("id")
self.type = fields.get("type", "Document")
def __getstate__(self) -> Dict[str, Any]:
"""Emit pydantic v2's pickle state so langchain can read it back."""
return {
"__dict__": {
"id": self.id,
"metadata": self.metadata,
"page_content": self.page_content,
"type": self.type,
},
"__pydantic_extra__": None,
"__pydantic_fields_set__": {"page_content", "metadata"},
"__pydantic_private__": None,
}
class LegacyDocstore:
"""Stand-in for ``langchain_community.docstore.in_memory.InMemoryDocstore``."""
def __init__(self, _dict: Dict[str, LegacyDocument] = None) -> None:
self._dict = _dict if _dict is not None else {}
def __setstate__(self, state: Any) -> None:
self._dict = state.get("_dict", {}) if isinstance(state, dict) else {}
def __getstate__(self) -> Dict[str, Any]:
return {"_dict": self._dict}
class CompatUnpickler(pickle.Unpickler):
"""Unpickler that accepts only the symbols a FAISS sidecar may contain.
langchain's document and docstore classes resolve to the local stand-ins;
every other global is refused, so a hostile ``index.pkl`` cannot execute
code through ``__reduce__``.
"""
_ALLOWED = {
**{path: LegacyDocument for path in _LEGACY_DOCUMENT_PATHS},
**{path: LegacyDocstore for path in _LEGACY_DOCSTORE_PATHS},
}
def find_class(self, module: str, name: str):
mapped = self._ALLOWED.get((module, name))
if mapped is not None:
return mapped
raise pickle.UnpicklingError(
f"Refusing to load unexpected symbol from FAISS index: {module}.{name}"
)
@contextmanager
def _legacy_pickle_names():
"""Expose the stand-ins under langchain's module paths while pickling.
``pickle`` records a class by its module and qualified name, and verifies
the pair resolves before writing it. Registering throwaway modules for the
duration of the dump is what lets the output reference langchain's paths —
and therefore stay readable by a DocsGPT that still has langchain — without
langchain being installed here. Any pre-existing modules are restored.
"""
installed = []
for cls, (module_path, attr) in (
(LegacyDocument, _CANONICAL_DOCUMENT_PATH),
(LegacyDocstore, _CANONICAL_DOCSTORE_PATH),
):
original_module = cls.__module__
original_name = cls.__qualname__
cls.__module__, cls.__qualname__ = module_path, attr
parts = module_path.split(".")
for depth in range(1, len(parts) + 1):
parent = ".".join(parts[:depth])
if parent not in sys.modules:
sys.modules[parent] = types.ModuleType(parent)
installed.append(parent)
setattr(sys.modules[module_path], attr, cls)
installed.append((cls, original_module, original_name))
try:
yield
finally:
for entry in reversed(installed):
if isinstance(entry, tuple):
cls, original_module, original_name = entry
cls.__module__, cls.__qualname__ = original_module, original_name
else:
sys.modules.pop(entry, None)
def load_pickle_sidecar(data: bytes) -> Tuple[Dict[str, Dict[str, Any]], Dict[int, str]]:
"""Read a legacy ``index.pkl`` into plain dictionaries.
Args:
data: Raw bytes of the pickled ``(docstore, index_to_docstore_id)``.
Returns:
``(documents, index_to_docstore_id)`` where ``documents`` maps a
chunk id to ``{"page_content": ..., "metadata": ...}`` and the second
maps a FAISS row number to a chunk id.
"""
docstore, mapping = CompatUnpickler(io.BytesIO(data)).load()
documents = {
doc_id: {
"page_content": getattr(doc, "page_content", "") or "",
"metadata": getattr(doc, "metadata", None) or {},
}
for doc_id, doc in getattr(docstore, "_dict", {}).items()
}
return documents, {int(row): doc_id for row, doc_id in (mapping or {}).items()}
def dump_pickle_sidecar(
documents: Dict[str, Dict[str, Any]], index_to_docstore_id: Dict[int, str]
) -> bytes:
"""Serialize to the legacy langchain layout, for backward compatibility."""
docstore = LegacyDocstore(
{
doc_id: LegacyDocument(
page_content=doc.get("page_content", ""),
metadata=doc.get("metadata") or {},
)
for doc_id, doc in documents.items()
}
)
with _legacy_pickle_names():
return pickle.dumps((docstore, dict(index_to_docstore_id)), protocol=4)
def load_json_sidecar(data: bytes) -> Tuple[Dict[str, Dict[str, Any]], Dict[int, str]]:
"""Read an ``index.json`` sidecar."""
payload = json.loads(data.decode("utf-8"))
documents = {
doc_id: {
"page_content": doc.get("page_content", "") or "",
"metadata": doc.get("metadata") or {},
}
for doc_id, doc in (payload.get("documents") or {}).items()
}
mapping = {
int(row): doc_id
for row, doc_id in (payload.get("index_to_docstore_id") or {}).items()
}
return documents, mapping
def dump_json_sidecar(
documents: Dict[str, Dict[str, Any]], index_to_docstore_id: Dict[int, str]
) -> bytes:
"""Serialize to the pickle-free sidecar written going forward."""
payload = {
"version": 1,
"documents": {
doc_id: {
"page_content": doc.get("page_content", ""),
"metadata": doc.get("metadata") or {},
}
for doc_id, doc in documents.items()
},
"index_to_docstore_id": {
str(row): doc_id for row, doc_id in index_to_docstore_id.items()
},
}
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
+132
View File
@@ -0,0 +1,132 @@
from typing import List, Optional
import importlib
from docsgpt.vectorstore.base import BaseVectorStore
from docsgpt.core.settings import settings
from docsgpt.vectorstore.model_registry import DEFAULT_EMBEDDING_DIMENSION
class LanceDBVectorStore(BaseVectorStore):
"""Class for LanceDB Vector Store integration."""
def __init__(self, path: str = settings.LANCEDB_PATH,
table_name_prefix: str = settings.LANCEDB_TABLE_NAME,
source_id: str = None,
embeddings_key: str = "embeddings"):
"""Initialize the LanceDB vector store."""
super().__init__()
self.path = path
self.table_name = f"{table_name_prefix}_{source_id}" if source_id else table_name_prefix
self.embeddings_key = embeddings_key
self._lance_db = None
self.docsearch = None
self._pa = None # PyArrow (pa) will be lazy loaded
@property
def pa(self):
"""Lazy load pyarrow module."""
if self._pa is None:
self._pa = importlib.import_module("pyarrow")
return self._pa
@property
def lancedb(self):
"""Lazy load lancedb module."""
if not hasattr(self, "_lancedb_module"):
self._lancedb_module = importlib.import_module("lancedb")
return self._lancedb_module
@property
def lance_db(self):
"""Lazy load the LanceDB connection."""
if self._lance_db is None:
self._lance_db = self.lancedb.connect(self.path)
return self._lance_db
@property
def table(self):
"""Lazy load the LanceDB table."""
if self.docsearch is None:
if self.table_name in self.lance_db.table_names():
self.docsearch = self.lance_db.open_table(self.table_name)
else:
self.docsearch = None
return self.docsearch
def ensure_table_exists(self):
"""Ensure the table exists before performing operations."""
if self.table is None:
embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, self.embeddings_key)
# A model outside the registry reports no width until it has run;
# ``list_size=None`` is a TypeError, not a permissive schema.
dimension = getattr(embeddings, "dimension", None) or DEFAULT_EMBEDDING_DIMENSION
schema = self.pa.schema([
self.pa.field("vector", self.pa.list_(self.pa.float32(), list_size=dimension)),
self.pa.field("text", self.pa.string()),
self.pa.field("metadata", self.pa.struct([
self.pa.field("key", self.pa.string()),
self.pa.field("value", self.pa.string())
]))
])
self.docsearch = self.lance_db.create_table(self.table_name, schema=schema)
def add_texts(self, texts: List[str], metadatas: Optional[List[dict]] = None, source_id: str = None):
"""Add texts with metadata and their embeddings to the LanceDB table."""
embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, self.embeddings_key).embed_documents(texts)
vectors = []
for embedding, text, metadata in zip(embeddings, texts, metadatas or [{}] * len(texts)):
if source_id:
metadata["source_id"] = source_id
metadata_struct = [{"key": k, "value": str(v)} for k, v in metadata.items()]
vectors.append({
"vector": embedding,
"text": text,
"metadata": metadata_struct
})
self.ensure_table_exists()
self.docsearch.add(vectors)
def search(self, query: str, k: int = 2, *args, query_vector=None, **kwargs):
"""Search LanceDB for the top k most similar vectors.
Args:
query_vector: Precomputed embedding of ``query``; when given the
store skips embedding the query itself.
"""
self.ensure_table_exists()
query_embedding = query_vector
if query_embedding is None:
query_embedding = self._get_embeddings(
settings.EMBEDDINGS_NAME, self.embeddings_key
).embed_query(query)
results = self.docsearch.search(query_embedding).limit(k).to_list()
return [(result["_distance"], result["text"], result["metadata"]) for result in results]
def delete_index(self):
"""Delete the entire LanceDB index (table)."""
if self.table:
self.lance_db.drop_table(self.table_name)
def assert_embedding_dimensions(self, embeddings):
"""Ensure that embedding dimensions match the table index dimensions."""
word_embedding_dimension = embeddings.dimension
if self.table:
table_index_dimension = len(self.docsearch.schema["vector"].type.value_type)
if word_embedding_dimension != table_index_dimension:
raise ValueError(
f"Embedding dimension mismatch: embeddings.dimension ({word_embedding_dimension}) "
f"!= table index dimension ({table_index_dimension})"
)
def filter_documents(self, filter_condition: dict) -> List[dict]:
"""Filter documents based on certain conditions."""
self.ensure_table_exists()
# Ensure source_id exists in the filter condition
if 'source_id' not in filter_condition:
raise ValueError("filter_condition must contain 'source_id'")
source_id = filter_condition["source_id"]
# Use LanceDB's native filtering if supported, otherwise filter manually
filtered_data = self.docsearch.filter(lambda x: x.metadata and x.metadata.get("source_id") == source_id).to_list()
return filtered_data
+220
View File
@@ -0,0 +1,220 @@
import logging
import os
import uuid
from contextlib import contextmanager
from typing import Any, Dict, List, Optional, Tuple
from docsgpt.core.optional_deps import require
from docsgpt.core.settings import settings
from docsgpt.vectorstore.base import BaseVectorStore
from docsgpt.vectorstore.document_class import Document
@contextmanager
def _without_milvus_uri_env():
"""Hide ``MILVUS_URI`` from pymilvus while it is imported.
pymilvus reads the ``MILVUS_URI`` environment variable at import time and
rejects anything that is not an ``http[s]://`` URL. DocsGPT's setting of
the same name defaults to a Milvus Lite file path, and ``load_dotenv``
puts it on the environment — so an unguarded import raises before the
store can pass its own ``uri``. The value is restored immediately after.
"""
sentinel = object()
previous = os.environ.pop("MILVUS_URI", sentinel)
try:
yield
finally:
if previous is not sentinel:
os.environ["MILVUS_URI"] = previous
class MilvusStore(BaseVectorStore):
"""Vector store backed by Milvus through the native ``pymilvus`` client.
Defaults to Milvus Lite (a local file at ``MILVUS_URI``); point
``MILVUS_URI`` at a server URL to use a full deployment. Rows carry an
indexed ``source_id`` so one collection can hold many sources.
"""
score_kind = "cosine_similarity"
def __init__(self, source_id: str = "", embeddings_key: str = "embeddings"):
super().__init__()
with _without_milvus_uri_env():
pymilvus = require("pymilvus", "VECTOR_STORE=milvus")
DataType, MilvusClient = pymilvus.DataType, pymilvus.MilvusClient
self._DataType = DataType
self._source_id = str(source_id).replace("docsgpt/indexes/", "").rstrip("/")
self._collection = settings.MILVUS_COLLECTION_NAME
self._embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, embeddings_key)
self._client = MilvusClient(
uri=settings.MILVUS_URI, token=settings.MILVUS_TOKEN or ""
)
self._ensure_collection()
@property
def _filter(self) -> str:
"""Boolean expression scoping every read to this source."""
escaped = self._source_id.replace('"', '\\"')
return f'source_id == "{escaped}"'
def _dimension(self) -> int:
"""Resolve the embedding width, probing the model when unset."""
dimension = getattr(self._embeddings, "dimension", None)
if not dimension:
dimension = len(self._embeddings.embed_query("dimension probe"))
return dimension
def _ensure_collection(self) -> None:
"""Create the collection, its vector index and the source_id index."""
if self._client.has_collection(self._collection):
return
DataType = self._DataType
schema = self._client.create_schema(auto_id=False, enable_dynamic_field=True)
schema.add_field("id", DataType.VARCHAR, is_primary=True, max_length=64)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=self._dimension())
schema.add_field("text", DataType.VARCHAR, max_length=65535)
schema.add_field("source_id", DataType.VARCHAR, max_length=512)
schema.add_field("metadata", DataType.JSON, nullable=True)
index_params = self._client.prepare_index_params()
index_params.add_index(
field_name="vector", index_type="AUTOINDEX", metric_type="COSINE"
)
try:
self._client.create_collection(
collection_name=self._collection,
schema=schema,
index_params=index_params,
)
except Exception as e:
# A concurrent worker may have created it first.
if "already exist" not in str(e).lower():
raise
@staticmethod
def _to_document(row: Dict[str, Any]) -> Document:
row = row or {}
return Document(
page_content=row.get("text") or "",
metadata=row.get("metadata") or {},
)
def search(self, question: str, k: int = 2, *args, **kwargs) -> List[Document]:
"""Return the ``k`` nearest chunks for ``question``."""
return [doc for doc, _ in self.search_with_scores(question, k, *args, **kwargs)]
def search_with_scores(
self,
question: str,
k: int = 2,
*args,
score_threshold: Optional[float] = None,
query_vector: Optional[List[float]] = None,
**kwargs,
) -> List[Tuple[Document, float]]:
"""Search, pairing each hit with its cosine similarity.
Args:
query_vector: Precomputed embedding of ``question``; when given the
store skips embedding the query itself.
"""
if query_vector is None:
query_vector = self._embeddings.embed_query(question)
results = self._client.search(
collection_name=self._collection,
data=[query_vector],
filter=self._filter,
limit=k,
output_fields=["text", "metadata"],
)
hits = results[0] if results else []
out = []
for hit in hits:
score = hit.get("distance")
if score_threshold is not None and score is not None and score < score_threshold:
continue
out.append((self._to_document(hit.get("entity") or {}), score))
return out
def add_texts(
self,
texts: List[str],
metadatas: Optional[List[dict]] = None,
*args,
**kwargs,
) -> List[str]:
"""Embed and insert ``texts``, stamping each with the source id."""
texts = list(texts)
if not texts:
return []
metadatas = list(metadatas or [{} for _ in texts])
vectors = self._embeddings.embed_documents(texts)
rows, ids = [], []
for text, metadata, vector in zip(texts, metadatas, vectors):
row_id = str(uuid.uuid4())
row_metadata = dict(metadata or {})
row_metadata["source_id"] = self._source_id
ids.append(row_id)
rows.append(
{
"id": row_id,
"vector": vector,
"text": text,
"source_id": self._source_id,
"metadata": row_metadata,
}
)
self._client.insert(collection_name=self._collection, data=rows)
return ids
def save_local(self, *args, **kwargs):
"""No-op: Milvus persists server-side."""
pass
def delete_index(self, *args, **kwargs):
"""Delete every row belonging to this source."""
try:
return self._client.delete(
collection_name=self._collection, filter=self._filter
)
except Exception as e:
logging.error("Error deleting index: %s", e, exc_info=True)
return None
def get_chunks(self) -> List[Dict[str, Any]]:
"""Return every chunk stored for this source."""
try:
rows = self._client.query(
collection_name=self._collection,
filter=self._filter,
output_fields=["id", "text", "metadata"],
)
return [
{
"doc_id": row.get("id"),
"text": row.get("text"),
"metadata": row.get("metadata") or {},
}
for row in rows
]
except Exception as e:
logging.error("Error getting chunks: %s", e, exc_info=True)
return []
def add_chunk(self, text: str, metadata: Optional[Dict[str, Any]] = None) -> str:
"""Add one chunk and return its id."""
ids = self.add_texts([text], [metadata or {}])
return ids[0]
def delete_chunk(self, chunk_id: str) -> bool:
"""Delete a single chunk by id."""
try:
self._client.delete(collection_name=self._collection, ids=[chunk_id])
return True
except Exception as e:
logging.error("Error deleting chunk: %s", e, exc_info=True)
return False
+177
View File
@@ -0,0 +1,177 @@
"""Canonical description of every embedding model DocsGPT knows how to run.
``EMBEDDINGS_NAME`` used to be a free-form string interpreted in half a dozen
places: a factory dict here, a bundled-model path probe there, a dimension
assertion gated on one model's name, and a hardcoded ``dimension = 768`` on the
remote client. Each of those encoded a different subset of the same facts, and
they drifted.
This module is the single place those facts live. A model is described once and
every consumer -- the local runner, the remote client, the schema bootstrap, the
chunker -- reads the same entry.
Unknown names are not an error: :func:`resolve` returns ``None`` and callers
fall back to treating the name as a Hugging Face repository, which is what a
user configuring an arbitrary model expects.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Optional, Tuple
@dataclass(frozen=True)
class EmbeddingModel:
"""Everything the application needs to know about one embedding model.
Attributes:
name: Canonical ``EMBEDDINGS_NAME`` value.
dimension: Width of the vectors it produces.
max_input_tokens: The model's own context window, counted in *its*
tokenizer. Used to bound what we send it, never to silently
reshape chunks.
pooling: ``"cls"`` or ``"mean"`` -- how token vectors become one vector.
normalize: Whether outputs are L2-normalised to unit length.
provider: Which runner handles it (``"fastembed"`` or ``"openai"``).
repo: Hugging Face repository holding weights and tokenizer.
onnx_file: Path within ``repo`` to the ONNX graph to run.
aliases: Other spellings of ``name`` accepted from configuration.
"""
name: str
dimension: int
max_input_tokens: int
pooling: str = "mean"
normalize: bool = True
provider: str = "fastembed"
repo: Optional[str] = None
onnx_file: Optional[str] = None
aliases: Tuple[str, ...] = field(default_factory=tuple)
#: The model DocsGPT installed before the granite migration. Kept as the
#: default so an existing deployment that upgrades keeps its index working;
#: new installs are pointed at granite by the setup script and env template.
MPNET = EmbeddingModel(
name="huggingface_sentence-transformers/all-mpnet-base-v2",
dimension=768,
max_input_tokens=384,
pooling="mean",
normalize=True,
repo="sentence-transformers/all-mpnet-base-v2",
onnx_file="onnx/model.onnx",
aliases=(
"huggingface_sentence-transformers-all-mpnet-base-v2",
"sentence-transformers/all-mpnet-base-v2",
"all-mpnet-base-v2",
),
)
#: Default for new installs: same 768 dimensions as mpnet, an 8x wider
#: effective input, and multilingual retrieval.
#:
#: Runs the int8-quantised graph, which is ~4x smaller than the fp32 one
#: (313 MB against 1247 MB) and keeps the image shippable now that both
#: defaults are baked in. It costs some numeric fidelity against fp32 --
#: measured at cosine 0.96 on short texts, with retrieval rank order
#: unchanged -- so it is not bit-comparable to a fp32 granite index.
GRANITE_311M = EmbeddingModel(
name="ibm-granite/granite-embedding-311m-multilingual-r2",
dimension=768,
max_input_tokens=32768,
pooling="cls",
normalize=True,
repo="ibm-granite/granite-embedding-311m-multilingual-r2",
onnx_file="onnx/model_quint8_avx2.onnx",
aliases=("granite-embedding-311m-multilingual-r2", "granite-311m"),
)
#: Smaller granite. Half the vector width, roughly three times the speed.
#: Int8-quantised on the same terms as GRANITE_311M above.
GRANITE_97M = EmbeddingModel(
name="ibm-granite/granite-embedding-97m-multilingual-r2",
dimension=384,
max_input_tokens=32768,
pooling="cls",
normalize=True,
repo="ibm-granite/granite-embedding-97m-multilingual-r2",
onnx_file="onnx/model_quint8_avx2.onnx",
aliases=("granite-embedding-97m-multilingual-r2", "granite-97m"),
)
OPENAI_ADA_002 = EmbeddingModel(
name="openai_text-embedding-ada-002",
dimension=1536,
max_input_tokens=8191,
pooling="mean",
normalize=True,
provider="openai",
repo=None,
onnx_file=None,
aliases=("text-embedding-ada-002",),
)
MODELS: Tuple[EmbeddingModel, ...] = (
MPNET,
GRANITE_311M,
GRANITE_97M,
OPENAI_ADA_002,
)
#: Fallback vector width when the configured model is unknown -- the width
#: every DocsGPT install has used to date, so an unrecognised model does not
#: silently reshape an existing table.
DEFAULT_EMBEDDING_DIMENSION = 768
#: Name that a fresh install should be configured with.
DEFAULT_NEW_INSTALL = GRANITE_311M.name
#: Name that ``settings.EMBEDDINGS_NAME`` defaults to, i.e. what an existing
#: deployment falls back to when it never pinned one.
DEFAULT_LEGACY = MPNET.name
def _index() -> Dict[str, EmbeddingModel]:
"""Build the lookup table of every accepted spelling."""
table: Dict[str, EmbeddingModel] = {}
for model in MODELS:
for key in (model.name, *model.aliases):
table[key.lower()] = model
return table
_LOOKUP = _index()
def resolve(name: Optional[str]) -> Optional[EmbeddingModel]:
"""Return the registry entry for ``name``, or ``None`` when unknown.
Args:
name: A configured ``EMBEDDINGS_NAME`` value, in any accepted spelling.
Returns:
The matching :class:`EmbeddingModel`, or ``None`` for a name the
registry does not describe -- which callers treat as a Hugging Face
repository rather than an error.
"""
if not name:
return None
return _LOOKUP.get(name.strip().lower())
def dimension_for(name: Optional[str]) -> Optional[int]:
"""Vector width for ``name``, or ``None`` when unknown."""
model = resolve(name)
return model.dimension if model else None
def max_input_tokens_for(name: Optional[str]) -> Optional[int]:
"""Context window for ``name``, or ``None`` when unknown."""
model = resolve(name)
return model.max_input_tokens if model else None
def known_names() -> Tuple[str, ...]:
"""Canonical names of every registered model, for error messages."""
return tuple(model.name for model in MODELS)
+236
View File
@@ -0,0 +1,236 @@
import logging
from functools import cached_property
from docsgpt.core.settings import settings
from docsgpt.vectorstore.base import BaseVectorStore
from docsgpt.vectorstore.document_class import Document
def _lazy_import_pymongo():
"""Lazy import of pymongo so installations that don't use the MongoDB vectorstore don't need it."""
try:
import pymongo
except ImportError as exc:
raise ImportError(
"Could not import pymongo python package. "
"Please install it with `pip install pymongo`."
) from exc
return pymongo
class MongoDBVectorStore(BaseVectorStore):
def __init__(
self,
source_id: str = "",
embeddings_key: str = "embeddings",
collection: str = "documents",
index_name: str = "vector_search_index",
text_key: str = "text",
embedding_key: str = "embedding",
database: str = "docsgpt",
):
self._index_name = index_name
self._text_key = text_key
self._embedding_key = embedding_key
self._embeddings_key = embeddings_key
self._mongo_uri = settings.MONGO_URI
self._database_name = database
self._collection_name = collection
self._source_id = source_id.replace("docsgpt/indexes/", "").rstrip("/")
self._embedding = self._get_embeddings(settings.EMBEDDINGS_NAME, embeddings_key)
@cached_property
def _client(self):
pymongo = _lazy_import_pymongo()
return pymongo.MongoClient(self._mongo_uri)
@cached_property
def _database(self):
return self._client[self._database_name]
@cached_property
def _collection(self):
return self._database[self._collection_name]
score_kind = "cosine_similarity"
def search(
self, question, k=2, *args, score_threshold=None, query_vector=None, **kwargs
):
"""Search via Atlas ``$vectorSearch``.
Args:
question: The query string.
k: Maximum number of results.
score_threshold: Optional ``vectorSearchScore`` floor in ``[0, 1]``;
results scoring below it are dropped.
query_vector: Precomputed embedding of ``question``, so a caller
searching several sources embeds the query only once.
"""
return [
doc
for doc, _ in self.search_with_scores(
question,
k,
*args,
score_threshold=score_threshold,
query_vector=query_vector,
**kwargs,
)
]
def search_with_scores(
self, question, k=2, *args, score_threshold=None, query_vector=None, **kwargs
):
"""Same search as :meth:`search`, pairing each hit with its score.
The score is Atlas' ``vectorSearchScore`` — the same quantity
``score_threshold`` is compared against. ``query_vector`` skips the
per-store query embedding when the caller already has one.
"""
if query_vector is None:
query_vector = self._embedding.embed_query(question)
pipeline = [
{
"$vectorSearch": {
"queryVector": query_vector,
"path": self._embedding_key,
"limit": k,
"numCandidates": k * 10,
"index": self._index_name,
"filter": {"source_id": {"$eq": self._source_id}},
}
},
{"$addFields": {"_score": {"$meta": "vectorSearchScore"}}},
]
if score_threshold is not None:
pipeline.append({"$match": {"_score": {"$gte": float(score_threshold)}}})
cursor = self._collection.aggregate(pipeline)
results = []
for doc in cursor:
text = doc[self._text_key]
doc.pop("_id")
doc.pop(self._text_key)
doc.pop(self._embedding_key)
score = doc.pop("_score", None)
metadata = doc
results.append(
(Document(text, metadata), None if score is None else float(score))
)
return results
def _insert_texts(self, texts, metadatas):
if not texts:
return []
embeddings = self._embedding.embed_documents(texts)
to_insert = [
{self._text_key: t, self._embedding_key: embedding, **m}
for t, m, embedding in zip(texts, metadatas, embeddings)
]
insert_result = self._collection.insert_many(to_insert)
return insert_result.inserted_ids
def add_texts(
self,
texts,
metadatas=None,
ids=None,
refresh_indices=True,
create_index_if_not_exists=True,
bulk_kwargs=None,
**kwargs,
):
# dims = self._embedding.client[1].word_embedding_dimension
# # check if index exists
# if create_index_if_not_exists:
# # check if index exists
# info = self._collection.index_information()
# if self._index_name not in info:
# index_mongo = {
# "fields": [{
# "type": "vector",
# "path": self._embedding_key,
# "numDimensions": dims,
# "similarity": "cosine",
# },
# {
# "type": "filter",
# "path": "store"
# }]
# }
# self._collection.create_index(self._index_name, index_mongo)
batch_size = 100
_metadatas = metadatas or ({} for _ in texts)
texts_batch = []
metadatas_batch = []
result_ids = []
for i, (text, metadata) in enumerate(zip(texts, _metadatas)):
texts_batch.append(text)
metadatas_batch.append(metadata)
if (i + 1) % batch_size == 0:
result_ids.extend(self._insert_texts(texts_batch, metadatas_batch))
texts_batch = []
metadatas_batch = []
if texts_batch:
result_ids.extend(self._insert_texts(texts_batch, metadatas_batch))
return result_ids
def delete_index(self, *args, **kwargs):
self._collection.delete_many({"source_id": self._source_id})
def get_chunks(self):
try:
chunks = []
cursor = self._collection.find({"source_id": self._source_id})
for doc in cursor:
doc_id = str(doc.get("_id"))
text = doc.get(self._text_key)
metadata = {
k: v
for k, v in doc.items()
if k
not in ["_id", self._text_key, self._embedding_key, "source_id"]
}
if text:
chunks.append(
{"doc_id": doc_id, "text": text, "metadata": metadata}
)
return chunks
except Exception as e:
logging.error(f"Error getting chunks: {e}", exc_info=True)
return []
def add_chunk(self, text, metadata=None):
metadata = metadata or {}
embeddings = self._embedding.embed_documents([text])
if not embeddings:
raise ValueError("Could not generate embedding for chunk")
chunk_data = {
self._text_key: text,
self._embedding_key: embeddings[0],
"source_id": self._source_id,
**metadata,
}
result = self._collection.insert_one(chunk_data)
return str(result.inserted_id)
def delete_chunk(self, chunk_id):
try:
from bson.objectid import ObjectId
object_id = ObjectId(chunk_id)
result = self._collection.delete_one({"_id": object_id})
return result.deleted_count > 0
except Exception as e:
logging.error(f"Error deleting chunk: {e}", exc_info=True)
return False
+135
View File
@@ -0,0 +1,135 @@
"""Process-wide psycopg connection pools keyed by DSN.
Every component that talks to the pgvector database — the vector store and the
GraphRAG graph store — checks out of the *same* pool for a given DSN, so a
request that touches N sources costs pooled checkouts instead of N fresh TCP +
TLS + auth handshakes. Keeping the registry in its own module (rather than in
``pgvector``) is what lets ``docsgpt.graphrag.store`` join it without
importing the vector store.
The boot-time schema hook deliberately does not use this: pools are built
lazily on first checkout, i.e. after the fork in a Celery/gunicorn worker, so a
preloading parent never hands a live socket to a child.
"""
from __future__ import annotations
import logging
import threading
from typing import Any, Dict
# Seconds a caller waits for a free pooled connection before giving up.
POOL_TIMEOUT_SECONDS = 30.0
# Physical connections one process may hold per DSN. Lives here, not in either
# store: ``pool_for`` keys its registry by DSN and applies ``max_size`` only on
# creation, so two stores disagreeing about the default would make the
# effective pool size depend on which of them touched the DSN first.
DEFAULT_POOL_MAX_SIZE = 8
# dsn -> psycopg_pool.ConnectionPool, one per process.
_POOLS: Dict[str, Any] = {}
_POOLS_LOCK = threading.Lock()
def configure_pooled_connection(conn) -> None:
"""Register pgvector's type adapters once per physical pooled connection.
Tolerates a database where ``CREATE EXTENSION vector`` has not run yet:
``register_vector`` looks the type up in the catalog and raises when it is
absent, which would leave the pool unable to open the very connection the
schema bootstrap needs. The write path re-registers once the type exists.
"""
from pgvector.psycopg import register_vector
try:
register_vector(conn)
except Exception as e:
logging.debug("pgvector types not registered yet: %s", e)
def resolve_pool_max_size() -> int:
"""Pool size from settings, defensively — 0 means one direct connection.
Returns:
int: ``PGVECTOR_POOL_MAX_SIZE`` when it is a non-negative, non-bool
int, else :data:`DEFAULT_POOL_MAX_SIZE`. The unit suite replaces
``settings`` with a MagicMock, whose attributes must never become
a pool size.
"""
from docsgpt.core.settings import settings
value = getattr(settings, "PGVECTOR_POOL_MAX_SIZE", DEFAULT_POOL_MAX_SIZE)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
return value
return DEFAULT_POOL_MAX_SIZE
def pool_for(dsn: str, max_size: int):
"""Return this process's connection pool for ``dsn``, creating it lazily.
The pool is keyed by DSN alone and is built on first checkout — i.e. after
the fork in a Celery/gunicorn worker, so a preloading parent never hands a
live socket to a child. The boot-time schema hook must therefore use a
direct connection rather than this pool.
Args:
dsn: libpq connection string; the pool's only identity.
max_size: Upper bound on physical connections, used on creation only.
Returns:
psycopg_pool.ConnectionPool: The shared pool for ``dsn``.
"""
pool = _POOLS.get(dsn)
if pool is not None:
return pool
with _POOLS_LOCK:
pool = _POOLS.get(dsn)
if pool is None:
from psycopg_pool import ConnectionPool
pool = ConnectionPool(
conninfo=dsn,
min_size=1,
max_size=max_size,
open=True,
timeout=POOL_TIMEOUT_SECONDS,
name="docsgpt-pgvector",
configure=configure_pooled_connection,
check=ConnectionPool.check_connection,
)
_POOLS[dsn] = pool
return pool
def release(dsn: str, conn, pooled: bool) -> None:
"""Hand ``conn`` back: to ``dsn``'s pool when pooled, else close it.
A pooled connection is rolled back first when it is still in a transaction,
so the next borrower gets a clean session. Never raises — releasing is
always best-effort cleanup on a path that is often a destructor.
Args:
dsn: Connection string the connection was opened for.
conn: The psycopg connection being released; ``None`` is a no-op.
pooled: Whether the connection came from :func:`pool_for`.
"""
if conn is None:
return
try:
if pooled:
try:
if conn.info.transaction_status.name != "IDLE":
conn.rollback()
except Exception:
# Connection already broken; the pool discards it.
pass
pool = _POOLS.get(dsn)
if pool is not None:
pool.putconn(conn)
elif not conn.closed:
conn.close()
elif not conn.closed:
conn.close()
except Exception as e:
logging.debug("Error releasing pooled connection: %s", e)
+773
View File
@@ -0,0 +1,773 @@
import logging
import math
import re
from typing import List, Optional, Any, Dict
from psycopg.types.json import Jsonb
from docsgpt.core.settings import settings
from docsgpt.vectorstore import pgconn
from docsgpt.vectorstore.base import BaseVectorStore
from docsgpt.vectorstore.document_class import Document
# table name -> IVFFlat ``lists`` (None when the table has no such index)
_IVFFLAT_LISTS_CACHE: Dict[str, Optional[int]] = {}
DEFAULT_EMBEDDING_DIM = 768
# Advisory-lock key shared with the boot hook so concurrent workers serialize DDL.
SCHEMA_LOCK_KEY = "docsgpt:vectors:ddl"
# The connection pools moved to ``docsgpt.vectorstore.pgconn`` so the graph
# store can share them without importing this module. Only the names something
# actually reaches through *this* module stay bound here — same objects, not
# copies: callers and tests read ``pgvector._POOLS``, patch
# ``pgvector._pool_for``, and assert on ``pgvector.DEFAULT_POOL_MAX_SIZE``.
# Anything else belongs to ``pgconn`` alone; re-exporting it here would just be
# a second name to keep in sync.
DEFAULT_POOL_MAX_SIZE = pgconn.DEFAULT_POOL_MAX_SIZE
_POOLS = pgconn._POOLS
_pool_for = pgconn.pool_for
class PGVectorStore(BaseVectorStore):
def __init__(
self,
source_id: str = "",
embeddings_key: str = "embeddings",
table_name: str = "documents",
decoded_token: Optional[str] = None,
vector_column: str = "embedding",
text_column: str = "text",
metadata_column: str = "metadata",
connection_string: str = None,
):
super().__init__()
# Store the source_id for use in add_chunk
self._source_id = str(source_id).replace("docsgpt/indexes/", "").rstrip("/")
self._embeddings_key = embeddings_key
self._table_name = table_name
self._vector_column = vector_column
self._text_column = text_column
self._metadata_column = metadata_column
self._embedding = self._get_embeddings(settings.EMBEDDINGS_NAME, embeddings_key)
# Use provided connection string or fall back to settings.
# If PGVECTOR_CONNECTION_STRING is not set but POSTGRES_URI is,
# reuse the same cluster — normalize from SQLAlchemy dialect to libpq form.
self._connection_string = connection_string or getattr(settings, 'PGVECTOR_CONNECTION_STRING', None)
if not self._connection_string and getattr(settings, 'POSTGRES_URI', None):
from docsgpt.core.db_uri import normalize_pgvector_connection_string
self._connection_string = normalize_pgvector_connection_string(settings.POSTGRES_URI)
if not self._connection_string:
raise ValueError(
"PostgreSQL connection string is required. "
"Set PGVECTOR_CONNECTION_STRING or POSTGRES_URI in settings, "
"or pass connection_string parameter."
)
try:
import psycopg
from pgvector.psycopg import register_vector
except ImportError:
raise ImportError(
"Could not import required packages. "
"Please install with `pip install 'psycopg[binary,pool]' pgvector`."
)
self._psycopg = psycopg
self._register_vector = register_vector
self._connection = None
self._pooled = False
self._pool_max_size = self._resolve_pool_max_size()
# No DDL here. The retriever builds one store per source per request, so
# construction must stay free: schema is owned at boot by
# ``ensure_vector_schema``, and the write path below re-checks it once as
# a safety net for processes that never ran the boot hook.
self._schema_ensured = False
# Shared with ``GraphStore`` via pgconn so the two can never disagree.
_resolve_pool_max_size = staticmethod(pgconn.resolve_pool_max_size)
def _get_connection(self):
"""Get or create this store's connection, pooled unless pooling is off."""
if self._connection is not None and self._connection.closed:
# Hand the dead connection back before replacing it: psycopg_pool
# never reclaims a checkout that is not returned, so dropping it
# costs the pool a slot for the life of the process.
self.close()
if self._connection is None:
if self._pool_max_size > 0:
self._connection = _pool_for(
self._connection_string, self._pool_max_size
).getconn()
self._pooled = True
else:
self._connection = self._psycopg.connect(self._connection_string)
# Register pgvector types
self._register_pgvector_types(self._connection)
self._pooled = False
self._apply_probes_once(self._connection)
return self._connection
def _register_pgvector_types(self, conn) -> None:
"""Register pgvector's adapters, tolerating a not-yet-created extension.
Kept as a method rather than calling :func:`pgconn.configure_pooled_connection`
directly because ``self._register_vector`` is the seam the tests patch.
"""
try:
self._register_vector(conn)
except Exception as e:
logging.debug("pgvector types not registered yet: %s", e)
def _apply_probes_once(self, conn) -> None:
"""Set ``ivfflat.probes`` once per physical connection and table.
A pooled connection outlives the store that checked it out, and the
``SET`` is session-level, so the probe lookup must not repeat on every
checkout. The marker lives on the connection object itself because that
is what the pool recycles.
"""
if getattr(conn, "_docsgpt_probes_table", None) == self._table_name:
return
self._apply_ivfflat_probes(conn)
try:
# ``SET`` (not SET LOCAL) is undone by a rollback, so make it stick
# before any query runs on this connection.
conn.commit()
conn._docsgpt_probes_table = self._table_name
except Exception as e: # never let session tuning break a query
logging.debug("Could not persist ivfflat.probes: %s", e)
def _ivfflat_lists(self, conn) -> Optional[int]:
"""Return the ``lists`` value of this table's IVFFlat index, if any.
Cached per table because it only changes when the index is rebuilt.
"""
if self._table_name in _IVFFLAT_LISTS_CACHE:
return _IVFFLAT_LISTS_CACHE[self._table_name]
lists = None
try:
with conn.cursor() as cursor:
cursor.execute(
"SELECT indexdef FROM pg_indexes "
"WHERE tablename = %s AND indexdef ILIKE %s",
(self._table_name, "%ivfflat%"),
)
row = cursor.fetchone()
if row:
match = re.search(r"lists\s*=\s*'?(\d+)", row[0])
if match:
lists = int(match.group(1))
except Exception as e: # index introspection must never break search
logging.debug("Could not read IVFFlat lists for %s: %s", self._table_name, e)
# Only cache a hit: an index may be created after this process booted,
# and caching None would keep probes unset for the process's lifetime.
if lists:
_IVFFLAT_LISTS_CACHE[self._table_name] = lists
return lists
def _apply_ivfflat_probes(self, conn) -> None:
"""Raise ``ivfflat.probes`` so a filtered search cannot come back empty.
An IVFFlat index partitions vectors into ``lists`` clusters and the
default ``probes = 1`` scans exactly one of them. Our searches filter by
``source_id`` *after* the index picks candidates, so with one probe the
candidates frequently all belong to other sources and the query returns
nothing — retrieval reports zero documents and the model answers with no
source material, silently. ``sqrt(lists)`` is pgvector's own recall
guidance and costs a proportional amount of scan.
"""
probes = settings.PGVECTOR_IVFFLAT_PROBES
if probes is None:
lists = self._ivfflat_lists(conn)
if not lists:
return
probes = max(1, math.isqrt(lists))
try:
with conn.cursor() as cursor:
cursor.execute(f"SET ivfflat.probes = {int(probes)};")
except Exception as e: # older pgvector / no index — search still works
logging.debug("Could not set ivfflat.probes: %s", e)
@staticmethod
def create_schema(
conn,
*,
table_name: str = "documents",
vector_column: str = "embedding",
text_column: str = "text",
metadata_column: str = "metadata",
dimension: int = DEFAULT_EMBEDDING_DIM,
) -> None:
"""Create the extension, table and indexes on ``conn`` without committing.
Shared by the boot hook (``ensure_vector_schema``) and the store's own
write-path safety net; the caller owns the transaction.
Args:
conn: Open psycopg connection.
table_name: Documents table to create.
vector_column: Embedding column name.
text_column: Chunk-text column name.
metadata_column: JSONB metadata column name.
dimension: Width of the embedding vectors.
"""
cursor = conn.cursor()
try:
# Enable pgvector extension
cursor.execute("CREATE EXTENSION IF NOT EXISTS vector;")
# Create table with vector column
create_table_query = f"""
CREATE TABLE IF NOT EXISTS {table_name} (
id SERIAL PRIMARY KEY,
{text_column} TEXT NOT NULL,
{vector_column} vector({dimension}),
{metadata_column} JSONB,
source_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
cursor.execute(create_table_query)
# NO vector index is created here, deliberately.
#
# This runs when the table is first created, i.e. on an EMPTY
# table. IVFFlat computes its cluster centroids at build time, so
# an index built on no rows gets random centroids and never
# recovers — measured recall 0.06 once 5k rows are added. Combined
# with our ``WHERE source_id = ...`` post-filter, that returned
# ZERO rows for sources with hundreds of chunks: retrieval reported
# no documents and the model answered from nothing, silently.
# pgvector only warns when sampled_rows < lists, so the common bad
# case is silent.
#
# Exact search is correct and fast well past the sizes most
# deployments ever reach. Add an index deliberately, sized to real
# data, once a corpus is large enough to need one.
# Create index for source_id filtering
source_index_query = f"""
CREATE INDEX IF NOT EXISTS {table_name}_source_id_idx
ON {table_name} (source_id);
"""
cursor.execute(source_index_query)
# Functional GIN index backing keyword_search full-text queries.
fts_index_query = f"""
CREATE INDEX IF NOT EXISTS {table_name}_text_fts_idx
ON {table_name} USING gin(to_tsvector('english', {text_column}));
"""
cursor.execute(fts_index_query)
finally:
cursor.close()
@staticmethod
def table_dimension(
conn, table_name: str = "documents", vector_column: str = "embedding"
) -> Optional[int]:
"""Width declared by the table's vector column, or ``None`` if unknown.
``None`` means the table is absent or the column is not a ``vector``.
"""
cursor = conn.cursor()
try:
cursor.execute("SELECT to_regclass(%s);", (table_name,))
row = cursor.fetchone()
if not row or row[0] is None:
return None
cursor.execute(
"SELECT format_type(a.atttypid, a.atttypmod) FROM pg_attribute a "
"WHERE a.attrelid = %s::regclass AND a.attname = %s "
"AND NOT a.attisdropped",
(table_name, vector_column),
)
row = cursor.fetchone()
if not row or not row[0]:
return None
match = re.search(r"vector\((\d+)\)", str(row[0]))
return int(match.group(1)) if match else None
finally:
cursor.close()
def _ensure_table_exists(self) -> None:
"""Create this store's schema under an advisory lock, then commit."""
conn = self._get_connection()
try:
cursor = conn.cursor()
try:
cursor.execute(
"SELECT pg_advisory_xact_lock(hashtext(%s));", (SCHEMA_LOCK_KEY,)
)
finally:
cursor.close()
self.create_schema(
conn,
table_name=self._table_name,
vector_column=self._vector_column,
text_column=self._text_column,
metadata_column=self._metadata_column,
dimension=getattr(self._embedding, "dimension", DEFAULT_EMBEDDING_DIM),
)
conn.commit()
# The extension may have just been created; pick up its adapters so
# the insert that follows can bind a vector.
self._register_pgvector_types(conn)
except Exception as e:
conn.rollback()
logging.error(f"Error creating table: {e}")
raise
def _ensure_schema_once(self) -> None:
"""Create the schema on this instance's first write, at most once.
Readers never create schema — boot owns it. This is the safety net for a
process that never ran the boot hook (scripts, tests, the first ingest on
a brand-new deployment); a read against a missing table surfaces
psycopg's own error, like every other store.
"""
if self._schema_ensured:
return
self._ensure_table_exists()
self._schema_ensured = True
score_kind = "cosine_similarity"
def search(
self,
question: str,
k: int = 2,
*args,
score_threshold: float = None,
query_vector: Optional[List[float]] = None,
**kwargs,
) -> List[Document]:
"""Search for similar documents using vector similarity.
Args:
question: The query string.
k: Maximum number of results.
score_threshold: Optional cosine-similarity floor in ``[0, 1]``.
Cosine distance = ``1 - similarity``; rows with similarity below
the threshold (distance above ``1 - threshold``) are dropped.
query_vector: Precomputed embedding of ``question``. Supplied by a
caller searching several sources with one query, so the query is
embedded once instead of once per store.
"""
return [
doc
for doc, _ in self.search_with_scores(
question,
k,
*args,
score_threshold=score_threshold,
query_vector=query_vector,
**kwargs,
)
]
def _nearest_sql(self) -> str:
"""Build the nearest-neighbour SELECT for this store's table.
Identifiers (table/column names) come from this instance's
construction, never from a request, so they cannot be interpolated by a
caller; the query *values* are always bound parameters.
"""
return (
f"SELECT {self._text_column}, {self._metadata_column}, "
f"({self._vector_column} <=> %s::vector) AS distance "
f"FROM {self._table_name} "
"WHERE source_id = %s "
f"ORDER BY {self._vector_column} <=> %s::vector "
"LIMIT %s;"
)
def _exact_search(self, cursor, query_vector, k: int, ann_results: list) -> list:
"""Redo a short indexed search exactly, when the source has more rows.
Args:
cursor: Open cursor on the search connection.
query_vector: The embedded query.
k: Requested top-k.
ann_results: What the indexed search returned.
Returns:
list: Exact rows when the indexed search under-returned, otherwise
``ann_results`` unchanged.
"""
try:
cursor.execute(
f"SELECT count(*) FROM {self._table_name} WHERE source_id = %s",
(self._source_id,),
)
available = cursor.fetchone()[0]
if len(ann_results) >= min(k, available):
return ann_results
cursor.execute("SET LOCAL enable_indexscan = off;")
cursor.execute("SET LOCAL enable_bitmapscan = off;")
cursor.execute(
self._nearest_sql(),
(query_vector, self._source_id, query_vector, k),
)
exact = cursor.fetchall()
if len(exact) > len(ann_results):
logging.info(
"Vector index under-returned for source %s (%d of %d); "
"used exact search instead.",
self._source_id, len(ann_results), min(k, available),
)
return exact
return ann_results
except Exception as e:
# Never let the safety net take down the search it is protecting —
# but roll back, or the aborted transaction poisons the connection
# and every later search on this store returns nothing.
logging.warning("Exact-search fallback failed: %s", e)
try:
cursor.connection.rollback()
except Exception:
# Connection already gone; nothing left to roll back.
pass
return ann_results
finally:
try:
# RESET, not "= on": a deployment may disable these globally.
cursor.execute("RESET enable_indexscan;")
cursor.execute("RESET enable_bitmapscan;")
except Exception:
# Cursor/transaction already unusable; the settings die with it.
pass
def search_with_scores(
self,
question: str,
k: int = 2,
*args,
score_threshold: float = None,
query_vector: Optional[List[float]] = None,
**kwargs,
) -> List[tuple]:
"""Same search as :meth:`search`, pairing each hit with its similarity.
The score is the cosine similarity (``1 - cosine_distance``) — the exact
quantity ``score_threshold`` is compared against, so a caller can read a
result's score and pick a threshold from it directly.
Args:
query_vector: Precomputed embedding of ``question``; when given the
store skips embedding the query itself.
"""
if query_vector is None:
query_vector = self._embedding.embed_query(question)
conn = self._get_connection()
cursor = conn.cursor()
try:
# Use cosine distance for similarity search with proper vector formatting
search_query = self._nearest_sql()
cursor.execute(search_query, (query_vector, self._source_id, query_vector, k))
results = cursor.fetchall()
# An ANN index filters ``source_id`` *after* choosing candidates, so
# a source holding a small share of the table can come back short —
# or empty — no matter how the index is tuned. Raising probes /
# ef_search only moves that threshold. When the result looks short,
# redo the query exactly: correctness is worth one extra scan, and a
# silent empty result reaches the model as "no documents exist".
if len(results) < k:
results = self._exact_search(cursor, query_vector, k, results)
max_distance = None
if score_threshold is not None:
max_distance = 1.0 - float(score_threshold)
documents = []
for text, metadata, distance in results:
if max_distance is not None and distance is not None and distance > max_distance:
continue
metadata = metadata or {}
score = None if distance is None else 1.0 - float(distance)
documents.append(
(Document(page_content=text, metadata=metadata), score)
)
# End the read transaction. On a persistent (pooled) connection an
# uncommitted SELECT leaves the backend "idle in transaction",
# pinning a snapshot and blocking VACUUM for as long as the store
# lives.
conn.commit()
return documents
except Exception as e:
logging.error(f"Error searching documents: {e}", exc_info=True)
try:
conn.rollback()
except Exception:
# Connection already gone; nothing left to roll back.
pass
return []
finally:
cursor.close()
def keyword_search(self, question: str, k: int = 10) -> List[Document]:
"""Full-text keyword search using Postgres ``websearch_to_tsquery``.
Returns the same ``Document`` shape as :meth:`search`. The question is
bound as a query parameter (never interpolated) to prevent injection.
"""
conn = self._get_connection()
cursor = conn.cursor()
try:
keyword_query = f"""
SELECT {self._text_column}, {self._metadata_column},
ts_rank(
to_tsvector('english', {self._text_column}),
websearch_to_tsquery('english', %s)
) AS rank
FROM {self._table_name}
WHERE source_id = %s
AND to_tsvector('english', {self._text_column})
@@ websearch_to_tsquery('english', %s)
ORDER BY rank DESC
LIMIT %s;
"""
cursor.execute(
keyword_query, (question, self._source_id, question, k)
)
results = cursor.fetchall()
documents = []
for text, metadata, _rank in results:
metadata = metadata or {}
documents.append(Document(page_content=text, metadata=metadata))
conn.commit()
return documents
except Exception as e:
logging.error(f"Error in keyword search: {e}", exc_info=True)
try:
conn.rollback()
except Exception:
# Connection already gone; nothing left to roll back.
pass
return []
finally:
cursor.close()
def add_texts(
self,
texts: List[str],
metadatas: Optional[List[Dict[str, Any]]] = None,
*args,
**kwargs,
) -> List[str]:
"""Add texts with their embeddings to the vector store"""
if not texts:
return []
embeddings = self._embedding.embed_documents(texts)
metadatas = metadatas or [{}] * len(texts)
self._ensure_schema_once()
conn = self._get_connection()
cursor = conn.cursor()
try:
insert_query = f"""
INSERT INTO {self._table_name} ({self._text_column}, {self._vector_column}, {self._metadata_column}, source_id)
VALUES (%s, %s, %s, %s)
RETURNING id;
"""
inserted_ids = []
for text, embedding, metadata in zip(texts, embeddings, metadatas):
cursor.execute(
insert_query,
(text, embedding, Jsonb(metadata), self._source_id)
)
inserted_id = cursor.fetchone()[0]
inserted_ids.append(str(inserted_id))
conn.commit()
return inserted_ids
except Exception as e:
conn.rollback()
logging.error(f"Error adding texts: {e}")
raise
finally:
cursor.close()
def delete_index(self, *args, **kwargs):
"""Delete all documents for this source_id"""
conn = self._get_connection()
cursor = conn.cursor()
try:
delete_query = f"DELETE FROM {self._table_name} WHERE source_id = %s;"
cursor.execute(delete_query, (self._source_id,))
conn.commit()
except Exception as e:
conn.rollback()
logging.error(f"Error deleting index: {e}")
raise
finally:
cursor.close()
def save_local(self, *args, **kwargs):
"""No-op for PostgreSQL - data is already persisted"""
pass
def get_chunks(self) -> List[Dict[str, Any]]:
"""Get all chunks for this source_id"""
conn = self._get_connection()
cursor = conn.cursor()
try:
select_query = f"""
SELECT id, {self._text_column}, {self._metadata_column}
FROM {self._table_name}
WHERE source_id = %s;
"""
cursor.execute(select_query, (self._source_id,))
results = cursor.fetchall()
chunks = []
for doc_id, text, metadata in results:
chunks.append({
"doc_id": str(doc_id),
"text": text,
"metadata": metadata or {}
})
conn.commit()
return chunks
except Exception as e:
logging.error(f"Error getting chunks: {e}")
try:
conn.rollback()
except Exception:
# Connection already gone; nothing left to roll back.
pass
return []
finally:
cursor.close()
def add_chunk(self, text: str, metadata: Optional[Dict[str, Any]] = None) -> str:
"""Add a single chunk to the vector store"""
metadata = metadata or {}
final_metadata = metadata.copy()
final_metadata["source_id"] = self._source_id
embeddings = self._embedding.embed_documents([text])
if not embeddings:
raise ValueError("Could not generate embedding for chunk")
self._ensure_schema_once()
conn = self._get_connection()
cursor = conn.cursor()
try:
insert_query = f"""
INSERT INTO {self._table_name} ({self._text_column}, {self._vector_column}, {self._metadata_column}, source_id)
VALUES (%s, %s, %s, %s)
RETURNING id;
"""
cursor.execute(
insert_query,
(text, embeddings[0], Jsonb(final_metadata), self._source_id)
)
inserted_id = cursor.fetchone()[0]
conn.commit()
return str(inserted_id)
except Exception as e:
conn.rollback()
logging.error(f"Error adding chunk: {e}")
raise
finally:
cursor.close()
def delete_chunk(self, chunk_id: str) -> bool:
"""Delete a specific chunk by its ID"""
conn = self._get_connection()
cursor = conn.cursor()
try:
delete_query = f"DELETE FROM {self._table_name} WHERE id = %s AND source_id = %s;"
cursor.execute(delete_query, (int(chunk_id), self._source_id))
deleted_count = cursor.rowcount
conn.commit()
return deleted_count > 0
except Exception as e:
conn.rollback()
logging.error(f"Error deleting chunk: {e}")
return False
finally:
cursor.close()
def delete_chunks_by_source_path(self, path: str) -> int:
"""Delete this source's chunks whose ``metadata.source`` equals ``path``.
One targeted statement instead of the base loop+scan. The path is bound
as a query parameter (never interpolated); only the internal table name
is f-string interpolated. Returns the number of rows deleted.
"""
conn = self._get_connection()
cursor = conn.cursor()
try:
delete_query = (
f"DELETE FROM {self._table_name} "
f"WHERE source_id = %s AND {self._metadata_column}->>'source' = %s;"
)
cursor.execute(delete_query, (self._source_id, path))
deleted_count = cursor.rowcount
conn.commit()
return deleted_count
except Exception as e:
conn.rollback()
logging.error(f"Error deleting chunks by source path: {e}")
raise
finally:
cursor.close()
def close(self) -> None:
"""Release this store's connection: back to the pool, or closed outright.
A pooled connection is rolled back first when it is still in a
transaction, so the next borrower gets a clean session.
"""
conn = getattr(self, "_connection", None)
if conn is None:
return
self._connection = None
pgconn.release(self._connection_string, conn, getattr(self, "_pooled", False))
def __del__(self):
"""Release the connection when the object is destroyed. Never raises."""
try:
self.close()
except Exception:
# Interpreter teardown can null out module globals; never raise here.
pass
+219
View File
@@ -0,0 +1,219 @@
import logging
import uuid
from typing import Any, Dict, List, Optional, Tuple
from docsgpt.core.settings import settings
from docsgpt.vectorstore.base import BaseVectorStore
from docsgpt.vectorstore.document_class import Document
class QdrantStore(BaseVectorStore):
"""Vector store backed by Qdrant through the native ``qdrant-client``.
Points carry a ``page_content`` payload plus a nested ``metadata`` object;
every read is filtered to the store's ``source_id`` so one collection can
hold many sources.
"""
score_kind = "cosine_similarity"
def __init__(self, source_id: str = "", embeddings_key: str = "embeddings"):
super().__init__()
from qdrant_client import QdrantClient, models
self._models = models
self._source_id = str(source_id).replace("docsgpt/indexes/", "").rstrip("/")
self._collection = settings.QDRANT_COLLECTION_NAME
self._embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, embeddings_key)
self._filter = models.Filter(
must=[
models.FieldCondition(
key="metadata.source_id",
match=models.MatchValue(value=self._source_id),
)
]
)
self._client = QdrantClient(**self._client_kwargs())
self._ensure_collection()
@staticmethod
def _client_kwargs() -> Dict[str, Any]:
"""Build ``QdrantClient`` kwargs, dropping unset optional settings.
``location``, ``url`` and ``path`` are mutually exclusive in
qdrant-client, so only the ones actually configured are passed.
"""
kwargs: Dict[str, Any] = {
"prefer_grpc": settings.QDRANT_PREFER_GRPC,
"grpc_port": settings.QDRANT_GRPC_PORT,
}
optional = {
"location": settings.QDRANT_LOCATION,
"url": settings.QDRANT_URL,
"host": settings.QDRANT_HOST,
"port": settings.QDRANT_PORT,
"https": settings.QDRANT_HTTPS,
"api_key": settings.QDRANT_API_KEY,
"prefix": settings.QDRANT_PREFIX,
"timeout": settings.QDRANT_TIMEOUT,
"path": settings.QDRANT_PATH,
}
kwargs.update({k: v for k, v in optional.items() if v is not None})
return kwargs
def _dimension(self) -> int:
"""Resolve the embedding width, probing the model when unset."""
dimension = getattr(self._embeddings, "dimension", None)
if not dimension:
dimension = len(self._embeddings.embed_query("dimension probe"))
return dimension
def _ensure_collection(self) -> None:
"""Create the collection and the source_id payload index if missing."""
models = self._models
try:
if not self._client.collection_exists(self._collection):
self._client.create_collection(
collection_name=self._collection,
vectors_config=models.VectorParams(
size=self._dimension(),
distance=models.Distance[settings.QDRANT_DISTANCE_FUNC.upper()],
),
)
self._client.create_payload_index(
collection_name=self._collection,
field_name="metadata.source_id",
field_schema=models.PayloadSchemaType.KEYWORD,
)
except Exception as e:
# A concurrent worker may have created either object first.
if "already exists" not in str(e).lower():
logging.warning("Qdrant collection setup: %s", e)
@staticmethod
def _to_document(payload: Dict[str, Any]) -> Document:
payload = payload or {}
return Document(
page_content=payload.get("page_content", ""),
metadata=payload.get("metadata") or {},
)
def search(self, question: str, k: int = 2, *args, **kwargs) -> List[Document]:
"""Return the ``k`` nearest chunks for ``question``."""
return [doc for doc, _ in self.search_with_scores(question, k, *args, **kwargs)]
def search_with_scores(
self,
question: str,
k: int = 2,
*args,
score_threshold: Optional[float] = None,
query_vector: Optional[List[float]] = None,
**kwargs,
) -> List[Tuple[Document, float]]:
"""Search, pairing each hit with its cosine similarity.
Args:
query_vector: Precomputed embedding of ``question``; when given the
store skips embedding the query itself.
"""
if query_vector is None:
query_vector = self._embeddings.embed_query(question)
hits = self._client.query_points(
collection_name=self._collection,
query=query_vector,
query_filter=self._filter,
limit=k,
with_payload=True,
score_threshold=score_threshold,
).points
return [(self._to_document(hit.payload), hit.score) for hit in hits]
def add_texts(
self,
texts: List[str],
metadatas: Optional[List[dict]] = None,
*args,
**kwargs,
) -> List[str]:
"""Embed and upsert ``texts``, stamping each with the source id."""
texts = list(texts)
if not texts:
return []
metadatas = list(metadatas or [{} for _ in texts])
vectors = self._embeddings.embed_documents(texts)
points, ids = [], []
for text, metadata, vector in zip(texts, metadatas, vectors):
point_id = str(uuid.uuid4())
payload_metadata = dict(metadata or {})
payload_metadata["source_id"] = self._source_id
ids.append(point_id)
points.append(
self._models.PointStruct(
id=point_id,
vector=vector,
payload={"page_content": text, "metadata": payload_metadata},
)
)
self._client.upsert(collection_name=self._collection, points=points)
return ids
def save_local(self, *args, **kwargs):
"""No-op: Qdrant persists server-side."""
pass
def delete_index(self, *args, **kwargs):
"""Delete every point belonging to this source."""
return self._client.delete(
collection_name=self._collection,
points_selector=self._models.FilterSelector(filter=self._filter),
)
def get_chunks(self) -> List[Dict[str, Any]]:
"""Return every chunk stored for this source."""
chunks: List[Dict[str, Any]] = []
offset = None
try:
while True:
records, offset = self._client.scroll(
collection_name=self._collection,
scroll_filter=self._filter,
limit=100,
with_payload=True,
with_vectors=False,
offset=offset,
)
for record in records:
payload = record.payload or {}
chunks.append(
{
"doc_id": str(record.id),
"text": payload.get("page_content"),
"metadata": payload.get("metadata") or {},
}
)
if offset is None:
break
return chunks
except Exception as e:
logging.error("Error getting chunks: %s", e, exc_info=True)
return []
def add_chunk(self, text: str, metadata: Optional[Dict[str, Any]] = None) -> str:
"""Add one chunk and return its id."""
ids = self.add_texts([text], [metadata or {}])
return ids[0]
def delete_chunk(self, chunk_id: str) -> bool:
"""Delete a single chunk by id."""
try:
self._client.delete(
collection_name=self._collection,
points_selector=self._models.PointIdsList(points=[chunk_id]),
)
return True
except Exception as e:
logging.error("Error deleting chunk: %s", e, exc_info=True)
return False
+24
View File
@@ -0,0 +1,24 @@
from docsgpt.vectorstore.faiss import FaissStore
from docsgpt.vectorstore.elasticsearch import ElasticsearchStore
from docsgpt.vectorstore.milvus import MilvusStore
from docsgpt.vectorstore.mongodb import MongoDBVectorStore
from docsgpt.vectorstore.qdrant import QdrantStore
from docsgpt.vectorstore.pgvector import PGVectorStore
class VectorCreator:
vectorstores = {
"faiss": FaissStore,
"elasticsearch": ElasticsearchStore,
"mongodb": MongoDBVectorStore,
"qdrant": QdrantStore,
"milvus": MilvusStore,
"pgvector": PGVectorStore
}
@classmethod
def create_vectorstore(cls, type, *args, **kwargs):
vectorstore_class = cls.vectorstores.get(type.lower())
if not vectorstore_class:
raise ValueError(f"No vectorstore class found for type {type}")
return vectorstore_class(*args, **kwargs)