|
1 | 1 | """The RAG query pipeline: question -> retrieved chunks -> answer. |
2 | 2 |
|
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: |
4 | 5 |
|
5 | 6 | 1. embed the user's question (``search_query:`` prefix), |
6 | 7 | 2. retrieve the top chunks via a ``{!knn}`` query — the existing |
7 | 8 | security/path/language filter queries compose with the vector query |
8 | 9 | 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). |
13 | 26 |
|
14 | 27 | The pipeline is independent of the REST service so it can be tested |
15 | 28 | with a faked Solr connection and LLM client, and reused (e.g. by a |
|
24 | 37 | from kitconcept.solr.rag.client import LLMClient |
25 | 38 | from kitconcept.solr.rag.client import LLMClientError |
26 | 39 | 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 |
27 | 42 | from kitconcept.solr.rag.config import TOP_K |
28 | 43 | from kitconcept.solr.rag.prompt import build_prompt |
29 | 44 | from kitconcept.solr.rag.prompt import strip_thinking |
30 | 45 | 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 |
31 | 48 | from plone import api |
32 | 49 | from zope.component import queryUtility |
33 | 50 |
|
@@ -90,18 +107,28 @@ def run_rag_search( |
90 | 107 | ) |
91 | 108 | try: |
92 | 109 | 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) |
94 | 125 | except (SolrConnectionException, OSError) as e: |
95 | 126 | # collective.solr raises raw socket errors (e.g. |
96 | 127 | # ConnectionRefusedError) when the Solr server is down |
97 | 128 | logger.warning("rag-search: Solr unavailable: %s", e) |
98 | 129 | 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() |
103 | 130 |
|
104 | | - prompt = build_prompt(question, chunks) |
| 131 | + prompt = build_prompt(question, context_chunks) |
105 | 132 | try: |
106 | 133 | answer = client.chat(prompt, system=SYSTEM_PROMPT) |
107 | 134 | except LLMClientError as e: |
@@ -148,35 +175,150 @@ def search_chunks( |
148 | 175 | response.close() |
149 | 176 |
|
150 | 177 |
|
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: |
153 | 192 |
|
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. |
158 | 207 | """ |
| 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.""" |
159 | 237 | order: list[str] = [] |
160 | | - best_chunk: dict[str, dict] = {} |
| 238 | + seen = set() |
161 | 239 | for chunk in chunks: |
162 | 240 | 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) |
166 | 243 | 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) |
171 | 313 |
|
172 | 314 | portal = api.portal.get() |
173 | 315 | portal_path = "/".join(portal.getPhysicalPath()) |
174 | 316 | portal_url = portal.absolute_url() |
175 | 317 |
|
176 | 318 | sources = [] |
177 | | - for parent_uid in order: |
| 319 | + for parent_uid in fused_parents: |
178 | 320 | parent = parents.get(parent_uid, {}) |
179 | | - chunk = best_chunk[parent_uid] |
| 321 | + chunk = best_chunk.get(parent_uid, {}) |
180 | 322 | path_string = parent.get("path_string") or chunk.get("path_string", "") |
181 | 323 | url = ( |
182 | 324 | portal_url + path_string[len(portal_path) :] |
|
0 commit comments