Vector Search and PMOs

Experiments in low-resource use of vector search

Other formats: Plain text, PDF, EPUB.

Overview

As part of our funding from the Patrick J. McGovern Foundation, we've been exploring how machine learning approaches might be useful in our democracy programme of work.

This report is working notes on the application of vector search to a few of our ongoing projects.

This covers:

  • Basics of vector search
  • Practically storing vectors in a postgres/django setup.
  • Three use cases:
    • Improving transcript parsers using vector analysis to find variations on significant phrases.
    • Creating a similarity dataset that could be used alongside a traditional database search.
    • A lightweight vector alert process to find declarations of interests.

Building a vector store in Postgres and Django

The idea of stand-alone "vector stores" is part of the commercial AI offering - but there are increasingly bolt-ons available for the kind of relational databases we already deploy. Postgres (the database provider we use for everything *except* TheyWorkForYou) already has a plugin available that can provide the right field types and indexes. There are similar projects in motion for mariadb, sqlite, and duckdb. This in general means we should be cautious about solutions involving new stores or databases - because it's pretty likely the techniques will be available in an easier way soon.

To find out how hard it is to work with the kind of tools we're already using, I made a vector explorer based on postgres and django (that could, for instance, replace the approach we have in CAPE relatively straightforwardly).

This work can be seen in the twfy-vector-explorer repo. In the Dockerfile, I'm using a different source for postgres that already has the pg_vector plugin loaded.

With that, in django you just need to bring in an extra package and you have access to the VectorField field type, and the HNSW index type. In practice, for loading very large amounts of data I was dropping and recreating this index.

Python django config to create a ParagraphVector model including a hnswindex

Searching TheyWorkForYou using vectors

The next step is to load TheyWorkForYou's transcript data into the database.

To do this we go through all speeches in the raw data (I used the last few years for devolved parliaments, and the last decade for the Commons), and convert them at the paragraph level into an embedding (in this case, a 364 length vector) and load them into the database (using the django bulk_create method).

Practically, this took about a day to get the data in - but that included some back and forth about the best way to create indexes. I think to fully convert the full TheyWorkForYou history would be the work of a week once the process was stabilised.

This enables: as demonstrated by this notebook, to take a search query, convert it to an embedding - and query the database for similar phrases that are *not* direct text matches.

In this case, a search for mental health is also returning paragraphs about ‘mental and physical health' and ‘mental illness care'.

Local vs remote embeddings

I tried three different methods of creating embeddings:

  • HuggingFace - To do bulk conversion of TWFY corpus I used the Hugging Face API. Periodically this ran into rate limits, but either waiting (or paying in future) would stop this.
  • Fastembed library - This used the same model but did inference locally using the CPU. It would take about a minute to do a day's records - but works great for converting the search query before passing to the vector research.
  • Fastembed-gpu library - A GPU version of the same framework.

The embeddings model I'm using is ‘bge-small-en-v1.5' - it was the example in the sample code and on a quick check the model itself is open source, and performed well in rankings. The source of this model is: ‘We pre-train the models using retromae [wikipedia and bookcorpus] and train them on large-scale pair data using contrastive learning.' - where that pair data is unclear.

In general, it seemed like there's less lock-in cost than I expected on an embedding model. Switching would require re-uploading the database - but the calculation itself is not a massive problem (days rather than weeks on a big historical dataset).

Uses

Once I had this working store, I've started to apply it to various problems we're working with at moment:

  • Better data on what people are voting on
  • Improved political monitoring and search
  • WhoFundsThem - work around the register of members interests

Improving transcript parsers

One of the problems I'm looking at is correctly identifying possible motions in parliamentary transcripts, and assigning those to decisions made.

This makes it easier for us to display in one place both what was being voted on, and how people voted.

In principle, generative AI tools like ChatGPT are word calculators, and should be good at exactly this kind of problem. What I found was that it was relatively good at extracting a motion if given text with a motion - but was much less effective when given a range of speeches that may or may not contain a motion.

