Conradf commited on
Commit
9178ee1
·
verified ·
1 Parent(s): 57a9a08

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. app/concepts.py +17 -0
  2. app/models.py +21 -3
  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"],