Fix ai-service Dockerfile: bake in drug_entities.json, override its path
This commit is contained in:
@@ -233,6 +233,118 @@ class QdrantRetriever:
|
||||
hits.append(SearchHit(_document(payload), 1.0))
|
||||
return hits
|
||||
|
||||
def find_by_indication(self, indication_text: str, limit: int) -> list[SearchHit]:
|
||||
"""Reverse lookup: every drug whose `chi_dinh` text mentions the given
|
||||
symptom/indication, keyword-matched. Deterministic, no fabrication
|
||||
risk — the same "exact match wins, no-match-means-None" philosophy
|
||||
`find_by_section` already uses, applied across drugs instead of
|
||||
within one. `limit` caps how many DRUGS are returned (one hit per
|
||||
drug, first match wins), not how many chunks are scanned — a common
|
||||
symptom can match far more drugs than is useful to show.
|
||||
|
||||
Prose only: a `block_descriptor` chunk carries no real `chi_dinh`
|
||||
text (its text is built only from metadata per the quarantine
|
||||
contract), so keyword-matching it would be meaningless.
|
||||
|
||||
`indication_text`, normalized, must appear as a CONTIGUOUS,
|
||||
word-boundary-anchored phrase in the chunk's text — not a plain
|
||||
substring (risks a false positive inside an unrelated longer word
|
||||
after diacritic-stripping) and not a scattered bag-of-words match
|
||||
either. Found live 2026-08-07: a token-SUBSET match (every word
|
||||
present *somewhere*, any order) let a long nonsense phrase built
|
||||
from common filler words ("bệnh chưa từng ghi nhận trong sách…")
|
||||
false-positive against real chi_dinh text, since words that common
|
||||
appear scattered through nearly everything — it reached generation
|
||||
before being caught, instead of failing here where it's cheap. A
|
||||
genuine paraphrase that doesn't share the book's exact wording is
|
||||
`search_indication`'s job (semantic), not this one's (lexical).
|
||||
"""
|
||||
import re
|
||||
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from rag.text import normalize_name
|
||||
|
||||
needle = normalize_name(indication_text)
|
||||
if not needle:
|
||||
return []
|
||||
needle_pattern = re.compile(rf"(?:^| ){re.escape(needle)}(?:$| )")
|
||||
|
||||
scroll_filter = Filter(
|
||||
must=[
|
||||
FieldCondition(key="section_key", match=MatchValue(value="chi_dinh")),
|
||||
FieldCondition(key="chunk_kind", match=MatchValue(value="prose")),
|
||||
]
|
||||
)
|
||||
hits: list[SearchHit] = []
|
||||
seen_drugs: set[str] = set()
|
||||
offset = None
|
||||
while True:
|
||||
points, offset = self._client.scroll(
|
||||
collection_name=self._collection_name,
|
||||
scroll_filter=scroll_filter,
|
||||
limit=256,
|
||||
offset=offset,
|
||||
with_payload=True,
|
||||
)
|
||||
for point in points:
|
||||
payload = dict(point.payload or {})
|
||||
drug_id = payload.get("drug_id")
|
||||
if drug_id in seen_drugs:
|
||||
continue
|
||||
text = normalize_name(payload.get("text", ""))
|
||||
if not needle_pattern.search(f" {text} "):
|
||||
continue
|
||||
seen_drugs.add(drug_id)
|
||||
hits.append(SearchHit(_document(payload), 1.0))
|
||||
if len(hits) >= limit:
|
||||
return hits
|
||||
if offset is None:
|
||||
break
|
||||
return hits
|
||||
|
||||
def search_indication(self, query: str, limit: int) -> list[SearchHit]:
|
||||
"""Dense-vector fallback for `find_by_indication` when no exact
|
||||
keyword phrase match exists — catches paraphrases ("sốt cao" vs
|
||||
"thân nhiệt tăng") a literal phrase match cannot. Deliberately narrow
|
||||
(`section_key=chi_dinh` only, never the whole corpus) so this stays
|
||||
a targeted fallback for one specific gap, not a return to unranked
|
||||
similarity search — see ADR 0008 on why the live path otherwise
|
||||
avoids `search()`. One hit per drug, highest-scoring chunk kept
|
||||
(Qdrant returns points pre-sorted by score)."""
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
vector = list(self._embedder.embed_query(query))
|
||||
if len(vector) != self._embedder.dimensions:
|
||||
raise ValueError(
|
||||
f"query vector has {len(vector)} dimensions; "
|
||||
f"expected {self._embedder.dimensions}"
|
||||
)
|
||||
points = self._client.search(
|
||||
collection_name=self._collection_name,
|
||||
query_vector=vector,
|
||||
query_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="section_key", match=MatchValue(value="chi_dinh")),
|
||||
FieldCondition(key="chunk_kind", match=MatchValue(value="prose")),
|
||||
]
|
||||
),
|
||||
limit=limit * 4,
|
||||
with_payload=True,
|
||||
)
|
||||
hits: list[SearchHit] = []
|
||||
seen_drugs: set[str] = set()
|
||||
for point in points:
|
||||
payload = dict(point.payload or {})
|
||||
drug_id = payload.get("drug_id")
|
||||
if drug_id in seen_drugs:
|
||||
continue
|
||||
seen_drugs.add(drug_id)
|
||||
hits.append(SearchHit(_document(payload), float(point.score)))
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
return hits
|
||||
|
||||
|
||||
class QdrantParentStore:
|
||||
def __init__(self, client: Any, collection_name: str) -> None:
|
||||
|
||||
Reference in New Issue
Block a user