feat(evidence): use server-side Qdrant BM25 retrieval
This commit is contained in:
@@ -24,6 +24,7 @@ from tht.vectorstore.store import VectorHit, hit_from_metadata
|
||||
|
||||
_GENERATION = re.compile(r"gen:[0-9a-f]{32}")
|
||||
_WORKSPACE = re.compile(r"[a-z][a-z0-9_-]{0,63}")
|
||||
_BM25_LANGUAGES = frozenset({"english", "italian"})
|
||||
_KEYWORD_INDEXES = (
|
||||
|
||||
"content_hash",
|
||||
@@ -127,6 +128,8 @@ class QdrantVectorStore:
|
||||
limit: int,
|
||||
kinds: list[str] | None = None,
|
||||
metadata_filter: dict[str, object] | None = None,
|
||||
query_text: str | None = None,
|
||||
query_language: str | None = None,
|
||||
) -> list[VectorHit]:
|
||||
require_positive_limit(limit)
|
||||
self._validate_embedding(embedding, query=True)
|
||||
@@ -155,16 +158,43 @@ class QdrantVectorStore:
|
||||
{"key": "vector_generation", "match": {"value": generation}},
|
||||
{"key": "document_id", "match": {"any": document_ids}},
|
||||
])
|
||||
response = self._call(
|
||||
"POST",
|
||||
f"/collections/{self._collection}/points/query",
|
||||
{
|
||||
"vector": embedding,
|
||||
"limit": limit,
|
||||
"with_payload": True,
|
||||
"filter": {"must": filter_must},
|
||||
},
|
||||
)
|
||||
if query_text is None:
|
||||
if allowed_record_kinds == ["evidence"]:
|
||||
raise VectorStoreError("Evidence hybrid query text is required")
|
||||
response = self._call(
|
||||
"POST",
|
||||
f"/collections/{self._collection}/points/query",
|
||||
{
|
||||
"vector": embedding,
|
||||
"limit": limit,
|
||||
"with_payload": True,
|
||||
"filter": {"must": filter_must},
|
||||
},
|
||||
)
|
||||
else:
|
||||
if allowed_record_kinds != ["evidence"]:
|
||||
raise VectorStoreError("Hybrid BM25 is only available for Evidence")
|
||||
if query_text.strip() == "" or query_language not in _BM25_LANGUAGES:
|
||||
raise VectorStoreError("Evidence BM25 query is invalid")
|
||||
shared_filter = {"must": filter_must}
|
||||
response = self._call(
|
||||
"POST",
|
||||
f"/collections/{self._collection}/points/query",
|
||||
{
|
||||
"prefetch": [
|
||||
{"query": embedding, "limit": limit * 2, "filter": shared_filter},
|
||||
{
|
||||
"query": self._bm25_document(query_text, query_language),
|
||||
"using": "bm25",
|
||||
"limit": limit * 2,
|
||||
"filter": shared_filter,
|
||||
},
|
||||
],
|
||||
"query": {"rrf": {}},
|
||||
"limit": limit,
|
||||
"with_payload": True,
|
||||
},
|
||||
)
|
||||
points = response.get("result", {}).get("points")
|
||||
if not isinstance(points, list):
|
||||
raise VectorStoreError("Qdrant returned malformed query response")
|
||||
@@ -201,6 +231,14 @@ class QdrantVectorStore:
|
||||
validate_collection_kinds(collection, [write_record.record.kind])
|
||||
self._validate_embedding(write_record.embedding, query=False)
|
||||
semantic_kind = qdrant_semantic_kind(write_record.record.kind)
|
||||
vector: list[float] | dict = write_record.embedding
|
||||
if write_record.sparse_text is not None:
|
||||
if semantic_kind != "evidence" or write_record.sparse_language not in _BM25_LANGUAGES:
|
||||
raise VectorStoreError("Evidence BM25 document is invalid")
|
||||
vector = {
|
||||
"": write_record.embedding,
|
||||
"bm25": self._bm25_document(write_record.sparse_text, write_record.sparse_language),
|
||||
}
|
||||
points.append(
|
||||
{
|
||||
"id": point_id(
|
||||
@@ -209,7 +247,7 @@ class QdrantVectorStore:
|
||||
write_record.record.id,
|
||||
self._workspace_revision if semantic_kind in ("schema_table", "schema_column", "evidence") else None,
|
||||
),
|
||||
"vector": write_record.embedding,
|
||||
"vector": vector,
|
||||
"payload": qdrant_payload(
|
||||
write_record.record,
|
||||
content_hash=write_record.content_hash,
|
||||
@@ -291,6 +329,14 @@ class QdrantVectorStore:
|
||||
def _workspace_filter(self) -> list[dict]:
|
||||
return [{"key": "workspace_id", "match": {"value": self._workspace_id}}]
|
||||
|
||||
@staticmethod
|
||||
def _bm25_document(text: str, language: str) -> dict:
|
||||
return {
|
||||
"text": text,
|
||||
"model": "qdrant/bm25",
|
||||
"options": {"language": language},
|
||||
}
|
||||
|
||||
def _revision_filter(self, kinds: list[str]) -> list[dict]:
|
||||
if self._workspace_revision is None:
|
||||
return []
|
||||
|
||||
@@ -14,6 +14,14 @@ from tht.cli.config_cmd import CONFIG_OPT
|
||||
preprocess_app = typer.Typer(help="Materialize versioned preprocessing artifacts")
|
||||
|
||||
|
||||
def _bm25_language(workspace_language: str) -> str:
|
||||
languages = {"en": "english", "it": "italian"}
|
||||
try:
|
||||
return languages[workspace_language]
|
||||
except KeyError as exc:
|
||||
raise ValueError("workspace language is unsupported for Qdrant BM25") from exc
|
||||
|
||||
|
||||
def _evidence_json_context(config: Path):
|
||||
from tht.cli.schema_cmd import _load_config_or_exit
|
||||
|
||||
@@ -112,6 +120,7 @@ def run_from_config(config: Path, *, dry_run: bool = False, resume: str | None =
|
||||
chunk_policy=ChunkPolicy(version="chunk-v1", max_chars=cfg.vector.max_chunk_chars),
|
||||
pipeline_version="evidence-v1",
|
||||
retain_published_generations=cfg.vector.retain_published_generations,
|
||||
sparse_language=_bm25_language(cfg.language),
|
||||
)
|
||||
def fingerprint(value: str) -> str:
|
||||
return "sha256:" + hashlib.sha256(value.encode()).hexdigest()
|
||||
@@ -145,6 +154,7 @@ def gc_from_config(config: Path, *, dry_run: bool = False):
|
||||
chunk_policy=ChunkPolicy(version="chunk-v1", max_chars=cfg.vector.max_chunk_chars),
|
||||
pipeline_version="evidence-v1",
|
||||
retain_published_generations=cfg.vector.retain_published_generations,
|
||||
sparse_language=_bm25_language(cfg.language),
|
||||
)
|
||||
pipeline.workspace_id = cfg._workspace_id
|
||||
return pipeline.gc(workspace_root=corpus_root.parent, dry_run=dry_run)
|
||||
|
||||
@@ -55,10 +55,20 @@ def open_searcher(cfg):
|
||||
store = build_vector_store(cfg)
|
||||
|
||||
class AdapterSearcher:
|
||||
def search(self, query_vec, top_n=10, kinds=None, metadata_filter=None):
|
||||
def search(
|
||||
self,
|
||||
query_vec,
|
||||
top_n=10,
|
||||
kinds=None,
|
||||
metadata_filter=None,
|
||||
query_text=None,
|
||||
query_language=None,
|
||||
):
|
||||
return store.search(
|
||||
tables_for_kinds(kinds), query_vec, limit=top_n, kinds=kinds,
|
||||
metadata_filter=metadata_filter,
|
||||
query_text=query_text,
|
||||
query_language=query_language,
|
||||
)
|
||||
|
||||
return AdapterSearcher()
|
||||
|
||||
@@ -123,7 +123,7 @@ class CorpusPipeline:
|
||||
self, *, store: CorpusStore, sources: list[EvidenceSource], embedder,
|
||||
vector_store: VectorStore, embedding_model: str, embedding_dimensions: int,
|
||||
chunk_policy: ChunkPolicy, pipeline_version: str, retain_published_generations: int = 3,
|
||||
workspace_id: str | None = None,
|
||||
workspace_id: str | None = None, sparse_language: str = "italian",
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.sources = sources
|
||||
@@ -137,6 +137,9 @@ class CorpusPipeline:
|
||||
raise ValueError("retain_published_generations must be at least 1")
|
||||
self.retain_published_generations = retain_published_generations
|
||||
self.workspace_id = workspace_id
|
||||
if sparse_language not in {"english", "italian"}:
|
||||
raise ValueError("unsupported Qdrant BM25 language")
|
||||
self.sparse_language = sparse_language
|
||||
|
||||
def _assert_workspace_binding(self) -> None:
|
||||
manifest = self.store.active_manifest()
|
||||
@@ -795,9 +798,8 @@ class CorpusPipeline:
|
||||
except Exception:
|
||||
logger.debug("Failed to delete the unpublished vector generation", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _vector_record(
|
||||
chunk: CanonicalChunk, embedding: list[float], generation: str, workspace_id: str,
|
||||
self, chunk: CanonicalChunk, embedding: list[float], generation: str, workspace_id: str,
|
||||
):
|
||||
record = VectorRecord(
|
||||
id=f"{workspace_id}:{generation}:{chunk.chunk_id}",
|
||||
@@ -810,4 +812,10 @@ class CorpusPipeline:
|
||||
"vector_generation": generation,
|
||||
},
|
||||
)
|
||||
return VectorWriteRecord(record=record, embedding=embedding, content_hash=chunk.content_hash)
|
||||
return VectorWriteRecord(
|
||||
record=record,
|
||||
embedding=embedding,
|
||||
content_hash=chunk.content_hash,
|
||||
sparse_text=chunk.content,
|
||||
sparse_language=self.sparse_language,
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ def build_preprocessing_pipeline(
|
||||
pipeline_version: str,
|
||||
retain_published_generations: int = 3,
|
||||
workspace_id: str | None = None,
|
||||
sparse_language: str = "italian",
|
||||
) -> CorpusPipeline:
|
||||
"""Construct preprocessing from the bounded infrastructure supplied by core."""
|
||||
return CorpusPipeline(
|
||||
@@ -38,6 +39,7 @@ def build_preprocessing_pipeline(
|
||||
pipeline_version=pipeline_version,
|
||||
retain_published_generations=retain_published_generations,
|
||||
workspace_id=workspace_id,
|
||||
sparse_language=sparse_language,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import re
|
||||
|
||||
from tht.evidence.corpus.store import CorpusStore
|
||||
from tht.ports.vector import VectorStoreError
|
||||
|
||||
|
||||
class CorpusWorkspaceMismatchError(RuntimeError):
|
||||
@@ -12,12 +13,27 @@ class CorpusWorkspaceMismatchError(RuntimeError):
|
||||
class ActiveEvidenceSearcher:
|
||||
"""Searcher facade that enforces ACTIVE generation predicates before LIMIT."""
|
||||
|
||||
def __init__(self, corpus: CorpusStore, delegate, expected_workspace_id: str | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
corpus: CorpusStore,
|
||||
delegate,
|
||||
expected_workspace_id: str | None = None,
|
||||
evidence_language: str = "italian",
|
||||
):
|
||||
self.corpus = corpus
|
||||
self.delegate = delegate
|
||||
self.expected_workspace_id = expected_workspace_id
|
||||
self.evidence_language = evidence_language
|
||||
|
||||
def search(self, embedding, top_n=10, kinds=None, metadata_filter=None):
|
||||
def search(
|
||||
self,
|
||||
embedding,
|
||||
top_n=10,
|
||||
kinds=None,
|
||||
metadata_filter=None,
|
||||
query_text=None,
|
||||
query_language=None,
|
||||
):
|
||||
requested = set(kinds) if kinds is not None else {
|
||||
"schema_table", "schema_column", "evidence", "memory", "solved_question",
|
||||
}
|
||||
@@ -59,9 +75,13 @@ class ActiveEvidenceSearcher:
|
||||
generation = mapping.get(document.document_id, manifest.vector_generation)
|
||||
if generation:
|
||||
by_generation.setdefault(generation, []).append(document.document_id)
|
||||
if by_generation and (not isinstance(query_text, str) or query_text.strip() == ""):
|
||||
raise VectorStoreError("Evidence hybrid query text is required")
|
||||
for generation, document_ids in sorted(by_generation.items()):
|
||||
hits.extend(self.delegate.search(
|
||||
embedding, top_n=top_n, kinds=["evidence"],
|
||||
query_text=query_text,
|
||||
query_language=query_language or self.evidence_language,
|
||||
metadata_filter={
|
||||
"vector_generation": generation,
|
||||
"document_ids": sorted(document_ids),
|
||||
@@ -73,7 +93,11 @@ class ActiveEvidenceSearcher:
|
||||
|
||||
def active_searcher(cfg, delegate, *, workspace_id: str | None = None):
|
||||
corpus_root = cfg.paths.artifacts.parent / "corpus"
|
||||
return ActiveEvidenceSearcher(CorpusStore(corpus_root), delegate, workspace_id)
|
||||
languages = {"en": "english", "it": "italian"}
|
||||
language = languages.get(getattr(cfg, "language", "en"))
|
||||
if language is None:
|
||||
raise VectorStoreError("workspace language is unsupported for Qdrant BM25")
|
||||
return ActiveEvidenceSearcher(CorpusStore(corpus_root), delegate, workspace_id, language)
|
||||
|
||||
|
||||
def validate_corpus_workspace(cfg, workspace_id: str) -> None:
|
||||
|
||||
@@ -39,6 +39,8 @@ class VectorWriteRecord:
|
||||
record: VectorRecord
|
||||
embedding: list[float]
|
||||
content_hash: str
|
||||
sparse_text: str | None = None
|
||||
sparse_language: str | None = None
|
||||
|
||||
|
||||
class VectorStoreError(Exception):
|
||||
@@ -74,6 +76,8 @@ class VectorStore(Protocol):
|
||||
limit: int,
|
||||
kinds: list[str] | None = None,
|
||||
metadata_filter: dict[str, object] | None = None,
|
||||
query_text: str | None = None,
|
||||
query_language: str | None = None,
|
||||
) -> list[VectorHit]: ...
|
||||
|
||||
def existing_hashes(self, collection: str, kinds: list[str]) -> dict[str, str]: ...
|
||||
|
||||
@@ -112,7 +112,10 @@ def combined_search(
|
||||
|
||||
if query_vec is None:
|
||||
query_vec = embedder.embed_query(keyword)
|
||||
vector_hits = store.search(query_vec, top_n=top * 2, kinds=kinds)
|
||||
search_kwargs = {"top_n": top * 2, "kinds": kinds}
|
||||
if kinds is not None and "evidence" in kinds:
|
||||
search_kwargs["query_text"] = keyword
|
||||
vector_hits = store.search(query_vec, **search_kwargs)
|
||||
rankings["vector"] = [(_vector_key(h), h.similarity) for h in vector_hits]
|
||||
by_key = {_vector_key(h): h for h in vector_hits}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user