Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~25 min· topic 9 of 10

Topic 5.9

Build: A Store for AskDocs

In one line

AskDocs version 5 puts a VectorStore interface between the product and its vectors: upsert, delete and search with a filter. An in-memory version runs here, and a second version shows the pgvector SQL the same calls map to.

Think of it like this

A shop keeps its stock in a back room. Staff do not rummage through boxes any more: they ask the stockroom clerk for "three of item 42, new edition only". The clerk can change the shelves, buy a bigger room or move to another building, and the staff keep asking in the same way. The VectorStore is that clerk.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

VectorStore
The interface AskDocs uses for vectors: upsert, delete, search with a filter.
Adapter
A class that implements the interface for one product, like the SQL version here.
Hit
One search result: an id, a score and its payload.
Evidence bar (MIN_SCORE)
The lowest score at which a passage counts as an answer source.
Superseded
Replaced by a newer version. Kept in the data, hidden by a filter.
Parameter ($1)
A placeholder in SQL; the database receives the value separately, so it can never be read as SQL.

Step by step

01Step 1: write the interface first

Three methods and what each returns. Write it before any implementation, so the implementation fits the interface and not the other way round.

vector_store.pywhole filepython
class VectorStore:
    def upsert(self, point_id, vector, payload): ...
    def delete(self, point_id): ...
    def search(self, vector, k, where=None): ...   # [(id, score, payload)], best first

02Step 2: the in-memory store

A dictionary from id to (vector, payload). search skips points whose payload does not match every key of where, scores the rest by dot product (the toy vectors have length 1, so this is cosine) and sorts best first. Equal scores keep insertion order in all three languages.

03Step 3: load the handbook with payloads

Each passage is embedded as title: text (Topic 4.5) and stored with {title, version}. The 2023 refund rule goes in first, as D1-old.

04Step 4: answer with a filter

answer embeds the question, searches with the optional filter and keeps hits at or above MIN_SCORE. No hits means the refusal sentence and no model. Otherwise the stand-in model repeats the first sentence of the best source and cites it.

Step 4: answer with a filterdiagram
Rendering diagram…

05Step 5: read the five runs

The same refund question is asked twice, without and with the filter. Compare the two sources lines: the filter keeps only the 2025 rule. The login question finds the password passage, the pass question finds the pass, and the scooter question has no source above the bar, so AskDocs refuses.

06Step 6: swap the implementation

The second example runs the same calls against SqlRecorderStore and prints the statements. The call search(vector, 3, {version: '2025'}) becomes a WHERE version = $2 with LIMIT $3. To use PostgreSQL for real you would write a class with these three methods that executes the statements, and change one line where the store is created.

Try it yourself

  1. 1

    Without the filter

    Ask the refund question with where = None and put D1 before D1-old in the handbook list. What changes in the sources line, and what does that tell you about relying on order?

  2. 2

    Delete and ask again

    Call store.delete("D1-old") before the loop and remove the filter. Is the filter still needed? When is deleting better than filtering?

  3. 3

    Raise the bar

    Set MIN_SCORE to 0.9. Which questions are now refused? Was any refusal wrong?

  4. 4

    Add a filter column

    Add a language key to the payloads and a second filter in the SQL example. Check the numbering of the $ parameters.

Code & diagrams

AskDocs v5: a VectorStore with filters New tab

Handbook documents D1-old (2023) and D1 to D12 passages used here; D1-old is new in this phase. The embedder is a toy with four word groups.

Sign in to run this example in your browser.

Expected output

Q: Can I get my money back if I cancel?  [no filter]
   sources: D1-old (0.98), D1 (0.98), D8 (0.83)
   answer:  A ride cancelled at least 6 hours before its start time is refunded in full. [D1-old]
Q: Can I get my money back if I cancel?  [filter: version=2025]
   sources: D1 (0.98), D8 (0.83), D2 (0.71)
   answer:  A ride cancelled at least 2 hours before its start time is refunded in full. [D1]
Q: I forgot my login, how do I get back in?  [filter: version=2025]
   sources: D3 (0.98)
   answer:  To reset your password, open the app, tap Forgot password and follow the link we email you. [D3]
Q: What does the monthly pass cost?  [filter: version=2025]
   sources: D8 (0.98), D1 (0.55)
   answer:  The monthly pass costs 25 dollars and includes unlimited rides of up to 45 minutes. [D8]
Q: Do you rent scooters?  [filter: version=2025]
   sources: none
   answer:  I can't find that in the handbook.
after deleting D1-old: 7 points
The same calls as pgvector SQL New tab

It only prints. A real adapter would send these statements with parameters through a PostgreSQL driver (Topic 5.10 shows where).

Sign in to run this example in your browser.

Expected output

INSERT INTO chunks (id, version, embedding) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, embedding = EXCLUDED.embedding;
   params: D3 | 2025 | [0.00,0.50,0.87,0.00]
DELETE FROM chunks WHERE id = $1;
   params: D1-old
SELECT id, 1 - (embedding <=> $1) AS score FROM chunks WHERE version = $2 ORDER BY embedding <=> $1 LIMIT $3;
   params: [0.00,0.00,1.00,0.00] | 2025 | 3
SELECT id, 1 - (embedding <=> $1) AS score FROM chunks ORDER BY embedding <=> $1 LIMIT $2;
   params: [1.00,0.00,0.00,0.00] | 5

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Paste the version into the SQL text

