In Progress

AI Sales Assistant for Photography Store

An agentic RAG assistant for an online camera store that answers catalog questions and grounds how-to answers in 65 official camera manuals.

PythonPython
LangChainLangChain
ClaudeClaude
pgvectorpgvector
SupabaseSupabase
AI Sales Assistant for Photography Store

Overview

Obscura is an agentic RAG assistant for an online camera store, and the storefront it sits behind. The code is on GitHub.

It answers two kinds of question in one conversation: what the store sells (price, stock, specs, comparisons) and how the gear actually works, grounded in the official manuals. The catalog holds 65+ camera bodies with a manual for each, plus 10 technique guides, just under 41,000 indexed chunks and growing.

The point was never a chatbot that sounds convincing. It was an assistant that only says what the catalog and the manuals support, and a way to prove that with numbers instead of vibes.

Status: in progress. Everything below runs locally. Deployment is the next piece.

What it does

  • Catalog answers. Price, stock, specs and comparisons, from the store's product data.
  • Manual answers. How to set white balance on a specific body, whether it has stabilization, how to shoot a time lapse. Cited back to the manual.
  • Technique answers. Exposure, metering, bokeh, from the store's guides rather than a camera manual.
  • Honest gaps. If neither the catalog nor the manual covers it, the assistant says so instead of filling the hole with world knowledge.
  • Clarifying questions. "How do I change white balance?" without naming a camera gets a question back, not a guess.

Architecture

How the pieces fit together

The agent decides what to call, retrieval decides what it gets to read, and tracing plus the two offline paths keep the corpus and the numbers honest.

Client
A question
POST /ask
API
FastAPI

/ask runs the agent. /search returns chunks on their own, so retrieval can be exercised without an agent in front of it.

Response
Answer plus sources
{ answer, sources }
Agent · LangGraph + Claude
A ReAct loop that owns its own control flow

The model picks a tool, reads the result, and loops until it can answer. There is no hand wired graph, so the routing rules live in the tool descriptions and the system prompt.

search_products

Browse or find cameras by name, brand, type, sensor format, stock.

get_product_info

Full record for one slug: price, stock, description, specs.

search_manual

Searches one camera's official manual.

explain_technique

Searches the store's photography guides for questions that are not about one camera's menus.

System prompt
Scope and honesty

Prices in EUR, comparisons fetched product by product, off-topic requests declined, and nothing claimed beyond what the tools returned.

The two search tools hand back the passages as data as well as text, which is where sources and evaluation read them from.

Retrieval
Hybrid search, fused, then reranked

Both arms are filtered to the same slice of the corpus before they rank, so a question about one camera can never surface a passage from another one.

Query
Question plus filters

product = nikon-z8, doc_type = manual

Arm 1 · meaning
pgvector, HNSW

bge-small, 384 dimensions, cosine distance. Top 20.

Arm 2 · words
Postgres full text, GIN

OR tsquery over a generated tsvector, ranked by ts_rank_cd. Top 20.

Fusion
Reciprocal Rank Fusion

1 / (60 + rank) from each arm, summed. Position counts, raw scores do not. A full outer join keeps whatever either arm found.

Rerank
Cross-encoder

bge-reranker-large rescores the 20 candidates by reading each pair together. The best 5 become the evidence.

Storage
Postgres with pgvector

chunks holds the corpus with its embedding, tsvector, doc type and product slug. products holds the catalog. HNSW and GIN indexes, hosted on Supabase.

Offline · ingestion
Load, chunk, embed, store

PDFs cleaned, split along the manual's own table of contents, embedded locally, written per source so a re-ingest never duplicates. Still run by hand.

Tracing
Langfuse on every ask

A root span per request, stamped with the model and a prompt version, so a metric change can be tied back to the prompt that caused it. Eval sweeps share a session id.

Offline · evaluation
Golden set, three layers

Retrieval scored on its own, then the agent's trajectory, then answer quality. A second entry point returns the full tool trace for exactly this.

  • Storefront. A Next.js store with a chat dock. The browser never talks to the agent directly; a server route proxies it, and any product the answer mentions is rendered as a preview card.
  • API. FastAPI. /ask runs the agent and returns JSON, /ask/stream sends the same answer token by token as it is written, and /search returns chunks on their own so retrieval can be tested without an agent in front of it.
  • Agent. A ReAct agent on LangGraph, running Claude Haiku 4.5. Four tools, one system prompt, and the model owns the control flow. Turns are checkpointed in Postgres and addressed by a thread id, so the client sends one message instead of replaying a transcript.
  • Retrieval. Hybrid search over Postgres with pgvector, then a local cross-encoder reranker.
  • Tracing. Every ask opens a Langfuse span stamped with the model and a prompt version, so a metric change can be tied back to the prompt that caused it.

There is no hand wired routing graph. An earlier version had one and it was replaced once the tools were good enough for the model to route on its own. That put the routing logic in two places I can edit as text: the tool descriptions and the system prompt.

A typical request

One question, end to end

The model owns the loop. It calls a tool, reads the result, and decides what to do next until it can answer.

  1. 1
    Request
    POST /ask

    "Does the Nikon Z8 have in-body stabilization?"

  2. 2
    Tool call
    search_products(query="Z8", brand="Nikon")
    nikon-z8 | Nikon Z8 | 3449 EUR | back-order | manual: yes

    Resolves the name into the slug every other tool expects.

  3. 3
    Tool call
    get_product_info("nikon-z8")
    { name, price_eur: 3449, in_stock: false,
      specs: { sensor_type, camera_type, warranty } }

    No stabilization field. A missing spec means "not listed", not "no", so the agent keeps going.

  4. 4
    Tool call
    search_manual("in-body stabilization",
        product="nikon-z8")
    5 passages [source: nikon-z8-manual.pdf]

    Hybrid retrieval, filtered to this camera's manual, then reranked.

  5. 5
    Answer
    { answer, sources }

    "Yes, the Nikon Z8 has in-body stabilization called Vibration Reduction..." cited to the manual.

