Skip to content

Commit f87fd67

Browse files
author
cl117
committed
add evaluation
1 parent 7df2c5e commit f87fd67

5 files changed

Lines changed: 198 additions & 4 deletions

File tree

evaluation/rankers.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,60 @@ def ranker_weighted_sum(query, alpha=0.5, exact_boost=0.5, pr_scale=1e6):
162162
return [d for d, _ in scored]
163163

164164

165+
def ranker_es_equation(query, pool_sort='_text_match(buckets: 1):desc,pagerank:desc'):
166+
"""
167+
FAITHFUL PORT of the old Elasticsearch ranking (master branch), for
168+
calibration. master/search.py ranks with a function_score whose script is:
169+
170+
script: _score * Math.log(pagerank + 1) # ln, natural log
171+
172+
BUT master sets NO `boost_mode`, and ES defaults boost_mode to MULTIPLY, so
173+
the effective score master actually computes is:
174+
175+
final = _score * (_score * ln(pagerank+1)) = _score^2 * ln(pagerank+1)
176+
177+
i.e. the text score is SQUARED. We reproduce that here: `text_match` is the
178+
Typesense analog of ES's `_score`, squared, times ln(pagerank+1). NO
179+
normalization, NO pr_scale knob (those belong to the new weighted_sum/
180+
multiplicative rankers, which are left intact).
181+
182+
Caveat: ES `_score` (BM25) and Typesense `text_match` are different engines'
183+
relevance scores, so this is the ES *equation* on Typesense's index, not
184+
byte-identical ES output.
185+
186+
pool_sort controls how the candidate pool is pulled:
187+
- buckets:1 (default) -- pagerank-aware pool; text score is flattened into
188+
one bucket, so the re-score is pagerank-dominated (squaring text has no
189+
effect). This is the faithful "old /search" pool.
190+
- '_text_match:desc,pagerank:desc' -- RAW continuous text score pool; lets
191+
text_match (and its square) actually reshuffle results, closer to how ES
192+
ranks on continuous BM25. Risk: with >pool exact-name matches, a canonical
193+
high-pagerank part can fall OUT of the pool entirely (the original bug).
194+
"""
195+
hits = _raw_search(query, pool_sort, per_page=CANDIDATE_POOL)
196+
if not hits:
197+
return []
198+
scored = []
199+
seen = set()
200+
for h in hits:
201+
doc = h['document']
202+
did = doc.get('displayId')
203+
if not did or did in seen:
204+
continue
205+
seen.add(did)
206+
pr = doc.get('pagerank', 0) or 0
207+
tm = h.get('text_match', 0)
208+
score = tm * tm * log1p(pr) # _score^2 * ln(pagerank + 1) (master's real boost_mode=multiply)
209+
scored.append((did, score))
210+
scored.sort(key=lambda x: x[1], reverse=True)
211+
return [d for d, _ in scored]
212+
213+
165214
# Registry the runner iterates over. Add new strategies here.
166215
# Original strategies are kept untouched for experiment reproducibility.
167216
RANKERS = {
217+
'es_equation(ln)': ranker_es_equation, # faithful old-ES baseline: text^2 * ln(pr+1), buckets:1 pool
218+
'es_eq(rawpool)': lambda q: ranker_es_equation(q, '_text_match:desc,pagerank:desc'), # raw continuous text pool
168219
'current(buckets10)': ranker_current,
169220
'buckets1': ranker_buckets1,
170221
'multiplicative': ranker_multiplicative,

evaluation/run_eval.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
Usage:
1010
python run_eval.py # summary table over all rankers
1111
python run_eval.py --per-query # also show per-query NDCG + top-gold rank
12+
python run_eval.py --raw-ranks # grid of top-gold RAW RANK: query x ranker
1213
python run_eval.py --gold mygold.json
1314
1415
Requires Typesense to be running (docker start typesense).
@@ -29,15 +30,45 @@ def load_gold(path):
2930
return json.load(f)
3031

3132

32-
def run(gold_path, per_query):
33+
def print_raw_rank_grid(gold_set, ranker_names, rank_matrix):
34+
"""Grid of the top-gold RAW RANK for every (query, ranker). '-' = not found.
35+
36+
rank_matrix[ranker_name][query] -> int rank or None. This is the position
37+
view the summary metrics compress away -- it exposes catastrophic burials
38+
(e.g. terminator -> rank 64) that a mean NDCG hides.
39+
"""
40+
queries = [e['query'] for e in gold_set]
41+
qw = max(len(q) for q in queries)
42+
# shorten ranker headers so the grid stays narrow
43+
def short(n):
44+
return n if len(n) <= 11 else n[:10] + '.'
45+
headers = [short(n) for n in ranker_names]
46+
colw = [max(len(h), 5) for h in headers]
47+
48+
header = f'{"query":<{qw}}' + ''.join(f' {h:>{w}}' for h, w in zip(headers, colw))
49+
print('=== top-gold RAW RANK grid (lower is better; - = not retrieved) ===')
50+
print(header)
51+
print('-' * len(header))
52+
for q in queries:
53+
row = f'{q:<{qw}}'
54+
for name, w in zip(ranker_names, colw):
55+
r = rank_matrix[name].get(q)
56+
row += f' {(str(r) if r is not None else "-"):>{w}}'
57+
print(row)
58+
print()
59+
60+
61+
def run(gold_path, per_query, raw_ranks):
3362
gold_set = load_gold(gold_path)
3463
print(f'Gold set: {len(gold_set)} queries from {os.path.basename(gold_path)}\n')
3564

3665
summary = {}
66+
rank_matrix = {} # ranker_name -> {query: top_gold_rank}
3767
for name, fn in rankers.RANKERS.items():
3868
agg = {'P@1': 0.0, 'P@5': 0.0, 'P@10': 0.0, 'MRR': 0.0, 'NDCG@10': 0.0}
3969
latencies = []
4070
per_query_rows = []
71+
rank_matrix[name] = {}
4172

4273
for entry in gold_set:
4374
q, gold = entry['query'], entry['gold']
@@ -49,6 +80,7 @@ def run(gold_path, per_query):
4980
for key in agg:
5081
agg[key] += m[key]
5182
per_query_rows.append((q, m['NDCG@10'], m['top_gold_rank']))
83+
rank_matrix[name][q] = m['top_gold_rank']
5284

5385
n = len(gold_set)
5486
for key in agg:
@@ -63,6 +95,9 @@ def run(gold_path, per_query):
6395
print(f' {q:28} NDCG@10={nd:.3f} top_gold_rank={rank_s}')
6496
print()
6597

98+
if raw_ranks:
99+
print_raw_rank_grid(gold_set, list(rankers.RANKERS.keys()), rank_matrix)
100+
66101
# Summary table
67102
cols = ['P@1', 'P@5', 'P@10', 'MRR', 'NDCG@10', 'latency_ms']
68103
header = f'{"ranker":22}' + ''.join(f'{c:>12}' for c in cols)
@@ -80,5 +115,7 @@ def run(gold_path, per_query):
80115
ap = argparse.ArgumentParser()
81116
ap.add_argument('--gold', default=GOLD_DEFAULT)
82117
ap.add_argument('--per-query', action='store_true')
118+
ap.add_argument('--raw-ranks', action='store_true',
119+
help='print a query x ranker grid of the top-gold raw rank')
83120
args = ap.parse_args()
84-
run(args.gold, args.per_query)
121+
run(args.gold, args.per_query, args.raw_ranks)

flask/explorer.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,5 +218,30 @@ def search_by_string():
218218
log.error(f'Error during search by string: {e}')
219219
raise
220220

221+
@app.route('/facets', methods=['GET'])
222+
def facets_by_string():
223+
"""
224+
Return facet counts (part role, part type, ...) for a text query, used to
225+
build the left-hand filter sidebar. Served entirely by Typesense -- NO SPARQL.
226+
227+
Example: GET /facets?query=rbs&facet_by=role,type,sboltype
228+
Response: {"found": 491,
229+
"facets": {"role": [{"value": "...Composite", "count": 163}, ...],
230+
"type": [...], "sboltype": [...]}}
231+
"""
232+
try:
233+
client = typesense_manager.get_client()
234+
collection_name = config_manager.get_typesense_collection_name()
235+
if not _collection_exists(client, collection_name):
236+
abort(503, 'Typesense is not working or the collection does not exist.')
237+
238+
query = request.args.get('query', '')
239+
facet_by = request.args.get('facet_by', 'role,type,sboltype')
240+
facet_fields = [f.strip() for f in facet_by.split(',') if f.strip()]
241+
return jsonify(search.get_facets(query, facet_fields))
242+
except Exception as e:
243+
log.error(f'Error during facet search: {e}')
244+
raise
245+
221246
if __name__ == "__main__":
222247
app.run(debug=False, threaded=True) # threaded=True

flask/index.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
{'name': 'name', 'type': 'string', 'optional': True},
2020
{'name': 'description', 'type': 'string', 'optional': True},
2121
{'name': 'type', 'type': 'string', 'optional': True, 'facet': True},
22-
{'name': 'role', 'type': 'string', 'optional': True},
23-
{'name': 'sboltype', 'type': 'string', 'optional': True},
22+
{'name': 'role', 'type': 'string', 'optional': True, 'facet': True},
23+
{'name': 'sboltype', 'type': 'string', 'optional': True, 'facet': True},
2424
{'name': 'keywords', 'type': 'string', 'optional': True},
2525
{'name': 'graph', 'type': 'string', 'facet': True},
2626
{'name': 'pagerank', 'type': 'float'},
@@ -51,6 +51,42 @@ def create_parts_collection(collection_name):
5151
logger_.log('Collection created', True)
5252

5353

54+
def upsert_synonyms(collection_name):
55+
"""
56+
Registers curated search synonyms (domain abbreviations, e.g. lac<->lacI,
57+
rfp<->mRFP1, "ribosome binding site"<->rbs) on the collection.
58+
59+
Synonyms are COLLECTION-SCOPED, so create_parts_collection() wipes them every
60+
time it recreates the collection -- this MUST run on every reindex or the
61+
recall fixes silently disappear. The list lives in synonyms.json (curated
62+
separately from code); each entry is {"id", "synonyms": [...]} for a
63+
multi-way set, optionally with "root" for a one-way expansion.
64+
65+
Missing/empty synonyms.json is non-fatal: indexing proceeds without synonyms.
66+
"""
67+
try:
68+
with open('synonyms.json', 'r') as f:
69+
synonym_sets = json.load(f)
70+
except FileNotFoundError:
71+
logger_.log('No synonyms.json found -> skipping synonyms', True)
72+
return
73+
74+
collection = typesense_manager.get_client().collections[collection_name]
75+
76+
# Clear existing synonyms first so the collection matches synonyms.json
77+
# exactly -- keeps this idempotent even when called standalone (not via a
78+
# full reindex, which would already recreate the collection from scratch).
79+
for existing in collection.synonyms.retrieve().get('synonyms', []):
80+
collection.synonyms[existing['id']].delete()
81+
82+
for entry in synonym_sets:
83+
body = {'synonyms': entry['synonyms']}
84+
if entry.get('root'):
85+
body['root'] = entry['root']
86+
collection.synonyms.upsert(entry['id'], body)
87+
logger_.log(f'Registered {len(synonym_sets)} synonym set(s)', True)
88+
89+
5490
def add_pagerank(parts_response, uri2rank):
5591
"""
5692
Adds the pagerank score for each part.
@@ -153,6 +189,7 @@ def update_index(uri2rank):
153189
add_sbol_type(parts_response)
154190
create_parts_collection(collection_name)
155191
bulk_index_parts(parts_response, collection_name)
192+
upsert_synonyms(collection_name)
156193

157194
logger_.log(f'******** Finished adding {len(parts_response)} parts to index ********', True)
158195
logger_.log('------------ Successfully updated index ------------\n', True)

flask/search.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,50 @@ def search_es(es_query: str) -> Dict:
8989
logger_.log("search_es(es_query: str)")
9090
raise
9191

92+
def get_facets(query_string: str, facet_fields: List[str]) -> Dict:
93+
"""
94+
Return facet counts for a text query WITHOUT fetching the documents.
95+
96+
A "facet" is a category field (part role, part type, ...). For the current
97+
query's matching set, Typesense counts how many parts fall under each value
98+
-- e.g. for query "rbs": role "Composite" -> 163, "Coding" -> 9. Those are the
99+
numbers shown in the left-hand filter dropdowns of the SynBioHub UI.
100+
101+
IMPORTANT: the counts are computed entirely by Typesense over the indexed
102+
facet fields -- NO SPARQL / Virtuoso call happens here. A field can only be
103+
faceted if it was declared with {'facet': True} in the collection schema
104+
(index.py) at index-build time, which requires a reindex to take effect.
105+
106+
Args:
107+
query_string: the user's search text (same string as /search); '' -> all.
108+
facet_fields: field names to facet on, e.g. ['role', 'type', 'sboltype'].
109+
110+
Returns:
111+
{'found': <total matches>,
112+
'facets': {'role': [{'value': ..., 'count': ...}, ...], ...}}
113+
"""
114+
collection_name = config_manager.get_typesense_collection_name()
115+
collection = typesense_manager.get_client().collections[collection_name]
116+
117+
params = {
118+
'q': query_string or '*', # '*' = match everything (empty query)
119+
'query_by': TEXT_QUERY_BY,
120+
'num_typos': '2',
121+
'facet_by': ','.join(facet_fields),
122+
'max_facet_values': 100, # distinct values returned per facet
123+
'per_page': 1, # we only want the counts, not the docs
124+
}
125+
response = collection.documents.search(params)
126+
127+
facets = {}
128+
for facet in response.get('facet_counts', []):
129+
facets[facet['field_name']] = [
130+
{'value': c['value'], 'count': c['count']}
131+
for c in facet.get('counts', [])
132+
]
133+
return {'found': response.get('found', 0), 'facets': facets}
134+
135+
92136
def _weighted_rerank(hits: List[Dict], query: str) -> List[Dict]:
93137
"""
94138
Re-rank Typesense hits by alpha*norm_text + (1-alpha)*norm_pr + boost*exact.

0 commit comments

Comments
 (0)