kbguard 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kbguard/__init__.py +1 -0
- kbguard/adapters/__init__.py +0 -0
- kbguard/adapters/base.py +70 -0
- kbguard/adapters/bridge_langchain.py +151 -0
- kbguard/adapters/chroma.py +125 -0
- kbguard/adapters/pgvector.py +191 -0
- kbguard/adapters/pinecone.py +153 -0
- kbguard/adapters/qdrant.py +134 -0
- kbguard/checks/__init__.py +0 -0
- kbguard/checks/base.py +61 -0
- kbguard/checks/chunking_damage.py +269 -0
- kbguard/checks/contradictions.py +358 -0
- kbguard/checks/duplicates.py +204 -0
- kbguard/checks/stale_versions.py +116 -0
- kbguard/cli.py +502 -0
- kbguard/config.py +191 -0
- kbguard/evals/__init__.py +0 -0
- kbguard/evals/golden.py +124 -0
- kbguard/evals/metrics/__init__.py +0 -0
- kbguard/evals/metrics/base.py +24 -0
- kbguard/evals/metrics/deepeval_plugin.py +186 -0
- kbguard/evals/metrics/ragas_plugin.py +212 -0
- kbguard/evals/retrieval.py +115 -0
- kbguard/evals/target.py +121 -0
- kbguard/ingest/__init__.py +0 -0
- kbguard/ingest/embeddings.py +76 -0
- kbguard/ingest/exporter.py +48 -0
- kbguard/ingest/metadata.py +112 -0
- kbguard/ingest/snapshot.py +302 -0
- kbguard/judge/__init__.py +0 -0
- kbguard/judge/cache.py +47 -0
- kbguard/judge/calibrate.py +94 -0
- kbguard/judge/client.py +102 -0
- kbguard/models.py +70 -0
- kbguard/py.typed +0 -0
- kbguard/registry.py +52 -0
- kbguard/report/__init__.py +0 -0
- kbguard/report/enrich.py +176 -0
- kbguard/report/html.py +52 -0
- kbguard/report/results.py +36 -0
- kbguard/report/templates/report.html.j2 +210 -0
- kbguard/scoring/__init__.py +0 -0
- kbguard/scoring/diff.py +129 -0
- kbguard/scoring/health.py +81 -0
- kbguard-0.1.0.dist-info/METADATA +195 -0
- kbguard-0.1.0.dist-info/RECORD +49 -0
- kbguard-0.1.0.dist-info/WHEEL +4 -0
- kbguard-0.1.0.dist-info/entry_points.txt +15 -0
- kbguard-0.1.0.dist-info/licenses/LICENSE +201 -0
kbguard/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
kbguard/adapters/base.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from typing import Any, Literal, Protocol
|
|
3
|
+
|
|
4
|
+
from kbguard.models import Chunk
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AdapterConnectionError(Exception):
|
|
8
|
+
"""Raised when an adapter can't actually reach/authenticate to its
|
|
9
|
+
store — wrong host/port, server down, bad credentials — as opposed to
|
|
10
|
+
a missing config *value* (that's a plain `ValueError`, raised before
|
|
11
|
+
any real call is attempted) or a missing *env var* (`ConfigError`).
|
|
12
|
+
Every adapter wraps its own client library's real connection failure
|
|
13
|
+
into this one shared type specifically so `cli.py` can print one clean
|
|
14
|
+
message for it; without this, a bad password or an unreachable host
|
|
15
|
+
surfaces as a full raw traceback from psycopg/qdrant_client/pinecone's
|
|
16
|
+
own exception types, confirmed by hand before this existed."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VectorStoreAdapter(Protocol):
|
|
20
|
+
"""The only contract kbguard knows about a vector store. Everything
|
|
21
|
+
downstream (ingest, checks, evals) works on the `Chunk` records this
|
|
22
|
+
produces — never on a store-specific client — which is what makes any
|
|
23
|
+
vector store pluggable.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
|
|
28
|
+
# "full": iter_chunks() genuinely walks the entire collection (every
|
|
29
|
+
# native adapter). "sampled": it can't — the wrapped store exposes no
|
|
30
|
+
# enumeration API at all, only similarity search, so iter_chunks()
|
|
31
|
+
# approximates coverage via search-based sampling instead (so far,
|
|
32
|
+
# only bridge_langchain.py). SnapshotWriter records this verbatim into
|
|
33
|
+
# the snapshot manifest, so nothing downstream can mistake a sampled
|
|
34
|
+
# scan for a complete one.
|
|
35
|
+
iteration_mode: Literal["full", "sampled"]
|
|
36
|
+
|
|
37
|
+
def connect(self, **settings: Any) -> None: ...
|
|
38
|
+
|
|
39
|
+
def collections(self) -> list[str]: ...
|
|
40
|
+
|
|
41
|
+
def count(self, collection: str) -> int: ...
|
|
42
|
+
|
|
43
|
+
def iter_chunks(
|
|
44
|
+
self,
|
|
45
|
+
collection: str,
|
|
46
|
+
batch_size: int = 500,
|
|
47
|
+
with_embeddings: bool = True,
|
|
48
|
+
) -> Iterator[Chunk]:
|
|
49
|
+
"""Must page through the *entire* collection without ever holding it
|
|
50
|
+
all in memory at once — implementations page internally (keyset or
|
|
51
|
+
cursor-based, store-dependent) and yield one Chunk at a time.
|
|
52
|
+
"""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def search(
|
|
56
|
+
self,
|
|
57
|
+
collection: str,
|
|
58
|
+
query_vector: list[float],
|
|
59
|
+
k: int,
|
|
60
|
+
filters: dict[str, Any] | None = None,
|
|
61
|
+
) -> list[tuple[Chunk, float]]:
|
|
62
|
+
"""Results are returned **best-match-first**; callers rely on this
|
|
63
|
+
ordering, not on the numeric score. Scores only need to be
|
|
64
|
+
comparable *within a single call* — never across two different
|
|
65
|
+
adapters, two different collections, or against some assumed
|
|
66
|
+
absolute scale (a Chroma distance and a Qdrant similarity are not
|
|
67
|
+
the same kind of number, and callers must not assume either
|
|
68
|
+
direction — lower-is-better or higher-is-better — holds generically).
|
|
69
|
+
"""
|
|
70
|
+
...
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import random
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from kbguard.models import Chunk
|
|
7
|
+
|
|
8
|
+
# Deterministic seed: sampling coverage should be reproducible between two
|
|
9
|
+
# scans of an unchanged collection (so a `diff` isn't just sampling noise),
|
|
10
|
+
# not a source of real randomness — this is sampling, not security.
|
|
11
|
+
_RANDOM_SEED = 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _require_setting(settings: dict[str, Any], key: str) -> Any:
|
|
15
|
+
if key not in settings:
|
|
16
|
+
raise ValueError(f"store.settings.{key} is required for the bridge_langchain adapter")
|
|
17
|
+
return settings[key]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _stable_id(page_content: str, metadata: dict[str, Any]) -> str:
|
|
21
|
+
# Used only when the wrapped store's own Document.id is None (common —
|
|
22
|
+
# many LangChain VectorStore implementations never set it). Content-
|
|
23
|
+
# based, not random, so the same chunk gets the same id across two
|
|
24
|
+
# separate scans; two genuinely distinct chunks that happen to share
|
|
25
|
+
# identical text and metadata would collide, which is an accepted,
|
|
26
|
+
# documented gap, not a silently-assumed-safe one.
|
|
27
|
+
payload = page_content + repr(sorted(metadata.items()))
|
|
28
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LangChainBridgeAdapter:
|
|
32
|
+
"""Wraps a LangChain VectorStore instance the client already has,
|
|
33
|
+
instead of connecting to a store natively. langchain_core.vectorstores
|
|
34
|
+
.VectorStore (checked against the installed library, not assumed)
|
|
35
|
+
exposes no "iterate everything" or "count" method at all — only
|
|
36
|
+
similarity search — so this adapter is deliberately less complete than
|
|
37
|
+
the native ones: iter_chunks() approximates coverage via search-based
|
|
38
|
+
sampling, reported honestly as iteration_mode = "sampled" (see
|
|
39
|
+
adapters/base.py), and count() refuses to report a number it has no
|
|
40
|
+
way to verify, rather than guessing.
|
|
41
|
+
|
|
42
|
+
A further real gap, worth naming explicitly: none of VectorStore's
|
|
43
|
+
search methods return a hit's own embedding vector, so every Chunk
|
|
44
|
+
this adapter produces has embedding=None regardless of the
|
|
45
|
+
with_embeddings argument — unlike every native adapter, which returns
|
|
46
|
+
real stored vectors. A caller relying on `embeddings.reuse_stored`
|
|
47
|
+
will recompute every chunk's embedding through the configured model
|
|
48
|
+
instead, which is a real cost difference worth knowing about, not a
|
|
49
|
+
bug in how `with_embeddings` is honored here.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name = "bridge_langchain"
|
|
53
|
+
iteration_mode = "sampled"
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self._vectorstore: Any = None
|
|
57
|
+
self._collection_name = "default"
|
|
58
|
+
self._embedding_dim: int | None = None
|
|
59
|
+
self._sample_queries: list[str] = []
|
|
60
|
+
self._probe_count = 20
|
|
61
|
+
|
|
62
|
+
def connect(self, **settings: Any) -> None:
|
|
63
|
+
self._vectorstore = _require_setting(settings, "vectorstore")
|
|
64
|
+
# Needed to generate the random probe vectors iter_chunks() uses to
|
|
65
|
+
# sample beyond whatever sample_queries covers — there's no API on
|
|
66
|
+
# a generic VectorStore to ask its own embedding dimension.
|
|
67
|
+
self._embedding_dim = _require_setting(settings, "embedding_dim")
|
|
68
|
+
# Real queries a human knows are representative of this
|
|
69
|
+
# collection's content sample far better than random vectors do —
|
|
70
|
+
# optional, but strongly recommended; see docs/guide/adapters/
|
|
71
|
+
# bridge-langchain.md.
|
|
72
|
+
self._sample_queries = settings.get("sample_queries", [])
|
|
73
|
+
self._probe_count = settings.get("probe_count", 20)
|
|
74
|
+
self._collection_name = settings.get("collection", "default")
|
|
75
|
+
|
|
76
|
+
def _require_vectorstore(self) -> Any:
|
|
77
|
+
if self._vectorstore is None:
|
|
78
|
+
raise RuntimeError("LangChainBridgeAdapter.connect() must be called before use")
|
|
79
|
+
return self._vectorstore
|
|
80
|
+
|
|
81
|
+
def collections(self) -> list[str]:
|
|
82
|
+
# One already-constructed VectorStore instance is already scoped
|
|
83
|
+
# to whatever single collection/index it was built against — there
|
|
84
|
+
# is no "list every collection this client has" operation to call.
|
|
85
|
+
return [self._collection_name]
|
|
86
|
+
|
|
87
|
+
def count(self, collection: str) -> int:
|
|
88
|
+
raise NotImplementedError(
|
|
89
|
+
"LangChainBridgeAdapter can't report an exact count -- the wrapped VectorStore has no "
|
|
90
|
+
"universal count/enumerate method. iter_chunks() reports iteration_mode='sampled' instead "
|
|
91
|
+
"of a count it has no way to verify; see docs/guide/adapters/bridge-langchain.md"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def iter_chunks(
|
|
95
|
+
self,
|
|
96
|
+
collection: str,
|
|
97
|
+
batch_size: int = 500,
|
|
98
|
+
with_embeddings: bool = True,
|
|
99
|
+
) -> Iterator[Chunk]:
|
|
100
|
+
vectorstore = self._require_vectorstore()
|
|
101
|
+
seen_ids: set[str] = set()
|
|
102
|
+
|
|
103
|
+
for query in self._sample_queries:
|
|
104
|
+
for doc, _score in vectorstore.similarity_search_with_score(query, k=batch_size):
|
|
105
|
+
chunk = self._to_chunk(doc)
|
|
106
|
+
if chunk.id in seen_ids:
|
|
107
|
+
continue
|
|
108
|
+
seen_ids.add(chunk.id)
|
|
109
|
+
yield chunk
|
|
110
|
+
|
|
111
|
+
# Beyond the (optional) human-provided sample_queries, probe with
|
|
112
|
+
# random unit-ish vectors in the embedding space to pick up content
|
|
113
|
+
# those specific queries wouldn't surface — still not a guarantee
|
|
114
|
+
# of full coverage, just a broader, documented sample.
|
|
115
|
+
assert self._embedding_dim is not None # guaranteed by connect()
|
|
116
|
+
rng = random.Random(_RANDOM_SEED)
|
|
117
|
+
for _ in range(self._probe_count):
|
|
118
|
+
probe_vector = [rng.uniform(-1.0, 1.0) for _ in range(self._embedding_dim)]
|
|
119
|
+
for doc in vectorstore.similarity_search_by_vector(probe_vector, k=batch_size):
|
|
120
|
+
chunk = self._to_chunk(doc)
|
|
121
|
+
if chunk.id in seen_ids:
|
|
122
|
+
continue
|
|
123
|
+
seen_ids.add(chunk.id)
|
|
124
|
+
yield chunk
|
|
125
|
+
|
|
126
|
+
def search(
|
|
127
|
+
self,
|
|
128
|
+
collection: str,
|
|
129
|
+
query_vector: list[float],
|
|
130
|
+
k: int,
|
|
131
|
+
filters: dict[str, Any] | None = None,
|
|
132
|
+
) -> list[tuple[Chunk, float]]:
|
|
133
|
+
if filters is not None:
|
|
134
|
+
raise NotImplementedError("LangChainBridgeAdapter.search() doesn't support filters yet")
|
|
135
|
+
|
|
136
|
+
vectorstore = self._require_vectorstore()
|
|
137
|
+
# similarity_search_by_vector (checked against the installed
|
|
138
|
+
# library) returns plain Documents, best-match-first, but no
|
|
139
|
+
# scores at all -- unlike every native adapter's search(). A
|
|
140
|
+
# descending rank-based placeholder satisfies the Protocol's
|
|
141
|
+
# best-match-first ordering guarantee; it is not a real similarity
|
|
142
|
+
# value and must never be compared against another adapter's
|
|
143
|
+
# score, which adapters/base.py's own docstring already rules out
|
|
144
|
+
# generically.
|
|
145
|
+
docs = vectorstore.similarity_search_by_vector(query_vector, k=k)
|
|
146
|
+
return [(self._to_chunk(doc), 1.0 - i * 0.001) for i, doc in enumerate(docs)]
|
|
147
|
+
|
|
148
|
+
def _to_chunk(self, doc: Any) -> Chunk:
|
|
149
|
+
metadata = dict(doc.metadata or {})
|
|
150
|
+
chunk_id = doc.id if getattr(doc, "id", None) is not None else _stable_id(doc.page_content, metadata)
|
|
151
|
+
return Chunk(id=chunk_id, text=doc.page_content, embedding=None, metadata=metadata)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from collections.abc import Iterator, Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import chromadb
|
|
5
|
+
from chromadb.api import ClientAPI
|
|
6
|
+
from chromadb.errors import ChromaError
|
|
7
|
+
|
|
8
|
+
from kbguard.adapters.base import AdapterConnectionError
|
|
9
|
+
from kbguard.models import Chunk
|
|
10
|
+
|
|
11
|
+
# kbguard always supplies its own embeddings (ingest/embeddings.py — reuse
|
|
12
|
+
# stored or recompute with the configured model) and never asks Chroma to
|
|
13
|
+
# embed text itself. Passing embedding_function=None everywhere stops Chroma
|
|
14
|
+
# from silently downloading its default embedding model on first use, which
|
|
15
|
+
# would otherwise be a surprise network call this project's "runs entirely
|
|
16
|
+
# in your environment" principle explicitly rules out.
|
|
17
|
+
_NO_EMBEDDING_FUNCTION = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChromaAdapter:
|
|
21
|
+
name = "chroma"
|
|
22
|
+
iteration_mode = "full"
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._client: ClientAPI | None = None
|
|
26
|
+
|
|
27
|
+
def connect(self, **settings: Any) -> None:
|
|
28
|
+
path = settings.get("path")
|
|
29
|
+
# No `path` -> in-process, in-memory client: this is what makes
|
|
30
|
+
# Chroma the fastest adapter to develop and test against (no server,
|
|
31
|
+
# no Docker). Given a `path`, data persists to that local directory
|
|
32
|
+
# between runs instead.
|
|
33
|
+
try:
|
|
34
|
+
if path is not None:
|
|
35
|
+
self._client = chromadb.PersistentClient(path=str(path))
|
|
36
|
+
else:
|
|
37
|
+
self._client = chromadb.EphemeralClient()
|
|
38
|
+
except ChromaError as exc:
|
|
39
|
+
# Local-only (no remote host to misconfigure), but an
|
|
40
|
+
# unwritable/nonexistent path still fails eagerly right here
|
|
41
|
+
# (confirmed by hand) -- chromadb.errors.ChromaError is the
|
|
42
|
+
# real common base, confirmed via the real exception's own
|
|
43
|
+
# __mro__ before relying on it.
|
|
44
|
+
raise AdapterConnectionError(f"could not open the Chroma store at {path!r}: {exc}") from exc
|
|
45
|
+
|
|
46
|
+
def _require_client(self) -> ClientAPI:
|
|
47
|
+
if self._client is None:
|
|
48
|
+
raise RuntimeError("ChromaAdapter.connect() must be called before use")
|
|
49
|
+
return self._client
|
|
50
|
+
|
|
51
|
+
def collections(self) -> list[str]:
|
|
52
|
+
return [c.name for c in self._require_client().list_collections()]
|
|
53
|
+
|
|
54
|
+
def count(self, collection: str) -> int:
|
|
55
|
+
col = self._require_client().get_collection(collection, embedding_function=_NO_EMBEDDING_FUNCTION)
|
|
56
|
+
return col.count()
|
|
57
|
+
|
|
58
|
+
def iter_chunks(
|
|
59
|
+
self,
|
|
60
|
+
collection: str,
|
|
61
|
+
batch_size: int = 500,
|
|
62
|
+
with_embeddings: bool = True,
|
|
63
|
+
) -> Iterator[Chunk]:
|
|
64
|
+
col = self._require_client().get_collection(collection, embedding_function=_NO_EMBEDDING_FUNCTION)
|
|
65
|
+
include = ["documents", "metadatas", *(["embeddings"] if with_embeddings else [])]
|
|
66
|
+
|
|
67
|
+
offset = 0
|
|
68
|
+
while True:
|
|
69
|
+
batch = col.get(limit=batch_size, offset=offset, include=include) # type: ignore[arg-type]
|
|
70
|
+
ids = batch["ids"]
|
|
71
|
+
if not ids:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
documents = batch["documents"]
|
|
75
|
+
metadatas = batch["metadatas"]
|
|
76
|
+
embeddings = batch["embeddings"]
|
|
77
|
+
|
|
78
|
+
for i, chunk_id in enumerate(ids):
|
|
79
|
+
text = documents[i] if documents is not None else None
|
|
80
|
+
metadata = metadatas[i] if metadatas is not None else None
|
|
81
|
+
embedding = [float(x) for x in embeddings[i]] if embeddings is not None else None
|
|
82
|
+
yield Chunk(
|
|
83
|
+
id=chunk_id,
|
|
84
|
+
text=text or "",
|
|
85
|
+
embedding=embedding,
|
|
86
|
+
metadata=dict(metadata or {}),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
offset += len(ids)
|
|
90
|
+
|
|
91
|
+
def search(
|
|
92
|
+
self,
|
|
93
|
+
collection: str,
|
|
94
|
+
query_vector: list[float],
|
|
95
|
+
k: int,
|
|
96
|
+
filters: dict[str, Any] | None = None,
|
|
97
|
+
) -> list[tuple[Chunk, float]]:
|
|
98
|
+
col = self._require_client().get_collection(collection, embedding_function=_NO_EMBEDDING_FUNCTION)
|
|
99
|
+
query_embeddings: list[Sequence[float] | Sequence[int]] = [query_vector]
|
|
100
|
+
result = col.query(
|
|
101
|
+
query_embeddings=query_embeddings,
|
|
102
|
+
n_results=k,
|
|
103
|
+
where=filters,
|
|
104
|
+
include=["documents", "metadatas", "embeddings", "distances"],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# query() nests one list per query vector; we only ever send one.
|
|
108
|
+
ids = result["ids"][0]
|
|
109
|
+
documents = result["documents"][0] if result["documents"] is not None else None
|
|
110
|
+
metadatas = result["metadatas"][0] if result["metadatas"] is not None else None
|
|
111
|
+
embeddings = result["embeddings"][0] if result["embeddings"] is not None else None
|
|
112
|
+
distances = result["distances"][0] if result["distances"] is not None else None
|
|
113
|
+
|
|
114
|
+
pairs: list[tuple[Chunk, float]] = []
|
|
115
|
+
for i, chunk_id in enumerate(ids):
|
|
116
|
+
text = documents[i] if documents is not None else None
|
|
117
|
+
metadata = metadatas[i] if metadatas is not None else None
|
|
118
|
+
embedding = [float(x) for x in embeddings[i]] if embeddings is not None else None
|
|
119
|
+
chunk = Chunk(id=chunk_id, text=text or "", embedding=embedding, metadata=dict(metadata or {}))
|
|
120
|
+
# Chroma's query() already returns nearest-first, satisfying the
|
|
121
|
+
# Protocol's best-match-first guarantee (adapters/base.py)
|
|
122
|
+
# without any re-sorting needed here.
|
|
123
|
+
score = float(distances[i]) if distances is not None else 0.0
|
|
124
|
+
pairs.append((chunk, score))
|
|
125
|
+
return pairs
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import psycopg
|
|
6
|
+
from pgvector import Vector
|
|
7
|
+
from pgvector.psycopg import register_vector
|
|
8
|
+
from psycopg import sql
|
|
9
|
+
|
|
10
|
+
from kbguard.adapters.base import AdapterConnectionError
|
|
11
|
+
from kbguard.config import resolve_env
|
|
12
|
+
from kbguard.models import Chunk
|
|
13
|
+
|
|
14
|
+
# Table/column names come from config (spec: pgvector adapter must work
|
|
15
|
+
# against a client's existing schema, not impose its own). Identifiers
|
|
16
|
+
# can't be passed as query parameters the way values can (no %s for a
|
|
17
|
+
# table/column name) — psycopg.sql.Identifier() safely quotes whatever
|
|
18
|
+
# string it's given, but this still validates against a strict allowlist
|
|
19
|
+
# first and fails with a clear message, rather than silently accepting an
|
|
20
|
+
# unusual identifier that merely happens not to be a SQL injection vector.
|
|
21
|
+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _safe_identifier(name: str, *, what: str) -> sql.Identifier:
|
|
25
|
+
if not _IDENTIFIER_RE.match(name):
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"{what} {name!r} is not a valid SQL identifier (letters, digits, underscore, "
|
|
28
|
+
f"can't start with a digit) — refusing to use it in a query"
|
|
29
|
+
)
|
|
30
|
+
return sql.Identifier(name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _require_setting(settings: dict[str, Any], key: str) -> Any:
|
|
34
|
+
if key not in settings:
|
|
35
|
+
raise ValueError(f"store.settings.{key} is required for the pgvector adapter")
|
|
36
|
+
return settings[key]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PgVectorAdapter:
|
|
40
|
+
name = "pgvector"
|
|
41
|
+
iteration_mode = "full"
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self._conn: psycopg.Connection[Any] | None = None
|
|
45
|
+
self._id_column = "id"
|
|
46
|
+
self._text_column = "text"
|
|
47
|
+
self._metadata_column = "metadata"
|
|
48
|
+
self._embedding_column = "embedding"
|
|
49
|
+
|
|
50
|
+
def connect(self, **settings: Any) -> None:
|
|
51
|
+
host = settings.get("host", "localhost")
|
|
52
|
+
port = settings.get("port", 5432)
|
|
53
|
+
user = _require_setting(settings, "user")
|
|
54
|
+
dbname = _require_setting(settings, "dbname")
|
|
55
|
+
password_env = settings.get("password_env")
|
|
56
|
+
password = resolve_env(password_env) if password_env else settings.get("password")
|
|
57
|
+
|
|
58
|
+
self._id_column = settings.get("id_column", "id")
|
|
59
|
+
self._text_column = settings.get("text_column", "text")
|
|
60
|
+
self._metadata_column = settings.get("metadata_column", "metadata")
|
|
61
|
+
self._embedding_column = settings.get("embedding_column", "embedding")
|
|
62
|
+
# Validated once here, not per-query — a bad identifier should fail
|
|
63
|
+
# at connect() time, not confusingly deep inside the first scan.
|
|
64
|
+
for value, what in [
|
|
65
|
+
(self._id_column, "id_column"),
|
|
66
|
+
(self._text_column, "text_column"),
|
|
67
|
+
(self._metadata_column, "metadata_column"),
|
|
68
|
+
(self._embedding_column, "embedding_column"),
|
|
69
|
+
]:
|
|
70
|
+
_safe_identifier(value, what=what)
|
|
71
|
+
|
|
72
|
+
# No explicit connect_timeout, by default, means no timeout at all
|
|
73
|
+
# -- confirmed by hand with a real unresponsive port: libpq (via
|
|
74
|
+
# psycopg) took 260 SECONDS to give up on its own, not an instant
|
|
75
|
+
# connection-refused. Same class of gap as adapters/pinecone.py's
|
|
76
|
+
# own timeout fix earlier -- a misconfigured/unreachable host
|
|
77
|
+
# should fail in seconds, not hang for minutes. Note this is a
|
|
78
|
+
# PER-ADDRESS timeout, not a total deadline -- confirmed by hand
|
|
79
|
+
# that "localhost" resolves to both ::1 and 127.0.0.1, so a real
|
|
80
|
+
# worst case is roughly 2x this value, not exactly this value.
|
|
81
|
+
connect_timeout = settings.get("connect_timeout", 10)
|
|
82
|
+
|
|
83
|
+
# autocommit: kbguard never writes, so there's nothing to commit or
|
|
84
|
+
# roll back — no reason to hold an open transaction across calls.
|
|
85
|
+
try:
|
|
86
|
+
self._conn = psycopg.connect(
|
|
87
|
+
host=host,
|
|
88
|
+
port=port,
|
|
89
|
+
user=user,
|
|
90
|
+
password=password,
|
|
91
|
+
dbname=dbname,
|
|
92
|
+
autocommit=True,
|
|
93
|
+
connect_timeout=connect_timeout,
|
|
94
|
+
)
|
|
95
|
+
except psycopg.Error as exc:
|
|
96
|
+
# psycopg.connect() is eager -- a bad host/port/password/dbname
|
|
97
|
+
# raises right here, confirmed by hand, unlike qdrant/pinecone's
|
|
98
|
+
# lazy clients. psycopg.Error is the real common base for this
|
|
99
|
+
# (confirmed via OperationalError.__mro__ before relying on it).
|
|
100
|
+
raise AdapterConnectionError(f"could not connect to pgvector at {host}:{port}: {exc}") from exc
|
|
101
|
+
# Requires the vector extension already enabled on the target
|
|
102
|
+
# database — kbguard never runs CREATE EXTENSION itself (DDL is a
|
|
103
|
+
# write; read-only is a hard design constraint, not a default).
|
|
104
|
+
register_vector(self._conn)
|
|
105
|
+
|
|
106
|
+
def _require_conn(self) -> psycopg.Connection[Any]:
|
|
107
|
+
if self._conn is None:
|
|
108
|
+
raise RuntimeError("PgVectorAdapter.connect() must be called before use")
|
|
109
|
+
return self._conn
|
|
110
|
+
|
|
111
|
+
def collections(self) -> list[str]:
|
|
112
|
+
rows = self._require_conn().execute(
|
|
113
|
+
"SELECT table_name FROM information_schema.tables "
|
|
114
|
+
"WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name"
|
|
115
|
+
).fetchall()
|
|
116
|
+
return [row[0] for row in rows]
|
|
117
|
+
|
|
118
|
+
def count(self, collection: str) -> int:
|
|
119
|
+
table = _safe_identifier(collection, what="collection")
|
|
120
|
+
row = self._require_conn().execute(sql.SQL("SELECT count(*) FROM {}").format(table)).fetchone()
|
|
121
|
+
assert row is not None
|
|
122
|
+
return row[0] # type: ignore[no-any-return]
|
|
123
|
+
|
|
124
|
+
def iter_chunks(
|
|
125
|
+
self,
|
|
126
|
+
collection: str,
|
|
127
|
+
batch_size: int = 500,
|
|
128
|
+
with_embeddings: bool = True,
|
|
129
|
+
) -> Iterator[Chunk]:
|
|
130
|
+
conn = self._require_conn()
|
|
131
|
+
table = _safe_identifier(collection, what="collection")
|
|
132
|
+
id_col = sql.Identifier(self._id_column)
|
|
133
|
+
select_cols = [id_col, sql.Identifier(self._text_column), sql.Identifier(self._metadata_column)]
|
|
134
|
+
if with_embeddings:
|
|
135
|
+
select_cols.append(sql.Identifier(self._embedding_column))
|
|
136
|
+
|
|
137
|
+
# Keyset (id > :last), not OFFSET: a row inserted or deleted mid-scan
|
|
138
|
+
# can make OFFSET skip or repeat rows on a live, multi-writer table
|
|
139
|
+
# — a real risk for pgvector specifically, unlike Chroma, which is
|
|
140
|
+
# typically local and single-writer.
|
|
141
|
+
query = sql.SQL("SELECT {} FROM {} WHERE {} > %s ORDER BY {} LIMIT %s").format(
|
|
142
|
+
sql.SQL(", ").join(select_cols), table, id_col, id_col
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
last_id = ""
|
|
146
|
+
while True:
|
|
147
|
+
rows = conn.execute(query, (last_id, batch_size)).fetchall()
|
|
148
|
+
if not rows:
|
|
149
|
+
return
|
|
150
|
+
for row in rows:
|
|
151
|
+
chunk_id, text, metadata = row[0], row[1], row[2]
|
|
152
|
+
embedding = row[3].to_list() if with_embeddings and row[3] is not None else None
|
|
153
|
+
yield Chunk(id=chunk_id, text=text, embedding=embedding, metadata=dict(metadata or {}))
|
|
154
|
+
last_id = rows[-1][0]
|
|
155
|
+
|
|
156
|
+
def search(
|
|
157
|
+
self,
|
|
158
|
+
collection: str,
|
|
159
|
+
query_vector: list[float],
|
|
160
|
+
k: int,
|
|
161
|
+
filters: dict[str, Any] | None = None,
|
|
162
|
+
) -> list[tuple[Chunk, float]]:
|
|
163
|
+
if filters is not None:
|
|
164
|
+
# Silently ignoring filters would return unfiltered results to
|
|
165
|
+
# a caller that explicitly asked for filtered ones — wrong in
|
|
166
|
+
# a way that's hard to notice. No caller passes filters yet;
|
|
167
|
+
# pgvector's filter translation lands when one actually needs it.
|
|
168
|
+
raise NotImplementedError("PgVectorAdapter.search() doesn't support filters yet")
|
|
169
|
+
|
|
170
|
+
conn = self._require_conn()
|
|
171
|
+
table = _safe_identifier(collection, what="collection")
|
|
172
|
+
id_col = sql.Identifier(self._id_column)
|
|
173
|
+
text_col = sql.Identifier(self._text_column)
|
|
174
|
+
meta_col = sql.Identifier(self._metadata_column)
|
|
175
|
+
emb_col = sql.Identifier(self._embedding_column)
|
|
176
|
+
|
|
177
|
+
# <=> is pgvector's cosine distance operator (lower = closer);
|
|
178
|
+
# ORDER BY distance ASC satisfies the Protocol's best-match-first
|
|
179
|
+
# guarantee directly — no re-sorting needed.
|
|
180
|
+
query = sql.SQL("SELECT {}, {}, {}, {}, {} <=> %s AS distance FROM {} ORDER BY distance LIMIT %s").format(
|
|
181
|
+
id_col, text_col, meta_col, emb_col, emb_col, table
|
|
182
|
+
)
|
|
183
|
+
rows = conn.execute(query, (Vector(query_vector), k)).fetchall()
|
|
184
|
+
|
|
185
|
+
results: list[tuple[Chunk, float]] = []
|
|
186
|
+
for row in rows:
|
|
187
|
+
chunk_id, text, metadata, raw_embedding, distance = row
|
|
188
|
+
embedding = raw_embedding.to_list() if raw_embedding is not None else None
|
|
189
|
+
chunk = Chunk(id=chunk_id, text=text, embedding=embedding, metadata=dict(metadata or {}))
|
|
190
|
+
results.append((chunk, float(distance)))
|
|
191
|
+
return results
|