Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- app/concepts.py +17 -0
- app/models.py +21 -3
- app/pipeline.py +46 -1
app/concepts.py
CHANGED
|
@@ -21,6 +21,23 @@ from app.models import ConceptSearchRequest
|
|
| 21 |
VERSION = "concepts-v1"
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
class ConceptError(Exception):
|
| 25 |
"""A concept search that cannot honestly return matches."""
|
| 26 |
|
|
|
|
| 21 |
VERSION = "concepts-v1"
|
| 22 |
|
| 23 |
|
| 24 |
+
def aim(
|
| 25 |
+
query: list[float], positive: list[list[float]], negative: list[list[float]]
|
| 26 |
+
) -> list[float] | None:
|
| 27 |
+
"""Point a question's own vector at the concepts kept and away from those pushed off.
|
| 28 |
+
|
| 29 |
+
The question is simply one more positive term, so a tagged search is the
|
| 30 |
+
same search: retrieval still matches the question's keywords, and the tags
|
| 31 |
+
only move the vector half of the ranking. ``None`` when the terms cancel the
|
| 32 |
+
question out, which has no direction to search in.
|
| 33 |
+
"""
|
| 34 |
+
combined = combine(
|
| 35 |
+
[np.asarray(query, dtype=np.float32), *(np.asarray(v, dtype=np.float32) for v in positive)],
|
| 36 |
+
[np.asarray(v, dtype=np.float32) for v in negative],
|
| 37 |
+
)
|
| 38 |
+
return None if combined is None else [float(value) for value in combined]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
class ConceptError(Exception):
|
| 42 |
"""A concept search that cannot honestly return matches."""
|
| 43 |
|
app/models.py
CHANGED
|
@@ -4,6 +4,8 @@ from typing import Annotated, Literal
|
|
| 4 |
|
| 5 |
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 6 |
|
|
|
|
|
|
|
| 7 |
Bucket = Literal[
|
| 8 |
"effect", "credible_null", "reported_null", "inconclusive", "failed", "unreported"
|
| 9 |
]
|
|
@@ -50,6 +52,24 @@ class SearchFilters(BaseModel):
|
|
| 50 |
return self.yearFrom is not None or self.yearTo is not None
|
| 51 |
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
class SearchRequest(BaseModel):
|
| 54 |
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False)
|
| 55 |
idea: str = Field(min_length=8, max_length=12000)
|
|
@@ -64,6 +84,7 @@ class SearchRequest(BaseModel):
|
|
| 64 |
outcomeSd: float | None = Field(default=None, gt=0, le=1e6)
|
| 65 |
baselineRisk: float | None = Field(default=None, gt=0, lt=1)
|
| 66 |
filters: SearchFilters | None = None
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
class Pico(BaseModel):
|
|
@@ -240,9 +261,6 @@ class MapRequest(BaseModel):
|
|
| 240 |
refresh: bool = False
|
| 241 |
|
| 242 |
|
| 243 |
-
ConceptTerm = Annotated[str, Field(min_length=2, max_length=200)]
|
| 244 |
-
|
| 245 |
-
|
| 246 |
class ConceptSearchRequest(BaseModel):
|
| 247 |
"""Concepts to add and to subtract before the index is searched by nearest neighbour.
|
| 248 |
|
|
|
|
| 4 |
|
| 5 |
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 6 |
|
| 7 |
+
ConceptTerm = Annotated[str, Field(min_length=2, max_length=200)]
|
| 8 |
+
|
| 9 |
Bucket = Literal[
|
| 10 |
"effect", "credible_null", "reported_null", "inconclusive", "failed", "unreported"
|
| 11 |
]
|
|
|
|
| 52 |
return self.yearFrom is not None or self.yearTo is not None
|
| 53 |
|
| 54 |
|
| 55 |
+
class ConceptSteer(BaseModel):
|
| 56 |
+
"""Concepts that aim the same search, rather than a search of their own.
|
| 57 |
+
|
| 58 |
+
Each term is one more direction added to (or subtracted from) the question's
|
| 59 |
+
own vector before retrieval, so the tags change what ranks highest among the
|
| 60 |
+
question's matches. Subtracting is a direction, not a filter: a paper is
|
| 61 |
+
pushed down for being about the term, never removed for mentioning it.
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
| 65 |
+
positive: list[ConceptTerm] = Field(default_factory=list, max_length=8)
|
| 66 |
+
negative: list[ConceptTerm] = Field(default_factory=list, max_length=8)
|
| 67 |
+
|
| 68 |
+
@property
|
| 69 |
+
def active(self) -> bool:
|
| 70 |
+
return bool(self.positive or self.negative)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
class SearchRequest(BaseModel):
|
| 74 |
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False)
|
| 75 |
idea: str = Field(min_length=8, max_length=12000)
|
|
|
|
| 84 |
outcomeSd: float | None = Field(default=None, gt=0, le=1e6)
|
| 85 |
baselineRisk: float | None = Field(default=None, gt=0, lt=1)
|
| 86 |
filters: SearchFilters | None = None
|
| 87 |
+
concepts: ConceptSteer | None = None
|
| 88 |
|
| 89 |
|
| 90 |
class Pico(BaseModel):
|
|
|
|
| 261 |
refresh: bool = False
|
| 262 |
|
| 263 |
|
|
|
|
|
|
|
|
|
|
| 264 |
class ConceptSearchRequest(BaseModel):
|
| 265 |
"""Concepts to add and to subtract before the index is searched by nearest neighbour.
|
| 266 |
|
app/pipeline.py
CHANGED
|
@@ -8,10 +8,11 @@ from datetime import UTC, datetime
|
|
| 8 |
from time import perf_counter
|
| 9 |
from uuid import uuid4
|
| 10 |
|
|
|
|
| 11 |
from app.config import Settings, settings
|
| 12 |
from app.fulltext import FullTextClient
|
| 13 |
from app.llm import LLMService, Usage
|
| 14 |
-
from app.models import Pico, SearchRequest
|
| 15 |
from app.repository import (
|
| 16 |
BUCKETS,
|
| 17 |
EFFECT_DIRECTIONS,
|
|
@@ -221,6 +222,45 @@ class SearchPipeline:
|
|
| 221 |
self.embedder = get_embedder(self.config.embedding_model, self.config.embedding_device)
|
| 222 |
return await asyncio.to_thread(self.embedder.embed_query, text)
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
async def expand(
|
| 225 |
self,
|
| 226 |
hits: list[dict],
|
|
@@ -767,6 +807,9 @@ class SearchPipeline:
|
|
| 767 |
vector = None
|
| 768 |
logger.warning("Local embeddings unavailable: %s", type(exc).__name__)
|
| 769 |
warnings.append("Local embeddings are unavailable; retrieval used BM25 keyword search.")
|
|
|
|
|
|
|
|
|
|
| 770 |
hits, mode = await self.repo.retrieve(query, vector)
|
| 771 |
expanded = await self.expand(hits, vector, warnings, pico.model_dump(), filters)
|
| 772 |
expansion_ids = [d["id"] for d in expanded]
|
|
@@ -993,6 +1036,8 @@ class SearchPipeline:
|
|
| 993 |
"retrieval": {
|
| 994 |
"mode": mode,
|
| 995 |
"expanded": len(expanded),
|
|
|
|
|
|
|
| 996 |
"durationMs": round((perf_counter() - started) * 1000),
|
| 997 |
},
|
| 998 |
"spin": aggregation["spin"],
|
|
|
|
| 8 |
from time import perf_counter
|
| 9 |
from uuid import uuid4
|
| 10 |
|
| 11 |
+
from app.concepts import aim
|
| 12 |
from app.config import Settings, settings
|
| 13 |
from app.fulltext import FullTextClient
|
| 14 |
from app.llm import LLMService, Usage
|
| 15 |
+
from app.models import ConceptSteer, Pico, SearchRequest
|
| 16 |
from app.repository import (
|
| 17 |
BUCKETS,
|
| 18 |
EFFECT_DIRECTIONS,
|
|
|
|
| 222 |
self.embedder = get_embedder(self.config.embedding_model, self.config.embedding_device)
|
| 223 |
return await asyncio.to_thread(self.embedder.embed_query, text)
|
| 224 |
|
| 225 |
+
async def aim(
|
| 226 |
+
self, vector: list[float] | None, steer: ConceptSteer, warnings: list[str]
|
| 227 |
+
) -> list[float] | None:
|
| 228 |
+
"""Move the question's vector towards the kept concepts and away from the pushed ones.
|
| 229 |
+
|
| 230 |
+
The tags change ranking, never the match set: the lexical query is
|
| 231 |
+
untouched, so a search with tags returns the question's studies in a
|
| 232 |
+
different order rather than a different search's studies.
|
| 233 |
+
"""
|
| 234 |
+
if vector is None:
|
| 235 |
+
warnings.append(
|
| 236 |
+
"Concept tags need embeddings to steer ranking, and embeddings are "
|
| 237 |
+
"unavailable here, so the tags were ignored."
|
| 238 |
+
)
|
| 239 |
+
return None
|
| 240 |
+
terms = list(dict.fromkeys([*steer.positive, *steer.negative]))
|
| 241 |
+
try:
|
| 242 |
+
embedded = await asyncio.gather(*(self.embed(term) for term in terms))
|
| 243 |
+
except Exception as exc:
|
| 244 |
+
logger.warning("Concept embedding unavailable: %s", type(exc).__name__)
|
| 245 |
+
warnings.append("The concept tags could not be embedded, so ranking was not steered.")
|
| 246 |
+
return vector
|
| 247 |
+
by_term = dict(zip(terms, embedded))
|
| 248 |
+
if any(by_term[term] is None for term in terms):
|
| 249 |
+
warnings.append("The concept tags could not be embedded, so ranking was not steered.")
|
| 250 |
+
return vector
|
| 251 |
+
steered = aim(
|
| 252 |
+
vector,
|
| 253 |
+
[by_term[term] for term in steer.positive],
|
| 254 |
+
[by_term[term] for term in steer.negative],
|
| 255 |
+
)
|
| 256 |
+
if steered is None:
|
| 257 |
+
warnings.append(
|
| 258 |
+
"The concept tags cancelled the question out, leaving no direction to "
|
| 259 |
+
"search in, so ranking was not steered."
|
| 260 |
+
)
|
| 261 |
+
return vector
|
| 262 |
+
return steered
|
| 263 |
+
|
| 264 |
async def expand(
|
| 265 |
self,
|
| 266 |
hits: list[dict],
|
|
|
|
| 807 |
vector = None
|
| 808 |
logger.warning("Local embeddings unavailable: %s", type(exc).__name__)
|
| 809 |
warnings.append("Local embeddings are unavailable; retrieval used BM25 keyword search.")
|
| 810 |
+
steer = request.concepts if request.concepts and request.concepts.active else None
|
| 811 |
+
if steer:
|
| 812 |
+
vector = await self.aim(vector, steer, warnings)
|
| 813 |
hits, mode = await self.repo.retrieve(query, vector)
|
| 814 |
expanded = await self.expand(hits, vector, warnings, pico.model_dump(), filters)
|
| 815 |
expansion_ids = [d["id"] for d in expanded]
|
|
|
|
| 1036 |
"retrieval": {
|
| 1037 |
"mode": mode,
|
| 1038 |
"expanded": len(expanded),
|
| 1039 |
+
# Echoed so a reordered page can say what reordered it.
|
| 1040 |
+
"concepts": steer.model_dump() if steer else None,
|
| 1041 |
"durationMs": round((perf_counter() - started) * 1000),
|
| 1042 |
},
|
| 1043 |
"spin": aggregation["spin"],
|