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.
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 first02Step 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.
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
Without the filter
Ask the refund question with
where = Noneand putD1beforeD1-oldin the handbook list. What changes in thesourcesline, and what does that tell you about relying on order? - 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
Raise the bar
Set
MIN_SCOREto 0.9. Which questions are now refused? Was any refusal wrong? - 4
Add a filter column
Add a
languagekey to the payloads and a second filter in the SQL example. Check the numbering of the$parameters.
Code & diagrams
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.
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 pointsIt only prints. A real adapter would send these statements with parameters through a PostgreSQL driver (Topic 5.10 shows where).
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] | 5Break 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.
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
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
What the step adds. An interface with three methods:
upsert(id, vector, payload),delete(id)andsearch(vector, k, where), which returns(id, score, payload)hits, best first.MemoryVectorStoreimplements it with a dictionary. Ingestion (Phase 4) callsupsert;retrieve(Phase 0) callssearch; nothing else knows how vectors are stored. - 3
How it plugs into what exists.
answerkeeps 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 onversionso it never quotes a superseded rule.Embedderis 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
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
The SQL version.
SqlRecorderStoreimplements the same three methods but only prints the statement and parameters it would send to PostgreSQL with pgvector: anINSERT ... ON CONFLICT ... DO UPDATE, aDELETE, and aSELECT ... 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
What AskDocs can do now that it could not before. Update or delete one passage without rebuilding everything, hide a superseded passage with a
versionfilter, 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 sameembedfunction). 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
What does the VectorStore interface contain, and what does each method return?
How does the version filter change the refund answer, and why not simply delete the old document?
How does the SQL version map search(vector, 3, {version: '2025'})?
Why are values passed as parameters but column names built into the text?
Practice
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.
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
VectorStoreinterface 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.