How to Build Native Semantic Search in Odoo 19 with pgvector

Strategic ERP implementation in Australia showcasing 40% cost reduction and improved operational efficiency.

Odoo 19 finally makes AI a first-class citizen of the stack, yet search itself has barely moved. Underneath the polish, name_search and _name_search still resolve to ilike predicates against a handful of indexed columns. A warehouse supervisor who types “carton that keeps arriving damaged from the Sydney supplier” gets nothing, because no substring in that sentence exists in product.template. The gap is not a UX problem. It is an architectural one, and it is solved at the database layer.

Native semantic search means the vector index lives inside the same PostgreSQL cluster that already holds your business records, not in a bolted-on service with its own auth model and backup schedule. That single decision cascades through your entire deployment topology, which is why teams that treat this as a weekend spike usually end up rebuilding it. Working with an AI-powered Odoo implementation partner early tends to save the second build, because the hard calls around index type, tenancy, and embedding refresh cost are made once rather than discovered in production.

Why Odoo 19 Keyword Search Hits a Ceiling

Lexical search fails on three predictable classes of query: paraphrase, domain synonym, and multi-entity intent. Odoo’s _rec_names_search widening helps marginally, and trigram indexes help with typos, but neither understands that “overdue supplier invoice” and “late vendor bill” describe the same records.

Vector embeddings solve this by projecting text into a high-dimensional space where semantic proximity becomes geometric proximity. The retrieval question stops being “does this string contain that string” and becomes “which records sit closest to this query vector under cosine distance”. For a technical consultant, the practical consequence is that relevance becomes tunable through data and indexing rather than through ever more baroque search domains.

The Vector Layer: Installing pgvector Inside the Odoo PostgreSQL Stack

pgvector is a PostgreSQL extension, so PostgreSQL extension management becomes a deployment concern rather than an application one. The extension must be present on every database Odoo can create, which in practice means baking it into your Postgres image and adding it to template1 so that new databases inherit it.

sql
CREATE EXTENSION IF NOT EXISTS vector;

ALTER TABLE product_template
    ADD COLUMN IF NOT EXISTS embedding vector(1536);

Do not run this from a Python init hook alone. Extension creation requires elevated privileges that your Odoo role may not carry in a hardened environment, so the safe pattern is an idempotent migration script executed by your provisioning pipeline, with the Odoo module simply asserting presence and failing loudly if the extension is missing.

HNSW or IVFFlat for ERP-Scale Workloads

pgvector offers two approximate nearest neighbour search strategies. IVFFlat partitions vectors into lists and scans a subset at query time, which makes builds fast and memory use modest. HNSW builds a multilayer navigable graph, which costs more to build and to hold in memory but delivers materially better recall and latency at query time.

For most Odoo deployments the answer is HNSW. ERP tables are written constantly and read constantly, and IVFFlat’s lists parameter needs periodic reclustering as the table grows, which is exactly the kind of maintenance nobody schedules.

sql
CREATE INDEX product_template_embedding_hnsw
    ON product_template
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

HNSW index tuning is a recall-versus-latency dial. Raising m and ef_construction improves graph quality at build time; raising ef_search at query time widens the candidate list per query. Start at the defaults, measure recall against a labelled query set, and only then adjust. If your corpus pushes memory limits, halfvec quantization halves the working set with modest recall loss, and binary quantization with a re-ranking pass goes further still.

Storing Embeddings Without Fighting the ORM

The ORM has no Vector field type, and inventing one that round-trips cleanly through read, write, and the web client is more trouble than it is worth. The pragmatic approach is a dedicated model whose column is added by SQL and whose Python field is declared as fields.Char with store=False or simply excluded from the ORM surface entirely.

python
class AiEmbedding(models.Model):
    _name = "ai.embedding"
    _description = "Semantic Index Entry"

    res_model = fields.Char(required=True, index=True)
    res_id = fields.Integer(required=True, index=True)
    company_id = fields.Many2one("res.company", index=True)
    chunk_text = fields.Text()
    # embedding vector(1536) is added via SQL in the module's post_init hook

Keeping embeddings in a satellite table rather than on the business model avoids bloating product_template reads, keeps your vacuum profile predictable, and lets one index serve many models.

The Embedding Pipeline: Queue Jobs, Chunking, and Model Choice

Embedding generation is a network call with variable latency, so it never belongs in a request cycle. Push it to queue_job or to a cron-drained staging table. Synchronous embedding inside create is the single most common cause of Odoo instances that feel fine in UAT and time out in production.

A chunking strategy matters more than most teams expect. Embedding an entire sales order as one blob dilutes the signal across line items, partner data, and notes. Chunk at the semantic unit: one chunk per description field, one per meaningful note, each carrying its res_model and res_id back-reference.

Embedding model inference cost is the other lever. Dimensions drive both storage and index memory, so a 768-dimension model can be the better engineering choice even when a 1536-dimension model scores higher on generic benchmarks.

If you are still weighing providers for the generation side of the workflow, the comparison in Gemini vs Claude vs GPT-5.6 for Odoo Development is a useful companion to this decision, since the model you pick for agent reasoning and the model you pick for embeddings rarely need to be the same vendor.

Keeping Embeddings in Sync With Record Writes

Override write to mark rows stale rather than to re-embed. A boolean needs_reindex flag plus a cron sweep gives you batching, retry semantics, and a natural backpressure valve when the provider rate-limits you. Track a content hash so that a write touching date_order does not trigger a pointless re-embedding of an unchanged description.

Querying Vectors Safely From the Odoo ORM

Vector search requires raw SQL, which means you have stepped outside the ORM’s security envelope and must step back in deliberately.