I think this could be much improved by creating a good dataset to give more examples or training data - but there are also fundamental disadvantages of this approach: it's slow and when it goes wrong, it won't always be clear *why*.

The approach I took was to use this vector search to augment a traditional approach.

Parliamentary speech is quite structured - with form and procedures for how actions like moving a motion will happen. This means it's a good candidate for building parsers that look for text that signifies "motion starts here" "motion ends here" and extracting the bit in the middle.

However, while speech is structured, it's not consistent - with lots of variations on similar forms of words. Here's where we can use vector search to help us out.

By searching for the motion start phrases we know about, we can find similar phrases, search for those and so on. Each one needs to be checked that it really is doing the same job - but it's a useful way to find variations at the start of the process - rather than encountering them as you expand the process.

To bring other machine learning tools into the mix - a really useful tool in building parsers is regular expressions (regex) - these are expressions used for pattern matching that describe abstract shapes of text ("a phrase followed by a bracket followed by text followed by close bracket followed by colon") and can then match and extract content from text it's given.

This is a really powerful tool - but you have to both understand in the abstract what you're trying to match, and how regex would express that. Once you get the hang of it, reading what expressions are doing is easy enough, but I always find creating them fiddly.

So when finding variations like "Amendment proposed (31 January):" I used ChatGPT to propose what the regular expression should be, and then give it a test to see if it's doing what it should. My general finding was that it was pretty good - but not 100% - and about one-in-five times needed some extra manual tweaking. But - this was a speed up,and it was easier to fix something that was 90% there than start from scratch.

screenshot of code showing variations in openings to motions: "I beg to move", "I therefore beg to move", "motion made, and question put", etc.

What's next for this approach?

Results of this approach are currently powering a motion detector that will feed into our new votes data platform.

The big downside of this approach is that while it accounts for past variation, future variation may catch it out. However, combined with the vector alert experiment (below) - we could create tools to raise the alarm about new variations.

Similarity injection for alert creation

With CAPE, we have previously experimented with a halfway house approach to machine learning enhanced search. Here we (working with a fellow as part of the Faculty support provided as part of Quadrature funding) generated vectors from terms used in the CAPE corpus, and used it to identify terms that were similar. These terms are then loaded into the CAPE database. During a search, when a known term is used, related terms are injected as ‘OR' into the query.

Search results for "solar panels", including 45 closely related terms such as photovoltaic, photovoltaic, and solar panel.

The advantages of this approach is we do not need to store historic embeddings or generate new ones for every search. What the fuzzy matching is doing is also visible to the user - with some related search terms highlighted. The magic is behind the scenes, but not a black box.

Based on that work, I explored a similar approach for the TheyWorkForYou corpus. Here we're trying to learn things from vectors of the whole corpus, to create a dataset that we can strategically apply to queries elsewhere.

Specifically: we want to help people make better email alerts that capture more references to the concept, rather than the exact phrase they're after.

To do this I took ten years of the Commons corpus and created ngrams of length 2-5 (counts of the unique occurrences of two word sequences, three word sequences, etc).

I then applied various thresholds to exclude phrases that are very uncommon (and help the dataset fit into memory), and created embeddings and loaded it into the vector explorer database.

I could now search through not just the sentences in the corpus, but find similarities between specific phrases. I added a few extra filters to catch where phrases were ending or starting "or, and", or duplicate longer phrases ("mental health" and "with mental health"). Experimenting with the threshold - a cosine similarity of about 0.15 seemed a good amount of relevant matches.

For instance, querying "mental health", would suggest:

  • Mental ill health
  • Mental illnesses
  • Mental and physical health
  • Health and mental
  • Mental illness
  • Mental ill-health
  • Mental or physical health.

If you wanted to have a keyword search for mental health, having the option to add some of the above would be helpful in capturing references you might otherwise miss.

