Spaces:
Paused
Paused
File size: 15,374 Bytes
9792ea7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 | # -*- coding: utf-8 -*-
"""Runtime handle for a single knowledge base.
A :class:`KnowledgeBase` instance is the **single algorithmic source of
truth** for talking to one knowledge base: it pairs an embedding model
with a vector-store collection (optionally scoped by a payload
``metadata_filter``) and exposes the four operations a caller ever
needs β :meth:`search`, :meth:`insert_document`,
:meth:`delete_document`, :meth:`list_documents`.
The handle is *narrow on purpose* β it carries the resolved runtime
state (embedding model + vector store + scope) and delegates every
operation to the bound :class:`VectorStoreBase`. Document parsing,
chunking, credential resolution, dimension policy validation, and
persistence of knowledge-base records all belong one layer up
(service-side :class:`KnowledgeBaseManagerBase` for hosted
deployments; the caller directly otherwise).
The backing collection is created on first use β each operation
transparently calls :meth:`ensure_collection`, which is itself
idempotent and memoised after the first success, so the only cost is
one extra round-trip on the very first call against a fresh
deployment.
``metadata_filter`` is the defense-in-depth scoping mechanism for
co-locating multiple logical knowledge bases inside the same physical
collection β typically multi-tenant deployments where every record
carries a ``{"tenant_id": "..."}`` payload. It is set once at
construction time and **always** applied: search/list never escape
it, and insert forces it onto every chunk's metadata so a malicious or
buggy parser cannot rebind a record into another scope.
"""
import asyncio
from ._document import Chunk
from ._vdb import VectorRecord, VectorSearchResult, VectorStoreBase
from .._utils._common import _generate_id
from ..embedding import EmbeddingModelBase
from ..message import DataBlock, TextBlock
from ._vdb import DocumentSummary
class KnowledgeBase:
"""Runtime handle for one knowledge base.
Binds an embedding model and a vector-store collection together so
callers can retrieve / insert / delete / list documents without
repeating the wiring. Cheap to construct (no I/O); the collection
itself is created lazily on the first operation, so a fresh
deployment "just works" without an explicit setup step.
.. code-block:: python
kb = KnowledgeBase(
name="company-handbook",
description="Internal HR and onboarding documents.",
embedding_model=embedding_model,
vector_store=vector_store,
collection="handbook",
)
await kb.insert_document(chunks)
results = await kb.search(["What is the PTO policy?"])
"""
name: str
"""Agent-oriented knowledge base name β used by tool descriptions
and frontend rendering."""
description: str
"""Agent-oriented knowledge base description β what this knowledge
base contains and when to retrieve from it."""
def __init__(
self,
name: str,
description: str,
embedding_model: EmbeddingModelBase,
vector_store: VectorStoreBase,
collection: str,
metadata_filter: dict | None = None,
) -> None:
"""Initialize the runtime handle.
Args:
name (`str`):
Agent-oriented knowledge base name. Surfaced to the
LLM (via tool descriptions) and to the front-end.
description (`str`):
Agent-oriented description. Should answer "what is in
this knowledge base and when should I search it?" β the
LLM uses it to decide whether to call the search tool
in agentic mode.
embedding_model (`EmbeddingModelBase`):
The embedding model used to embed both queries and
inserted chunks. Must be the same model used at
indexing time and at retrieval time, otherwise vectors
will not be comparable.
vector_store (`VectorStoreBase`):
The shared vector-store connection. The store must
already be entered (its own ``__aenter__`` already
called) before any operation on this handle runs.
collection (`str`):
The physical collection backing this knowledge base.
Created lazily on the first operation; see
:meth:`ensure_collection`.
metadata_filter (`dict | None`, optional):
Defense-in-depth payload filter. When set:
- :meth:`search` and :meth:`list_documents` restrict
results to records whose payload matches every
``key == value`` pair;
- :meth:`insert_document` forces these keys onto every
inserted chunk's metadata, overriding caller-supplied
values, so records cannot leak into another scope.
``None`` disables filtering β the default for
deployments where every knowledge base owns its
collection outright.
"""
self.name = name
self.description = description
self._embedding_model = embedding_model
self._vector_store = vector_store
self._collection = collection
self._metadata_filter = metadata_filter
# Memoise the "collection exists" check after the first
# successful ensure_collection so subsequent operations avoid
# the extra round-trip.
self._collection_ready = False
# ------------------------------------------------------------------
# Read-only accessors
# ------------------------------------------------------------------
@property
def embedding_model(self) -> EmbeddingModelBase:
"""The bound embedding model."""
return self._embedding_model
@property
def vector_store(self) -> VectorStoreBase:
"""The bound vector store."""
return self._vector_store
@property
def collection(self) -> str:
"""The physical collection backing this knowledge base."""
return self._collection
@property
def metadata_filter(self) -> dict | None:
"""The defense-in-depth payload filter, or ``None``."""
return self._metadata_filter
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def ensure_collection(self) -> None:
"""Idempotently create the backing collection if missing.
Called transparently at the top of every public operation β
callers should not need to invoke it themselves. Memoised on
the instance after the first success, so subsequent calls are
a single ``if`` check.
Looks up the collection via
:meth:`VectorStoreBase.has_collection` and creates it with the
embedding model's :attr:`~EmbeddingModelBase.dimensions` when
absent.
Raises whatever the backend raises if the collection exists at
an incompatible dimension (the backend is the authority on
that; we do not double-check here).
"""
if self._collection_ready:
return
if not await self._vector_store.has_collection(self._collection):
await self._vector_store.create_collection(
self._collection,
dimensions=self._embedding_model.dimensions,
)
self._collection_ready = True
# ------------------------------------------------------------------
# Search
# ------------------------------------------------------------------
async def search(
self,
queries: list[str | TextBlock | DataBlock],
top_k: int = 5,
score_threshold: float | None = None,
) -> list[VectorSearchResult]:
"""Search the knowledge base with one or more queries.
All queries are embedded in a single batch, then searched
concurrently against the bound collection (with
:attr:`metadata_filter` applied). Hits are deduplicated by
``(document_id, chunk_index)`` keeping the best score,
optionally filtered by ``score_threshold``, sorted by
descending score, and truncated to ``top_k``.
Args:
queries (`list[str | TextBlock | DataBlock]`):
Query inputs. Text may be either bare ``str`` or
:class:`TextBlock`; :class:`DataBlock` items are
**silently dropped** when the bound embedding model
does not declare ``supports_multimodal`` β text-only
models would otherwise reject them. Callers can
therefore pass a mixed list without per-KB filtering.
top_k (`int`, defaults to ``5``):
Maximum number of results returned across all queries
(after dedup).
score_threshold (`float | None`, optional):
Minimum similarity score for a hit to be retained.
Only meaningful for similarity metrics where higher is
better (cosine / dot-product). ``None`` disables
filtering.
Returns:
`list[VectorSearchResult]`:
At most ``top_k`` deduplicated hits ordered by
descending similarity score. Empty when there are no
queries the bound embedding model can consume.
"""
if not queries:
return []
if not self._embedding_model.supports_multimodal:
queries = [q for q in queries if not isinstance(q, DataBlock)]
if not queries:
return []
await self.ensure_collection()
response = await self._embedding_model(queries)
results_per_query = await asyncio.gather(
*(
self._vector_store.search(
collection=self._collection,
query_vector=vector,
top_k=top_k,
metadata_filter=self._metadata_filter,
)
for vector in response.embeddings
),
)
best: dict[tuple[str, int], VectorSearchResult] = {}
for results in results_per_query:
for result in results:
if (
score_threshold is not None
and result.score < score_threshold
):
continue
# ``(document_id, chunk_index)`` is the stable identity
# of a chunk: it survives reindex (block UUIDs do not)
# and uniquely names "this slice of that document"
# regardless of which query surfaced it.
key = (result.document_id, result.chunk.chunk_index)
if key not in best or result.score > best[key].score:
best[key] = result
merged = sorted(
best.values(),
key=lambda result: result.score,
reverse=True,
)
return merged[:top_k]
# ------------------------------------------------------------------
# Document management
# ------------------------------------------------------------------
async def insert_document(
self,
chunks: list[Chunk],
document_id: str | None = None,
document_metadata: dict | None = None,
) -> str:
"""Embed and insert a list of chunks as a single source document.
All chunks share the resolved ``document_id``;
:meth:`delete_document` later removes them as a unit. Each
chunk's metadata is merged in this precedence (highest wins):
1. :attr:`metadata_filter` keys β defense-in-depth scoping, so
a chunk can never be inserted with a payload that escapes
the filter (any escape would silently disappear at retrieve
time anyway, but failing closed at insert is clearer).
2. The chunk's pre-existing ``metadata`` β parser-supplied.
3. ``document_metadata`` β document-level fields propagated
down (filename, media type, upload time, ...).
Args:
chunks (`list[Chunk]`):
The pre-chunked document content (already produced by
a parser + chunker pipeline). An empty list is a
no-op.
document_id (`str | None`, optional):
The document identifier. When ``None`` a fresh UUID
hex is generated and returned so the caller can record
it for future :meth:`delete_document` calls.
document_metadata (`dict | None`, optional):
Document-level metadata (filename, media type, size,
upload time, ...). Merged into each chunk's
``metadata``.
Returns:
`str`:
The (possibly generated) document id.
Raises:
`RuntimeError`:
If the embedding model returns a number of vectors
that does not match the number of chunks.
"""
if not chunks:
return document_id or _generate_id()
document_id = document_id or _generate_id()
await self.ensure_collection()
# Precedence: metadata_filter wins (security boundary), then
# chunk metadata, then document_metadata. See docstring.
for chunk in chunks:
chunk.metadata = {
**(document_metadata or {}),
**chunk.metadata,
**(self._metadata_filter or {}),
}
response = await self._embedding_model(
[chunk.content for chunk in chunks],
)
if len(response.embeddings) != len(chunks):
raise RuntimeError(
f"Embedding model returned {len(response.embeddings)} "
f"vectors for {len(chunks)} chunks.",
)
records = [
VectorRecord(
vector=vector,
document_id=document_id,
chunk=chunk,
)
for vector, chunk in zip(response.embeddings, chunks)
]
await self._vector_store.insert(self._collection, records)
return document_id
async def delete_document(self, document_id: str) -> None:
"""Remove every record for one source document.
Args:
document_id (`str`):
The source document id whose records should be removed.
"""
await self.ensure_collection()
await self._vector_store.delete(
self._collection,
document_id,
)
async def list_documents(self) -> list["DocumentSummary"]:
"""List all distinct source documents in this knowledge base.
Filtered by :attr:`metadata_filter` when set, so callers only
ever see documents within their own scope.
Returns:
`list[DocumentSummary]`:
One summary per indexed document, in unspecified order.
"""
await self.ensure_collection()
return await self._vector_store.list_documents(
self._collection,
metadata_filter=self._metadata_filter,
)
|