python
def semantic_search(self, query_vector, model_name, limit=20):
    self.env.cr.execute("""
        SELECT res_id, 1 - (embedding <=> %s::vector) AS score
          FROM ai_embedding
         WHERE res_model = %s
           AND company_id IN %s
      ORDER BY embedding <=> %s::vector
         LIMIT %s
    """, (query_vector, model_name, tuple(self.env.companies.ids),
          query_vector, limit))
    ids = [row[0] for row in self.env.cr.fetchall()]
    return self.env[model_name].browse(ids).exists()

The browse().exists() call is not cosmetic. It forces the ORM to apply ir.rule record-level security on read, so a user who lacks access to a matched record sees it silently drop out of results.

Record Rules, Multi-Company, and Filtered Vector Search

Filtering interacts badly with approximate indexes. If you retrieve the top 20 neighbours and then discard 18 through record rules, the user sees two results and assumes the feature is broken. Over-fetch by a healthy factor, apply multi-company data isolation in the SQL predicate where you safely can, and enable pgvector’s iterative index scans so the planner keeps pulling candidates until the post-filter quota is satisfied. Relaxed ordering usually gives better recall than strict ordering for ERP-style filtered queries.

The OWL Layer: Reactive Search Without Blocking the UI

Semantic search is slower than ilike. Round-tripping a query through an embedding provider and back adds latency that a naive OWL component will surface as a frozen dropdown.

javascript
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { useDebounced } from "@web/core/utils/timing";

export class SemanticSearch extends Component {
    setup() {
        this.orm = useService("orm");
        this.state = useState({ results: [], busy: false });
        this.onInput = useDebounced((ev) => this.search(ev.target.value), 350);
    }

    async search(term) {
        if (term.length < 3) { return; }
        this.state.busy = true;
        this.state.results = await this.orm.call(
            "ai.embedding", "search_ui", [term]
        );
        this.state.busy = false;
    }
}

Debounce, Abort, and Fine-Grained Reactivity

OWL 2 tracks reads at the property level, so mutating state.results re-renders only the subtree that consumed it. Use that: keep busy and results on the same reactive object and let OWL 2 component state handle the rest rather than hand-rolling render guards. The subtle bug to watch for is out-of-order responses, where a slow request for “inv” resolves after a fast one for “invoice”. Stamp each request with a sequence number and discard stale replies.

Feeding Retrieval Into Odoo 19 AI Agentic Workflows

Once retrieval is reliable, it becomes a tool that Odoo 19’s AI agents can call. This is retrieval augmented generation in its most defensible form, because the agent grounds its answer in records the requesting user is already permitted to see.

Expose the search as a narrowly scoped method rather than as a general SQL capability, return record references alongside text so the agent can cite and link, and always execute retrieval as the calling user rather than as a service account. An agent that can read every company’s data because it runs as superuser is a compliance incident waiting for an audit.

The DevOps Layer: Extension Management, Migrations, and Cost Guardrails

Three operational realities decide whether this survives contact with production. First, staging and production parity: a staging database without the extension will fail your module install at the worst moment, so pin the pgvector version in your image. Second, index rebuild windows, since HNSW builds are expensive and CONCURRENTLY is your friend on live tables. Third, cost observability, because embedding spend scales with write volume and an import of fifty thousand products will generate a bill nobody forecast. Meter it, cap it, and alert on it.

If you are scoping a semantic search rollout and want the index strategy, tenancy model, and embedding refresh cadence pressure-tested before you commit engineering time, Book a Consultation and we can map it against your actual data volumes and Odoo 19 deployment topology.

Conclusion

Native semantic search in Odoo 19 is less an AI project than a disciplined database and DevOps project with an AI component. pgvector keeps vectors next to the records they describe, the ORM boundary keeps record rules enforced, OWL keeps the interface responsive under real latency, and agentic workflows sit on top only once retrieval is trustworthy. Get the index type, chunking strategy, and refresh pipeline right, and the AI layer becomes almost boring to add. Get them wrong, and no model choice will rescue the result.

Frequently Asked Questions

Does pgvector work with Odoo Online or Odoo.sh?

Odoo Online does not permit arbitrary PostgreSQL extension management, so native vector search is not available there. Odoo.sh and self-hosted deployments both work, though on Odoo.sh you will need the extension enabled on your database and should confirm it survives branch rebuilds.

How many dimensions should the embedding column use?

Match your embedding model exactly, since the column type is fixed at creation. For ERP text, 768 to 1536 dimensions is the practical band. Lower dimensions reduce index memory and speed up approximate nearest neighbour search, and the recall difference on domain-specific corpora is often smaller than benchmark scores suggest.

Will HNSW index tuning slow down normal Odoo writes?

Inserts into an HNSW index are more expensive than into a B-tree, which is another argument for the satellite table pattern. Because embeddings are written by a queue job rather than by user transactions, the cost lands on background workers instead of on interactive requests.

How do I stop semantic search from leaking data across companies?

Filter on company_id inside the SQL predicate and then re-apply ir.rule record-level security by browsing the resulting IDs through the ORM. Both layers matter, because the SQL filter preserves recall while the ORM pass enforces multi-company data isolation and per-user access rules.

Can I run semantic search without an external embedding API?

Yes. A self-hosted sentence transformer served alongside your Odoo instance removes embedding model inference cost from your bill and keeps data in your own network, which often matters for Australian privacy obligations. You trade that against GPU provisioning and the ongoing maintenance of another service in your stack.

Reach Out for Support

Facing a problem? Contact us and receive expert help and fast solutions.