In the Python tab of the second example, replace the two lines params.append(where[column]) and clauses.append(column + " = $" + str(len(params))) with the single line clauses.append(column + " = '" + str(where[column]) + "'"). Run.

terminal
$ Run
── what you'll see ──
INSERT INTO chunks (id, version, embedding) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, embedding = EXCLUDED.embedding;
params: D3 | 2025 | [0.00,0.50,0.87,0.00]
DELETE FROM chunks WHERE id = $1;
params: D1-old
SELECT id, 1 - (embedding <=> $1) AS score FROM chunks WHERE version = '2025' ORDER BY embedding <=> $1 LIMIT $2;
params: [0.00,0.00,1.00,0.00] | 3
SELECT id, 1 - (embedding <=> $1) AS score FROM chunks ORDER BY embedding <=> $1 LIMIT $2;
params: [1.00,0.00,0.00,0.00] | 5

Myth vs fact

Myth

An interface is extra work for something that may never change.

Fact

Three methods and one class. It also makes testing easy: the in-memory store is a perfect test double for the real one.

Myth

A filter and a delete do the same job.

Fact

A delete removes the document for everyone. A filter hides it from some queries while keeping it for others, such as an auditor who needs the old rule.

Myth

SQL is safe if I only build it from my own variables.

Fact

Variables often hold user text. Parameters are the only reliable way.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Make the in-memory store part of the test suite: run the same contract tests against it and against the real adapter (upsert twice keeps one point, delete hides it, search respects the filter and k, a filter that matches nothing returns an empty list).

  • ▸

    Return the hits with scores and payloads, not only ids. Phase 8's citations need titles, and Phase 9's evaluation needs scores.

  • ▸

    Keep the embedding call outside the store. The store takes vectors, so changing the model does not change the store, and you can fake the embedder in tests.

Remember this

  1. 1

    Why the product needs this now. Through Phase 4 AskDocs kept its index (a list, then an index class) inside the retriever. Phase 4 produced chunks with ids and metadata, and the search now has to respect filters, updates and deletes. If every part of the product talks to the data structure directly, the first change of store means touching them all.

  2. 2

    What the step adds. An interface with three methods: upsert(id, vector, payload), delete(id) and search(vector, k, where), which returns (id, score, payload) hits, best first. MemoryVectorStore implements it with a dictionary. Ingestion (Phase 4) calls upsert; retrieve (Phase 0) calls search; nothing else knows how vectors are stored.

  3. 3

    How it plugs into what exists. answer keeps its shape: embed the question, search, apply the evidence bar, build the prompt, answer. The retriever gains one argument, a filter, and AskDocs now filters on version so it never quotes a superseded rule. Embedder is the same toy as before (one number per word group), standing in for a real model until Phase 2's embedding model is wired in.

  4. 4

    A superseded document. The handbook gets a document D1-old, the 2023 refund rule with a six-hour limit (later phases may add documents, but D1 to D12 never change). It is loaded before D1 on purpose: the old and new passages are almost identical to an embedding, so without a filter the old one can come first. This is the problem the lab in Topic 5.5 showed with a real model.

  5. 5

    The SQL version. SqlRecorderStore implements the same three methods but only prints the statement and parameters it would send to PostgreSQL with pgvector: an INSERT ... ON CONFLICT ... DO UPDATE, a DELETE, and a SELECT ... ORDER BY embedding <=> $1 LIMIT $n. Values go in numbered parameters, never pasted into the SQL text. Column names come from code, not from user input, because a column name cannot be a parameter.

  6. 6

    What AskDocs can do now that it could not before. Update or delete one passage without rebuilding everything, hide a superseded passage with a version filter, and swap the store by writing one class. What is still missing. The in-memory store still forgets everything when the program stops (the SQL version only prints; a real adapter would run it), and the vectors still come from a toy embedder, so retrieval quality is not what a real model gives (swap in a real embedder behind the same embed function). There is no keyword search or re-ranking (Phase 6), no query rewriting (Phase 7), the evidence bar of 0.5 is a guess (Topic 8.4), and nothing yet measures whether retrieval is right (Phase 9). The playbook next gives recipes for operating the store.

Explain it without notes

01

What does the VectorStore interface contain, and what does each method return?

02

How does the version filter change the refund answer, and why not simply delete the old document?

03

How does the SQL version map search(vector, 3, {version: '2025'})?

04

Why are values passed as parameters but column names built into the text?

Practice

01

Add count() to the in-memory store and delete_where(where) that removes every point whose payload matches the filter. Print the count before and after delete_where({"version": "2023"}) on a store with three points.

02

Write get(point_id) that returns (vector, payload) or None, and print None for a missing id. Why does a real adapter need this method for re-ingestion (Topic 4.7)?

Trade-offs

  • ↔

    An interface: one place to change when you change the store, and a little design up front.

  • ↔

    A version filter instead of a delete: history stays available, and every query must remember to filter.

  • ↔

    An in-memory store in tests: fast and exact, but it will not show real-index behaviour such as approximate misses.

Done when you can

  • Done when you can write the three-method VectorStore interface and an in-memory version from memory.

  • Done when you can explain how a filter keeps a superseded document out of an answer.

  • Done when you can translate upsert, delete and filtered search into pgvector SQL with parameters.

  • Done when you can say what AskDocs still lacks after this phase and which phase adds it.