**Phase 112 — final verification pass (all 4 tasks already complete in `complete/`):** - Verified gate fix: `app/api/chat.py::plan_turn` — HIGH iff `best_cosine >= relevance_threshold` OR (`fts_hits > 0` AND `best_cosine >= lexical_support_floor`); `lexical_support_floor` (default 0.35, `BOR_LEXICAL_SUPPORT_FLOOR`, bounds-validated) in `app/config.py` + `.env.example`; A8 revision note (2026-09-14) in `.agents/PLAN.md`. - Verified prompt contract: `app/rag/prompts.py` diff is docstring-only (dated owner-decision-iii entry); `tests/unit/test_prompt_lock.py` byte-pins PERSONA/TOOLS_SECTION/DEFLECT body (sha256+length). - Verified README: L11 + L575 deflection copy refreshed; `grep "haven't done anything" README.md` → no hits; disclosed-answer behavior documented. - Tests: `uv run pytest --cov=app --cov-report=term-missing` → **2378 passed, 99% coverage (>90%)**; includes Mongolia-quadrant unit pins (fts>0 + cosine<floor → LOW). - E2E in isolation: `uv run pytest tests/e2e/test_honest_deflection.py -v --no-cov` → **4 passed** (out-of-KB question: `deflected=true`, `sources==[]`, 2–3 suggestions); regression `test_chat_rag.py` + `test_retrieval_quality.py` → **7 passed**. - Lint/types: `uv run ruff check .` → clean; `uv run pyright` → 0 errors. **Completion criteria:** weak-FTS→LOW unit-pinned ✅ · no false citations + 2–3 alternatives E2E ✅ · prompts byte-identical (test-pinned) + README matches ✅ · suite/coverage/e2e/lint all green ✅ · commit + phase move → left to harness (no `git commit` run, per rules; changes in working tree). **Deviations:** none. Next pending phase: `113_source_chip_quality`.
586 lines
24 KiB
Python
586 lines
24 KiB
Python
"""Hybrid retrieval: pgvector cosine ∪ Postgres FTS, RRF-fused (PLAN §6, A7).
|
||
|
||
* **Vector list** — top-N chunks by cosine distance (``embedding <=> $1``),
|
||
each carrying its cosine ``1 − distance`` (the honesty-gate input).
|
||
* **Lexical list** — top-N chunks matching an OR-``tsquery`` over the
|
||
question's tokens, ordered by ``ts_rank``, UNION the name-hit list:
|
||
documents whose normalized name (title + path stem, alnum-only,
|
||
lowercased) contains a DIGIT-BEARING normalized question token of
|
||
length >= 4 — bare tokens (``1panel``) and the join of adjacent
|
||
question tokens starting with a letter ("Qwen 3.8" → ``qwen38``).
|
||
The digit requirement is the precision guard: plain prose words
|
||
("server", "arguments" — 4+ chars but no digit) never name-match,
|
||
while versioned product names (the incident's whole point) always
|
||
carry one. The name-hit documents LEAD the lexical list (ranked by
|
||
match count, then total matched length, then catalog order; capped
|
||
at :data:`NAME_HIT_LIMIT`),
|
||
the FTS rows follow. This is what finds name-your-tool questions
|
||
("gitlab") that vector similarity buries — and, the 2026-09-05
|
||
incident, the versioned-name case the default parser lexes
|
||
incompatibly (question "Qwen 3.8" → ``qwen``/``3``/``8`` can never
|
||
match a document's ``qwen3``/``8``/``27b`` tokens, while every
|
||
unrelated llama.cpp quadlet out-ranks the target on the shared
|
||
``llama``/``cpp`` tokens). The name-hit rows carry ``fts_hit=True``
|
||
(they ARE the lexical signal — the A8 honesty gate answers on them
|
||
only when the best cosine clears ``BOR_LEXICAL_SUPPORT_FLOOR``,
|
||
A8 revised 2026-09-14) and ``cosine=0.0``; the RRF fusion is
|
||
unchanged (same lists, same ``1/(k+rank)`` terms).
|
||
* **Fusion** — Reciprocal Rank Fusion (``score = Σ 1/(k + rank)`` over the
|
||
lists a chunk appears in; chunks hit by both lists get both terms). The
|
||
fused score ranks; :meth:`select_documents` and :func:`weak_hit_titles`
|
||
keep working off ``score``.
|
||
|
||
The product requirement (LOCKED A7, revised 2026-08-24): the LLM receives
|
||
the **entire relevant document**, not just the chunk — chunk hits map back
|
||
to their parents, dedupe, rank by best fused score, and the full text of
|
||
the top-N documents is always fed through, never truncated. If a future KB
|
||
ever makes the prompt too large for the model, the ``LLMError`` → SSE
|
||
``error`` path surfaces it loudly — no silent partial context.
|
||
|
||
Deterministic tie-break for equal fused scores:
|
||
``(−fused, −cosine, document.path, chunk.position)``.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
import uuid
|
||
from collections.abc import Sequence
|
||
from dataclasses import dataclass, replace
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy import select, text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.config import get_settings
|
||
from app.models import Chunk, Document
|
||
|
||
#: Shared overflow marker — now used by the steering (<tuning>) section
|
||
#: only (phase 15; imported by ``app.rag.prompts``). The document context
|
||
#: path never truncates (A7 revised, owner permission 2026-08-24).
|
||
TRUNCATION_MARKER = "[…truncated…]"
|
||
|
||
#: Alphanumeric tokens of a question (``to_tsquery`` input, OR-joined),
|
||
#: with DOTTED tokens kept whole (``llama.cpp`` → ``llama.cpp``). The
|
||
#: default parser lexes a dotted word as ONE lexeme (``to_tsvector`` of
|
||
#: "llama.cpp" → ``'llama.cpp'``; of "Qwen 3.8" → ``'3.8'``), so a split
|
||
#: token (``llama`` | ``cpp``) can never match the document side — the
|
||
#: 2026-09-05 incident's question phrase "llama.cpp" lexed
|
||
#: incompatibly on both sides of the query. Dotted tokens are passed
|
||
#: through as single ``to_tsquery`` lexemes (verified: Postgres accepts
|
||
#: ``'llama.cpp'`` and ``'3.8'`` as lexemes).
|
||
_TOKEN_RE = re.compile(r"[a-z0-9]+(?:\.[a-z0-9]+)*")
|
||
|
||
#: Name-hit policy (the 2026-09-05 incident): a question token shorter
|
||
#: than this (normalized) is too weak a name signal ("3", "8", "the") —
|
||
#: it would match half the KB by accident.
|
||
NAME_TOKEN_MIN_LEN = 4
|
||
|
||
#: The name-hit list may contribute at most this many documents to the
|
||
#: lexical side (before the FTS rows); the rest are dropped.
|
||
NAME_HIT_LIMIT = 10
|
||
|
||
#: Alphanumeric runs of a lowercased string (name normalization).
|
||
_ALNUM_RE = re.compile(r"[a-z0-9]+")
|
||
|
||
|
||
def _normalize_name(s: str) -> str:
|
||
"""Lowercased, alnum-only form of *s* (``Qwen 3.8`` → ``qwen38``)."""
|
||
return "".join(_ALNUM_RE.findall(s.lower()))
|
||
|
||
|
||
def name_hit_tokens(question: str) -> list[str]:
|
||
"""The normalized name-match candidates of one question.
|
||
|
||
Only DIGIT-BEARING candidates count (the precision guard — a plain
|
||
prose word like "server" or "arguments" must never name-match a
|
||
document; a versioned product name always carries a digit):
|
||
|
||
* the :func:`_normalize_name` form of every whitespace token, kept
|
||
when at least :data:`NAME_TOKEN_MIN_LEN` chars (``1panel``,
|
||
``qwen38`` from a single written token);
|
||
* the versioned-name JOIN — the normalized concatenation of every
|
||
ADJACENT token pair whose SECOND token is purely numeric (a
|
||
version number: ``"… for Qwen 3.8"`` → ``Qwen`` + ``3.8`` joins
|
||
to ``qwen38``; prose joins like ``correct`` + ``llama`` and the
|
||
word-after-version boundaries like ``3.8`` + ``show`` are
|
||
dropped). A purely-numeric second token means the join always
|
||
starts with the first token's text (a letter in practice), so no
|
||
digit-leading artifact (``38show``) can survive.
|
||
|
||
Order of first appearance, de-duplicated.
|
||
"""
|
||
tokens = question.split()
|
||
out: list[str] = []
|
||
seen: set[str] = set()
|
||
|
||
def add(tok: str) -> None:
|
||
if (
|
||
len(tok) >= NAME_TOKEN_MIN_LEN
|
||
and any(ch.isdigit() for ch in tok)
|
||
and tok not in seen
|
||
):
|
||
seen.add(tok)
|
||
out.append(tok)
|
||
|
||
for i, raw in enumerate(tokens):
|
||
norm = _normalize_name(raw)
|
||
add(norm)
|
||
if i + 1 < len(tokens):
|
||
next_norm = _normalize_name(tokens[i + 1])
|
||
if next_norm and next_norm.isdigit():
|
||
add(norm + next_norm) # the versioned-name join
|
||
return out
|
||
|
||
|
||
#: One row of the lexical candidate query (all fields needed to build a
|
||
#: detached :class:`Document` plus the chunk fields and ``ts_rank``).
|
||
_LEXICAL_SQL = text(
|
||
"""
|
||
SELECT c.id AS chunk_id,
|
||
c.position AS position,
|
||
c.content AS content,
|
||
d.id AS doc_id,
|
||
d.source AS source,
|
||
d.path AS path,
|
||
d.full_path AS full_path,
|
||
d.title AS title,
|
||
d.content AS doc_content,
|
||
d.content_hash AS content_hash,
|
||
d.indexed_at AS indexed_at,
|
||
d.created_at AS created_at,
|
||
c.is_summary AS is_summary,
|
||
ts_rank(c.tsv, to_tsquery('english', :tsquery)) AS rank
|
||
FROM chunks c
|
||
JOIN documents d ON d.id = c.document_id
|
||
WHERE c.tsv @@ to_tsquery('english', :tsquery)
|
||
ORDER BY rank DESC, d.path ASC, c.position ASC
|
||
LIMIT :limit
|
||
"""
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class RetrievedChunk:
|
||
"""One retrieval candidate: fused rank score + parent document row.
|
||
|
||
* ``score`` — RRF fused score (the ranking key for document selection
|
||
and weak-hit titles).
|
||
* ``cosine`` — vector similarity ``1 − distance`` (the honesty-gate
|
||
input; ``0.0`` for lexical-only hits that have no vector rank).
|
||
* ``fts_hit`` — the chunk matched the question's OR-tsquery.
|
||
* ``is_summary`` — True for the lite-model summary chunk (phase 30,
|
||
position −1): its parent *is* the source document, so a summary hit
|
||
resolves to the full source document through the unchanged
|
||
chunk→document mapping (A7 revised). Default ``False`` keeps every
|
||
ordinary content chunk valid.
|
||
"""
|
||
|
||
chunk_id: uuid.UUID
|
||
position: int
|
||
content: str
|
||
score: float
|
||
document: Document
|
||
cosine: float = 0.0
|
||
fts_hit: bool = False
|
||
is_summary: bool = False
|
||
|
||
|
||
def lexical_tsquery(question: str) -> str | None:
|
||
"""OR-joined token string for ``to_tsquery('english', …)``, or ``None``.
|
||
|
||
Tokens are lowercased ``[a-z0-9]+`` runs with DOTTED words kept whole
|
||
(see :data:`_TOKEN_RE` — the default parser lexes ``llama.cpp`` as
|
||
one lexeme, so the query must carry it whole too), de-duplicated in
|
||
order of first appearance. Postgres does the lexing/stemming; a
|
||
question whose tokens are all stopwords lexes to an *empty* tsquery
|
||
(which matches nothing), so no special-casing is needed there.
|
||
Pure-symbol questions ("???", "🔧") yield no tokens → ``None`` → no
|
||
lexical query at all.
|
||
"""
|
||
seen: set[str] = set()
|
||
tokens: list[str] = []
|
||
for tok in _TOKEN_RE.findall(question.lower()):
|
||
if tok not in seen:
|
||
seen.add(tok)
|
||
tokens.append(tok)
|
||
return " | ".join(tokens) if tokens else None
|
||
|
||
|
||
def fuse(
|
||
vector: Sequence[RetrievedChunk],
|
||
lexical: Sequence[RetrievedChunk],
|
||
k: int,
|
||
) -> list[RetrievedChunk]:
|
||
"""Reciprocal Rank Fusion over the two ranked candidate lists.
|
||
|
||
``score(chunk) = Σ 1/(k + rank)`` — one term per list the chunk appears
|
||
in (ranks are 1-based; a chunk in both lists gets both terms). Returns
|
||
the union ordered by ``(-score, -cosine, document.path, position)``.
|
||
|
||
Lexical-only hits (no vector rank) enter with ``cosine=0.0`` and
|
||
``fts_hit=True``; vector chunks matched by the lexical list get
|
||
``fts_hit=True`` in place (the input objects are mutated — callers
|
||
should not reuse them afterwards).
|
||
"""
|
||
if k <= 0:
|
||
raise ValueError("rrf k must be > 0")
|
||
by_id: dict[uuid.UUID, RetrievedChunk] = {}
|
||
fused: dict[uuid.UUID, float] = {}
|
||
for rank, rc in enumerate(vector, start=1):
|
||
by_id[rc.chunk_id] = rc
|
||
fused[rc.chunk_id] = fused.get(rc.chunk_id, 0.0) + 1.0 / (k + rank)
|
||
for rank, rc in enumerate(lexical, start=1):
|
||
term = 1.0 / (k + rank)
|
||
if rc.chunk_id in by_id:
|
||
existing = by_id[rc.chunk_id]
|
||
by_id[rc.chunk_id] = replace(existing, fts_hit=True)
|
||
fused[rc.chunk_id] += term
|
||
else:
|
||
rc = replace(rc, fts_hit=True)
|
||
by_id[rc.chunk_id] = rc
|
||
fused[rc.chunk_id] = fused.get(rc.chunk_id, 0.0) + term
|
||
out = [replace(rc, score=fused[rc.chunk_id]) for rc in by_id.values()]
|
||
out.sort(key=lambda rc: (-rc.score, -rc.cosine, rc.document.path, rc.position))
|
||
return out
|
||
|
||
|
||
def apply_recency_boost(
|
||
chunks: Sequence[RetrievedChunk],
|
||
*,
|
||
now: datetime | None = None,
|
||
weight: float | None = None,
|
||
half_life_days: int | None = None,
|
||
) -> list[RetrievedChunk]:
|
||
"""Additive recency boost on the fused score (phase 106, D6).
|
||
|
||
Each chunk's score becomes
|
||
``score + weight * exp(−age_days / half_life_days)`` where
|
||
``age_days = max(0, (now − document.created_at))`` in days — a
|
||
zero-age document gets the full *weight* (the MAXIMUM additive
|
||
score), each ``half_life_days`` of age multiplies the remaining
|
||
boost by ``e**-1`` (≈0.37), and a FUTURE ``created_at`` clamps to
|
||
age 0 (the document reads as brand-new — consistent with D3's
|
||
today-folding in :mod:`app.rag.doc_dates`). Defaults: *weight* /
|
||
*half_life_days* from :func:`get_settings` (``recency_boost`` /
|
||
``recency_half_life_days``), *now* from ``datetime.now(UTC)``.
|
||
|
||
Magnitude rationale (the ``0.0007`` default, the k=60 RRF scale):
|
||
rank 1 vs 2 in one list differs by ~0.00026 and rank 1 vs 10 by
|
||
~0.0021, so the full weight is a bounded 2-3 rank head start —
|
||
enough to break near-ties toward the newer document, far below the
|
||
fused gap between a document that answers and one that merely
|
||
resembles (the phase-106 fine-line battery pins the measured
|
||
margin: 0.00263 ≥ 3× the zero-age boost).
|
||
|
||
Pure (the :func:`fuse` convention): the inputs are never mutated —
|
||
every boosted chunk is a ``replace()`` copy — and the result is
|
||
re-sorted with the EXISTING deterministic key
|
||
``(−score, −cosine, document.path, position)``; with ``weight=0``
|
||
every score is untouched and an already-fused (already-sorted)
|
||
input comes back byte-identical (the kill switch, pinned).
|
||
|
||
Untouched by design: the A8 honesty gate and ``query_log.top_score``
|
||
(both read the chunk's ``cosine``, which the boost never modifies),
|
||
:func:`weak_hit_titles` (titles only), and the never-truncated
|
||
top-N contract (:func:`select_documents` still feeds whole
|
||
documents — the boost re-ranks WHICH documents, never truncates).
|
||
SINGLE APPLY SITE: :func:`retrieve()` is the only caller in
|
||
``app/`` — the chat API and ``scripts/eval_retrieval.py`` inherit
|
||
the boost through it; nothing else may apply it.
|
||
"""
|
||
if weight is None or half_life_days is None:
|
||
settings = get_settings()
|
||
if weight is None:
|
||
weight = settings.recency_boost
|
||
if half_life_days is None:
|
||
half_life_days = settings.recency_half_life_days
|
||
if half_life_days <= 0:
|
||
raise ValueError("half_life_days must be > 0")
|
||
if now is None:
|
||
now = datetime.now(UTC)
|
||
boosted: list[RetrievedChunk] = []
|
||
for rc in chunks:
|
||
age_days = max(0.0, (now - rc.document.created_at).total_seconds() / 86400.0)
|
||
boosted.append(
|
||
replace(
|
||
rc,
|
||
score=rc.score + weight * math.exp(-age_days / half_life_days),
|
||
)
|
||
)
|
||
boosted.sort(key=lambda rc: (-rc.score, -rc.cosine, rc.document.path, rc.position))
|
||
return boosted
|
||
|
||
|
||
def _vector_candidates(
|
||
db: Session, question_embedding: list[float], limit: int
|
||
) -> list[RetrievedChunk]:
|
||
"""Top-*limit* chunks by pgvector cosine distance (``<=>``).
|
||
|
||
``cosine = 1 − distance``. Chunks whose embedding is still NULL
|
||
(two-phase import in progress) are skipped. Each candidate carries
|
||
its ``Chunk.is_summary`` flag (phase 30) so a summary hit stays
|
||
identifiable after fusion.
|
||
"""
|
||
distance = Chunk.embedding.cosine_distance(question_embedding)
|
||
rows = db.execute(
|
||
select(Chunk, distance.label("distance"), Document)
|
||
.join(Document, Chunk.document_id == Document.id)
|
||
.where(Chunk.embedding.is_not(None))
|
||
.order_by(distance)
|
||
.limit(limit)
|
||
).all()
|
||
return [
|
||
RetrievedChunk(
|
||
chunk_id=chunk.id,
|
||
position=chunk.position,
|
||
content=chunk.content,
|
||
score=0.0, # fused score is filled in by :func:`fuse`
|
||
document=doc,
|
||
cosine=round(1.0 - float(dist), 6),
|
||
is_summary=chunk.is_summary,
|
||
)
|
||
for chunk, dist, doc in rows
|
||
]
|
||
|
||
|
||
#: One name-hit row: the document fields (detached :class:`Document`)
|
||
#: plus the representative chunk — the ``is_summary`` chunk when the
|
||
#: document has one (its natural-language summary is the best chunk of
|
||
#: a machine file, and the chunk the vector list is likeliest to have
|
||
#: ranked too, so the RRF merge dedupes cleanly), else chunk 0.
|
||
_NAME_HIT_SQL = text(
|
||
"""
|
||
SELECT d.id AS doc_id,
|
||
d.source AS source,
|
||
d.path AS path,
|
||
d.full_path AS full_path,
|
||
d.title AS title,
|
||
d.content AS doc_content,
|
||
d.content_hash AS content_hash,
|
||
d.indexed_at AS indexed_at,
|
||
d.created_at AS created_at,
|
||
c.id AS chunk_id,
|
||
c.position AS position,
|
||
c.content AS content,
|
||
c.is_summary AS is_summary
|
||
FROM documents d
|
||
LEFT JOIN LATERAL (
|
||
SELECT id, position, content, is_summary
|
||
FROM chunks
|
||
WHERE document_id = d.id
|
||
ORDER BY is_summary DESC, position ASC
|
||
LIMIT 1
|
||
) c ON true
|
||
WHERE d.id = ANY(:ids) AND c.id IS NOT NULL
|
||
"""
|
||
)
|
||
|
||
|
||
def _name_hit_chunks(db: Session, question: str) -> list[RetrievedChunk]:
|
||
"""The documents whose NAME matches the question (the 2026-09-05
|
||
incident, the versioned-name signal).
|
||
|
||
A document is a name hit when its normalized name —
|
||
:func:`_normalize_name` of its ``title`` followed by the normalized
|
||
path stem (``qwen3.8-27b-juggernaut-vulkan.container`` →
|
||
``qwen3827bjuggernautvulkan``) — contains at least one
|
||
:func:`name_hit_tokens` candidate (``qwen38``). Ranked by
|
||
(distinct matched tokens, total matched length, source, path) —
|
||
catalog order is the final deterministic tie-break — capped at
|
||
:data:`NAME_HIT_LIMIT`. Each hit becomes one lexical
|
||
:class:`RetrievedChunk` (its representative chunk, ``fts_hit=True``,
|
||
``cosine=0.0``). Two lightweight queries: one projection over
|
||
(id, source, path, title) in catalog order, one LATERAL chunk
|
||
fetch for the ≤ :data:`NAME_HIT_LIMIT` winners (no full-content
|
||
load; the content joins in via the row fetch below).
|
||
"""
|
||
tokens = name_hit_tokens(question)
|
||
if not tokens:
|
||
return []
|
||
rows = db.execute(
|
||
select(Document.id, Document.source, Document.path, Document.title).order_by(
|
||
Document.source, Document.path
|
||
)
|
||
).all()
|
||
scored: list[tuple[int, int, uuid.UUID]] = []
|
||
by_id: dict[uuid.UUID, tuple[str, str]] = {} # id -> (source, path)
|
||
for doc_id, source, path, title in rows:
|
||
by_id[doc_id] = (source, path)
|
||
name = _normalize_name(title) + _normalize_name(Path(path).stem)
|
||
matched = [t for t in tokens if t in name]
|
||
if matched:
|
||
scored.append((len(matched), sum(len(t) for t in matched), doc_id))
|
||
if not scored:
|
||
return []
|
||
# Ranked by (distinct matched tokens, total matched length), the
|
||
# deterministic catalog tie-break (source, path) last.
|
||
scored.sort(
|
||
key=lambda s: (-s[0], -s[1], by_id[s[2]][0], by_id[s[2]][1])
|
||
)
|
||
ids = [s[2] for s in scored[:NAME_HIT_LIMIT]]
|
||
hit_rows = list(db.execute(_NAME_HIT_SQL, {"ids": ids}).all())
|
||
# The LATERAL query returns winners in id order; re-order by the
|
||
# ranked order computed above so the lexical list is deterministic.
|
||
order = {doc_id: rank for rank, doc_id in enumerate(ids)}
|
||
hit_rows.sort(key=lambda row: order[row.doc_id])
|
||
out: list[RetrievedChunk] = []
|
||
for row in hit_rows:
|
||
doc = Document(
|
||
id=row.doc_id,
|
||
source=row.source,
|
||
path=row.path,
|
||
full_path=row.full_path,
|
||
title=row.title,
|
||
content=row.doc_content,
|
||
content_hash=row.content_hash,
|
||
indexed_at=row.indexed_at,
|
||
created_at=row.created_at,
|
||
)
|
||
out.append(
|
||
RetrievedChunk(
|
||
chunk_id=row.chunk_id,
|
||
position=row.position,
|
||
content=row.content,
|
||
score=0.0, # filled in by :func:`fuse`
|
||
document=doc,
|
||
cosine=0.0, # no vector rank — name-only hit
|
||
fts_hit=True, # lexical signal — A8 answers if cosine corroborates
|
||
is_summary=bool(row.is_summary),
|
||
)
|
||
)
|
||
return out
|
||
|
||
|
||
def _lexical_candidates(db: Session, question: str, limit: int) -> list[RetrievedChunk]:
|
||
"""The lexical candidate list: name hits, then FTS rows (A7).
|
||
|
||
The name-hit documents (:func:`_name_hit_chunks`, the versioned-name
|
||
signal) LEAD the list — they are the strongest lexical evidence for
|
||
a name-your-thing question — followed by the top-*limit* chunks
|
||
matching the question's OR-tsquery, ordered by ``ts_rank`` (with
|
||
``d.path, c.position`` as the deterministic tie-break). Deduped by
|
||
chunk id (a name-hit representative chunk the FTS list also ranked
|
||
appears once). An empty tsquery (stopword-only question) simply
|
||
contributes no FTS rows; a question with no name tokens contributes
|
||
no name hits — both halves are independent.
|
||
"""
|
||
out: list[RetrievedChunk] = []
|
||
seen: set[uuid.UUID] = set()
|
||
for rc in _name_hit_chunks(db, question):
|
||
if rc.chunk_id not in seen:
|
||
seen.add(rc.chunk_id)
|
||
out.append(rc)
|
||
tsquery = lexical_tsquery(question)
|
||
if tsquery is None:
|
||
return out
|
||
rows = db.execute(
|
||
_LEXICAL_SQL, {"tsquery": tsquery, "limit": limit}
|
||
).all()
|
||
for row in rows:
|
||
if row.chunk_id in seen:
|
||
continue
|
||
seen.add(row.chunk_id)
|
||
doc = Document(
|
||
id=row.doc_id,
|
||
source=row.source,
|
||
path=row.path,
|
||
full_path=row.full_path,
|
||
title=row.title,
|
||
content=row.doc_content,
|
||
content_hash=row.content_hash,
|
||
indexed_at=row.indexed_at,
|
||
created_at=row.created_at,
|
||
)
|
||
out.append(
|
||
RetrievedChunk(
|
||
chunk_id=row.chunk_id,
|
||
position=row.position,
|
||
content=row.content,
|
||
score=0.0, # filled in by :func:`fuse`
|
||
document=doc,
|
||
cosine=0.0, # no vector rank — lexical-only hit
|
||
fts_hit=True,
|
||
is_summary=row.is_summary,
|
||
)
|
||
)
|
||
return out
|
||
|
||
|
||
def retrieve(
|
||
db: Session,
|
||
question: str,
|
||
question_embedding: list[float],
|
||
vector_candidates: int | None = None,
|
||
lexical_candidates: int | None = None,
|
||
) -> list[RetrievedChunk]:
|
||
"""Hybrid retrieval (A7): vector top-N ∪ FTS top-N, RRF-fused.
|
||
|
||
Returns the fused candidate list in rank order (best first). Each
|
||
:class:`RetrievedChunk` carries the fused ``score`` (ranking), the
|
||
``cosine`` similarity (honesty gate) and the ``fts_hit`` flag.
|
||
"""
|
||
settings = get_settings()
|
||
v_n = (
|
||
settings.hybrid_vector_candidates if vector_candidates is None else vector_candidates
|
||
)
|
||
l_n = (
|
||
settings.hybrid_lexical_candidates if lexical_candidates is None else lexical_candidates
|
||
)
|
||
if v_n <= 0:
|
||
raise ValueError("vector_candidates must be >= 1")
|
||
if l_n <= 0:
|
||
raise ValueError("lexical_candidates must be >= 1")
|
||
vector = _vector_candidates(db, question_embedding, v_n)
|
||
lexical = _lexical_candidates(db, question, l_n)
|
||
fused = fuse(vector, lexical, settings.rrf_k)
|
||
if settings.recency_boost > 0:
|
||
# Phase 106, D6 — the SINGLE recency-boost apply site: an
|
||
# additive post-fusion re-rank (see :func:`apply_recency_boost`).
|
||
# ``0`` = off: the pre-phase ranking returns byte-identical (the
|
||
# kill switch) and weight-0 callers pay nothing.
|
||
return apply_recency_boost(fused)
|
||
return fused
|
||
|
||
|
||
def weak_hit_titles(chunks: Sequence[RetrievedChunk]) -> list[str]:
|
||
"""Distinct parent-document titles of *chunks*, best fused score first.
|
||
|
||
Deflection mode (PLAN §6, A8) is built from these *titles only* — the
|
||
LOW prompt and the "Maybe try" chips never see document content.
|
||
"""
|
||
titles: list[str] = []
|
||
seen: set[uuid.UUID] = set()
|
||
for rc in sorted(chunks, key=lambda c: c.score, reverse=True):
|
||
if rc.document.id in seen:
|
||
continue
|
||
seen.add(rc.document.id)
|
||
titles.append(rc.document.title)
|
||
return titles
|
||
|
||
|
||
def select_documents(
|
||
chunks: Sequence[RetrievedChunk],
|
||
n: int | None = None,
|
||
) -> list[Document]:
|
||
"""Map chunk hits to distinct parent documents, ranked by best fused score.
|
||
|
||
At most *n* documents are returned (default ``BOR_TOP_N_DOCS``). The
|
||
returned rows carry the full document content, byte-identical — a
|
||
matched parent document is **never truncated** (A7 revised, owner
|
||
permission 2026-08-24). There is deliberately no context budget: an
|
||
oversized prompt must fail loudly through the ``LLMError`` → SSE
|
||
``error`` path, never arrive as silent partial context.
|
||
"""
|
||
top_n = n if n is not None else get_settings().top_n_docs
|
||
|
||
docs: list[Document] = []
|
||
seen: set[uuid.UUID] = set()
|
||
for rc in sorted(chunks, key=lambda c: c.score, reverse=True):
|
||
if rc.document.id in seen:
|
||
continue
|
||
seen.add(rc.document.id)
|
||
docs.append(rc.document)
|
||
return docs[:top_n]
|