The Flask Mega-Tutorial, Part X: Full Text Search (2012)

Posted by
on under

(Great news! There is a new version of this tutorial!)

This is the tenth article in the series in which I document my experience writing web applications in Python using the Flask microframework.

The goal of the tutorial series is to develop a decently featured microblogging application that demonstrating total lack of originality I have decided to call microblog.

NOTE: This article was revised in September 2014 to be in sync with current versions of Python and Flask.

Here is an index of all the articles in the series that have been published to date:

Recap

In the previous article in the series we've enhanced our database queries so that we can get results on pages.

Today, we are going to continue working on our database, but in a different area. All applications that store content must provide a search capability.

For many types of web sites it is possible to just let Google, Bing, etc. index all the content and provide the search results. This works well for sites that have mostly static pages, like a forum. In our little microblog application the basic unit of content is just a short user post, not a whole page. The type of search results that we want are dynamic. For example, if we search for the word "dog" we want to see blog posts from any users that include that word. It is obvious that until someone searches for that word there is no page that the big search engines could have indexed with these results, so clearly we have no choice other than rolling our own search.

Introduction to full text search engines

Unfortunately support for full text search in relational databases is not well standardized. Each database implements full text search in its own way, and SQLAlchemy at this time does not have a full text search abstration.

We are currently using SQLite for our database, so we could just create a full text index using the facilities provided by SQLite, bypassing SQLAlchemy. But that isn't a good idea, because if one day we decide to switch to another database we would need to rewrite our full text search capability for another database.

So instead, we are going to let our database deal with the regular data, and we are going to create a specialized database that will be dedicated to text searches.

There are a few open source full text search engines. The only one that to my knowledge has a Flask extension is Whoosh, an engine also written in Python. The advantage of using a pure Python engine is that it will install and run anywhere a Python interpreter is available. The disadvantage is that search performance will not be up to par with other engines that are written in C or C++. In my opinion the ideal solution would be to have a Flask extension that can connect to several engines and abstract us from dealing with a particular one in the same way Flask-SQLAlchemy gives us the freedom to use several database engines, but nothing of that kind seems to be available for full text searching at this time. Django developers do have a very nice extension that supports several full text search engines called django-haystack. Maybe one day someone will create a similar extension for Flask.

But for now, we'll implement our text searching with Whoosh. The extension that we are going to use is Flask-WhooshAlchemy, which integrates a Whoosh database with Flask-SQLAlchemy models.

Python 3 Compatibility

Unfortunately, we have a problem with Python 3 and these packages. The Flask-WhooshAlchemy extension was never made compatible with Python 3. I have forked this extension and made a few changes to make it work, so if you are on Python 3 you will need to uninstall the official version and install my fork:

$ flask/bin/pip uninstall flask-whooshalchemy
$ flask/bin/pip install git+git://github.com/miguelgrinberg/flask-whooshalchemy.git

Sadly this isn't the only problem. Whoosh also has issues with Python 3, it seems. In my testing I have encontered this bug, and to my knowledge there isn't a solution available, which means that at this time the full text search capability does not work well on Python 3. I will update this section once the issues are resolved.

Configuration

Configuration for Flask-WhooshAlchemy is pretty simple. We just need to tell the extension what is the name of the full text search database (file config.py):

WHOOSH_BASE = os.path.join(basedir, 'search.db')

Model changes

Since Flask-WhooshAlchemy integrates with Flask-SQLAlchemy, we indicate what data is to be indexed for searching in the proper model class (file app/models.py):

from app import app

import sys
if sys.version_info >= (3, 0):
    enable_search = False
else:
    enable_search = True
    import flask_whooshalchemy as whooshalchemy

class Post(db.Model):
    __searchable__ = ['body']

    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.String(140))
    timestamp = db.Column(db.DateTime)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'))

    def __repr__(self):
        return '<Post %r>' % (self.body)

if enable_search:
    whooshalchemy.whoosh_index(app, Post)

The model has a new __searchable__ field, which is an array with all the database fields that will be in the searchable index. In our case we only want to index the body field of our posts.

We also have to initialize the full text index for this model by calling the whoosh_index function. Note that since we know that the search capability currently does not work on Python 3 we have to skip its initialization. Once the problems in Whoosh are fixed the logic around enable_search can be removed.

Since this isn't a change that affects the format of our relational database we do not need to record a new migration.

Unfortunately any posts that were in the database before the full text engine was added will not be indexed. To make sure the database and the full text engine are synchronized we are going to delete all posts from the database and start over. First we start the Python interpreter. For Windows users:

flask\Scripts\python

And for everyone else:

flask/bin/python

Then in the Python prompt we delete all the posts:

>>> from app.models import Post
>>> from app import db
>>> for post in Post.query.all():
...    db.session.delete(post)
>>> db.session.commit()

Searching

And now we are ready to start searching. First let's add a few new posts to the database. We have two options to do this. We can just start the application and enter posts via the web browser, as regular users would do, or we can also do it in the Python prompt.

From the Python prompt we can do it as follows:

>>> from app.models import User, Post
>>> from app import db
>>> import datetime
>>> u = User.query.get(1)
>>> p = Post(body='my first post', timestamp=datetime.datetime.utcnow(), author=u)
>>> db.session.add(p)
>>> p = Post(body='my second post', timestamp=datetime.datetime.utcnow(), author=u)
>>> db.session.add(p)
>>> p = Post(body='my third and last post', timestamp=datetime.datetime.utcnow(), author=u)
>>> db.session.add(p)
>>> db.session.commit()

The Flask-WhooshAlchemy extension is nice, because it hooks up into Flask-SQLAlchemy commits automatically. We do not need to maintain the full text index, it is all done for us transparently.

Now that we have a few posts in our full text index we can issue searches:

>>> Post.query.whoosh_search('post').all()
[<Post u'my second post'>, <Post u'my first post'>, <Post u'my third and last post'>]
>>> Post.query.whoosh_search('second').all()
[<Post u'my second post'>]
>>> Post.query.whoosh_search('second OR last').all()
[<Post u'my second post'>, <Post u'my third and last post'>]

As you can see in the examples above, the queries do not need to be limited to single words. In fact, Whoosh supports a pretty powerful search query language.

Integrating full text searches into the application

To make the searching capability available to our application's users we have to add just a few small changes.

Configuration

As far as configuration, we'll just indicate how many search results should be returned as a maximum (file config.py):

MAX_SEARCH_RESULTS = 50

Search form

We are going to add a search form to the navigation bar at the top of the page. Putting the search box at the top is nice, because then the search will be accessible from all pages.

First we add a search form class (file app/forms.py):

class SearchForm(Form):
    search = StringField('search', validators=[DataRequired()])

Then we need to create a search form object and make it available to all templates, since we will be putting the search form in the navigation bar that is common to all pages. The easiest way to achieve this is to create the form in the before_request handler, and then stick it in Flask's global g (file app/views.py):

from forms import SearchForm

@app.before_request
def before_request():
    g.user = current_user
    if g.user.is_authenticated:
        g.user.last_seen = datetime.utcnow()
        db.session.add(g.user)
        db.session.commit()
        g.search_form = SearchForm()

Then we add the form to our template (file app/templates/base.html):

<div>Microblog:
    <a href="{{ url_for('index') }}">Home</a>
    {% if g.user.is_authenticated %}
    | <a href="{{ url_for('user', nickname=g.user.nickname) }}">Your Profile</a>
    | <form style="display: inline;" action="{{ url_for('search') }}" method="post" name="search">{{ g.search_form.hidden_tag() }}{{ g.search_form.search(size=20) }}<input type="submit" value="Search"></form>
    | <a href="{{ url_for('logout') }}">Logout</a>
    {% endif %}
</div>

Note that we only display the form when we have a logged in user. Likewise, the before_request handler will only create a form when a user is logged in, since our application does not show any content to guests that are not authenticated.

Search view function

The action field of our form was set above to send all search requests the the search view function. This is where we will be issuing our full text queries (file app/views.py):

@app.route('/search', methods=['POST'])
@login_required
def search():
    if not g.search_form.validate_on_submit():
        return redirect(url_for('index'))
    return redirect(url_for('search_results', query=g.search_form.search.data))

This function doesn't really do much, it just collects the search query from the form and then redirects to another page passing this query as an argument. The reason the search work isn't done directly here is that if a user then hits the refresh button the browser will put up a warning indicating that form data will be resubmitted. This is avoided when the response to a POST request is a redirect, because after the redirect the browser's refresh button will reload the redirected page.

Search results page

Once a query string has been received the form POST handler sends it via page redirection to the search_results handler (file app/views.py):

from config import MAX_SEARCH_RESULTS

@app.route('/search_results/<query>')
@login_required
def search_results(query):
    results = Post.query.whoosh_search(query, MAX_SEARCH_RESULTS).all()
    return render_template('search_results.html',
                           query=query,
                           results=results)

The search results view function sends the query into Whoosh, passing a maximum number of search results, since we don't want to be presenting a potentially large number of hits, we are happy showing just the first fifty.

The final piece is the search results template (file app/templates/search_results.html):

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <h1>Search results for "{{ query }}":</h1>
  {% for post in results %}
      {% include 'post.html' %}
  {% endfor %}
{% endblock %}

