Skip to content

feat: retrieval and citation-grounded RAG query - #4

Merged
div0rce merged 9 commits into
mainfrom
feat/m03-rag-query
May 28, 2026
Merged

feat: retrieval and citation-grounded RAG query#4
div0rce merged 9 commits into
mainfrom
feat/m03-rag-query

Conversation

@div0rce

@div0rce div0rce commented May 28, 2026

Copy link
Copy Markdown
Owner

Milestone

M3 — Retrieval + citation-grounded RAG

Summary

End-to-end RAG: pgvector cosine top-k retrieval over chunks, an LLMClient Protocol
with FakeLLM (used in tests) and ClaudeClient (httpx-based; /v1/messages), an
answer_query orchestrator that enforces citation-or-refuse, a POST /query
FastAPI endpoint with Pydantic request/response schemas, and 17 new tests covering
retrieval ordering, refusal triggers, and citation→chunk mapping correctness — all
running offline against FakeLLM and FakeEmbedder.

Definition of Done

  • All DoD items from MILESTONES.md met
  • make check passes (ruff + ruff-format + mypy strict + pytest)
  • Tests added/updated for new logic — 17 new M3 tests across retrieval, RAG,
    and the /query router
  • PROGRESS.md updated — M3 marked complete on branch; M2 row updated to ☑ merged
    with PR feat: document ingestion and embedding pipeline #3 link; 4 new decision-log entries
  • No secrets committed; sample data is synthetic
  • Guardrails intact (citation-or-refuse, PII redaction, confidence gating, audit
    logging) — citation-or-refuse implemented and tested; PII redaction, confidence
    gating, and audit logging arrive in M5/M7

M3 DoD verification (from MILESTONES.md)

  • POST /query returns answer + citations for an in-corpus question (manual
    check with real key).
    End-to-end POST /query returns
    {status:"answered", answer:"... [chunk:N]", citations:[{chunk_id,document_id,score,text}], reason:null}
    via the FakeLLM smoke test (test_query_router::test_post_query_happy_path_returns_answer_with_citations).
    The path is the same with LLM_PROVIDER=anthropic and a real key — answer_query
    delegates to LLMClient, and ClaudeClient is a drop-in.
  • Tests (FakeLLM): retrieval ordering, refusal when unsupported, citation→chunk
    mapping correctness.
    • Ordering (test_retrieval.py): crafted unit vectors with known strength
      yield strict descending similarity; k limit honoured; NULL embeddings excluded;
      self-similarity = 1.0.
    • Refusal: no_support when top score < RETRIEVAL_MIN_SCORE and when the
      corpus is empty; uncited when the LLM produced no [chunk:N] markers; uncited
      when the LLM cited only chunk ids that were not in the retrieved set;
      empty_query on whitespace-only input.
    • Citation mapping (test_rag.py::test_answer_query_citation_to_chunk_mapping_is_correct):
      a mixed valid+bogus+duplicate citation string parses to unique valid citations in
      first-seen order, with score, document_id, and text resolved from the
      retrieved set.

Notes / decisions

See PROGRESS.md "Decision log" — four M3 entries:

  • Citation marker format is [chunk:N] where N is the integer chunk id.
  • Refusal reasons are explicit strings (empty_query, no_support, uncited) so
    M7 audit and M9 eval can bucket failures without re-running the pipeline.
  • LLMClient.complete(system, user, max_tokens, temperature) is single-turn by
    design; streaming and tool use can extend later.
  • llm_temperature defaults to 0.0 per CLAUDE.md.

Other deliberate choices:

  • The /query router resolves Session, embedder, and LLM via three separate
    Depends callables so tests can override each independently with FastAPI's
    app.dependency_overrides.
  • Retrieval is pure SQL with no embedding work — the caller embeds the query so the
    ingest-time and query-time embedders can be unified by the FastAPI dependency.
  • OpenAIEmbedder/ClaudeClient both go through httpx directly; no SDK deps.

