The KAGAMI mark КАГАМИ
kagami.bg/academy · lesson · machine-readable viewVERIFIED 2026-10-01 · UPDATED 2026-10-01
IDENTITY
module
GX10-04-130 · Local accounting assistant: retrieval over the Bulgarian Corporate Income Tax Act and VAT Act, with citation and refusal
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Advanced
duration
about 60 min
prerequisites
Ollama running locally (the series pins 0.35.0, see 04-01), PostgreSQL with the pgvector extension, Python 3.10+, an accountant who reviews every answer
trust_label
VERIFIED 2026-10-01 (legal texts read in a consolidated text; model pages on ollama.com; package versions on PyPI) · UPDATED 2026-10-01 · NOT TESTED on a GB10-class machine; NOT run against a real PostgreSQL or a real Ollama; only the answer-or-refuse logic (render) was run in a sandbox with invented rows (Python 3.10.12)
versions
Ollama 0.35.0 (series pin) · ollama Python client 0.6.3 (2026-09-29) · pgvector Python package 0.5.0 (2026-07-06) · psycopg 3.3.6 · embedding model bge-m3 (1024 dimensions, 8K context)
legal_texts
CIT Act (ZKPO), consolidated text up to State Gazette no. 55 of 16.06.2026, and VAT Act (ZDDS), consolidated text up to State Gazette no. 69 of 31.07.2026, read on 2026-10-01 from a private consolidation (KiK Info); the official source is the State Gazette. Quoted verbatim in the lesson (Bulgarian original, with an unofficial English translation): ZKPO Art. 10(1), 19, 20, 21(1); ZDDS Art. 66(1), 66(2), 67(1)
language
human view: english edition · bulgarian edition: /academy/gx10/ (same file name)
previous / next
04-129 draft VAT journals and return / 04-132 bank statement reconciliation (finance chain: 04-127, 04-128, 04-129, 04-130, 04-132, 04-133, 04-134, 04-176; related: 04-133 corporate tax scenarios)
PURPOSE

Build a small retrieval system on your own machine that answers questions about Bulgarian tax law only by returning the verbatim text of the matching article together with act, article number, edition and source link, and that refuses when no stored text is close enough. The tool does not give tax advice and does not decide how a transaction is treated: an accountant does. All example content is public legal text; no client data is involved.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

04-132 bank statement reconciliation (04-132_Bank_Statement_Reconciliation.html) · previous: 04-129 draft VAT journals and return · related: 04-133 corporate tax scenarios · GX10 series index · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
gx10ragpgvectorollamabulgarian-tax-lawzkpozddscitation-or-refusaldraft-with-human-review
VERIFIED · 01.10.2026 UPDATED · 01.10.2026

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.

⏱ 60 min Advanced GX10 Accounting · law RAG · PostgreSQL · Ollama · Python
Ollama (bge-m3)🔒 local PostgreSQL + pgvector🔒 local Python🔒 local
🔄
UPDATED · 01.10.2026 — what changed
The lesson was reworked. We removed things we could not confirm or that hurt a citation: cutting every article to 500 characters (that quotes a law by halves), the references "VAT Act Art. 84" and "CIT Act Art. 204" in the sample questions, the postings "Dt 4531 / Ct 4532", the endpoint that "suggests a journal entry" with amounts in leva, and the claim that Llama 70B "writes Bulgarian with correct terminology": on its Ollama page Bulgarian is not among the listed languages. We also replaced the embedding model: 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).
⚠️
What we have not run ourselves
We did not run Ollama, the embeddings, PostgreSQL or 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

02Before you start

Package / modelVersion (checked 01.10.2026)What it is for
Ollama0.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
psycopg3.3.6PostgreSQL connection
bge-m31.2 GB · 8K context · 100+ languagesvectors for search
💡
Why not Llama 3.3 70B, as before
On the 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

  1. 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.

    ✅
    Rule
    The 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.
  2. Prepare the environment

    bash
    python3 -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>.

  3. The texts: the kb.json file

    Why 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 load
    These 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.
  4. Loading into PostgreSQL: kb_ingest.py

    The script creates the table, checks that every record has all fields and a non-empty text, and stores a vector for each. The size 1024 is that of bge-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)
  5. Search with refusal: kb_ask.py

    Why is the refusal in code? A prompt telling the model "if you don't know, stay silent" works sometimes. The comparison similarity >= MIN_SCORE works always. If nothing is close enough, the program prints a fixed refusal and calls no model. The render function 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:])))
  6. Run it and tune the threshold

    bash
    python3 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 abroadrefusal: no such text in kb.json

    The 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_SCORE until 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 fit
    Optionally, 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.
  7. Maintenance and handing over to the accountant

    • When a law is amended, you (or the accountant) copy the new text into kb.json with the new edition and run kb_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.

04Check

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

  1. bge-m3 on Ollama 🔒 local — multilingual, 8K context, 1.2 GB.
  2. mxbai-embed-large on Ollama — 512 context window (the model from the old lesson).
  3. llama3.3 on Ollama — list of supported languages; Bulgarian is not listed.
  4. ollama on PyPI — version 0.6.3 · pgvector on PyPI — version 0.5.0 · psycopg on PyPI — version 3.3.6.
  5. 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.
  6. 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.
  7. State Gazette — the official source of the text of the laws.