A Local Accounting Assistant That Cites and Refuses
We show how to build, on your own machine, a small assistant over the Bulgarian Corporate Income Tax Act (ZKPO) and VAT Act (ZDDS): given a question it does not "make things up" but returns the verbatim text of the article with its edition and a link to the source, and when no suitable text is found it refuses. It is a draft for the accountant, not tax advice. The accountant decides.
bge-m3 is multilingual with an 8K context, while the old mxbai-embed-large has a 512 window. We added: a refusal enforced by the code, not by a prompt to the model (below the similarity threshold the program calls no model at all); verbatim quotes with the edition and a link; checked texts of Art. 10, 19, 20, 21 of the CIT Act and Art. 66, 67 of the VAT Act (below); a verified corporate tax rate — 10 percent; versions: ollama 0.6.3, pgvector 0.5.0, Ollama 0.35.0 (as in 04-01). Part of the code was run: the function that decides "answer or refuse" was executed in a sandbox with invented rows. Everything else was not run (see the next box).
pgvector — only the answer-or-refuse logic, with invented rows (Python 3.10.12), and not on a GB10-class machine (hence no "TESTED" label). The threshold MIN_SCORE = 0.55 is a starting value; we did not calibrate it, so tune it on your own questions. We do not know how any particular generative model handles Bulgarian, which is why the core uses none. We read the legal texts from a consolidated text on KiK Info (private), not from the State Gazette: before you load an article into the database, check it against the official text. We do not quote the VAT reduced rates (Art. 66a) or any other article; they were not checked here. The legal texts stay in Bulgarian, so ask your questions in Bulgarian; we did not test English questions against Bulgarian text.
01What you'll learn
- How to store a law article as a record with act, number, edition and link.
- How vector search in PostgreSQL finds the text closest to a question.
- How the code, not the model, decides when to refuse.
- Why the answer is a verbatim quote and not rewritten text.
- How to tune the threshold and check it with questions that must get a refusal.
- How to hand the result to the accountant so that the decision stays with the human.
02Before you start
- Ollama running on your own machine and listening only on the local address (as in 04-01, where the series pins Ollama 0.35.0; do not open it to the network).
- PostgreSQL with the
pgvectorextension. - Python 3.10+ and the ability to make a virtual environment (
venv). - An accountant who reviews every answer. This is the condition for the whole lesson.
- The legal texts are public. Client data is not loaded anywhere in this lesson.
| Package / model | Version (checked 01.10.2026) | What it is for |
|---|---|---|
| Ollama | 0.35.0 (as in the series) | runs the embedding model locally |
| ollama (Python) | 0.6.3 (PyPI, 29.09.2026) | calls Ollama from Python |
| pgvector (Python) | 0.5.0 (PyPI, 06.07.2026) | links psycopg and vectors |
| psycopg | 3.3.6 | PostgreSQL connection |
| bge-m3 | 1.2 GB · 8K context · 100+ languages | vectors for search |
llama3.3 page in Ollama the supported languages are English, German, French, Italian, Portuguese, Hindi, Spanish and Thai. Bulgarian is not listed, and the authors advise against using it for other languages without fine-tuning. So the assistant here does not rely on a generative model: the answer is the text from the database. If you add a model for explanation, first test it in Bulgarian with questions whose correct answer you know.03Steps
-
What the laws say (checked verbatim)
Why first? The assistant is only as good as the texts in its database. Here are the texts we will load and the rates we checked. All are from the KiK Info consolidated text, read on 01.10.2026: ZKPO up to State Gazette no. 55 of 16.06.2026; ZDDS up to State Gazette no. 69 of 31.07.2026. The Bulgarian original is the authoritative text; the English below each quote is our unofficial translation.
📜Corporate Income Tax Act (ZKPO)Art. 10(1): „Счетоводен разход се признава за данъчни цели, когато е документално обоснован чрез първичен счетоводен документ по смисъла на Закона за счетоводството, отразяващ вярно стопанската операция.“
An accounting expense is recognised for tax purposes when it is documented by a primary accounting document within the meaning of the Accountancy Act that faithfully reflects the business transaction.Art. 19: „Данъчната основа за определяне на корпоративния данък е данъчната печалба.“
The tax base for determining corporate tax is the taxable profit.Art. 20: „Данъчната ставка на корпоративния данък е 10 на сто.“
The tax rate of corporate tax is 10 percent.Art. 21(1): „Данъчният период за определяне на корпоративния данък е календарната година, освен когато в този закон е предвидено друго.“
The tax period for determining corporate tax is the calendar year, unless this Act provides otherwise.📜Value Added Tax Act (ZDDS)Art. 66(1) (amended — State Gazette no. 52 of 2022, in force from 01.07.2022): „Стандартната ставка на данъка е 20 на сто за облагаемите доставки с място на изпълнение на територията на страната, освен изрично посочените като облагаеми с намалена или нулева ставка на данъка.“
The standard rate of the tax is 20 percent for taxable supplies with a place of supply in the country, except those expressly specified as taxable at a reduced or zero rate.Art. 66(2): „Ставката по ал. 1 се прилага и при внос на стоки на територията на страната, и при облагаеми вътреобщностни придобивания на територията на страната, освен изрично посочените като облагаеми с намалена или нулева ставка на данъка.“
The rate under para. 1 also applies to imports of goods into the country and to taxable intra-Community acquisitions in the country, except those expressly specified as taxable at a reduced or zero rate.Art. 67(1): „Размерът на данъка се определя, като данъчната основа се умножи по ставката на данъка.“
The amount of the tax is determined by multiplying the tax base by the tax rate.So we confirmed that corporate tax is 10 percent (ZKPO Art. 20) and the standard VAT rate is 20 percent (ZDDS Art. 66(1)). Rates change by law, which is why every record in the database carries its edition. The official source is the State Gazette; the KiK Info consolidation is not official.
✅RuleThe assistant gives no tax advice and does not decide how a specific transaction is treated. It shows the text closest to the question and says where it comes from. The accountant decides. -
Prepare the environment
bashpython3 -m venv .venv source .venv/bin/activate pip install ollama==0.6.3 pgvector==0.5.0 "psycopg[binary]" ollama pull bge-m3 python3 -c "import ollama, pgvector, psycopg; print('ok')"The database connection is not written into the script. Set it in an environment variable (
KB_DSN) with the value from your own installation, in place of the template:<database-connection>. -
The texts: the
kb.jsonfileWhy a file and not an automatic download? A human copies the articles from the official text and checks them. Automatic PDF parsing cuts sentences and confuses numbers (the old code cut every article to 500 characters). Here every record is a whole article or a whole paragraph, with act, number, edition and link. The text itself stays in Bulgarian.
json · kb.json[ {"act": "ЗКПО", "article": "чл. 10, ал. 1", "edition": "consolidated text up to State Gazette no. 55 of 16.06.2026, read on 01.10.2026", "url": "https://kik-info.com/normativna-baza/zakoni/zkpo/", "body": "Счетоводен разход се признава за данъчни цели, когато е документално обоснован чрез първичен счетоводен документ по смисъла на Закона за счетоводството, отразяващ вярно стопанската операция."}, {"act": "ЗКПО", "article": "чл. 19", "edition": "consolidated text up to State Gazette no. 55 of 16.06.2026, read on 01.10.2026", "url": "https://kik-info.com/normativna-baza/zakoni/zkpo/", "body": "Данъчната основа за определяне на корпоративния данък е данъчната печалба."}, {"act": "ЗКПО", "article": "чл. 20", "edition": "consolidated text up to State Gazette no. 55 of 16.06.2026, read on 01.10.2026", "url": "https://kik-info.com/normativna-baza/zakoni/zkpo/", "body": "Данъчната ставка на корпоративния данък е 10 на сто."}, {"act": "ЗКПО", "article": "чл. 21, ал. 1", "edition": "consolidated text up to State Gazette no. 55 of 16.06.2026, read on 01.10.2026", "url": "https://kik-info.com/normativna-baza/zakoni/zkpo/", "body": "Данъчният период за определяне на корпоративния данък е календарната година, освен когато в този закон е предвидено друго."}, {"act": "ЗДДС", "article": "чл. 66, ал. 1", "edition": "consolidated text up to State Gazette no. 69 of 31.07.2026, read on 01.10.2026", "url": "https://kik-info.com/normativna-baza/zakoni/zdds/", "body": "Стандартната ставка на данъка е 20 на сто за облагаемите доставки с място на изпълнение на територията на страната, освен изрично посочените като облагаеми с намалена или нулева ставка на данъка."}, {"act": "ЗДДС", "article": "чл. 66, ал. 2", "edition": "consolidated text up to State Gazette no. 69 of 31.07.2026, read on 01.10.2026", "url": "https://kik-info.com/normativna-baza/zakoni/zdds/", "body": "Ставката по ал. 1 се прилага и при внос на стоки на територията на страната, и при облагаеми вътреобщностни придобивания на територията на страната, освен изрично посочените като облагаеми с намалена или нулева ставка на данъка."}, {"act": "ЗДДС", "article": "чл. 67, ал. 1", "edition": "consolidated text up to State Gazette no. 69 of 31.07.2026, read on 01.10.2026", "url": "https://kik-info.com/normativna-baza/zakoni/zdds/", "body": "Размерът на данъка се определя, като данъчната основа се умножи по ставката на данъка."} ]⚠️Before you loadThese are exactly seven records. Before using them for real work, compare each with the text in the State Gazette, because laws are amended. Add a new article only after you have copied it whole and checked it. -
Loading into PostgreSQL:
kb_ingest.pyThe script creates the table, checks that every record has all fields and a non-empty text, and stores a vector for each. The size
1024is that ofbge-m3. A second run adds no duplicates (unique key). For a few dozen articles no index is needed: exact search is fast enough and exact.python · kb_ingest.py"""Loads kb.json into PostgreSQL with bge-m3 vectors. Public texts only.""" import json, os import ollama import psycopg from pgvector import Vector from pgvector.psycopg import register_vector EMBED = "bge-m3" # 1024 dimensions, multilingual FIELDS = ("act", "article", "edition", "url", "body") DDL = """ CREATE TABLE IF NOT EXISTS kb ( id bigserial PRIMARY KEY, act text NOT NULL, article text NOT NULL, edition text NOT NULL, url text NOT NULL, body text NOT NULL, embedding vector(1024) NOT NULL, UNIQUE (act, article, edition) ) """ def embed(text): return ollama.embed(model=EMBED, input=text)["embeddings"][0] def check(entry): for f in FIELDS: if not str(entry.get(f, "")).strip(): raise ValueError(f"missing field {f}: {entry}") if __name__ == "__main__": entries = json.load(open("kb.json", encoding="utf-8")) for e in entries: check(e) with psycopg.connect(os.environ["KB_DSN"], autocommit=True) as conn: conn.execute("CREATE EXTENSION IF NOT EXISTS vector") register_vector(conn) conn.execute(DDL) for e in entries: vec = Vector(embed(f'{e["act"]} {e["article"]}. {e["body"]}')) conn.execute( "INSERT INTO kb (act, article, edition, url, body, embedding) " "VALUES (%s, %s, %s, %s, %s, %s) " "ON CONFLICT (act, article, edition) DO NOTHING", (e["act"], e["article"], e["edition"], e["url"], e["body"], vec)) n = conn.execute("SELECT count(*) FROM kb").fetchone()[0] print("records in the database:", n) -
Search with refusal:
kb_ask.pyWhy is the refusal in code? A prompt telling the model "if you don't know, stay silent" works sometimes. The comparison
similarity >= MIN_SCOREworks always. If nothing is close enough, the program prints a fixed refusal and calls no model. Therenderfunction is separate so that it can be tested without a database and without Ollama. The messages are in English in this edition.python · kb_ask.py"""Search over kb: a verbatim quote with its source, or a refusal.""" import os, sys import ollama import psycopg from pgvector import Vector from pgvector.psycopg import register_vector EMBED = "bge-m3" MIN_SCORE = 0.55 # STARTING value: tune it on your own questions TOP_K = 3 REFUSAL = ("I do not find this in the loaded texts. " "I will not answer from memory: ask the accountant.") FOOTER = "Quote from the loaded text; not tax advice. The accountant decides." SQL = """ SELECT act, article, edition, url, body, 1 - (embedding <=> %s) AS score FROM kb ORDER BY embedding <=> %s LIMIT %s """ def embed(text): return ollama.embed(model=EMBED, input=text)["embeddings"][0] def render(rows, min_score=MIN_SCORE): """rows: (act, article, edition, url, body, score), best first.""" hits = [r for r in rows if r[5] >= min_score] if not hits: return REFUSAL parts = [] for act, article, edition, url, body, score in hits: parts.append(f"{act}, {article} ({edition})\n\"{body}\"\n" f"Source: {url} · similarity {score:.2f}") return "\n\n".join(parts) + "\n\n" + FOOTER def ask(conn, question): q = Vector(embed(question)) rows = conn.execute(SQL, (q, q, TOP_K)).fetchall() return render(rows) if __name__ == "__main__": with psycopg.connect(os.environ["KB_DSN"]) as conn: register_vector(conn) print(ask(conn, " ".join(sys.argv[1:]))) -
Run it and tune the threshold
bashpython3 kb_ingest.py python3 kb_ask.py "Каква е ставката на корпоративния данък?" python3 kb_ask.py "Каква е стандартната ставка на ДДС?" python3 kb_ask.py "Колко е данъкът при източника върху лихви, платени в чужбина?"Question (Bulgarian, with a gloss) Expected behaviour (not run) Каква е ставката на корпоративния данък? (what is the corporate tax rate?) quote: ZKPO Art. 20 — "10 на сто", with edition and link Каква е стандартната ставка на ДДС? (what is the standard VAT rate?) quote: ZDDS Art. 66(1) — "20 на сто" Какъв е данъчният период за корпоративния данък? (what is the tax period?) quote: ZKPO Art. 21(1) Withholding tax on interest paid abroad refusal: no such text in kb.jsonThe threshold is a trade-off. Too low — the program will return an unrelated article as an "answer". Too high — it will refuse even well-formed questions. Make at least 10 questions of your own, five of which must get a refusal (they are not in the database), and raise or lower
MIN_SCOREuntil both kinds come out right. Do not raise it to "look smarter": a refusal is better than a wrong article.💡Where a language model could fitOptionally, and only after you have tested it in Bulgarian: it can explain the quotes already found in plain words, and only below the verbatim text, not instead of it. Do not let it add articles that were not returned. 🔒 local ⚠️ We have not run this step. -
Maintenance and handing over to the accountant
- When a law is amended, you (or the accountant) copy the new text into
kb.jsonwith the new edition and runkb_ingest.py. The old record stays with its own edition, because the key includes the edition. - Show the accountant what is in
kb.json: the list is theirs to check, not yours. - They decide how a specific transaction is treated. The assistant files nothing, signs nothing and "recommends" nothing.
- When a law is amended, you (or the accountant) copy the new text into
04Check
- The packages are installed and
ollama listshowsbge-m3. kb_ingest.pysays "records in the database: 7"; a second run adds none.- The corporate tax question returns Art. 20 of the CIT Act with edition and link.
- A question with no text in
kb.jsonreturns the refusal. - You tuned
MIN_SCOREwith at least 10 questions of your own, five of them for refusal. - Every article in
kb.jsonwas checked against the State Gazette; the database connection is in an environment variable. - The accountant has seen the answers; the tool decided and filed nothing.
Quiz
1. When does the assistant refuse to answer?
2. Why is the answer the verbatim article text and not a model's summary?
3. Why does every record carry the edition it was checked against?
4. What is the status of an answer from the assistant?
05What's next
06Sources
- bge-m3 on Ollama 🔒 local — multilingual, 8K context, 1.2 GB.
- mxbai-embed-large on Ollama — 512 context window (the model from the old lesson).
- llama3.3 on Ollama — list of supported languages; Bulgarian is not listed.
- ollama on PyPI — version 0.6.3 · pgvector on PyPI — version 0.5.0 · psycopg on PyPI — version 3.3.6.
- Corporate Income Tax Act — consolidated text (KiK Info, in Bulgarian) — Art. 10(1), 19, 20, 21(1) checked verbatim (01.10.2026, up to State Gazette no. 55 of 16.06.2026). ⚠️ The consolidation is not official; the official text is in the State Gazette.
- Value Added Tax Act — consolidated text (KiK Info, in Bulgarian) — Art. 66(1) and (2) and Art. 67(1) checked verbatim (01.10.2026, up to State Gazette no. 69 of 31.07.2026). ⚠️ The reduced rates (Art. 66a) were not verified here.
- State Gazette — the official source of the text of the laws.