Skip to content

Commit 49f87a0

Browse files
Merge pull request #92 from kitconcept/kid/460/hybrid-rrf-tests
RAG hybrid retrieval: BM25 + knn with RRF, integration test, prompt fix (#79)
2 parents 692780b + 3197b91 commit 49f87a0

10 files changed

Lines changed: 596 additions & 45 deletions

File tree

IMPLEMENTATION-79.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,17 +306,62 @@ repository's estimates).
306306

307307
**Step P1 — Hybrid RRF + tests (2d), first follow-up:**
308308

309-
- [ ] **P1.1 Hybrid retrieval (1d)**: BM25 (reusing the existing
309+
- [x] **P1.1 Hybrid retrieval (1d)**: BM25 (reusing the existing
310310
`SolrSearch` query building) + knn as two Solr requests, client-side
311311
Reciprocal Rank Fusion (k=60) in `@rag-search`; designed to be swapped
312312
for Solr's native RRF combiner when 9.11/10.1 ships. Hybrid was
313313
originally MVP scope (empirical signal that pure vector is not enough
314314
on intranet content); the pure-knn MVP results should confirm the
315315
deferral was acceptable — if not, this item moves up.
316-
- [ ] **P1.2 Test pass (1d)**: unit tests (chunker, LLM client mocked) + one
316+
- [x] **P1.2 Test pass (1d)**: unit tests (chunker, LLM client mocked) + one
317317
integration test against the docker-compose stack with deterministic
318318
mock endpoints (CI needs no GPU/model).
319319

320+
### Implementation notes — Step P1 (design decisions made on the way)
321+
322+
- **Fusion happens at the parent-document level.** Chunks are invisible
323+
to keyword search by design (stored, not indexed), so BM25 ranks
324+
parent documents, while the knn chunk hits are collapsed to their
325+
parents. Client-side RRF (`k=60`) fuses the two rankings; ties
326+
resolve toward the vector ranking.
327+
- **The keyword scoring expression is an exact copy of the `@solr`
328+
main query** — same fields, same boosts (decision after review):
329+
- we DO want the same fields with the same weighting, including
330+
`Subject`;
331+
- we DO want `searchwords^1000` (the editorial keyword-pinning
332+
mechanism) and the `-showinsearch:False` exclusion — both must
333+
behave identically in the AI search (important);
334+
- `id^0.75` is included; whether id matching is useful for natural
335+
language questions may be revisited later (note);
336+
- `text_prefix`/`text_suffix^0.75` are likely unneeded for full NL
337+
questions (they serve terse/partial-word queries) but are included
338+
for exact parity since their low boosts don't disturb the ranking;
339+
may be revisited (note).
340+
A pinning unit test spells out every clause, so the copy cannot
341+
drift silently. NOT inherited (deliberately): facet/search-tab
342+
conditions, highlighting, spellcheck, pagination — request-driven
343+
UI machinery without meaning for the RAG query.
344+
- **Shared query builder refactoring deferred** (overflow list): for
345+
now the expression is copied and the production `@solr` service is
346+
left untouched; the later refactoring extracts the common core with
347+
the acceptance criteria: (a) new unit tests for the factored-out
348+
parts, (b) the pre-existing solr service tests pass unchanged after
349+
the refactoring, (c) the RAG route gets its own tests modeled on the
350+
solr service test examples.
351+
- **Keyword-only parents contribute context.** For a parent that only
352+
the keyword ranking surfaced, the leading chunks are fetched from
353+
Solr so its text reaches the model — otherwise a document found by
354+
keyword search could appear as a source without being able to
355+
influence the answer.
356+
- **Retrieval mode override** `KITCONCEPT_SOLR_RAG_RETRIEVAL=knn`
357+
(default `hybrid`) exists solely for the pure-vector vs. hybrid
358+
comparison in the evaluation on the real corpus; not a supported
359+
setting.
360+
- The unit-test part of P1.2 had already been delivered inline with
361+
Steps 1 and 3; the new piece is the integration test with a
362+
deterministic mock LLM (topic-axis unit vectors, canned answer)
363+
against the real docker Solr — CI-runnable with no network/model.
364+
320365
**AI presentation via tabs configuration** (decided with the team
321366
2026-07-23, replaces the earlier "AI search" toggle; to be elaborated
322367
and implemented after the UX has been seen live in kitconcept.intranet):
@@ -343,6 +388,7 @@ remains):
343388
| Full configuration surface: registry records for model names, topK, chunk size, prompt override | 0.5d |
344389
| Acceptance test flow + full CI wiring (after the search-UI integration, so acceptance tests target the real UI) | 1d |
345390
| Separate Solr core/ports for tests vs. local dev site (see known issue 1) | 0.5d |
391+
| Extract a shared keyword-query builder used by both `@solr` and the RAG pipeline (criteria: unit tests for the shared part, old solr tests green, RAG route tests modeled on the solr test examples) | 0.5d |
346392

347393
**Later roadmap** (tracked, not scheduled): full evaluation harness
348394
(Recall@k/MRR/nDCG as CI regression gate, RAGAS faithfulness/relevancy with

SPECIFICATION-79.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,31 @@ Resolved open point:
331331
yet be produced manually, e2e tests follow when the editor
332332
settles.
333333

334+
Remaining open point:
335+
336+
1. **Facet/tab conditions and the AI search** — requirements question,
337+
**to be discussed with Dante and Timo in the review**. The classic
338+
search presents tabbed results per content type with facet conditions
339+
in selected tabs (e.g. the person search). Two distinct readings for
340+
the AI search:
341+
- *Facets as navigation over the results:* does **not** transfer — the
342+
source list is a small top-K evidence set justifying the answer, not
343+
an exhaustive listing to narrow down; filtering the evidence away
344+
would undermine the fact-checking contract.
345+
- *Facets/tabs as scope constraints on the question* (ask within a
346+
tab or facet selection, e.g. a department in the person search):
347+
transfers well and the architecture already supports it — the knn
348+
query composes any filter query as a pre-filter (as security, path
349+
and language do today). Adding tab/facet parameters to
350+
`@rag-search` is a bounded, additive change reusing the classic
351+
search's condition builders. Caveat: chunks do not denormalize
352+
`portal_type` or the facet fields, so scoped retrieval needs either
353+
those fields on the chunks (schema addition + reindex) or filtering
354+
at the parent-collapse step — a real design decision.
355+
Proposed MVP stance: whole-intranet scope (matches the single search
356+
box); scoped RAG as a follow-up once the modal UX (external search
357+
modal project) defines what scoping looks like.
358+
334359
## 9. References
335360

336361
Research summaries behind the recommendations (full reports in the planning

backend/news/79.feature.3

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hybrid retrieval for the RAG search: parent-level Reciprocal Rank Fusion of the knn chunk ranking with the classic keyword query (an exact copy of the @solr main query incl. searchwords boost and showinsearch exclusion); prompt context labeled by document title instead of leaking bracketed reference numbers. @reebalazs

backend/src/kitconcept/solr/rag/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@
5353
# generation prompt).
5454
TOP_K = 5
5555

56+
# Reciprocal Rank Fusion constant for hybrid retrieval (the standard
57+
# value from Cormack et al. 2009; Solr's native RRF uses it as well).
58+
RRF_K = 60
59+
60+
# Retrieval mode: "hybrid" (BM25 + vector, RRF-fused; the default) or
61+
# "knn" (pure vector). The env override exists for the evaluation on
62+
# the real corpus (compare the two modes); not a supported setting.
63+
RETRIEVAL_HYBRID = "hybrid"
64+
RETRIEVAL_KNN = "knn"
65+
5666
REGISTRY_ENABLED_KEY = "kitconcept.solr.rag_enabled"
5767

5868
# Endpoint paths, relative to the server root URL. The defaults match
@@ -70,6 +80,7 @@
7080
ENV_CHAT_MODEL = "KITCONCEPT_SOLR_LLM_CHAT_MODEL"
7181
ENV_EMBED_PATH = "KITCONCEPT_SOLR_LLM_EMBED_PATH"
7282
ENV_CHAT_PATH = "KITCONCEPT_SOLR_LLM_CHAT_PATH"
83+
ENV_RETRIEVAL = "KITCONCEPT_SOLR_RAG_RETRIEVAL"
7384

7485

7586
@dataclass(frozen=True)
@@ -84,6 +95,7 @@ class RagConfig:
8495
chat_path: str = DEFAULT_CHAT_PATH
8596
embed_timeout: float = EMBED_TIMEOUT
8697
chat_timeout: float = CHAT_TIMEOUT
98+
retrieval: str = RETRIEVAL_HYBRID
8799

88100

89101
def rag_enabled() -> bool:
@@ -119,6 +131,7 @@ def get_rag_config() -> RagConfig | None:
119131
chat_model=os.environ.get(ENV_CHAT_MODEL, "").strip() or DEFAULT_CHAT_MODEL,
120132
embed_path=os.environ.get(ENV_EMBED_PATH, "").strip() or DEFAULT_EMBED_PATH,
121133
chat_path=os.environ.get(ENV_CHAT_PATH, "").strip() or DEFAULT_CHAT_PATH,
134+
retrieval=os.environ.get(ENV_RETRIEVAL, "").strip() or RETRIEVAL_HYBRID,
122135
)
123136

124137

backend/src/kitconcept/solr/rag/pipeline.py

Lines changed: 169 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
"""The RAG query pipeline: question -> retrieved chunks -> answer.
22
3-
Implements the single-turn RAG search (SPECIFICATION-79.md §4):
3+
Implements the single-turn RAG search (SPECIFICATION-79.md §4) with
4+
hybrid retrieval:
45
56
1. embed the user's question (``search_query:`` prefix),
67
2. retrieve the top chunks via a ``{!knn}`` query — the existing
78
security/path/language filter queries compose with the vector query
89
as HNSW pre-filters, so permission trimming works unchanged,
9-
3. collapse the chunk hits to their parent documents (the sources),
10-
4. generate the answer with the general-purpose model, prompted with
11-
the matched chunk texts (chunk-level context, decision 9) and
12-
constrained to the provided context.
10+
3. retrieve the top parent documents via the classic keyword (BM25)
11+
query and fuse both rankings with client-side Reciprocal Rank
12+
Fusion — hybrid is the industry default because keyword and vector
13+
search have complementary failure modes (exact names/codes vs.
14+
paraphrase). Solr's native RRF lands in 9.11/10.1; the client-side
15+
fusion is drop-in replaceable by it,
16+
4. assemble the context chunks for the fused parent ranking (chunks
17+
from the knn hits; fetched from Solr for keyword-only parents),
18+
5. generate the answer with the general-purpose model, prompted with
19+
the chunk texts (chunk-level context, decision 9) and constrained
20+
to the provided context.
21+
22+
Fusion happens at the *parent document* level: chunks are invisible to
23+
keyword search by design (their text is stored but not indexed), so
24+
BM25 ranks parents, while the chunk hits of the knn side are collapsed
25+
to their parents (parent-document retrieval).
1326
1427
The pipeline is independent of the REST service so it can be tested
1528
with a faked Solr connection and LLM client, and reused (e.g. by a
@@ -24,10 +37,14 @@
2437
from kitconcept.solr.rag.client import LLMClient
2538
from kitconcept.solr.rag.client import LLMClientError
2639
from kitconcept.solr.rag.config import RagConfig
40+
from kitconcept.solr.rag.config import RETRIEVAL_HYBRID
41+
from kitconcept.solr.rag.config import RRF_K
2742
from kitconcept.solr.rag.config import TOP_K
2843
from kitconcept.solr.rag.prompt import build_prompt
2944
from kitconcept.solr.rag.prompt import strip_thinking
3045
from kitconcept.solr.rag.prompt import SYSTEM_PROMPT
46+
from kitconcept.solr.services.solr_utils import escape
47+
from kitconcept.solr.services.solr_utils import replace_reserved
3148
from plone import api
3249
from zope.component import queryUtility
3350

@@ -90,18 +107,28 @@ def run_rag_search(
90107
)
91108
try:
92109
chunks = search_chunks(conn, vector, security_filter, path_prefix, lang)
93-
sources = collapse_sources(conn, chunks) if chunks else []
110+
knn_parents = parent_ranking(chunks)
111+
if config.retrieval == RETRIEVAL_HYBRID:
112+
keyword_parents = search_keyword(
113+
conn, question, security_filter, path_prefix, lang
114+
)
115+
fused_parents = rrf_fuse([knn_parents, keyword_parents])
116+
else:
117+
fused_parents = knn_parents
118+
fused_parents = fused_parents[:TOP_K]
119+
if not fused_parents:
120+
# No matching (visible) content: not an error - the answer is
121+
# that there is no answer.
122+
return RagResult()
123+
context_chunks = assemble_context(conn, chunks, fused_parents)
124+
sources = build_sources(conn, fused_parents, context_chunks)
94125
except (SolrConnectionException, OSError) as e:
95126
# collective.solr raises raw socket errors (e.g.
96127
# ConnectionRefusedError) when the Solr server is down
97128
logger.warning("rag-search: Solr unavailable: %s", e)
98129
return RagResult.failure(ERROR_SOLR_UNAVAILABLE, str(e))
99-
if not chunks:
100-
# No matching (visible) content: not an error - the answer is
101-
# that there is no answer.
102-
return RagResult()
103130

104-
prompt = build_prompt(question, chunks)
131+
prompt = build_prompt(question, context_chunks)
105132
try:
106133
answer = client.chat(prompt, system=SYSTEM_PROMPT)
107134
except LLMClientError as e:
@@ -148,35 +175,150 @@ def search_chunks(
148175
response.close()
149176

150177

151-
def collapse_sources(conn, chunks: list[dict]) -> list[dict]:
152-
"""Parent documents of the matched chunks, in rank order.
178+
def search_keyword(
179+
conn,
180+
question: str,
181+
security_filter: str,
182+
path_prefix: str | None = None,
183+
lang: str | None = None,
184+
) -> list[str]:
185+
"""Top-K parent documents for the classic keyword (BM25) query.
186+
187+
The scoring expression is an exact copy of the ``@solr`` main
188+
query (``SolrSearch._base_query``): same fields, same boosts —
189+
including ``searchwords^1000`` (the editorial "pin a document for
190+
a keyword" mechanism) and the ``-showinsearch:False`` exclusion,
191+
both of which must behave identically in the AI search. Notes:
153192
154-
Parent-document retrieval: retrieval matches chunks, but the user
155-
sees the parent documents as the sources. Parent metadata is
156-
fetched from Solr in one query and merged with a snippet from the
157-
best-ranked chunk of each parent.
193+
- ``id^0.75``: kept for parity; whether id matching makes sense
194+
for natural language questions may be revisited.
195+
- ``text_prefix``/``text_suffix^0.75``: likely unneeded for full
196+
NL questions (they serve terse/partial-word queries), but
197+
included for exact parity since their low boosts don't disturb
198+
the ranking; may be revisited.
199+
200+
Not inherited (deliberately): facet/search-tab conditions,
201+
highlighting, spellcheck, pagination — request-driven UI machinery
202+
of the classic search page that has no meaning here and does not
203+
affect the ranking. Extracting a shared query builder so the copy
204+
cannot drift is a planned refactoring (see the overflow list).
205+
206+
Chunks are excluded — they carry no indexed text anyway.
158207
"""
208+
term = f"({escape(replace_reserved(question))})"
209+
query = (
210+
f"+(Title:{term}^5 OR Description:{term}^2 OR id:{term}^0.75 "
211+
f"OR text_prefix:{term}^0.75 OR text_suffix:{term}^0.75 "
212+
f"OR default:{term} OR body_text:{term} OR SearchableText:{term} "
213+
f"OR Subject:{term} OR searchwords:({term})^1000) -showinsearch:False"
214+
)
215+
filter_queries = [security_filter, "-is_rag_chunk:true"]
216+
if path_prefix:
217+
portal_path = "/".join(api.portal.get().getPhysicalPath())
218+
prefix = portal_path + path_prefix.rstrip("/")
219+
filter_queries.append(f'path_parents:"{prefix}"')
220+
if lang:
221+
filter_queries.append(f"Language:({lang} OR any)")
222+
response = conn.search(
223+
q=query,
224+
fq=filter_queries,
225+
fl="UID",
226+
rows=TOP_K,
227+
)
228+
try:
229+
results = SolrResponse(response).results()
230+
finally:
231+
response.close()
232+
return [flare["UID"] for flare in results]
233+
234+
235+
def parent_ranking(chunks: list[dict]) -> list[str]:
236+
"""Parent UIDs of the chunk hits, deduplicated, in rank order."""
159237
order: list[str] = []
160-
best_chunk: dict[str, dict] = {}
238+
seen = set()
161239
for chunk in chunks:
162240
parent_uid = chunk.get("parent_uid")
163-
if not parent_uid:
164-
continue
165-
if parent_uid not in best_chunk:
241+
if parent_uid and parent_uid not in seen:
242+
seen.add(parent_uid)
166243
order.append(parent_uid)
167-
best_chunk[parent_uid] = chunk
168-
if not order:
169-
return []
170-
parents = fetch_parents(conn, order)
244+
return order
245+
246+
247+
def rrf_fuse(rankings: list[list[str]], k: int = RRF_K) -> list[str]:
248+
"""Reciprocal Rank Fusion of ranked UID lists.
249+
250+
``score(d) = sum over rankings of 1 / (k + rank(d))`` — the
251+
standard fusion that needs no score normalization (Cormack et al.
252+
2009). Ties keep the order of the first ranking.
253+
"""
254+
scores: dict[str, float] = {}
255+
for ranking in rankings:
256+
for index, uid in enumerate(ranking):
257+
scores[uid] = scores.get(uid, 0.0) + 1.0 / (k + index + 1)
258+
return sorted(scores, key=lambda uid: -scores[uid])
259+
260+
261+
def assemble_context(
262+
conn, knn_chunks: list[dict], fused_parents: list[str]
263+
) -> list[dict]:
264+
"""Context chunks for the fused parent ranking, capped at TOP_K.
265+
266+
Chunks retrieved by the knn query are used as-is; for parents that
267+
only the keyword ranking surfaced, the leading chunks are fetched
268+
from Solr — their text must reach the model, otherwise a document
269+
found by keyword search could not contribute to the answer.
270+
"""
271+
by_parent: dict[str, list[dict]] = {}
272+
for chunk in knn_chunks:
273+
by_parent.setdefault(chunk.get("parent_uid"), []).append(chunk)
274+
context: list[dict] = []
275+
for parent_uid in fused_parents:
276+
if parent_uid in by_parent:
277+
context.extend(by_parent[parent_uid])
278+
else:
279+
context.extend(fetch_leading_chunks(conn, parent_uid))
280+
if len(context) >= TOP_K:
281+
break
282+
return context[:TOP_K]
283+
284+
285+
def fetch_leading_chunks(conn, parent_uid: str, limit: int = 2) -> list[dict]:
286+
"""The first chunks of a document (for keyword-only parents)."""
287+
response = conn.search(
288+
q=f'+parent_uid:"{parent_uid}" +is_rag_chunk:true',
289+
sort="chunk_index asc",
290+
fl=CHUNK_FIELD_LIST,
291+
rows=limit,
292+
)
293+
try:
294+
return list(SolrResponse(response).results())
295+
finally:
296+
response.close()
297+
298+
299+
def build_sources(
300+
conn, fused_parents: list[str], context_chunks: list[dict]
301+
) -> list[dict]:
302+
"""Source documents in fused rank order.
303+
304+
Parent-document retrieval: the user sees the parent documents as
305+
the sources. Parent metadata is fetched from Solr in one query and
306+
merged with a snippet from the best-ranked context chunk of each
307+
parent (empty when a parent contributed no context).
308+
"""
309+
best_chunk: dict[str, dict] = {}
310+
for chunk in context_chunks:
311+
best_chunk.setdefault(chunk.get("parent_uid"), chunk)
312+
parents = fetch_parents(conn, fused_parents)
171313

172314
portal = api.portal.get()
173315
portal_path = "/".join(portal.getPhysicalPath())
174316
portal_url = portal.absolute_url()
175317

176318
sources = []
177-
for parent_uid in order:
319+
for parent_uid in fused_parents:
178320
parent = parents.get(parent_uid, {})
179-
chunk = best_chunk[parent_uid]
321+
chunk = best_chunk.get(parent_uid, {})
180322
path_string = parent.get("path_string") or chunk.get("path_string", "")
181323
url = (
182324
portal_url + path_string[len(portal_path) :]

0 commit comments

Comments
 (0)