Test coverage

  • test_retrieval.py (5): cosine ordering, k limit, NULL exclusion, self-similarity,
    invalid-input rejection.
  • test_rag.py (7): happy path, no_support refusal, empty-corpus refusal, uncited
    refusal, unknown-chunk-id refusal, citation-mapping correctness, empty-query refusal.
  • test_query_router.py (5): request validation (empty body, oversized query),
    happy path round-trip, empty-corpus refusal, uncited refusal.

Total project tests: 74 (was 57 in M2).

Reminder

Please squash-merge this PR (Golden Rule #2: one milestone = one squash-merge
commit on main).

div0rce added 7 commits May 28, 2026 18:20
cosine_top_k(session, *, query_vec, k) returns ChunkScore(chunk, score) tuples
ranked by cosine similarity descending. Score is 1 - distance so 1.0 == perfect
match. NULL embeddings are excluded; ties are broken deterministically by chunk id.

Caller embeds the query themselves so the same EmbeddingProvider used at ingest
time is also used at query time (and so this module stays a pure SQL adapter).
- base.py: LLMClient Protocol with .model_name and .complete(system, user,
  max_tokens, temperature) → LLMResponse(text, model, stop_reason). Single-turn
  semantics; richer features can extend later without breaking M3.
- fake.py: deterministic canned-response client for tests. Optional response_factory
  callable lets tests vary output across calls (e.g. different citations per question).
- claude.py: thin httpx POST against Anthropic /v1/messages, no SDK dep. Concatenates
  text content blocks; surfaces stop_reason. Uses anthropic-version: 2023-06-01.
- __init__.py: get_llm(settings) factory dispatches anthropic|fake.
- config.py: adds llm_provider, claude_model, llm_temperature (default 0.0 per
  CLAUDE.md 'pin temperatures for LLM calls used in eval'), llm_max_tokens.
- .env.example: documents the new LLM_* vars.
answer_query(session, *, query, embedder, llm, settings) returns a RagAnswer with
status='answered' (with citations) or status='refused' (with a reason).

Refusal triggers:
- 'empty_query'   — whitespace-only input.
- 'no_support'    — no retrieved chunks, or top score < retrieval_min_score.
- 'uncited'       — LLM produced text but no [chunk:N] referenced a retrieved id.

The system prompt instructs the model to cite using [chunk:N] markers and to refuse
verbatim with REFUSAL_TEXT if context is insufficient. The parser maps citations
back to retrieved chunks; citations to chunk ids we did not supply are dropped.
Adds backend/app/routers/query.py with QueryRequest, CitationOut, and QueryResponse
Pydantic models. The handler resolves session, embedder, and LLM via FastAPI Depends
so tests can override each independently (TestClient + dependency_overrides).

main.py includes the query router; module also imports cleanly without a DB connection,
and OpenAPI/docs routes still work alongside /health and /query.
…moke

- test_retrieval.py (5 tests): cosine_top_k orders by descending similarity using
  crafted unit vectors with known strengths; respects k limit; excludes NULL-embedding
  chunks (property-based — robust to other DB state); self-similarity = 1.0; rejects
  invalid inputs.
- test_rag.py (7 tests): happy path returns at least one citation that maps to a
  retrieved chunk; refusal when top score < retrieval_min_score; refusal when corpus
  is empty; refusal when LLM does not cite ([chunk:N] absent); refusal when LLM cites
  only unknown chunk ids; mixed valid+bogus citation parsing keeps unique valid ones in
  first-seen order; refusal on whitespace-only query without touching the session.
- test_query_router.py (5 tests): TestClient with dependency_overrides for session,
  embedder, and llm. Validates request schema (empty body, oversized query → 422);
  happy path round-trip; refusal on empty corpus; refusal on uncited LLM response.

All RAG/router tests use FakeLLM and an aligned-query embedder; no live API calls
in the test suite. Total project tests: 74.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4060f83002

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread backend/app/rag.py
Comment on lines +92 to +95
if hit is None:
# The model cited a chunk we did not supply. Drop it; the caller's
# uncited-answer check below will refuse if no valid citations remain.
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject fabricated citations instead of dropping them