Take "Does the Nikon Z8 have in-body stabilization?", a real run from the golden set. The agent resolves the name to a slug, pulls the product record, and finds no stabilization field in the specs. The tool description says a missing field means "not listed" rather than "no", so it searches that camera's manual instead of answering from the gap, and cites what it found.

Two details make that loop behave:

  • The tool docstrings are the routing rules. They are the only view the model has of these functions. That makes them prompt engineering, and they break like prompts: an example list in one docstring was read as an allowlist, and a whole class of question quietly stopped retrieving.
  • The API response is separate from the trace. Callers get {answer, sources}. Evaluation calls a second entry point that also returns every tool call and every tool output.

Ingestion

Load, chunk, embed, store. The parts that matter:

  • Section aware chunking. If a manual has a table of contents, each entry defines a section, and every chunk is prefixed with its heading path. The section's topic then lands in both the embedding and the keyword index, so a passage stays findable when its body text never uses the words a customer would. Manuals without a TOC fall back to 400 token chunks with 50 of overlap.
  • Local embeddings. bge-small-en-v1.5, 384 dimensions. Re-indexing the corpus costs time and nothing else, which is what makes chunking experiments affordable.
  • Metadata on every chunk. Document type, brand, and the product slug for manuals. This is what makes filtered retrieval possible.

Adding a manual still means running that script by hand. An automated path from upload to indexed chunks is not built yet.

Hybrid retrieval

Vector search is good at meaning and bad at exact strings. Keyword search is the opposite. Both arms run in one SQL statement, filtered to the same slice, and are merged with Reciprocal Rank Fusion:

with q as (select to_tsquery('english', %s) as tsq),
vec as (                               -- arm 1: nearest by meaning
  select id, content, source,
         row_number() over (order by embedding <=> %s::vector) as rank
  from chunks where true and product = %s and doc_type = %s
  order by embedding <=> %s::vector limit 20
),
kw as (                                -- arm 2: best keyword matches
  select id, content, source,
         row_number() over (order by ts_rank_cd(content_tsv, (select tsq from q)) desc) as rank
  from chunks where content_tsv @@ (select tsq from q) and product = %s and doc_type = %s
  limit 20
)
select coalesce(vec.content, kw.content) as content,
       coalesce(1.0 / (60 + vec.rank), 0)
     + coalesce(1.0 / (60 + kw.rank), 0) as score
from vec full outer join kw on vec.id = kw.id
order by score desc limit 20
  • Fusion by rank, not by score. Cosine similarity and ts_rank_cd are on unrelated scales, so normalizing one against the other would be guesswork. RRF only uses position, and a chunk found by both arms adds up.
  • A full outer join. A passage only one arm found still competes. That is the point of running two.
  • The filter sits inside both arms. A question about one camera cannot surface another camera's manual, by construction rather than by instruction.

Then the reranker. The bi-encoder is fast because it never sees query and passage together; a cross-encoder does exactly that, which is sharper and far too slow for 41,000 chunks. So it rescores 20 candidates and keeps 5.

Evaluation

Retrieval and generation fail differently, so they are scored separately, in three layers over a golden set of 27 questions grouped by failure mode.

  • Retrieval alone. Context precision and recall where the right passage is known. Nothing is generated, so the loop is cheap enough to run on every chunking or reranking change.
  • Trajectory. Assertions on what the agent did: which tools it called, whether it asked, whether it refused. 15 of 15 on the latest run.
  • Answer quality. Faithfulness and relevancy, judged by Gemini rather than the model that wrote the answers.

Two bugs only the harness could have caught: the docstring allowlist above, where correct sounding answers had no evidence behind them, and an empty answer bug that one model never triggered and another hit in 13 of 30 traces.

Routing is stochastic, and the harness makes that visible rather than fixing it: the same question can retrieve on one run and answer without a tool on the next.

Three problems worth the write-up

Approximate search was quietly dropping the right passage. A question about the Z8's stabilization scored zero even though the passage was in the database. With a filter over tens of thousands of chunks, the vector index collects candidates globally, the filter throws almost all of them away, and the real matches never enter the pool. Widening the graph search and turning on iterative scanning fixed it. It was most likely also behind an earlier quality drop I had blamed on corpus dilution.

Chunk boundaries decided the answers. With fixed size chunks, the answering line often sat mid-chunk inside something unrelated, which diluted the embedding. Section aware chunking fixed it on two rows and cost a little on a third. Digging into a row that did not move, I found that 34 of the 65 manuals have no embedded table of contents and silently fall back. Only visible because retrieval is measured on its own.

The vocabulary gap. Users say "stabilization", the manual says "vibration reduction". The right passage was in the pool but the reranker would not rank it top 5. I tested a stronger cross-encoder offline on the full set first, confirmed the two small regressions were tail end reordering rather than lost signal, then swapped it in.

What is next

  • Deployment, so the storefront and the agent are reachable rather than local.
  • Heading detection for the manuals with no table of contents, which would extend section aware chunking to half the corpus.
  • Query rewriting for the vocabulary gap.
  • An automated indexing path for new manuals.