DEV Community

Alexander Valenchits
Alexander Valenchits

Posted on

Your Django App Has Years of Data. Here's How to Make AI Agents Actually Use It.

The Problem Every Django Developer Knows

You have a Django app with years of data — products, articles, orders, users. Your users type natural language queries into a search box and get either nothing or keyword-matched garbage.

Worse: you want to connect an AI agent that can answer questions about this data. But everything is locked inside relational tables. To feed it to an LLM, you either dump the database, write custom ETL pipelines, or stand up a separate vector store and manually keep it in sync with your ORM.

I ran into exactly this problem. So I built a library that solves it with one config file.


The Gap Between Django ORM and AI

Classic Django search solutions solve one problem — search. But they all come with a price:

Solution What You Need Why It Hurts
Haystack + Elasticsearch Separate server, manual field mapping Mountains of boilerplate, no semantics
django.contrib.postgres.search PostgreSQL only Exact match, no meaning
Custom RAG pipeline Export scripts, pandas, numpy Data goes stale, no ORM connection

None of them answer the real question: How do I make an AI agent work with live data from my Django app without rewriting the architecture?


The Core Idea: Your ORM Graph as a Vector Source

django-graph-search doesn't just vectorize individual model fields. It traverses your ORM relation graph and builds a rich document that captures the full context of an object.

Take a simple Product model:

Product(pk=42)
├── name: "Pixel 8"
├── description: "Camera-first Android phone with Tensor G3"
├── category → Category.name: "Smartphones"          ← FK
├── tags → [Tag.name: "android", "5G", "camera"]     ← M2M
└── brand → Brand.description: "Google hardware..."  ← FK depth=2
         └── country → Country.name: "USA"           ← depth=2
Enter fullscreen mode Exit fullscreen mode

All of this gets merged into one text document, which is passed to the embedding model. The resulting vector is semantically rich — it carries information not just about the object itself, but about its entire relational context.

This is done by GraphResolver, which recursively walks _meta.get_fields(), handles FK, M2M, and reverse relations, and tracks cycles via a visited set.


Zero Migrations. Zero Schema Changes.

The most important part: all of this is added on top of your existing Django application. You don't change models, don't create new tables, don't touch your views.

Everything lives in settings.py:

INSTALLED_APPS = [
    ...,
    "django_graph_search",
]

GRAPH_SEARCH = {
    "MODELS": [
        {
            "model": "shop.Product",
            "fields": ["name", "description", "category__name", "tags__name"],
            "follow_relations": True,
            "relation_depth": 2,
        },
    ],
    "VECTOR_STORE": {
        "BACKEND": "django_graph_search.backends.ChromaDBBackend",
        "OPTIONS": {"persist_directory": "vector_db"},
    },
    "EMBEDDINGS": {
        "default": {
            "BACKEND": "django_graph_search.embeddings.SentenceTransformerBackend",
            # Multilingual — works with Russian, English, German, etc.
            "MODEL_NAME": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
        }
    },
    "DELTA_INDEXING": True,
}
Enter fullscreen mode Exit fullscreen mode

One command and your vector index is built:

python manage.py build_search_index
Enter fullscreen mode Exit fullscreen mode

Your Django app runs exactly as before. Data stays in PostgreSQL. Vectors live in ChromaDB alongside. post_save signals keep the index updated automatically whenever objects change.


Control What Gets Vectorized

Not everything in your database should end up in a vector. Technical fields like slug, created_at, internal admin notes — these are noise that degrades search quality.

weight_fields gives you precise control:

"weight_fields": {
    "title": 2.0,         # repeated twice — embedding "remembers" it more
    "description": 1.0,   # standard weight
    "internal_note": 0.0, # weight 0.0 = completely excluded from the vector
    "slug": 0.0,
}
Enter fullscreen mode Exit fullscreen mode

This isn't just filtering. Under the hood, the GraphResolver._apply_weight() method repeats text fragments in the document proportionally to their weight. A field with weight 2.0 appears twice in the concatenated string, shifting the embedding centroid toward that concept in vector space.


RAG Over Django Data: The Real Pattern

Now for the main point. Here's how this connects to AI agents.

The standard RAG pattern (Retrieval-Augmented Generation) requires: get user query → retrieve relevant context → pass to LLM. Normally "retrieve context" means separate infrastructure. With django-graph-search, it's three lines:

from django_graph_search import search

def build_llm_context(user_question: str) -> str:
    results = search(
        user_question,
        models=["shop.Product", "blog.Article"],
        limit=5
    )
    # Each result contains "text" — the full indexed document
    # and "score" — cosine similarity from 0.0 to 1.0
    context = "\n\n".join(
        r["text"] for r in results if r["score"] > 0.7
    )
    return context

# Pass context to the system prompt of any LLM
Enter fullscreen mode Exit fullscreen mode

Key detail: results[*]["text"] is not just str(instance). It's the full text document used to build the vector — with all related fields, all weights applied. The LLM receives rich, relational context, not just an object name.


LangGraph Pipeline: Agents With Memory

For more demanding use cases, the library ships an optional LangGraph pipeline. It adds:

  • Query expansion — an LLM generates 2–3 semantic reformulations; search runs across all variants and merges results
  • Reranking — top-K candidates are reordered by an LLM based on true relevance
  • Streaming — clients see real-time progress via SSE or NDJSON
GRAPH_SEARCH = {
    "LANGGRAPH": {
        "ENABLED": True,
        "QUERY_EXPANSION": True,
        "RERANKING": True,
        "MAX_EXPANDED_QUERIES": 3,
        "FALLBACK_ON_ERROR": True,  # if LLM fails, falls back to pure vector search
        "LLM": {
            "BACKEND": "myapp.llm.OllamaBackend",  # plug in your own Ollama/vLLM/OpenAI
        },
    },
}
Enter fullscreen mode Exit fullscreen mode

Important: langgraph is an optional dependency. If the package is not installed, the pipeline automatically falls back to a built-in _FallbackGraph with identical behavior. Your application code doesn't change.

The conversational endpoint adds stateful memory between requests:

User:  "show me smartphones under $500"
Agent: [returns list]
User:  "only Samsung"
Agent: [understands previous context, interpreted_query = "Samsung smartphones under $500"]
Enter fullscreen mode Exit fullscreen mode

The Path to Production — Same Config, Different Backend

Migrating between vector backends is just changing one line:

Dev:        ChromaDB  → local, no server, files on disk
Staging:    FAISS     → fast, CPU-only, everything in memory
Production: pgvector  → if you already have PostgreSQL — no new server at all
            Qdrant    → if you need filtering and horizontal scaling
Enter fullscreen mode Exit fullscreen mode

Embedding models are swappable the same way: local sentence-transformers, OpenAI, Cohere — all through EMBEDDINGS.BACKEND. Zero code changes.


What This Really Is

django-graph-search is not just another search library. It's a vector layer on top of your existing Django ORM that:

  1. Requires no database schema changes
  2. Automatically builds rich documents by traversing model relations
  3. Makes your application data available to AI agents via a standard RAG pattern
  4. Scales from local ChromaDB to production Qdrant without refactoring
pip install django-graph-search[chromadb]
Enter fullscreen mode Exit fullscreen mode

Your data is already there. It just needs a vector layer.


GitHub: svalench/django_graph_search · PyPI: django-graph-search

Top comments (0)