When the LLM output contains at least one valid retrieved citation plus one fabricated citation, this branch silently drops the fabricated id while answer_query still returns status="answered" with the original answer text, including the bogus [chunk:N] marker. In that mixed-citation scenario the caller sees an unsupported source reference that is absent from the structured citations, so the citation-or-refuse guardrail is bypassed; the response should be refused or the invalid marker otherwise handled before returning the answer.

Useful? React with 👍 / 👎.

@div0rce
div0rce merged commit dd29298 into main May 28, 2026
1 check passed
@div0rce
div0rce deleted the feat/m03-rag-query branch May 28, 2026 23:55
div0rce added a commit that referenced this pull request May 29, 2026
* docs(progress): record M3 merged (PR #4) and mark M4 in progress

* feat(extraction): ExtractedField[T] wrapper, schema registry, invoice schema

ExtractedField[T] (PEP 695 generic Pydantic v2 model, extra='forbid') forces every
extracted field to carry value, confidence in [0,1], and a source_chunk_id ≥ 1.

InvoicePayload composes four ExtractedField[T] fields (invoice_number, vendor,
issue_date, total_due) and matches the synthetic invoice corpus.

The registry exposes get_schema(name) / list_schemas() so the orchestrator and the
POST /extract router can resolve schemas by string name. Adding a schema is a
two-line edit: define the model and register it.

* feat(extract): schema-constrained extraction orchestrator with strict validation

extract_document(session, *, document_id, schema_name, llm, settings) returns an
ExtractionResult with status='ok' (persisted extraction_id, flat payload + per-field
confidence + per-field citations) or status='failed' (with a typed reason and detail
string). Failure reasons are stable strings:

- document_not_found / no_chunks   — preconditions.
- unknown_schema                    — registry lookup miss.
- parse_error                       — output is not valid JSON (markdown fences are
  tolerated; nothing else).
- schema_invalid                    — JSON does not match the Pydantic schema
  (missing field, wrong type, confidence out of [0,1], extra keys, etc.).
- invalid_citation                  — a source_chunk_id was not in the supplied
  context. Same hard-fail posture as the M3 RAG layer.

Failures do NOT persist an extraction row. Only ok results land in the extractions
table via extractions_repo.create().

* feat(extract): POST /extract router with strict request/response schemas

ExtractRequest validates document_id (>=1) and schema_name (1-128 chars). Resolves
session and LLMClient via FastAPI Depends so tests can override each via
app.dependency_overrides.

The handler is intentionally thin — it delegates to backend.app.extract.extract_document
and converts the ExtractionResult to ExtractResponse. On status='ok' the session is
committed so the new extractions row is durable; failure paths issue no writes.

main.py now includes both the M3 query router and the M4 extract router.

* test(m4): valid extraction, malformed/schema-invalid/invalid-citation refusals, persistence

test_extract.py (12 tests):
- happy path: extraction validates, persists, payload + per-field confidence + per-field
  citations round-trip from the repo, model_name='fake-llm' captured.
- markdown-fenced JSON parses successfully.
- parse_error: non-JSON output and JSON-but-not-an-object (array).
- schema_invalid: missing required field, confidence out of [0,1], extra unknown keys
  (extra='forbid').
- invalid_citation: source_chunk_id pointing at a chunk not in the supplied context.
- document_not_found: unknown document_id.
- no_chunks: document with zero chunks.
- unknown_schema: unregistered schema_name.
- failures do not persist (parametrized over parse_error and schema_invalid).
- InvoicePayload sanity round-trip.

test_extract_router.py (6 tests):
- request validation (empty body, document_id=0).
- happy path: 200 with status='ok', extraction_id, payload, field_confidence, field_citations.
- failed surfaces: malformed LLM output (status='failed', reason='parse_error'),
  unknown document (document_not_found), unknown schema (unknown_schema).

All tests use FakeLLM only — no live API calls in CI. Total project tests: 96.

* docs(progress): mark M4 complete on branch with DoD verification and decision log

* fix: enforce invoice issue date schema
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant