qmd_py.search.vector¶
Vector search: pgvector embeddings, one physical table per embedding model, chunking, and the HNSW candidate-CTE query.
vector ¶
Vector search: pgvector embeddings, one physical table per embedding
model (embeddings_<slug>), created dynamically once a model's dimension
is known - fixes the TS reference's one-model-at-a-time limitation (a
single content_vectors.embedding column, destructively dropped on a
model switch). Candidate retrieval happens in a CTE via the HNSW-indexed
<=> operator, exactly as the TS reference's searchVec does; the
collection filter is applied only on the outer join, never inside the
CTE, since ORDER BY distance LIMIT n is the only access pattern that
uses the ANN index.
EmbedResult
dataclass
¶
Counts from one embed_pending_documents() pass.
Attributes:
-
docs_processed(int) –Distinct content hashes embedded. Documents that already had vectors for this model are skipped and not counted.
-
chunks_embedded(int) –Vectors written. Higher than
docs_processedwhenever documents were long enough to split.
VectorIndexHealth
dataclass
¶
Whether semantic search is usable, and how stale it is.
Attributes:
-
has_vector_index(bool) –Whether this model's table exists at all - False means nothing has ever been embedded with it, and vector search will return empty rather than fail.
-
needs_embedding(int) –Active documents with no vector for this model. Equals the total document count when
has_vector_indexis False.
chunk_document ¶
Split a body into overlapping character windows for embedding.
Fixed-width slicing on a conservative chars-per-token estimate - no
heading or AST-aware break points, and no tokenizer verification.
That is a deliberate simplification of the TS reference; the estimate
can undercount dense code, which is why the rerank path re-measures
with the real tokenizer (see hybrid._rerank_safe_text).
Returns:
-
list[tuple[str, int]]–(text, start_offset)pairs, always at least one - an empty body -
list[tuple[str, int]]–yields
[("", 0)]rather than an empty list, so a document can -
list[tuple[str, int]]–never silently go unembedded. Offsets index into
body, and -
list[tuple[str, int]]–consecutive chunks overlap by design so a match spanning a
-
list[tuple[str, int]]–boundary is still retrievable.
Source code in src/qmd_py/search/vector.py
embeddings_table_name ¶
Physical table name holding one embedding model's vectors.
Each model gets its own table, so adding a model is additive and switching between them never drops data.
The slug is reduced to lowercase alphanumerics and underscores, which is what makes it safe to interpolate into DDL - these names cannot be bound as parameters.
Source code in src/qmd_py/search/vector.py
has_embeddings_table
async
¶
Whether anything has ever been embedded with this model - i.e. its
embeddings_<slug> table exists. Lets a caller batching query
embeddings up front (see hybrid.hybrid_query) skip the embedding
round trip entirely when vector search would return empty anyway.
Source code in src/qmd_py/search/vector.py
_pgvector_schema
async
¶
Schema pgvector's vector type/opclasses actually live in - looked
up from pg_extension rather than assumed to be the app's own
configured schema. On a fresh database, Alembic's CREATE EXTENSION IF
NOT EXISTS vector does install it there - but on a shared database
(e.g. this project's real server, which already had another service
install pgvector into public long before qmd-py existed), extensions
are database-wide singletons: Alembic's call just found it already
installed elsewhere and no-opped. See ensure_embedding_model for why
every reference to the type/opclasses is schema-qualified rather than
relying on search_path.
Source code in src/qmd_py/search/vector.py
get_or_probe_dimension
async
¶
The registered dimension for an already-known model, or one
embedding call to discover it for a brand-new one - avoids wasting a
probe request every time embed runs against a model already in use.
Source code in src/qmd_py/search/vector.py
ensure_embedding_model
async
¶
ensure_embedding_model(
session: AsyncSession,
slug: str,
role: str,
dimension: int,
) -> EmbeddingModel
Registers (or fetches) an embedding-model row and creates its
dedicated embeddings_<slug> table - idempotent, never destructive;
switching models means a different table, never a dropped column.
Source code in src/qmd_py/search/vector.py
embed_pending_documents
async
¶
embed_pending_documents(
session: AsyncSession,
user: CurrentUser,
llm_client: LlmClient,
model_slug: str,
dimension: int,
collection_name: str | None = None,
) -> EmbedResult
Embed every active document not yet present in embeddings_<slug>.
Commits after each document rather than leaving one giant transaction
to the caller: the pending query (hash NOT IN (...)) makes the run
naturally resumable, so a crash midway through a large embed keeps
everything already written, and the transaction stays short on the
shared server. No retry/duration-cap/force-re-embed yet.
Source code in src/qmd_py/search/vector.py
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 | |
get_vector_index_health
async
¶
get_vector_index_health(
session: AsyncSession,
user: CurrentUser,
model_slug: str,
) -> VectorIndexHealth
Whether model_slug has ever been embedded at all, and how many of
the user's active documents still lack an embedding for it - backs
the MCP server's dynamic instructions and status tool (Phase 9),
which need this same "needs embedding" count embed_pending_documents
already computes internally but didn't expose on its own.
Source code in src/qmd_py/search/vector.py
search_vec
async
¶
search_vec(
session: AsyncSession,
user: CurrentUser,
query: str,
llm_client: LlmClient,
model_slug: str,
limit: int = 20,
collection_name: str | None = None,
query_embedding: list[float] | None = None,
) -> list[SearchResult]
Rank documents by embedding similarity to the query.
Embeds the query, retrieves nearest chunks through the HNSW index, then deduplicates to one hit per document, keeping its closest chunk.
Parameters:
-
model_slug(str) –Which embedding model's table to search. Must match what the documents were embedded with - vectors from different models are not comparable.
-
limit(int, default:20) –Maximum documents returned. Three times this many chunks are retrieved first, since several may belong to one document.
-
collection_name(str | None, default:None) –Restrict to one collection; None searches every collection the user can read.
-
query_embedding(list[float] | None, default:None) –Precomputed vector for
query, skipping the embedding round trip. Must have been produced fromformat_query_for_embedding(query, model_slug)with the same model -hybrid_queryuses this to embed all its vec/hyde variants in one batched request.
Returns:
-
list[SearchResult]–Hits ordered by descending similarity,
source="vec"and -
list[SearchResult]–chunk_posset. Empty - not an error - when nothing has been -
list[SearchResult]–embedded with this model yet, when no collection is accessible,
-
list[SearchResult]–or when the index holds no neighbours.
Note
Makes one embedding call to the LLM router per invocation, unless
query_embedding is supplied. The collection filter is applied
outside the nearest-neighbour CTE, because ORDER BY distance
LIMIT n is the only access pattern the ANN index can serve.
Source code in src/qmd_py/search/vector.py
371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 | |
cleanup_orphaned_embeddings
async
¶
Deletes embedding rows whose hash no longer belongs to any active
document, across every registered embedding model's table - backs the
cleanup command's "remove orphaned embedding chunks" step. Content
rows themselves are cleanup_orphaned_content's job (store.py).