From this I took the search log from 2020+, reduced it to searches that have been done 50 times, and then to searches that were also in the ngram list to end up with about 1000 common search queries. Many of these were for politician names - and so less useful - but there's some good illustrations of the concept.

The first word here is an actual search that has been made - the rest are extra checkboxes we could then prompt with:

  • foreign aid: overseas aid, international aid
  • Freedom of information: right to information, information rights
  • air pollution: air quality, air pollutants
  • offshore wind: offshore renewable energy, offshore renewable, offshore energy
  • gambling harm: gambling-related harm, gambling addiction
  • contaminated blood: blood contamination, infected blood
  • Gay rights: lgbt rights, rights of lgbt, transgender rights
  • free school meals: free school dinners, free school lunches, school meals, school meal provisions

What's next for this approach?

The simplest thing is to run the generator for a much larger set of ngrams - so we have this as a store for future queries (e.g. 100,000 rather than 1000). This is doable in a few days of processing. This data is then valuable even if we delete the current version of the vector database.

An additional table in TheyWorkForYou could (like in CAPE) inject these into the search or alert creation process.

This will then be frozen in time - and be less useful in suggesting similarities on live issues. But as many different terms may be used in the same debate (and sometimes what we're catching here is different partisan inflections) - analysis of incoming transcripts might help periodic updates.

This might be quite a scalable approach to vector search for low resource PMOs - it works well in a "volunteer data scientist follows the steps and produces the dataset" model and then it comfortably sits in the background of traditional search, without major infrastructure overhaul.

Building a vector alert for references to members interests

When speaking in a debate that touches on one of their outside financial interests, MPs are supposed to mention this. One of the interesting things I found in the initial experiment was that the vector search was kicking up lots of variations in this approach.

For WhoFundsThem - we want to be able to track and give information to volunteers about these kinds of declarations. This information helps us either create guidance, or datasets that can be used directly to answer the questions.

But if we had a comprehensive list of these as they were said - we could do some regular analysis and comms around good and bad ways this information is disclosed.

This seemed a good candidate to try and create a vector alert tool. This can be seen in https://github.com/mysociety/mini-transcript-search.

To do searches of recent transcripts - you don't need to build a big database or index at all. Instead, I pulled out the embedding generating code into a little repository - which then converted the last few days to vectors to calculate the vector similarity against a specific query in about a minute.

I've built this as a tool that could be used from a notebook or the command line.

Running this today:

uvx --from git+https://github.com/mysociety/mini-transcript-search transcript-search "register of members financial interests" --threshold 0.4 --n 5 --dest yesterday.json

  • Installs the underlying requirements and embeddings model.
  • Downloads transcripts
  • Generates embeddings locally.
  • Does a search - and dumps the results in a file.

This gave me a file that contained:

Screenshot of a json result from the search saying "I refer members to my entry in the register of members finaincal intersts"

Screenshot of a speech from Dan Cardon talking about the register of financail intersts.

From this we can click through to the register on TheyWorkForYou to find what he's talking about is a donation from the Prison Officers Association.

Now, this is to be fair something that would work pretty well on a normal search. Extending it back for all of September gives us a few more you might not otherwise pick up:

Screenshot of 'i start by declaring an interest' flagged.

Screenshot of Grahame Morris speech tallking about links to transport unions,.

Screenshot of Andrew George speech - declaring an interest viaprofessional experience.

A search for the normal form of words would miss this (slightly more rhetorical) of declaring interests - but if trying to understand how MPs talk positively about their other interests - it's really useful to have this.

What's next for this approach?

For some standing searches that are useful for *us* behind the scenes, we can create a notebook repo that updates daily. Most of the work is in creating the embeddings in the first place and extra searches are relatively quick.

We've done a pilot blog post of what analysis based on this kind of search can look like - looking at one week of disclosures.

Related research