And here, once again, we can reuse our post.html sub-template, so we don't need to worry about rendering avatars or other formatting elements, since all of that is done in a generic way in the sub-template.

Final words

We now have completed yet another important, though often overlooked piece that any decent web application must have.

The source code for the updated microblog application is available below:

Download microblog-0.10.zip.

As always, the above download does not include a database or a flask virtual environment. See previous articles in the series to learn how to create these.

I hope you enjoyed this tutorial. If you have any questions feel free to write in the comments below. Thank you for reading, and I will be seeing you again in the next installment!

Miguel

Become a Patron!

Hello, and thank you for visiting my blog! If you enjoyed this article, please consider supporting my work on this blog on Patreon!

151 comments
  • #101 Miguel Grinberg said

    @Aby: sounds like a file permissions problem. You may need to fix the permissions on whoosh_index.

  • #102 Roman said

    What if I want to get a simple search based on static html files (i dont have a db in my app). What search engine could help me?

  • #103 Miguel Grinberg said

    @Roman: The google search APIs will probably work well for you.

  • #104 Miguel Grinberg said

    @Cory: This has been answered above. See #19 and #20.

  • #105 Nishchay Kala said

    Regarding the issue
    https://github.com/gyllstromk/Flask-WhooshAlchemy/issues/20 as pointed out by Neil.

    I too was facing the same issue as 'Post.query.whoosh_search('post').all() ' returned empty list all the time.

    I found that it can be solved with using the specific versions specified here
    'https://github.com/gyllstromk/Flask-WhooshAlchemy/blob/master/requirements.txt'

    I was using following versions:
    Flask==0.10.1
    Flask-SQLAlchemy-2.1
    Whoosh-2.7.2
    blinker-1.4

    I replaced them with
    Flask==0.10.1
    Flask-SQLAlchemy==1.0
    Whoosh==2.6.0
    blinker==1.3

  • #106 Basil Dubyk said

    @Nishchay Kala

    Thank you for your suggestion with specific versions. It works for my app.

    @Miguel Grinberg Thanks for the tutorial!

  • #107 Juanjo Fernandez said

    Hi Miguel,

    Did you found another full text search instead of Whooshalchemy for Python 3?
    Some news about this?

    Thanks and congratulations for this blog

  • #108 Miguel Grinberg said

    @Juanjo: I believe Whoosh has been ported to Python 3 already. I haven't had a chance to test it yet, but I will update the tutorial soon if that is the case.

  • #109 Stanley said

    Hi Miguel, Did you have any news about porting to Py 3 #108? If you get the worked version and test, please mark a comment for us. Thx

  • #110 Miguel Grinberg said

    @Stanley: I have not have time to review this yet. Sorry.

  • #111 Stanley said

    Hi Miguel, Not mind, thx for your reply. Today I got a workaround for Whoosh with Python3.5(Flask-SQLAlchemy==2.1,Flask-WhooshAlchemy==0.56,SQLAlchemy==1.0.12). It worked using your codes.
    It seems that whoosh did not fully support python3.5. The below code has a little update:
    (models.py)
    import flask_whooshalchemy as whooshalchemy
    whooshalchemy.whoosh_index(app, Post)
    (./site_packages/flask_whooshalchemy.py)
    Old code:=================
    for model, values in bytype.iteritems():
    index = whoosh_index(app, values[0][1].class)
    New code:=================
    for model, values in bytype.items():
    index = whoosh_index(app, values[0][1].class)

  • #112 Jakob said

    It seems like that just recent that the problem has been solved:
    https://bitbucket.org/mchaput/whoosh/issues/395/ascii-codec-error-when-performing-query

  • #113 Ammar said

    Sir i've followed your tutorials step by step and every thing works just fine except the Full-Text Search tutorial.

    Inside app/modules.py, i've already added the searchable inside class Post:

    __searchable__ = ['body']
    
    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text)
    body_html = db.Column(db.Text)
    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    

    at the bottom also added the if statment out side the class :

    if enable_search:
    whooshalchemy.whoosh_index(app, Post)

    tell now every thing works without any errors, but if i want to search it gives me no results:

    Search results for "post":

    also inside the shell am getting nothing, why !!!!!

    And thanks in advance for your great tutorials :) .

  • #114 Ammar said

    And if i logged out from the site, it gives me this error :

    UndefinedError: 'flask.ctx._AppCtxGlobals object' has no attribute 'search_form' .

  • #115 Miguel Grinberg said

    @Ammar: Hard to know without seeing a full stack trace. But in any case, if you get my files from the github repository you can compare and find your mistake.

  • #116 Kazauwa said

    Thank you very much for your lessons! Just wanted to notify you that it seems that the bug was finally fixed

    https://bitbucket.org/mchaput/whoosh/issues/395/ascii-codec-error-when-performing-query

    Thanks again!

  • #117 Justin said

    Miguel,

    I am a data scientist who is completely new to web development and, I must say, this is a spectacular tutorial. Very clear, concise, and linear. I definitely will be using Flask on future projects.

    One concern...I am using Python 2.7, but my queries (Post.query.whoosh_search('stuff').all()) are returning empty lists. Does the Whoosh library require any addition configuration?

  • #118 Miguel Grinberg said

    @Justin: you would not be able to search old stuff, once you add the support for the full text search any new data that goes in the database will also be added to the whoosh index.

  • #119 Chris said

    Kaya, I want to say thank you for posting the fix. I was stuck all day until I read your post.

    Miguel - I started learning python two months ago. Your tutorial is GOLD. Cheers!

  • #120 Timothy Cleveland said

    Every time I try to run search I get this:

    Traceback (most recent call last):
    File "/Users/timothy/Amazingblog/flask/lib/python3.5/site-packages/flask/app.py", line 1836, in call
    return self.wsgi_app(environ, start_response)
    File "/Users/timothy/Amazingblog/flask/lib/python3.5/site-packages/flask/app.py", line 1820, in wsgi_app
    response = self.make_response(self.handle_exception(e))
    File "/Users/timothy/Amazingblog/flask/lib/python3.5/site-packages/flask/app.py", line 1403, in handle_exception
    reraise(exc_type, exc_value, tb)
    File "/Users/timothy/Amazingblog/flask/lib/python3.5/site-packages/flask/_compat.py", line 33, in reraise
    raise value
    File "/Users/timothy/Amazingblog/flask/lib/python3.5/site-packages/flask/app.py", line 1817, in wsgi_app
    response = self.full_dispatch_request()
    File "/Users/timothy/Amazingblog/flask/lib/python3.5/site-packages/flask/app.py", line 1477, in full_dispatch_request
    rv = self.handle_user_exception(e)
    File "/Users/timothy/Amazingblog/flask/lib/python3.5/site-packages/flask/app.py", line 1381, in handle_user_exception
    reraise(exc_type, exc_value, tb)
    File "/Users/timothy/Amazingblog/flask/lib/python3.5/site-packages/flask/_compat.py", line 33, in reraise
    raise value
    File "/Users/timothy/Amazingblog/flask/lib/python3.5/site-packages/flask/app.py", line 1475, in full_dispatch_request
    rv = self.dispatch_request()
    File "/Users/timothy/Amazingblog/flask/lib/python3.5/site-packages/flask/app.py", line 1461, in dispatch_request
    return self.view_functionsrule.endpoint
    File "/Users/timothy/Amazingblog/app/views.py", line 282, in search
    results = Post.query.whoosh_search(query, MAX_SEARCH_RESULTS).all()AttributeError: 'BaseQuery' object has no attribute 'whoosh_search'

  • #121 Miguel Grinberg said

    @Timothy: Have you installed and configured the Flask-Whooshalchemy extension, as indicated in this article? Compare your code to mine on GitHub, maybe that'll help you find what's wrong.

  • #122 Xavi said

    Hey Miguel,
    I'm having trouble when clicking search "button" because when i do that i get nothing but the same page i was before clicking that button. I really have no idea whats going on here.
    I would appreciate a lot any help and nonetheless i would like to greet you on doing these tutorials. You're awesome!
    Best regards,
    Xavi

  • #123 Miguel Grinberg said

    @Xavi: do the searches work from the Python console? Have you checked what request(s) are being sent to the server when you press the search button? I really can't help you here, you need to debug the problem. Feel free to use my code on GitHub as a reference.

  • #124 Xavi said

    @Miguel Grinberg: Yes, they work exactly like the way u show in this tutorial part. And the requests being sent are something like this:
    "127.0.0.1 - - [04/Dec/2016 20:23:57] "GET /?csrf_token=1480886632%23%23042041590cac789b713ebadd44dc0cf775889e3c&search=Xavi HTTP/1.1" 200 -"
    ....
    and i'm only passing "methods=["POST"]""... That's really confusing.. Thank you a lot for wasting ur time solving noob problems :p

  • #125 Xavi said

    @Miguel Grinberg: Thank you a lot, i solved the problem! Could you give me a hint on how to make a search bar giving results from different tables ? In my case they are: Politic, Organization and Proposal. I already have done that to the table Politic. But now i want to mix all the results from searching about these three tables records..

Leave a Comment