Lesson 06 / 16
Hybrid Search
Measuring keyword matching and embedding search separately and together. Three setups run side by side on three fictional query classes: in subscriber language keyword search is 0.0250 and embedding is 0.5000; in document language both are 0.5000; on the document code query keyword search is 1.0000 and embedding is 0.0000. Rank fusion gives 0.4000 in the document language class, that is, worse than both; this holds at constants of 5, 20 and 60. In the code class fusion drops the share of keeping the target document in the first five from 1.0000 to 0.0500. The reason is the number of documents shared by the first five of the two lists: 0.12 in subscriber language, 1.05 in document language. A document that stands second in both lists passes a document that is first in one and absent from the other. Fusion raises recall up to rank twenty from 0.7122 to 0.7214; the place it gains is depth, the place it loses is the first rank.
Published Updated
In this lesson
- Measures keyword and embedding search separately on three fictional query classes and counts which one collapses in which class.
- Shows that rank fusion changes the top-one hit rate and the recall up to rank twenty in opposite directions.
- Finds the query class in which the fusion stays worse than both setups and explains the cause with the overlap count of the two lists.
Contents
The previous lesson built the link between the query and the document from a single place: the angle of the embedding vectors. The filter narrowed this link, it did not change it. Yet what is searched in a support corpus is not always a meaning. When a document code, an error number or the exact name of a procedure is searched, the question asked is not “what is similar to this” but “where exactly does this occur”. Semantic similarity passes by the side of this question.
Hybrid search is running two rankings together for the same query and gathering their results into a single list. The expectation is that the whole is better than its parts: let keyword search catch the exact match that the embedding cannot see, and let the embedding catch the synonym that keyword search cannot see. This lesson measures three setups (keyword only, embedding only, combined) side by side on three query classes. One of the results does not meet the expectation: in one query class the combined setup stays worse than both procedures.
- EV48. The corpus, the embedding procedure, the tokenizer and the query classes are fictional; no real search engine, vector store or embedding endpoint is called. The seed is 20260218.
- EV49. The searchable text of the document is kept together with its code. Because the code is not a concept surface form, it does not change the embedding vector; this is shown in the block.
- EV50. The three query classes are derived from the same documents and differ only in their surface forms: subscriber language carries the query-side form, document language carries the document-side form, and the code query carries the document code. All three are forty queries.
- EV51. The keyword ranking is the number of overlapping tokens. Relevance scoring was built and measured in the Search Engines and Text Retrieval course of the Databases curriculum; it is not repeated here. In this lesson the only function of the keyword side is to produce a second ranking.
- EV52. Fusion is rank-based: the score of a document is the sum of the inverses of its ranks in the two
lists,
1 / (constant + rank). The constant is 20 by default and is also tested at 5 and 60. - EV53. The top-one hit rate is the share of the first result that carries the target concept, retrieval precision is the same share for the first five results, and retrieval recall is the share of the documents carrying the target concept that are caught in the first twenty. The target document column is the share of the document from which the query was derived being in the first five.
- EV54. Every concept is carried by more than one document; for this reason precision and recall are read at different depths and are not reduced to a single number.
- EV55. All three rankings are deterministic: the same query gives the same list in two runs. The run-independent measure of this lesson is the number of documents shared by the first five of the two lists, and it is a property of the pair of procedures, independent of the query.
- EV56. A query that carries no concept surface form produces a zero vector; all similarities become zero and the order falls to the storage order. This is not a defect but the embedding leaving its domain of definition, and it is measured in the code class.
- EV57. This lesson is the close of the topic; what the retrieved chunk does to the model is not measured and is left to the next topic.
Three Query Classes
# FICTIONAL corpus, FICTIONAL embedding simulator, FICTIONAL query classes. No real endpoint. SEED, M32 = 20260218, 0xFFFFFFFF DIM = 32 def rng(t): x = ((t ^ (t >> 16)) * 2246822507) & M32 x = ((x ^ (x >> 13)) * 3266489909) & M32 s = [(x ^ (x >> 16)) & M32] def next_value(): s[0] = (s[0] * 1664525 + 1013904223) & M32 return s[0] / 4294967296 return next_value def pick(u, w): t = 0.0 for i, x in enumerate(w): t += x if u < t: return i return len(w) - 1 def tokenize(m): # FICTIONAL tokenizer, pieces of at most five characters b = [] for s in m.lower().replace(",", " , ").split(): while len(s) > 5: b.append(s[:5]) s = s[5:] b.append(s) return b # ---- FICTIONAL semantic axis: each concept has a separate surface form on the document side and on the query side CLASSES = ["tariff", "fault", "meter", "outage", "maintenance"] CODE = {"tariff": "TR", "fault": "AR", "meter": "SY", "outage": "KS", "maintenance": "BK"} CONCEPTS = { # concept: (label, document-side form, query-side form) "tier": ("tariff", "tier threshold", "which band am i in"), "unit_rate": ("tariff", "unit price", "fee per cubic metre"), "subscriber_type": ("tariff", "subscriber type", "home or shop"), "switch": ("tariff", "tariff switch", "changing plan"), "fault_code": ("fault", "fault code", "error id"), "leak": ("fault", "line leakage", "water escaping"), "pressure": ("fault", "pressure drop", "weak flow"), "crew": ("fault", "field crew", "who will be sent"), "index_value": ("meter", "index value", "what the dial shows"), "reading": ("meter", "reading procedure", "how it gets saved"), "replacement": ("meter", "meter replacement", "new hardware"), "verify": ("meter", "reading verification", "typed in wrong"), "planned": ("outage", "planned outage", "announced stoppage"), "notice": ("outage", "notice channel", "where i hear about it"), "duration": ("outage", "outage duration", "how long it lasts"), "restoration": ("outage", "service restoration", "when it comes back"), "periodic": ("maintenance", "periodic maintenance", "regular checkup"), "valve": ("maintenance", "valve inspection", "tap shutoff routine"), "filter_swap": ("maintenance", "filter swap", "cleaning part"), "calibration": ("maintenance", "calibration record", "gauge accuracy"), } KEYS = list(CONCEPTS) COMMON = ["entry", "region", "term", "step", "form", "approval", "note"] DOCUMENTS = [] for i in range(200): r = rng(SEED + 7919 * i) s = CLASSES[pick(r(), [0.22, 0.24, 0.20, 0.18, 0.16])] pool = [k for k in KEYS if CONCEPTS[k][0] == s] main = pool[int(r() * len(pool))] n = 2 + int(r() * 4) paragraphs, concepts = [], [main] for _ in range(n): y = pool[int(r() * len(pool))] concepts.append(y) o = COMMON[int(r() * len(COMMON))] paragraphs.append(f"{CONCEPTS[y][1]} for {o} is kept and written together with {CONCEPTS[main][1]}") DOCUMENTS.append({"code": f"{CODE[s]}{i + 1:03d}", "label": s, "main": main, "concepts": concepts, "title": f"{CONCEPTS[main][1]} procedure", "paragraphs": paragraphs}) def doc_text(d): return d["title"] + " " + " ".join(d["paragraphs"]) def _to_concepts(m): # the surface form is first mapped to a concept v = [0.0] * len(KEYS) dm = " " + m.lower() + " " for i, k in enumerate(KEYS): if CONCEPTS[k][1] in dm: v[i] += 1.0 if CONCEPTS[k][2] in dm: v[i] += 1.0 return v PROJECTION = [] for i in range(len(KEYS)): r = rng(SEED ^ (i + 1) * 2654435761) PROJECTION.append([r() * 2 - 1 for _ in range(64)]) def embed(m, dim=DIM): # the concept is then reduced to fewer dimensions v = [0.0] * dim for i, a in enumerate(_to_concepts(m)): if a: for j in range(dim): v[j] += a * PROJECTION[i][j % 64] n = sum(x * x for x in v) ** 0.5 return [x / n for x in v] if n else v def cosine(a, b): return sum(x * y for x, y in zip(a, b)) K, CONSTANT = 5, 20 SEARCHABLE = [d["code"] + " " + doc_text(d) for d in DOCUMENTS] # the code enters the searchable text of the document VEC = [embed(a) for a in SEARCHABLE] TOKEN_SETS = [set(tokenize(a)) for a in SEARCHABLE] def queries(mode): # three separate surface forms from the same documents q = [] for i in range(40): r = rng(SEED + 104729 * i) d = DOCUMENTS[int(r() * len(DOCUMENTS))] o = COMMON[int(r() * len(COMMON))] q.append({"text": {"subscriber": f"{CONCEPTS[d['main']][2]} {o}", "document": f"{CONCEPTS[d['main']][1]} {o}", "code": f"{d['code']} numbered document"}[mode], "concept": d["main"], "code": d["code"]}) return q QUERY_CLASSES = {k: queries(k) for k in ("subscriber", "document", "code")} def keyword_rank(m): # by the number of overlapping tokens qb = set(tokenize(m)) return sorted(range(len(DOCUMENTS)), key=lambda i: -len(qb & TOKEN_SETS[i])) def embedding_rank(m): qv = embed(m) return sorted(range(len(DOCUMENTS)), key=lambda i: -cosine(qv, VEC[i])) def hybrid_rank(m, constant=CONSTANT): # rank fusion pk = {i: 1.0 / (constant + n) for n, i in enumerate(keyword_rank(m))} pe = {i: 1.0 / (constant + n) for n, i in enumerate(embedding_rank(m))} return sorted(range(len(DOCUMENTS)), key=lambda i: -(pk[i] + pe[i])) for k, s in QUERY_CLASSES.items(): print(f"{k:10s} example query: {s[0]['text']}") print(f"document code does not change the embedding: " f"{embed(SEARCHABLE[0]) == embed(doc_text(DOCUMENTS[0]))} , code query vector length " f"{sum(x * x for x in embed(QUERY_CLASSES['code'][0]['text'])):.1f}")
subscriber example query: how it gets saved form document example query: reading procedure form code example query: SY194 numbered document document code does not change the embedding: True , code query vector length 0.0
The three queries derived from the same document and ask the same thing; the only place they differ is the surface form. The last line measures two things. Adding the document code to the searchable text does not change the embedding vector: the code is not a concept surface form, so the embedding does not see it. The second is harsher: the vector length of the code query is 0.0. Because there is no recognized concept in the query, the vector is zero, all the cosine similarities come out zero and the ranking falls to the storage order. The embedding is not mistaken on this query, it stays outside its domain of definition.
The Table of the Three Setups
def measure(qset, rank): first = prec = rec = target = 0.0 for q in qset: relevant = {i for i, d in enumerate(DOCUMENTS) if d["main"] == q["concept"]} s = rank(q["text"]) first += s[0] in relevant prec += len(set(s[:K]) & relevant) / K rec += len(set(s[:20]) & relevant) / len(relevant) target += any(DOCUMENTS[i]["code"] == q["code"] for i in s[:K]) return [x / len(qset) for x in (first, prec, rec, target)] SETUPS = (("keyword", keyword_rank), ("embedding", embedding_rank), ("hybrid", hybrid_rank)) print("class setup top1 precision@5 recall@20 target document@5") for mode, qset in QUERY_CLASSES.items(): for name, f in SETUPS: d = measure(qset, f) print(f"{mode:10s} {name:9s} {d[0]:>6.4f} {d[1]:>11.4f} {d[2]:>9.4f} {d[3]:>17.4f}") print("hybrid top1 in the document class , constant 5 / 20 / 60: " + " / ".join(f"{measure(QUERY_CLASSES['document'], lambda m, c=c: hybrid_rank(m, c))[0]:.4f}" for c in (5, 20, 60)))
class setup top1 precision@5 recall@20 target document@5 subscriber keyword 0.0250 0.0800 0.0967 0.0250 subscriber embedding 0.5000 0.4700 0.7122 0.1500 subscriber hybrid 0.4250 0.2900 0.4616 0.1250 document keyword 0.5000 0.3650 0.6616 0.1750 document embedding 0.5000 0.4700 0.7122 0.1500 document hybrid 0.4000 0.4250 0.7214 0.1250 code keyword 1.0000 0.2500 0.1950 1.0000 code embedding 0.0000 0.0800 0.1078 0.0250 code hybrid 0.0000 0.0850 0.1950 0.0500 hybrid top1 in the document class , constant 5 / 20 / 60: 0.4500 / 0.4000 / 0.4000
The first two triples build the justification for hybrid search. In subscriber language keyword search is 0.0250 and embedding is 0.5000; on the code query keyword search is 1.0000 and embedding is 0.0000. The two procedures work in exactly opposite places on the same corpus, and choosing a single procedure means sacrificing one of the query classes entirely.
The hybrid rows show that this sacrifice does not go away. In subscriber language the hybrid is 0.4250, a little below the embedding; this is the expected cost. In document language, while both procedures give 0.5000, the hybrid gives 0.4000. The total is worse than both of its parts. The last line says that this is not the game of one choice of constant: at 5, 20 and 60 it is 0.4500 / 0.4000 / 0.4000, and all three are below 0.5000.
The code class is the most expensive row. Keyword search puts the target document in the first five in forty of the forty queries; the hybrid drops this to 0.0500. In the query class that is the reason hybrid search exists, fusion sinks the working procedure together with the one that does not work. The cause is in the table itself: the embedding side works with a zero vector in this class and the order it produces carries no information, but the fusion does not know this and gives that order equal weight.
One column looks in the opposite direction. In document language recall@20 is 0.6616 for keyword search, 0.7122 for the embedding, and 0.7214 for the hybrid. Up to rank twenty the fusion really does gain. In the code class too, recall@20 rises from 0.1078 to 0.1950. The place where fusion gains is depth, the place where it loses is the first rank, and in a retrieval pipeline what enters the prompt is the first few chunks.
What the Fusion Loses
print("class shared in first five both correct hybrid wrong") for mode, qset in QUERY_CLASSES.items(): shared = lost = 0 for q in qset: relevant = {i for i, d in enumerate(DOCUMENTS) if d["main"] == q["concept"]} kw, em, hy = (f(q["text"]) for _, f in SETUPS) shared += len(set(kw[:K]) & set(em[:K])) lost += kw[0] in relevant and em[0] in relevant and hy[0] not in relevant print(f"{mode:10s} {shared / len(qset):>20.2f} {lost:>25}") def relevant_docs(q): return {i for i, d in enumerate(DOCUMENTS) if d["main"] == q["concept"]} q = next(x for x in QUERY_CLASSES["document"] if keyword_rank(x["text"])[0] in relevant_docs(x) and embedding_rank(x["text"])[0] in relevant_docs(x) and hybrid_rank(x["text"])[0] not in relevant_docs(x)) print(f"\nquery: {q['text']} -- target concept: {q['concept']}") for name, f in SETUPS: print(f" {name:9s}", [f"{DOCUMENTS[i]['code']}/{DOCUMENTS[i]['main']}" for i in f(q["text"])[:3]])
class shared in first five both correct hybrid wrong subscriber 0.12 0 document 1.05 3 code 4.03 0 query: pressure drop region -- target concept: pressure keyword ['AR002/pressure', 'AR015/crew', 'AR018/pressure'] embedding ['AR079/pressure', 'AR088/pressure', 'AR101/crew'] hybrid ['AR033/crew', 'AR015/crew', 'AR018/pressure']
The shared documents column is a number that belongs not to any query but to the pair of procedures itself, and it is independent of the run. In subscriber language the first five of the two lists share 0.12 documents on average: the procedures almost never agree. In document language it is 1.05, that is, one in five.
The example shows the consequence of this number. Keyword search puts AR002 and the embedding puts AR079 at the first
rank; both carry the target concept, so both procedures are correct. The hybrid puts AR033 at the first rank, and
that document belongs to the crew concept. AR002 is first in one list and far behind in the other; its score comes
from a single large term. AR033 is high in both lists but first in neither, and the sum of two medium terms passes
one large term. Rank fusion rewards the document of compromise; when the two sides do not agree, the second
choice of both wins. In the document language class this happens in three of the forty queries and never in the
opposite direction: there is not a single example in which the hybrid finds the right one on a query where both
procedures were wrong. The counterpart of the three-query drop in the first rank is zero gain.
The 4.03 in the code row is the trap. The high overlap here is not agreement: because the embedding side works with a zero vector, its list is the storage order, and the tail of the keyword side after the exact match is the same order. The two lists share the same uninformed tail. Fusion cannot tell apart the two lists being the same from both being empty; if it could, it would give keyword search full weight in this class. Hybrid search asks not for an average but for a decision: which side has the say on which query.
Summary
- On three query classes the two procedures work in opposite places: in subscriber language keyword search is 0.0250 and embedding is 0.5000, on the code query keyword search is 1.0000 and embedding is 0.0000.
- A query that carries no concept surface form produces a zero vector; the embedding is not mistaken, it leaves its domain of definition and its ranking falls to the storage order.
- In the document language class both procedures give 0.5000 while rank fusion gives 0.4000; the combined setup is worse than both and this holds at constants of 5, 20 and 60.
- The cause is that the first five of the two lists share 1.05 documents: the document of compromise that stands second in both lists passes the document that is first in one and absent from the other.
- In the code class fusion drops the share of the target document being in the first five from 1.0000 to 0.0500; giving equal weight to an uninformed list sinks the working procedure as well.
- Fusion gains in depth: in document language recall@20 rises from 0.7122 to 0.7214. The gain is at rank twenty, the loss is at the first rank, and what enters the prompt is the first rank.
Next Step
Through six lessons a single question was asked and answered better each time: which chunk of the corpus belongs to this query. The embedding became a search key, the domain fit was measured, the similarity measure was chosen, what the index loses was counted, the point where the filter breaks the neighborhood was found and the fusion of two rankings was weighed. All of these lessons are on the retrieval side, and the measure of all of them is the retrieved chunk itself.
What is not measured is what comes after. The found chunk is put into a prompt and given to a model; from that point on, the right chunk producing the right answer is an assumption and was never tested in this topic. A wrong answer can be produced even though the right chunk was retrieved, the model can give the right answer by chance even though the wrong chunk was retrieved, and worst of all, the retrieved chunk can lead the model to produce a worse answer than its own answer without retrieval. The next topic builds the retrieval pipeline from end to end and separates two columns in its first lesson: was the right chunk retrieved, and was the right answer produced with what was retrieved.
To keep your progress and take notes, Log in
My notes
Log in to take notes.